Document Content

# `am_kv_store` — a versioned key/value tier for agentsmemory

**Status:** proposal, for M · **Wing:** `wing_agentmemories` · **Date:** 2026-08-27
**Grounded in:** live `am_skillset` (40 tools), `main` @ `7e8870a` (v0.0.98), ADR-036, and the palace's own `llm_init` tier.
**Naming:** originally proposed as `am_root`; renamed to `am_kv_store` by M on 2026-08-27 — the tool names the *mechanism*, not the one privileged use.

***

## The one thing that changes the answer

**`am_bootstrap`** **already exists.** It is merged on `main` and it does the half of this proposal that removes the pointer chain.

`internal/mcpserver/bootstrap.go:16` registers `am_bootstrap(wing)`. Its own description:

> Start work in a wing with ONE call. Returns the wing's entry point, the eager tier's content inline, the on-demand tier as pointers with the call that fetches them, **corrections already swept server-side**, the resolved wing, and a truncation report. **Replaces a client-side protocol of 13 calls that also needed a hardcoded root drawer id.**

`am_entry_point(wing)` (`internal/mcpserver/kg.go:223`) is the smaller sibling: "a session needs no id from a skill file and no multi-hop walk to begin."

**Neither is on the live server.** `am_skillset` returns 40 tools today; `bootstrap` and `entry_point` are in neither. Main is tagged v0.0.98 with both landed under ADR-036. So the status is: *built, merged, undeployed, and — per ADR-036's own unchecked follow-up — unmeasured.*

That reframes the question. The "teamskill + drawer id nonsense" is not an open design problem. It is a **deploy**.

***

## What the current chain actually costs

Reconstructed from the live artifacts, this is what a session does today to reach its must-load tier:

| #  | Call                                                                 | Why it exists                                                               |
| :- | :------------------------------------------------------------------- | :-------------------------------------------------------------------------- |
| 1  | `am_list_skills`                                                     | read `start-here`'s **description** — the pointer lives there so it is free |
| 2  | —                                                                    | description says: root = room `llm_init` in your wing                       |
| 3  | `am_list_drawers(wing, room:"llm_init")`                             | 3 drawers; identify the root **by its content prefix**                      |
| 4  | `am_kg_query(entity:<64-hex id>, outgoing)`                          | the `must.*` edge list                                                      |
| 5  | N × `am_get_drawer(id, whole:true)`                                  | one per `must.*` edge                                                       |
| 6  | 3 × `am_kg_query(predicate:"retracts" / "supersedes" / "qualifies")` | corrections are **incoming** edges; step 4 structurally cannot show them    |
| 7  | `am_list_skills` + 2 × `am_load_skill`                               | `human-decisions`, `memory-orchestration`                                   |

ADR-036 measured it: **13 calls, \~99KB (\~25k tokens), plus a hardcoded root id.**

Three of those steps exist only to compensate for a defect:

* Step 3 identifies the root *by reading its first line* because there is no address for it.

* Step 6 is three separate sweeps because outgoing traversal cannot see a correction. On 2026-08-25 a session that ran only `retracts` shipped a wrong ADR pointer.

* The root drawer carries `⚠IF STEP 1 RETURNS ZERO EDGES IT FAILED — STOP` because **`am_kg_query`** **fails open**: an unknown or mistyped entity returns `count:0` with no error. That is the first entry in the `llm_init` TRAPS drawer.

M's instinct is right about all three. The disagreement is only about the instrument.

***

## Grading the four ideas separately

The proposal `am_kv_store([cmd, payload, [version]])` (proposed as `am_root`) bundles four things with very different merits.

### 1. One addressed entry point instead of a pointer chain — ✅ already built

Ship `am_bootstrap`. Then honour ADR-036's follow-up before telling any client to depend on it:

> Report whether the bootstrap actually beat 13 calls / \~2.8k output tokens, with the measurement.

F-16 is the bootstrap's own falsifier: *"it must beat 13 calls / \~2.8k output tokens, measured, or it has reproduced the problem inside one call."* That box is unchecked.

### 2. Versioning — ✅ a real gap, and the strongest part of the proposal

There is **no version history on a drawer** today:

* `am_update_drawer` mutates in place and always re-embeds. A multi-chunk memory cannot be moved between rooms or wings at all (`internal/palace/service.go:717-733`).

* `MaxEmbedRunes = 4000` (`internal/palace/chunk.go:56`) — an update past 4000 runes is **refused outright**.

* So the root drawer has to carry the instruction **"DO NOT GROW THIS DRAWER."** That is a storage limit dictating what the content is allowed to say. It is the exact smell being reacted to.

The only versioning that exists is the KG's temporal validity window (`am_kg_add` + `am_kg_invalidate`) and skill versions (`am_update_skill` bumps). Drawers have neither.

What versioning buys:

* **Corrections become the default read.** Today `retracts` / `supersedes` / `qualifies` are incoming edges requiring three deliberate sweeps. With versions, "what superseded this" is `v+1`, returned by default.

* **Editing stops being lossy.** The `llm_init` FLOOR PLAN drawer records that an earlier version of itself was wrong in two ways — that history survives only because someone wrote prose about it.

* **Compare-and-swap.** `if_version: N` → conflict on a concurrent write. Two agents editing the root today are silent last-write-wins.

⚠ **The cost the proposal does not price: retention.** The root is edited often. Immutable versions of a hot key grow without bound, and every version of an *embedded* drawer costs an embedding. Versioning is cheap on a K/V tier (no embedding) and expensive on drawers. Decide the retention policy before the first write, not after.

### 3. Raw K/V, no chunking / no embedding / no ranking — ✅ a real, small gap

| Need                                | Today                                   | What it costs                                                                     |
| :---------------------------------- | :-------------------------------------- | :-------------------------------------------------------------------------------- |
| store a bootstrap/config blob       | a drawer                                | chunked at 1600, embedded, **competes in the ranking pool** against real memories |
| exceed 4000 runes on an edited doc  | refused                                 | split into `must.*` parts with their own edges                                    |
| deterministic read of known content | `am_get_drawer(id)`                     | needs a 64-hex id; a **prefix silently creates a new node** on write              |
| know whether the key exists         | `am_kg_query` → `count:0`, **no error** | fails open                                                                        |

A K/V tier fixes all four, and the fourth deserves its own line: **a missing key can be an error.** The whole `⚠IF STEP 1 RETURNS ZERO EDGES IT FAILED — STOP` rule moves out of prose an agent must remember and into the protocol. That is a real reduction in how much correctness lives in instructions.

### 4. One opaque command-dispatched endpoint, expanded by per-user instructions — ❌ this is the part to drop

**(a)** **`[cmd, payload, version]`** **as a positional array is the weakest schema MCP allows.** No per-command parameter validation, no per-command description, no enum of legal commands. This project already measured where guidance actually lands (2026-08-17): *"tool.description = STRONGEST (in context at the moment of the call, EVERY client)."* An opaque dispatcher deletes the strongest guidance surface in the system, at the call every session makes first.

