Skip to content

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-coreatoms/background.ts (schema + class replaced: one sections[] of nested entries, the four legacy records deleted, merge() override, renderFragments(), ### headings); atoms/awareness.ts (### heading, renderFragments()); atoms/base.ts (PromptFragment declared at layer 0 beside RenderContext); molecules/memory-routing.ts (new: Placement, placementKey, MemoryRoutingSpec); molecules/memory-record.ts (target and payload deleted; additive label?); organisms/apply-memory-overlay.ts (new); organisms/agent.ts (AgentPromptSectionData = {name, source, fragments}text deleted); rendering/sections/base.ts (render() replaced by required renderFragments()); rendering/renderer.ts (renderInitial collapses into the shared join).
    • packages/agent-runtimepresets/memory/ (new: section vocabularies); memory/toolbox.ts (target and payload leave the model-facing schema; label enters; 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/payload columns dropped from the v1 DDL).
    • packages/agent-server — the hand-mirrored AgentPromptSectionData declaration is deleted in favour of a type-import from core; the composition payload’s instance.background and prompt.sections change shape wholesale; renderPath deleted; the access_method snake_case shim deleted.
    • packages/agent-dashboardapi/composition.ts mirror updated (still hand-maintained); RenderedPromptView.tsx fragment spans; RolesPage.tsx:110 count 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

#WasNow isWhy it changed
3BackgroundSchema = sections[] + flat entries[] + the four legacy z.record(z.unknown()) records, populated foreverBackgroundSchema = { 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.
3Flat 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.
3normalizeBackgroundInput() 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.
3merge() override justified as “reproduces JS object-spread positioning exactly”, 8 parity fixturesOverride survives on a smaller, honest warrant, ~3 fixtures. Merge by identity (section.id, then entry.key); this’s order wins; other replaces wholesaleByte 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.
3id-uniqueness asserted in prose, enforced nowheresuperRefine 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.
3value: unknown, _formatDict recursion, provenance-dependent markdown escapingvalue: string | number | boolean; rendering is one line, - **${key}**: ${value}, with a uniform provenance-independent newline collapse on every interpolated slotEscaping 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.
3Byte-identical rendering as the reshape’s safety argument; “no -u” as an acceptance criterionDropped as a goal. Demoted to a throwaway porting diagnostic, deleted with the branchIt 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 CFixed 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.
4target 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 vocabularyAfter 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.
4payload kept on record + write input as an opaque host/import channelpayload deleted from record, write input, model-facing schema, and the SQLite DDLIdentical 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.
4memory_save = {kind, content, tags?, label?, payload?, supersedes?}memory_save = {kind, content, tags?, label?, supersedes?} — five flat fields, zero unions, zero nested objectspayload’s only stated purpose was satisfying the structured target arms the model no longer authors.
5label? justified negatively as “the only pure-sync answer”; optionality assumed; no charset floorlabel? 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).
5Absent-label degradation silentkeySource: "label" | "content" on the promotion row — the signal/address split of Decision 9A record with no stable address is append-only and reconcile is inert for it; Decision 10’s “none silent” rule must cover that.
7primitive: "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 oneThe 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.
9RoutingContext.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.
12AgentPromptSectionData = {name, source, text} + fragments? optional; partition pinned by a test= {name, source, fragments}. text deleted; readers join. fragments required and non-emptyKeeping 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.
12PromptSection 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.
12Fragment source: "role" | "instance" | "memory", plus a server-minted "unknown" armFragment 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.
12memoryChannel: "overlay" | "recall"; RecallResult.recordIds; RenderContext.recall widened to {text, memoryIds?}All three cut from v1. The "memory" arm carries memoryIds onlyVerified: 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.
12Server mirror at routes/composition.ts:112 + an Equals<A,B> drift guard + exporting AgentIntrospectServer 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.
12Dashboard falls back to a single <pre> when fragments is absentDashboard requires fragments; the server normalizes at the wire boundaryThe 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 surfacesPR #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 unionThe 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.
14Tolerance pinned in conformance Tier 1Pinned in the SQLite-specific suiteInMemoryMemoryStore 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.
16Landing order gated on the mem/* release stack; “no codemod”; migrateBackgroundData as a deprecation seamVersion ceremony deleted. Land in dependency order; bump once at the end with whatever absolute numbers are correct then. No codemod, no seam, no deprecation windowPure 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 acceptMemoryRecordSchema.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

  1. 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 that AgentPromptSectionData.source gains "memory". (0008:7 and 0008:65 contradict 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 and Background’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 existing Background headings” (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 “target is a proposal, not a promotion” is retired outright (Decision 4). Everything else in 0008 stands.

  2. 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).
  3. Background is 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 address
    title: 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() iterates sections in declaration order, emits ### ${title} for sections that have entries, and renders each entry as exactly one line.

    Uniqueness is enforced, not asserted. superRefine sits on the sections array, not on the outer object, so BackgroundSchema remains a ZodObject with an intact .shape and C1 is satisfied; .parse({}) still yields {sections: []}. It pins unique section.id across the array and unique entry.key within a section. This is a correctness requirement, not tidiness: placementKey is an address only if (sectionId, key) names at most one slot, and Decision 9’s placement-keyed store lookup and Decision 10’s authored-wins fold-time decline both depend on it. Duplicates are otherwise reachable through replace({sections}), through direct construction, and through the overlay itself.

    Nesting, not a flat entries[] with a section field. The flat shape existed to be foldable-into from four legacy records — section on 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. placementKey becomes JSON.stringify([sectionId, key]), injective without the \0 sentinel (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))}, where collapse replaces any \r?\n\s* run with a single space. title goes 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 /m structural 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 value to a single-line regex, but MemoryRecordSchema.content is z.string().min(1) — unconstrained model-authored prose (memory-record.ts:194), and memory_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 inside applyMemoryOverlay, 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. Background is built from runtime data at the ADR-0004 instantiate seam: composition.test.ts:653-665 is the shape of it — a per-tenant hook building a Background from fetched values and the server shipping the result at POST /agents/:id/composition/delivered. A z.string()-only schema turns a host handing over a number into an HTTP 500 on a path that previously took z.unknown(). Numbers and booleans cannot contain newlines, so the union preserves every property above at the cost of one String().
    • Stated design consequence, with an owner: Background values 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 in docs/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 with RangeError: Maximum call stack size exceeded on a self-referencing value that z.record(z.unknown()) accepts happily; its array arm renders - **arr**: [object Object], [object Object]; and AgenticModel’s Object.freeze(schema.parse(data)) is one level deep, so (b.data.teamContext as any).team = "MUTATED" succeeds on a supposedly frozen atom and changes toPrompt() 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 mandatory AgentConfig ctor 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 declare user_profile produces two user_profile sections — 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 in this keep this’s position; ids only in other append in other’s order; on collision other’s record replaces this’s wholesale, including memoryIds, never a union (unioning would attribute a value to memories that did not produce it). other wins on title and promotion too; 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 are base.test.ts and backpack.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: applyMemoryOverlay must not be implemented as background.merge(memoryBackground). That would launder memory entries through a path that ignores promotion entirely.

    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) and assembleRecall’s ## Recalled Memories (recall.ts:79, SCAFFOLD_LINES, appended into Context via recallRender) move to ### in the same change. Fixing Background alone would leave ## Context containing 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 via scopeRender/recallRender must be ###-or-deeper. The ## Current State collision with the State atom (atoms/state.ts:43) resolves by deletion: the colliding heading was hardcoded at background.ts:41 and no longer exists — titles are data now, and no preset may re-pick that title (the project preset ships Project State / id project_state). The collision was real: the two headings never co-occur in one message (Background renders in renderInitial, State only in renderContinuation, 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 User with nothing under it is not merely prompt noise; it is an empty slot the model can see and is invited to fill by inference.

  4. MemoryTarget is 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: target was a frozen field on a frozen record, so the fold was deterministic no matter who produced the value; content is 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 declares promotion: "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. MemoryTargetSchema is 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. target is removed from SaveParamsSchema (toolbox.ts:137), from MemoryRecordViewSchema (:128), from MemoryWriteInputSchema (store.ts:57), and from MemoryRecordSchema (memory-record.ts:202). After Decisions 4, 6 and 9 it has zero readers by construction: the model can no longer write it, sameTarget is deleted (D6), recall.ts:168’s candidacy predicate becomes “routing would place it” (D9), and applyMemoryOverlay consumes PlacedRecord from 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 widening target.section to z.string(). A milder instance of a defect is still the defect.

    Removing target from MemoryWriteInput costs nothing. memory-record.ts:95-96 already documents that “the target is stored and returned untouched; nothing acts on it”, and a repo-wide grep finds no reader in agent-server, agent-dashboard, examples/ or agents/. 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.

    payload goes with it, on identical evidence. After this decision payload has no model writer (removed from SaveParamsSchema), no validator (targetPayloadSchema and the MemoryRecordSchema.superRefine are both deleted with the target union), and no reader — every .payload site 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 for target. Phase C reintroduces payload with 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 its never exhaustiveness guard (:112-167); the MemoryRecordSchema.superRefine (:206-221), so the module reverts from a ZodEffects to a plain ZodObject; sameTarget() (toolbox.ts:92-107); the target and payload columns from the v1 SQLite DDL, their RawRow fields, INSERT columns, serialize and JSON.parse sites; 13 barrel exports from molecules/index.ts — 10 value exports (:47-53, :60, :62, :65) and 3 type exports (:68, :69, :78); the target round-trip case in the conformance kit (conformance.ts:81,92).

    SQLite is edited in place. MEMORY_SCHEMA_V1 is a CREATE TABLE IF NOT EXISTS block guarded by MEMORY_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. target was 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-99 says 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.

  5. The model authors a label; the spec authors the address.

    target carried two things — a section and a key. 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 per kind makes “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, so reconcile is 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_save gains label?: 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/predicate record shape fails because subject (“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. tags is a set, so extracting a key segment from it needs an arbitrary choice function. label is 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. label rejects 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.
    • label enters MemoryRecordViewSchema, because the rewritten D2 conflict guidance tells the model to “supersede it or use a different label” 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.

  6. 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, beside memory-record.ts) declares Placement, placementKey, and the spec interface. Preset instances live in packages/agent-runtime/src/presets/memory/ per D-1. Resolution mirrors capabilities: 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 in AgentConfig, 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 (a Placement is 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 frozen ctx second argument, and version (a spec outlives runs; its identity must land in the ledger, 0008:82).

    placementKey(p) — total, injective, stable — replaces sameTarget (toolbox.ts:92-107) entirely, and fixes a live bug in doing so: sameTarget returns true for any two awareness targets and any two recovery targets (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 not target leaves the record; placementKey being 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 owns agent.memory.promote / agent.memory.overlay (0008:80).

  7. v1 routes background only. An earlier design gave RoutingContext a declaration list for background sections and judgment domains and none for example, awareness, recovery or manual — so D-2 was mechanically enforced for two of six arms, and recovery (guarded tier, 0008:73) had the highest blast radius with no declaration gate at all. Rather than invent four declaration mechanisms speculatively, PlacementSchema in v1 admits the background arm only. This narrows 0008:130’s Phase B (“auto-tier only — background/awareness”) to background. section is z.string().min(1) — forced by D-1 regardless of authorship, and, now that BackgroundTargetSchema’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 “placementKey is 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 — and specVersion on the row makes pre-release re-derivation free. The honest warrant: Placement’s arms will differ structurally (background carries section + key; judgment would carry domain + slot), so a discriminated union is the natural shape even at width one.

  8. B-2 settled: NEWEST-CREATED WINS, tiebreak id ascending. (Unchanged by the revision.) The merged specs contradict. 0008:62 says the overlay “renders the newest deterministically”; docs/memory/guide.md:354 says “older wins”. guide.md:635-641 and evolution-cookbook.md:830-835 partly 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 at recall.ts:155-158); and under older-wins a corrected fact loses to the one it corrects whenever the agent forgets supersedes — permanently unfixable, reproducing the exact bug class this program exists to close.

    The equal-createdAt case is common, not exotic: both reference stores assign one now per batch (store.ts:139, sqlite-store.ts:258-259) and the conformance kit ships a tick() helper because ties are the default. id ascending, compared with raw </> (never localeCompare — locale- and ICU-dependent), is arbitrary but total and stable, which is the entire requirement. Order by createdAt, never updatedAt (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:641 and evolution-cookbook.md:835; guide.md:578-586 (“the model may propose targets” / “memory_save does expose target”) → “the model contributes content signals; the routing spec derives placement”.

  9. Routing is EVALUATED freely and COMMITTED once, at promotion. route() is pure, sync and cheap, so it may be evaluated wherever useful: at memory_save, for the rekeyed D2 gate; inside assembleRecall, because ADR-0008 D4’s candidate exposure currently identifies candidates by record.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 / lintComposition preview (0008:88). It is committed exactly once, at promotion, onto the ADR-0008 D5 promotion row as placement + 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 MemoryWriteInputSchema could 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.sections is a declaration projection, not the live sections. It is readonly Pick<BackgroundSection, "id" | "title" | "promotion">[], derived from config.background.sections with one .map(). Decision 3 unifies the authoring type so there is one BackgroundSection, but routing must not see entries. If ctx carried 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 includes entries invites the framework to ship content.

    Two consequences stated rather than buried. (a) applyMemoryOverlay(config, records) as written at 0008:7 is unimplementable — the signature becomes applyMemoryOverlay(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 every memory_save now 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-equality placementKey lookup 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 a MemorySearchQuery field, because nothing on a record carries a placement.)

  10. Routing outcomes are three-way, all recorded, none silent, and none a throw on the normal path. (Unchanged by the revision.)

    • route() returns nulldeclined, reason spec-declined. The record stays recall-tier forever — which 0008:116 already calls the correct failure mode.
    • The placement names a section not in ctx.sections, or a committed placement’s section was later removed ⇒ declined, reason undeclared-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 Placement that fails PlacementSchema.parsethrow. That is the only throw, and it is a spec bug by construction.

    No fourth unrenderable-content reason 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_save and every turn-1 assembleRecall for 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_save return that says composed: false teaches 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.

  11. Async classification is a named separate function, never an async hook. (Unchanged by the revision.) classifyThenRoute(records, ctx, spec, classify) mirrors hydrateThenDrop (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 a Placement — 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_save or assembleRecall time, 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: classifyThenRoute is 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.

  12. Attribution: sections are made of fragments, and text is gone.

    // atoms/base.ts — layer 0, beside RenderContext
    export interface PromptFragment {
    readonly source: "authored" | "memory";
    readonly text: string; // never ""
    readonly memoryIds?: readonly string[]; // non-empty iff source === "memory"
    }
    // rendering/sections/base.ts
    export interface PromptSection {
    readonly name: string;
    renderFragments(ctx?: RenderContext): readonly PromptFragment[]; // [] ⇒ section filtered out
    }
    // organisms/agent.ts
    export interface AgentPromptSectionData {
    readonly name: string;
    readonly source: "role" | "instance";
    readonly fragments: readonly PromptFragment[]; // non-empty
    }

    text is deleted, not derived. Keeping a stored text beside fragments is a second source of truth for the same bytes, which is exactly the charge this decision levels at renderPath two 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) is renderSections(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 — no fragmentSeparator field, and now not merely by convention but by force, since there is no glue.

    PromptSection has one producing method. render() is deleted from the protocol. “Optional renderFragments? 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 7 implements PromptSection sites are in agent-core; 6 are single-source and their body becomes const 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, and renderContinuation (:60-65) joins fragments too rather than being left calling a method that no longer exists.

    Fragment source is two-valued: "authored" | "memory". Section-level source stays "role" | "instance" and stays on Agent.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: StateSection renders 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 ## Context wrapper (context.ts:21), section headings — is "authored" by rule, so the partition stays total.

    No memoryChannel in v1. The pre-revision design shipped memoryChannel: "overlay" | "recall" and the free-hand pass then proposed plumbing recall attribution end to end (RecallResult.recordIds, RenderContext.recall widened from string to {text, memoryIds?}). Both are cut. Verified: nothing in this repo sets RenderContext.recallAgentRunner._renderCtx (agent-runner.ts:456-459) returns scope ? {scope} : undefined and ClaudeCodeRunner uses the same helper — so the “known-false source: instance attribution of recall bytes” has zero producers, and introspection is nullary so no recall fragment exists to attribute anyway. Widening RenderContext.recall would 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. memoryChannel and recordIds land in the PR that first wires recall into a render and first reads the ids. AwarenessRecallRenderFn = (recall: string) => string stays 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. AgenticModel freezes schema.parse(data) (base.ts:48), zod strips unknown keys, and buildAgentFromConfig reconstructs via new Background(cfg.data.background) (:99) — a side map does not survive one instantiate. Stronger still: AgentRegistration.instantiate is 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 calls renderSections() nullary (routes/composition.ts:687). memoryIds also does double duty as ADR-0008 D3’s source discriminator — memoryIds.length > 0 means memory-derived — which is why Decision 3’s merge replaces memoryIds wholesale 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, and AgentPromptSectionData is already barrel-exported. The Equals<A,B> drift-guard helper, the drift-guard test, and the requirement to export AgentIntrospect all disappear with the mirror. The structural-typing precedent at config.ts:33-45 does not reach this member: its documented hazard is nominal identity across two core module instances (“NEVER instanceof SessionScope”), and import type is erased at build. But the producer is still a duck typecomposition.ts:686 branches on typeof a.renderSections === "function" and forwards the result unvalidated — so a hand-rolled AgentLike returning the old {name, source, text} shape would ship fragments: undefined to 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.

    renderPath is deleted from the payload, from api/composition.ts, from RenderedPromptView’s props and chip branch, and from the AgentLensPage.responsive.test.tsx fixture. It is exactly sections.some(s => s.source === "unknown") and the dashboard’s own docblock says so (RenderedPromptView.tsx:11). The joined fallback itself survives, on architecture: PromotedAgent genuinely has no renderSections (workflows/as-agent.ts:162-175), AgentLike is 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 where SOURCE_TONE.unknown already renders a chip — it does not enter the fragment union.

    ContextSection does not split, and Background gets 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 nullaryRenderContext forwarding is deliberately confined to Awareness (context.ts:32) and a nullary toPrompt() is the atom contract.

    The dashboard mirror (api/composition.ts:20-25) stays hand-maintained, and this is coupling, not compat: @agentic-patterns/dashboard is a private browser SPA with no Node types, and type-importing @agentic-patterns/server would pull hono and the server’s whole .d.ts graph 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:110 is fixed here, not deferred. Object.keys(a.background).length reports a constant 4 today and would report a constant 1 under the new shape, for every agent, forever. It counts background.sections.length and the total entry count, rendered as N sections · M entries. AgentLensPage.tsx:309 renders 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 :423 as d.accessMethod ?? d.access_method). Core’s AwarenessDomainSchema (awareness.ts:9-13) has only accessMethod; the snake_case arm is a pre-ADR-0002-rename shim.

  13. 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 runMemoryStoreConformance into a universal tier and a caps.search-keyed tier over one shared corpus, asserting match sets (ids sorted), never total order.

    Measured divergences: InMemoryMemoryStore.search matches 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-385 tokenizes 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 exported tokenize() 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-383 documents OR as deliberately chosen against FTS5’s implicit AND; Postgres plainto_tsquery defaults 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 a ts_rank backend, 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-537 is deleted and replaced by the Tier-1/Tier-2 statement plus this carve-out.

  14. 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.section to an unrecognised value and every query-less listing on that partition throws, because _rowToRecord (sqlite-store.ts:477-495) runs memoryRecord()MemoryRecordSchema.parse inside rows.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 list reports #455 OPEN, mergedAt: null, head mem/routing-3-tolerant-target; its commit is not an ancestor of origin/main and not on this branch; memory-record.ts here contains no readStoredMemoryRecord, StoredMemoryTargetSchema, UnknownMemoryTargetSchema, isKnownTarget or MemoryRecordDegradation (the only hits in the tree are stale gitignored dist/ 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 target and payload gone there is no v1 field that can degrade, StoredMemoryTargetSchema would describe a shape nothing can store, and isKnownTarget() 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/catch in SqliteMemoryStore around _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 InMemoryMemoryStore holds already-parsed frozen records and never re-parses on read (memoryRecord() appears only at store.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_version bump — but for a different reason than before. Decision 4 edits MEMORY_SCHEMA_V1 in place to drop the target and payload columns, keeps MEMORY_TARGET_SCHEMA_VERSION = 1, and the disposable db is deleted.

  15. B-3 is a COORDINATION ITEM, and this revision makes it louder. ADR-0008 names an external codegen-patterns agents subsystem that persists overlays (0008:10, 0008:83) — and 0008:10 itself 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 no MemoryTarget reference, no @agentic-patterns/* dependency, and no agents or memory subsystem 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:

    1. Grep for MemoryTarget / MemoryTargetSchema / BackgroundTargetSchema imports from @agentic-patterns/core. Previously “would still compile”; now a type error.
    2. Grep for any exhaustive switch on a target.primitive discriminant. 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.
    3. Confirm whether that repo writes target/payload on its own rows. Determines whether it needs the promotion-row surface before this lands.
    4. Grep for runMemoryStoreConformance — ADR-0007 D11 (0007:94) asserts that subsystem imports and runs the kit, and Decision 13 changes its shape.
    5. Grep for persisted AgentPromptSectionData payloads. The question changes from “is anything typed against this” to “is anything persisting this”, since fragments is now required and text/renderPath are gone.
    6. New: grep for persisted background.teamContext / projectContext / conventions / currentState keys. 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: MemoryTarget is exported from @agentic-patterns/core but is not re-exported by @agentic-patterns/runtime — the external surface is narrower than previously believed.

  16. Landing order. No version ceremony.

    The previous version of this decision was a release plan: absolute versions computed against npm latest, a precondition that the mem/* release stack merge to main first, a “no codemod” argument, and an exported migrateBackgroundData(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 that scripts/publish.sh:127-131,160-163 skips already-published versions silently is worth remembering, but it is a scripting hazard, not a design input. The 16 new Background(...) sites are hand-edited in one sitting. Owning apps that persist AgentConfig rows 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:

    1. Point the eval harness at the shipped backend. It constructs new InMemoryMemoryStore() per family and per case while the companion boots loadMemoryStore() → 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 temp path (never a process-wide AP_MEMORY_DB_PATH, which would point evals at the user’s real db), and exit 2 if loadMemoryStore reports unavailable — 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.
    2. Conformance Tier 1 / Tier 2 + the shared tokenizer.
    3. 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.
    4. Routing types + label + presets.
    5. Background reshape + attribution + the heading fix.
    6. Promotion rows, the overlay, then the target/payload deletion together with the D2 gate rekey in one PR (see Consequences — splitting them silently kills the gate).

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. BackgroundSchema goes from six fields expressing two representations to one field expressing one. memory-record.ts loses ~112 lines of target union plus a 16-line superRefine and reverts from ZodEffects to a plain ZodObject. memory_save goes from six parameters including a 6-arm nested union and an open record to five flat fields with zero unions. 13 barrel exports retire. AgentPromptSectionData loses a field; PromptSection loses a method; three hand-mirrored declarations become two.
  • Whole mechanisms disappear, not just fields: normalizeBackgroundInput and the ctor-before-super() construction; the mandatory AgentConfig pre-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 /m test; the provenance-dependent-rendering concept; the ${section}\0${key} composite key; renderPath; the Equals<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 its renderFragments() (there is one method); text disagreeing with its fragments (there is no text); _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 State heading collision with the State atom.
  • The heading tree inside ## Context becomes 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 ## Context blocks at :65,:69) and organisms/__tests__/__snapshots__/agent.test.ts.snap (:82,:87,:91). role.test.ts.snap and sections.test.ts.snap are 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 at composition.test.ts:321,741,742,759 (which now read sections[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/composition payload changes shape wholesale, in two places: instance.background goes {teamContext:{…}}{sections:[…]}, and prompt.sections[] goes {name,source,text}{name,source,fragments} with renderPath gone. Allowed, and a coordination item for codegen-patterns if it persists either.
  • The target/payload deletion and the D2 gate rekey must land in ONE PR. The gate at toolbox.ts:307-336 filters candidates on record.target !== undefined && sameTarget(...). Delete the field earlier and the gate is either dead code or — worse — silently no-ops: args.target is written but stripped by the record schema (z.object defaults to "strip"), so record.target is always undefined, 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 wrong kind now 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 label field 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 inside label now 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: keySource on the promotion row; the per-row read-tolerance counter; the RoutingContext.sections projection; the wire-boundary normalization in the composition route; the superRefine uniqueness 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. Whether kind:"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 user under 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. promotion defaulting to locked means 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 authored key containing ** 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 of Persona and Manual.
  • earned is 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-336 returns {status:"conflict"} with no write and no corroboration, and corroborate is a Phase-B operation.
  • Spill from the overlay to the recall tier is reported in v1, not wired. 0008:86 promises 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, since recall.ts:168 matches 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 with undeclared-section and 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 label is permanently append-only and reconcile is inert for it. keySource: "content" makes that visible in the report.
  • OverlayPlan / RoutingReport field lists are illustrative. One typed drops list 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’s bytesPerPrimitive sites contradict the chars pin and must be renamed charsPerPrimitive.
  • 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 order supports.length DESC → createdAt DESC → id ASC. The digits are not. Two non-negotiables: the composition-wide ceiling must sit strictly below the sum of per-primitive budgets or ceilingBreached is 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. Protecting kind:"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. instantiate returns a bare AgentLike, so the composition route never sees a report and the server has no store to recompute one. Combined with Decision 12’s rule that stored memoryIds is 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. label is 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 the route-project-vocabulary-isolated eval 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.section to z.string() and add targetSource. 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 Background fields. 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, a merge() 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 value enforced at parse. Tempting (the constraint lives in the schema with an exact failing path) and wrong: MemoryRecordSchema.content is 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 give Background its 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 text on AgentPromptSectionData beside fragments. 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, structured RenderContext.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: boolean on a section. Cannot express “learnable, but a human confirms” — ADR-0008’s Guarded tier. The four-state promotion enum reuses 0008:69-74’s own tier names, so no new vocabulary enters the system.
  • Change SqliteMemoryStore to substring matching, or implement bm25 in InMemoryMemoryStore to pin total order. The first abandons FTS5/bm25 on the production backend; the second would then fail a Postgres ts_rank backend.
  • Mark ADR-0008 Rejected. Overkill. D3, D4, D5, D6 and D8 all survive intact and are load-bearing here.

Follow-ups

  1. 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).
  2. 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.
  3. Routing: memory-routing.ts + placementKey + label + the user-background and project presets. classifyThenRoute exported and unused.
  4. Composition: the Background replacement + merge() override + the ### heading fix across background.ts, awareness.ts:152 and recall.ts:79 + applyMemoryOverlay + promotion rows.
  5. Withdrawal, in one PR: target and payload deleted from record / write input / tool schema / SQLite DDL, sameTarget deleted, the D2 gate rekeyed onto the committed placement, recall candidacy predicate, composed-id dedupe. Splitting these silently no-ops the gate.
  6. Attribution: fragments on AgentPromptSectionData with text deleted, required renderFragments on PromptSection, the server type-import + wire normalization, renderPath deleted, the dashboard fragment chips, the RolesPage fix.
  7. Docs sweep, one PR. Beyond the previously named guide.md:354, :536-537, :578-586, :641; evolution-cookbook.md:30-33, :835, and the bytesPerPrimitive sites — the deleted four-record vocabulary is referenced live at docs/memory/guide.md:196,276-277,306,512,525, docs/memory/evolution-cookbook.md:68,87,146,174,739, and docs/playground-redesign.md:52. Several are worked examples with a target: { primitive: "background", section: "conventions", key: "deployFreeze" } payload that will no longer parse against anything.
  8. Phase C: lintComposition over the overlay report; overlay→recall spill wiring with its cap and validity requirements; awareness/judgment/example placement arms with their declaration surfaces; payload reintroduced 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.

  1. Default promotion for a declared section: locked or guarded? locked is honest (“declared for authoring, not for memory”) and matches D-1’s opinion-free posture. guarded is more useful but requires a HumanApprovalGate, 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 picks locked; both are defensible.
  2. 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.
  3. Should memory fragments be visibly marked in the prompt? 0008:98 states 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.
  4. 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?
  5. How does the overlay report reach the composition lens? Decision 12 says the report is the display authority, and instantiate returns a bare AgentLike (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.
  6. Is label the right word? It is now the model’s only structural field and routing accuracy rides entirely on its quality. label invites section-shaped values (“user”, “project”); attribute actively discourages them, because “user” is not an attribute. This is an eval question — run the route-* case table against both Manual phrasings — not something to settle by argument in the ADR.
  7. Should asAgent() emit a single role-sourced section, so first-party promoted pipelines stop rendering as source: "unknown"? The "unknown" arm must survive regardless (AgentLike is deliberately minimal), so this deletes no code — it only stops the most common non-Agent producer from being unattributed. Cheap; the question is whether attribution honesty for pipelines is worth one more surface on PromotedAgent.
  8. 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 the State-atom collision returns as data instead of as a hardcoded string.