ADR 0009 — Memory routing and Background composition: the model saves content, a developer-declared spec decides where it lands
- Status: PROPOSED — design review, revised 2026-08-08 after the context owner withdrew compatibility as a design criterion (see Revision). Supersedes specific statements in ADR-0008 (named in Decision 1); leaves ADR-0007’s store contract intact except for one named protocol extension (Decision 9). Program #414; epic #415.
- Date: 2026-08-08
- Context owner: Doug
- Scope:
packages/agent-core—atoms/background.ts(schema + class replaced: onesections[]of nested entries, the four legacy records deleted,merge()override,renderFragments(),###headings);atoms/awareness.ts(###heading,renderFragments());atoms/base.ts(PromptFragmentdeclared at layer 0 besideRenderContext);molecules/memory-routing.ts(new:Placement,placementKey,MemoryRoutingSpec);molecules/memory-record.ts(targetandpayloaddeleted; additivelabel?);organisms/apply-memory-overlay.ts(new);organisms/agent.ts(AgentPromptSectionData={name, source, fragments}—textdeleted);rendering/sections/base.ts(render()replaced by requiredrenderFragments());rendering/renderer.ts(renderInitialcollapses into the shared join).packages/agent-runtime—presets/memory/(new: section vocabularies);memory/toolbox.ts(targetandpayloadleave the model-facing schema;labelenters; the D2 gate rekeys onto the derived address);memory/recall.ts(candidacy predicate; composed-id dedupe;### Recalled Memories);memory/conformance.ts(Tier 1 / Tier 2 split);memory/store.ts+sqlite-store.ts(shared tokenizer; per-row read tolerance; promotion rows;target/payloadcolumns dropped from the v1 DDL).packages/agent-server— the hand-mirroredAgentPromptSectionDatadeclaration is deleted in favour of a type-import from core; the composition payload’sinstance.backgroundandprompt.sectionschange shape wholesale;renderPathdeleted; theaccess_methodsnake_case shim deleted.packages/agent-dashboard—api/composition.tsmirror updated (still hand-maintained);RenderedPromptView.tsxfragment spans;RolesPage.tsx:110count fix.docs/memory/guide.md,docs/memory/evolution-cookbook.md,docs/playground-redesign.md— corrections named in Decisions 1, 8, 13 plus the deleted-vocabulary sweep named in Follow-up 6.- External (coordination item, see Decision 15): codegen-patterns.
Revision — compat constraints withdrawn
The ruling (context owner, 2026-08-08):
“I do not care at all about compatibility. I DO NOT want it impacting us and our decisions at all until I reach some form of actual release — and I’ll signal when that is.”
This is an absolute constraint on the design, not a preference. The following are therefore not valid warrants anywhere in this ADR, and every decision that rested on one has been re-decided: a test in this repo needing an update (snapshot -u is allowed where a render change is intended); a wire/HTTP payload shape changing; @agentic-patterns/core being published; rows in the disposable dogfood db (~/.local/state/ap/memory.db) failing to parse; a downstream first-party repo pinning a version (ADR-0002’s own precedent shipped a harder break as a hard break); semver ceremony, deprecation windows, migration shims, release sequencing.
What still constrains: layer rules; frozen Zod atoms with nullary toPrompt(); applyMemoryOverlay pure / total / order-independent; C1 (AgenticModel stores a ZodObject and parses in the ctor, so .transform()/z.preprocess are impossible at the top level) and C2 (AgentConfigSchema.background is .parse()d without the ctor at agent-config.ts:63,:136 and organisms/agent.ts:30); D-1/D-2/D-3; simplicity, now a first-class criterion — machinery that existed only to preserve an old shape is deleted, and the design must get smaller, not merely different.
Decisions changed: 1, 3, 4, 5, 7 (warrant only), 9 (one clause), 12, 14 (rewritten end to end), 15 (probe list), 16 (rewritten end to end). Decisions 2, 6, 8, 10, 11, 13 are unchanged. No decision is deleted and nothing is renumbered — Decision 14 keeps its number and its correctness property while losing the field-specific machinery it was built around.
WAS → NOW-IS
| # | Was | Now is | Why it changed |
|---|---|---|---|
| 3 | BackgroundSchema = sections[] + flat entries[] + the four legacy z.record(z.unknown()) records, populated forever | BackgroundSchema = { sections: BackgroundSection[] }. One field, one representation. | Every retention warrant was compat: four in-repo test assertions, a “wire contract” whose only consumer renders it as an opaque JSON blob, and downstream persisted rows. Census stands: 16 new Background(...) sites, all internal. |
| 3 | Flat entries[] joined to sections by ${section}\0${key} | Entries nested inside their section. BackgroundSection = {id, title, promotion, entries[]}; BackgroundEntry = {key, value, memoryIds} | The section field existed to stamp entries with which legacy record they were folded from. With no fold, it is a join key for a join with no reason. Nesting makes D-2’s “every entry lives in a declared section” true by construction. |
| 3 | normalizeBackgroundInput() in the ctor before super(); mandatory AgentConfig ctor pre-normalization; “declare all four legacy sections in canonical order”; migrateBackgroundData() | All deleted. BackgroundSchema.parse(raw) returns the authored declaration verbatim; .parse({}) returns {sections: []} | Nothing left to normalize. C1’s workaround and C2’s live hazard both evaporate: a ctor-bypassing .parse() now loses nothing. |
| 3 | merge() override justified as “reproduces JS object-spread positioning exactly”, 8 parity fixtures | Override survives on a smaller, honest warrant, ~3 fixtures. Merge by identity (section.id, then entry.key); this’s order wins; other replaces wholesale | Byte identity is dropped. The base class concatenates arrays, which with the new uniqueness refinement makes a.merge(b) throw whenever both declare a section — a worse contract than the base’s own “other overwrites”. Public-API hygiene, zero in-repo call sites, and this ADR says so. |
| 3 | id-uniqueness asserted in prose, enforced nowhere | superRefine on the sections array pins unique section.id and unique entry.key within a section (outer stays a ZodObject, so C1 holds) | placementKey is only an address if (sectionId, key) names at most one slot. Decision 9’s store lookup and Decision 10’s authored-wins both assume it. Correctness, not compat. |
| 3 | value: unknown, _formatDict recursion, provenance-dependent markdown escaping | value: string | number | boolean; rendering is one line, - **${key}**: ${value}, with a uniform provenance-independent newline collapse on every interpolated slot | Escaping was needed because value: unknown gave the renderer three arms and a recursive one, and because the mitigation had to know who authored the text. Collapse makes a value structurally unable to begin a line, so line-leading #/-/*/> is unreachable — for authored, host-derived and memory-derived text alike. |
| 3 | Byte-identical rendering as the reshape’s safety argument; “no -u” as an acceptance criterion | Dropped as a goal. Demoted to a throwaway porting diagnostic, deleted with the branch | It bought exactly “no in-repo snapshot churn”, which the ruling voids. It was self-defeating anyway: the ADR deferred two real render bugs to a later PR that moves the same bytes. |
| 3 / Rejected | ##-inside-## Context nesting and the ## Current State collision deferred to Phase C | Fixed here, and across all three producers: background.ts, awareness.ts:152, and recall.ts:79’s SCAFFOLD_LINES. ## Context is the only ## | Only reason to defer was the byte-identity proof. The defect is a property of the section, and pinning a fragment partition over a known-wrong heading tree bakes it in. |
| 4 | target stays on MemoryRecord and MemoryWriteInput as “an advisory signal a spec may read” | target deleted from both. Placement is the only address vocabulary. memory-record.ts carries no composition vocabulary | After D4/D6/D9 the field has zero readers by construction. “Advisory signal” is the same “two semantics in one field forever” defect this ADR uses to reject widening it. Removing it from MemoryWriteInput costs nothing — there is no host addressing capability today to regress from. |
| 4 | payload kept on record + write input as an opaque host/import channel | payload deleted from record, write input, model-facing schema, and the SQLite DDL | Identical evidence to target — no model writer, no validator, no reader. Applying opposite verdicts to the same evidence was residual compat. Phase C reintroduces it with its consumer. |
| 4 | memory_save = {kind, content, tags?, label?, payload?, supersedes?} | memory_save = {kind, content, tags?, label?, supersedes?} — five flat fields, zero unions, zero nested objects | payload’s only stated purpose was satisfying the structured target arms the model no longer authors. |
| 5 | label? justified negatively as “the only pure-sync answer”; optionality assumed; no charset floor | label? survives, re-derived positively against a now-live field of alternatives. Optionality is argued. Gains a control-character/newline floor as general data hygiene. Enters MemoryRecordViewSchema | “Only” was scoped by “without changing the stored record shape”. With a free hand, three alternatives became live and had to be beaten on merit. Required label would convert “model omits” (safe) into “model guesses badly” (catastrophic collision). |
| 5 | Absent-label degradation silent | keySource: "label" | "content" on the promotion row — the signal/address split of Decision 9 | A record with no stable address is append-only and reconcile is inert for it; Decision 10’s “none silent” rule must cover that. |
| 7 | primitive: "background" kept because “placementKey is a stored address and must stay injective when arms are added” | Kept because Placement’s arms differ structurally (background carries section+key; judgment would carry domain+slot), so a discriminated union is the natural shape even at width one | The stored-injectivity warrant was forward-compat on data that does not exist yet, on a disposable db. specVersion on the promotion row makes pre-release re-derivation free. |
| 9 | RoutingContext.sections unified with BackgroundSection (entries included) | RoutingContext.sections: readonly Pick<BackgroundSection, "id" | "title" | "promotion">[], projected with one .map() | If ctx carries current content, a spec can legally read it and the committed placement stops being reproducible — the fold stops being order-independent. Correctness constraint, restored. |
| 12 | AgentPromptSectionData = {name, source, text} + fragments? optional; partition pinned by a test | = {name, source, fragments}. text deleted; readers join. fragments required and non-empty | Keeping text beside fragments is a second source of truth for the same bytes — the exact charge this ADR levels at renderPath. The invariant becomes definitional only when there is nothing to disagree with. |
| 12 | PromptSection keeps render(), gains optional renderFragments? | PromptSection = {name, renderFragments(ctx?)} — one producing method. PromptRenderer.renderInitial’s duplicate assembly deleted; renderContinuation joins fragments too | “Optional because required would break external implementers” is verbatim compat. The partition is total only if every section can attribute its bytes. |
| 12 | Fragment source: "role" | "instance" | "memory", plus a server-minted "unknown" arm | Fragment source: "authored" | "memory". Section source stays "role" | "instance" on Agent.renderSections’s table (server widens to "unknown" on the wire) | The 3-arm fragment union duplicated the section source on every non-memory fragment and needed a collapse rule nobody wanted. Two values say the only thing a fragment knows that its section does not. Also answers “what is StateSection.source?” — it is not a section-class property. |
| 12 | memoryChannel: "overlay" | "recall"; RecallResult.recordIds; RenderContext.recall widened to {text, memoryIds?} | All three cut from v1. The "memory" arm carries memoryIds only | Verified: nothing in the repo sets RenderContext.recall (agent-runner.ts:456-459 returns scope ? {scope} : undefined), so the “known-false instance attribution” it cured has zero producers. The only reason to add a field before its consumer exists is to avoid a later break — which the ruling voids. |
| 12 | Server mirror at routes/composition.ts:112 + an Equals<A,B> drift guard + exporting AgentIntrospect | Server type-imports AgentPromptSectionData from core (already a declared dependency). Mirror, guard and export all deleted. renderPath deleted (derived from sections.some(s => s.source === "unknown")) | The structural-typing precedent at config.ts:33-45 guards nominal identity (instanceof); import type is erased and TS is structural, so it does not reach this member. |
| 12 | Dashboard falls back to a single <pre> when fragments is absent | Dashboard requires fragments; the server normalizes at the wire boundary | The producer is a duck type (typeof a.renderSections === "function"), not a validated one, so a hand-rolled AgentLike returning the old shape would ship fragments: undefined and crash the SPA. Normalizing once, server-side, in the function that already synthesizes the "unknown" arm, is what makes the required type true. Correctness, not compat. |
| 14 | “A malformed stored target must degrade… ships one release before any vocabulary change” — six new exported core surfaces | PR #455 has not merged (verified: gh pr list → #455 OPEN, mergedAt: null). Close or rewrite it. The durable property is runtime-local: a per-row try/catch at _rowToRecord’s callers that skips, counts and reports — no core export, no field-specific union | The tolerant arm existed because a closed enum union sat on a stored record. Deleting target removes the failure class at the source. The seam’s principle (one bad row must not blind a partition) is correctness and survives; its machinery defended exactly one field. |
| 14 | Tolerance pinned in conformance Tier 1 | Pinned in the SQLite-specific suite | InMemoryMemoryStore holds parsed frozen records and never re-parses on read (memoryRecord() appears only on write paths, store.ts:150,184,252), so a universal-tier test cannot construct the failure. |
| 16 | Landing order gated on the mem/* release stack; “no codemod”; migrateBackgroundData as a deprecation seam | Version ceremony deleted. Land in dependency order; bump once at the end with whatever absolute numbers are correct then. No codemod, no seam, no deprecation window | Pure release ceremony over downstream persisted data, voided by name. |
What the challengers caught and this revision fixes (recorded so it is not re-litigated): the free-hand pass over-corrected on the single-line value constraint (it never asked what applyMemoryOverlay must accept — MemoryRecordSchema.content is unconstrained prose), on section.id uniqueness (asserted, unenforced), on RoutingContext.sections (declaration list turned into a content list), and on speculative recall-attribution plumbing. It carried residual compat in the merge() warrant, the payload-vs-target asymmetry, the Placement discriminant warrant, the MemoryWriteInput “capability regression”, and — most consequentially — in keeping text on AgentPromptSectionData while deleting renderPath for being derived.
Context
The companion agent forgets things it demonstrably saved. “My name is Doug” then, in a fresh conversation, “what’s my name?” works; “who am I?” returns nothing. assembleRecall has three tiers (recall.ts:155-186): a query-independent profile tier, an opt-in pinned-candidate tier, and a hits tier that passes the first user message verbatim to store.search. The hits tier is lexical — “who am I?” shares no content word with “The user’s name is Doug” — and there is no zero-hit fallback. kind:"profile" records are phrasing-proof and that tier works; it is empty in practice because nothing taught the model to write them. That half is already fixed on PR #451 (kind guidance in the Manual + a profile sub-budget) and is not relitigated here.
The residue #451 does not solve is the design problem: a fact or preference whose wording misses the next question is unreachable, permanently. ADR-0008’s answer is right — identity and durable preferences should not be retrieved, they should be composed. applyMemoryOverlay is the mechanism and it does not exist. Two structural problems block writing it.
Problem A — the Background atom is the wrong shape. BackgroundSchema (atoms/background.ts:9-14) is four z.record(z.unknown()) fields, rendered by toPrompt() (:26-45) as up to four fixed ## headings. All four are project-shaped. A companion has nowhere to put “the user’s name is Doug” — the conformance kit’s own canonical record files a user’s UI preference under conventions (memory/conformance.ts:78-82). The census: 16 new Background(...) sites repo-wide, every one a library-internal default or a test; zero in examples/, agents/, or presets/; conventions is used exactly once, in a test proving the heading renders (atoms.test.ts:250). Nothing ships on this vocabulary — which, with compat withdrawn, is now the whole argument for deleting it rather than folding into it. And Background renders as one undivided blob inside a single {name:"Context", source:"instance"} entry shared with Awareness (organisms/agent.ts:138-153), so ADR-0008 D3’s fragment-level attribution is not expressible today.
Problem B — the model decides where a memory lands. BackgroundTargetSchema (molecules/memory-record.ts:61-65) pins a four-arm section enum plus a free-string key, and that enum is embedded in the model-facing memory_save schema (memory/toolbox.ts:137). Placement is a per-write judgment the model gets wrong, varies by model, and — under ADR-0008 D4’s Auto tier (0008:71, “no approval”) — a wrong target edits the agent’s own system prompt with no human in the loop. A wrong kind merely costs recall quality; a wrong target costs prompt integrity.
The shape this ADR reaches for already exists in-repo. workflows/backpack.ts is a run-scoped accumulator where a developer-declared BackpackSpec owns the hooks and the producer never decides placement: expand (:79, null ⇒ skip, recorded; throw ⇒ fail loud at the write site), identify (:88), merge (:94), finalize (:101), renderEntry (:104) — all pure and synchronous, with hydrateThenDrop (:643-651) as the single named async escape hatch, and a structural test asserting the module emits nothing (__tests__/backpack.test.ts:344-351). Memory wants the same division of labour: the model saves content; a developer-declared spec decides where.
Decision
-
This ADR supersedes ADR-0008’s atom-immutability promise, and says exactly which half. Three statements are withdrawn:
0008:7— “no changes to any atom’s schema or rendering”, and the same line’s parenthetical thatAgentPromptSectionData.sourcegains"memory". (0008:7and0008:65contradict each other: the Scope line puts"memory"on the section, D3 puts it “at the fragment level”. D3 governs; the Scope line was shorthand. This ADR puts it on the fragment, two-valued — see Decision 12.)0008:99— “the anatomy absorbs memory with zero schema changes to atoms”.docs/memory/evolution-cookbook.md:30-33— “ADR-0008 adds zero schema or rendering changes to atoms”.
What replaces them, and it is stronger than the pre-revision claim:
Background’s schema is replaced andBackground’s rendering changes. The prior version of this decision claimed byte-identical rendering as the safety argument; that claim is withdrawn along with the goal (see Decision 3). ADR-0008’s actual claimed benefit — “No new render sections … facts land under existingBackgroundheadings” (0008:98) — survives, because the section list is unchanged and memory still lands under a heading inside## Context; what moves is the heading depth and the vocabulary behind it. A new ADR rather than an amendment, because a fresh reader of 0008 must not inherit a promise that is false. ADR-0008 D3, D4, D5, D6 and D8 survive intact and are cited throughout.0008:55’s “targetis a proposal, not a promotion” is retired outright (Decision 4). Everything else in 0008 stands. -
Doug’s three settled constraints, recorded not re-argued. (Unchanged by the revision.)
- D-1 — vocabularies are opt-in presets, not schema defaults. Section vocabularies ship in
packages/agent-runtime/src/presets/memory/. The existing four become a project/company preset; a user-background preset ships beside it. The framework declares no opinion on user data until a developer opts in. - D-2 — memory may FILL a declared section; it may NEVER DECLARE one. Capability by declaration. Verbatim the rule ADR-0008 already sets for judgment domains (
0008:61). - D-3 — backends must not diverge on search semantics. The conformance kit exists to guarantee this. It overrules
docs/memory/guide.md:536-537(“Match granularity … deliberately NOT pinned”). That bullet is deleted (Decision 13).
- D-1 — vocabularies are opt-in presets, not schema defaults. Section vocabularies ship in
-
Backgroundis one array of declared sections, each holding its own entries. The four legacy records are deleted.BackgroundEntrySchema = z.object({key: z.string().min(1),value: z.union([z.string(), z.number(), z.boolean()]),memoryIds: z.array(z.string().min(1)).default([]),});BackgroundSectionSchema = z.object({id: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/), // a persisted addresstitle: z.string().min(1),promotion: z.enum(["locked", "guarded", "earned", "auto"]).default("locked"),entries: z.array(BackgroundEntrySchema).default([]),});BackgroundSchema = z.object({sections: z.array(BackgroundSectionSchema).default([]).superRefine(uniqueIdsAndKeys),});There is exactly one way to express a Background.
toPrompt()iteratessectionsin declaration order, emits### ${title}for sections that have entries, and renders each entry as exactly one line.Uniqueness is enforced, not asserted.
superRefinesits on thesectionsarray, not on the outer object, soBackgroundSchemaremains aZodObjectwith an intact.shapeand C1 is satisfied;.parse({})still yields{sections: []}. It pins uniquesection.idacross the array and uniqueentry.keywithin a section. This is a correctness requirement, not tidiness:placementKeyis an address only if(sectionId, key)names at most one slot, and Decision 9’s placement-keyed store lookup and Decision 10’sauthored-winsfold-time decline both depend on it. Duplicates are otherwise reachable throughreplace({sections}), through direct construction, and through the overlay itself.Nesting, not a flat
entries[]with asectionfield. The flat shape existed to be foldable-into from four legacy records —sectionon the entry was the stamp saying which record it came from. With no fold, it is a join key for a join with no reason. Nesting makes D-2’s core invariant — every entry lives in a declared section — true by construction: an orphan entry is unrepresentable in the type rather than skipped at render.placementKeybecomesJSON.stringify([sectionId, key]), injective without the\0sentinel (whose injectivity depended on section ids never containing NUL, which nothing enforced).description?is dropped: nothing renders it, and an unused field in a schema is the dead weight being deleted elsewhere; it is additively re-addable if the dashboard wants it.Values are scalars, and rendering collapses newlines uniformly. Every entry renders as
- **${collapse(key)}**: ${collapse(String(value))}, wherecollapsereplaces any\r?\n\s*run with a single space.titlegoes through the same collapse. This is the whole anti-injection mechanism and it replaces the escaping layer entirely:- A key, a title and a value can never begin a line, so a line-leading
#,-,*,>or code fence is structurally unreachable through them. No escaping, no/mstructural test pinning an escape table. - The collapse is provenance-independent. The rejected design escaped memory-derived text and left authored text alone, which meant identical bytes rendered differently depending on who wrote them.
toPrompt()now has zero provenance awareness. - The fold stays total. This is the constraint the free-hand pass got wrong: it narrowed
valueto a single-line regex, butMemoryRecordSchema.contentisz.string().min(1)— unconstrained model-authored prose (memory-record.ts:194), andmemory_save’s own description (“One durable fact, prompt-ready, standalone”,toolbox.ts:135) forbids nothing. With every normalization seam deleted and C1 ruling out.transform(), a multi-line memory would have had nowhere to be collapsed and would have thrown insideapplyMemoryOverlay, or needed a decline reason that does not exist. Collapsing at render is the only answer that is total, uniform and requires no new decline. - The scalar union rather than
z.string()is deliberate and it is a data-path decision, not authoring ergonomics.Backgroundis built from runtime data at the ADR-0004 instantiate seam:composition.test.ts:653-665is the shape of it — a per-tenant hook building a Background from fetched values and the server shipping the result atPOST /agents/:id/composition/delivered. Az.string()-only schema turns a host handing over a number into an HTTP 500 on a path that previously tookz.unknown(). Numbers and booleans cannot contain newlines, so the union preserves every property above at the cost of oneString(). - Stated design consequence, with an owner:
Backgroundvalues are now a constrained authored/derived vocabulary. An instantiate host with a non-scalar value (an object, an array) must render it to a scalar before constructing. That is the framework’s position, not an oversight, and it belongs indocs/memory/guide.md.
Three defects die with the legacy records, and are named because they are live today, not hypothetical:
_formatDict’s recursive arm crashes withRangeError: Maximum call stack size exceededon a self-referencing value thatz.record(z.unknown())accepts happily; its array arm renders- **arr**: [object Object], [object Object]; andAgenticModel’sObject.freeze(schema.parse(data))is one level deep, so(b.data.teamContext as any).team = "MUTATED"succeeds on a supposedly frozen atom and changestoPrompt()output. With scalar leaves the only unfrozen thing left is the array/object spine, which a plain recursive freeze covers — no cycle guard needed, because a cycle is unrepresentable.Deleted with the legacy records, and this is most of the simplification:
normalizeBackgroundInput()and the runs-in-the-ctor-before-super()construction C1 forced; the mandatoryAgentConfigctor pre-normalization and with it C2 as a live hazard (verified:BackgroundSchema.parse(raw)at all three ctor-bypassing sites returns the authored declaration verbatim, and.parse({})returns{sections: []}— a config that declares nothing yields an overlay that declines everything, which is D-1’s opt-in posture working correctly, not a silent no-op); the “declare all four legacy sections in canonical order even when empty” corollary; the core-hardcodes-the-runtime-preset’s-tiers coupling (a layer-rule-adjacent smell with nothing enforcing the agreement);migrateBackgroundData(raw); the${section}\0${key}composite key; and 24 render/merge parity fixtures.merge()is still overridden, on a smaller warrant than before.AgenticModel.merge()is type-directed and concatenates arrays (base.ts:85-88), so merging two Backgrounds that both declareuser_profileproduces twouser_profilesections — which, with the uniqueness refinement above, means the inherited path throws. Throwing on any two Backgrounds that share a section id is a worse public contract than the base class’s own “other overwrites” doctrine. So: merge by identity, one uniform rule at both levels — ids inthiskeepthis’s position; ids only inotherappend inother’s order; on collisionother’s record replacesthis’s wholesale, includingmemoryIds, never a union (unioning would attribute a value to memories that did not produce it).otherwins ontitleandpromotiontoo; both sides are authored config, so there is no privilege-escalation path and no need for a most-restrictive-tier lattice, which would be a second source of truth about tiers. Stated plainly:Background.merge()has zero call sites in this repo — the only.merge(hits arebase.test.tsandbackpack.ts’s unrelated spec hook. This override is speculative-but-correct public-API maintenance and this ADR does not pretend otherwise. Three fixtures: new-id append, colliding-id replace, nested entry-key collide.Invariant that must not be violated later:
applyMemoryOverlaymust not be implemented asbackground.merge(memoryBackground). That would launder memory entries through a path that ignorespromotionentirely.Rendering changes, deliberately, and across all three offenders. Background sections emit
### ${title}inside## Context, matching how every other atom nests (Persona→### Tone,Mission→### Objective,Capability→### ${name}).Awareness.toPrompt()’s## Available Information Sources(awareness.ts:152) andassembleRecall’s## Recalled Memories(recall.ts:79,SCAFFOLD_LINES, appended into Context viarecallRender) move to###in the same change. Fixing Background alone would leave## Contextcontaining a mix of##and###children, which is worse than uniform depth — and that is a quality argument about the heading tree, not an argument about matching existing behaviour. Contract, stated so it does not decay: any text a host injects into Context viascopeRender/recallRendermust be###-or-deeper. The## Current Statecollision with theStateatom (atoms/state.ts:43) resolves by deletion: the colliding heading was hardcoded atbackground.ts:41and no longer exists — titles are data now, and no preset may re-pick that title (the project preset shipsProject State/ idproject_state). The collision was real: the two headings never co-occur in one message (Background renders inrenderInitial, State only inrenderContinuation,renderer.ts:42-63), but a model in a live conversation sees both across turns and cannot tell which is being updated.An empty section is skipped, not headed — the one legacy quirk kept, on its own merit and for a sharper reason than “that is what it did”. Under D-1 a developer declares a vocabulary up front and memory fills it over time, so declared-but-empty is the normal steady state. Emitting
### About the Userwith nothing under it is not merely prompt noise; it is an empty slot the model can see and is invited to fill by inference. - A key, a title and a value can never begin a line, so a line-leading
-
MemoryTargetis DELETED. Placement is derived by the routing spec at promotion. (R-1 — adopted, with its first rationale discarded.)R-1’s determinism argument is unsound and must not appear in this ADR:
targetwas a frozen field on a frozen record, so the fold was deterministic no matter who produced the value;contentis equally model-authored and is rendered verbatim. Non-determinism at write time is not non-determinism at fold time. The three arguments that survive:- Blast radius. ADR-0008 D4 puts
background.*in the Auto tier — “written + reconciled (audited via events; no approval)” (0008:71). Auto sections are real and shipped: the user-background preset declarespromotion: "auto"precisely so the motivating case works. A model-authored address into an auto section is an unapproved edit to the agent’s own system prompt. - Model-independence, sharpened by D-1.
MemoryTargetSchemais a static module-level Zod value embedded in the tool schema (toolbox.ts:137). Per-agent vocabularies cannot be expressed there without minting a per-toolbox tool schema. Once the vocabulary is developer-declared there is no stable enum left to show the model. - Reversibility. Adding a model hint later is additive. Withdrawing authority later is not.
The field goes, not just the model’s authority over it.
targetis removed fromSaveParamsSchema(toolbox.ts:137), fromMemoryRecordViewSchema(:128), fromMemoryWriteInputSchema(store.ts:57), and fromMemoryRecordSchema(memory-record.ts:202). After Decisions 4, 6 and 9 it has zero readers by construction: the model can no longer write it,sameTargetis deleted (D6),recall.ts:168’s candidacy predicate becomes “routing would place it” (D9), andapplyMemoryOverlayconsumesPlacedRecordfrom promotion rows (D9). The only surviving reader the pre-revision text named — “an advisory signal a spec may read” — is precisely the “two semantics living in one field forever” defect this ADR uses to reject wideningtarget.sectiontoz.string(). A milder instance of a defect is still the defect.Removing
targetfromMemoryWriteInputcosts nothing.memory-record.ts:95-96already documents that “the target is stored and returned untouched; nothing acts on it”, and a repo-wide grep finds no reader inagent-server,agent-dashboard,examples/oragents/. There is no host addressing capability today to regress from — only a field that gets stored and handed back. A host-supplied promotion on the Decision 9 promotion-row surface is therefore a new feature, scoped on its own merits, not a restoration.payloadgoes with it, on identical evidence. After this decisionpayloadhas no model writer (removed fromSaveParamsSchema), no validator (targetPayloadSchemaand theMemoryRecordSchema.superRefineare both deleted with the target union), and no reader — every.payloadsite in the subsystem is pass-through persistence (store.ts:158,sqlite-store.ts:280,285,288,491). Keeping it “as an opaque host/import channel awaiting Phase C” would be the exact justification this decision refuses fortarget. Phase C reintroducespayloadwith its consumer, which is when its shape can be designed against a reader instead of guessed.Removed:
BackgroundTargetSchema,JudgmentTargetSchema,ExampleTargetSchema,AwarenessTargetSchema,RecoveryTargetSchema,ManualTargetSchema,MemoryTargetSchema,type MemoryTarget(memory-record.ts:56-110);ExampleTargetPayloadSchema,AwarenessTargetPayloadSchema,targetPayloadSchema()and itsneverexhaustiveness guard (:112-167); theMemoryRecordSchema.superRefine(:206-221), so the module reverts from aZodEffectsto a plainZodObject;sameTarget()(toolbox.ts:92-107); thetargetandpayloadcolumns from the v1 SQLite DDL, theirRawRowfields, INSERT columns, serialize andJSON.parsesites; 13 barrel exports frommolecules/index.ts— 10 value exports (:47-53,:60,:62,:65) and 3 type exports (:68,:69,:78); thetargetround-trip case in the conformance kit (conformance.ts:81,92).SQLite is edited in place.
MEMORY_SCHEMA_V1is aCREATE TABLE IF NOT EXISTSblock guarded byMEMORY_TARGET_SCHEMA_VERSION = 1(sqlite-store.ts:74-104,_migrate:448-467). Drop the two columns from the v1 DDL, keep version 1, delete the disposable db. No bump, no migration, no dead column.One argument that was offered for this deletion and does not hold, recorded so nobody re-runs it: “the target union is ADR-0008 composition vocabulary leaking into an ADR-0007 data contract”.
memory-record.ts’s docblock declares the module as ADR-0007 and ADR-0008 (“Decision 1, target union shape only”) and advertises payload-shape validation.targetwas declared scope, not a leak; the module’s declared ADR-0008 scope is withdrawn along with the field. The argument that does hold is zero readers. The second supporting fact also holds and is worth quoting:memory-record.ts:97-99says the schema “carries the full union now because it is breaking to widen the stored record later” — the six-arm width existed for an explicitly compat reason, which is now void. - Blast radius. ADR-0008 D4 puts
-
The model authors a
label; the spec authors the address.targetcarried two things — a section and akey. Withdrawing model authority over the section is well-argued; withdrawing it over the key needs its own answer, and every pure-sync alternative fails: one fixed key perkindmakes “the user’s name is Doug” and “the user lives in Denver” collide at one address (the rekeyed D2 gate then demands a supersede that destroys the first fact — a catastrophic regression on the exact motivating scenario); a content hash makes nothing ever collide, soreconcileis dead code and the section grows until budget spill; deriving a key from prose is the thing a pure spec explicitly cannot do.Therefore:
memory_savegainslabel?: string(≤64 chars) — a NAME for the thing learned (“name”, “timezone”, “theme”), carrying no section, no primitive, and no addressing authority. The spec may slugify it into the key segment, or ignore it. If absent, the spec falls back to a content-derived key.With the record shape free, this is re-derived positively rather than by elimination-under-constraint:
- Why a scalar name and not a structured pair. A
subject/predicaterecord shape fails becausesubject(“user”, “project”) is a section name by another route — it hands back the placement authority Decision 4 withdraws, breaching D-2 through the schema rather than through the fold. It also doubles the structural surface the Manual must teach and halves the traffic behind each field, which is #451’s lesson. - Why a record field and not a routing computation. A key computed with no record field collapses into content-derivation, which is stable only against byte-identical re-saves: “my name is Doug” and “the user’s name is Doug” land at two addresses forever.
- Why not
tags.tagsis a set, so extracting a key segment from it needs an arbitrary choice function.labelis a scalar and therefore injective by construction. - Why optional, argued rather than assumed. Making it required would delete the fallback branch, which is tempting now that nothing stops a required field. It is wrong: required converts “the model omits it” (safe — unique key, append-only) into “the model guesses badly” (
"info"for both name and timezone → the catastrophic collision this decision exists to prevent, followed by a supersede that destroys the first fact). - Charset floor.
labelrejects control characters and newlines at parse. This is general data hygiene — a ≤64-char scalar name has no business containing control characters — not a rendering constraint imported from an ADR-0008 atom. Slugification stays in the spec, not the record, so “Name” and “name ” collapse to one address while the record keeps what the model actually said. It does not discharge any escaping obligation, and does not need to: Decision 3’s uniform newline collapse discharges that structurally. labelentersMemoryRecordViewSchema, because the rewritten D2 conflict guidance tells the model to “supersede it or use a differentlabel” and it must be able to see the existing one.
The absent-label degradation is reported, not silent: the derived placement carries
keySource: "label" | "content"on the promotion row. It belongs on the row rather than the record because of Decision 9’s signal/address split — the record carries what the model observed, the row carries what the system decided — not because “no schema change” is cheaper. Decision 10’s “none silent” rule then covers the case where a record has no stable address, and budget spill can see it. - Why a scalar name and not a structured pair. A
-
The routing spec: type in core, instances in runtime presets, resolved by name at the instantiate seam. (Unchanged by the revision.)
molecules/memory-routing.ts(Layer 2, besidememory-record.ts) declaresPlacement,placementKey, and the spec interface. Preset instances live inpackages/agent-runtime/src/presets/memory/per D-1. Resolution mirrorscapabilities: z.array(z.string())+CapabilityResolver(atoms/agent-config.ts:68, docblock:13-16) exactly — a spec is a bag of functions, so it cannot live inAgentConfig, which is “declarative, serializable” by its own docblock.interface MemoryRoutingSpec {readonly key: string; readonly version: string;readonly route: (r: RoutableRecord, ctx: RoutingContext) => Placement | null | undefined;readonly reconcile?: (a: RoutableRecord, b: RoutableRecord) => "a" | "b" | "both";readonly finalize: (placed: readonly PlacedRecord[], report: RoutingReport) => OverlayPlan;readonly renderEntry?: (placed: PlacedRecord) => string;}All hooks pure and synchronous, mirroring
backpack.ts:19-25. Mapping:route ← expand(same null-means-skip contract),reconcile ← merge,finalize ← finalize,renderEntry ← renderEntry— now load-bearing, because ADR-0008 D3 needs a per-fragment attribution unit. Dropped from the backpack shape:identify(aPlacementis typed, so identity is a framework function —placementKey— not a spec hook);absorb(no branch scope); the[#N]IndexedView machinery (memory’s handles are record ids, which D3 already requires). Added: a frozenctxsecond argument, andversion(a spec outlives runs; its identity must land in the ledger,0008:82).placementKey(p)— total, injective, stable — replacessameTarget(toolbox.ts:92-107) entirely, and fixes a live bug in doing so:sameTargetreturnstruefor any twoawarenesstargets and any tworecoverytargets (toolbox.ts:100-101, “discriminant-only arms”), so today all awareness memories collide as one address and the D2 gate lets exactly one through per scope, forever. That bug dies whether or nottargetleaves the record;placementKeybeing total, injective and stable is a requirement of storing addresses on promotion rows.The routing module emits nothing, per the backpack precedent (
backpack.ts:15-17), pinned by a ported structural source-scan test. Instrumentation lives in the promotion Play, which ownsagent.memory.promote/agent.memory.overlay(0008:80). -
v1 routes
backgroundonly. An earlier design gaveRoutingContexta declaration list for background sections and judgment domains and none forexample,awareness,recoveryormanual— so D-2 was mechanically enforced for two of six arms, andrecovery(guarded tier,0008:73) had the highest blast radius with no declaration gate at all. Rather than invent four declaration mechanisms speculatively,PlacementSchemain v1 admits thebackgroundarm only. This narrows0008:130’s Phase B (“auto-tier only — background/awareness”) to background.sectionisz.string().min(1)— forced by D-1 regardless of authorship, and, now thatBackgroundTargetSchema’s four-name enum is deleted, there is no competing vocabulary left for it to disagree with.The
primitive: "background"discriminant stays, on a corrected warrant. The pre-revision text justified it as “placementKeyis committed to promotion rows as a stored address and must remain injective when arms are added, so the namespace is a correctness requirement on stored data”. That is forward-compat on data that does not exist yet — promotion rows arrive later in this stack and live in a disposable db — andspecVersionon the row makes pre-release re-derivation free. The honest warrant:Placement’s arms will differ structurally (background carriessection+key; judgment would carrydomain+slot), so a discriminated union is the natural shape even at width one. -
B-2 settled: NEWEST-CREATED WINS, tiebreak
idascending. (Unchanged by the revision.) The merged specs contradict.0008:62says the overlay “renders the newest deterministically”;docs/memory/guide.md:354says “older wins”.guide.md:635-641andevolution-cookbook.md:830-835partly reconcile this — conflicts are supersede-first, enforced at write in #421 — and that half is settled. What remains contradictory is the residual arbitration direction.Newest wins. Three reasons: newest is already the shipped resolution at the write-time collision picker (
toolbox.ts:322-327— stated as an inference about intent, since that reducer chooses which record to report); it matches every other recency semantic in memory (store.ts:130-133,sqlite-store.ts:239, the profile tier atrecall.ts:155-158); and under older-wins a corrected fact loses to the one it corrects whenever the agent forgetssupersedes— permanently unfixable, reproducing the exact bug class this program exists to close.The equal-
createdAtcase is common, not exotic: both reference stores assign onenowper batch (store.ts:139,sqlite-store.ts:258-259) and the conformance kit ships atick()helper because ties are the default.idascending, compared with raw</>(neverlocaleCompare— locale- and ICU-dependent), is arbitrary but total and stable, which is the entire requirement. Order bycreatedAt, neverupdatedAt(conformance.ts:286-298).Pin this in
applyMemoryOverlay’s own unit tests, not the store conformance kit. D-3 governs search semantics across backends; the overlay tiebreak is a pure core fold and no backend can diverge on it.Documents corrected in the same PR:
guide.md:354;guide.md:641andevolution-cookbook.md:835;guide.md:578-586(“the model may propose targets” / “memory_savedoes exposetarget”) → “the model contributes content signals; the routing spec derives placement”. -
Routing is EVALUATED freely and COMMITTED once, at promotion.
route()is pure, sync and cheap, so it may be evaluated wherever useful: atmemory_save, for the rekeyed D2 gate; insideassembleRecall, because ADR-0008 D4’s candidate exposure currently identifies candidates byrecord.target !== undefined(recall.ts:168) and that predicate dies with the field — candidacy becomes “routing would place it”, computable in-process with no store change; and in the dashboard /lintCompositionpreview (0008:88). It is committed exactly once, at promotion, onto the ADR-0008 D5 promotion row asplacement+placementKey+keySource+specKey+specVersion+promotedAt+ tier.Why the promotion row and not the record: recomputing at instantiate means a spec edit silently re-places memories a human approved under the old address; and a create-only
MemoryWriteInputSchemacould only write a placement at birth, i.e. before promotion, which is the semantics being removed. The signal/address split this implies is the design’s spine and every question in this ADR resolves by asking which side of it a thing belongs on. The RECORD carries what the model observed —content,kind,tags,label— durable, immutable, part of the memory. The PROMOTION ROW carries what the system decided —placement,placementKey,keySource,specKey,specVersion,promotedAt, tier — revisable, approvable, versioned.RoutingContext.sectionsis a declaration projection, not the live sections. It isreadonly Pick<BackgroundSection, "id" | "title" | "promotion">[], derived fromconfig.background.sectionswith one.map(). Decision 3 unifies the authoring type so there is oneBackgroundSection, but routing must not seeentries. Ifctxcarried current content a spec could legally read it, and then the address a record routes to would depend on which records were already composed — the committed placement would stop being reproducible and the fold would stop being order-independent, which is a binding constraint. It also keeps preset vocabularies opinion-free per D-1: a vocabulary type that includesentriesinvites the framework to ship content.Two consequences stated rather than buried. (a)
applyMemoryOverlay(config, records)as written at0008:7is unimplementable — the signature becomesapplyMemoryOverlay(config, placed: PlacedRecord[], spec) → { config, report }, with the runtime performing the record ⋈ promotion-row join. (b) Store precondition: rekeying the D2 gate onto a derived address means essentially everymemory_savenow routes, and today’s gate pays an unfiltered 500-record recency page (COLLISION_SCAN_LIMIT,toolbox.ts:67-75, whose docblock documents that past 500 newer records “its collision slips past the gate”). An exact-equalityplacementKeylookup on the promotion-row surface — conformance-pinned from day one — is a precondition of the gate rekey, not a follow-up. (Note the correction the stack plan makes: the lookup is a promotion-row operation, not aMemorySearchQueryfield, because nothing on a record carries a placement.) -
Routing outcomes are three-way, all recorded, none silent, and none a throw on the normal path. (Unchanged by the revision.)
route()returnsnull⇒ declined, reasonspec-declined. The record stays recall-tier forever — which0008:116already calls the correct failure mode.- The placement names a section not in
ctx.sections, or a committed placement’s section was later removed ⇒ declined, reasonundeclared-section. - The address is already held by authored config ⇒ declined, reason
authored-wins(0008:60). A fold-time decline, not a route-time one — authored config can change after promotion — and the report separates the two. - A
Placementthat failsPlacementSchema.parse⇒ throw. That is the only throw, and it is a spec bug by construction.
No fourth
unrenderable-contentreason is needed, because Decision 3’s uniform newline collapse means nothing a memory can contain fails to render.An earlier design made an undeclared section a throw, framed as “the mechanical enforcement of D-2”. It inverts D-2. D-2 says memory may fill a declared section; the correct consequence for an agent with no user-background section is “this fact stays recall-only”. Concretely: one shared preset spec, two agents, agent B opts out per D-1 — every
memory_saveand every turn-1assembleRecallfor agent B would throw. A developer following D-1 exactly as written would brick agent B. Backpack’s throw is safe because it is confined to one write site; here evaluation is spread across three, one on the first-user-message critical path.The outcome is never reported back to the model. A
memory_savereturn that sayscomposed: falseteaches the model to rephrase until it gets composed — the placement-gaming authority Decision 4 withdraws, re-acquired through a feedback channel. Outcomes go to the report, the event, and the dashboard. -
Async classification is a named separate function, never an async hook. (Unchanged by the revision.)
classifyThenRoute(records, ctx, spec, classify)mirrorshydrateThenDrop(backpack.ts:637-651) exactly, including that the client comes from the tool’s own deps and never from the spec. The classifier may only enrich advisory signals; it may never produce aPlacement— otherwise every argument in Decision 4 returns through a side door. Signals persist on the promotion row, not by mutating the record (the store is create-only).Signals on the promotion row do not exist at
memory_saveorassembleRecalltime, so under the async path the address used by the D2 gate and by recall candidacy is computed without signals while the committed address is computed with them. They disagree by construction. v1 resolves this by shipping the shape unused:classifyThenRouteis exported, no preset uses it, the pure path is the only live path. Enabling it requires either confining evaluation to promotion only, or a pre-promotion annotation operation on the store protocol. Named, not solved. -
Attribution: sections are made of fragments, and
textis gone.// atoms/base.ts — layer 0, beside RenderContextexport interface PromptFragment {readonly source: "authored" | "memory";readonly text: string; // never ""readonly memoryIds?: readonly string[]; // non-empty iff source === "memory"}// rendering/sections/base.tsexport interface PromptSection {readonly name: string;renderFragments(ctx?: RenderContext): readonly PromptFragment[]; // [] ⇒ section filtered out}// organisms/agent.tsexport interface AgentPromptSectionData {readonly name: string;readonly source: "role" | "instance";readonly fragments: readonly PromptFragment[]; // non-empty}textis deleted, not derived. Keeping a storedtextbesidefragmentsis a second source of truth for the same bytes, which is exactly the charge this decision levels atrenderPathtwo paragraphs down; a “derived” field on a hand-constructible type is still a field two producers can disagree about, and every literal construction site — including the server’s own fallback synthesis — would have to supply both. Readers join:renderInitialPrompt(ctx)isrenderSections(ctx).map(s => s.fragments.map(f => f.text).join("")).join("\n\n"). The partition invariant stops being a test and becomes the definition, because there is nothing left to disagree with. Separators fold into the fragment slices — nofragmentSeparatorfield, and now not merely by convention but by force, since there is no glue.PromptSectionhas one producing method.render()is deleted from the protocol. “OptionalrenderFragments?because required would break external implementers of an exported interface” was verbatim compat; the architecture argument runs the other way, since the partition is total only if every section can attribute its bytes. All 7implements PromptSectionsites are inagent-core; 6 are single-source and their body becomesconst t = <old body>; return t === "" ? [] : [{ source: "authored", text: t }].PromptRenderer.renderInitial— a second, duplicate implementation of prompt assembly with no production caller (renderer.ts:42-52) — collapses into the same join, andrenderContinuation(:60-65) joins fragments too rather than being left calling a method that no longer exists.Fragment
sourceis two-valued:"authored" | "memory". Section-levelsourcestays"role" | "instance"and stays onAgent.renderSections’s table, where the pre-revision design had it — source is a fact about the assembly, not about the section class. That placement answers what would otherwise be an unanswerable required field:StateSectionrenders execution state “managed by loops, not agents” (atoms/state.ts:31-33) and has no"role" | "instance"answer of its own. A three-armed fragment union (role | instance | memory) would duplicate the section’s source on every non-memory fragment and needs a collapse rule (“does one memory fragment make the section memory?”) that no consumer wants. Two values say the only thing a fragment knows that its section does not: whether these bytes came from the store. Structural text — the## Contextwrapper (context.ts:21), section headings — is"authored"by rule, so the partition stays total.No
memoryChannelin v1. The pre-revision design shippedmemoryChannel: "overlay" | "recall"and the free-hand pass then proposed plumbing recall attribution end to end (RecallResult.recordIds,RenderContext.recallwidened fromstringto{text, memoryIds?}). Both are cut. Verified: nothing in this repo setsRenderContext.recall—AgentRunner._renderCtx(agent-runner.ts:456-459) returnsscope ? {scope} : undefinedandClaudeCodeRunneruses the same helper — so the “known-falsesource: instanceattribution of recall bytes” has zero producers, and introspection is nullary so no recall fragment exists to attribute anyway. WideningRenderContext.recallwould also contradict its own docblock guard (atoms/base.ts:29-32: “a finished string, never structured data”) and ADR-0007 D8a. The only reason to add a field before its consumer exists is to avoid a later breaking change, which is the ruling’s first casualty.memoryChannelandrecordIdsland in the PR that first wires recall into a render and first reads the ids.AwarenessRecallRenderFn = (recall: string) => stringstays as it is — re-decided on merit, not preserved: the hook formats text and core places it, which is the least authority that works.Per-entry provenance lives on the entry, as schema data (
BackgroundEntry.memoryIds), not in a side map.AgenticModelfreezesschema.parse(data)(base.ts:48), zod strips unknown keys, andbuildAgentFromConfigreconstructs vianew Background(cfg.data.background)(:99) — a side map does not survive one instantiate. Stronger still:AgentRegistration.instantiateis typed(context?) => Promise<AgentLike>(agent-server/src/config.ts:111), so the overlay report is structurally discarded at the ADR-0004 instantiate seam; a ctx-threaded map has nothing to be threaded from. And the server callsrenderSections()nullary (routes/composition.ts:687).memoryIdsalso does double duty as ADR-0008 D3’ssourcediscriminator —memoryIds.length > 0means memory-derived — which is why Decision 3’s merge replacesmemoryIdswholesale rather than unioning.The server mirror is deleted; the wire is normalized there.
AgentIntrospect.renderSections?: (ctx?: RenderContext) => AgentPromptSectionData[], type-imported from@agentic-patterns/core— already a declared runtime dependency of@agentic-patterns/server, andAgentPromptSectionDatais already barrel-exported. TheEquals<A,B>drift-guard helper, the drift-guard test, and the requirement to exportAgentIntrospectall disappear with the mirror. The structural-typing precedent atconfig.ts:33-45does not reach this member: its documented hazard is nominal identity across two core module instances (“NEVERinstanceof SessionScope”), andimport typeis erased at build. But the producer is still a duck type —composition.ts:686branches ontypeof a.renderSections === "function"and forwards the result unvalidated — so a hand-rolledAgentLikereturning the old{name, source, text}shape would shipfragments: undefinedto a dashboard whose type says it is required. The route therefore normalizes, in the same function that already synthesizes the"unknown"arm:s => ({ name: s.name, source: s.source, fragments: s.fragments ?? [{ source: "authored", text: s.text ?? "" }] }). One site, server-side, and it is what makes the required field actually true.renderPathis deleted from the payload, fromapi/composition.ts, fromRenderedPromptView’s props and chip branch, and from theAgentLensPage.responsive.test.tsxfixture. It is exactlysections.some(s => s.source === "unknown")and the dashboard’s own docblock says so (RenderedPromptView.tsx:11). The joined fallback itself survives, on architecture:PromotedAgentgenuinely has norenderSections(workflows/as-agent.ts:162-175),AgentLikeis deliberately minimal (runner/types.ts:17-32) so introspection must stay optional at the type level, and fabricating"instance"would be the confident-but-wrong attribution the route forbids (composition.ts:682-688)."unknown"stays at section level only, where it already lives and whereSOURCE_TONE.unknownalready renders a chip — it does not enter the fragment union.ContextSectiondoes not split, andBackgroundgets no prompt section of its own. One of the two pre-revision arguments for this is now void and is withdrawn: “a promoted Background would emit N##headings” stopped being true the moment Background emits###. The argument that stands on its own: the split’s only claimed benefit is Background-vs-Awareness attribution, which is precisely what the fragment partition delivers, so it buys nothing already bought.Background.toPrompt()stays nullary —RenderContextforwarding is deliberately confined toAwareness(context.ts:32) and a nullarytoPrompt()is the atom contract.The dashboard mirror (
api/composition.ts:20-25) stays hand-maintained, and this is coupling, not compat:@agentic-patterns/dashboardis a private browser SPA with no Node types, and type-importing@agentic-patterns/serverwould pull hono and the server’s whole.d.tsgraph into the SPA’s typecheck. The dashboard is an HTTP client of a payload the server owns; hand-declaring the response type is what HTTP clients do absent codegen. Say so rather than pretend otherwise.RolesPage.tsx:110is fixed here, not deferred.Object.keys(a.background).lengthreports a constant4today and would report a constant1under the new shape, for every agent, forever. It countsbackground.sections.lengthand the total entry count, rendered asN sections · M entries.AgentLensPage.tsx:309renders the blob opaquely and is checked for the same class of regression.Also deleted while in this interface:
AwarenessDomainLike.access_method(composition.ts:119-121, read at:423asd.accessMethod ?? d.access_method). Core’sAwarenessDomainSchema(awareness.ts:9-13) has onlyaccessMethod; the snake_case arm is a pre-ADR-0002-rename shim. -
Conformance: Tier 1 universal, Tier 2 per capability class; both backends change. (Unchanged by the revision, except that the Decision 14 pin moves out of Tier 1 — see there.) D-3 is enforced by splitting
runMemoryStoreConformanceinto a universal tier and acaps.search-keyed tier over one shared corpus, asserting match sets (ids sorted), never total order.Measured divergences:
InMemoryMemoryStore.searchmatches substrings (store.ts:223-228) so"am"hits"name"and"prefer"hits"Prefers", while FTS5 matches whole tokens and returns zero; FTS5 folds diacritics and in-memory does not. The in-memory store adopts token semantics — FTS5 is what the shipped companion runs on, and the future Postgres tsvector backend is token-based too, so aligning in-memory aligns 3-of-3 rather than 1-of-3.(a) SQLite changes too.
sqlite-store.ts:377-385tokenizes the query on whitespace and OR-joins each token as a quoted FTS5 phrase; if in-memory splits on punctuation while SQLite keeps a punctuated token as an adjacency phrase, the fix creates a new divergence. Lift one shared exportedtokenize()used by both, and have SQLite OR-join the resulting sub-tokens individually. (b) The multi-token boolean combinator is the most portability-critical axis.sqlite-store.ts:380-383documents OR as deliberately chosen against FTS5’s implicit AND; Postgresplainto_tsquerydefaults to AND. Tier 2 must contain a case whose tokens do not co-occur in one record, or a Postgres backend declaring"keyword"passes every test and is still non-conformant.Tier 1 additionally pins batch-tie ordering (
write([a,b,c])in one call, then a limited query-less listing — today in-memory returns[a,b]and SQLite returns[c,b], different records). Total ranking order is explicitly NOT pinned — pinning it would force in-memory to reimplement bm25 and then fail ats_rankbackend, relocating the divergence rather than removing it. D-3 is honoured on match semantics and on the two ordering invariants already in the kit; it is narrowed on total rank order, stated here rather than left implicit.guide.md:536-537is deleted and replaced by the Tier-1/Tier-2 statement plus this carve-out. -
A bad stored row must degrade one record, not blind a partition — and that property is runtime-local.
The failure is real and reproduced: rewrite one row’s
target.sectionto an unrecognised value and every query-less listing on that partition throws, because_rowToRecord(sqlite-store.ts:477-495) runsmemoryRecord()→MemoryRecordSchema.parseinsiderows.map. One bad row kills the partition’s recall; it does not skip a record. And the query-less listing is exactly the profile tier (recall.ts:152-158), the phrasing-proof tier this program leans on. That property is correctness, is independent of which field broke, and survives this revision unchanged.Everything built around it does not. Two corrections:
(a) The premise was wrong. PR #455 has not merged. Verified:
gh pr listreports #455OPEN,mergedAt: null, headmem/routing-3-tolerant-target; its commit is not an ancestor oforigin/mainand not on this branch;memory-record.tshere contains noreadStoredMemoryRecord,StoredMemoryTargetSchema,UnknownMemoryTargetSchema,isKnownTargetorMemoryRecordDegradation(the only hits in the tree are stale gitignoreddist/output). So there is nothing to retire and no installed base to be careful about. Close or rewrite #455 before merge — strictly cheaper than retiring shipped symbols, and it turns what read as a compat cost into a pure deletion.(b) The tolerant arm existed because a closed enum union sat on a stored record. Decision 4 deletes the union, so the failure class is removed at the source rather than absorbed. With
targetandpayloadgone there is no v1 field that can degrade,StoredMemoryTargetSchemawould describe a shape nothing can store, andisKnownTarget()would narrow three call sites that no longer exist. Retire the field-specific machinery; keep the property.What ships instead: a private per-row
try/catchinSqliteMemoryStorearound_rowToRecord, which skips the row, increments a counter, and surfaces the count on the read result. Roughly ten lines, runtime-local, no core export at all — a zero-inhabitant public surface kept for an undated future is exactly the dead weight this revision deletes elsewhere. This must actually be implemented, not assumed to follow from a seam existing.Pinned in the SQLite suite, not conformance Tier 1. Tier 1 is universal and runs against both backends, but
InMemoryMemoryStoreholds already-parsed frozen records and never re-parses on read (memoryRecord()appears only atstore.ts:150,184,252, all write paths), so a universal-tier test cannot construct an unparseable row for it. This is not a D-3 breach — D-3 governs search semantics — but the pin as previously written could not be authored. Alternative if it is wanted universally: a backend-declared corruption hook in the kit, pinned only for backends that declare it.No SQL migration and no
PRAGMA user_versionbump — but for a different reason than before. Decision 4 editsMEMORY_SCHEMA_V1in place to drop thetargetandpayloadcolumns, keepsMEMORY_TARGET_SCHEMA_VERSION = 1, and the disposable db is deleted. -
B-3 is a COORDINATION ITEM, and this revision makes it louder. ADR-0008 names an external
codegen-patternsagentssubsystem that persists overlays (0008:10,0008:83) — and0008:10itself reads “External (design alignment only)”, the strongest available evidence that this is forward-looking alignment rather than an installed base. Probes run from here found noMemoryTargetreference, no@agentic-patterns/*dependency, and noagentsormemorysubsystem on the branches inspected. That is not proof — GitHub code search indexes default branches only, and two of ~40 branches were walked.Under the ruling this is not a blocker and not a reason to soften any decision: same org, so it is a coordination cost, paid by a conversation. But it is sharper than before, because three changes went from additive to wholesale:
- Grep for
MemoryTarget/MemoryTargetSchema/BackgroundTargetSchemaimports from@agentic-patterns/core. Previously “would still compile”; now a type error. - Grep for any exhaustive switch on a
target.primitivediscriminant. Previously a no-op; now a required change in that repo, loud rather than silent — which is the better failure mode, and ADR-0002’s precedent covers it. - Confirm whether that repo writes
target/payloadon its own rows. Determines whether it needs the promotion-row surface before this lands. - Grep for
runMemoryStoreConformance— ADR-0007 D11 (0007:94) asserts that subsystem imports and runs the kit, and Decision 13 changes its shape. - Grep for persisted
AgentPromptSectionDatapayloads. The question changes from “is anything typed against this” to “is anything persisting this”, sincefragmentsis now required andtext/renderPathare gone. - New: grep for persisted
background.teamContext/projectContext/conventions/currentStatekeys. This is the one genuinely silent failure in the whole revision: such a row parses to{sections: []}with Zod stripping the rest and no diagnostic.
One correction to the record:
MemoryTargetis exported from@agentic-patterns/corebut is not re-exported by@agentic-patterns/runtime— the external surface is narrower than previously believed. - Grep for
-
Landing order. No version ceremony.
The previous version of this decision was a release plan: absolute versions computed against npm
latest, a precondition that themem/*release stack merge tomainfirst, a “no codemod” argument, and an exportedmigrateBackgroundData(raw)deprecation seam for owning apps. All of it is release/deprecation ceremony over downstream persisted data, which the ruling voids by name. Deleted. Bump once at the end of the stack, with whatever absolute numbers are correct at that moment; the mechanical footgun thatscripts/publish.sh:127-131,160-163skips already-published versions silently is worth remembering, but it is a scripting hazard, not a design input. The 16new Background(...)sites are hand-edited in one sitting. Owning apps that persistAgentConfigrows re-author them, and that is an upgrade-time cost they opt into by bumping — ADR-0002’s own stated precedent, set for a harder break.Landing order, which is about dependencies and about the measuring instrument, not about releases:
- Point the eval harness at the shipped backend. It constructs
new InMemoryMemoryStore()per family and per case while the companion bootsloadMemoryStore()→ SQLite, so the recall family currently scores the substring matcher and the budget family’s truncation depends on a batch-tie order reversed between backends. Pass an explicit temppath(never a process-wideAP_MEMORY_DB_PATH, which would point evals at the user’s real db), and exit 2 ifloadMemoryStorereportsunavailable— it silently returns an in-memory store on both failure paths, which is precisely the bug being fixed. This one is not negotiable and not re-orderable: no claim of the form “routing improved recall” is falsifiable until it lands. - Conformance Tier 1 / Tier 2 + the shared tokenizer.
- Per-row read tolerance (Decision 14) — now a small runtime-local change with no ordering constraint relative to anything else, since it no longer defends a field.
- Routing types +
label+ presets. Backgroundreshape + attribution + the heading fix.- Promotion rows, the overlay, then the
target/payloaddeletion together with the D2 gate rekey in one PR (see Consequences — splitting them silently kills the gate).
- Point the eval harness at the shipped backend. It constructs
Consequences
Good
- Identity stops depending on phrasing. A
kind:"profile"record routed by a user-background preset composes permanently, and the lexical hits tier becomes irrelevant for identity questions — the reported bug, closed structurally rather than by better search. - The framework ships no opinion about user data. A companion declares a user vocabulary; a code agent declares a project one; neither inherits the other’s headings (D-1).
- Placement stops being a per-write model judgment. It becomes a developer-declared, versioned, testable pure function, with the same discipline the backpack already proves in-repo.
- The published surface gets smaller in every direction.
BackgroundSchemagoes from six fields expressing two representations to one field expressing one.memory-record.tsloses ~112 lines of target union plus a 16-linesuperRefineand reverts fromZodEffectsto a plainZodObject.memory_savegoes from six parameters including a 6-arm nested union and an open record to five flat fields with zero unions. 13 barrel exports retire.AgentPromptSectionDataloses a field;PromptSectionloses a method; three hand-mirrored declarations become two. - Whole mechanisms disappear, not just fields:
normalizeBackgroundInputand the ctor-before-super()construction; the mandatoryAgentConfigpre-normalization; C1’s workaround and C2 as a live hazard;migrateBackgroundData;_formatDict’s three interpolation arms and its unbounded recursion; the entire escaping layer and its/mtest; the provenance-dependent-rendering concept; the${section}\0${key}composite key;renderPath; theEquals<A,B>drift guard;PromptRenderer.renderInitial’s duplicate assembly; 24 parity fixtures. - Bug classes removed by construction rather than policed by tests: prompt-structure injection through a value (a value can no longer begin a line); orphan entries (unrepresentable in the type); duplicate section ids (rejected at parse); a section’s
render()disagreeing with itsrenderFragments()(there is one method);textdisagreeing with its fragments (there is notext);_formatDict’s stack overflow on a cyclic value (cycles unrepresentable); the shallow-freeze hole (scalar leaves). - Four latent bugs die as side effects:
sameTarget’s awareness/recovery collapse; one bad stored row killing a partition’s recall; the dashboard’s constant key count; the## Current Stateheading collision with theStateatom. - The heading tree inside
## Contextbecomes well-formed across all three producers, and the rule for host-injected Context text is written down.
Costs / risks — including the ones the compat constraint used to buy off, now real and paid once
- 16
new Background(...)sites are rewritten by hand:integration.test.ts:92,composition.test.ts:188,657,sections.test.ts:180,205,renderer.test.ts:45,agent.test.ts:81,563,agent.ts:77,84,194,atoms.test.ts:242,247,262,271,build-agent-from-config.ts:99. - Two snapshots get
-u, deliberately, with the diff reviewed as intended:rendering/__tests__/__snapshots__/renderer.test.ts.snap(the## Contextblocks at:65,:69) andorganisms/__tests__/__snapshots__/agent.test.ts.snap(:82,:87,:91).role.test.ts.snapandsections.test.ts.snapare unaffected — verified, neither contains Background output. Inline assertions rewritten:atoms.test.ts:245-275,sections.test.ts:180-210,agent.test.ts:333-341, and the four wire assertions atcomposition.test.ts:321,741,742,759(which now readsections[i].entries). - Prompt bytes move once. Provider prompt caches for existing agents invalidate; the eval prompt baseline is re-taken. This is paid once for all three heading producers together, which is the main reason to bundle them.
- The
/agents/:id/compositionpayload changes shape wholesale, in two places:instance.backgroundgoes{teamContext:{…}}→{sections:[…]}, andprompt.sections[]goes{name,source,text}→{name,source,fragments}withrenderPathgone. Allowed, and a coordination item for codegen-patterns if it persists either. - The
target/payloaddeletion and the D2 gate rekey must land in ONE PR. The gate attoolbox.ts:307-336filters candidates onrecord.target !== undefined && sameTarget(...). Delete the field earlier and the gate is either dead code or — worse — silently no-ops:args.targetis written but stripped by the record schema (z.objectdefaults to"strip"), sorecord.targetis alwaysundefined, the filter never matches, and every targeted save silently succeeds instead of returning the conflict envelope. That is exactly the C2 failure class this ADR names. - Routing accuracy transfers blast radius onto
kind. A wrongkindnow costs placement, not just recall quality — and PR #451’s kind guidance has no real traffic behind it yet. - Two live behaviour changes need test plans that do not exist: the D2 gate rekeyed onto a derived address fires on far more writes than today, and in-memory search stops matching substrings (a change every existing in-memory-backed test may feel).
- A store protocol extension (placement-keyed lookup on the promotion-row surface) becomes a precondition of the gate rekey, on a protocol ADR-0007 deliberately kept to six methods.
- The
labelfield adds a model-facing concept to teach, in a subsystem where the Manual — not the schema — is what makes the model write good structural fields. Its charset floor also means a model emitting a newline insidelabelnow gets a tool-arg validation error rather than a silently mangled key: recoverable, but a new failure the eval families should cover. merge()survives with zero in-repo call sites. It is speculative-but-correct public-API maintenance either way, and this ADR bills it that way rather than pretending it is load-bearing.- A host importing pre-classified memories now has no model-facing or write-input addressing path at all. Correct, but it must be documented rather than discovered.
- New machinery, honestly counted against the deletions:
keySourceon the promotion row; the per-row read-tolerance counter; theRoutingContext.sectionsprojection; the wire-boundary normalization in the composition route; thesuperRefineuniqueness check; the newline-collapse helper. Perhaps 40 lines against several hundred deleted — a good ratio, but not a pure subtraction.
Known limits, stated rather than hidden
- Routing accuracy is an empirical question this ADR cannot settle. A pure sync spec routes reliably on structural signals (
kind,tags,label, scope keys) and cannot route on prose semantics. Whetherkind:"profile"plus a preset actually catches the identity facts a companion saves is an eval, not an argument. - The Locked tier is a slot guarantee, not a behavioural one. An entry like
- **Operating principle**: always defer to the userunder an author-declared auto section changes behaviour as effectively as a persona edit, with no approval. The framework cannot distinguish a fact from an instruction in free prose.promotiondefaulting tolockedmeans an author who never opts in has no unapproved write path at all; it does not protect an author who does. - Prompt-structure injection is closed structurally, with one residue. Decision 3’s uniform newline collapse makes it impossible for a key, title or value to begin a line, so line-leading
#/-/*/>/fences are unreachable. What remains is cosmetic: an authoredkeycontaining**still breaks bold rendering (- **a**b**: v). That is called out in the docblock rather than fixed with a second regex — authors are trusted to write markdown-shaped values inline, which is already true ofPersonaandManual. earnedis inert for background in v1. ADR-0008’s recurrence mechanics (0008:76) work by corroborating a near-duplicate, but the shipped targeted-collision gate does the opposite —toolbox.ts:328-336returns{status:"conflict"}with no write and no corroboration, andcorroborateis a Phase-B operation.- Spill from the overlay to the recall tier is reported in v1, not wired.
0008:86promises over-budget records fall back to recall; doing that safely needs a fetch path that re-applies scope and validity filters, a cap so pinned spill cannot starve the hits tier, and an answer for the staleness window between instantiate and first user message. v1 ships the composed-id dedupe (which fixes a real double-render bug, sincerecall.ts:168matches promoted records too) and the report. - A committed placement can go stale. Placements freeze at promotion with a
specVersion. A section rename or a routing fix leaves prior placements pointing at an address that may no longer be declared. v1’s answer is a decline withundeclared-sectionand a lint entry — there is no re-route, re-promote or migrate operation, and defining one is deferred. Section ids are persisted addresses, so the naming pass before merge is a hard gate, not a nicety. - A record with no
labelis permanently append-only andreconcileis inert for it.keySource: "content"makes that visible in the report. OverlayPlan/RoutingReportfield lists are illustrative. One typeddropslist rather than parallel lists; ids only and never record content (the report feeds an SSE event; embedding content would leak user memories into telemetry); stats total over the v1 promotable set.0008:80’s “bytes per primitive” and the cookbook’sbytesPerPrimitivesites contradict the chars pin and must be renamedcharsPerPrimitive.- Budget numbers have no empirical basis. The policy is defensible — per-primitive, chars not tokens, overlay-background below
DEFAULT_RECALL_BUDGET_CHARS = 4000, whole-entry granularity, spill ordersupports.lengthDESC →createdAtDESC →idASC. The digits are not. Two non-negotiables: the composition-wide ceiling must sit strictly below the sum of per-primitive budgets orceilingBreachedis unreachable dead code; and the accounting counts only memory-derived entry text, a departure from recall’s scaffold-inclusive accounting. - Spill priority does not use
kind. Protectingkind:"profile"from spill would invert the substantive argument: profile records are the one kind with a guaranteed always-injected recall tier, so they are the safest to spill. - The overlay report is discarded before it can be displayed.
instantiatereturns a bareAgentLike, so the composition route never sees a report and the server has no store to recompute one. Combined with Decision 12’s rule that storedmemoryIdsis authorable data and therefore not display authority, overlay chips are currently unrenderable in the lens. See open question 5. - Multi-line memory prose renders as one line. The collapse is lossless in meaning and lossy in formatting; a memory whose value genuinely wants two lines should be two entries.
Rejected alternatives
- Model proposes, spec decides (hybrid placement). The proposal is worthless if the spec is authoritative, and non-worthless only if the spec is tempted to obey it — which re-opens exactly the authority being withdrawn.
labelis not this: it names the thing learned, not a section. The parse-time constraint keeps it a name, the Manual must keep teaching it as a name, and theroute-project-vocabulary-isolatedeval case is the tripwire against a spec that starts reading it as an address. - A second free-text hint parameter. The hint channel already exists and is called
tags— model-authored, persisted, searchable. - Keep model authorship and gate every background write. Defeats ADR-0008 D4’s Auto tier, whose entire point is that settled facts land without approval.
- Widen
MemoryRecord.target.sectiontoz.string()and addtargetSource. Still rejected, and now for a purely architectural reason rather than “breaking for an external exhaustive switch”: it leaves two semantics — model proposal, system address — living in one field forever. Deleting the field makes the alternative moot rather than merely unattractive, and the same argument is what condemns the earlier “keep it as an advisory signal” compromise. - Keep the four legacy
Backgroundfields. Every argument for retention was compat: in-repo test assertions, a “wire contract” rendered opaquely, and downstream persisted rows. Retention costs are permanent: dead fields in the schema, a normalizer that must synthesize all four in canonical order, amerge()override specified as “reproduce object-spread positioning exactly”, and every future reader learning two ways to express a Background. - Byte-identical rendering as the reshape’s safety argument. It bought exactly “no in-repo snapshot churn”. It was also self-defeating: this ADR previously deferred two real rendering bugs to a later PR that moves the same bytes anyway, so byte identity did not avoid the churn — it scheduled it twice and split one intentional diff across two review windows.
- Single-line
valueenforced at parse. Tempting (the constraint lives in the schema with an exact failing path) and wrong:MemoryRecordSchema.contentis unconstrained prose, so a two-line memory would throw inside a fold that must be total, or need a decline reason invented to absorb it. Uniform collapse at render is total, provenance-independent, and preserves every anti-injection property. - Recompute placement at every instantiate, store nothing. Cheapest to build; destroys ADR-0008 D5’s point-in-time reconstruction and makes gate approval meaningless.
- Make the routing spec Zod-serializable data so it can live in
AgentConfig. A declarative rule table is a DSL that will grow into a language. The backpack precedent is that developer-declared hooks are code. - Split
ContextSection, or giveBackgroundits own prompt section. Its only claimed benefit is Background-vs-Awareness attribution, which the fragment partition already delivers. (The old supporting argument — “it would emit N##headings” — is void now that Background emits###, and is withdrawn.) - Keep
textonAgentPromptSectionDatabesidefragments. A second source of truth for the same bytes, kept only because existing consumers read it. If a joined view is wanted for ergonomics, it is a helper function over fragments, not a stored field. - Plumb recall attribution now (
RecallResult.recordIds, structuredRenderContext.recall). Cures a false attribution with zero producers, to feed a consumer that does not exist. The only reason to add a field before its consumer exists is to avoid a later breaking change. promotable: booleanon a section. Cannot express “learnable, but a human confirms” — ADR-0008’s Guarded tier. The four-statepromotionenum reuses0008:69-74’s own tier names, so no new vocabulary enters the system.- Change
SqliteMemoryStoreto substring matching, or implement bm25 inInMemoryMemoryStoreto pin total order. The first abandons FTS5/bm25 on the production backend; the second would then fail a Postgrests_rankbackend. - Mark ADR-0008 Rejected. Overkill. D3, D4, D5, D6 and D8 all survive intact and are load-bearing here.
Follow-ups
- Preconditions: the eval harness pointed at SQLite (Decision 16); the B-3 coordination greps (Decision 15) — a conversation, not a gate; the promotion-row placement-keyed lookup, which is a precondition of the gate rekey (Decision 9).
- Instrument + portability: eval gate tiers and the two red families; the shared tokenizer; conformance Tier 1 / Tier 2; per-row read tolerance (Decision 14) — and close or rewrite PR #455, which is open and describes the retired design.
- Routing:
memory-routing.ts+placementKey+label+ the user-background and project presets.classifyThenRouteexported and unused. - Composition: the
Backgroundreplacement +merge()override + the###heading fix acrossbackground.ts,awareness.ts:152andrecall.ts:79+applyMemoryOverlay+ promotion rows. - Withdrawal, in one PR:
targetandpayloaddeleted from record / write input / tool schema / SQLite DDL,sameTargetdeleted, the D2 gate rekeyed onto the committed placement, recall candidacy predicate, composed-id dedupe. Splitting these silently no-ops the gate. - Attribution:
fragmentsonAgentPromptSectionDatawithtextdeleted, requiredrenderFragmentsonPromptSection, the server type-import + wire normalization,renderPathdeleted, the dashboard fragment chips, theRolesPagefix. - Docs sweep, one PR. Beyond the previously named
guide.md:354,:536-537,:578-586,:641;evolution-cookbook.md:30-33,:835, and thebytesPerPrimitivesites — the deleted four-record vocabulary is referenced live atdocs/memory/guide.md:196,276-277,306,512,525,docs/memory/evolution-cookbook.md:68,87,146,174,739, anddocs/playground-redesign.md:52. Several are worked examples with atarget: { primitive: "background", section: "conventions", key: "deployFreeze" }payload that will no longer parse against anything. - Phase C:
lintCompositionover the overlay report; overlay→recall spill wiring with its cap and validity requirements; awareness/judgment/example placement arms with their declaration surfaces;payloadreintroduced with its structured consumer;memoryChannel+ recall attribution when recall is first wired into a render.
Open questions for Doug
The pre-revision list had six. Two are resolved by the free hand and are recorded as closed rather than dropped: “accept the additive label? field?” — moot, because label is no longer additive; it replaces target as the model’s single structural contribution and the record is a net field smaller. And “does ADR-0008 change status?” — it gets a header pointer reading SUPERSEDED IN PART (see ADR-0009); a fresh reader hitting 0008:99 unaided is the failure mode this ADR exists to prevent, and there is no reason to leave that to taste.
- Default
promotionfor a declared section:lockedorguarded?lockedis honest (“declared for authoring, not for memory”) and matches D-1’s opinion-free posture.guardedis more useful but requires aHumanApprovalGate, and no agent has one wired today — so a guarded default means “the agent saves a fact and the fact never reaches the agent”, which is the originating bug shipped as the framework default. This ADR pickslocked; both are defensible. - Is the semantic Locked-tier bypass (Known limits, bullet 2) an accepted limit or a blocker for the auto tier? If it is a blocker, the user-background preset must ship
guarded, question 1 answers itself, and the paraphrase eval family cannot flip to a hard gate until an approval gate exists. - Should memory fragments be visibly marked in the prompt?
0008:98states the goal as “indistinguishable from authored composition in the prompt while remaining fully distinguishable in the tooling”. A values position, and the one thing that would make the semantic Locked-tier bypass legible to the model itself. - Budget digits. The relationships are defensible; the numbers are invented. Ship as tunable defaults with the ~10k-char worst-case memory-attributable prompt spend stated, or measure first?
- How does the overlay report reach the composition lens? Decision 12 says the report is the display authority, and
instantiatereturns a bareAgentLike(agent-server/src/config.ts:111), so the report is structurally discarded before the route sees the agent. Three options: widen the ADR-0004 instantiate seam to return{agent, report}(churn-free now, but it is an ADR-0004 change); have the report ride on the agent; or relax the rule to “stored attribution renders, marked unverified”. Until one is picked, overlay chips are unrenderable. - Is
labelthe right word? It is now the model’s only structural field and routing accuracy rides entirely on its quality.labelinvites section-shaped values (“user”, “project”);attributeactively discourages them, because “user” is not an attribute. This is an eval question — run theroute-*case table against both Manual phrasings — not something to settle by argument in the ADR. - Should
asAgent()emit a single role-sourced section, so first-party promoted pipelines stop rendering assource: "unknown"? The"unknown"arm must survive regardless (AgentLikeis deliberately minimal), so this deletes no code — it only stops the most common non-Agentproducer from being unattributed. Cheap; the question is whether attribution honesty for pipelines is worth one more surface onPromotedAgent. - Section id and title naming pass — a hard gate, not a nicety. Ids are persisted placement addresses, so renaming one later is a stored-placement migration; and no preset may re-pick the title
Current State, or theState-atom collision returns as data instead of as a hardcoded string.