Cheap fix — same tool, same expandability, but the client validates and the model sees the legal ops:

```jsonc
am_kv_store({
  op: "get" | "put" | "list" | "history",  // enum, each described
  wing:       "wing_agentmemories",        // scoped, always
  key:        "init.must",
  value:      "…",                          // put
  version:     7,                           // get: pin. omit → latest
  if_version:  6                            // put: CAS guard
})
```

**(a2) "Undocumented" is refused by a build gate, not just by argument.** `TestEveryCatalogToolIsNamedInTheReadme` (`internal/mcpserver/catalog_test.go:212`) fails the build when a catalogue tool is not named in the README. A capability the caller cannot discover is this repository's signature defect and it is gated. ⚠But note the useful distinction: *"the server defines no semantics; each project writes its key conventions in its own AGENTS.md"* is good design and should be kept. **The tool ships documented; the conventions stay per-project.**

**(b) "expandable by only instructions on each user" reintroduces the indirection it removes.** Today: skill description → room name → drawer id. Under an instruction-expanded dispatcher: per-user instruction → command vocabulary → key namespace. Same hop count, and worse in one respect — the skill description is *discoverable* (`am_list_skills` is free and every client renders it), while a command vocabulary living only in a user's instructions is invisible to any session that did not load them. The palace already names this failure: *"A DRAWER WITH NO EDGE IS AN ORPHAN: findable by search, invisible to traversal."* An instruction-only command set is findable by one agent, invisible to the rest.

**(c) The KG loss is bigger than "no traversal."** `am_kg_query(predicate:"retracts")` **without an entity** returns every correction in the workspace in one call. K/V has no analogue — you would enumerate keys and filter client-side, or hand-maintain a secondary index, i.e. rebuild the graph, worse. And ADR-036 *just* spent an entire ADR putting the graph **on the read path**. A K/V that displaces it moves against work that merged days ago.

**(d)** **~~Two stores, two truths.~~** **WITHDRAWN (M, 2026-08-27) — it contradicted this doc's own framing.**

I argued a KV value is unsearchable, carries no anchors, no tunnels, no `am_status` counts, so the palace gains a second orphan class. **M's answer: this tier is not for memories we collect — it is for the entrypoint and the session-start data "we need to survive."**

That is correct and it dissolves the objection. If the tier is *definitionally* the known-address tier, unsearchable is the SPECIFICATION, not a defect. An entrypoint is never found by question; it is read by name, every session, unconditionally. It cannot be an orphan of a corpus it was never in. Anchors pin memories to code so they go stale, and tunnels link memories across wings — neither applies to a config blob.

**What survives, converted from objection to requirement:** `am_status` / `doctor` visibility. If every session depends on this store, an operator must be able to see it exists. Already tracked in the undecided requirements — now with a reason behind it.

**And one real residue, which is a discipline rule, not a mechanism:** the risk is not the tier being unsearchable — it is CONTENT DRIFTING INTO IT THAT SHOULD HAVE BEEN A MEMORY. 36,000 runes with no chunk limit is a lot of room, and roots grow; that is exactly why the current one has to shout "DO NOT GROW THIS DRAWER". Give it 9x the space and someone eventually pastes a rationale in there — and THAT would be invisible to search. The boundary is the one the existing root already states about itself: **"a spine: it stores how to traverse, never what is there."** Carry that rule to the new tier and (d) is fully answered.

**(e)** **~~Wing scoping is not optional.~~** **RETRACTED (M, 2026-08-27).**

I argued a KV tier must be wing-scoped or it repeats the KG's workspace-wide mistake "at the entry point, the single worst place to repeat it." **The objection does not transfer, for a reason I should have caught: the KG's problem is UNBIDDEN SURFACING IN RECALL.** A workspace-wide fact competes in every project's search results whether you asked for it or not. **KV has no recall** — nothing surfaces unbidden, everything is addressed by name. Two projects collide only by choosing the same key, and `list` makes that VISIBLE rather than silent.

**And there is a positive argument for team scope that outweighs what is left.** Requiring `wing` on the bootstrap read reintroduces the exact problem the tier exists to solve: wing resolution is a five-rung ladder, and `am_status` returns `default_wing: ""` for this very registration. If reading the entrypoint requires knowing your wing, but knowing how to work here is WHAT THE ENTRYPOINT TELLS YOU, the bootstrap is circular.

So `am_kv_store({command:"read", id:"root"})` needs **zero prior knowledge**. Per-project namespacing stays available as a key convention — `agentmemories.root`, `agentmemories.must.*` — and the team-level `root` can name which project keys exist, routing the session onward in one read.

***

## Amendment — the KG is already a standalone versioned store (M, mid-session)

M: *"we can use kg? without drawers? they depends nowhere? i mean kg just from object/etc.?"*

**Yes. Verified in source, not from the tool description.**

`db/migrations/00010_kg.sql:22-36` — `kg_triples` is `team_id, id, subject, predicate, object, valid_from, valid_to, confidence, source_closet, source_file, source_drawer_id, extracted_at` (+ `derived` from 00028). Critically:

* **`source_drawer_id TEXT NOT NULL DEFAULT ''`** — a drawer-less fact is the schema *default*, not a special case. The KG depends on nothing.

