diff --git a/99-system/INDEX.md b/99-system/INDEX.md index da60cd3..c511ae9 100644 --- a/99-system/INDEX.md +++ b/99-system/INDEX.md @@ -15,6 +15,8 @@ Full note list with one-line summaries. Update this file whenever notes are adde - `evidence-row.schema.json` — JSON Schema 2020-12 companion for EvidenceRow and EvidencePacket - [[TASK-CONTRACT-SCHEMA]] — bounded artifact-work contract, readback requirements, runtime limit, and dispatch gate - `task-contract.schema.json` — JSON Schema 2020-12 companion for task contracts +- [[MEMORY-RUNTIME]] — scoped local memory lifecycle, durable fences, bounded context packs, and rebuild contract +- `memory-record.schema.json` — JSON Schema 2020-12 companion for durable runtime memory records - [[ENFORCEMENT]] — read-only instruction, task-contract, routing-failure, and prompt-asset lint modes - [[EXTERNAL-ASSETS]] — keep large binaries outside the text vault while preserving relative-path traceability - [[MIGRATION]] — phased consolidation guide for adopting an existing Markdown or Obsidian vault @@ -46,7 +48,7 @@ Full note list with one-line summaries. Update this file whenever notes are adde ## 50-procedures -(empty) +- [AKM memory runtime playbook](../adapters/hermes/memory/PLAYBOOK.md) — operate candidate review, scoped recall, guarded forget, rebuild, doctor, and reconciliation ## 60-actions @@ -54,7 +56,7 @@ Full note list with one-line summaries. Update this file whenever notes are adde ## 70-evaluation -(empty) +- [AKM memory runtime evaluation](../adapters/hermes/memory/EVALUATION.md) — conformance, adversarial safety, shadow metrics, and global-injection HOLD gates ## 80-outputs @@ -69,6 +71,7 @@ Full note list with one-line summaries. Update this file whenever notes are adde - [custom](../adapters/custom/README.md) — write an adapter for any agent - [aside](../adapters/aside/README.md) — Aside instructions + native-memory pointer division - [hermes](../adapters/hermes/README.md) — multi-profile session instructions + shared AKM pointer division +- [hermes memory runtime](../adapters/hermes/memory/README.md) — optional local scoped-memory adapter, CLI, tests, and external runtime-root boundary ## examples diff --git a/99-system/LOG.md b/99-system/LOG.md index a71510d..2f346ef 100644 --- a/99-system/LOG.md +++ b/99-system/LOG.md @@ -21,3 +21,4 @@ Append-only change history. Format: `YYYY-MM-DD | change | note` - 2026-07-10 | Nara Finder–proposal-mcp subscription integration logged | recorded Claude/Codex CLI MCP setup, Nemotron persona targeting, event success/failure analysis, and copy-reaction validation - 2026-07-10 | Local instance notes migrated out of Dev AKM core | moved 12 classified notes plus 2 maintainer mapping notes to `/Volumes/data/Obsidian/akm`; retained only reusable core system, tests, and adapters in Dev - 2026-07-24 | Production-proven evidence and execution controls promoted to core v0.3 | added portable EvidenceRow/EvidencePacket and bounded task-contract schemas, four read-only enforcement modes, operational-document budgets, routing-failure handoff, tests, and CI coverage while excluding live-instance device, backup, private-repository, fixed-memory, and HOLD retrieval-adapter policy +- 2026-08-13 | Reusable local memory runtime framework added | [[MEMORY-RUNTIME]] diff --git a/99-system/MEMORY-RUNTIME.md b/99-system/MEMORY-RUNTIME.md new file mode 100644 index 0000000..6930462 --- /dev/null +++ b/99-system/MEMORY-RUNTIME.md @@ -0,0 +1,313 @@ +--- +type: documentation +aliases: + - "AKM Memory Runtime Contract" + - "Hermes local memory contract" +description: "Canonical AKM contract for scoped local memory candidates, lifecycle, reversible forget fences, disposable derived state, write reconciliation, bounded context packs, and shadow evaluation." +author: + - "AKM Contributors" +date created: 2026-08-13 +date modified: 2026-08-13 +tags: + - akm + - memory + - runtime + - hermes + - governance +schemaVersion: "1.0.0" +status: canonical +provenance: + - "https://github.com/garrytan/gbrain/tree/1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5" +adaptationBoundary: "AKM independently implements selected lifecycle, fence, bounded-context, and delta concepts in a Markdown-authoritative local runtime; it does not vendor gbrain code, services, databases, or runtime data." +--- + +# AKM Memory Runtime + +## 1. Purpose and authority + +This document defines the AKM-native local memory runtime implemented at `adapters/hermes/memory/`. The machine-readable record companion is `99-system/memory-record.schema.json`. + +The runtime is an adapter over AKM, not a second knowledge base: + +1. AKM Markdown and original sources remain the sole canonical authority for knowledge, context, stable pointer rules, and procedures. +2. A runtime record is a scoped operational candidate or lifecycle projection. It never outranks its cited source. +3. Runtime Markdown records and forget fences are the durable local replay surface. The JSON index is disposable. +4. Generated candidates cannot write directly into `20-knowledge/`, `30-context/`, `40-memory/`, or `50-procedures/`. +5. `[[SECURITY]]`, `[[ROUTER]]`, `[[SCHEMA]]`, `[[EVIDENCE-SCHEMA]]`, and `[[LOOP]]` continue to govern secrets, classification, note structure, evidence, and Learn Back. + +This contract adopts useful memory mechanics without installing or running gbrain and without importing its database-first authority model. + +## 2. Non-negotiable invariants + +| ID | Invariant | Runtime behavior | +|---|---|---| +| M1 | Canonical remains Markdown/original | A record carries a source path and lineage. Derived state says it is a projection, not source truth. | +| M2 | Derived state is disposable | Removing `derived/state.json` and running `rebuild` restores it from runtime Markdown records and fences. | +| M3 | Candidates do not govern | `remember` and extraction persist `candidate`; only an explicit approval can create `active`. | +| M4 | Failure is not absence | Provider, refusal, filter, malformed, and truncation outcomes remain distinct from `nothing_salient`. | +| M5 | Scope denies by default | `source_id` is mandatory. Every applicable project/profile/user dimension must exactly match. Unauthorized probes return `not_found`. | +| M6 | Visibility is not egress | Private/restricted never leave the local boundary. Public/non-sensitive still need an explicit egress bit. | +| M7 | Forget is durable and reversible | Every forget needs preview, exact token, Markdown fence, readback, and a reversible restore decision. | +| M8 | Partial writes are not success | Canonical success plus derived failure produces a failed command and unresolved receipt. | +| M9 | Retrieval is bounded | Context packs use allowlisted terms, stable identities, count/token caps, cursor suppression, and compact fields. | +| M10 | Prompt injection is not enabled globally | The bounded pack/reflex remains opt-in and global injection remains `HOLD` until the shadow rubric passes. | +| M11 | Audit is out-of-band | Attempted, selected, and delivered usage are distinguishable, but audit rows never enter prompt packs. | +| M12 | Risky ambiguity fails closed | Missing scope, malformed artifacts, stale confirmations, leaked gold, or uncertain conflicts produce `HOLD`/failure. | + +## 3. Data model + +### 3.1 Record identity + +A record ID is a stable SHA-256-derived identity over normalized text, entity, kind, scope, and source path. Repeating the same input yields the same identity instead of a duplicate record. Revision increments only when lifecycle/decision state changes. + +Required record groups are: + +- stable ID, schema version, revision; +- lifecycle state and timestamps; +- text plus bounded safe summary; +- kind and entity; +- exact scope dimensions; +- visibility and independent egress decision; +- source path and lineage; +- optional TTL and stable-authority flag; +- supersession links; +- decision/extraction evidence and lifecycle history. + +### 3.2 Lifecycle + +```text +candidate ──explicit approve──> active +candidate ──explicit reject───> rejected (terminal unless forgotten) +active ──new approved same entity/kind/scope──> superseded +active ──TTL reached──────────────────────────> expired +candidate|active|superseded|expired|rejected ──confirmed fence──> forgotten +forgotten ──explicit restore evidence──> prior recorded lifecycle state +``` + +The local derived index computes TTL expiry. Stable `preference` and `safety` authority cannot be given a TTL and therefore cannot auto-expire. A changing preference or safety rule must be explicitly superseded, rejected, or forgotten with evidence. + +### 3.3 Supersession + +Approval finds an existing active record only within the exact same scope, kind, and entity. The old record becomes `superseded`, its revision/history changes, and its `superseded_by` points to the new record. The new record records `supersedes`. Similar text in another project/profile/user never supersedes across the scope boundary. + +### 3.4 TTL + +TTL is a positive number of seconds set at remember time. Expired items are excluded from ordinary recall and context packs but stay visible to `get(..., include_inactive=True)`, delta recall, doctor, and rebuild. Expiry does not delete its source or history. + +## 4. Extraction boundary + +The exact outcome type is: + +| Outcome | Meaning | +|---|---| +| `success` | Valid candidate list exists. | +| `nothing_salient` | Processing completed normally and produced no candidate. | +| `provider_error` | An explicitly external provider boundary reported operational failure. | +| `refusal` | Provider refused. | +| `content_filter` | Provider filtered the request/output. | +| `malformed_output` | The supplied output does not match the candidate boundary. | +| `truncated_output` | The supplied provider result is incomplete. | + +`extract_local` is deterministic and recognizes only explicit `Remember:` or `Memory:` lines. `ingest_provider_boundary` validates an already-produced outcome/payload. Neither method invokes a model or network. Persisted extraction results remain shadow candidates and are never injected or promoted automatically. + +## 5. Storage and rebuild + +The operator supplies a local runtime root. No default inside the tracked repository is allowed. The constructor detects AKM roots through `99-system/SCHEMA.md` plus `99-system/ROUTER.md` and rejects any runtime root at or below that canonical vault. + +```text +records/*.md durable runtime records +fences/*.md durable reversible forget fences +receipts/*.json write/reconciliation receipts +previews/*.json short-lived destructive previews +promotion-queue/*.json classification recommendations +cursors/*.json delivered identity cursor +audit/usage.jsonl out-of-band usage stages +derived/state.json disposable state/index +``` + +Records and fences use explicit markers plus strict JSON code blocks so rebuild can parse them without a YAML dependency. An invalid marker, block, ID, scope, lifecycle, timestamp, or filename relation is excluded and reported rather than guessed. + +Runtime file access is fail-closed after construction as well as at initial root selection. Every read, append, checksum, and atomic replacement traverses the selected root and fixed child directories through directory file descriptors with `O_NOFOLLOW`, verifies that the runtime root device/inode did not change, and accepts only single-link regular files. Replacing the root or a child directory with a symlink, or inserting a hardlinked audit/file artifact, raises a validation error rather than reading or writing outside the selected root. + +`rebuild`: + +1. enumerates regular non-symlink record Markdown; +2. validates each record and filename identity; +3. enumerates and validates forget fences; +4. computes TTL and active fence state; +5. recomputes source-file checksums; +6. atomically replaces the derived JSON index; +7. reports malformed artifacts. + +Rebuild never reads or modifies canonical 20/30/40/50 notes. + +## 6. Write receipts and reconciliation + +Every state-changing operation writes a receipt with: + +- operation and affected stable IDs; +- canonical status; +- derived status; +- canonical SHA-256 checksum(s); +- record revision; +- failure detail; +- reconcile state and completion time. + +The write order is canonical runtime Markdown first, derived index second. Therefore a derived success with canonical failure is structurally impossible. A canonical success followed by a derived failure raises `MemoryWriteError`, exits non-zero through the CLI, and leaves `reconcile_status: retry_required`. + +`reconcile` first performs a full rebuild. Only after that succeeds does it mark retry-required receipts reconciled. A manual canonical failure is not auto-cleared. + +## 7. Scope, visibility, and egress + +### 7.1 Scope + +Every record needs `source_id`. `project`, `profile`, and `user` may be null only when that dimension is intentionally not applicable. For every non-null record dimension the caller must provide an exact match. + +List/query recall silently omits denied records. Direct get/forget/egress probes return the same `MemoryNotFound` response for nonexistent and unauthorized IDs. This prevents existence disclosure. + +### 7.2 Visibility versus egress + +| Visibility | Internal scoped recall | External-model egress | +|---|---:|---:| +| `public` | allowed | only with `egress_allowed: true` | +| `non_sensitive` | allowed | only with `egress_allowed: true` | +| `private` | allowed | denied | +| `restricted` | allowed | denied | + +The visibility label never implies permission to transmit. The egress bit never overrides private/restricted denial. + +## 8. Destructive guard and forget fence + +Forget has two mandatory phases. + +### Preview + +The preview resolves exact authorized records and records count, IDs, lifecycle states, revisions, current checksums, scope, reason, creation time, and 15-minute expiry. A mass preview requires explicit `all_in_scope`; omission is not interpreted as “all.” + +### Confirm + +Confirmation must present the exact token under the same scope. The runtime rejects missing, expired, reused, cross-scope, and checksum-stale tokens. It writes one Markdown fence per record, reads each back, then rebuilds. Ordinary recall after rebuild omits forgotten records. + +The token value itself is stored only in the preview; the durable fence carries its SHA-256 hash. Restore explicitly marks the fence inactive with decision evidence, preserving history instead of deleting the fence. + +## 9. Recall surfaces + +### 9.1 Ordinary recall + +Ordinary recall returns active, authorized, egress-eligible records within a hard maximum. Its state filter is fixed to `active`; callers cannot request candidate, rejected, superseded, expired, or forgotten rows through this surface. Direct inactive `get(..., include_inactive=True)` is an internal operator path and denies all external-model requests unless the record is active. Other inactive lifecycle inspection uses explicit operator verbs such as `status`, `doctor`, or `delta` and is never used for a normal prompt pack. + +### 9.2 Bounded reflex/context pack + +The caller supplies entity/project terms and a separate allowlist. Every requested term must be allowlisted; otherwise the result is `HOLD` with no items. The pack applies: + +- exact scoped active records only; +- optional `since` timestamp; +- maximum count 1–20; +- token estimate budget 1–4000; +- stable injection identity `record-id@revision`; +- per-session delivered cursor suppression. + +Each item contains only: + +```text +identity +pointer +safe_summary +freshness +conflict +direct_read +``` + +No full evidence, full audit, receipt, source body, or usage record is included. `direct_read: true` tells the caller that the compact summary is insufficient. A same-entity active conflict becomes `HOLD` in the item and cannot be silently averaged. + +### 9.3 Delta recall + +Delta recall returns `created`, `modified`, `superseded`, `expired`, and `forgotten` events after an optional cursor, plus a new opaque `d1.` cursor. The cursor records both its UTC boundary time and stable identities for events already observed exactly at that boundary; a later lifecycle event with the same timestamp is therefore delivered once rather than skipped or repeated. Legacy ISO timestamp cursors remain accepted with strict-after semantics, and future-dated cursors fail closed. Delta draws lifecycle events from record Markdown and forgotten events from fences, so rebuild does not erase change history. + +## 10. Promotion queue + +`promote` is intentionally a queue verb, not a canonical write verb, and accepts only a current `candidate` (never `rejected`). It records: + +- candidate ID/revision/checksum; +- source and scope; +- candidate/decision evidence; +- recommended ROUTER destination; +- `canonical_write_performed: false`. + +Recommendations are kind-sensitive (`30-context/` for preferences, `50-procedures/` for procedures, and a ROUTER decision for facts). A human/operator must then follow `[[ROUTER]]`, inbox-first rules, frontmatter schema, evidence checks, INDEX update, and LOG rules in a separate canonical-authorized workflow. + +## 11. Doctor + +`doctor` is a fail-closed consistency audit. It reports: + +| Check | Failure meaning | +|---|---| +| `orphan` / `db_only` | Derived ID lacks a durable record Markdown file. | +| `canonical_only` | Durable record is absent from derived state and needs rebuild. | +| `canonical_drift` | Derived record content differs from the lifecycle state recomputed from Markdown/fences. | +| `derived_state_error` | Derived state is missing while records exist, or its JSON is malformed. | +| `expired_but_recalled` | Usage audit says a record was delivered at or after expiry. | +| `missing_scope` | Canonical or derived row lacks mandatory `source_id`. | +| `unresolved_receipts` | A write needs retry/manual reconciliation. | +| `malformed_records` | Record marker/JSON/schema/identity validation failed. | +| `malformed_fences` | Fence marker/JSON/scope/identity validation failed. | +| `checksum_drift` | Derived checksum differs from the current durable Markdown bytes. | + +The CLI returns non-zero when doctor is not clean. The operator may run `reconcile` for retry-required derived failures. Malformed or manual canonical failures require readback and an explicit repair; they are never auto-normalized. + +## 12. Usage audit and duplicate suppression + +Each bounded retrieval records separate events for: + +1. `attempted`: authorized records considered after allowlisted term matching; +2. `selected`: records that fit count/token/conflict policy; +3. `delivered`: items actually handed to the opted-in consumer. + +Only delivery updates a session cursor. An attempted or selected item that was not delivered remains eligible later. Stable identity includes revision, so a legitimately revised record may be delivered after the old revision while exact duplicates are suppressed. + +Usage audit is diagnostic input to metrics and doctor only. It never enters the context pack. + +## 13. Evaluation boundary and metrics + +Runtime fixtures must not contain `gold`, `answer`, `answers`, `expected`, or equivalent sealed-answer keys at any depth. Runtime result artifacts contain query IDs and selected IDs only. `HoldoutEvaluator` opens sealed gold only after the result file already exists and reports aggregate exact match; it never passes gold to runtime retrieval. + +The fixed metric surface is: + +- false-memory rate; +- duplicate-injection rate; +- conflict-exposure rate; +- usefulness rate; +- token cost; +- latency; +- manual-correction rate; +- source/lineage recovery rate. + +No-observation rates are `null`, never invented as zero. Thresholds and promotion gates are defined in the [memory runtime evaluation rubric](../adapters/hermes/memory/EVALUATION.md). + +## 14. Operator states + +| Surface | Current state | +|---|---| +| Local remember/recall/status | implemented | +| Explicit approve/reject | implemented | +| Guarded forget/restore | implemented | +| Rebuild/reconcile/doctor | implemented | +| Local explicit-marker extraction | implemented, shadow only | +| Provider outcome boundary | implemented, no provider invocation | +| Bounded context pack/reflex | implemented, opt-in | +| Global prompt injection | `HOLD` | +| Automatic canonical promotion | prohibited | +| External/cloud service | absent | + +## 15. Recovery and rollback + +The runtime root is disposable operational state except for local Markdown records/fences needed to preserve lifecycle decisions. Back up those Markdown artifacts before a runtime-root migration. To roll back an adapter version, revert the version-controlled adapter and documentation changes; never remove or rewrite cited AKM originals. + +For an individual memory error: + +1. stop context delivery; +2. run `doctor` and retain its JSON output; +3. run `reconcile` only for a derived-write receipt failure; +4. use confirmed forget for an invalid memory, or explicit restore for an erroneous fence; +5. rebuild and recall under the exact scope; +6. record a Learn Back evaluation under `70-evaluation/` if the failure exposed a recurring process defect. diff --git a/99-system/memory-record.schema.json b/99-system/memory-record.schema.json new file mode 100644 index 0000000..20eedd9 --- /dev/null +++ b/99-system/memory-record.schema.json @@ -0,0 +1,284 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/DECK6/akm/blob/main/99-system/memory-record.schema.json", + "$comment": "Independent AKM schema adapting selected memory lifecycle concepts from gbrain commit 1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5; no gbrain code, service, database, or runtime data is included.", + "title": "AKM local memory record", + "description": "A scoped runtime record whose durable Markdown form remains subordinate to its cited AKM/original source and from which disposable derived state can be rebuilt.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "state", + "revision", + "text", + "safe_summary", + "kind", + "entity", + "scope", + "visibility", + "egress_allowed", + "stable_authority", + "source", + "created_at", + "modified_at", + "expires_at", + "supersedes", + "superseded_by", + "decision_evidence", + "history" + ], + "properties": { + "schema_version": { + "const": "1.0.0" + }, + "id": { + "type": "string", + "pattern": "^mem_[a-z0-9_-]{3,80}$" + }, + "state": { + "enum": [ + "candidate", + "active", + "superseded", + "expired", + "forgotten", + "rejected" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "text": { + "type": "string", + "minLength": 1 + }, + "safe_summary": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "kind": { + "enum": [ + "fact", + "event", + "preference", + "safety", + "commitment", + "belief", + "procedure" + ] + }, + "entity": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "scope": { + "$ref": "#/$defs/scope" + }, + "visibility": { + "enum": [ + "public", + "non_sensitive", + "private", + "restricted" + ] + }, + "egress_allowed": { + "type": "boolean" + }, + "stable_authority": { + "type": "boolean" + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "lineage" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "lineage": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "modified_at": { + "$ref": "#/$defs/timestamp" + }, + "expires_at": { + "anyOf": [ + { + "$ref": "#/$defs/timestamp" + }, + { + "type": "null" + } + ] + }, + "forgotten_at": { + "anyOf": [ + { + "$ref": "#/$defs/timestamp" + }, + { + "type": "null" + } + ] + }, + "supersedes": { + "anyOf": [ + { + "type": "string", + "pattern": "^mem_[a-z0-9_-]{3,80}$" + }, + { + "type": "null" + } + ] + }, + "superseded_by": { + "anyOf": [ + { + "type": "string", + "pattern": "^mem_[a-z0-9_-]{3,80}$" + }, + { + "type": "null" + } + ] + }, + "decision_evidence": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "history": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/history_event" + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "stable_authority": { + "const": true + } + } + }, + "then": { + "properties": { + "kind": { + "enum": [ + "preference", + "safety" + ] + }, + "expires_at": { + "type": "null" + } + } + } + } + ], + "$defs": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_id", + "project", + "profile", + "user" + ], + "properties": { + "source_id": { + "type": "string", + "minLength": 1 + }, + "project": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "profile": { + "type": [ + "string", + "null" + ], + "minLength": 1 + }, + "user": { + "type": [ + "string", + "null" + ], + "minLength": 1 + } + } + }, + "history_event": { + "type": "object", + "additionalProperties": false, + "required": [ + "change", + "at", + "evidence" + ], + "properties": { + "change": { + "enum": [ + "created", + "modified", + "superseded", + "expired", + "forgotten" + ] + }, + "at": { + "$ref": "#/$defs/timestamp" + }, + "evidence": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string" + }, + "related_record_id": { + "type": "string", + "pattern": "^mem_[a-z0-9_-]{3,80}$" + } + } + } + } +} diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index a1d363f..46381f0 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -42,6 +42,10 @@ Hermes runs several profiles against one machine. AKM is a **single shared insta - Semantic search across AKM markdown: reuse an existing local search engine (e.g. `qmd`) and add the AKM root as a collection. - Explicit link/tag traversal: any doc-graph tool that parses `[[wikilinks]]` + frontmatter works on AKM as-is. +## Optional local memory runtime + +For explicit candidate review, exact scope isolation, reversible forget fences, bounded context packs, and rebuildable derived state, see [`adapters/hermes/memory/`](memory/README.md). The adapter requires an operator-supplied runtime root outside the AKM repository and keeps global prompt injection on `HOLD` by default. + ## Safety AKM `99-system/SECURITY.md` runs **before** ROUTER. It matches Hermes' own rule: never store secret values, store their names/locations instead. Layer folders are gitignored and never leave the machine. diff --git a/adapters/hermes/memory/EVALUATION.md b/adapters/hermes/memory/EVALUATION.md new file mode 100644 index 0000000..e81cd4d --- /dev/null +++ b/adapters/hermes/memory/EVALUATION.md @@ -0,0 +1,268 @@ +--- +type: documentation +aliases: + - "AKM Memory Runtime Evaluation" + - "AKM memory shadow promotion rubric" +description: "Preregistered conformance, safety, holdout, quality, cost, and promotion gates for the AKM-native local memory runtime and its opt-in bounded context pack." +author: + - "AKM Contributors" +date created: 2026-08-13 +date modified: 2026-08-13 +tags: + - akm + - memory + - evaluation + - rubric + - holdout + - safety +akmLayer: evaluation +akmRole: verification-rule +akmType: rubric +trustLevel: reviewed +CMDS: Develop +sourceType: synthesis +nextAction: verify +usedBy: + - "[[adapters/hermes/memory/PLAYBOOK]]" +provenance: + - "https://github.com/garrytan/gbrain/tree/1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5" +adaptationBoundary: "Evaluates AKM's independent Markdown-authoritative runtime; gbrain code, databases, services, and runtime data are outside scope." +--- + +# AKM Memory Runtime Evaluation + +## 1. Purpose and current verdict + +This rubric separates implementation conformance from permission to inject runtime memory globally. The local runtime may pass its deterministic conformance harness while global prompt injection remains `HOLD`. + +Current preregistered state: + +| Surface | Gate | +|---|---| +| Local candidate lifecycle/CLI | eligible after all mandatory conformance tests pass | +| Opt-in bounded pack/reflex | shadow/pilot only | +| External-model pack | deny by default; per-record egress gate plus shadow evaluation | +| Automatic canonical promotion | prohibited | +| Global prompt injection | `HOLD` until this rubric passes on sealed holdout data and receives explicit operator approval | + +Passing tests does not self-authorize rollout. + +## 2. Evaluation isolation + +Use three separate locations under a temporary evaluation root: + +```text +fixtures/runtime/ query/input cases visible to runtime +results/runtime/ immutable runtime outputs before scoring +sealed-gold/evaluator/ expected IDs/labels visible only to evaluator +``` + +Requirements: + +1. Runtime fixture IDs are stable and unique. +2. Runtime fixtures contain no `gold`, `answer`, `answers`, `expected`, `expected_ids`, or equivalent answer-bearing key at any depth. +3. Result files are written and checksummed before the evaluator reads sealed gold. +4. Runtime code receives fixture and runtime root only, never a gold path or answer object. +5. Gold and result artifact paths must not be identical, nested, colocated, or merged. +6. A leak, duplicate case ID, result mutation after scoring, or evaluator/runtime path overlap is `FAIL`. + +## 3. Mandatory conformance harness + +Every implementation revision must pass all rows: + +| ID | Scenario | Required observation | +|---|---|---| +| C1 | remember → recall → forget → rebuild → recall | Candidate requires approval; active is recalled; confirmed fence hides it; rebuild preserves forgotten state. | +| C2 | scope isolation | Wrong source/project/profile/user sees no list result and an ID probe returns `not_found`. | +| C3 | private egress | Private/restricted is absent from external-model output even if egress flag is true. | +| C4 | TTL | Expiring active record is excluded after expiry; inactive inspection reports `expired`. | +| C5 | stable authority | Stable preference/safety rejects TTL and remains active until explicit lifecycle decision. | +| C6 | supersession | New approved same-scope entity supersedes old; links/history survive rebuild. | +| C7 | extraction outcome | Each of seven outcomes remains distinct; failure is never counted as no salience. | +| C8 | write reconciliation | Canonical success/derived failure exits non-zero, preserves unresolved receipt, and reconcile rebuilds before closing it. | +| C9 | destructive confirmation | Missing/stale/cross-scope/reused token fails; mass action needs explicit `all_in_scope`; fence is read back. | +| C10 | doctor | Detects DB-only/orphan, canonical-only/content drift, missing/malformed derived state, missing scope, malformed fence/record, unresolved receipt, checksum drift, expired delivery. | +| C11 | shadow candidates | Extraction cannot activate, inject, or promote automatically. | +| C12 | bounded reflex | Non-allowlisted term returns HOLD; count/token limits hold; compact fields only. | +| C13 | duplicate suppression | Same `record-id@revision` is not delivered twice after cursor update. | +| C14 | delta recall | Created, modified, superseded, expired, and forgotten changes appear strictly after cursor. | +| C15 | promotion queue | Recommendation/evidence persists but no 20/30/40/50 write occurs. | +| C16 | usage audit | Attempted, selected, delivered are distinct; audit is absent from pack. | +| C17 | CLI failure semantics | Success JSON uses stdout; validation/write/doctor failure uses non-zero and JSON stderr. | +| C18 | derived rebuild | Removing/replacing the derived index cannot create or erase durable record/fence meaning. | + +Any missing scenario is `NOT_TESTED`, not a pass. + +## 4. Adversarial security set + +The sealed suite must include: + +- missing and partially missing scope; +- project/profile/user crossover probes; +- candidate-state override attempts through ordinary recall and CLI; +- private/restricted egress with egress flag true; +- external-model call with egress flag false; +- path traversal, post-initialization root/child symlink replacement, and hardlink artifact attempts; +- malformed Markdown marker/JSON fence; +- forged derived-only ID; +- checksum drift between preview and confirm; +- reused/expired/cross-scope confirmation token; +- same-timestamp delta events after cursor issuance, repeated opaque-cursor replay, and malformed opaque cursors; +- candidate or rejected item offered for injection; +- stable preference/safety with TTL; +- gold/expected answer key nested inside a fixture; +- duplicate identity through reordered retrieval; +- expired record delivered from stale cursor/index; +- conflict presented as unqualified fact; +- provider failure mislabeled as `nothing_salient`. + +Required threshold: zero unauthorized disclosure, zero destructive bypass, zero answer leakage, and zero candidate-as-active events. + +## 5. Metric definitions + +Use one observation row per attempted memory-assisted task. Missing labels stay missing; do not silently coerce them to false. + +### 5.1 False memory + +A delivered memory is false when direct source/lineage recovery shows that the summary is unsupported, materially wrong, stale without an expiry/conflict signal, or attached to the wrong entity/scope. + +```text +falseMemoryRate = falseDeliveredMemories / evaluatedDeliveredMemories +``` + +### 5.2 Duplicate injection + +A duplicate is the same stable `record-id@revision` delivered more than once to the same session after the first actual delivery. + +```text +duplicateInjectionRate = duplicateDeliveries / totalDeliveries +``` + +Attempts and selections that were not delivered are not duplicates. + +### 5.3 Conflict exposure + +A failure occurs when two active eligible memories conflict and the pack does not expose `HOLD`/conflict before synthesis. + +```text +conflictExposureRate = hiddenBlockingConflicts / evaluatedBlockingConflicts +``` + +For promotion this is an error rate and must be zero. + +### 5.4 Usefulness + +A memory is useful only when a blinded evaluator says it reduced source recovery effort or improved a task-relevant answer without adding a false/duplicate/hidden-conflict failure. + +```text +usefulnessRate = usefulAssistedTasks / evaluatedAssistedTasks +``` + +### 5.5 Token cost and latency + +Record actual delivered context tokens using the named downstream tokenizer/provider counter when a model is involved. The runtime's character heuristic is a budget guard, not a scored token measurement. Record end-to-end retrieval latency in milliseconds, median, p95, and fixture size. + +### 5.6 Manual correction + +```text +manualCorrectionRate = assistedTasksNeedingMemoryCorrection / evaluatedAssistedTasks +``` + +Corrections include wrong scope, wrong entity, stale preference, missing conflict, unsafe egress, or bad summary. + +### 5.7 Source/lineage recovery + +```text +sourceLineageRecoveryRate = deliveredMemoriesWithRecoverableCorrectSource / evaluatedDeliveredMemories +``` + +Recovery requires the pointer to open the intended canonical Markdown/original source and the lineage to explain the candidate decision. A plausible path string alone does not pass. + +## 6. Shadow comparison design + +Freeze before measurement: + +1. public/non-sensitive and separately local-private fixture partitions; +2. exact scopes and egress modes; +3. task/query texts without answers; +4. sealed expected memory IDs/source pointers; +5. baseline route with no memory pack; +6. prototype route with opt-in bounded pack; +7. max count/token budget and allowlisted terms; +8. timeout/latency measurement boundary; +9. evaluator identities and adjudication rule; +10. hashes of fixture, runtime version, and gold. + +Run baseline and prototype on the same tasks. Randomize presentation order to blinded usefulness evaluators. Do not tune weights, summaries, caps, or allowlists on holdout cases; tuned cases become development fixtures and require replacement holdouts. + +## 7. Promotion gates + +### 7.1 Local runtime gate + +All mandatory conformance and adversarial tests must pass. Python compilation, combined pytest, enforcement, task-contract lint, schema parse, real CLI smoke, and readback must all exit 0. Doctor must be clean at the end of the smoke flow. + +### 7.2 Opt-in bounded pack pilot + +Minimum sealed evaluation size: 50 tasks, including at least: + +- 10 scope/isolation cases; +- 10 freshness/TTL/supersession cases; +- 10 duplicate/cursor/delta cases; +- 10 conflict/source-recovery cases; +- 10 usefulness/cost cases. + +Required pilot thresholds: + +| Metric | Threshold | +|---|---:| +| Unauthorized scope/egress disclosures | 0 | +| Destructive confirmation bypasses | 0 | +| False-memory rate | ≤ 1% | +| Duplicate-injection rate | 0% | +| Hidden blocking conflict rate | 0% | +| Source/lineage recovery | 100% | +| Manual correction | ≤ baseline and ≤ 5% | +| Usefulness | ≥ 70% of scored assisted tasks | +| Median delivered memory tokens | ≤ frozen budget and no baseline task regression > 10% without adjudicated benefit | +| Median latency | ≤ 100 ms local; p95 ≤ 250 ms on frozen fixture machine | + +Threshold changes after observing holdout results require a new rubric version and fresh sealed set. + +### 7.3 Global injection gate + +Global injection remains `HOLD` even after one pilot pass. Enabling it requires all of: + +1. two consecutive sealed pilot passes on different frozen fixtures; +2. no regression in the existing retrieval suite; +3. explicit operator approval recorded in canonical AKM context; +4. an explicit rollback switch and proof that disabling injection leaves remember/doctor intact; +5. updated adapter/profile guidance and scoped lint; +6. no change to `40-memory/` structure unless separately approved under the repository's routing rules; +7. live retrieval verification using the adopter's configured local search path in the same authorized rollout run. + +Until then, status must continue to report `global_injection: HOLD`. + +## 8. Required result artifact + +The evaluator result must record: + +- runtime/schema revision and checksums; +- fixture/result/gold paths and separate hashes; +- scope and egress partition counts; +- conformance case counts and exact failures; +- all metric numerators/denominators, not percentages alone; +- token method, latency clock boundary, and machine/runtime identity; +- false-memory and conflict adjudication notes without leaking private contents; +- doctor/status outputs; +- final `PASS`, `HOLD`, `FAIL`, or `NOT_TESTED`; +- rollback action. + +The result cannot modify runtime outputs after gold is opened. + +## 9. Stop and rollback conditions + +Immediately stop delivery and set `FAIL` for scope/egress disclosure, destructive bypass, answer leakage, canonical-layer automatic write, candidate-as-active injection, or a normal-success response after partial write failure. + +Set `HOLD` for insufficient cases, unresolved receipt, malformed fence, source drift, missing conflict adjudication, ambiguous scope, missing direct-read pointer, or threshold not yet measured. + +Recovery follows the [memory runtime playbook](PLAYBOOK.md): disable delivery, retain safe diagnostics, reconcile only eligible derived failures, use confirmed forget/restore, rebuild, run doctor, add a regression test, and follow `[[LOOP]]` for recurring defects. diff --git a/adapters/hermes/memory/PLAYBOOK.md b/adapters/hermes/memory/PLAYBOOK.md new file mode 100644 index 0000000..ec299dc --- /dev/null +++ b/adapters/hermes/memory/PLAYBOOK.md @@ -0,0 +1,302 @@ +--- +type: documentation +aliases: + - "AKM Memory Runtime Playbook" + - "Hermes memory operator playbook" +description: "Fail-closed operator procedure for scoped remember, explicit activation, bounded recall, guarded forget, rebuild, reconciliation, doctor, and promotion review in the AKM-native local memory runtime." +author: + - "AKM Contributors" +date created: 2026-08-13 +date modified: 2026-08-13 +tags: + - akm + - memory + - hermes + - playbook + - runtime +akmLayer: procedure +akmRole: executable-procedure +akmType: playbook +trustLevel: reviewed +CMDS: Develop +sourceType: synthesis +nextAction: verify +usedBy: + - "[[MEMORY-RUNTIME]]" +provenance: + - "https://github.com/garrytan/gbrain/tree/1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5" +adaptationBoundary: "Uses AKM's independent Markdown-authoritative adapter and excludes gbrain code, databases, services, and runtime data." +--- + +# AKM Memory Runtime Playbook + +## 1. Purpose + +Use this procedure to operate the local Hermes memory adapter without creating a competing canonical knowledge store. It covers the smallest safe path from candidate creation through scoped recall and reversible forget. + +The operator contract is: + +```text +security + exact scope +→ candidate remember or shadow extraction +→ source/decision review +→ explicit approve or reject +→ bounded scoped recall +→ attempted/selected/delivered audit +→ guarded forget when needed +→ rebuild + doctor readback +``` + +Global prompt injection is not part of this procedure. It remains `HOLD` until the shadow evaluation rubric passes. + +## 2. Preconditions + +Before running a command: + +1. Read `99-system/MEMORY-RUNTIME.md`, `99-system/SECURITY.md`, and the relevant source Markdown. +2. Select an untracked runtime root outside the AKM repository. The adapter does not provide an in-repository default. +3. State the exact `source_id`, project, profile, and user. Do not omit a dimension merely to broaden recall. +4. Confirm no input contains secret values or auth/token/cookie/session/credential material. +5. Confirm source content will remain local; the adapter performs no external call. +6. For an external-model pack, confirm visibility is public/non-sensitive and egress was explicitly approved on each record. +7. Confirm the runtime root and fixed child directories are private real directories, not symlinks. Treat any post-initialization root-identity, symlink, hardlink, or non-regular-file validation error as a stop condition; never repair it by weakening the guard. + +If any precondition is uncertain, stop with `HOLD`. + +## 3. Common command prefix + +Use the same scope on every step in one flow: + +```bash +: "${AKM_RUNTIME_ROOT:?set this to an untracked directory outside the AKM repository}" +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm \ + --project runtime-demo \ + --profile default \ + --user operator \ + +``` + +The CLI writes successful JSON to stdout. It writes validated failures to stderr and exits with status 2. Treat a non-zero status as a stopped operation, even if a canonical record exists; inspect its receipt ID and reconcile. + +## 4. Remember + +### 4.1 Direct candidate + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + remember \ + --text "Use pointer-first retrieval." \ + --entity retrieval-policy \ + --kind preference \ + --stable-authority \ + --source-path 40-memory/operating-boundaries.md +``` + +Read back: + +- `record.state` must be `candidate`; +- source path/lineage must identify the reviewed input; +- scope must exactly match the command; +- `receipt.canonical_status` and `derived_status` must both be `written`; +- `receipt.reconcile_status` must be `not_required`. + +Do not interpret successful remember as permission to use the candidate in a prompt. + +### 4.2 Deterministic shadow extraction + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + extract --input "Remember: Use pointer-first retrieval." --persist +``` + +Only explicit `Remember:`/`Memory:` lines are candidates. Ordinary text returns `nothing_salient`. Provider/refusal/filter/malformed/truncated failures must be submitted as their exact outcome through `provider-outcome`; never rewrite one as `nothing_salient`. + +## 5. Review and explicit activation + +Review the cited source directly. Check identity, scope, visibility, egress, kind, entity, TTL, and stable-authority flag. Then choose one: + +```bash +# approve +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + approve --id mem_ID --decision-evidence "operator-readback:source-path#line" + +# reject +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + reject --id mem_ID --decision-evidence "source-does-not-support-candidate" +``` + +Approval supersedes only the active record with the same entity, kind, and exact scope. Read back both IDs when supersession occurs. Preference/safety records marked stable authority cannot have a TTL. + +## 6. Recall + +### 6.1 Ordinary scoped recall + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + recall --query pointer-first --max-count 10 +``` + +Expected result: active records only. Wrong-scope list recall returns no rows. Wrong-scope direct ID probes return `MemoryNotFound`, indistinguishable from a missing ID. + +Add `--external-model` only after explicit egress review. Private/restricted items must remain absent even if an egress flag was mistakenly set. + +### 6.2 Bounded context pack/reflex + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + context-pack \ + --entity-term retrieval-policy \ + --allowed-term retrieval-policy \ + --session-id task-123 \ + --max-count 3 \ + --token-budget 300 \ + --deliver +``` + +Gate the output: + +1. `status` must be `ready`; a term outside the allowlist returns `HOLD` and no item. +2. Item count and token estimate must stay within declared budgets. +3. Each item must contain only identity, pointer, safe summary, freshness, conflict, and direct-read flag. +4. Any `conflict: HOLD` blocks unqualified injection. +5. Any `direct_read: true` requires direct source read before an important claim. +6. Repeat delivery under the same session must suppress the identical `record-id@revision`. + +Do not copy receipts or `audit/usage.jsonl` into the prompt. + +### 6.3 Delta recall + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + delta --cursor 2026-08-13T00:00:00Z +``` + +Persist the returned opaque `d1.` `next_cursor` unchanged in caller-owned task state only when needed. It carries the boundary timestamp plus already-seen event identities so same-timestamp lifecycle changes are neither lost nor duplicated. Legacy ISO timestamp cursors are migration input only. Delta events are lifecycle signals, not full evidence. + +## 7. Promotion review + +Queue a candidate for ROUTER review: + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + promote --id mem_ID --decision-evidence "candidate-and-source-reviewed" +``` + +Verify `canonical_write_performed: false`. The recommendation is advisory. A separate authorized workflow must: + +1. follow `99-system/ROUTER.md` and inbox-first rules; +2. preserve source/candidate/decision evidence; +3. use AKM frontmatter and templates; +4. avoid direct automatic changes to the `40-memory/` surface; +5. update INDEX/LOG only when the canonical note workflow actually completes. + +## 8. Guarded forget + +### 8.1 Preview + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + forget --preview --id mem_ID --reason "incorrect-source-association" +``` + +For a scope-wide action, `--all-in-scope` is mandatory. Review count, exact IDs, states, revisions, checksums, and expiry. If any target is unexpected, do not confirm. + +### 8.2 Confirm and read back + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + forget --confirm TOKEN_FROM_PREVIEW +``` + +The same-scope, unexpired, unused token must match unchanged canonical checksums. Confirmed output must report `readback.state: forgotten`. Then run rebuild and recall; the forgotten ID must remain absent. + +### 8.3 Restore an erroneous fence + +```bash +python3 -m adapters.hermes.memory \ + --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator \ + restore --id mem_ID --decision-evidence "operator-confirmed-fence-error" +``` + +Restore deactivates the fence with evidence. It does not delete history. + +## 9. Rebuild, doctor, and reconcile + +Run after an upgrade, derived-state loss, failed write, or suspicious recall: + +```bash +python3 -m adapters.hermes.memory --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator rebuild + +python3 -m adapters.hermes.memory --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator doctor +``` + +Doctor must be clean before resuming delivery. Treat these as blocking: + +- DB/index-only or orphan ID; +- canonical-only or content-mismatched derived state; +- missing/malformed derived state while durable records exist; +- missing scope; +- checksum drift; +- malformed record/fence; +- record delivered after expiry; +- unresolved receipt. + +For a receipt with canonical `written` and derived `failed`: + +```bash +python3 -m adapters.hermes.memory --root $AKM_RUNTIME_ROOT \ + --source-id akm --project runtime-demo --profile default --user operator reconcile +``` + +Re-run doctor. Do not use reconcile to hide a canonical/manual failure. + +## 10. Verification checklist + +- [ ] Runtime root is local, untracked, and outside the AKM repository. +- [ ] Candidate starts non-governing. +- [ ] Approval/rejection evidence is present. +- [ ] Scope dimensions exactly match. +- [ ] Visibility and egress were evaluated separately. +- [ ] Count/token/entity allowlist bounds are explicit. +- [ ] Usage audit distinguishes attempted/selected/delivered. +- [ ] Duplicate identity is suppressed after actual delivery. +- [ ] Forget used preview, token, fence readback, rebuild, and recall. +- [ ] Doctor is clean. +- [ ] Promotion queue wrote no final-layer note. +- [ ] Global injection remains `HOLD` unless a future maintainer-approved gate changes it. + +## 11. Learn Back + +On false memory, scope leak, expired delivery, failed forget, hidden conflict, duplicate injection, or reconciliation failure: + +1. stop delivery; +2. retain safe receipt/doctor output without secret data; +3. follow `99-system/LOOP.md` and record the failure under `70-evaluation/`; +4. repair the designated runtime/procedure/evaluation layer; +5. add a regression test before re-enabling the path; +6. rerun the conformance sequence `remember → recall → forget → rebuild → recall`. diff --git a/adapters/hermes/memory/README.md b/adapters/hermes/memory/README.md new file mode 100644 index 0000000..7f2ad13 --- /dev/null +++ b/adapters/hermes/memory/README.md @@ -0,0 +1,157 @@ +# Hermes AKM memory runtime + +This package is a local, fail-closed memory adapter. It does not replace AKM, install gbrain, call a model, or write generated text into `20-knowledge/`, `30-context/`, `40-memory/`, or `50-procedures/`. + +The operator must supply an untracked runtime root outside the AKM repository. The adapter has no in-repository default and rejects roots beneath a directory identified by both `99-system/SCHEMA.md` and `99-system/ROUTER.md`. Durable local records and forget fences are Markdown. `derived/state.json` is disposable and `rebuild` recreates it from those Markdown artifacts. + +## Provenance and adaptation boundary + +The design adapts selected memory lifecycle, durable forget-fence, bounded-context, and delta/cursor concepts from [gbrain at commit `1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5`](https://github.com/garrytan/gbrain/tree/1ec6a6e842a15f2bde2ebe8c3a686a6fa6b17aa5). gbrain is Copyright (c) 2026 Garry Tan and is MIT-licensed, which is compatible with this repository's MIT license. This adapter is an independent Python implementation: it does not copy or vendor gbrain source code, install gbrain, adopt its database-first authority model, or include gbrain runtime data. + +## Safety boundary + +- A remembered item starts as `candidate`; only `approve` makes it `active`. +- Ordinary `recall` is active-only and exposes no lifecycle-state override; candidates are available only to explicit review/decision operations. Even an internal inactive `get` cannot return a candidate to an external-model request. +- `promote` means “put a candidate in the ROUTER review queue.” It never writes a final AKM note. +- `private` and `restricted` items are never eligible for external-model egress. Other visibility classes still require explicit `--egress-allowed`. +- Every record is scoped by `source_id` and, when present, `project`, `profile`, and `user`. Missing/mismatched scope is denied as `not_found`. +- `forget` always requires an exact impact preview and its short-lived confirmation token. The resulting Markdown fence is read back and can be explicitly restored. +- Global prompt injection is `HOLD`. `context-pack`/`reflex` is an opt-in bounded surface for shadow evaluation. +- The runtime never reads authentication, token, cookie, session, or credential stores and never makes a network/model call. +- Runtime reads and writes use no-follow directory/file descriptors, preserve the initially selected root identity, and reject post-initialization root/child symlink replacement or hardlinked artifacts. + +## CLI + +From the AKM root, first provide an external local directory: + +```bash +: "${AKM_RUNTIME_ROOT:?set this to an untracked directory outside the AKM repository}" +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + remember \ + --text "Use pointer-first retrieval." \ + --entity retrieval-policy \ + --source-path 40-memory/operating-boundaries.md +``` + +All successful commands emit one JSON object to stdout. Validated failures emit one JSON object to stderr and exit non-zero. + +Explicitly approve the returned record ID: + +```bash +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + approve --id mem_REPLACE_ME --decision-evidence operator-reviewed-source +``` + +Recall or create a compact, allowlisted context pack: + +```bash +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + recall --query pointer-first + +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + context-pack \ + --entity-term retrieval-policy --allowed-term retrieval-policy \ + --session-id demo-1 --max-count 3 --token-budget 300 --deliver +``` + +`context-pack` returns only stable identity, pointer, safe summary, freshness, conflict/HOLD, and direct-read flag. Full evidence, receipts, and usage audit never enter the pack. Reusing the session cursor suppresses an already delivered `record-id@revision`. + +`delta` returns an opaque `d1.` cursor. Persist and pass it back unchanged; do not parse or replace it with a newly sampled timestamp. Legacy ISO cursors remain accepted for strict-after migration compatibility, while the opaque cursor also prevents loss of events created at the same timestamp boundary. Future-dated cursors are rejected. + +Preview and confirm forget as two separate commands: + +```bash +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + forget --preview --id mem_REPLACE_ME --reason operator-request + +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + forget --confirm REPLACE_WITH_PREVIEW_TOKEN +``` + +Recovery is explicit: + +```bash +python3 -m adapters.hermes.memory \ + --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator \ + restore --id mem_REPLACE_ME --decision-evidence operator-reversed-forget +``` + +## Extraction boundary + +`extract --input 'Remember: ...' --persist` is a deterministic local parser for explicit `Remember:` or `Memory:` lines. Persisted output remains a shadow candidate. `provider-outcome` accepts already-produced provider output at an explicit boundary; it does not invoke a provider. + +The outcome vocabulary is exact: + +```text +success +nothing_salient +provider_error +refusal +content_filter +malformed_output +truncated_output +``` + +A processing failure is never rewritten as `nothing_salient`. + +## Rebuild, reconciliation, and doctor + +```bash +python3 -m adapters.hermes.memory --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator rebuild + +python3 -m adapters.hermes.memory --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator reconcile + +python3 -m adapters.hermes.memory --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator doctor + +python3 -m adapters.hermes.memory --root "$AKM_RUNTIME_ROOT" \ + --source-id akm --project runtime-demo --profile default --user operator status +``` + +`doctor` exits non-zero through the CLI when it finds DB/index-only rows, canonical-only/mismatched derived rows, missing or malformed derived state, missing scope, unresolved receipts, malformed records/fences, checksum drift, or a record delivered after expiry. `reconcile` rebuilds derived state first, then resolves receipts whose canonical Markdown succeeded while derived writing failed. + +## Runtime layout + +```text +/ + records/*.md durable candidate/lifecycle records + fences/*.md durable reversible forget fences + receipts/*.json canonical/derived write receipts + previews/*.json short-lived destructive previews + promotion-queue/*.json ROUTER recommendations; no canonical write + cursors/*.json delivered identity suppression + audit/usage.jsonl attempted/selected/delivered usage only + derived/state.json disposable index rebuilt from Markdown +``` + +Do not place this root in the AKM repository. Tests use pytest-managed temporary directories only. If the root or any fixed child directory is replaced or linked after initialization, stop the consumer, restore a clean private runtime directory, and rerun `rebuild` plus `doctor`; do not bypass the validation error. + +## Operator and evaluation guides + +- [Operator playbook](PLAYBOOK.md) +- [Evaluation rubric](EVALUATION.md) + +## Test + +The focused test requires `pytest` and `jsonschema` in the active Python environment: + +```bash +PYTHONPYCACHEPREFIX="$AKM_RUNTIME_ROOT/pycache" \ + python3 -m py_compile adapters/hermes/memory/*.py +python3 -m pytest adapters/hermes/memory/tests/test_memory_runtime.py -q -p no:cacheprovider +``` diff --git a/adapters/hermes/memory/__init__.py b/adapters/hermes/memory/__init__.py new file mode 100644 index 0000000..d5ebcac --- /dev/null +++ b/adapters/hermes/memory/__init__.py @@ -0,0 +1,27 @@ +"""Public API for the AKM-native Hermes memory runtime.""" + +from .evaluation import HoldoutEvaluator +from .runtime import ( + ConfirmationRequired, + ExtractionOutcome, + MemoryNotFound, + MemoryRuntime, + MemoryRuntimeError, + MemoryWriteError, + Scope, + ValidationError, + compute_metrics, +) + +__all__ = [ + "ConfirmationRequired", + "ExtractionOutcome", + "HoldoutEvaluator", + "MemoryNotFound", + "MemoryRuntime", + "MemoryRuntimeError", + "MemoryWriteError", + "Scope", + "ValidationError", + "compute_metrics", +] diff --git a/adapters/hermes/memory/__main__.py b/adapters/hermes/memory/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/adapters/hermes/memory/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/adapters/hermes/memory/cli.py b/adapters/hermes/memory/cli.py new file mode 100644 index 0000000..a290df1 --- /dev/null +++ b/adapters/hermes/memory/cli.py @@ -0,0 +1,198 @@ +"""JSON CLI for exercising the AKM-native local memory runtime.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Sequence + +from .runtime import MemoryRuntime, MemoryRuntimeError, Scope + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m adapters.hermes.memory") + parser.add_argument("--root", type=Path, required=True, help="local runtime root") + parser.add_argument("--source-id", required=True) + parser.add_argument("--project") + parser.add_argument("--profile") + parser.add_argument("--user") + sub = parser.add_subparsers(dest="command", required=True) + + remember = sub.add_parser("remember") + remember.add_argument("--text", required=True) + remember.add_argument("--entity", required=True) + remember.add_argument("--kind", default="fact") + remember.add_argument("--visibility", default="non_sensitive") + remember.add_argument("--egress-allowed", action="store_true") + remember.add_argument("--ttl-seconds", type=int) + remember.add_argument("--stable-authority", action="store_true") + remember.add_argument("--source-path", required=True) + remember.add_argument("--lineage", action="append", default=["operator-cli"]) + + approve = sub.add_parser("approve") + approve.add_argument("--id", required=True) + approve.add_argument("--decision-evidence", required=True) + + reject = sub.add_parser("reject") + reject.add_argument("--id", required=True) + reject.add_argument("--decision-evidence", required=True) + + recall = sub.add_parser("recall") + recall.add_argument("--query") + recall.add_argument("--id") + recall.add_argument("--external-model", action="store_true") + recall.add_argument("--max-count", type=int, default=20) + + forget = sub.add_parser("forget") + forget_mode = forget.add_mutually_exclusive_group(required=True) + forget_mode.add_argument("--preview", action="store_true") + forget_mode.add_argument("--confirm") + forget.add_argument("--id", action="append") + forget.add_argument("--all-in-scope", action="store_true") + forget.add_argument("--reason", default="operator-request") + + restore = sub.add_parser("restore") + restore.add_argument("--id", required=True) + restore.add_argument("--decision-evidence", required=True) + + extract = sub.add_parser("extract") + extract.add_argument("--input", required=True) + extract.add_argument("--persist", action="store_true") + + provider = sub.add_parser("provider-outcome") + provider.add_argument("--outcome", required=True) + provider.add_argument("--payload-json") + provider.add_argument("--persist", action="store_true") + + context = sub.add_parser("context-pack", aliases=["reflex"]) + context.add_argument("--entity-term", action="append", required=True) + context.add_argument("--allowed-term", action="append", required=True) + context.add_argument("--session-id", required=True) + context.add_argument("--since") + context.add_argument("--max-count", type=int, default=5) + context.add_argument("--token-budget", type=int, default=400) + context.add_argument("--external-model", action="store_true") + context.add_argument("--deliver", action="store_true") + + delta = sub.add_parser("delta") + delta.add_argument("--cursor") + + promote = sub.add_parser("promote") + promote.add_argument("--id", required=True) + promote.add_argument("--decision-evidence", required=True) + + sub.add_parser("status") + sub.add_parser("rebuild") + sub.add_parser("doctor") + sub.add_parser("reconcile") + return parser + + +def _scope(args: argparse.Namespace) -> Scope: + return Scope( + source_id=args.source_id, + project=args.project, + profile=args.profile, + user=args.user, + ) + + +def _dispatch(runtime: MemoryRuntime, scope: Scope, args: argparse.Namespace) -> dict[str, Any]: + command = args.command + if command == "remember": + return runtime.remember( + args.text, + scope=scope, + entity=args.entity, + kind=args.kind, + visibility=args.visibility, + egress_allowed=args.egress_allowed, + ttl_seconds=args.ttl_seconds, + stable_authority=args.stable_authority, + source_path=args.source_path, + source_lineage=args.lineage, + ) + if command == "approve": + return runtime.approve(args.id, scope=scope, decision_evidence=args.decision_evidence) + if command == "reject": + return runtime.reject(args.id, scope=scope, decision_evidence=args.decision_evidence) + if command == "recall": + return { + "records": runtime.recall( + scope=scope, + query=args.query, + record_id=args.id, + external_model=args.external_model, + max_count=args.max_count, + ) + } + if command == "forget": + if args.preview: + return runtime.forget_preview( + scope=scope, + record_ids=args.id, + all_in_scope=args.all_in_scope, + reason=args.reason, + ) + return runtime.forget_confirm(args.confirm, scope=scope) + if command == "restore": + return runtime.restore_forgotten( + args.id, scope=scope, decision_evidence=args.decision_evidence + ) + if command == "extract": + outcome = runtime.extract_local(args.input, scope=scope, persist=args.persist) + return {"outcome": outcome.outcome, "candidates": outcome.candidates, "detail": outcome.detail} + if command == "provider-outcome": + payload = json.loads(args.payload_json) if args.payload_json else None + outcome = runtime.ingest_provider_boundary( + args.outcome, payload=payload, scope=scope, persist=args.persist + ) + return {"outcome": outcome.outcome, "candidates": outcome.candidates, "detail": outcome.detail} + if command in {"context-pack", "reflex"}: + return runtime.context_pack( + scope=scope, + entity_terms=args.entity_term, + allowed_terms=args.allowed_term, + session_id=args.session_id, + since=args.since, + max_count=args.max_count, + token_budget=args.token_budget, + external_model=args.external_model, + deliver=args.deliver, + ) + if command == "delta": + return runtime.delta_recall(scope=scope, cursor=args.cursor) + if command == "promote": + return runtime.queue_promotion( + args.id, scope=scope, decision_evidence=args.decision_evidence + ) + if command == "status": + return runtime.status(scope=scope) + if command == "rebuild": + return runtime.rebuild() + if command == "doctor": + report = runtime.doctor() + if not report["ok"]: + raise MemoryRuntimeError("doctor found fail-closed issues: " + json.dumps(report, sort_keys=True)) + return report + if command == "reconcile": + return runtime.reconcile() + raise MemoryRuntimeError(f"unknown command: {command}") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + try: + result = _dispatch(MemoryRuntime(args.root), _scope(args), args) + except (MemoryRuntimeError, json.JSONDecodeError) as exc: + payload = {"ok": False, "error": type(exc).__name__, "message": str(exc)} + receipt_id = getattr(exc, "receipt_id", None) + if receipt_id: + payload["receipt_id"] = receipt_id + print(json.dumps(payload, ensure_ascii=False, sort_keys=True), file=sys.stderr) + return 2 + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 diff --git a/adapters/hermes/memory/evaluation.py b/adapters/hermes/memory/evaluation.py new file mode 100644 index 0000000..6eb4b7f --- /dev/null +++ b/adapters/hermes/memory/evaluation.py @@ -0,0 +1,99 @@ +"""Sealed-gold evaluation boundary for the AKM memory runtime.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .runtime import ValidationError + + +class HoldoutEvaluator: + """Keep runtime fixtures/results separate from held-out answer material.""" + + FORBIDDEN_FIXTURE_KEYS = {"gold", "answer", "answers", "expected", "expected_ids"} + + @classmethod + def prepare_fixture(cls, fixture_path: str | Path) -> dict[str, Any]: + fixture = cls._read_object(Path(fixture_path)) + cls._assert_no_gold(fixture, path="$") + cases = fixture.get("cases") + if not isinstance(cases, list): + raise ValidationError("fixture cases must be a list") + seen: set[str] = set() + for index, case in enumerate(cases): + if not isinstance(case, dict) or not isinstance(case.get("id"), str): + raise ValidationError(f"fixture case {index} needs an id") + if case["id"] in seen: + raise ValidationError(f"duplicate fixture case id: {case['id']}") + seen.add(case["id"]) + return fixture + + @classmethod + def score_result(cls, result_path: str | Path, gold_path: str | Path) -> dict[str, Any]: + result_file = Path(result_path).expanduser().resolve() + gold_file = Path(gold_path).expanduser().resolve() + if cls._paths_overlap(result_file, gold_file): + raise ValidationError("runtime result and sealed gold paths must be isolated") + result = cls._read_object(result_file) + gold = cls._read_object(gold_file) + rows = result.get("results") + if not isinstance(rows, list): + raise ValidationError("result rows must be a list") + if "gold" in result: + raise ValidationError("runtime result contains sealed gold") + matched = 0 + scored = 0 + seen: set[str] = set() + for row in rows: + if not isinstance(row, dict) or not isinstance(row.get("id"), str): + raise ValidationError("result row needs an id") + case_id = row["id"] + if case_id in seen: + raise ValidationError(f"duplicate result id: {case_id}") + seen.add(case_id) + if case_id not in gold: + continue + scored += 1 + expected = gold[case_id] + selected = row.get("selected", []) + if selected == expected: + matched += 1 + return { + "scored_cases": scored, + "exact_matches": matched, + "exact_match_rate": matched / scored if scored else None, + } + + @staticmethod + def _paths_overlap(left: Path, right: Path) -> bool: + left_parent = left.parent + right_parent = right.parent + if left == right or left_parent == right_parent: + return True + try: + return left_parent.is_relative_to(right_parent) or right_parent.is_relative_to(left_parent) + except ValueError: + return False + + @classmethod + def _assert_no_gold(cls, value: Any, *, path: str) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key.casefold() in cls.FORBIDDEN_FIXTURE_KEYS: + raise ValidationError(f"sealed gold key is forbidden in runtime fixture at {path}.{key}") + cls._assert_no_gold(child, path=f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + cls._assert_no_gold(child, path=f"{path}[{index}]") + + @staticmethod + def _read_object(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValidationError(f"invalid evaluator JSON: {path}") from exc + if not isinstance(value, dict): + raise ValidationError(f"evaluator artifact must be an object: {path}") + return value diff --git a/adapters/hermes/memory/runtime.py b/adapters/hermes/memory/runtime.py new file mode 100644 index 0000000..fc4a73e --- /dev/null +++ b/adapters/hermes/memory/runtime.py @@ -0,0 +1,1680 @@ +"""AKM-native local memory runtime. + +Durable runtime records and forget fences are Markdown files. The JSON state +file is deliberately disposable and is rebuilt from those Markdown artifacts. +Nothing in this module writes into AKM's canonical 20/30/40/50 layers. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import math +import os +import re +import secrets +import stat +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Sequence + + +UTC = timezone.utc +RECORD_MARKER = "" +FENCE_MARKER = "" +JSON_BLOCK = re.compile(r"```json\s*\n(?P.*?)\n```", re.DOTALL) +STATES = {"candidate", "active", "superseded", "expired", "forgotten", "rejected"} +EXTRACTION_OUTCOMES = { + "success", + "nothing_salient", + "provider_error", + "refusal", + "content_filter", + "malformed_output", + "truncated_output", +} +VISIBILITIES = {"public", "non_sensitive", "private", "restricted"} +KINDS = {"fact", "event", "preference", "safety", "commitment", "belief", "procedure"} +STABLE_AUTHORITY_KINDS = {"preference", "safety"} +SAFE_SUMMARY_LIMIT = 240 +DELTA_CURSOR_MAX_CHARS = 262_144 +DELTA_CURSOR_MAX_SEEN = 10_000 +RECORD_FIELDS = { + "schema_version", + "id", + "state", + "revision", + "text", + "safe_summary", + "kind", + "entity", + "scope", + "visibility", + "egress_allowed", + "stable_authority", + "source", + "created_at", + "modified_at", + "expires_at", + "supersedes", + "superseded_by", + "decision_evidence", + "history", +} +SCOPE_FIELDS = {"source_id", "project", "profile", "user"} +SOURCE_FIELDS = {"path", "lineage"} +HISTORY_FIELDS = {"change", "at", "evidence", "detail", "related_record_id"} +HISTORY_CHANGES = {"created", "modified", "superseded", "expired", "forgotten"} + + +class MemoryRuntimeError(RuntimeError): + """Base class for runtime failures safe to expose to an operator.""" + + +class ValidationError(MemoryRuntimeError): + """Input or durable artifact failed a fail-closed validation.""" + + +class MemoryNotFound(MemoryRuntimeError): + """Record is missing or inaccessible; deliberately does not distinguish.""" + + +class ConfirmationRequired(MemoryRuntimeError): + """Destructive operation lacks a current exact-impact confirmation token.""" + + +class MemoryWriteError(MemoryRuntimeError): + """Canonical/derived write did not reconcile to a completed receipt.""" + + def __init__(self, message: str, receipt_id: str) -> None: + super().__init__(message) + self.receipt_id = receipt_id + + +@dataclass(frozen=True) +class Scope: + source_id: str + project: str | None = None + profile: str | None = None + user: str | None = None + + def __post_init__(self) -> None: + if not isinstance(self.source_id, str) or not self.source_id.strip(): + raise ValidationError("scope source_id is required") + for name in ("project", "profile", "user"): + value = getattr(self, name) + if value is not None and (not isinstance(value, str) or not value.strip()): + raise ValidationError(f"scope {name} must be a non-empty string when supplied") + + def as_dict(self) -> dict[str, str | None]: + return { + "source_id": self.source_id, + "project": self.project, + "profile": self.profile, + "user": self.user, + } + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "Scope": + if not isinstance(value, Mapping) or set(value) != SCOPE_FIELDS: + raise ValidationError("scope must contain exactly source_id/project/profile/user") + return cls( + source_id=str(value.get("source_id") or ""), + project=value.get("project"), + profile=value.get("profile"), + user=value.get("user"), + ) + + +@dataclass(frozen=True) +class ExtractionOutcome: + outcome: str + candidates: list[dict[str, Any]] + detail: str | None = None + + +@dataclass(frozen=True) +class RuntimePaths: + root: Path + records: Path + fences: Path + derived: Path + derived_state: Path + receipts: Path + previews: Path + promotion_queue: Path + cursors: Path + audit: Path + usage_audit: Path + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def _parse_time(value: str) -> datetime: + if not isinstance(value, str): + raise ValidationError("timestamp must be a string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValidationError(f"invalid timestamp: {value}") from exc + if parsed.tzinfo is None: + raise ValidationError("timestamp must include a timezone") + return parsed.astimezone(UTC) + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _safe_summary(text: str) -> str: + compact = " ".join(text.split()) + if len(compact) <= SAFE_SUMMARY_LIMIT: + return compact + return compact[: SAFE_SUMMARY_LIMIT - 1].rstrip() + "…" + + +def _slug(value: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return normalized[:80] or "memory" + + +def _scope_matches(record_scope: Mapping[str, Any], request: Scope) -> bool: + if not record_scope.get("source_id"): + return False + supplied = request.as_dict() + for field in ("source_id", "project", "profile", "user"): + expected = record_scope.get(field) + if expected is not None and supplied.get(field) != expected: + return False + return True + + +def _egress_allowed(record: Mapping[str, Any], external_model: bool) -> bool: + if not external_model: + return True + if record.get("visibility") in {"private", "restricted"}: + return False + return bool(record.get("egress_allowed")) + + +def _delta_event_id(source: str, ordinal: int, event: Mapping[str, Any]) -> str: + material = { + "source": source, + "ordinal": ordinal, + "record_id": event["record_id"], + "change": event["change"], + "at": event["at"], + } + return _sha256_bytes(_canonical_json(material).encode("utf-8")) + + +def _encode_delta_cursor(at: datetime, seen: Iterable[str]) -> str: + payload = _canonical_json({"at": _iso(at), "seen": sorted(set(seen))}).encode("utf-8") + return "d1." + base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def _decode_delta_cursor(value: str | None) -> tuple[datetime | None, set[str], bool]: + if value is None: + return None, set(), False + if len(value) > DELTA_CURSOR_MAX_CHARS: + raise ValidationError("delta cursor is too large") + if not value.startswith("d1."): + return _parse_time(value), set(), False + encoded = value[3:] + if not encoded or not re.fullmatch(r"[A-Za-z0-9_-]+", encoded): + raise ValidationError("invalid delta cursor") + try: + padding = "=" * (-len(encoded) % 4) + payload = json.loads(base64.urlsafe_b64decode(encoded + padding).decode("utf-8")) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError("invalid delta cursor") from exc + if not isinstance(payload, dict) or set(payload) != {"at", "seen"}: + raise ValidationError("invalid delta cursor") + if ( + not isinstance(payload["seen"], list) + or len(payload["seen"]) > DELTA_CURSOR_MAX_SEEN + or any( + not isinstance(item, str) or not re.fullmatch(r"[0-9a-f]{64}", item) + for item in payload["seen"] + ) + ): + raise ValidationError("invalid delta cursor") + return _parse_time(payload["at"]), set(payload["seen"]), True + + +class MemoryRuntime: + """Local, scoped memory runtime whose derived index is rebuildable.""" + + def __init__( + self, + root: str | Path, + *, + now: Callable[[], datetime] | None = None, + ) -> None: + root_path = Path(root).expanduser().resolve() + self._reject_akm_canonical_root(root_path) + self._now = now or _utc_now + self.paths = RuntimePaths( + root=root_path, + records=root_path / "records", + fences=root_path / "fences", + derived=root_path / "derived", + derived_state=root_path / "derived" / "state.json", + receipts=root_path / "receipts", + previews=root_path / "previews", + promotion_queue=root_path / "promotion-queue", + cursors=root_path / "cursors", + audit=root_path / "audit", + usage_audit=root_path / "audit" / "usage.jsonl", + ) + for directory in ( + self.paths.records, + self.paths.fences, + self.paths.derived, + self.paths.receipts, + self.paths.previews, + self.paths.promotion_queue, + self.paths.cursors, + self.paths.audit, + ): + directory.mkdir(parents=True, exist_ok=True) + root_fd = os.open(self.paths.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + root_stat = os.fstat(root_fd) + self._root_identity = (root_stat.st_dev, root_stat.st_ino) + finally: + os.close(root_fd) + + @staticmethod + def _reject_akm_canonical_root(root_path: Path) -> None: + """Do not let local runtime state become an AKM canonical subtree.""" + for ancestor in (root_path, *root_path.parents): + if (ancestor / "99-system" / "SCHEMA.md").is_file() and ( + ancestor / "99-system" / "ROUTER.md" + ).is_file(): + raise ValidationError("runtime root must be outside the AKM canonical vault") + + def record_path(self, record_id: str) -> Path: + self._validate_id(record_id) + return self.paths.records / f"{record_id}.md" + + def remember( + self, + text: str, + *, + scope: Scope, + entity: str, + kind: str = "fact", + visibility: str = "non_sensitive", + egress_allowed: bool = False, + ttl_seconds: int | None = None, + stable_authority: bool = False, + source_path: str, + source_lineage: Sequence[str], + extraction_evidence: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + text = self._required_text(text, "text") + entity = self._required_text(entity, "entity") + source_path = self._required_text(source_path, "source_path") + if kind not in KINDS: + raise ValidationError(f"unsupported memory kind: {kind}") + if visibility not in VISIBILITIES: + raise ValidationError(f"unsupported visibility: {visibility}") + if not isinstance(source_lineage, Sequence) or isinstance(source_lineage, (str, bytes)): + raise ValidationError("source_lineage must be a list of non-empty strings") + lineage = [self._required_text(str(item), "source_lineage item") for item in source_lineage] + if not lineage: + raise ValidationError("source_lineage must not be empty") + if ttl_seconds is not None and (not isinstance(ttl_seconds, int) or ttl_seconds <= 0): + raise ValidationError("ttl_seconds must be a positive integer") + if stable_authority and kind not in STABLE_AUTHORITY_KINDS: + raise ValidationError("stable authority is limited to preference and safety records") + if stable_authority and ttl_seconds is not None: + raise ValidationError("stable authority preference/safety records may not have TTL") + + now = self._now() + identity_payload = { + "text": text, + "entity": entity, + "kind": kind, + "scope": scope.as_dict(), + "source_path": source_path, + } + record_id = "mem_" + hashlib.sha256(_canonical_json(identity_payload).encode()).hexdigest()[:24] + path = self.record_path(record_id) + if path.exists(): + record = self._read_markdown_json(path, RECORD_MARKER) + if not _scope_matches(record.get("scope", {}), scope): + raise MemoryNotFound("memory not found") + receipt = self._completed_noop_receipt("remember-duplicate", record, path) + return {"record": record, "receipt": receipt, "duplicate": True} + + expires_at = _iso(now + timedelta(seconds=ttl_seconds)) if ttl_seconds else None + record: dict[str, Any] = { + "schema_version": "1.0.0", + "id": record_id, + "state": "candidate", + "revision": 1, + "text": text, + "safe_summary": _safe_summary(text), + "kind": kind, + "entity": entity, + "scope": scope.as_dict(), + "visibility": visibility, + "egress_allowed": bool(egress_allowed), + "stable_authority": bool(stable_authority), + "source": {"path": source_path, "lineage": lineage}, + "created_at": _iso(now), + "modified_at": _iso(now), + "expires_at": expires_at, + "supersedes": None, + "superseded_by": None, + "decision_evidence": dict(extraction_evidence) if extraction_evidence else None, + "history": [ + { + "change": "created", + "at": _iso(now), + "evidence": "explicit-remember", + } + ], + } + receipt_id = self._receipt_id("remember", record_id) + receipt = self._new_receipt(receipt_id, "remember", [record_id]) + self._write_receipt(receipt) + try: + checksum = self._write_record(record) + receipt.update( + canonical_status="written", + canonical_checksum=checksum, + revision=record["revision"], + ) + self._write_receipt(receipt) + except Exception as exc: + receipt.update(canonical_status="failed", failure=str(exc), reconcile_status="manual") + self._write_receipt(receipt) + raise MemoryWriteError("canonical Markdown write failed", receipt_id) from exc + self._finish_derived_write(receipt, receipt_id) + return {"record": record, "receipt": receipt, "duplicate": False} + + def approve( + self, + record_id: str, + *, + scope: Scope, + decision_evidence: str, + ) -> dict[str, Any]: + decision_evidence = self._required_text(decision_evidence, "decision_evidence") + target = self.get(record_id, scope=scope, include_inactive=True) + if target["state"] != "candidate": + raise ValidationError("only candidate records can be explicitly approved") + now = self._now() + effective = self._build_state()["records"] + old_records: list[dict[str, Any]] = [] + for row in effective.values(): + if ( + row["id"] != record_id + and row.get("state") == "active" + and row.get("kind") == target.get("kind") + and row.get("entity") == target.get("entity") + and row.get("scope") == target.get("scope") + ): + original = self._read_record(row["id"]) + original["state"] = "superseded" + original["superseded_by"] = record_id + original["modified_at"] = _iso(now) + original["revision"] += 1 + original["history"].append( + { + "change": "superseded", + "at": _iso(now), + "evidence": decision_evidence, + "related_record_id": record_id, + } + ) + old_records.append(original) + + target = self._read_record(record_id) + target["state"] = "active" + target["modified_at"] = _iso(now) + target["revision"] += 1 + target["decision_evidence"] = {"approval": decision_evidence} + if old_records: + target["supersedes"] = old_records[-1]["id"] + target["history"].append( + { + "change": "modified", + "at": _iso(now), + "evidence": decision_evidence, + "detail": "candidate-approved", + } + ) + receipt_id = self._receipt_id("approve", record_id) + receipt = self._new_receipt(receipt_id, "approve", [row["id"] for row in old_records] + [record_id]) + self._write_receipt(receipt) + try: + checksums = {row["id"]: self._write_record(row) for row in old_records} + checksums[record_id] = self._write_record(target) + receipt.update( + canonical_status="written", + canonical_checksum=checksums, + revision=target["revision"], + ) + self._write_receipt(receipt) + except Exception as exc: + receipt.update(canonical_status="failed", failure=str(exc), reconcile_status="manual") + self._write_receipt(receipt) + raise MemoryWriteError("approval Markdown write failed", receipt_id) from exc + self._finish_derived_write(receipt, receipt_id) + return {"record": target, "superseded": [row["id"] for row in old_records], "receipt": receipt} + + def reject( + self, + record_id: str, + *, + scope: Scope, + decision_evidence: str, + ) -> dict[str, Any]: + target = self.get(record_id, scope=scope, include_inactive=True) + if target["state"] != "candidate": + raise ValidationError("only candidate records can be rejected") + target = self._read_record(record_id) + now = self._now() + target["state"] = "rejected" + target["revision"] += 1 + target["modified_at"] = _iso(now) + target["decision_evidence"] = {"rejection": self._required_text(decision_evidence, "decision_evidence")} + target["history"].append( + {"change": "modified", "at": _iso(now), "evidence": decision_evidence, "detail": "rejected"} + ) + return self._write_mutation("reject", target) + + def get( + self, + record_id: str, + *, + scope: Scope, + include_inactive: bool = False, + external_model: bool = False, + ) -> dict[str, Any]: + self._validate_id(record_id) + record = self._build_state()["records"].get(record_id) + if not record or not _scope_matches(record.get("scope", {}), scope): + raise MemoryNotFound("memory not found") + if not _egress_allowed(record, external_model): + raise MemoryNotFound("memory not found") + if external_model and record.get("state") != "active": + raise MemoryNotFound("memory not found") + if not include_inactive and record.get("state") != "active": + raise MemoryNotFound("memory not found") + return json.loads(json.dumps(record)) + + def recall( + self, + *, + scope: Scope, + query: str | None = None, + record_id: str | None = None, + include_states: Iterable[str] = ("active",), + external_model: bool = False, + max_count: int = 20, + ) -> list[dict[str, Any]]: + if max_count <= 0 or max_count > 100: + raise ValidationError("max_count must be between 1 and 100") + allowed_states = set(include_states) + if allowed_states != {"active"}: + raise ValidationError("ordinary recall permits active records only") + if record_id: + row = self.get( + record_id, + scope=scope, + include_inactive=False, + external_model=external_model, + ) + return [row] + needle = (query or "").casefold().strip() + rows = [] + for row in self._build_state()["records"].values(): + if row.get("state") not in allowed_states: + continue + if not _scope_matches(row.get("scope", {}), scope): + continue + if not _egress_allowed(row, external_model): + continue + haystack = " ".join( + str(row.get(field, "")) for field in ("text", "safe_summary", "entity", "kind") + ).casefold() + if needle and needle not in haystack: + continue + rows.append(json.loads(json.dumps(row))) + rows.sort(key=lambda item: (item.get("modified_at", ""), item["id"]), reverse=True) + return rows[:max_count] + + def forget_preview( + self, + *, + scope: Scope, + record_ids: Sequence[str] | None = None, + all_in_scope: bool = False, + reason: str, + ) -> dict[str, Any]: + reason = self._required_text(reason, "reason") + if not record_ids and not all_in_scope: + raise ValidationError("record_ids or explicit all_in_scope is required") + if record_ids and all_in_scope: + raise ValidationError("choose record_ids or all_in_scope, not both") + if all_in_scope: + rows = [ + row + for row in self._build_state()["records"].values() + if _scope_matches(row.get("scope", {}), scope) and row.get("state") != "forgotten" + ] + else: + rows = [] + for record_id in record_ids or []: + try: + rows.append(self.get(record_id, scope=scope, include_inactive=True)) + except MemoryNotFound as exc: + raise MemoryNotFound("memory not found") from exc + if not rows: + raise MemoryNotFound("memory not found") + rows.sort(key=lambda item: item["id"]) + token = secrets.token_urlsafe(24) + now = self._now() + preview = { + "schema_version": "1.0.0", + "confirmation_token": token, + "scope": scope.as_dict(), + "reason": reason, + "created_at": _iso(now), + "expires_at": _iso(now + timedelta(minutes=15)), + "confirmed_at": None, + "impact": { + "count": len(rows), + "record_ids": [row["id"] for row in rows], + "states": {row["id"]: row["state"] for row in rows}, + "revisions": {row["id"]: row["revision"] for row in rows}, + "checksums": { + row["id"]: self._file_checksum(self.record_path(row["id"])) for row in rows + }, + }, + } + self._atomic_json(self.paths.previews / f"{token}.json", preview) + return preview + + def forget_confirm(self, confirmation_token: str, *, scope: Scope) -> dict[str, Any]: + if not confirmation_token or not re.fullmatch(r"[A-Za-z0-9_-]+", confirmation_token): + raise ConfirmationRequired("a valid preview confirmation token is required") + path = self.paths.previews / f"{confirmation_token}.json" + if not path.is_file(): + raise ConfirmationRequired("preview confirmation token is missing or expired") + preview = self._read_json(path) + if preview.get("scope") != scope.as_dict(): + raise MemoryNotFound("memory not found") + if preview.get("confirmed_at"): + raise ConfirmationRequired("preview confirmation token was already consumed") + if _parse_time(preview["expires_at"]) < self._now(): + raise ConfirmationRequired("preview confirmation token expired") + for record_id in preview["impact"]["record_ids"]: + self.get(record_id, scope=scope, include_inactive=True) + expected = preview["impact"]["checksums"][record_id] + if self._file_checksum(self.record_path(record_id)) != expected: + raise ConfirmationRequired("preview is stale; canonical record changed") + + now = self._now() + receipt_id = self._receipt_id("forget", confirmation_token) + receipt = self._new_receipt(receipt_id, "forget", preview["impact"]["record_ids"]) + self._write_receipt(receipt) + try: + fence_checksums: dict[str, str] = {} + for record_id in preview["impact"]["record_ids"]: + record = self._read_record(record_id) + fence = { + "schema_version": "1.0.0", + "record_id": record_id, + "scope": scope.as_dict(), + "reason": preview["reason"], + "forgotten_at": _iso(now), + "record_revision": record["revision"], + "preview_token_hash": _sha256_bytes(confirmation_token.encode()), + "active": True, + "restored_at": None, + "restore_evidence": None, + } + fence_path = self.paths.fences / f"{record_id}.md" + fence_checksums[record_id] = self._write_markdown_json( + fence_path, FENCE_MARKER, f"AKM Forget Fence {record_id}", fence + ) + readback = self._read_markdown_json(fence_path, FENCE_MARKER) + if readback != fence: + raise OSError(f"forget fence readback mismatch: {record_id}") + preview["confirmed_at"] = _iso(now) + self._atomic_json(path, preview) + receipt.update( + canonical_status="written", + canonical_checksum=fence_checksums, + revision=max(preview["impact"]["revisions"].values()), + ) + self._write_receipt(receipt) + except Exception as exc: + receipt.update(canonical_status="failed", failure=str(exc), reconcile_status="manual") + self._write_receipt(receipt) + raise MemoryWriteError("forget fence write/readback failed", receipt_id) from exc + self._finish_derived_write(receipt, receipt_id) + first = preview["impact"]["record_ids"][0] + return { + "forgotten_count": len(preview["impact"]["record_ids"]), + "record_ids": preview["impact"]["record_ids"], + "readback": self.get(first, scope=scope, include_inactive=True), + "receipt": receipt, + } + + def restore_forgotten( + self, + record_id: str, + *, + scope: Scope, + decision_evidence: str, + ) -> dict[str, Any]: + record = self.get(record_id, scope=scope, include_inactive=True) + if record["state"] != "forgotten": + raise ValidationError("record is not forgotten") + fence_path = self.paths.fences / f"{record_id}.md" + fence = self._read_markdown_json(fence_path, FENCE_MARKER) + fence["active"] = False + fence["restored_at"] = _iso(self._now()) + fence["restore_evidence"] = self._required_text(decision_evidence, "decision_evidence") + receipt_id = self._receipt_id("restore", record_id) + receipt = self._new_receipt(receipt_id, "restore", [record_id]) + self._write_receipt(receipt) + try: + checksum = self._write_markdown_json( + fence_path, FENCE_MARKER, f"AKM Forget Fence {record_id}", fence + ) + if self._read_markdown_json(fence_path, FENCE_MARKER) != fence: + raise OSError(f"restore fence readback mismatch: {record_id}") + receipt.update( + canonical_status="written", + canonical_checksum=checksum, + revision=record["revision"], + ) + self._write_receipt(receipt) + except Exception as exc: + receipt.update(canonical_status="failed", failure=str(exc), reconcile_status="manual") + self._write_receipt(receipt) + raise MemoryWriteError("restore fence write/readback failed", receipt_id) from exc + self._finish_derived_write(receipt, receipt_id) + return { + "record": self.get(record_id, scope=scope, include_inactive=True), + "fence": fence, + "receipt": receipt, + } + + def extract_local( + self, + input_text: str, + *, + scope: Scope, + persist: bool = False, + ) -> ExtractionOutcome: + input_text = self._required_text(input_text, "input_text") + candidates: list[dict[str, Any]] = [] + for line_number, line in enumerate(input_text.splitlines(), start=1): + match = re.match(r"^\s*(?:remember|memory)\s*:\s*(.+?)\s*$", line, re.IGNORECASE) + if not match: + continue + text = match.group(1) + candidate = { + "text": text, + "entity": _slug(text), + "evidence": {"extractor": "local-explicit-marker-v1", "line": line_number}, + } + if persist: + remembered = self.remember( + text, + scope=scope, + entity=candidate["entity"], + source_path="runtime-input://explicit-marker", + source_lineage=[f"local-input-line:{line_number}"], + extraction_evidence=candidate["evidence"], + ) + candidate = remembered["record"] + candidates.append(candidate) + if not candidates: + return ExtractionOutcome("nothing_salient", []) + return ExtractionOutcome("success", candidates) + + def ingest_provider_boundary( + self, + outcome: str, + *, + payload: Mapping[str, Any] | None, + scope: Scope, + persist: bool = False, + ) -> ExtractionOutcome: + if outcome not in EXTRACTION_OUTCOMES: + raise ValidationError(f"unknown provider outcome: {outcome}") + if outcome != "success": + if payload and payload.get("candidates"): + raise ValidationError("provider outcome may not carry candidates unless it is success") + return ExtractionOutcome(outcome, [], detail=str(payload.get("detail")) if payload else None) + if not isinstance(payload, Mapping) or not isinstance(payload.get("candidates"), list): + return ExtractionOutcome("malformed_output", [], "success payload lacks candidates list") + candidates: list[dict[str, Any]] = [] + for index, raw in enumerate(payload["candidates"]): + if not isinstance(raw, Mapping) or not isinstance(raw.get("text"), str): + return ExtractionOutcome("malformed_output", [], f"candidate {index} is malformed") + text = raw["text"].strip() + if not text: + return ExtractionOutcome("malformed_output", [], f"candidate {index} is empty") + candidate = {"text": text, "entity": str(raw.get("entity") or _slug(text))} + if persist: + candidate = self.remember( + text, + scope=scope, + entity=candidate["entity"], + source_path="provider-boundary://supplied-output", + source_lineage=["explicit-provider-boundary"], + extraction_evidence={"provider_boundary": True, "candidate_index": index}, + )["record"] + candidates.append(candidate) + return ExtractionOutcome("success" if candidates else "nothing_salient", candidates) + + def context_pack( + self, + *, + scope: Scope, + entity_terms: Sequence[str], + allowed_terms: Sequence[str], + session_id: str, + since: str | None = None, + max_count: int = 5, + token_budget: int = 400, + external_model: bool = False, + deliver: bool = False, + ) -> dict[str, Any]: + session_id = self._required_text(session_id, "session_id") + if max_count < 1 or max_count > 20: + raise ValidationError("max_count must be between 1 and 20") + if token_budget < 1 or token_budget > 4000: + raise ValidationError("token_budget must be between 1 and 4000") + terms = [self._required_text(term, "entity term") for term in entity_terms] + allow = {self._required_text(term, "allowed term").casefold() for term in allowed_terms} + if not terms or any(term.casefold() not in allow for term in terms): + self._append_usage( + phase="attempted", record_ids=[], scope=scope, session_id=session_id, detail="term-not-allowlisted" + ) + return { + "status": "HOLD", + "reason": "entity/project term is not allowlisted", + "items": [], + "token_estimate": 0, + "suppressed_duplicates": [], + "next_cursor": since, + } + since_time = _parse_time(since) if since else None + all_rows = self.recall(scope=scope, external_model=external_model, max_count=100) + attempted = [ + row + for row in all_rows + if any( + term.casefold() + in " ".join((row.get("entity", ""), row.get("safe_summary", ""))).casefold() + for term in terms + ) + and (since_time is None or _parse_time(row["modified_at"]) > since_time) + ] + self._append_usage( + phase="attempted", + record_ids=[row["id"] for row in attempted], + scope=scope, + session_id=session_id, + ) + session_slug = _slug(session_id)[:48] + session_hash = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:16] + cursor_path = self.paths.cursors / f"{session_slug}-{session_hash}.json" + cursor = self._read_json(cursor_path) if cursor_path.is_file() else {"delivered": []} + delivered_identities = set(cursor.get("delivered", [])) + suppressed: list[str] = [] + selected: list[dict[str, Any]] = [] + token_total = 0 + entity_counts: dict[str, int] = {} + for row in attempted: + identity = f"{row['id']}@{row['revision']}" + if identity in delivered_identities: + suppressed.append(identity) + continue + entity_counts[row["entity"]] = entity_counts.get(row["entity"], 0) + 1 + for row in attempted: + identity = f"{row['id']}@{row['revision']}" + if identity in delivered_identities: + continue + conflict = "HOLD" if entity_counts.get(row["entity"], 0) > 1 else "none" + item = { + "identity": identity, + "pointer": f"{row['source']['path']}#memory:{row['id']}", + "safe_summary": row["safe_summary"], + "freshness": { + "modified_at": row["modified_at"], + "expires_at": row["expires_at"], + }, + "conflict": conflict, + "direct_read": len(row["text"]) > SAFE_SUMMARY_LIMIT, + } + estimate = max(1, math.ceil(len(_canonical_json(item)) / 4)) + if len(selected) >= max_count or token_total + estimate > token_budget: + continue + selected.append(item) + token_total += estimate + self._append_usage( + phase="selected", + record_ids=[item["identity"].split("@", 1)[0] for item in selected], + scope=scope, + session_id=session_id, + ) + if deliver: + delivered_identities.update(item["identity"] for item in selected) + self._atomic_json( + cursor_path, + { + "session_id": session_id, + "delivered": sorted(delivered_identities), + "updated_at": _iso(self._now()), + }, + ) + self._append_usage( + phase="delivered", + record_ids=[item["identity"].split("@", 1)[0] for item in selected], + scope=scope, + session_id=session_id, + ) + return { + "status": "ready", + "items": selected, + "token_estimate": token_total, + "suppressed_duplicates": suppressed, + "next_cursor": max((row["modified_at"] for row in attempted), default=since), + } + + def delta_recall(self, *, scope: Scope, cursor: str | None = None) -> dict[str, Any]: + self._assert_root_identity() + after, seen_at_boundary, opaque_cursor = _decode_delta_cursor(cursor) + snapshot_at = self._now() + if after is not None and after > snapshot_at: + raise ValidationError("delta cursor is in the future") + events: list[dict[str, Any]] = [] + records, _ = self._load_records() + for record in records.values(): + if not _scope_matches(record.get("scope", {}), scope): + continue + for ordinal, raw in enumerate(record.get("history", [])): + at = _parse_time(raw["at"]) + event = { + "record_id": record["id"], + "change": raw["change"], + "at": raw["at"], + "revision": record["revision"], + } + event["_cursor_id"] = _delta_event_id(f"record:{record['id']}", ordinal, event) + if at <= snapshot_at: + events.append(event) + if record.get("expires_at") and record.get("state") == "active": + expires = _parse_time(record["expires_at"]) + event = { + "record_id": record["id"], + "change": "expired", + "at": record["expires_at"], + "revision": record["revision"], + } + event["_cursor_id"] = _delta_event_id(f"expiry:{record['id']}", 0, event) + if expires <= snapshot_at: + events.append(event) + fences, _ = self._load_fences() + for fence in fences.values(): + if not fence.get("active") or not _scope_matches(fence.get("scope", {}), scope): + continue + at = _parse_time(fence["forgotten_at"]) + event = { + "record_id": fence["record_id"], + "change": "forgotten", + "at": fence["forgotten_at"], + "revision": fence["record_revision"], + } + event["_cursor_id"] = _delta_event_id( + f"fence:{fence['record_id']}@{fence['record_revision']}", 0, event + ) + if at <= snapshot_at: + events.append(event) + events.sort(key=lambda item: (item["at"], item["record_id"], item["change"], item["_cursor_id"])) + delivered: list[dict[str, Any]] = [] + for event in events: + event_at = _parse_time(event["at"]) + if after is not None: + if event_at < after: + continue + if event_at == after and (not opaque_cursor or event["_cursor_id"] in seen_at_boundary): + continue + delivered.append({key: value for key, value in event.items() if key != "_cursor_id"}) + boundary_seen = [ + event["_cursor_id"] + for event in events + if _parse_time(event["at"]) == snapshot_at + ] + return {"events": delivered, "next_cursor": _encode_delta_cursor(snapshot_at, boundary_seen)} + + def queue_promotion( + self, + record_id: str, + *, + scope: Scope, + decision_evidence: str, + ) -> dict[str, Any]: + record = self.get(record_id, scope=scope, include_inactive=True) + if record["state"] != "candidate": + raise ValidationError("promotion queue accepts only candidates") + destinations = { + "preference": "30-context/users/ (ROUTER review required)", + "safety": "30-context/domains/ (operator approval and ROUTER review required)", + "procedure": "50-procedures/playbooks/ (ROUTER review required)", + "event": "30-context/projects/ (ROUTER review required)", + } + queued = { + "schema_version": "1.0.0", + "record_id": record_id, + "candidate_revision": record["revision"], + "candidate_checksum": self._file_checksum(self.record_path(record_id)), + "recommended_destination": destinations.get( + record["kind"], "20-knowledge/ or 30-context/ (ROUTER decision required)" + ), + "decision_evidence": self._required_text(decision_evidence, "decision_evidence"), + "source": record["source"], + "scope": record["scope"], + "status": "queued", + "canonical_write_performed": False, + "created_at": _iso(self._now()), + } + self._atomic_json(self.paths.promotion_queue / f"{record_id}.json", queued) + return queued + + def status(self, *, scope: Scope | None = None) -> dict[str, Any]: + counts: dict[str, int] = {} + for row in self._build_state()["records"].values(): + if scope is not None and not _scope_matches(row.get("scope", {}), scope): + continue + counts[row["state"]] = counts.get(row["state"], 0) + 1 + doctor = self.doctor() + return { + "canonical_authority": "AKM Markdown and original sources", + "derived_state": str(self.paths.derived_state), + "counts": counts, + "unresolved_receipts": len(doctor["unresolved_receipts"]), + "global_injection": "HOLD", + "shadow_extraction": True, + } + + def rebuild(self) -> dict[str, Any]: + state = self._build_state() + self._write_derived_state(state) + return { + "records": len(state["records"]), + "malformed_records": state["diagnostics"]["malformed_records"], + "malformed_fences": state["diagnostics"]["malformed_fences"], + "derived_state": str(self.paths.derived_state), + } + + def reconcile(self) -> dict[str, Any]: + self._assert_root_identity() + rebuild = self.rebuild() + if rebuild["malformed_records"] or rebuild["malformed_fences"]: + raise ValidationError("reconcile stopped because rebuild found malformed durable artifacts") + reconciled: list[str] = [] + for path in sorted(self.paths.receipts.glob("*.json")): + receipt = self._read_json(path) + if receipt.get("reconcile_status") != "retry_required": + continue + receipt["derived_status"] = "written" + receipt["reconcile_status"] = "reconciled" + receipt["reconciled_at"] = _iso(self._now()) + receipt["failure"] = None + self._atomic_json(path, receipt) + reconciled.append(receipt["id"]) + return {"reconciled_receipts": reconciled, "derived_state": str(self.paths.derived_state)} + + def doctor(self) -> dict[str, Any]: + self._assert_root_identity() + canonical, malformed_records = self._load_records() + fences, malformed_fences = self._load_fences() + derived_state_error: str | None = None + try: + derived = self._read_json(self.paths.derived_state) if self.paths.derived_state.is_file() else { + "records": {}, "checksums": {} + } + if canonical and not self.paths.derived_state.is_file(): + derived_state_error = "missing-derived-state" + except (OSError, json.JSONDecodeError, ValidationError): + derived = {"records": {}, "checksums": {}} + derived_state_error = "malformed-derived-state" + derived_records = derived.get("records", {}) if isinstance(derived.get("records"), dict) else {} + derived_checksums = derived.get("checksums", {}) if isinstance(derived.get("checksums"), dict) else {} + canonical_ids = set(canonical) + db_only = sorted(set(derived_records) - canonical_ids) + canonical_only = sorted(canonical_ids - set(derived_records)) + expected_records = self._build_state()["records"] + canonical_drift = sorted( + record_id + for record_id in canonical_ids.intersection(derived_records) + if derived_records[record_id] != expected_records[record_id] + ) + missing_scope = sorted( + record_id + for record_id, row in {**canonical, **derived_records}.items() + if not isinstance(row.get("scope"), Mapping) or not row.get("scope", {}).get("source_id") + ) + checksum_drift = sorted( + record_id + for record_id in canonical_ids.intersection(derived_checksums) + if derived_checksums[record_id] != self._file_checksum(self.record_path(record_id)) + ) + unresolved_receipts: list[str] = [] + for path in sorted(self.paths.receipts.glob("*.json")): + try: + receipt = self._read_json(path) + except (OSError, json.JSONDecodeError): + unresolved_receipts.append(path.stem) + continue + if receipt.get("reconcile_status") in {"retry_required", "manual"}: + unresolved_receipts.append(str(receipt.get("id", path.stem))) + expired_but_recalled: set[str] = set() + if self.paths.usage_audit.is_file(): + for line in self._read_text(self.paths.usage_audit).splitlines(): + if not line.strip(): + continue + try: + usage = json.loads(line) + except json.JSONDecodeError: + continue + if usage.get("phase") != "delivered": + continue + used_at = _parse_time(usage["at"]) + for record_id in usage.get("record_ids", []): + record = canonical.get(record_id) + if record and record.get("expires_at") and used_at >= _parse_time(record["expires_at"]): + expired_but_recalled.add(record_id) + malformed_fence_paths = list(malformed_fences) + for record_id, fence in fences.items(): + if record_id not in canonical or fence.get("record_id") != record_id: + malformed_fence_paths.append(str(self.paths.fences / f"{record_id}.md")) + return { + "ok": not any( + ( + db_only, + canonical_only, + canonical_drift, + derived_state_error, + missing_scope, + checksum_drift, + unresolved_receipts, + malformed_records, + malformed_fence_paths, + expired_but_recalled, + ) + ), + "orphan": db_only, + "db_only": db_only, + "canonical_only": canonical_only, + "canonical_drift": canonical_drift, + "derived_state_error": derived_state_error, + "expired_but_recalled": sorted(expired_but_recalled), + "missing_scope": missing_scope, + "unresolved_receipts": sorted(unresolved_receipts), + "malformed_records": malformed_records, + "malformed_fences": sorted(set(malformed_fence_paths)), + "checksum_drift": checksum_drift, + } + + def read_receipt(self, receipt_id: str) -> dict[str, Any]: + if not re.fullmatch(r"rcpt_[a-f0-9]{24}", receipt_id): + raise ValidationError("invalid receipt id") + path = self.paths.receipts / f"{receipt_id}.json" + if not path.is_file(): + raise MemoryNotFound("receipt not found") + return self._read_json(path) + + def _append_usage( + self, + *, + phase: str, + record_ids: Sequence[str], + scope: Scope, + session_id: str, + detail: str | None = None, + ) -> None: + if phase not in {"attempted", "selected", "delivered"}: + raise ValidationError("invalid usage phase") + event = { + "schema_version": "1.0.0", + "phase": phase, + "record_ids": list(record_ids), + "scope": scope.as_dict(), + "session_id": session_id, + "at": _iso(self._now()), + "detail": detail, + } + self._append_text(self.paths.usage_audit, _canonical_json(event) + "\n") + + def _write_mutation(self, operation: str, record: dict[str, Any]) -> dict[str, Any]: + receipt_id = self._receipt_id(operation, record["id"]) + receipt = self._new_receipt(receipt_id, operation, [record["id"]]) + self._write_receipt(receipt) + try: + checksum = self._write_record(record) + receipt.update( + canonical_status="written", + canonical_checksum=checksum, + revision=record["revision"], + ) + self._write_receipt(receipt) + except Exception as exc: + receipt.update(canonical_status="failed", failure=str(exc), reconcile_status="manual") + self._write_receipt(receipt) + raise MemoryWriteError(f"{operation} Markdown write failed", receipt_id) from exc + self._finish_derived_write(receipt, receipt_id) + return {"record": record, "receipt": receipt} + + def _finish_derived_write(self, receipt: dict[str, Any], receipt_id: str) -> None: + try: + self._write_derived_state(self._build_state()) + except Exception as exc: + receipt.update( + derived_status="failed", + failure=str(exc), + reconcile_status="retry_required", + completed_at=None, + ) + self._write_receipt(receipt) + raise MemoryWriteError( + "canonical Markdown was written but derived state failed; reconcile required", + receipt_id, + ) from exc + receipt.update( + derived_status="written", + reconcile_status="not_required", + failure=None, + completed_at=_iso(self._now()), + ) + self._write_receipt(receipt) + + def _completed_noop_receipt( + self, operation: str, record: Mapping[str, Any], path: Path + ) -> dict[str, Any]: + receipt_id = self._receipt_id(operation, record["id"]) + receipt = self._new_receipt(receipt_id, operation, [record["id"]]) + receipt.update( + canonical_status="unchanged", + canonical_checksum=self._file_checksum(path), + revision=record["revision"], + ) + self._write_receipt(receipt) + self._finish_derived_write(receipt, receipt_id) + return receipt + + def _new_receipt(self, receipt_id: str, operation: str, record_ids: Sequence[str]) -> dict[str, Any]: + return { + "schema_version": "1.0.0", + "id": receipt_id, + "operation": operation, + "record_ids": list(record_ids), + "canonical_status": "pending", + "derived_status": "pending", + "canonical_checksum": None, + "revision": None, + "failure": None, + "reconcile_status": "pending", + "created_at": _iso(self._now()), + "completed_at": None, + } + + def _receipt_id(self, operation: str, identity: str) -> str: + entropy = secrets.token_hex(8) + digest = hashlib.sha256(f"{operation}\0{identity}\0{_iso(self._now())}\0{entropy}".encode()).hexdigest() + return "rcpt_" + digest[:24] + + def _write_receipt(self, receipt: Mapping[str, Any]) -> None: + self._atomic_json(self.paths.receipts / f"{receipt['id']}.json", receipt) + + def _write_record(self, record: Mapping[str, Any]) -> str: + self._validate_record(record) + return self._write_markdown_json( + self.record_path(str(record["id"])), + RECORD_MARKER, + f"AKM Memory Record {record['id']}", + record, + ) + + def _read_record(self, record_id: str) -> dict[str, Any]: + path = self.record_path(record_id) + if not path.is_file(): + raise MemoryNotFound("memory not found") + record = self._read_markdown_json(path, RECORD_MARKER) + self._validate_record(record) + return record + + def _load_records(self) -> tuple[dict[str, dict[str, Any]], list[str]]: + records: dict[str, dict[str, Any]] = {} + malformed: list[str] = [] + for path in sorted(self.paths.records.glob("*.md")): + if path.is_symlink() or not path.is_file(): + malformed.append(str(path)) + continue + try: + record = self._read_markdown_json(path, RECORD_MARKER) + self._validate_record(record) + if path.stem != record["id"] or record["id"] in records: + raise ValidationError("record filename/id mismatch or duplicate") + records[record["id"]] = record + except (OSError, json.JSONDecodeError, ValidationError): + malformed.append(str(path)) + return records, malformed + + def _load_fences(self) -> tuple[dict[str, dict[str, Any]], list[str]]: + fences: dict[str, dict[str, Any]] = {} + malformed: list[str] = [] + for path in sorted(self.paths.fences.glob("*.md")): + if path.is_symlink() or not path.is_file(): + malformed.append(str(path)) + continue + try: + fence = self._read_markdown_json(path, FENCE_MARKER) + required = { + "record_id", + "scope", + "forgotten_at", + "record_revision", + "active", + } + if not required.issubset(fence) or path.stem != fence["record_id"]: + raise ValidationError("malformed forget fence") + Scope.from_mapping(fence["scope"]) + _parse_time(fence["forgotten_at"]) + if not isinstance(fence["active"], bool): + raise ValidationError("forget fence active must be boolean") + fences[fence["record_id"]] = fence + except (OSError, json.JSONDecodeError, ValidationError, TypeError): + malformed.append(str(path)) + return fences, malformed + + def _build_state(self) -> dict[str, Any]: + self._assert_root_identity() + records, malformed_records = self._load_records() + fences, malformed_fences = self._load_fences() + effective: dict[str, dict[str, Any]] = {} + for record_id, raw in records.items(): + record = json.loads(json.dumps(raw)) + if ( + record["state"] == "active" + and record.get("expires_at") + and not record.get("stable_authority") + and _parse_time(record["expires_at"]) <= self._now() + ): + record["state"] = "expired" + fence = fences.get(record_id) + if fence and fence.get("active"): + record["state"] = "forgotten" + record["forgotten_at"] = fence["forgotten_at"] + effective[record_id] = record + checksums = { + record_id: self._file_checksum(self.record_path(record_id)) for record_id in records + } + return { + "schema_version": "1.0.0", + "generated_at": _iso(self._now()), + "authority": "runtime Markdown records and fences; AKM sources remain claim authority", + "records": effective, + "checksums": checksums, + "diagnostics": { + "malformed_records": malformed_records, + "malformed_fences": malformed_fences, + }, + } + + def _write_derived_state(self, state: dict[str, Any]) -> None: + self._atomic_json(self.paths.derived_state, state) + + def _validate_record(self, record: Mapping[str, Any]) -> None: + missing = RECORD_FIELDS - set(record) + if missing: + raise ValidationError(f"memory record missing fields: {sorted(missing)}") + unexpected = set(record) - RECORD_FIELDS - {"forgotten_at"} + if unexpected: + raise ValidationError(f"memory record has unexpected fields: {sorted(unexpected)}") + if record.get("schema_version") != "1.0.0": + raise ValidationError("unsupported memory record schema version") + self._validate_id(str(record["id"])) + if record["state"] not in STATES: + raise ValidationError("invalid lifecycle state") + if record["kind"] not in KINDS or record["visibility"] not in VISIBILITIES: + raise ValidationError("invalid kind or visibility") + if ( + not isinstance(record["revision"], int) + or isinstance(record["revision"], bool) + or record["revision"] < 1 + ): + raise ValidationError("revision must be a positive integer") + text = self._required_text(record["text"], "text") + summary = self._required_text(record["safe_summary"], "safe_summary") + entity = self._required_text(record["entity"], "entity") + if len(summary) > SAFE_SUMMARY_LIMIT: + raise ValidationError("safe_summary exceeds the schema limit") + if len(entity) > 200: + raise ValidationError("entity exceeds the schema limit") + if not isinstance(record["egress_allowed"], bool) or not isinstance( + record["stable_authority"], bool + ): + raise ValidationError("egress_allowed and stable_authority must be boolean") + if _safe_summary(text) != summary: + raise ValidationError("safe_summary does not match the bounded text summary") + Scope.from_mapping(record["scope"]) + created_at = _parse_time(record["created_at"]) + modified_at = _parse_time(record["modified_at"]) + if modified_at < created_at: + raise ValidationError("modified_at may not precede created_at") + if record.get("expires_at"): + _parse_time(record["expires_at"]) + if record.get("forgotten_at"): + _parse_time(record["forgotten_at"]) + for relation in ("supersedes", "superseded_by"): + if record.get(relation) is not None: + self._validate_id(record[relation]) + if record.get("decision_evidence") is not None and not isinstance( + record["decision_evidence"], Mapping + ): + raise ValidationError("decision_evidence must be an object or null") + source = record.get("source") + if not isinstance(source, Mapping) or not source.get("path") or not source.get("lineage"): + raise ValidationError("source path and lineage are required") + if set(source) != SOURCE_FIELDS: + raise ValidationError("source must contain exactly path and lineage") + self._required_text(source["path"], "source path") + if not isinstance(source["lineage"], list) or not source["lineage"] or any( + not isinstance(item, str) or not item.strip() for item in source["lineage"] + ): + raise ValidationError("source lineage must be a non-empty string list") + if not isinstance(record["history"], list) or not record["history"]: + raise ValidationError("history must be a non-empty list") + for event in record["history"]: + if not isinstance(event, Mapping): + raise ValidationError("history event must be an object") + if not {"change", "at", "evidence"}.issubset(event) or set(event) - HISTORY_FIELDS: + raise ValidationError("history event fields do not match the schema") + if event["change"] not in HISTORY_CHANGES: + raise ValidationError("invalid history change") + _parse_time(event["at"]) + self._required_text(event["evidence"], "history evidence") + if "detail" in event and not isinstance(event["detail"], str): + raise ValidationError("history detail must be a string") + if "related_record_id" in event: + self._validate_id(event["related_record_id"]) + if record.get("stable_authority") and record.get("expires_at"): + raise ValidationError("stable authority may not expire") + if record.get("stable_authority") and record.get("kind") not in STABLE_AUTHORITY_KINDS: + raise ValidationError("stable authority is limited to preference and safety records") + + @staticmethod + def _validate_id(record_id: str) -> None: + if not re.fullmatch(r"mem_[a-z0-9_-]{3,80}", record_id): + raise ValidationError("invalid memory id") + + @staticmethod + def _required_text(value: str, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValidationError(f"{field} is required") + return value.strip() + + def _read_markdown_json(self, path: Path, marker: str) -> dict[str, Any]: + text = self._read_text(path) + if marker not in text: + raise ValidationError(f"missing marker in {path}") + match = JSON_BLOCK.search(text) + if not match: + raise ValidationError(f"missing JSON block in {path}") + payload = json.loads(match.group("payload")) + if not isinstance(payload, dict): + raise ValidationError(f"JSON block must be an object in {path}") + return payload + + def _write_markdown_json( + self, + path: Path, + marker: str, + title: str, + value: Mapping[str, Any], + ) -> str: + body = ( + f"{marker}\n# {title}\n\n" + "This machine-readable Markdown artifact is local runtime state. " + "AKM source Markdown remains authoritative for claims.\n\n" + "```json\n" + + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + "\n```\n" + ) + self._atomic_text(path, body) + return self._file_checksum(path) + + def _read_json(self, path: Path) -> dict[str, Any]: + value = json.loads(self._read_text(path)) + if not isinstance(value, dict): + raise ValidationError(f"expected JSON object: {path}") + return value + + def _atomic_json(self, path: Path, value: Mapping[str, Any]) -> None: + self._atomic_text(path, json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n") + + def _atomic_text(self, path: Path, text: str) -> None: + parent_fd, leaf = self._secure_parent_fd(path) + temp_name = f".{leaf}.{secrets.token_hex(12)}" + descriptor: int | None = None + try: + descriptor = os.open( + temp_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=parent_fd, + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, leaf, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + os.fsync(parent_fd) + except Exception as exc: + if descriptor is not None: + os.close(descriptor) + try: + os.unlink(temp_name, dir_fd=parent_fd) + except FileNotFoundError: + pass + if isinstance(exc, OSError): + raise ValidationError(f"unsafe runtime path: {path}") from exc + raise + finally: + os.close(parent_fd) + + def _append_text(self, path: Path, text: str) -> None: + parent_fd, leaf = self._secure_parent_fd(path) + descriptor: int | None = None + try: + descriptor = os.open( + leaf, + os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, + 0o600, + dir_fd=parent_fd, + ) + file_stat = os.fstat(descriptor) + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1: + raise ValidationError(f"runtime path is not a regular file: {path}") + with os.fdopen(descriptor, "a", encoding="utf-8") as handle: + descriptor = None + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + except OSError as exc: + raise ValidationError(f"unsafe runtime path: {path}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_fd) + + def _read_text(self, path: Path) -> str: + parent_fd, leaf = self._secure_parent_fd(path) + descriptor: int | None = None + try: + descriptor = os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd) + file_stat = os.fstat(descriptor) + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1: + raise ValidationError(f"runtime path is not a regular file: {path}") + with os.fdopen(descriptor, "r", encoding="utf-8") as handle: + descriptor = None + return handle.read() + except OSError as exc: + raise ValidationError(f"unsafe runtime path: {path}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_fd) + + def _read_bytes(self, path: Path) -> bytes: + parent_fd, leaf = self._secure_parent_fd(path) + descriptor: int | None = None + try: + descriptor = os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd) + file_stat = os.fstat(descriptor) + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1: + raise ValidationError(f"runtime path is not a regular file: {path}") + with os.fdopen(descriptor, "rb") as handle: + descriptor = None + return handle.read() + except OSError as exc: + raise ValidationError(f"unsafe runtime path: {path}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_fd) + + def _secure_parent_fd(self, path: Path) -> tuple[int, str]: + """Open each runtime path component without following symlinks. + + The runtime is single-writer, but an operator or local process may replace + a child directory after construction. Directory-fd traversal plus + ``O_NOFOLLOW`` keeps every later read/write inside the originally selected + runtime root instead of trusting a one-time ``Path.resolve`` check. + """ + + try: + relative = path.relative_to(self.paths.root) + except ValueError as exc: + raise ValidationError(f"runtime path escapes selected root: {path}") from exc + if not relative.parts or any(part in {"", ".", ".."} for part in relative.parts): + raise ValidationError(f"invalid runtime path: {path}") + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + current_fd: int | None = None + try: + current_fd = os.open(self.paths.root, flags) + root_stat = os.fstat(current_fd) + if (root_stat.st_dev, root_stat.st_ino) != self._root_identity: + raise ValidationError("runtime root identity changed after initialization") + for component in relative.parts[:-1]: + next_fd = os.open(component, flags, dir_fd=current_fd) + os.close(current_fd) + current_fd = next_fd + except (OSError, ValidationError) as exc: + if current_fd is not None: + os.close(current_fd) + if isinstance(exc, ValidationError): + raise + raise ValidationError(f"unsafe runtime path: {path}") from exc + assert current_fd is not None + return current_fd, relative.parts[-1] + + def _assert_root_identity(self) -> None: + descriptor: int | None = None + try: + descriptor = os.open( + self.paths.root, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + root_stat = os.fstat(descriptor) + if (root_stat.st_dev, root_stat.st_ino) != self._root_identity: + raise ValidationError("runtime root identity changed after initialization") + for directory in ( + self.paths.records, + self.paths.fences, + self.paths.derived, + self.paths.receipts, + self.paths.previews, + self.paths.promotion_queue, + self.paths.cursors, + self.paths.audit, + ): + relative = directory.relative_to(self.paths.root) + if len(relative.parts) != 1: + raise ValidationError(f"invalid runtime directory layout: {directory}") + child_fd = os.open( + relative.parts[0], + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=descriptor, + ) + os.close(child_fd) + except OSError as exc: + raise ValidationError(f"unsafe runtime root: {self.paths.root}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + + def _file_checksum(self, path: Path) -> str: + return _sha256_bytes(self._read_bytes(path)) + + +def compute_metrics(observations: Sequence[Mapping[str, Any]]) -> dict[str, float | int | None]: + """Compute the fixed evaluation surface without inventing missing denominators.""" + + if not observations: + return { + "false_memory_rate": None, + "duplicate_injection_rate": None, + "conflict_exposure_rate": None, + "usefulness_rate": None, + "token_cost": 0, + "latency_ms": None, + "manual_correction_rate": None, + "source_lineage_recovery_rate": None, + } + total = len(observations) + + def rate(field: str) -> float: + return sum(bool(row.get(field)) for row in observations) / total + + latencies = [float(row["latency_ms"]) for row in observations if row.get("latency_ms") is not None] + return { + "false_memory_rate": rate("false_memory"), + "duplicate_injection_rate": rate("duplicate_injection"), + "conflict_exposure_rate": rate("conflict_exposed"), + "usefulness_rate": rate("useful"), + "token_cost": sum(int(row.get("token_cost", 0)) for row in observations), + "latency_ms": sum(latencies) / len(latencies) if latencies else None, + "manual_correction_rate": rate("manual_correction"), + "source_lineage_recovery_rate": rate("source_lineage_recovered"), + } diff --git a/adapters/hermes/memory/tests/test_memory_runtime.py b/adapters/hermes/memory/tests/test_memory_runtime.py new file mode 100644 index 0000000..351ef31 --- /dev/null +++ b/adapters/hermes/memory/tests/test_memory_runtime.py @@ -0,0 +1,838 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import jsonschema +import pytest + +from adapters.hermes.memory import ( + ConfirmationRequired, + HoldoutEvaluator, + MemoryNotFound, + MemoryRuntime, + MemoryWriteError, + Scope, + ValidationError, + compute_metrics, +) + + +UTC = timezone.utc + + +class Clock: + def __init__(self) -> None: + self.value = datetime(2026, 8, 13, 1, 0, tzinfo=UTC) + + def __call__(self) -> datetime: + return self.value + + def advance(self, **kwargs: int) -> None: + self.value += timedelta(**kwargs) + + +@pytest.fixture +def clock() -> Clock: + return Clock() + + +@pytest.fixture +def scope() -> Scope: + return Scope(source_id="akm", project="runtime-demo", profile="default", user="operator") + + +@pytest.fixture +def runtime(tmp_path: Path, clock: Clock) -> MemoryRuntime: + return MemoryRuntime(tmp_path / "runtime", now=clock) + + +def remember_active( + runtime: MemoryRuntime, + scope: Scope, + text: str, + *, + entity: str, + **kwargs: object, +) -> dict: + result = runtime.remember( + text, + scope=scope, + entity=entity, + source_path="40-memory/user-preferences.md", + source_lineage=["user-stated"], + **kwargs, + ) + runtime.approve( + result["record"]["id"], + scope=scope, + decision_evidence="operator-approved:test", + ) + return runtime.get(result["record"]["id"], scope=scope) + + +def test_schema_accepts_runtime_record(runtime: MemoryRuntime, scope: Scope) -> None: + result = runtime.remember( + "Use concise status updates.", + scope=scope, + entity="status-updates", + kind="preference", + stable_authority=True, + source_path="40-memory/user-preferences.md", + source_lineage=["user-stated"], + ) + schema_path = Path(__file__).parents[4] / "99-system" / "memory-record.schema.json" + schema = json.loads(schema_path.read_text(encoding="utf-8")) + jsonschema.validate(result["record"], schema) + assert result["record"]["state"] == "candidate" + assert result["receipt"]["canonical_status"] == "written" + assert result["receipt"]["derived_status"] == "written" + + +def test_runtime_root_must_stay_outside_an_akm_canonical_vault(tmp_path: Path) -> None: + akm_root = tmp_path / "akm" + (akm_root / "99-system").mkdir(parents=True) + (akm_root / "99-system" / "SCHEMA.md").write_text("# schema\n", encoding="utf-8") + (akm_root / "99-system" / "ROUTER.md").write_text("# router\n", encoding="utf-8") + with pytest.raises(ValidationError, match="outside the AKM canonical vault"): + MemoryRuntime(akm_root / "40-memory" / "runtime") + + +def test_runtime_rejects_post_init_symlink_directory_escape(tmp_path: Path) -> None: + root = tmp_path / "runtime" + outside = tmp_path / "outside" + outside.mkdir() + runtime = MemoryRuntime(root) + runtime.paths.records.rmdir() + runtime.paths.records.symlink_to(outside, target_is_directory=True) + + with pytest.raises(MemoryWriteError) as captured: + runtime.remember( + "Symlink escape must fail closed.", + scope=Scope(source_id="akm"), + entity="symlink-escape", + source_path="30-context/projects/symlink.md", + source_lineage=["adversarial-probe"], + ) + assert list(outside.iterdir()) == [] + receipt = runtime.read_receipt(captured.value.receipt_id) + assert receipt["canonical_status"] == "failed" + assert receipt["reconcile_status"] == "manual" + with pytest.raises(ValidationError, match="unsafe runtime root"): + runtime.status(scope=Scope(source_id="akm")) + + +def test_runtime_rejects_post_init_root_replacement(tmp_path: Path) -> None: + root = tmp_path / "runtime" + moved = tmp_path / "moved-runtime" + outside = tmp_path / "outside-root" + runtime = MemoryRuntime(root) + outside.mkdir() + root.rename(moved) + root.symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValidationError, match="unsafe runtime (?:path|root)|identity changed"): + runtime.status(scope=Scope(source_id="akm")) + assert list(outside.iterdir()) == [] + + +def test_runtime_rejects_usage_audit_hardlink_escape( + runtime: MemoryRuntime, scope: Scope, tmp_path: Path +) -> None: + outside = tmp_path / "outside-audit.jsonl" + outside.write_text("external-prefix\n", encoding="utf-8") + os.link(outside, runtime.paths.usage_audit) + + with pytest.raises(ValidationError, match="regular file"): + runtime.context_pack( + scope=scope, + entity_terms=["atlas"], + allowed_terms=["atlas"], + session_id="hardlink-probe", + ) + assert outside.read_text(encoding="utf-8") == "external-prefix\n" + + +def test_conformance_remember_recall_forget_rebuild_recall( + runtime: MemoryRuntime, scope: Scope +) -> None: + active = remember_active(runtime, scope, "The runtime is local only.", entity="runtime") + assert runtime.recall(scope=scope, query="local")[0]["id"] == active["id"] + + preview = runtime.forget_preview( + scope=scope, + record_ids=[active["id"]], + reason="conformance-test", + ) + assert preview["impact"]["count"] == 1 + result = runtime.forget_confirm(preview["confirmation_token"], scope=scope) + assert result["readback"]["state"] == "forgotten" + schema_path = Path(__file__).parents[4] / "99-system" / "memory-record.schema.json" + jsonschema.validate(result["readback"], json.loads(schema_path.read_text(encoding="utf-8"))) + + runtime.rebuild() + assert runtime.recall(scope=scope, query="local") == [] + assert runtime.get(active["id"], scope=scope, include_inactive=True)["state"] == "forgotten" + + +def test_scope_isolation_and_unauthorized_probes_are_not_found( + runtime: MemoryRuntime, scope: Scope +) -> None: + record = remember_active(runtime, scope, "Private project fact.", entity="secret-project") + wrong = Scope(source_id="akm", project="other", profile="default", user="operator") + + assert runtime.recall(scope=wrong, query="Private") == [] + with pytest.raises(MemoryNotFound): + runtime.get(record["id"], scope=wrong) + with pytest.raises(MemoryNotFound): + runtime.forget_preview(scope=wrong, record_ids=[record["id"]], reason="probe") + + +def test_ordinary_recall_cannot_expose_candidate_state( + runtime: MemoryRuntime, scope: Scope +) -> None: + candidate = runtime.remember( + "Candidate must stay out of ordinary recall.", + scope=scope, + entity="candidate-isolation", + source_path="00-inbox/candidate.md", + source_lineage=["adversarial-audit"], + )["record"] + assert runtime.recall(scope=scope, query="Candidate") == [] + with pytest.raises(MemoryNotFound, match="memory not found"): + runtime.recall(scope=scope, record_id=candidate["id"]) + with pytest.raises(ValidationError, match="active records only"): + runtime.recall(scope=scope, include_states=["candidate"]) + with pytest.raises(MemoryNotFound, match="memory not found"): + runtime.get( + candidate["id"], + scope=scope, + include_inactive=True, + external_model=True, + ) + + +def test_private_visibility_is_separate_from_external_model_egress( + runtime: MemoryRuntime, scope: Scope +) -> None: + private = remember_active( + runtime, + scope, + "A private internal detail.", + entity="private-detail", + visibility="private", + egress_allowed=True, + ) + public = remember_active( + runtime, + scope, + "A publishable detail.", + entity="public-detail", + visibility="public", + egress_allowed=True, + ) + + internal = runtime.recall(scope=scope) + assert {row["id"] for row in internal} == {private["id"], public["id"]} + external = runtime.recall(scope=scope, external_model=True) + assert [row["id"] for row in external] == [public["id"]] + with pytest.raises(MemoryNotFound): + runtime.get(private["id"], scope=scope, external_model=True) + + +def test_ttl_and_stable_authority_never_auto_expire( + runtime: MemoryRuntime, scope: Scope, clock: Clock +) -> None: + expiring = remember_active( + runtime, + scope, + "Temporary launch context.", + entity="launch", + ttl_seconds=60, + ) + with pytest.raises(ValidationError, match="stable authority"): + runtime.remember( + "Never upload secrets.", + scope=scope, + entity="secret-safety", + kind="safety", + stable_authority=True, + ttl_seconds=60, + source_path="40-memory/safety.md", + source_lineage=["operator-approved"], + ) + stable = remember_active( + runtime, + scope, + "Prefer short summaries.", + entity="summary-style", + kind="preference", + stable_authority=True, + ) + + clock.advance(seconds=61) + assert runtime.recall(scope=scope, query="Temporary") == [] + assert runtime.get(expiring["id"], scope=scope, include_inactive=True)["state"] == "expired" + assert runtime.get(stable["id"], scope=scope)["state"] == "active" + + +def test_supersession_is_persisted_and_survives_rebuild( + runtime: MemoryRuntime, scope: Scope +) -> None: + old = remember_active(runtime, scope, "Use option A.", entity="runtime-option") + new = remember_active(runtime, scope, "Use option B.", entity="runtime-option") + + assert runtime.get(old["id"], scope=scope, include_inactive=True)["state"] == "superseded" + assert runtime.get(old["id"], scope=scope, include_inactive=True)["superseded_by"] == new["id"] + runtime.rebuild() + assert [row["id"] for row in runtime.recall(scope=scope, query="option")] == [new["id"]] + + +def test_rejected_lifecycle_is_durable_and_not_recalled( + runtime: MemoryRuntime, scope: Scope +) -> None: + candidate = runtime.remember( + "Unsupported candidate.", + scope=scope, + entity="rejected", + source_path="00-inbox/candidate.md", + source_lineage=["shadow"], + )["record"] + rejected = runtime.reject( + candidate["id"], scope=scope, decision_evidence="direct source did not support it" + )["record"] + assert rejected["state"] == "rejected" + runtime.rebuild() + assert runtime.recall(scope=scope, query="Unsupported") == [] + assert runtime.get(candidate["id"], scope=scope, include_inactive=True)["state"] == "rejected" + with pytest.raises(ValidationError, match="only candidate records"): + runtime.approve(candidate["id"], scope=scope, decision_evidence="cannot undo rejection") + with pytest.raises(ValidationError, match="only candidates"): + runtime.queue_promotion( + candidate["id"], scope=scope, decision_evidence="cannot promote rejected evidence" + ) + + +def test_extraction_outcomes_are_explicit_and_shadow_only( + runtime: MemoryRuntime, scope: Scope +) -> None: + success = runtime.extract_local( + "Remember: Use pointer-first retrieval.\nordinary line", + scope=scope, + persist=True, + ) + assert success.outcome == "success" + assert len(success.candidates) == 1 + candidate = runtime.get(success.candidates[0]["id"], scope=scope, include_inactive=True) + assert candidate["state"] == "candidate" + assert runtime.recall(scope=scope) == [] + + nothing = runtime.extract_local("ordinary line", scope=scope) + assert nothing.outcome == "nothing_salient" + for outcome in ( + "provider_error", + "refusal", + "content_filter", + "malformed_output", + "truncated_output", + ): + observed = runtime.ingest_provider_boundary(outcome, payload=None, scope=scope) + assert observed.outcome == outcome + assert observed.candidates == [] + with pytest.raises(ValidationError, match="provider outcome"): + runtime.ingest_provider_boundary("nothing_salient", payload={"candidates": [1]}, scope=scope) + + +def test_write_receipt_failure_is_non_success_and_reconcilable( + runtime: MemoryRuntime, scope: Scope, monkeypatch: pytest.MonkeyPatch +) -> None: + original = runtime._write_derived_state + + def fail_once(state: dict) -> None: + monkeypatch.setattr(runtime, "_write_derived_state", original) + raise OSError("simulated derived failure") + + monkeypatch.setattr(runtime, "_write_derived_state", fail_once) + with pytest.raises(MemoryWriteError) as captured: + runtime.remember( + "Canonical write must not look successful.", + scope=scope, + entity="receipt", + source_path="30-context/projects/runtime.md", + source_lineage=["operator"], + ) + receipt = runtime.read_receipt(captured.value.receipt_id) + assert receipt["canonical_status"] == "written" + assert receipt["derived_status"] == "failed" + assert receipt["reconcile_status"] == "retry_required" + assert runtime.doctor()["unresolved_receipts"] == [captured.value.receipt_id] + + reconciled = runtime.reconcile() + assert captured.value.receipt_id in reconciled["reconciled_receipts"] + assert runtime.doctor()["unresolved_receipts"] == [] + + +def test_doctor_detects_db_only_drift_missing_scope_malformed_fence_and_expired_use( + runtime: MemoryRuntime, scope: Scope, clock: Clock +) -> None: + record = remember_active( + runtime, + scope, + "Short-lived detail.", + entity="doctor-detail", + ttl_seconds=10, + ) + state = json.loads(runtime.paths.derived_state.read_text(encoding="utf-8")) + ghost = dict(record) + ghost["id"] = "mem_db_only" + ghost["scope"] = {} + state["records"][ghost["id"]] = ghost + runtime.paths.derived_state.write_text(json.dumps(state), encoding="utf-8") + (runtime.paths.fences / "bad.md").write_text("not a valid forget fence", encoding="utf-8") + clock.advance(seconds=11) + runtime._append_usage( + phase="delivered", + record_ids=[record["id"]], + scope=scope, + session_id="doctor-test", + ) + + report = runtime.doctor() + assert "mem_db_only" in report["db_only"] + assert "mem_db_only" in report["missing_scope"] + assert report["malformed_fences"] + assert record["id"] in report["expired_but_recalled"] + + +def test_doctor_detects_checksum_drift(runtime: MemoryRuntime, scope: Scope) -> None: + record = runtime.remember( + "Checksum source.", + scope=scope, + entity="checksum", + source_path="30-context/projects/checksum.md", + source_lineage=["operator"], + )["record"] + path = runtime.record_path(record["id"]) + path.write_text(path.read_text(encoding="utf-8") + "\nmanual drift\n", encoding="utf-8") + assert record["id"] in runtime.doctor()["checksum_drift"] + + +def test_doctor_detects_missing_derived_state_and_rebuild_repairs_it( + runtime: MemoryRuntime, scope: Scope +) -> None: + record = runtime.remember( + "Rebuildable canonical record.", + scope=scope, + entity="rebuild", + source_path="30-context/projects/rebuild.md", + source_lineage=["operator"], + )["record"] + runtime.paths.derived_state.unlink() + report = runtime.doctor() + assert report["derived_state_error"] == "missing-derived-state" + assert report["canonical_only"] == [record["id"]] + assert report["ok"] is False + runtime.rebuild() + assert runtime.doctor()["ok"] is True + + +def test_forget_requires_preview_token_and_exact_scope( + runtime: MemoryRuntime, scope: Scope +) -> None: + first = remember_active(runtime, scope, "First record.", entity="first") + second = remember_active(runtime, scope, "Second record.", entity="second") + with pytest.raises(ConfirmationRequired): + runtime.forget_confirm("missing-token", scope=scope) + with pytest.raises(ValidationError, match="all_in_scope"): + runtime.forget_preview(scope=scope, record_ids=None, reason="mass") + + preview = runtime.forget_preview(scope=scope, all_in_scope=True, reason="mass") + assert preview["impact"]["record_ids"] == sorted([first["id"], second["id"]]) + wrong = Scope(source_id="akm", project="other", profile="default", user="operator") + with pytest.raises(MemoryNotFound): + runtime.forget_confirm(preview["confirmation_token"], scope=wrong) + result = runtime.forget_confirm(preview["confirmation_token"], scope=scope) + assert result["forgotten_count"] == 2 + + +def test_forget_fence_is_reversible_with_explicit_restore_evidence( + runtime: MemoryRuntime, scope: Scope +) -> None: + record = remember_active(runtime, scope, "Restorable record.", entity="restore") + preview = runtime.forget_preview( + scope=scope, record_ids=[record["id"]], reason="mistaken-forget" + ) + runtime.forget_confirm(preview["confirmation_token"], scope=scope) + restored = runtime.restore_forgotten( + record["id"], scope=scope, decision_evidence="operator-reviewed-restore" + ) + assert restored["record"]["state"] == "active" + assert restored["fence"]["active"] is False + assert restored["fence"]["restore_evidence"] == "operator-reviewed-restore" + assert restored["receipt"]["operation"] == "restore" + assert restored["receipt"]["canonical_status"] == "written" + assert restored["receipt"]["derived_status"] == "written" + runtime.rebuild() + assert runtime.recall(scope=scope, query="Restorable")[0]["id"] == record["id"] + + +def test_context_pack_is_bounded_pointer_first_and_suppresses_duplicates( + runtime: MemoryRuntime, scope: Scope +) -> None: + one = remember_active( + runtime, + scope, + "Project Atlas uses pointer-first retrieval with a compact summary.", + entity="atlas", + ) + remember_active( + runtime, + scope, + "Project Borealis is unrelated and must not leak.", + entity="borealis", + ) + + held = runtime.context_pack( + scope=scope, + entity_terms=["Borealis"], + allowed_terms=["Atlas"], + session_id="session-a", + max_count=2, + token_budget=100, + ) + assert held["status"] == "HOLD" + assert held["items"] == [] + + pack = runtime.context_pack( + scope=scope, + entity_terms=["Atlas"], + allowed_terms=["Atlas"], + session_id="session-a", + max_count=1, + token_budget=100, + deliver=True, + ) + assert pack["status"] == "ready" + assert pack["items"][0]["identity"] == f"{one['id']}@{one['revision']}" + assert set(pack["items"][0]) == { + "identity", + "pointer", + "safe_summary", + "freshness", + "conflict", + "direct_read", + } + assert "evidence" not in json.dumps(pack) + assert "usage" not in json.dumps(pack) + + duplicate = runtime.context_pack( + scope=scope, + entity_terms=["Atlas"], + allowed_terms=["Atlas"], + session_id="session-a", + max_count=1, + token_budget=100, + deliver=True, + ) + assert duplicate["items"] == [] + assert duplicate["suppressed_duplicates"] == [f"{one['id']}@{one['revision']}"] + + +def test_context_pack_records_attempted_selected_and_delivered_but_never_embeds_audit( + runtime: MemoryRuntime, scope: Scope +) -> None: + remember_active(runtime, scope, "Atlas context.", entity="atlas") + pack = runtime.context_pack( + scope=scope, + entity_terms=["Atlas"], + allowed_terms=["Atlas"], + session_id="usage-session", + deliver=True, + ) + rows = [json.loads(line) for line in runtime.paths.usage_audit.read_text(encoding="utf-8").splitlines()] + assert [row["phase"] for row in rows] == ["attempted", "selected", "delivered"] + assert "audit" not in pack + + +def test_delta_recall_reports_lifecycle_changes_after_cursor( + runtime: MemoryRuntime, scope: Scope, clock: Clock +) -> None: + first = remember_active(runtime, scope, "Old setting.", entity="setting") + cursor = runtime.delta_recall(scope=scope)["next_cursor"] + clock.advance(seconds=1) + second = remember_active(runtime, scope, "New setting.", entity="setting") + preview = runtime.forget_preview(scope=scope, record_ids=[second["id"]], reason="delta") + runtime.forget_confirm(preview["confirmation_token"], scope=scope) + + delta = runtime.delta_recall(scope=scope, cursor=cursor) + kinds = {event["change"] for event in delta["events"]} + assert {"created", "modified", "superseded", "forgotten"}.issubset(kinds) + assert first["id"] in {event["record_id"] for event in delta["events"]} + + +def test_delta_cursor_delivers_new_same_timestamp_events_once( + runtime: MemoryRuntime, scope: Scope +) -> None: + first = runtime.remember( + "First boundary event.", + scope=scope, + entity="delta-boundary-first", + source_path="30-context/projects/delta-first.md", + source_lineage=["adversarial-audit"], + )["record"] + cursor = runtime.delta_recall(scope=scope)["next_cursor"] + second = runtime.remember( + "Second event at the exact same clock timestamp.", + scope=scope, + entity="delta-boundary-second", + source_path="30-context/projects/delta-second.md", + source_lineage=["adversarial-audit"], + )["record"] + runtime.approve(second["id"], scope=scope, decision_evidence="same timestamp audit") + + delta = runtime.delta_recall(scope=scope, cursor=cursor) + assert first["id"] not in {event["record_id"] for event in delta["events"]} + assert second["id"] in {event["record_id"] for event in delta["events"]} + assert {event["change"] for event in delta["events"]} == {"created", "modified"} + repeated = runtime.delta_recall(scope=scope, cursor=delta["next_cursor"]) + assert repeated["events"] == [] + + +def test_delta_cursor_does_not_replay_prior_event_when_same_record_changes_at_boundary( + runtime: MemoryRuntime, scope: Scope +) -> None: + candidate = runtime.remember( + "Boundary candidate.", + scope=scope, + entity="delta-existing-boundary", + source_path="30-context/projects/delta-existing.md", + source_lineage=["adversarial-audit"], + )["record"] + cursor = runtime.delta_recall(scope=scope)["next_cursor"] + runtime.approve(candidate["id"], scope=scope, decision_evidence="same record boundary") + + delta = runtime.delta_recall(scope=scope, cursor=cursor) + assert [(event["record_id"], event["change"]) for event in delta["events"]] == [ + (candidate["id"], "modified") + ] + + +def test_delta_cursor_rejects_malformed_opaque_payload( + runtime: MemoryRuntime, scope: Scope +) -> None: + with pytest.raises(ValidationError, match="invalid delta cursor"): + runtime.delta_recall(scope=scope, cursor="d1.not-valid-json") + with pytest.raises(ValidationError, match="future"): + runtime.delta_recall(scope=scope, cursor="2099-01-01T00:00:00Z") + with pytest.raises(ValidationError, match="too large"): + runtime.delta_recall(scope=scope, cursor="d1." + ("a" * 262_145)) + + +def test_promotion_queue_recommends_router_destination_without_writing_layers( + runtime: MemoryRuntime, scope: Scope, tmp_path: Path +) -> None: + candidate = runtime.remember( + "A reusable operating preference.", + scope=scope, + entity="promotion", + kind="preference", + source_path="00-inbox/operator-note.md", + source_lineage=["operator"], + )["record"] + queued = runtime.queue_promotion( + candidate["id"], + scope=scope, + decision_evidence="candidate extraction line 1", + ) + assert queued["recommended_destination"].startswith("30-context/") + assert queued["decision_evidence"] == "candidate extraction line 1" + assert runtime.paths.promotion_queue.joinpath(f"{candidate['id']}.json").exists() + assert not (tmp_path / "20-knowledge").exists() + assert not (tmp_path / "30-context").exists() + assert not (tmp_path / "40-memory").exists() + assert not (tmp_path / "50-procedures").exists() + + +def test_holdout_evaluator_keeps_gold_sealed_from_runtime(tmp_path: Path) -> None: + fixture = tmp_path / "fixture.json" + fixture.write_text(json.dumps({"cases": [{"id": "q1", "query": "atlas"}]}), encoding="utf-8") + prepared = HoldoutEvaluator.prepare_fixture(fixture) + assert prepared == {"cases": [{"id": "q1", "query": "atlas"}]} + leaked = tmp_path / "leaked.json" + leaked.write_text(json.dumps({"cases": [], "gold": {"q1": "answer"}}), encoding="utf-8") + with pytest.raises(ValidationError, match="sealed gold"): + HoldoutEvaluator.prepare_fixture(leaked) + + result = tmp_path / "results" / "runtime" / "result.json" + result.parent.mkdir(parents=True) + result.write_text(json.dumps({"results": [{"id": "q1", "selected": ["mem_1"]}]}), encoding="utf-8") + gold = tmp_path / "sealed-gold" / "evaluator" / "gold.json" + gold.parent.mkdir(parents=True) + gold.write_text(json.dumps({"q1": ["mem_1"]}), encoding="utf-8") + score = HoldoutEvaluator.score_result(result, gold) + assert score["exact_match_rate"] == 1.0 + assert "gold" not in result.read_text(encoding="utf-8") + with pytest.raises(ValidationError, match="paths must be isolated"): + HoldoutEvaluator.score_result(result, result) + + +def test_session_cursor_uses_full_identity_not_only_a_lossy_slug( + runtime: MemoryRuntime, scope: Scope +) -> None: + remember_active(runtime, scope, "Atlas detail.", entity="atlas") + first = runtime.context_pack( + scope=scope, + entity_terms=["atlas"], + allowed_terms=["atlas"], + session_id="session/a", + deliver=True, + ) + second = runtime.context_pack( + scope=scope, + entity_terms=["atlas"], + allowed_terms=["atlas"], + session_id="session a", + deliver=True, + ) + assert len(first["items"]) == 1 + assert len(second["items"]) == 1 + + +def test_runtime_validation_rejects_schema_unknown_fields( + runtime: MemoryRuntime, scope: Scope +) -> None: + candidate = runtime.remember( + "Schema drift.", + scope=scope, + entity="schema", + source_path="30-context/projects/schema.md", + source_lineage=["direct-read"], + )["record"] + path = runtime.record_path(candidate["id"]) + text = path.read_text(encoding="utf-8") + payload_text = text.split("```json\n", 1)[1].split("\n```", 1)[0] + payload = json.loads(payload_text) + payload["unknown_field"] = "must fail" + path.write_text( + text.replace(payload_text, json.dumps(payload, sort_keys=True, indent=2)), + encoding="utf-8", + ) + assert str(path) in runtime.rebuild()["malformed_records"] + + +def test_metrics_surface_all_required_measurements() -> None: + metrics = compute_metrics( + [ + { + "false_memory": False, + "duplicate_injection": True, + "conflict_exposed": False, + "useful": True, + "token_cost": 42, + "latency_ms": 7.5, + "manual_correction": False, + "source_lineage_recovered": True, + }, + { + "false_memory": True, + "duplicate_injection": False, + "conflict_exposed": True, + "useful": False, + "token_cost": 10, + "latency_ms": 2.5, + "manual_correction": True, + "source_lineage_recovered": False, + }, + ] + ) + assert metrics == { + "false_memory_rate": 0.5, + "duplicate_injection_rate": 0.5, + "conflict_exposure_rate": 0.5, + "usefulness_rate": 0.5, + "token_cost": 52, + "latency_ms": 5.0, + "manual_correction_rate": 0.5, + "source_lineage_recovery_rate": 0.5, + } + + +def test_cli_json_workflow_and_nonzero_failure(tmp_path: Path) -> None: + root = tmp_path / "cli-runtime" + base = [ + sys.executable, + "-m", + "adapters.hermes.memory", + "--root", + str(root), + "--source-id", + "akm", + "--project", + "cli", + "--profile", + "default", + "--user", + "operator", + ] + remembered = subprocess.run( + base + + [ + "remember", + "--text", + "CLI memory candidate.", + "--entity", + "cli", + "--source-path", + "00-inbox/cli.md", + ], + check=True, + cwd=Path(__file__).parents[4], + capture_output=True, + text=True, + ) + record_id = json.loads(remembered.stdout)["record"]["id"] + approved = subprocess.run( + base + ["approve", "--id", record_id, "--decision-evidence", "cli-operator"], + check=True, + cwd=Path(__file__).parents[4], + capture_output=True, + text=True, + ) + assert json.loads(approved.stdout)["record"]["state"] == "active" + recalled = subprocess.run( + base + ["recall", "--query", "candidate"], + check=True, + cwd=Path(__file__).parents[4], + capture_output=True, + text=True, + ) + assert json.loads(recalled.stdout)["records"][0]["id"] == record_id + state_override = subprocess.run( + base + ["recall", "--include-state", "candidate", "--external-model"], + check=False, + cwd=Path(__file__).parents[4], + capture_output=True, + text=True, + ) + assert state_override.returncode != 0 + assert "unrecognized arguments" in state_override.stderr + failed = subprocess.run( + base + ["forget", "--confirm", "invalid"], + check=False, + cwd=Path(__file__).parents[4], + capture_output=True, + text=True, + ) + assert failed.returncode != 0 + assert json.loads(failed.stderr)["ok"] is False + + +def test_status_is_fail_closed_and_reports_hold(runtime: MemoryRuntime, scope: Scope) -> None: + status = runtime.status(scope=scope) + assert status["global_injection"] == "HOLD" + assert status["canonical_authority"] == "AKM Markdown and original sources" + assert status["counts"] == {}