Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ This project uses Semantic Versioning as **interpreted for MemNet**: package `a.
- **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).
- **Invent only — LAN MCP front over several serves (#191)** — `MemNetLanMcpFront` outside `MemNetSystem` (`MN-REQ-06.9` / `MN-VER-06-S07`). One MCP catalogue, N LAN `memnet serve` backends; `SessionOwnerRegistry` is owner (explicit pin allowed; silent hash is not sole routing). One owner per session; `pin_map` / `find` SHALL NOT span backends. Cousin of #47 (peer sid handoff), not the same invent. tip≠face. `inventOnly=true`; `implemented=false`; no engine code; no SemVer bump. Wire: [`docs/operations/memnet-lan-mcp-front.md`](docs/operations/memnet-lan-mcp-front.md).

### Proposed (not shipped)
- **Session cap LRU evict (MN-REQ-05.5)** — at `MEMNET_MAX_SESSIONS`, the least-recently-touched saveable session is saved to `MEMNET_EXPIRE_SNAPSHOT_DIR` and then dropped; the dir enables eviction writes even with save-on-expire off. No dir refuses `limit_exceeded`. Restore keeps ACL, drops leases. A commit naming a dropped lease is refused `lease_lost` (new mutate arg `rid=`; nothing written; not a touch). A `rid=` naming another llm_id's live lease is refused `reserved` (existing reserve code); a commit with no `rid=` is unchanged. Deploy `evictShipped=false` and `commitRefusesLostLease=false`; the runtime tests in `tests/test_session_evict.py` are xfail until the engine ships. Verify: MN-VER-05-S03..S09.

## [0.19.25] - 2026-10-14

### Changed
Expand Down
2 changes: 2 additions & 0 deletions docs/operations/one-session-per-document.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Settings this gate uses:
| Concurrent sessions | 1024 (`MEMNET_MAX_SESSIONS`) |
| Document size | about 1 800 part nodes plus a few opaque `USR` text nodes |

**Proposed, not shipped (MN-REQ-05.5):** once the session cap eviction ships, setting `MEMNET_EXPIRE_SNAPSHOT_DIR` also enables eviction snapshot writes, even with `MEMNET_SAVE_ON_EXPIRE` off. When the cap is full, the least-recently-touched saveable session is written to `{dir}/{sid}.snap` and then dropped; the dir is the one knob (`MEMNET_SNAPSHOT_DIR` may be set as an alias, and conflicting paths refuse start). With no dir set, a full cap refuses `limit_exceeded` and drops nothing. The table above is the shipped behaviour until then.

Ingest caps (`max_nodes=2000` / `max_edges=2000`) are Path-B ingest, not this mutate path. Session row cap remains 5000 non-LAW rows (**nodes plus edges**). `session load` of a snapshot is **not** bound by ingest budget; it walks leftover `parse_line` + `MemStore.upsert` (`MEMNET_MAX_ROWS`, max sessions, leftover value/line/`FIELD_COUNT` on a malformed pipe line).

## Request envelope (direct serve)
Expand Down
4 changes: 2 additions & 2 deletions sysml-models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ Design authority: rebuilt requirements + ADR-001 (GQL agent wire) + `docs/gramma
| File | Package | Role |
|------|---------|------|
| `models/connections.sysml` | `MemNetConnections` | SharedLlmMemory, SessionHandoff (+ CallerId / SessionBind / SessionCapability), WorkingMemorySlice, SessionImportRequest, optional ImportGuardDecision; ServeUsageLook / ImportGuardArmedLook / HumanUsagePage (ops look); application `CompanyAnalyticalSsot` / `HostSearchBridge` / `DeviceMemNetFleet` / `TipMemNetAccess` / `MemNetLanMcpCluster` / `MemNetTwoMovesContrast` |
| `models/requirements.sysml` | `MemNetRequirements` | MN-REQ-00…13 (01.7/01.8, 06.4, **06.5** human usage look, **06.6** device services / one droplet MemNet MCP tip/ops, product face sysmledge, **06.7** SSOT → code allocate, **06.8** tip MemNet access portal, **06.9** ClusterRoute LAN MCP front invent #191, **06.10** SliceHandCarry invent #47 cousin, **06.12** product gateway on memnet-mcp, 12.9–12.13, 13.1 Recall/Commit; 02.9 cousin store-key; 04.8 cue \|Q\|>1; 04.9 empty-q outline) |
| `models/requirements.sysml` | `MemNetRequirements` | MN-REQ-00…13 (01.7/01.8, 06.4, **06.5** human usage look, **06.6** device services / one droplet MemNet MCP tip/ops, product face sysmledge, **06.7** SSOT → code allocate, **06.8** tip MemNet access portal, **06.9** ClusterRoute LAN MCP front invent #191, **06.10** SliceHandCarry invent #47 cousin, **05.5** session cap LRU evict (proposed, not shipped; restore: same ACL, no lease carried over), **06.12** product gateway on memnet-mcp, 12.9–12.13, 13.1 Recall/Commit; 02.9 cousin store-key; 04.8 cue \|Q\|>1; 04.9 empty-q outline) |
| `models/cousins.sysml` | `MemNetCousinContrast` | TARGET vs eight cousin pointing/identity designs (not a product switch; SysMLEdge is a distinct pin_map; overlay family `SysMLEdgePrj-*`; this engine repo and `modelbasedPrj-*` are repo-based — MUST NOT use SysMLEdge as model SSOT; `SysMLEdgePrj-*` bound desk is working model SSOT) |
| `models/deploy.sysml` | `MemNet` | Nested parts; `RecallCommit` two-operator cut; Multitask spine; `MemNetUsageDashboard` / `MemNetOpsFleet` / `TipMemNetAccessPortal` / `MemNetLanMcpFront` / `MemNetProductGateway` / `MemNetTwoMoves` outside `MemNetSystem` |
| `models/implementation.sysml` | `MemNetImplementation` | `SoftwareAllocate` logical → live modules; one Hatch wheel many hosts; sysmledge not in wheel |
| `models/behaviour.sysml` | `MemNetBehaviour` | HandoffById, SessionImportReceive, Multitask async, landed-client hydrate/flush |
| `models/verify.sysml` | `MemNetVerification` | MN-VER-12-G00 + S01…S14; MN-VER-04-S01…S05; MN-VER-09-S01; MN-VER-13-S01; MN-VER-06-S01…S10 (S06 storage roles; S07 ClusterRoute LAN MCP front; S08 two named moves; S09 admin usage; S10 product gateway); MN-VER-01-S03 |
| `models/verify.sysml` | `MemNetVerification` | MN-VER-12-G00 + S01…S14; MN-VER-04-S01…S05; MN-VER-09-S01; MN-VER-13-S01; MN-VER-05-S03…S09 (MN-REQ-05.5 session cap LRU evict, proposed; runtime checks in `tests/test_session_evict.py`, xfail until shipped); MN-VER-06-S01…S10 (S06 storage roles; S07 ClusterRoute LAN MCP front; S08 two named moves; S09 admin usage; S10 product gateway); MN-VER-01-S03 |
| `models/root.sysml` | `ProjectMemNet` | Root imports (load last). MUST NOT import `MemNetArchive` |
| `models/archive.sysml` | `MemNetArchive` | ARCHIVE shelf (leftover_* / TierACodec / LegacyPipe* / retired Neo4j). **Off** `config.yaml` load |

Expand Down
48 changes: 47 additions & 1 deletion sysml-models/models/behaviour.sysml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ package MemNetBehaviour {
File stays until the user deletes it. Not Neo4j.
*/
}
attribute def EvEvictForCap {
doc /*
MN-REQ-05.5 (proposed, not shipped): session cap full on open or
load, after purge_expired; this live session is the oldest-touched
evictable victim. The victim lock is taken at pick and held through
save, drop and registry removal. Save first, then drop from RAM.
*/
}
attribute def EvEvictSaved {
doc /*
MN-REQ-05.5 (proposed, not shipped): the victim's snapshot is complete
(temp file, fsync, rename, fsync dir). Only this event drops the victim
from RAM. Runtime check: tests/test_session_evict.py.
*/
}
attribute def EvCloseOrExpire {
doc /* session_close or TTL purge after optional expire save. */
}
Expand Down Expand Up @@ -328,7 +343,11 @@ package MemNetBehaviour {
then closed;

state closed {
doc /* No session id; open or load required. */
doc /*
No session id; open or load required. An evicted session (MN-REQ-05.5)
returns here; closed_to_loading via EvLoadSnapshot restores it from
its snapshot: same ACL, no lease carried over.
*/
}
transition closed_to_opening
first closed
Expand Down Expand Up @@ -433,6 +452,33 @@ package MemNetBehaviour {
first expireSaving
accept EvCloseOrExpire
then closed;

state evictSaving {
doc /*
MN-REQ-05.5 (proposed): cap eviction of the oldest-touched saveable
session. Write its snapshot first (fsync, atomic 0600, @SNAP v2);
save ok drops RAM to closed, save failure keeps the session active.
Never entered for the session being opened or loaded, or for a
session holding an active write lock or an open bind. The victim is
marked evicting under the cap lock; its victim lock is held from pick
through save, drop and registry removal. Save runs outside the cap lock;
the victim keeps its slot until dropped, and the open that picked it
waits for this save.
Atomic write: temp file in the same dir, fsync file, rename, fsync dir.
*/
}
transition active_to_evict_saving
first active
accept EvEvictForCap
then evictSaving;
transition evict_saving_to_closed
first evictSaving
accept EvEvictSaved
then closed;
transition evict_saving_to_active
first evictSaving
accept EvError
then active;
}

// --- 2. Goldfish loop (engine side of turn) ---
Expand Down
53 changes: 53 additions & 0 deletions sysml-models/models/deploy.sysml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,52 @@ package MemNet {
attribute maxLaw : Integer = 100;
attribute maxRelations : Integer = 200;
attribute maxSessions : Integer = 1024;
// MN-REQ-05.5 session cap LRU evict: proposed, not shipped (engine pending).
// Each flag is a spec record; its runtime check is the pytest on the same
// line in tests/test_session_evict.py (xfail until the engine ships).
attribute evictShipped : Boolean = false;
attribute evictOnSessionCap : Boolean = true; // test_evicts_oldest_touched_first
attribute envMaxSessions : String = "MEMNET_MAX_SESSIONS";
attribute envSnapshotDir : String = "MEMNET_EXPIRE_SNAPSHOT_DIR"; // reused; no second eviction knob
attribute envSnapshotDirAlias : String = "MEMNET_SNAPSHOT_DIR"; // optional neutral alias; same path or refuse start
attribute evictSnapshotDirAliasConflictRefuses : Boolean = true; // test_snapshot_dir_alias_conflict_refuses
attribute evictSnapshotDirDefault : String = "unset"; // test_no_snapshot_dir_refuses_without_eviction
attribute evictCapFullWithDirEvicts : Boolean = true; // test_cap_full_with_snapshot_dir_evicts_oldest
attribute evictPicksOldestTouch : Boolean = true; // test_evicts_oldest_touched_first
attribute evictTouchOnReadOrWrite : Boolean = true; // test_reads_and_writes_are_touches
attribute evictNonTouchReadsNotTouch : Boolean = true; // test_non_touch_reads_do_not_touch
attribute evictMaintenanceSavesNotTouch : Boolean = true; // test_maintenance_save_is_not_a_touch
attribute evictNeverReadOpenIsVictim : Boolean = true; // test_never_read_earliest_open_is_victim
attribute evictOpenIsFirstTouch : Boolean = true; // tests/test_read_touch_regression.py::test_open_stamps_first_touch
// Tie order (MN-REQ-05.5): pick is (modified_at, touch_seq). touch_seq is a
// process-wide monotonic counter on SessionEntry, not persisted in snapshots
// (a load is a fresh touch). Name-then-id fallback is unused: the counter is held.
// Both tie tests: test_tie_touch_order_a_then_b_evicts_a, test_tie_touch_order_b_then_a_evicts_b
attribute touchSeqMonotonic : Boolean = true;
attribute touchSeqInSnapshot : Boolean = false; // test_touch_seq_not_in_snapshot_fresh_on_load
attribute evictSavesFirstFsync : Boolean = true; // test_victim_saved_before_drop_atomic_fsync
attribute evictVictimLockHeld : Boolean = true; // test_victim_lock_held_from_pick_through_drop
attribute evictOneCapLock : Boolean = true; // test_cap_two_concurrent_opens_one_victim_per_slot
attribute evictVictimKeepsSlot : Boolean = true; // test_cap_two_concurrent_opens_one_victim_per_slot
attribute evictSkipsUnsaveable : Boolean = true; // test_unsaveable_victim_skipped_next_oldest_tried
attribute evictUnsaveableNotAutoDropped : Boolean = true; // test_unsaveable_held_counted_on_serve_status
attribute evictRefusesWhenNoneEvictable : Boolean = true; // test_all_in_use_refusal_drops_nothing
attribute evictNeverSelf : Boolean = true; // test_session_being_loaded_is_not_evicted
attribute evictNeverWriteLocked : Boolean = true; // test_write_locked_session_exempt
attribute evictNeverOpenBind : Boolean = true; // test_open_bind_exempt
attribute evictCallerNoEvictionField : Boolean = true; // test_caller_result_has_no_eviction_field
attribute evictResultNoCapFields : Boolean = true; // test_open_and_load_results_carry_no_cap_fields
attribute evictReportAdminOnlyNameNotId : Boolean = true; // test_admin_report_names_victim_never_mn_id
attribute evictLoadRestores : Boolean = true; // test_restore_keeps_rows_edges_and_acl
attribute evictReopenRestoredTrue : Boolean = true; // test_reopen_restored_true_no_alerts
attribute evictLoadFreshTtl : Boolean = true; // test_load_is_touch_with_fresh_ttl
attribute evictKeepsAclNoLease : Boolean = true; // test_restore_carries_no_lease
// MN-REQ-05.5 lost lease: a commit naming a rid the caller no longer holds
// is refused lease_lost, nothing written, not a touch. Proposed, not shipped
// (new mutate wire arg rid=). Flips with evictShipped at the ship commit;
// test_ship_flags_flip_together enforces it. Runtime check is
// tests/test_session_evict.py::test_restore_lease_commit_fails_cleanly.
attribute commitRefusesLostLease : Boolean = false;
attribute maxDepth : Integer = 4;
attribute defaultQueryMaxRows : Integer = 50;
attribute defaultQueryDepth : Integer = 2;
Expand Down Expand Up @@ -185,6 +231,7 @@ package MemNet {
satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_2_QueryFanoutCaps;
satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_3_ConsistentValueByteCap;
satisfy MN_REQ_00_MissionBridge::MN_REQ_05_HardCaps::MN_REQ_05_4_ConsistentFieldCap;
// MN-REQ-05.5 satisfy is not linked: the satisfy lands with the engine (evictShipped=false).
satisfy MN_REQ_00_MissionBridge::MN_REQ_01_SessionLifecycle::MN_REQ_01_10_SessionLifecycleAclWho;
satisfy MN_REQ_00_MissionBridge::MN_REQ_10_LlmPropertiesAndLimits::MN_REQ_10_2_PinMapMustFitContext;
satisfy MN_REQ_00_MissionBridge::MN_REQ_12_MultitaskMode::MN_REQ_12_7_NoAssumeAclReserveIngest;
Expand Down Expand Up @@ -3165,11 +3212,17 @@ package MemNet {
(save_on_expire, expire_snapshot_dir_set) and the count of
sessions held because expire-save failed
(expire_snapshot_failed). Path redacted. Look only.
MN-REQ-05.5 (proposed, not shipped): sessions_evicted and
sessions_unsaveable_held for cap evictions. Admin only: the evicted
session's name or tag, never a real mn_ id. Caller results carry no
eviction field. Runtime: tests/test_session_evict.py.
*/
attribute lookOnly : Boolean = true;
attribute saveOnExpireFlag : Boolean = true;
attribute expireSnapshotDirSetFlag : Boolean = true;
attribute expireSnapshotFailedCount : Boolean = true;
attribute sessionsEvictedCounter : Boolean = true;
attribute sessionsUnsaveableHeld : Boolean = true;
attribute pathRedacted : Boolean = true;
}

Expand Down
Loading
Loading