* **`valid_from`** **/** **`valid_to`**, `''` = currently true. `KGInvalidate` sets `valid_to` *without deleting the row* (`internal/palace/kg.go:22`), and `am_kg_query(as_of:)` time-travels.

So **a temporally-versioned, dependency-free store already exists.** That shrinks what `am_kv_store` needs to build — but three constraints stop it from being the whole answer:

**1.** **`MaxKGValueLen = 128`** **runes** (`internal/palace/kg.go:35`, enforced on runes at `:55`, "frozen MAX\_NAME\_LENGTH"). This is a store for **labels, not payloads**. The root drawer is \~3.5k runes — it cannot go in. This is the single hard ceiling.

**2. No** **`wing`** **column.** Scoped by `team_id` alone, so every fact is workspace-wide — exactly what ADR-036 had to design around. A KG-only root would be visible to every project at once.

**3. No auto-supersede.** `internal/palace/kg.go:567`: *"the frozen no-auto-supersede rule: to replace a fact, invalidate it first."* Re-adding without invalidating leaves **two current facts**. That is a footgun under KV semantics, where `put` is expected to overwrite.

**What this changes:** for anything that fits in 128 runes — pointers, ids, flags, state, edge lists — build nothing. `am_kg_add` + `am_kg_invalidate` + `am_kg_query(as_of:)` is already the versioned KV, and `am_kg_query(predicate:)` with no entity reads a whole relation in one call. What remains genuinely missing is narrower than the original proposal: a **wing-scoped payload tier** for values over 128 runes, with put-overwrites semantics.

***

## Task state: the KG holds the STATUS FIELD, not the plan (narrowed after review, 2026-08-27)

> **Correction.** An earlier version of this section was headed *"the best fit for the KG is task state"* and oversold it. A task **status** genuinely has triple shape (`task --status--> awaiting_m`). A task **plan** does not — encoding a blob as a fake fact misuses the graph, and `kg_triples` has no `wing` column, so one project's scratch plan competes in every other project's recall. Status fields to the KG; plans to the KV tier; narrative to a drawer. The evidence below stands unchanged — it is about drawers failing, and it is why the KV tier is needed.

M: *"but for tasks between sessions etc. where we creating updating deleting drawer?"*

**This is the strongest case in the palace, and it is not hypothetical — the drawer version is broken right now.**

`wing_agentmemories` room `llm_open_threads` holds 13 drawers. The live list, `688f73e9…`, is **6,448 runes**, single-chunk, grown in place since 2026-08-23. `MaxEmbedRunes = 4000` (enforced at `service.go:775`) **refuses an update above it.** So the sixteenth refresh pass fails, and the team already worked around it: there are now **four separate "DELTA — fold into the next refresh pass" drawers**, each opening with *"filed separately because the live list is past the 4,000-rune update bound."*

One record became five, none of which is authoritative. That is the create/update/delete cycle failing under its own storage model.

**And a hand-maintained list drifts.** Two items in that live list are already stale, verifiable from this session alone:

* Item 5 says production serves pre-#25 code and *"`am_status.ranking`* *came back with no* *`unit=`* *at all."* Today it returns `unit=memory` — production has moved.

* Item 8 / #23 item 1 was closed by PR#43, whose `status` param **is live now** (it is in the `am_kg_query` schema this session loaded).

### Why the KG fits this exactly

| Task-state need                                         | KG answer                                                                                                          |
| :------------------------------------------------------ | :----------------------------------------------------------------------------------------------------------------- |
| the field that churns (`status`, `owner`, `blocked_on`) | ≤128 runes — fits easily                                                                                           |
| "list everything still open"                            | `am_kg_query(predicate:"status", status:"current")` — **predicate without entity, one call** (ADR-026 T5, shipped) |
| history: "when did this become blocked"                 | `am_kg_timeline(entity)` — free, because `KGInvalidate` ends a row instead of deleting it                          |
| delete                                                  | you never do; you invalidate, and the audit trail survives                                                         |
| cost per update                                         | **no embedding at all** (today every status flip re-embeds a 6.4k-rune drawer)                                     |
| the narrative — why it is blocked, what was tried       | stays a drawer, linked by `source_drawer_id`                                                                       |

**The list stops being a record and becomes a query.** That resolves the open thread M parked on 2026-08-25 (*"split the list / append-only + index drawer / raise the bound"*) with a fourth option nobody listed: **stop storing the list.** A query cannot drift, cannot hit 4,000 runes, and cannot have five copies.

### Shape

```
wing_agentmemories:thread.bootstrap-deploy  --status-->     awaiting_m
                                            --owner-->      m
                                            --blocked_on--> release-tag
                                            --detail-->     <drawer id>   # the why
```

### ⚠ Three things that will bite, in order of severity

**1. No auto-supersede is worst exactly here.** Status is *the* mutable field, and `internal/palace/kg.go:567` requires `am_kg_invalidate` **before** re-adding. Forget it and you hold **two current statuses, silently**. Task state needs this as a hard discipline rule, or a helper that does both.

**2. There is no** **`am_kg_delete`.** The surface is `kg_add`, `kg_invalidate`, `kg_query`, `kg_stats`, `kg_timeline` — verified. A typo'd entity is permanent, and a prefix id *silently creates a new node* (documented trap). Drawers can be deleted; facts cannot. Wrong facts accumulate as ended rows forever.

**3.** **~~A known temporal bug already affects this use case.~~** **MEASURED 2026-08-27 — the bite is far smaller than claimed, and the default path is clean.**
The original claim: `temporalEndKey("2026-08-24") → 23:59:59Z` makes `valid_to` inclusive through end of day, so a task closed TODAY still returns from `as_of:today` and drops out only tomorrow. **Probed live on the hosted palace at \~12:35Z** by invalidating a real fact through the default path (no `ended`, so `valid_to` is a bare date) and querying the same minute:

| query, immediately after invalidating         | the retracted fact                                                                                                      |
| :-------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------- |
| `am_kg_query(entity)` — the bare default call | **excluded** — `status` defaults to `"current"`, filtered server-side; response carries `withheld:{ended:1}` and a hint |
| `as_of:2026-08-27` (today)                    | **excluded** — the status filter **composes** with `as_of`                                                              |
| `as_of:today` + `status:"all"`                | returned, `current:false`, `valid_to=2026-08-27`                                                                        |
| `as_of:tomorrow` + `status:"all"`             | excluded                                                                                                                |

★ **The requirement this section actually needs — "I decide now, so the next query returns false" — already holds, with no workaround.** The end-of-day promotion is now observable **only** on `status:"all"`/`"ended"` combined with `as_of:<today>`. Issue #47's *"as\_of and current disagree for up to a day"* survives on that one combination, never on a path an agent writes by default. So this is a **documentation gap, not a correctness risk** for holding status in the KG — which strengthens the narrowing rather than threatening it.

⚠ Two records were stale and are now corrected in the palace (drawer `8260e280` `supersedes` `4b8a5ee9`): the 2026-08-24 probe's *"NO* *`as_of`* *→ every fact passes, there is no time filter on the default call"*, and its *"`current:false`* *is the ONLY thing telling a reader to skip it — a convention the reader honours, not a mechanism the tool enforces."* **The filter shipped since.** That drawer's own closing line — *"the temporal MODEL is right, the FILTER is the missing half"* — is answered.

> **Still worth not doing: repairing this with arithmetic.** Subtracting a day from `valid_to` writes a false fact — trading a one-day false *positive* for a one-day false *negative* and moving the error **into the past** — and it inverts the interval on the most common path, since same-day create-then-correct yields `valid_from = valid_to = today` and −1 day makes `valid_from > valid_to`. If the residual `status:"all"` + `as_of:today` overlap is ever worth closing: default `ended` to a full UTC timestamp (one line, no migration, new rows only), then decide half-open `[valid_from, valid_to)` once, under issue #74 / PR #72 round-3 R1. Boundary semantics get fixed in the comparison, never simulated at each call site.

Plus the standing caveat: no `wing` column, so encode it in the subject (`wing_x:thread.y`). `normalizeEntityID` only lowercases and swaps spaces/apostrophes, so a colon survives — convention, not enforcement.

***

## Near-misses — two in this codebase, one upstream; none usable as-is

Before building, the honest check: does something here already do this?

**1. Skills — the closest existing thing, and I missed it first time round.** `db/migrations/00001_init.sql:61-72`:

```sql
name        TEXT NOT NULL,              -- slug, unique per team
content     TEXT NOT NULL DEFAULT '',   -- the skill body served to agents
version     INTEGER NOT NULL DEFAULT 1, -- bumped on every update_skill
UNIQUE (team_id, name)
```

**That is a versioned key/value store keyed by name**, with `am_update_skill` / `am_load_skill` as put/get. Far nearer to the ask than the KG. Still wrong, but for an honest reason: **a skill is a published convention every session loads at wake-up.** Filling it with task plans pollutes `am_list_skills` — the surface agents read to learn how to work here. Wrong tier, right shape.

**2. The KG — right interface, wrong capacity and scope.** `MaxKGValueLen = 128` (labels, not payloads), no `wing` column (workspace-wide), and triples are for facts other sessions query by entity. Good for `status`, `owner`, `blocked_on`, edge lists. Not for blobs.

**3. Upstream mempalace's L0-L3 layered wake-up — the closest thing anyone has built to this, and the audit above misses it because it only searched THIS codebase.** `layers.py`, from the frozen Python original this project is a self-described faithful rewrite of:

| tier   | what it is                             |
| :----- | :------------------------------------- |
| **L0** | identity, \~100 tokens, `identity.txt` |
| **L1** | essential story — top-weighted drawers |
| **L2** | on-demand, filtered get                |
| **L3** | deep semantic search                   |

**L0 is already "a store you address by name."** It just happens to be the filesystem, and the rewrite dropped it when it became a service. That reframes this proposal honestly: `am_kv_store` is not a novel invention, it is **re-acquiring a capability the port left behind** — which is a stronger argument than novelty, not a weaker one.

**Where upstream's is better than what §THE SPEC proposes.** L0-L3 is a **cost gradient**, not a blob: \~100 tokens, then weighted, then filtered, then search. A flat K/V read is all-or-nothing at 36,000 runes, and the Risks row about roots growing is exactly the failure a gradient prevents by construction. It also drew our own line first — L0/L1 is the known-address tier and L3 the didn't-know-to-ask tier, which is §*"The line that decides what goes in it"* arrived at independently.

**Where ours is better.** Versioned with `history` (a file has none); team-scoped and served over MCP rather than a local FS; and the tier lives in the KEY, so `list id:"must."` **is** the L1 selection — no separate weighting mechanism to build or tune. Upstream needs "top-weighted drawers" to mean something; we get the same selection from the namespace for free.

★ **This should change step 1 of the Recommendation, not the decision.** Today's F-16 numbers say `am_bootstrap` does not subtract (33 calls / 182,575 B, \~2KB *more*). Upstream's answer to that same problem was tiering, not one bigger call. If `am_kv_store` ships as a single flat blob it will hit the same wall from the other side — so the key namespace has to carry the gradient (`must.` eager, `ref.` on-demand) rather than one `must.everything` value. That is a design constraint on this ADR, discoverable now, and cheap now.

**Three near-misses — two here, one upstream — and none usable as-is. That is a real gap, not a duplicate.**

## The argument that matters most: this protects retrieval quality

Underweighted in the first draft, and it is the strongest case for the tier.

The palace's premises — *stored exactly, never summarised; accumulation is the value; embed everything, rank everything* — are **precisely inverted** for a task plan, which wants overwrite-in-place, delete-freely, no embedding, no ranking, no history.

That is not only ergonomics. Ephemera in the corpus are **unrelated drawers competing in the ranked pool**, and this repository has already paid to learn what a bad prior costs: `ADR-003-retire-the-closet-prior.md` retires a curation prior from default ranking precisely because of what it did to which document reaches top-1. *(The specific "0.10 MRR / one source lifting fifty drawers" figure came from review and I did not re-derive it — the ADR and the mechanism are verified, that number is not.)*

**So a KV tier is not a convenience. It keeps ephemera out of the ranked pool, and that is a retrieval-quality argument, not a comfort one.**

## THE SPEC — settled with M, 2026-08-27

Not exploratory any more. This is the shape M specified across five turns.

### The tool

```jsonc
am_kv_store({
  command: "read" | "write" | "delete" | "list" | "history",
  id:      "root",          // the KEY — author-chosen, stable. NO wing param.
  payload: "…",             // write — up to 36,000 runes
  version:  3,              // read — optional; omit for latest
  limit:   50,              // list / history
  offset:   0               // list / history
})
```

M's vocabulary, kept verbatim: `command` and `id`, not `op` and `key`.

### Key namespace — flat dotted, exactly the existing predicate grammar

`root` · `must.craft.traps` · `ref.craft.git-model` · `must.craft.memory-home`

**No paths, no slashes, no nesting.** One vocabulary for predicates and keys instead of two. (`am_kg_add` rejects `/`, `\\` and `..` in a predicate anyway, so a path grammar could never have been shared.)

`root` is the well-known key. The entire bootstrap addresses it by name — no id from a skill file, no hash.

### Why an author-chosen key and not a drawer id

`internal/palace/chunk.go:148` — `DrawerID = sha256(teamID, wing, room, sourceFile, chunkIndex, content)`. **Content is in the hash**, so a re-add, a `merge_wing` relabel or a chunk-parent change moves the address. That is the documented "ID DIVERGENCE" cause. **You cannot build a stable entry point on a content hash.** A key you chose survives every edit.

### The two stores, and the one asymmetry

|                      | scope                          | address form                                                               |
| :------------------- | :----------------------------- | :------------------------------------------------------------------------- |
| `kv_entries` (new)   | `team_id` — **no wing column** | plain `root`; project namespacing by key convention (`agentmemories.root`) |
| `kg_triples` (00010) | `team_id` — no wing column     | same convention applies to entities                                        |

**Both are team-scoped, and that is now deliberate rather than a defect in one of them.** The KG's workspace-wide scope hurts because facts surface unbidden in recall; KV is addressed by name only, so a collision requires two authors choosing one key and `list` exposes it.

`id|blob` is `am_kv_store`. `id|id|how` stays the KG — **for genuine relations only** (`retracts`, `supersedes`, `qualifies`, cross-references), with readable keys as targets:

```
wing_craft:craft.traps --supersedes--> wing_craft:craft.traps-v1
```

**⚠ Tier membership is NOT an edge.** The tier lives in the key, so the must tier is one call and nothing points at a key for tier reasons:

```
am_kv_store({command:"list", id:"must."})   -> the whole must tier, no KG hop
```

An earlier draft carried the tier in BOTH the predicate and the key (`root --must.craft.traps--> wing_craft:must.craft.traps`) and then warned about the renaming coupling that redundancy created. M rejected it, correctly: **the key is ours to name, and that is the point** — it replaces `start-here` skill -> `llm_init` room -> identify-root-by-content-prefix -> 64-hex hash with `id:"root"`. Remove the redundancy and there is no coupling to accept.

**Mixed targets need no new field:** 64-hex → drawer, anything else → KV key.

### Bootstrap, end state

```
am_kv_store({command:"read", id:"root"})       → the blob
am_kv_store({command:"list", id:"must."})      → the must tier (no KG hop)
am_kg_query({predicate:"retracts"})            → corrections, the one thing only the graph can answer
```

### Limits and behaviour

* **25,000 runes per value.** `wholeMemoryBudget = 40_000` (`internal/mcpserver/drawers.go:56`) exists because the harness *"drops a result past roughly 40-45KB to a file rather than delivering it"* (`:839`). 5k tokens ≈ 24.5k runes, \~40% headroom.

* **`MaxEmbedRunes = 4000`** **does not apply.** That ceiling is the embedder's (`chunk.go:49`); a KV value is never embedded. **This is what kills "DO NOT GROW THIS DRAWER."**

* **Refuse an over-cap write at** **`write`** **time** — never at read time in a future session's bootstrap, where it is silent and unfixable.

* **`read`** **on a missing key is an ERROR.** Unlike `am_kg_query`'s `count:0`-no-error. Retires `⚠IF STEP 1 RETURNS ZERO EDGES IT FAILED — STOP`.

* **No search, no embedding, no chunking, no ranking.** Fetched by name only.

* `write` overwrites and bumps the version; `read` returns latest.

### Versions and history — settled with M, 2026-08-27

`read` returns the **latest**. `history` returns **all versions**, paged.

```jsonc
am_kv_store({command:"history", id:"root", limit:50, offset:0})
// -> [{version, written_at, runes, first_line}, ...]  newest first
//    + total_versions
```

**⚠** **`history`** **returns METADATA, never payloads.** A payload is up to 25,000 runes, so `history(limit:20)` carrying full payloads would be **500,000 runes — 12x past the 40,000 spill threshold**. The response would go to a file and never enter context, which is the precise failure this design exists to avoid. Fetch a specific one with `read({id, version:N})`. History then stays cheap and bounded *regardless of payload size* — the property that matters if the root is edited often.

* **Newest first**, `limit`/`offset` matching `am_list_drawers` (default 50) — same convention, no surprise.

* **`total_versions`, named unambiguously.** `am_diary_read` already ships the trap of returning `"total":11` where 11 is a CHUNK count, not an entry count. Do not inherit it.

* **`delete`** **removes every version of the key**, following `am_delete_drawer`'s rule that a multi-part memory goes whole. Orphan versions are the same defect class.

**RETENTION: keep all versions, paged. And this is no longer an expensive decision.** Because history is paged from day one, adding prune-to-last-N later changes no API shape and breaks no caller — `read` still returns latest, `history` still pages. Keep-all now is safe and pruning stays available.

⚠ Watch only this: keep-all with 25k-rune payloads on a hot key is unbounded. \~500 edits of the root is \~12MB for one key. Not a crisis for SQLite, not free either.

### Storage

```sql
CREATE TABLE kv_entries (
    team_id    TEXT NOT NULL,
    id         TEXT NOT NULL,          -- the key; project namespacing by convention
    version    INTEGER NOT NULL,
    payload    TEXT NOT NULL,
    written_at TEXT NOT NULL,
    PRIMARY KEY (team_id, id, version)
);
```

Migration **00030** (`00029_search_events_rerank_skip_reason.sql` is head) — but per ADR-036, **allocate at merge, not at authoring**: a per-branch uniqueness check is blind to cross-branch collisions.

***

## Review pass, 2026-08-27 — four corrections, each re-measured

A teammate pulled this doc and filed four corrections. **Two hold and are folded in. Two did not reproduce against the live palace**, and the measurements are below so the disagreement is resolvable rather than a matter of who asserted last.

### ✅ HELD — ADR-038 exists (see above)

### ✅ HELD — temporalEndKey is a DECISION, not a defect

I framed it as a bug that made the `kg_supersede` gap worse. Wrong, and the code says so in its own comment:

```go
// temporalEndKey normalizes an upper-bound temporal value: a bare date becomes the
// END of that day, so a fact valid_to "2026-01-31" stays in effect through Jan 31.
```

`inEffectAt` (kg.go:962) then excludes only when `temporalEndKey(row.ValidTo) < asOfKey`. That is a **deliberate inclusive reading**, documented at the point of definition.

**The real finding is sharper than "bug":** `status:"current"` excludes immediately, `as_of` does not — the two filters disagree for one day. They answer different questions, nothing documents that they differ, and no test pins it. **A decision to make, not a defect to fix.** And it is NOT the bug M reported: *"visada trinta true"* is the `RowsAffected` discard — `am_kg_invalidate` answering success for a fact it never ended — which is **PR #73**, separate and already fixed.

### ❌ DID NOT REPRODUCE — `llm_open_threads` is still 13 drawers

Claim: *"live count is 1 drawer, 485 runes; ADR-027's split convention was applied."*

Measured at 2026-08-27 via `am_list_drawers(wing:"wing_agentmemories", room:"llm_open_threads", limit:20)` on the hosted palace: **`count: 13`**. The live list `688f73e9…` is still present at \~6,448 runes carrying all 13 items, and all four DELTA drawers are still there (`031c6dbe`, `b1c07cec`, `63512847`, `ae417593`), each still opening *"filed separately because the live list is past the 4,000-rune update bound."*

### ❌ DID NOT REPRODUCE — `am_bootstrap` is still absent from the live catalogue

Claim: *"stale after today's rebuild — both are in the running binary and offered by the live server."*

Re-called `am_skillset` after the review: **`tool_count: 40`**, and neither `am_bootstrap` nor `am_entry_point` is in the list. Same 40 as this morning. `am_status` reports `mode: hosted`.

**Most likely explanation for both:** the rebuild and the room cleanup landed on a **`--local`** **server or a different palace**, not the hosted one at `aiagentmemory.dev` that this session and the doc are measuring. This project has been bitten by exactly that split before — `am_delete_wing` is `--local`-only, and `MERGE ≠ DEPLOY` is item 5 of the very list in question. **Worth reconciling which palace each of us measured before either number is quoted again.**

⚠ Both of the teammate's endorsements survive either way: **ADR-036's F-16 measurement is still unchecked**, and the retrieval-quality argument is unaffected. But the doc's *"the drawer approach has already failed — live, right now"* claim depends on which palace you are looking at, and that is now an open question rather than a settled one.

***

## Recommendation

Three steps, separable, in order.

**1. Deploy** **`am_bootstrap`** **/** **`am_entry_point`, then measure F-16.**
Zero new design. It removes the pointer chain being objected to. Until it is deployed, everything else optimises a path nobody is on — and ADR-036 says nobody should be told to depend on it before the measurement exists.

