Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a.
## [Unreleased]

### Added
- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, E17 `WHERE CONTAINS`, E18 snapshot `value_bytes` (decoded vs escaped) / tab-CR / `max_fields` vs `line_bytes`, and RSS fixtures. Probe and tests; no engine or cap-default change. No SemVer bump. Wire: [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md).
- **Honesty `c` — one session per document over serve (probe)** — Loopback `memnet-serve` readiness for a product gate that keeps one session per document (TTL 60, save-on-expire, `MEMNET_MAX_SESSIONS=1024`, ~1 800 parts). Extra probes E11–E14, E12 on a fulldoc-with-edges fixture (3000 nodes + 4500 edges at 5000 and 10000), E16 latency, E17 `WHERE CONTAINS`, E18 snapshot `value_bytes` (decoded vs escaped) / tab-CR / `max_fields` vs `line_bytes`, and RSS fixtures. Probe, tests, and [`docs/operations/one-session-per-document.md`](docs/operations/one-session-per-document.md) now match the engine fix (lossless snapshot, honoured WHERE, ACL who on save/load/close). No SemVer bump.

### Fixed
- **Honesty `c` — snapshot lossless round-trip, value cap, WHERE, ACL who (#201 follow-on)** — Snapshot emit escapes every `str.splitlines()` separator (LF/CR/VT/FF/FS/GS/RS/NEL/LS/PS) plus `|`/`\`; load splits records on LF only. Undeclared RAM properties persist by widening the snapshot SCHEMA (live SCHEMA unchanged; extras on EDG/LAW or over `max_fields` fail closed). `line_bytes` is the escaped/raw line at save verify and load. Save fails closed (`snapshot_unsaveable`) rather than write an unloadable file. Expire-save that cannot write (`snapshot_unsaveable` or any other save failure) writes no file and **keeps RAM**; the session still counts against `MEMNET_MAX_SESSIONS`, retries emit `@WRN: expire_snapshot_failed|{code}`, and access after TTL is `session_expired|overdue` until an explicit successful save or close. When save-on-expire is off, TTL still drops RAM. GQL mutate, leftover pipe, and snapshot load share one decoded `MEMNET_MAX_VALUE_BYTES` cap (`limit_exceeded|value_bytes n/max`). MATCH WHERE SET/DELETE honours the predicate or refuses `unsupported_predicate` with nothing applied. Session save/load/close who-check when ACL is enabled (`--caller` / `MEMNET_CALLER`; MCP passes `caller`). 0.19.18 snapshots still load. MN-REQ-01.9 / 01.10 / 03.4 / 05.3. No version bump.

### Changed
- **Invent only — ClusterRoute vs SliceHandCarry (#191 / #47 cousin)** — `MemNetTwoMoves` outside `MemNetSystem` (`MN-REQ-06.9` + `MN-REQ-06.10` / `MN-VER-06-S08`). ClusterRoute = where the session lives (`MemNetLanMcpFront`; one owner; `pin_map` / `find` SHALL NOT span backends). SliceHandCarry = explicit copy into another session (`export_pin_map` or `session_save` → LAN file copy → dest import/`session_load`; `import_slice` same-serve only). Not a live hop. `import_slice(from_url)` not shipped. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/cluster-route-vs-slice-hand-carry.md`](docs/operations/cluster-route-vs-slice-hand-carry.md).
Expand Down
37 changes: 28 additions & 9 deletions docs/cap-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,17 @@ Raised at map load (`memnet/tag_map.py`). Session open fails; nothing is stored.
| Limit | Default | Knob | Wire |
|-------|---------|------|------|
| Field value | 4096 | `MEMNET_MAX_VALUE_BYTES` | `@ERR: limit_exceeded\|value_bytes {n}/{max}` |
| Whole pipe line | 32768 | `MEMNET_MAX_LINE_BYTES` | `@ERR: limit_exceeded\|line_bytes {n}/{max}` |
| Whole pipe / snapshot line | 32768 | `MEMNET_MAX_LINE_BYTES` | `@ERR: limit_exceeded\|line_bytes {n}/{max}` |

Enforced on leftover `@TAG` `parse_line` only. **GQL `mutate` does not check these** (bug list).
**Value cap** is one hard cap on leftover `@TAG` `parse_line`, GQL `mutate` (CREATE / SET field values), and snapshot load. It is measured as the **UTF-8 byte length of the decoded (raw) property value**, not the escaped wire form. A product MAY raise `MEMNET_MAX_VALUE_BYTES` to `16384`. Over-cap refuses `limit_exceeded|value_bytes {n}/{max}` and does not store the row.

**Line cap** is the **UTF-8 byte length of the escaped/raw leftover-pipe or snapshot line** (backslash and pipe count twice on the wire). Save verify and load share this check. A single field under the value cap still fits default `32768`. Many fields whose escaped form exceeds `line_bytes` refuse at save (`snapshot_unsaveable` wrapping `line_bytes`) rather than write a file load will refuse. GQL statements are not pipe lines.

Snapshot emit escapes every Python `str.splitlines()` separator (LF, CR, VT, FF, FS/GS/RS, NEL, LS, PS) plus `|` and `\`. Load splits records on LF only (not `str.splitlines()`).

**Undeclared properties.** GQL mutate may store keys that are absent from the live tag SCHEMA (GraphElement extras; Path-B locators such as `qname`). Those keys stay in RAM. Snapshot save persists them by widening the **snapshot** SCHEMA (live session SCHEMA is unchanged). After load, the restored map includes the extra columns. Extras on fixed tags `EDG` / `LAW`, or a widened SCHEMA over `max_fields`, refuse `snapshot_unsaveable` and write no file.

Snapshot save verifies every emitted row can parse back to the same values. If any row cannot, save refuses `@ERR: snapshot_unsaveable|{tag} nick={nick} …` and writes no file. Expire-save in that case (or any other save failure, such as an unwritable disk or directory) emits `@WRN: expire_snapshot_failed|{code}` on every sweep or access that retries expiry, writes no file, and **keeps the session in RAM**. It still counts against `MEMNET_MAX_SESSIONS`. Access after TTL is `@ERR: session_expired|overdue`. An explicit `session save` that succeeds, or an explicit `session close`, ends that hold. When save-on-expire is off, TTL still drops RAM. Snapshots written by 0.19.18 (pipe and backslash escapes only) still load.

## Mutate batch line cap

Expand Down Expand Up @@ -287,9 +295,11 @@ Source: `memnet/catalog_snap.py` `snap_model` / `_precheck_plan`; `memnet/serve.
| Expire, save off (default) | Session dropped from memory. First access of the still-registered expired id: `@ERR: session_expired\|snap_missing` (exit 2). After purge already ran: `@ERR: session_not_found\|unknown session` |
| Expire, `MEMNET_SAVE_ON_EXPIRE` truthy + `MEMNET_EXPIRE_SNAPSHOT_DIR` set | Snapshot `{dir}/{sid}.snap` (filename only; do not log it). Next use: `@ERR: session_expired\|snap_available`. Restore: `session_load` with that id |
| Save-on-expire on, dir unset | `@WRN: save_on_expire_no_dir\|dir unset`, then drop; `snap_missing` |
| Save-on-expire on, row not round-trippable or write fails | `@WRN: expire_snapshot_failed\|{code}`, **no file**, RAM **stays**; access `@ERR: session_expired\|overdue`. Still counts against `MEMNET_MAX_SESSIONS`. Cleared by a successful explicit `session save` or `session close` |
| `session save` after TTL with save-on-expire | Allowed; `@WRN: session_expired_saved`. Id then gone |
| `session save` after TTL with save off | `@ERR: session_expired` / `snap_missing`; no file |
| Status (no paths, no ids) | `@STAT: save_on_expire\|0\|` / `1`; `@STAT: expire_snapshot_dir_set\|0\|` / `1` |
| Unsaveable explicit save | `@ERR: snapshot_unsaveable\|{tag} nick={nick} …`; no file; overdue hold stays if expire-save had already failed |
| Status (no paths, no ids) | `@STAT: save_on_expire\|0\|` / `1`; `@STAT: expire_snapshot_dir_set\|0\|` / `1`; `@STAT: expire_snapshot_failed\|n\|` |

Source: `memnet/session.py`, `memnet/config.py` `save_on_expire` / `expire_snapshot_dir`.

Expand All @@ -308,6 +318,8 @@ Off until `session acl-enable`, a grant/bind (which enables), or `MEMNET_ACL=1`
| `acl_scope` | WorkerWriteScope miss | `@ERR: acl_scope\|id/label outside WorkerWriteScope (GRANT)` or `edge outside …` |
| `acl_bad_caller` / `acl_bad_bind` / `acl_bad_scope` | Malformed grant/bind/scope | matching `@ERR:` |

When session ACL is enabled, **`session save` / `session load` (into that ACL'd session) / `session close`** accept `--caller` / `MEMNET_CALLER` and enforce `acl_who` / `acl_denied` / `acl_forbidden` (save and load use `pin_map`; close uses `mutate`). MCP `session_save` / `session_load` / `session_close` pass `caller` through. Without ACL, behaviour is unchanged.

Bind is **skipped** when `require_bind=False` and the trusted path is on (`MEMNET_SERVE_INTERNAL` or `MEMNET_TEST_INLINE` or `MEMNET_ACL_SKIP_BIND`). **`memnet serve` sets `MEMNET_SERVE_INTERNAL=1`**, so CLI/MCP through serve does **not** enforce bind today (who and scope still do). Library `MutateGate.apply(..., require_bind=True)` still refuses. Listed as a bug; not fixed in this run.

In-scope writes from earlier batches stay; a later out-of-scope batch is refused (not a partial of that batch).
Expand Down Expand Up @@ -383,17 +395,24 @@ Default `MEMNET_SAVE_ON_EXPIRE` is off. At TTL:
3. Caller sees `@ERR: session_expired|snap_missing` (or `session_not_found` if the id was never known)
4. No snapshot file unless save-on-expire **and** a dir were armed **before** expiry

## MATCH WHERE (honour or refuse)

GQL `MATCH … WHERE … SET` / `DELETE` SHALL honour the WHERE predicate. Honoured forms: `true` / `false`, property equality / `<>` / `!=`, `CONTAINS`, `STARTS WITH`, `ENDS WITH`, `=~`, `'k' IN n.list`, and `AND` / `OR` / `NOT` of those. Property-map equality in MATCH still filters. `MATCH (n WHERE true)-[r {id}]->() DELETE r` remains the gated edge-delete spelling.

If lowering cannot honour the predicate, the whole statement refuses and applies nothing:

`@ERR: unsupported_predicate|WHERE {name} is not honoured`

## Bugs found this run (do not fix here)

1. **leftover `query walk`** (`WalkQuery` / `context_walk_hops`): clips hops at `max_rows` and fan-out **without** Truncation/`@ERR`.
2. **leftover `query context`**: `context_pack` without `clip_notes` slices `max_rows` silently.
3. **leftover `query neighbors` / `query path`**: depth `min` without Truncation; path may return empty.
4. **GQL `mutate`** does not enforce `MEMNET_MAX_VALUE_BYTES` / `MEMNET_MAX_LINE_BYTES` (pipe leftover does).
5. **`MEMNET_LOCK_TIMEOUT_MS`** is stored on `Caps` and never applied.
6. **`@WRN` budget**: lines after 12 vanish with no mark.
7. **Serve bind skip:** `memnet serve` sets `MEMNET_SERVE_INTERNAL=1`, so session **bind** is not enforced on the TCP/IPC product path (who/scope are). Library `require_bind=True` still refuses.
8. **Serve response frame** over cap raises `ConnectionError` rather than `@ERR: frame_too_large`.
9. **`max_fanout`** clamps only outgoing `_edges_from`, not inbound `_edges_to`, so a high in-degree hub is not Truncation-marked for fan-out.
4. **`MEMNET_LOCK_TIMEOUT_MS`** is stored on `Caps` and never applied.
5. **`@WRN` budget**: lines after 12 vanish with no mark.
6. **Serve bind skip:** `memnet serve` sets `MEMNET_SERVE_INTERNAL=1`, so session **bind** is not enforced on the TCP/IPC product path (who/scope are). Library `require_bind=True` still refuses.
7. **Serve response frame** over cap raises `ConnectionError` rather than `@ERR: frame_too_large`.
8. **`max_fanout`** clamps only outgoing `_edges_from`, not inbound `_edges_to`, so a high in-degree hub is not Truncation-marked for fan-out.

Product `pin_map` Truncation for `max_rows` / `depth` / `fanout` / `shell` **is** signalled. Mutate/ingest row-cap batches **do** roll back.

Expand Down
7 changes: 4 additions & 3 deletions docs/operations/admin-usage-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ A refuse MUST NOT look like a complete empty report (`ok` JSON). If a measured f
```json
{
"ok": true,
"sessions": {"live": 2, "max": 1024},
"sessions": {"live": 2, "max": 1024, "expire_snapshot_failed": 0},
"session_rows": [
{
"alias": "s_ab12cd34ef56a1b2",
Expand All @@ -68,7 +68,8 @@ A refuse MUST NOT look like a complete empty report (`ok` JSON). If a measured f
"relations_max": 200,
"last_access": "2026-10-08T02:00:00Z",
"ttl_left_s": 3510,
"save_on_expire_armed": false
"save_on_expire_armed": false,
"expire_snapshot_failed": false
}
],
"process": {
Expand Down Expand Up @@ -129,7 +130,7 @@ A refuse MUST NOT look like a complete empty report (`ok` JSON). If a measured f
}
```

`alias` is HMAC-SHA256 of the real sid keyed by `MEMNET_ADMIN_TOKEN`, hex-truncated, prefixed `s_`. Stable for that serve credential; rotating the token rotates aliases. `save_on_expire_armed` repeats the **process** Caps flag (not a per-session arm today).
`alias` is HMAC-SHA256 of the real sid keyed by `MEMNET_ADMIN_TOKEN`, hex-truncated, prefixed `s_`. Stable for that serve credential; rotating the token rotates aliases. `save_on_expire_armed` repeats the **process** Caps flag (not a per-session arm today). `sessions.expire_snapshot_failed` is how many sessions are held in RAM because expire-save could not write; the per-row boolean matches.

Peek only: the report does not `session_open` / close / load / save / mutate / purge or slide TTL.

Expand Down
4 changes: 2 additions & 2 deletions docs/operations/honesty-c-wire-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ Regression: `tests/test_catalog_snap.py` (`test_cross_cut_satisfy_is_catalog_loc
| CLI `query pin-map` / leftover `query warm` / MCP `pin_map` | Same composer text. |
| `query find` / MCP `find` | Still seed-only; codebook miss stays empty skip (no Peak_L). CueConflict only when `total>1`. |
| `export_pin_map` `conflict=` | Still true only when body contains `## CueConflict`. Peak_L miss is not `conflict=1`. |
| `write_snapshot` / `session_save` | `@WRN: snapshot_schema_drop` when RAM locator keys (`qname`, `path`, `requirementId`, `skill_id`) are absent from SCHEMA columns. SCHEMA unchanged. Path-B ingest not refused. |
| `write_snapshot` / `session_save` | **0.19.10:** `@WRN: snapshot_schema_drop` when RAM locator keys (`qname`, `path`, `requirementId`, `skill_id`) are absent from SCHEMA columns; extras vanished. **Later honesty `c` (MN-REQ-01.9):** extras persist by widening the snapshot SCHEMA; live SCHEMA unchanged; `snapshot_schema_drop` remains only for fixed-tag extras that cannot persist. |

Regression: `tests/test_peak_l.py` (`test_two_peaks_cue_miss_not_cue_conflict`); `tests/test_bounded_match_find.py` / `tests/test_honesty_c_wire.py` CueConflict; `tests/test_snapshot.py` (`test_session_save_warns_when_qname_not_in_schema`).
Regression: `tests/test_peak_l.py` (`test_two_peaks_cue_miss_not_cue_conflict`); `tests/test_bounded_match_find.py` / `tests/test_honesty_c_wire.py` CueConflict; `tests/test_snapshot.py` (`test_session_save_qname_not_in_schema_persists`).

**H2 hypothesis:** warn on `write_snapshot` is enough. Foam bind used a narrow SCHEMA without `PRT.qname`; save dropped RAM `qname`; keep-id reload then missed `qname=` and Peak_L looked like CueConflict. Fix the lie on emit + warn on save. Do not silently widen SCHEMA. Nest SysML still says “two peaks → CueConflict” until a later nest pass — engine wire is Peak_L.

Expand Down
Loading
Loading