**2. Use the KG for anything under 128 runes — build nothing.**
Pointers, ids, flags, state, edge lists are already covered by `am_kg_add` + `am_kg_invalidate` + `am_kg_query(as_of:)`, with no drawer dependency. Mind the no-auto-supersede rule: invalidate before re-adding, or you hold two current facts.

**2b. Add** **`am_kv_store`** **only as the wing-scoped PAYLOAD tier — values over 128 runes.**
Named object args with an op enum. **Fails closed** on a missing key. `put` overwrites (unlike the KG). CAS via `if_version`. Retention policy decided before the first write. This is the narrowed, genuinely net-new surface.

**3. Do not let it replace drawers or the KG.**
Keep `must.*` edges as the relation layer; let the root *value* carry the edge list as data — which is already what the root drawer is: *"a spine: it stores how to traverse, never what is there."*

***

## The line that decides what goes in it

> **K/V is the known-address tier. Search is the didn't-know-to-ask tier.**

M, 2026-08-27, and it is the cleaner statement of the same split:

> *"That's not for memories we collect — that's the critical thinking part, what we need to survive."*

**Survival data vs. accumulated knowledge.** Different lifecycle, different access pattern, therefore different store. That is the argument for tiering, and it is why the unsearchability objection (§4d) was withdrawn.

For simple, addressable things — config, a bootstrap payload, a wing's entry blob — a versioned K/V is the right instrument and the palace lacks one. But *"simple memory"* is a category **the writer picks and the reader cannot**. A session recalling something does not know in advance whether the answer was filed as simple.

So: `am_kv_store` for things addressed **by name**. Never for things found by **question**.

***

## Undecided requirements — settle these before the ADR

Three settled by M on 2026-08-27; two still open.

* **~~TTL?~~** **NO.** Keys persist until `delete`. No expiry field, no sweeper.

* **~~Size cap~~** **36,000 runes.** ⚠The criterion is DELIVERY, not context capacity: 36,000 runes is \~7.3k tokens, which any modern context holds trivially. The binding constraint is `wholeMemoryBudget = 40_000` (`internal/mcpserver/drawers.go:56`), because the harness spills a larger response to a file where it never enters context at all. Measured JSON overhead: 4.2% on prose, 7.3% on dense drawer text, so 36,000 lands at \~37.5-38.6KB and clears both.

* **~~Scope~~** **TEAM. NO** **`wing`** **COLUMN, NO** **`wing`** **PARAM.** M's call, and it retires my own §4e objection — see below.

* **Does** **`doctor`** **see it?** The `doctor` subcommand exists (`cmd/server/doctor_test.go`); an unseen store is an unmaintained one.

* **Does it count against quota?** Every tool handler calls `admit(ctx, usageSvc)`. A KV read on every bootstrap is a per-session cost — deliberate, or exempt?

It is a new table plus a new tool surface, so it is a durable decision and wants a small ADR — but a small one.

✅ **ADR-038 EXISTS — my earlier "not in this repository" was wrong.** I checked `docs/adr/` on `main` only. It is on `origin/task/adr-038-dedupe-on-the-content-refer-by-the-id`, **PR #72, OPEN, Proposed**, filename `ADR-038-…-refer-by-the-id-and-end-instead-of-overwrite.md`. "End instead of overwrite" is in the title; it is a real cited record, not a phantom. Lesson: a corpus claim about an ADR must check remote branches, because ADRs are authored on branches here.

## Open questions for M

1. **Deploy first?** Is there a reason `am_bootstrap` has not shipped to the hosted server, or is it just pending a release?
2. ~~Retention: keep all, last N, or last N + pinned?~~ **RESOLVED (M, 2026-08-27): keep all, paged via** **`history`.** No longer expensive to change later — pruning can be added without touching the API.
3. ~~Does it supersede~~ ~~`am_entry_point`?~~ **RESOLVED by the rename (M, 2026-08-27).** A tool named for the mechanism plainly does not supersede the entry point — `am_entry_point` reads *from* `am_kv_store`. One storage tier, two views.
4. ~~Key namespace: flat or path?~~ **RESOLVED (M, 2026-08-27): flat dotted,** **`must.*`** **/** **`ref.*`, no paths.** One vocabulary for keys and predicates.

***

## Review pass 2 — the F-16 measurement, the ADR-038 collision, and the scope line (ai:claude-code-aks, 2026-08-27)

Findings from the palace, none of them currently in this doc. Filed as `wing_agentmemories/technical` drawer `62c90d4a`, which `qualifies` the ADR-039 record `cbae96a5`.

**1. ⚠ ADR-010 is not mentioned anywhere here, and it lands on Recommendation step 3.**
ADR-010 would retire the agent-facing delete in favour of supersede-on-update — and it is **live, not dormant**: the palace records `ADR-010 superseded_by ADR-038`, the absorption, PR #72 round 2 at `d336254`, still open. This doc already cites ADR-038 under *Undecided requirements* without connecting it: “end instead of overwrite” in its title **is** this mechanism. Its wiring table claims the only caller assuming drawer-id stability is the tool itself. That is recorded as **wrong** (drawer `c59b7209`, verified against `main` 2026-08-27): every `must.*`/`ref.*` edge addresses a **drawer id**, so supersede-on-update would orphan the whole tier on *every* correction — turning today's occasional bug systematic. That is exactly the tier step 3 says to keep as the relation layer.

Pick one before this becomes an ADR:

* **(a)** `must.*`/`ref.*` edges take **kv keys** as their object. ADR-010's gap stops mattering for the tier — and this is a strong argument *for* `am_kv_store` that the doc does not currently make.

* **(b)** They keep addressing drawer ids, and ADR-039 and ADR-010 collide.

Recorded as *"resolve before T2"*, still unreported upstream.

**2. The scope line worth stating outright: this is ADDITIVE.**
It touches neither `am_update_drawer` nor `am_delete_drawer`, so it does **not** fix the delete/update-drawer problem for ordinary drawers. Diary entries, decisions and review findings still hash content into their id, still chunk at 1600, still refuse multi-chunk update. That is where the recorded casualties are: three delete-and-refile cycles in one day on 2026-08-24 (*"each one destroyed provenance I would rather have kept"*); five-plus in the 2026-08-27 session, every one from overshooting 1600 *while writing the rule against delete-and-refile*; a `⛔CLOSED` header on chunk 0 while chunks 1-2 served the reversed instruction as current; a 2-chunk inbox item that can never leave the inbox. Untouched too: issue #71 with the `derived = true` filter (`kg.go:1208`), and issue #65.

What it **does** fix is the rewritten-record class — entry points, `must.*`, indexes, `llm_open_threads` — which is real and sufficient. Saying so plainly protects the ADR from being read as a fix for something it deliberately is not.

**3. Minor —** the `internal/palace/service.go:717-733` citation above has moved; the same guard was recorded at `service.go:964` on 2026-08-27. It shifted twice in three days, so cite the symbol (`Service.Update`'s `len(chunks) > 1` guard) rather than the line.

**4. ⚠ The F-16 measurement Recommendation step 1 asks for HAS ALREADY BEEN RUN — and it falsifies step 1's premise.**
Measured 2026-08-27 against the live hosted palace, `curl tools/call`, bytes by `wc -c` (drawer `dc3c82f8`):

| path                                   |  calls |       bytes |
| :------------------------------------- | -----: | ----------: |
| hand protocol, as AGENTS.md prescribes |     33 |     180,500 |
| `am_bootstrap(wing)` alone             |      1 |      10,704 |
| **bootstrap-led session, end to end**  | **33** | **182,575** |

**They do not subtract.** Bootstrap's entry node is `room:<wing>/llm_init`, so its eager tier is that room's three drawers; the `must.*` tier hangs off the **root drawer**, one hop further, and Bootstrap does not follow it. Same call count, \~2KB *more*. So step 1's *"it removes the pointer chain being objected to"* is not true today, and *"replaces a client-side protocol of 13 calls"* is measured against the wrong tier.

Two further findings from the same run, both directly design-relevant here:

* `truncation.omitted: 0` is **true and misleading at once** — `omitted` is defined against what the entry node offered, never against what the protocol requires. Three eager records, `on_demand: null`, `omitted: 0`: structurally complete, semantically **\~9% of the tier**.

* The correction sweep is **id-scoped** — `CorrectionsFor` filters `if !want[row.Object]` (`kg.go:1134`) over the ids it just returned. It answered `corrections: null` while the workspace sweep returns 80 (12 `retracts` + 17 `supersedes` + 51 `qualifies`) — including the very `supersedes` that settles whether `am_bootstrap` is deployed.

★ **This is the strongest argument FOR** **`am_kv_store`** **in the whole document, and it is currently absent from it.** The reason bootstrap does not subtract is precisely that the entry payload is *scattered across drawers reachable only by traversal*. One addressed key carrying the tier **as a value** is the fix bootstrap failed to be. Step 1 should be re-scoped from *"deploy, then measure"* to *"the measurement exists, and it is why 2b is needed."*

**5.** **`must.state.open`** **is the motivating example this doc never uses.**
6,448 runes — roughly **a third of the entire** **`must.*`** **tier payload** — stale, **frozen** (over the 4,000-rune update bound, so `am_update_drawer` refuses it outright), and mostly tracker state. `must.craft.filing` forbids exactly that content: *"current config values, versions, counts, branch state… FILE THE ADDRESS, NOT THE VALUE."* Measured across 8 read-only sessions on 2026-08-25: sessions 4-8 each spent \~1/3 of their tier budget on it, and four had to disprove it live (drawer `78ff14c5`).

That is a live state record with a different lifecycle, unmaintainable in a drawer and unfixable in place — exactly what *"The line that decides what goes in it"* calls survival data. It also answers the Risks row about content drifting in: **the drift already happened, in the other direction.** State drifted into the `must.*` tier because there was nowhere else to put it.

**6. Stale in the review pass above.** *"❌ DID NOT REPRODUCE —* *`am_bootstrap`* *is still absent from the live catalogue"* is settled. Verified live in this session: `am_skillset` reports `tool_count: 42` with both `am_bootstrap` and `am_entry_point` present. Drawer `0a010234` `supersedes` `978a3dad` on precisely this point (post-redeploy, 2026-08-27), so the disputed-measurement row in Risks can be closed on its deployment half.

***

# PROPOSED ADR (draft) — for review, not yet numbered

> **Number and migration are allocated AT MERGE, never at authoring.** `main` holds ADR-037; ADR-038 is open on PR #72. Next free is **039** — but a per-branch check is blind to cross-branch collisions, which is the rule this repo already recorded after its ADR-number collision, and `TestADRNumbersAreUnique` guards it thereafter. Same for the migration: `00029` is head on `main`, so this is **00030** unless another branch lands first.

## ADR-0XX: A store you address by name

**Status:** Proposed · **Date:** 2026-08-27
**Cross-references:** `internal/mcpserver/drawers.go:56` (`wholeMemoryBudget`), `internal/palace/chunk.go:56` (`MaxEmbedRunes`), `internal/palace/chunk.go:148` (`DrawerID`), `internal/palace/kg.go:35` (`MaxKGValueLen`), `db/migrations/00001_init.sql:61` (skills), `db/migrations/00010_kg.sql:22` (`kg_triples`), `internal/mcpserver/catalog_test.go:212` (`TestEveryCatalogToolIsNamedInTheReadme`), ADR-036 (the bootstrap surface this complements), ADR-003 (the ranking prior this protects)

## Context

Every session reaches its must-load tier through a chain: the `start-here` skill description names room `llm_init`, a session lists that room, identifies the root **by reading its first line**, then walks a 64-hex id through the KG. ADR-036 measured the client-side form at **13 calls, \~99KB (\~25k tokens), plus a hardcoded root drawer id.**

Three of those hops exist only to compensate for a defect, and the root drawer carries prose to paper over each: identify-by-content-prefix because there is no address; three predicate sweeps because corrections are incoming edges; and `⚠IF STEP 1 RETURNS ZERO EDGES IT FAILED — STOP`, because `am_kg_query` fails open.

**The root cause is that every address in this system is derived, not chosen.** `DrawerID = sha256(teamID, wing, room, sourceFile, chunkIndex, content)` — content is in the hash, so a re-add, a `merge_wing` relabel or a chunk-parent change moves it. You cannot build a stable entry point on a content hash.

## Existing Primitives Audit

Four candidates. None serves, and the reasons differ:

| Primitive                          | Shape                                                                                                                               | Why not                                                                                                                                                                                           |
| :--------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Skills** (`00001_init.sql:61`)   | `name` unique per team, `content`, `version` bumped on update — **a versioned KV keyed by name**                                    | Closest existing thing. But a skill is a wake-up convention every session loads; filling it with bootstrap payloads pollutes `am_list_skills`, the surface agents read to learn how to work here. |
| **KG** (`00010_kg.sql:22`)         | temporal triples, `source_drawer_id` defaults `''` so facts stand alone; `valid_from`/`valid_to` + `KGInvalidate` = real versioning | `MaxKGValueLen = 128` runes. A store for labels, not payloads.                                                                                                                                    |
| **Drawers**                        | verbatim, chunked, embedded, ranked                                                                                                 | Content-hash id; `MaxEmbedRunes = 4000` refuses an update above it — which is why the root drawer must instruct "DO NOT GROW THIS DRAWER".                                                        |
| **Artifacts** (upstream mempalace) | exact content, no chunk, no embed                                                                                                   | `put` returns a **server-assigned id** — the create→get-id→remember-the-id dance again.                                                                                                           |

**Three near-misses across two codebases, all failing on the same axis: the address is assigned, not chosen.**

## Decision

Add **`am_kv_store`** — a team-scoped, versioned key/value store addressed by an author-chosen name.

```jsonc
am_kv_store({
  command: "read" | "write" | "delete" | "list" | "history",
  id:      "root",       // author-chosen key. NO wing param.
  payload: "…",          // write, <= 36,000 runes
  version:  3,           // read, optional -> latest
  limit: 50, offset: 0   // list | history
})
```

```sql
CREATE TABLE kv_entries (
    team_id    TEXT NOT NULL,
    id         TEXT NOT NULL,
    version    INTEGER NOT NULL,
    payload    TEXT NOT NULL,
    written_at TEXT NOT NULL,
    PRIMARY KEY (team_id, id, version)
);
```

**Five properties, each load-bearing:**

1. **The key is chosen, not derived.** `root` survives every edit. This is the whole point.
2. **`read`** **on a missing key ERRORS.** Fails closed, unlike `am_kg_query`'s `count:0`-no-error. Retires the `⚠IF STEP 1 RETURNS ZERO` rule from prose into the protocol.
3. **No embedding, no chunking, no ranking.** So `MaxEmbedRunes` does not apply, and ephemera never enter the ranked pool.
4. **Keep all versions, paged.** `read` returns latest; `history` returns **metadata only** (`version`, `written_at`, `runes`, `first_line`) plus `total_versions`, newest first. `delete` takes every version of a key.
5. **Team-scoped, keys flat-dotted.** `root`, `must.craft.traps`, `agentmemories.root` — the same grammar as KG predicates, so one vocabulary. Tier lives in the key, so `list` with prefix `must.` IS the must tier, with no KG hop and nothing pointing at a key for tier reasons.

**Bootstrap end state:**

```
am_kv_store({command:"read", id:"root"})    -> the blob, zero prior knowledge
am_kv_store({command:"list", id:"must."})   -> the must tier
am_kg_query({predicate:"retracts"})         -> corrections; the one thing only the graph can answer
```

## Alternatives Considered

* **A positional** **`[cmd, payload, version]`** **dispatcher.** REJECTED. The tool description is the strongest guidance surface — in context at the moment of the call, in every client — and an opaque dispatcher deletes it at the call every session makes first. `TestEveryCatalogToolIsNamedInTheReadme` (`catalog_test.go:212`) already fails the build over undiscoverable capability. *The server defines no key semantics; each project writes its key conventions in its own AGENTS.md.*

* **Wing-scoped.** REJECTED. Reading the entrypoint would require resolving a wing first — a five-rung ladder, with `default_wing` empty on real registrations — but knowing how to work here is what the entrypoint tells you. Circular. The KG's workspace-wide scope hurts because facts surface **unbidden in recall**; KV is addressed by name, so a collision needs two authors picking one key and `list` exposes it.

* **Put payloads in the KG.** REJECTED at 128 runes.

* **Put them in skills.** REJECTED — pollutes the wake-up surface.

* **TTL / a scratch tier.** REJECTED. Keys persist until `delete`.

* **Raise** **`MaxEmbedRunes`.** REJECTED — it would enlarge what gets embedded and ranked, which is the opposite of the goal.

## Consequences

**Retires:** the `start-here` -> room -> content-prefix -> 64-hex chain; "DO NOT GROW THIS DRAWER"; the `⚠IF STEP 1 RETURNS ZERO EDGES` trap; handling 64-hex ids at all (`⚠IDS ARE FULL-LENGTH — a prefix silently creates a NEW node` stops applying to the bootstrap path).

**Protects retrieval quality.** Bootstrap and scratch content are unrelated documents competing in the ranked pool. ADR-003 retired the closet prior for what it did to top-1; this keeps a whole class of ephemera out of that pool by construction.

**Costs:** one new table, one new tool, one more thing `doctor` should know about. Keep-all-versions on a hot key with 36,000-rune payloads is unbounded — \~500 edits of the root is \~12MB for one key.

## Out of Scope

* **Search over KV.** Deliberate — it is the known-address tier.

* **Relations.** `id|id|how` stays in the KG.

* **`kg_supersede`.** A real, separate gap (upstream has it, we do not) — filed to `wing_agentmemories`/`inbox`, independent of this.

* **Deploying** **`am_bootstrap`** **and measuring ADR-036 F-16.** Independent and higher priority: it is already built and merged.

## Risks

| Risk                                                                                                | Mitigation                                                                                                                                                                                                                                            |
| :-------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Content drifts in that should have been a memory** — 36,000 runes is a lot of room and roots grow | The boundary the current root already states about itself: *"a spine: it stores how to traverse, never what is there."* Carry the rule; consider a gate.                                                                                              |
| **A payload near the cap spills to a file on read** and never enters context                        | Cap is 36,000 runes against `wholeMemoryBudget = 40_000`; measured JSON overhead 4.2% (prose) / 7.3% (dense). **Refuse an over-cap write at** **`write`** **time**, never at read time.                                                               |
| **Migration 00030 collides with an open branch**                                                    | Allocate at merge.                                                                                                                                                                                                                                    |
| ⚠ **Two motivating measurements are DISPUTED**                                                      | This session measures `llm_open_threads` at 13 drawers and `am_skillset` at 40 tools on the hosted palace; a reviewer measures 1 drawer and both bootstrap tools live. **Reconcile which palace before either number is quoted in the accepted ADR.** |

## Rollback

New table plus a new tool, both additive. `00030` carries a `-- +goose Down`. Removing the tool breaks only callers that adopted it — which is why adoption should follow a measurement, not a promise. Revert order: binary, tool registration, migration.

## What would falsify this

The bootstrap read must **measurably** beat the chain it replaces, on calls and output tokens, against the same palace. If it does not, this has reproduced the problem behind a nicer name — the same falsifier ADR-036 set for itself in F-16, and which is still unchecked.

## Follow-ups / still undecided

* [ ] **Does** **`doctor`** **see the store?** An unseen store is an unmaintained one.

* [ ] **Does a read count against quota?** Every handler calls `admit(ctx, usageSvc)`; a KV read per bootstrap is a real per-session cost — deliberate or exempt?

* [ ] Report the measured before/after for the bootstrap path, whichever way it falls.

* [ ] Reconcile the disputed palace measurements above.

Proof Shared Document

Proof Shared Document

This is a collaborative document on Proof. To read or edit it programmatically:

  • Fetch this URL with Accept: application/json to get content + API links.
  • Fetch this URL with Accept: text/markdown to get raw markdown.
  • Read endpoint: GET /api/agent/xv0h5i40/v3/document (one call: markdown + comments + suggestions)
  • Edit endpoint: POST /api/agent/xv0h5i40/v3/edit (intent-level operations including comment/suggest; no base token)
  • Bug reporting: POST /api/bridge/report_bug (or /d/xv0h5i40/bridge/report_bug)
  • Full API docs: /agent-docs
  • Open source reference: https://github.com/EveryInc/proof-sdk
  • No browser automation needed — use plain HTTP requests (curl/web_fetch).

Auth: If this URL includes ?token=, send it as Authorization: Bearer <token>.