# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [9.91.0] - 2026-09-10

**Every chart that serves a model now mints a receipt.** A receipt is the proof
of what the model was actually handed, minted at the call and committed in the
bundle the call already writes — and until this release exactly ONE stage minted
one. Three other charts in this library handed a model a request and left
`no-receipt-on-chart` on every view they produced, so a reader of an `LLMCall`
or messageAPI recording could only ever see Reconstructed, never Verified.

Two reasons were on the record for that. **Neither survived.** The first — that
`Receipt.cache.transform` has no honest value on a chart running no cache
strategy — was already refuted in 9.88.0 and left refuted in the source: an
`Agent` with a pass-through strategy records `'unchanged'` today, and the value
is true for the same reason where no strategy exists at all, because *the
request that went out IS the request that was assembled*. **No fourth enum value
was added.** The second — the salt — never applied to `LLMCall`, which owns its
executor and mints a run id exactly as `Agent` does. It DOES apply to the two
messageAPI charts, and that was verified rather than assumed: footprintjs stamps
`TraversalContext.runId` on recorder events, not on a stage's scope; there is no
`$runId` on `ScopeFacade`; `ExecutionEnv` is a fixed type carrying `traceId` and
no run id. A chart builder run on somebody else's executor genuinely cannot
invent the value — so it is a dep, and a chart given none mints NOTHING rather
than salting every hash with an empty string.

### Added

- **`LLMCall` mints a receipt** (`LLMCall.ts` · `callLLM`), salted with the run
  id `createExecutor` already mints per run — read at CALL time, not closed over
  at build time, so the second run of the same `LLMCall` is not salted with the
  first run's value. `servedAt` on an `LLMCall` recording now carries
  `basis.model`, `params` and the cache verdict, and stops raising
  `no-receipt-on-chart`.
- **`LLMCallOptions.recordReceipt`** — the twin of `AgentOptions.recordReceipt`,
  same field, same contract, default ON. `false` declines the mint (one
  commit-log value and a SHA-256 per piece, per message, per tool schema);
  `servedAt` still rebuilds the view and declares the missing witness.
- **`MessageApiChartDeps.getRunId` / `AgentMessageApiChartDeps.getRunId`** —
  supply the run id and the chart mints on every turn; omit it and it mints
  none. `src/core/agent/messageApiReceipt.ts` owns that rule for both charts,
  because the two are deliberate twins and a mint written out in each is two
  chances to disagree about what the model was handed.
- **`receipt.ts` · `receiptPieces`** — the injection-record → receipt-piece map,
  which was about to have four copies. One owner, calling the same
  `contributingPieces` the system-prompt join calls, so a piece cannot be on the
  receipt and absent from the string.

### Fixed

- **`servedAt` reported an EMPTY tool list on a call that served tools.**
  `servedView.ts` · `viewOf` read the served list from `dynamicToolSchemas`
  alone. The agent charts map the tools slot's output onto that key at the mount
  boundary; the messageAPI charts carry it out under the slot's own name — so a
  `buildAgentMessageApiChart` view said `tools.names: []` about a call that
  served one. An empty list is not an omission, it is a DENIAL, and it went
  unnoticed for as long as no receipt existed to contradict it. The read is now
  a fallback chain (the agent key first, so no agent recording changes), the
  same shape the conversation has had since 9.88.0. Red before, on
  `test/lib/time-travel/receipt-conformance.test.ts` · *the tool the model was
  served is hashed by the receipt AND rebuilt from the log*.

### Changed

- **`SERVED_GAPS['no-receipt-on-chart']` is unchanged, and still true.** Its
  printed sentence never named a chart, so nothing in it went false; the
  MECHANISM comment beside it now names the shapes that still reach it — a
  messageAPI chart handed no run id, a run that declined with `recordReceipt:
  false`, a recording made before 9.88.0, and a consumer's own `call-llm` stage,
  which this library does not mint for. The cause `'no-receipt-committed'` is
  still driven by a REAL run in both walks; the driver moved from an `LLMCall`
  (which now mints) to a messageAPI chart with no run id.
- **The composed request is assembled once per chart.** Each of the three
  charts now builds ONE `LLMRequest` object and both sends it and fingerprints
  it, and each routes its conversation through `stripFrameworkFields` — the
  third rule of the one assembly — so the mint and the rebuild cannot drift.
  Every other byte those charts commit is unchanged: measured by dumping each
  chart's commit log, its per-bundle keys and its final state before and after,
  and diffing (identical apart from the new `receipt` key).

### Docs

- `src/lib/time-travel/README.md` — a "Which charts mint a receipt" table (chart
  → mints? → the salt) with the builder example, and every measurement that
  named an `LLMCall` view as receipt-less retaken on the shape that still is.
- `docs-next` `debug/time-travel.mdx` — the same section for the site. No new
  route.
- `docs/design/2026-09-recorded-not-built.md` — entry 8's reproduction retaken
  (it still stands; only its driver moved) and an appendix marking the standing
  no-mint fact BUILT in 9.91.0, with the reasons that died and the one that
  survives.

## [9.90.0] - 2026-09-10

**The recording carries its own milestones.** Until now a stored run could not
say which of its stops were an LLM turn, a tool call or a decision: every reader
re-derived that from the stage id — `milestoneFor(runtimeStageId)`, a switch
that parses `#` and `/`, lives outside the recording, and goes stale the day a
stage is renamed. footprintjs 9.21 gave a chart a way to DECLARE a stage's
names at build time and stamp them on its first commit bundle
(`CommitBundle.tags`). This release puts the agent's milestones there — law 3
of footprintjs's declared-tags design: **the tag is the fact; the derivation is
the fallback.**

### Added

- **Declared milestones, one vocabulary, one owner.** `conventions.ts` now
  holds ONE table (local stage id → `Milestone`) that both `milestoneFor(id)`
  and the new `milestoneTagsFor(localStageId)` read, so the fact on the bundle
  and the fallback from the id can never disagree. Every milestone site in
  `buildAgentChart`, `buildDynamicAgentChart`, `LLMCall` and the two messageAPI
  charts declares `milestoneTagsFor(<its id>)` — never a literal. The wire
  form: `milestone:<kind>` (what a reader filters on) and
  `milestone-label:<label>` (the human word). Exported: `MILESTONE_KINDS`,
  `MILESTONE_TAG_PREFIX`, `MILESTONE_LABEL_TAG_PREFIX`, `milestoneTag(kind)`,
  `milestoneTags(milestone)`, `milestoneTagsFor(localStageId)` (throws for an
  id the table does not classify — a wiring mistake is loud), and
  `milestoneFromTags(tags, labelWhenUndeclared?)` (reads a bundle's tags back;
  non-strings ignored; a `milestone:` tag with an unknown kind is `null`).
- **The Map advertises the vocabulary.** `buildTimeStructure` lists the tags
  each stage CAN produce before any run — a lens draws its legend first.
- `examples/observability/25-declared-vs-derived-stops.ts` — one real run
  scrubbed two ways: `tagStops(['milestone:llm-turn'])` (footprintjs's own
  strategy, no agent id conventions) and a DERIVED mark (a write-set predicate:
  stops where `currentSkillId` was written), with the measured cost of each on
  42 commits — declared 0.016 ms, write-set 0.020 ms, `milestoneStops`
  0.018 ms, and a `stateAt` fold per candidate stop 11.7 ms (≈ 750×). That
  ratio is why the declaration is the fact and the derivation is the fallback.

### Changed

- **`milestoneStops` reads the bundle first.** A stop's first bundle
  (`log[stop.commitIdx]`, where footprintjs stamps the tags) decides: tags
  present → they are the milestone, and a bundle tagged as something ELSE is
  not a stop however recognisable its id; no tags at all → `milestoneFor(id)`,
  exactly as before. A recording made before 9.90.0 therefore yields the same
  axis it always did; `milestoneOf(stop)`, `Stop<Milestone>` and the labels
  are unchanged in shape. `test/lib/time-travel/milestone-stops-equivalence.test.ts`
  now also pins the TAG-ONLY axis against the id axis on every fixture (both
  chart shapes, every drill, a paused-then-resumed run, an `LLMCall`) — the
  forgotten-tag catch: a declaration site without its tag goes red — plus a
  stripped-tags recording (the fallback), a mixed log, and the Map.
- **footprintjs `^9.21.1` (was `^9.20.0`)** — dev and peer. 9.21.1 is the
  release that lets a subflow mounted as a decider / selector BRANCH carry
  declared tags (`SubflowMountOptions.tags`, landing on the mount's FIRST
  bundle), which is how the three context slots — `sf-system-prompt` /
  `sf-messages` / `sf-tools`, selector branches in every Agent chart — declare
  `milestone:slot`. **Every milestone stage is declared.** On a 9.90.0
  recording the id fallback runs zero times, and the equivalence test counts
  it on every fixture, so a future undeclared site is caught even where the
  fallback would have hidden it on the axis.

```ts
import { tagStops, timeTravel } from 'footprintjs/trace';
import { milestoneTag, milestoneStopsStrategy } from 'agentfootprint';

// A reader with NO agent id conventions — footprintjs's own strategy, our word:
const turns = timeTravel(agent.getSnapshot()!, { strategy: tagStops([milestoneTag('llm-turn')]) });
turns.stops.map((s) => s.label);   // ['Run start', 'CallLLM', 'CallLLM', 'Run end']
turns.stops[1].meta;               // ['milestone:llm-turn', 'milestone-label:LLM turn']

// The agent's own axis, unchanged in shape — tag first, id fallback:
const all = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
all.stops[5].meta;                 // { kind: 'llm-turn', label: 'LLM turn' } — read off the bundle
```

## [9.89.3] - 2026-09-10

**One owner of a subflow's served state.** A patch: no signature changes; a
tool without `redact` behaves byte for byte as before.

### Changed

- **footprintjs `^9.20.0` (was `^9.19.1`), and the refold in
  `servableSnapshot` is deleted.** 9.89.1 made `servableSnapshot` the one
  owner of what `flowchartAsTool({ redact })` / `runbookAsTool({ redact })`
  may show, and had to refold every subflow's final state from its scrubbed
  `history` with footprintjs's `stateAt`, because
  `getSnapshot({ redact: true })` served
  `subflowResults[*].treeContext.globalContext` (and its `#n` twin) as the
  subflow's RAW heap — footprintjs mirrored the run-level runtime only, and
  9.89.2 named it as the one limit left. footprintjs 9.20.0 closed it at the
  root: a subflow keeps its own redacted mirror whenever the run does, and the
  redacted view serves that mirror as one object under both keys, equal to the
  fold over the subflow's scrubbed history. With that, the refold here was a
  SECOND owner of the same rule — and two owners drift — so it is gone: under
  a policy `servableSnapshot` returns `executor.getSnapshot({ redact: true })`
  exactly as the substrate serves it, and without one `executor.getSnapshot()`
  as before. Removed from `src/core/servableSnapshot.ts`: `refoldSubflowStates`,
  `refoldOne`, `isSubflowStateEntry`, the `SubflowStateEntry` type and the
  `footprintjs/trace` import. The five 9.19 proofs of 9.89.2 (§6) stay green.

  What a consumer sees — the same served state, now from the substrate, with
  ONE byte-level difference under a policy: a subflow entry's
  `treeContext.initialState` (its pre-seed base, `{}` — the seed is a commit,
  `history[0]`) is no longer dropped by this package but served as footprintjs
  serves it. `test/core/flowchartAsTool.redact.test.ts` §7 flips: footprintjs's
  own redacted view holds the placeholder (red on 9.19.x), and
  `servableSnapshot`'s view is that very object (identity), not a refold:

  ```ts
  const executor = new FlowChartExecutor(chart); // mounts a subflow that writes innerKey
  executor.setRedactionPolicy({ keys: ['innerKey'] });
  await executor.run({ input: {} });
  executor.getSnapshot({ redact: true }).subflowResults!['sf'].treeContext.globalContext;
  // { innerKey: 'REDACTED', derived: 'ok' } — the subflow's own mirror (the raw heap on 9.19.x)
  servableSnapshot(executor, policy); // the object getSnapshot({ redact: true }) returned — nothing rewritten
  ```

  The `redact` JSDoc on both options, `servableSnapshot`'s module note, the
  runbook recording notes, the runbook-as-tool guide and entry 6 of
  `docs/design/2026-09-recorded-not-built.md` now say the limit closed instead
  of answering it: nothing left the log carries that the served view does not
  scrub; the resume checkpoint is, as before, not a served view.

## [9.89.2] - 2026-09-10

**A redacted tool is as clean as the log, and the log is clean.** A patch: no
signature changes, no code path changed; a tool without `redact` behaves byte
for byte as before.

### Changed

- **footprintjs `^9.19.1` (was `^9.18.0`), and the five "substrate limits"
  9.89.1 could only pin are re-stated as closed.** 9.89.1 made
  `servableSnapshot` the one owner of what `flowchartAsTool({ redact })` /
  `runbookAsTool({ redact })` may show, and had to say that a served view is
  only as clean as the log beneath it — footprintjs 9.18 wrote plaintext INTO
  the record on five paths that bypassed the scope facade: `fields` (dot-path)
  redaction reached recorder views only; a subflow `outputMapper`'s merge-back
  landed in the parent log verbatim; an `inputMapper`'s seed was the subflow's
  raw `history[0]` and its narrated `Input:` line; and a stage that READ a
  redacted key kept the plaintext in `executionTree.*.stageReads`. footprintjs
  9.19.0 closed all five at the root — one `RedactionRule` per run, asked by
  `StageContext` on every staged write and every tracked read — and 9.19.1
  fixed a latent net-change defect its CI found, so this package now requires
  `^9.19.1` (dev and peer). Nothing in this package's code changed to get
  there: the served view was already built from the log, so it is now exactly
  as clean as the policy says.

  What a consumer sees now — the same charts 9.89.1 pinned, assertions
  inverted (`test/core/flowchartAsTool.redact.test.ts` §6; each `it` is red on
  footprintjs 9.18 and green on 9.19.1):

  ```ts
  const tool = flowchartAsTool({
    name: 'seeded',
    description: 'Seeds a subflow with the key and reads it there.',
    flowchart: chart,            // parent writes apiKey; the subflow is seeded with it
    keepRecord: true,
    redact: { keys: ['apiKey'] },
  });
  await tool.execute({}, ctx);   // → '{"apiKey":"REDACTED","seen":26}' — the subflow computed on the real 26-byte key
  const record = JSON.stringify(innerRunsOf(tool)!.get(ctx.toolCallId)!);
  record.includes('sk-live-');   // false — result, log, mirror, stageReads, narrative Input: line, history[0], parent log
  ```

  The law's other half is unchanged and now pinned in the same file: the live
  heap a stage computes on and the resume checkpoint (`err.checkpoint` on a
  paused run) hold the real values, because resumption must replay them.

  **The one limit that remains, named.**
  `subflowResults[*].treeContext.globalContext` (and its per-iteration `#n`
  twin) is still the subflow's own raw heap under
  `getSnapshot({ redact: true })` — footprintjs mirrors the run-level runtime
  only. That is exactly why `servableSnapshot` refolds each subflow's final
  state from its scrubbed `history`, and the limit and its answer are now
  pinned side by side (§7 of the same file): the raw view carries the secret,
  the served view does not. The `redact` JSDoc, `servableSnapshot`'s module
  note, the runbook option and guide, and entry 6 of
  `docs/design/2026-09-recorded-not-built.md` say this instead of the 9.18
  list.

## [9.89.1] - 2026-09-10

**What a chart-backed tool may show is one rule.** A patch: no signature
changes, and a tool without `redact` behaves byte for byte as before.

### Fixed

- **`flowchartAsTool({ redact })` / `runbookAsTool({ redact })` served the
  secret everywhere except the log.** footprintjs scrubs at COMMIT time, so a
  policy-redacted key never entered the inner commit log — and the option's
  own documentation drew the wrong conclusion from that. The live state view
  is not a commit: it is the run's raw heap, and its scrubbed twin (the
  *redacted mirror* footprintjs maintains beside it) is served only by
  `getSnapshot({ redact: true })`. Both tools called `getSnapshot()` bare, so
  the string the model read (`JSON.stringify(snapshot.values)`, or whatever a
  `resultMapper` built from it), the envelope's state, and a kept record's
  `sharedState` all carried the plaintext while the log beside them said
  `REDACTED` (`docs/design/2026-09-recorded-not-built.md` · entry 6, now
  built). Fixed at the root, not per field: ONE owner,
  `src/core/servableSnapshot.ts` · `servableSnapshot`, decides what leaves the
  executor — the redacted view under a policy, the raw snapshot without one —
  and every state-bearing exit of both tools (result, envelope, recording,
  kept record on ok / error / paused) reads from it. One thing that view
  leaves raw is handled the same way: a subflow's final state
  (`subflowResults[*].treeContext.globalContext`) is the subflow's own
  isolated heap, which footprintjs does not mirror, so it is refolded from
  that subflow's scrubbed `history` through footprintjs's own `stateAt` — the
  construction the run-level mirror is, done at serve time.

  ```ts
  const tool = flowchartAsTool({
    name: 'weather_advice',
    description: 'Forecast tomorrow and advise on biking.',
    flowchart: adviceChart,          // writes scope.apiKey = 'sk-…'
    keepRecord: true,
    redact: { keys: ['apiKey'] },
  });
  await tool.execute({}, ctx);       // → '{"apiKey":"REDACTED","advice":"bike"}'
  const { snapshot } = innerRunsOf(tool)!.get(ctx.toolCallId)!.recording!;
  snapshot.sharedState.apiKey;       // 'REDACTED' — was the plaintext
  JSON.stringify(snapshot).includes('sk-'); // false — in every field the log scrubs
  ```

  Two consequences, both deliberate. Under a policy a kept record omits
  `initialState` — footprintjs's own law for the redacted view (the raw
  pre-run seed never passed a policy) — so a fold of it reports
  `basis: 'log-only'` and says so. And a served view can only be as clean as
  the log beneath it: footprintjs 9.18.0 leaves plaintext IN the log for
  `fields` (dot-path) redaction (recorder views only), for a subflow
  `outputMapper`'s merge-back and for an `inputMapper`'s seed (both bypass
  the scope facade; the seed is also narrated as an `Input:` line), and a
  stage that READS a redacted key keeps the plaintext in its tracked reads
  (`executionTree.*.stageReads`). Those are named on the option and pinned as they are
  in `test/core/flowchartAsTool.redact.test.ts`, beside the reproduction
  (nested objects, patterns, arrays, subflow states, error and paused exits,
  and the no-option path against a direct `getSnapshot()`).

## [9.89.0] - 2026-09-09

**The third digest half, and one owner of the axis.** Two follow-ups to 9.88.0,
both additive: a 9.88.0 consumer compiles and behaves identically.

### Added

- **`toolDigestInput(tool)` — a consumer can verify the schema rows of a
  receipt.** 9.88.0 exported `receiptHash` and `messageDigestInput`, so a reader
  holding `servedAt(k)` and `receiptAt(k)` could prove the system text, every
  piece and every message against the receipt from outside this package — and
  could prove everything the model was served EXCEPT the tools' schemas. The
  receipt hashed each schema through a serializer the root barrel did not
  export, so a consumer's schema rows could never read Verified; its only
  options were to copy the serializer (a second owner of the rule, which drifts
  the day the digest gains a field — the message digest gained two in 9.88.0)
  or to leave the rows unchecked. The law now holds for the third row on the
  object a consumer already holds:

  ```ts
  receiptHash(runId, toolDigestInput(servedAt(k).tools.schemas[i])) === receiptAt(k).tools.schemaHashes[name]
  ```

  It takes an `LLMToolSchema` — the tool as handed to the provider port, which
  is what `servedAt(k).tools.schemas` reads back — never a `Tool` definition,
  which carries `execute` and other fields the model never saw. It is the ONLY
  spelling of the schema rule: `buildReceipt` calls it too. A schema JSON cannot
  express (a `BigInt`; a cycle) digests to the `UNSERIALIZABLE` mark on both
  sides and never throws — the `BigInt` is the worked example, because a
  cyclic schema is refused by footprintjs's `deepEqual` in the subflow
  outputMapper before any receipt is minted under `dynamic-grouped` (a
  substrate limit, not a hole in the rule). `stableJson` stays off the root
  barrel on purpose —
  `hash(stableJson(tool))` would be the rule written a second time. Pinned on
  real runs in both chart shapes, every tool of every epoch
  (`test/lib/time-travel/receipt-conformance.test.ts`), on the unserializable
  cases and on the barrel by identity
  (`test/lib/time-travel/tool-digest-input.test.ts`).

### Changed

- **`milestoneStops` is a filter over footprintjs's own stop grammar.**
  footprintjs 9.18.0 shipped `filterStops(stops, keep)` — the bookend guard, the
  re-partition, `Stop.meta` for a strategy's own vocabulary and `Stop.prologue`
  on a start that absorbed stages — because two consumers had each re-derived
  all of it by hand against 9.17. This was one of them. The hand-rolled guard,
  the re-partition loop and `milestoneOf`'s re-derivation are gone;
  `milestoneStops` is now one expression, `filterStops(commitStops(log, tree),
  keep)`, and the one owner of the `[start, …stages, end]` contract is the
  library that returns it. Public names and signatures are unchanged
  (`milestoneOf`, `milestoneStops`, `milestoneStopsStrategy`); the stops are
  now typed `Stop<Milestone>`, so `cursor.at()?.meta?.kind` is typed, and
  `milestoneOf(stop)` reads that `meta` when a stop carries one and falls back
  to classifying the `runtimeStageId` for a stop from another strategy (a meta
  of some other vocabulary is not mistaken for a milestone). **Behaviour is
  identical and proven, not asserted:**
  `test/lib/time-travel/milestone-stops-equivalence.test.ts` carries the 9.88.0
  implementation verbatim and drives it beside the new one over every recorded
  fixture the milestone tests use — both chart shapes, a skill graph, a
  dynamic-grouped run with its drilled inner histories, a plumbing-only log, an
  empty log, and a paused-then-resumed run including the 9.18 chained axis —
  requiring agreement on every stop's step, id, kind, label and commit range and
  on the `stateAt` fold at every stop. The only differences are the two things
  9.18 added, and both are asserted present and right: `meta` is the milestone
  on every milestone stop and absent on the bookends; `prologue: true` is on
  the start exactly when it absorbed a stage. A renderer that means "before
  anything ran" can now check `kind === 'start' && !prologue` instead of
  assuming it from the kind. `footprintjs` peer and dev ranges move to
  `^9.18.0`. `milestone-stops-contract.test.ts` still mocks a broken
  `commitStops` and requires the refusal to reach the consumer — it is now the
  port's refusal (`filterStops: expected a bookended axis …`), not ours.
  `test/type-regressions/MilestoneStops.assignability.test.ts` pins that the
  bare 9.88.0 shapes (`TimeTravelStrategy`, `Stop[]`, `milestoneOf(Stop)`)
  still compile.

## [9.88.0] - 2026-09-07

**The receipt at the stop.** Stand on an `llm-turn` stop, ask what the model
read, and until now the honest answer was "most of it". The request a provider
receives is assembled from committed pieces and is itself never committed — the
`call-llm` bundle holds the response, not the ask. Two things now stand at every
turn, and one law binds them:

```
hash(servedAt(k)) === receiptAt(k).hash
```

`servedAt(snapshot, epoch)` rebuilds the request from the committed pieces;
`receiptAt(snapshot, epoch)` reads the hashes-and-references record the call
itself left behind. Agreement means the record is complete. Disagreement means
something reached the model that the run never wrote down — a defect in the
record, not in the check.

Writing the conformance test found **five** places where the committed pieces
and the sent request had drifted apart. A four-lens review then found eleven
more, and two independent verifiers found five more after that — two of them
NEW instances of the very laws the previous pass was enforcing. Every one is
reproduced and closed below, each with a test that fails without the fix. None
was closed by loosening an assertion.

And then a fourth pass found two more, in the same place, for the same reason:
the gap catalogue was checked by a person reading it against the two shapes, and
three careful readings came up short three times. That is not a run of bad luck.
It is the defect this library diagnosed in 9.86 — **a hand-counted list is short
the day after** — so the catalogue is no longer counted. It is WALKED:
`test/lib/time-travel/gap-catalogue-walk.test.ts` derives every field of both
shapes and requires each one to be named by a gap or excused in writing, then
damages a real recording the way each gap describes and requires every field
that moves to be named by that gap.

Then a **fourth review round read the walk instead of trusting it**. The walk
was green and bit on five attack
probes — and three shipped sentences a renderer prints were still FALSE, which
is the half no walk can check. The one that matters most said only `Agent` has a
run id to salt the hashes with, in a paragraph explaining why `LLMCall` mints no
receipt; `LLMCall.ts` · `createExecutor` mints `runId: makeRunId()` exactly as
`Agent` does. The other two were the same defect in the account itself: a gap
naming a field whose absence its own mechanism does not cause. And the walk's
DIVERGENCE half turned out to be vacuous for two of its four rows — the damages
moved nothing, so both rows passed while proving nothing, and one gap's field
list could be emptied outright with the file still green.

And then a SEVENTH round drove a real view for every sentence in the catalogue
instead of reading them, which is how this release actually ends. It found four
more printed sentences that mislead a reader — one of them written by the round
before it, and MEASURED false: `no-run-log` said its fields "could not be fully
recovered here" on a view where the damaged rebuild is byte-identical to the
intact one. It also overturned that round's own conclusion, that a reduced
sentence cannot go false. Ten of the eleven still can. What ends the class is
not a rule about wording but an ASSERTION per claim, driven on a real run, and
that file now exists. All of it is closed below, at the root, additively.

### Added

- **`servedAt(source, epoch)` / `servedViews(source)`** — the request an epoch
  was served, rebuilt from the log: the joined system text and its pieces, the
  conversation as sent, the request-only lines, the tool names and schemas, the
  forced tool and the wrap-up withholding. Works on a live snapshot and on a
  recording read back from JSON, in both chart shapes.
- **`receiptAt(source, epoch)`** — the receipt, or `undefined` on a recording
  made before this release. A missing receipt never makes an epoch unreadable:
  `servedAt` rebuilds it either way, so an old recording stays readable. (It
  does return `undefined` for an epoch the run does not have, which is
  `epochAt`'s answer rather than a fact about receipts — the docstring says so
  now and used to say the opposite.)
- **`ServedView.gaps`** — what the log honestly cannot rebuild, each entry
  naming the receipt field it explains. `SERVED_GAPS` is the catalogue those
  sentences come from, so a renderer prints the library's own wording. A rebuild
  that quietly omits a piece looks exactly like one that proved the piece
  absent; this is what keeps them apart.
- **`epochAt` / `epochLocations`** — the ONE owner of where an iteration's
  pieces live: the run's own log under `reactMode: 'dynamic'`, the turn's inner
  `sf-llm-call` history under `'dynamic-grouped'`. `contextLedger` and
  `context-bisect`'s trajectory assembler each carried a private copy of that
  fork; both now ask this one and their own copies are gone. An `EpochLocation`
  carries the FOLD SOURCE its log belongs to — the log plus the base it was
  recorded against — and that fold's `basis` verdict.
- **`keyedFold(source)`** — the value of one state key at one commit, folded
  from the run's own base. Built because a RESUMED run replays a fiction
  without it: a resume is a fresh executor seeded from `checkpoint.sharedState`,
  so the whole pre-pause world is the resumed run's fold BASE and not its log,
  and `commitValueAt` says in its own docstring that it cannot see a base.
  Measured on a paused-and-resumed agent under `reactMode: 'dynamic'`: the old
  read returned an EMPTY system prompt and a one-message window for a call that
  really went out with 27 characters of prompt and three messages, and declared
  no gap. It now rebuilds both and matches the wire, in both chart shapes.
  `keyed-fold-equivalence.test.ts` pins every answer against footprintjs's own
  `stateAt`, which is used here for the base and the `basis` verdict.
- **`receipt.params`** — the sampling knobs the call went out with:
  `temperature`, `maxTokens`, `thinkingBudget`, `stop`, `toolChoice`, read off
  the request the provider PORT was handed. The same context at
  `temperature: 0` and at `1.2` is a different call, and "why did this turn
  ramble?" is unanswerable from a record that kept the prompt and dropped the
  dial. Scalars and short strings; no bytes, no privacy change.
- **`receipt.cache.markersApplied`** — which `cache_control` breakpoints the
  strategy actually applied, three scalars each (`field`, `boundaryIndex`,
  `ttl`). `transformHash` is a digest over the whole prepared request: inside a
  run it says only "something changed", and across epochs it is not comparable
  at all — so it could not answer *did the breakpoints move between call 3 and
  call 4?*, which is the question that decides an Anthropic bill. Two receipts'
  `markersApplied` answer it by inspection.
- **`receipt.cache.transform`** — `'unchanged' | 'rewritten' | 'unknown'`.
  Branch on this, never on `transformHash === null`.
- **`RECEIPT_BOUNDARY`** — the one sentence every receipt field is true at, so
  a renderer prints the library's own wording: *a receipt describes the request
  as this library last saw it.* Where that is — the provider port — is in the
  comment beside the constant, because the sentence is printed and the sixth
  round's rule is that a printed sentence names no mechanism.
- **`ServedView.basis`** — `{ model, provider, runId }`, read off the receipt.
  A served view could not previously say WHICH model saw this, only that
  something did. Absent when no receipt was committed, and deliberately not
  part of the law: there is no committed counterpart to check it against.
- **`AgentOptions.recordReceipt`** — the receipt's OFF SWITCH. Default ON.
  There is no privacy reason to decline it (it carries no bytes) but there is a
  cost reason: one commit-log value per iteration plus a SHA-256 per system
  piece, per message and per tool schema. An offline eval loop scoring ten
  thousand turns nobody will scrub is entitled to skip all of it. `servedAt`
  still rebuilds every epoch; `receiptAt` returns `undefined`, exactly as on a
  pre-9.88 recording.
- **Four more `ServedGapKind`s**, because a view must be able to say what it
  cannot prove: `no-fold-base` (the recording travelled without
  `initialState` — most of a resumed run is then unreadable),
  `no-conversation-on-record` (neither `history` nor `messagesInjections` was
  committed: the turns are UNKNOWN, not empty), `no-run-log` (a subtree handed
  in on its own loses every run constant) and `provider-defaults` (the sampling
  dials are the PORT's values; a vendor may resolve its own). Seven kinds ship,
  with `no-receipt-on-chart` below.
- **`UNGAPPED_FIELDS`** — the other half of the account. `SERVED_GAPS` says what
  a rebuild cannot prove; this says which fields need no gap and, in one
  sentence each, why (`omittedForAttention`, `callRuntimeStageId`, `gaps`).
  Between them they cover every field of a `Receipt` and a `ServedView`, and a
  walk is what keeps that true rather than a person's reading. TWO reasons
  qualify a field for this list and they are not the same reason: no fold can
  fail to produce it (`callRuntimeStageId`), or its absence is universal and has
  nothing to do with this recording (`omittedForAttention` — no chart supplies
  it, on any run). A field a gap DOES name never belongs here, whatever else is
  true of it, which is why `epoch` left.
- **`ServedGap` kind `no-receipt-on-chart`** — an epoch that minted no receipt
  now says so. `LLMCall` and the two message-API charts run a `call-llm` stage
  and rebuild perfectly, but mint nothing, so `ServedView.basis` was dropped and
  NOTHING explained it — a Lens denying rather than omitting, on a source
  `servedAt`'s own `@param` names as supported. The gap names the fields only a
  receipt carries (`basis.*` — all four, `params`, `cache.*`) and says what
  follows for them. WHY there is no receipt is carried as data, not prose — see
  `ServedGap.cause` below.
- **`ServedGap.cause` and `ServedGapCause`** — the discriminating fact, computed
  where it is known instead of guessed at in a sentence.
  `'no-receipt-committed'` when nothing was written under the receipt key;
  `'receipt-shape-rejected'` when something WAS and it carries no basis, so the
  read refused it — the value that means the recording is damaged rather than
  that the run never minted. A renderer prints the gap's structural sentence
  and, if it wants, the cause. Absent on every other gap: a gap carries one when
  the site that raised it read something that told it.
- **`receiptHash` / `messageDigestInput`** — the digest halves of the law, so a
  consumer checks the rebuild with the library's own rule instead of a fourth
  copy of it.
- **`BoundaryRecorder`** carries `systemPromptText` and the tool catalog through
  from `stream.llm_start`. Both were dropped there, which meant the one opt-in
  that puts the prompt on the record (`recordSystemPrompt`) reached every sink
  except the ordered boundary stream a replay reads.

### Fixed

- **The system-prompt join is one function.** `joinSystemPrompt` — pieces with
  content, `'\n\n'` between them — was written inline five times (`callLLM`,
  `LLMCall`, both message-API charts, the tool-calls self-call frame). The
  joined string is never committed, so a rebuild rests entirely on the join
  being one rule; a sixth copy in the reader is exactly the defect this closes.
- **`seed` records two build-time facts** it never had to before, both
  value-conditional so every other agent's committed key set is byte-identical:
  `forcedOutputToolName` (so a rebuild can NAME the forced tool without reading
  the receipt it is checking) and `toolWantsByName` (the last input to the
  staged-refs nudge that was build-time-only — with it, the one model-facing
  line written to no history is recomposed from committed state instead of
  being declared a gap).
- **`stepOutputText` skips the receipt.** A run-salted digest is semantically
  empty by construction; left in a step's output text it is hundreds of
  characters of noise inside a character budget that then has less room for the
  assistant's own words. Measured: it alone reordered `localizeContextBug`'s
  suspects and demoted a planted fact below a tool. A text corpus scored by an
  embedder must contain only text somebody wrote.
- **A resumed run no longer replays a fiction.** `readAtCall` /
  `readRunConstant` folded with `commitValueAt`, which cannot see a run's
  initial state. Every key not re-`set` after a resume folded to absent, so
  `servedAt` returned an empty system prompt and a truncated conversation while
  declaring NO gap. Both now fold through `keyedFold`, from the base the
  recording carries; when that base did not travel, the view raises
  `no-fold-base` instead of a confident empty one.
- **`servedAt` no longer asserts an empty conversation on an `LLMCall` run** —
  a source its own JSDoc named. Those charts have no `history`: the messages
  slot IS the conversation, and `?? []` swallowed the difference. The
  messages-slot join is now one exported function (`messagesFromInjections`),
  called by `LLMCall` on the way out and by the rebuild on the way back — the
  same argument that made the system-prompt join one function. A chart that
  committed neither source raises `no-conversation-on-record`.
- **`SERVED_GAPS['cache-transform']` no longer claims more than a receipt can
  know.** It said a null `transformHash` "is the proof that this particular
  call was not rewritten". It is not: the receipt is minted at the provider
  PORT, and a decorated provider, a vendor adapter or a consumer's own
  `complete()` rewrites downstream of it. Reproduced with an injected system
  suffix and an unregistered tool, both invisible to a record reading
  `'unchanged'`. The sentence is now scoped to the CACHE STRATEGY and carries
  `RECEIPT_BOUNDARY`.
- **`messageDigestInput` covers the join key — BOTH of them.** `toolCallId` —
  `tool_use_id` on Anthropic's wire, `tool_call_id` on OpenAI's — pairs a tool
  result to the call that asked for it, and was excluded. Two parallel calls
  whose results happen to be byte-identical hashed the SAME, so filing one
  call's answer under another was invisible to the law. `toolName` was excluded
  too, and on two shipped providers it is the join key: `GeminiProvider` ·
  `toGeminiContents` pairs a `functionResponse` to its call BY NAME (and drops
  a non-real id), and `OllamaProvider` · `toOllamaMessages` puts `tool_name` on
  the wire. There, two `role:'tool'` messages with identical text and SWAPPED
  names still fingerprinted identically — the exact mis-pairing the id was
  added to catch, invisible on the providers that need it most. It rides as its
  own field beside the id, so neither can absorb the other's bytes. It also now covers `thinkingBlocks` and
  `toolCalls[].providerMeta` as `stableJson` fingerprints: `adapters/types.ts`
  is explicit that a signed thinking block must be echoed byte-exact or the API
  rejects the turn, so two requests that differ only there are not the same
  request. Signatures are opaque tokens, not content — no bytes are added.
- **`stableJson` no longer collapses the unreadable to the empty string.** It
  returns `undefined`, and callers substitute a mark (`UNSERIALIZABLE`) or
  branch. Before, two DIFFERENT requests that could not be serialized compared
  equal, and the receipt wrote `transformHash: null` — "the cache strategy
  changed nothing" — about a pair it had never read. That case is now
  `cache.transform: 'unknown'`.
- **`readRunConstant` on a subtree says so.** Handed a recording with no run
  log, it read every run constant as absent; the view now raises `no-run-log`.
- **The per-epoch scrub is a constant factor over the batch form.**
  `epochLocations` re-located every epoch on every `servedAt` call, and the
  iteration number was resolved by rescanning the log per bundle. Measured
  against that shape: the scrub cost 2.4x the batch form at 13 epochs, 3.7x at
  49 and 5.0x at 97 — the factor grew with the run, which is what turned a
  600-turn scrub into 20.8 s. Epochs are now located once per recording
  (a module-level `WeakMap`), the iteration is read from the same one-pass index
  every other key uses, and the fold resumes forward rather than replaying from
  its anchor. The overhead is 1.0-1.1x at every size, and the absolute scrub of
  a 96-turn run (1,651 commits, 97 epochs) fell from 50.2 ms to 17.8 ms. The
  batch form pays for the correctness fix: 10.0 ms to 16.2 ms on the same run,
  because every read now folds from the base.
- **Control characters in `receipt.ts` are written as escapes.** The digest
  separators were literal `U+001C`-`U+001F` bytes in the source — invisible in a
  terminal, in a diff and in review, and one `sed` away from being eaten. Same
  bytes, same hashes; they can now be read.
- **`test/lib/time-travel/receipt-conformance.test.ts` · `describe('a redacted
  run')` was a NO-OP, and two READMEs documented what it pretended to prove.**
  It passed `redact: [...]` to `Agent.create`, which has no such option;
  `tsconfig.json` excludes `test/`, so the unknown key was never typechecked and
  was silently dropped. The run was not redacted, and the READMEs' "honest edge"
  told a reader a recording was safe to pass on. The case is replaced by what is
  true — an agent recording carries the plaintext, the receipt carries
  unredacted run-salted hashes of it, and redaction here is EXECUTOR-level
  (`flowchartAsTool({ redact })` scrubs an inner run's commit log; note that the
  same snapshot's live `sharedState` is not scrubbed). Both READMEs now say so.
  `test/type-regressions/AgentOptionsRedaction.assignability.test.ts` pins it at
  the compiler: `redact` is not an `AgentOptions` key and an excess property on
  the literal is refused. The false sentence itself is swept out of the two
  places the previous pass missed — `receipt.ts`'s THIRD LAW, which said the
  salt "is the reason a recording is safe to pass on", and the PRINTED takeaway
  of `examples/observability/24-receipt-at-the-stop.ts`, which a reader copies.
  Both now say what a recording actually contains: the salt protects the
  fingerprints and only the fingerprints.

#### The five the verifiers found — two of them new instances of these laws

- **`servedAt` raises `no-fold-base` off BOTH folds, not one.** An epoch has
  two: the log holding its call, and the RUN log holding its build-time
  constants (the forced tool's name, the `wants` the staged-refs nudge is
  composed from). Under `reactMode: 'dynamic-grouped'` those are different logs
  with different bases, and `EpochLocation.runBasis` — added by the previous
  pass for exactly this — was computed, exported on a public type, and read by
  nobody. So a grouped recording that travelled without its RUN base read every
  run constant as absent and declared NO gap: the Lens denying rather than
  omitting, which is the law the two blocking fixes before it were about. The
  gap now also names what a missing run base costs — `tools.names`,
  `tools.forced`, `messages.requestOnly`.
- **A fold's answers are DETACHED.** `keyedFold` memoizes: the same object comes
  back for every read of the same question, and the forward cursor seeds every
  later epoch's replay from that very object. `servedAt` then aliased those
  objects straight into `ServedView` (`tools.schemas`, and `messages.asSent` in
  the common case), so a consumer that edited what it was handed silently
  rewrote what LATER epochs reported was served. This was new in 9.88.0 — the
  reader it replaced, footprintjs's `commitValueAt`, clones per call, so the
  same edit was harmless before; the memo introduced it, and the memo is where
  it is closed. Every answer is now deep-frozen before it is cached, which is
  what footprintjs's own `stateAt` already does, and `servedAt` copies the two
  containers it would otherwise alias so a `ServedView` is a value in its own
  right. `receiptAt` and `epochLocations` (array and locations) are frozen for
  the same reason. Cost, measured at 601 epochs / 10,219 commits: see below.
- **`receipt.params` describes the request the PORT got.** It was read from
  `baseRequest` — the request handed TO the cache strategy — so a strategy that
  rewrote `maxTokens` or `temperature` left five receipt fields describing
  something the port was never handed, no gap named them, and
  `SERVED_GAPS['provider-defaults']` asserted the falsehood in words ("the
  sampling knobs on the receipt are the values the PORT was handed"). It now
  reads `preparedRequest`, which is what `ReceiptParams` and `RECEIPT_BOUNDARY`
  already promised, and the `provider-defaults` sentence is true.
- **`SERVED_GAPS['cache-transform']` names everything a rewrite could have
  changed.** It listed the three `cache.*` fields — the report — and excused
  nothing it reports on. A strategy is handed the whole composed request, and
  both the receipt and the rebuild describe the version it was GIVEN, so the
  gap now also covers `system.*`, `messages.*` and `tools.*`. `params` is
  deliberately not among them: that one is read past the strategy.
- **`FlowchartAsToolOptions.redact` no longer claims a kept record is safe to
  serve back to a model.** The commit-log half of the claim is true; the
  `sharedState` of a kept recording is the live view and holds the plaintext.
  The behaviour is a different subsystem's decision and is recorded, with its
  reproduction and what a fix would cost, as entry 6 of
  `docs/design/2026-09-recorded-not-built.md`.
- **`ServedGap.fields` says which spelling it uses.** The catalogue is rendered
  beside a `ServedView` but names `Receipt` paths, and three of them differ
  (`system.hash`/`chars` is the view's `system.text`,
  `messages.entries`/`count` is `messages.asSent`, `tools.schemaHashes` is
  `tools.schemas`). The mapping is now on the field's own docstring instead of
  in a renderer's head.
- **`SERVED_GAPS['no-fold-base']` names the COUNTS, and the epoch number.** It
  named `system.hash` and `messages.entries` and not `system.chars` or
  `messages.count` — the counts of the very things it named. The two counts
  were not unnamed: `cache-transform` names them, for an unrelated reason, and
  its docstring said of exactly those fields that the rebuild "produces them
  and they DO agree with the receipt". So a base-less rebuild reported a
  shorter prompt over fewer turns while the only entry covering the counts told
  a reader they agreed. Measured on a resumed run whose base was stripped: a real system
  prompt and a real window become a shorter prompt and a shorter window, and
  both counts move with them. `tools.withheld` joins them, because it is folded
  from `wrapUpAsked` like any other key, and the VIEW's own `epoch` too, because
  a fold that cannot read `iteration` numbers the turn by its POSITION instead —
  so the view and the receipt can disagree about which turn this is. (That last
  entry shipped as `basis.epoch`, the RECEIPT's number, which a missing base
  cannot touch. Corrected in the round below.)
- **`cache-transform`'s docstring no longer asserts agreement it cannot have.**
  It said of its composition fields that "the rebuild produces them and they
  agree with the receipt", which is false on every view that also raises
  `no-fold-base`. Both the docstring and the sentence a renderer prints are now
  scoped: this entry says the rebuild stops AT the cache strategy, never that
  the rebuild got that far, and where another gap on the same view names the
  same field that one is the stronger claim.
- **`no-conversation-on-record` names `messages.requestOnly`.** The staged-refs
  nudge is recomposed FROM the conversation, so a rebuild with no conversation
  finds no refs and reports no nudge — indistinguishable from a call that had
  none.
- **A `ServedView` is a value, all of it.** Two of its six containers were
  frozen (`messages.asSent`, `tools.schemas`, the two that would otherwise alias
  the fold's memo); `system.pieces`, `messages.requestOnly`, `tools.names`,
  `gaps` and the view object itself were plain, while the type said `readonly`
  throughout. No leak — the other four are built per call — but a promise that
  holds for two containers out of six is one a reader cannot use. The whole view
  is frozen now, down to the pieces and gaps, and the docstring says which two
  are also copies and why.

#### The fourth round — three false sentences and one vacuous half

The walk was green and bit on five attack probes. What it cannot see is whether
a sentence is TRUE, and three of them were not.

- **A shipped sentence said `LLMCall` has no run id to salt hashes with, and it
  does.** `servedView.ts`'s module comment, and the `no-receipt-on-chart` `why` a
  renderer prints verbatim, both explained the refusal to mint a receipt on the
  three non-agent charts with THE SALT: "only `Agent` has a run id to give". But
  `LLMCall.ts` · `createExecutor` mints `runId: makeRunId()` exactly as `Agent`
  does and owns its own executor, so the salt is there for the taking. The
  DECISION is unchanged — declare, do not mint — because the other reason holds
  for all three charts: a receipt carries a cache verdict about a strategy none
  of them runs, and `cache.transform` has no value meaning "no strategy ran".
  The salt clause is now SCOPED to the two message-API charts, where it is true
  and is the reason wiring cannot fix them: `buildMessageApiChart` and
  `buildAgentMessageApiChart` are exported chart BUILDERS whose deps carry no run
  id and no way to ask for one, run on a consumer's own executor. The printed
  sentence gives the cache verdict and nothing else, because that is the half
  that holds everywhere the gap fires.
- **The epoch account was INVERTED.** `no-fold-base` named `basis.epoch` — the
  RECEIPT's number, minted live and carried in the call's own bundle, which no
  missing base can move — while the number that CAN be fabricated, the view's
  own `epoch`, was excused in `UNGAPPED_FIELDS` on the ground that "a caller
  passes it to `servedAt` and gets it back". True of `servedAt`; untrue of
  `servedViews()`, which returns whatever the fold produced, and
  `EpochLocation.epoch` falls back to POSITION when it cannot read `iteration`.
  Measured: a resumed run whose base and `iteration` writes had both gone
  rebuilt its second turn as epoch 1 while that turn's own receipt still said 2.
  Now `no-fold-base` names `epoch` and `no-receipt-on-chart` names `basis.epoch`
  (a receipt-only field like the other three on `basis`), the `UNGAPPED_FIELDS`
  key is gone, and `ServedGap.fields` names the ONE place the two shapes hold
  two records of one fact rather than two spellings of it. Pinned by a new
  conformance case that measures the disagreement.
- **`omittedForAttention` was blamed on the missing receipt.** It was in
  `no-receipt-on-chart.fields`, but its absence has nothing to do with a
  receipt: no chart in this library supplies it, on any recording, so it is absent on views that
  HAVE a receipt too — where nothing explained it at all. It is a key of
  `UNGAPPED_FIELDS` now with the true reason: a slot writes its budget drops to
  `slotCompositions` inside its own subflow and no boundary bubbles them out, so
  `buildReceipt` is never handed one. Recorded as entry 9 of
  `docs/design/2026-09-recorded-not-built.md`.
- **The walk's divergence half was VACUOUS for two of four rows.** It asks "is
  every field that MOVED named?", which a damage that moves nothing satisfies
  for free. `no-run-log`'s damage moved nothing at all and
  `no-conversation-on-record`'s never reached `messages.requestOnly`: measured,
  emptying `no-run-log.fields` to `[]` and deleting `messages.requestOnly` from
  `no-conversation-on-record` left the file green. The previous round wrote a
  guard for exactly this hazard, for ONE row. It is general now: the loop
  collects the moved paths per damage and FAILS on an empty set, and both rows
  are driven on a run that composes a staged-refs nudge, which is the one shape
  where losing the run log or the conversation really costs a request-only line.
  "A damage row that damages nothing" joins the header's own blind-spot list.
- **`no-fold-base`'s "mechanical rule" named a reader it does not apply to.**
  The comment said a field belongs on the list when the rebuild derives it from
  `readAtCall`, `readAfterCall` or `readRunConstant`. `readAfterCall` reads the
  receipt, which the call's own bundle commits — no missing base can cost it,
  and naming it is what let the receipt's own `basis.epoch` onto a list it does
  not belong on. The rule is now the two readers that fold over values the log
  may never have written, with the third named as deliberately excluded.
- **`cache-transform` says it is unconditional.** It is raised on every view,
  including the three charts that can run no cache strategy at all — where the
  same view's `no-receipt-on-chart` says exactly that. The `why` now OPENS with
  the condition: raised unconditionally, vacuous where no strategy ran, a
  boundary rather than a claim that anything was rewritten. Raising it
  conditionally instead would mean inferring "no strategy ran" from a recording,
  which is the absence-of-evidence reading this whole feature refuses; recorded
  as entry 8 of `docs/design/2026-09-recorded-not-built.md`.
#### The fifth and sixth rounds — a printed gap sentence stops describing code

Five review rounds, and each one found NEW false prose in the sentences the
round before had just written, at a roughly constant rate. That is not a run of
careless writing. It is the law this library named in 9.84–9.86 — **a sentence
composed once and read many times is a PREDICTION** — one surface over: composed
once, and read against every later version of the code it describes.

The reproduction is one shipped string. `SERVED_GAPS['no-receipt-on-chart'].why`
said *"THREE causes and none of them is a hole in this view: …"*. A fourth path
was then added — a value under the receipt key refused because it carries no
basis — and BOTH halves went false at once: four causes, and that one IS a hole.
Nobody edited the string. Nobody had to.

**THE RULE, AND THEN THE RULE THAT REPLACED IT.** The fifth round allowed a
gap sentence three things: which fields it covers, what MECHANICALLY could not
be established, and what therefore follows. The sixth round deleted the middle
one, and the reason is the whole story of this release.

Five rounds tried to write TRUE mechanism sentences and the rate of new
falsehoods held constant. So the sixth put one question to all ten printed
sentences — *could this become false without anyone editing it?* — and NINE
could, two of them being false the day they shipped. Exactly one could not:

> `UNGAPPED_FIELDS.gaps` — *"The account itself rather than a fact about the
> request: a gap naming this list would be the account excusing its own
> absence."*

It survives because it makes **no claim about code**. It says what the field
means inside the account, and nothing outside the sentence can falsify it. Every
other sentence described a MECHANISM — "the fold could not read", "only its
inputs are on the record", "the request-only lines are recomposed from the
conversation" — and a mechanism is code, and code moves. The conclusion is not
to write them better. It is to STOP WRITING THEM.

**A printed gap sentence may now say only three things: WHICH FIELDS it covers,
WHAT THEY MEAN ON THIS VIEW for the person reading, and WHAT TO DO
DIFFERENTLY.** It may not name a module, a function, a key, a version, a chart,
a strategy, an option, or any mechanism at all — not `initialState`, not "the
fold", not "the cache strategy", not "recomposed from". A sentence that needs
one of those words to be understood is explaining WHY the gap exists, which is
not the printed sentence's job. NINE of the ten got shorter — the tenth is the
one that already had the shape, and it is unchanged to the byte.

**None of it is lost.** The mechanism moved into the code comment above each
catalogue entry, phrased for a maintainer and carrying the `file · symbol`
pointers that are correct there and banned in printed prose. The CAUSE is
already data (`ServedGap.cause`). The docs still explain the mechanism at
length, because a doc is versioned with the code and its reader can open the
file — so `src/lib/time-travel/README.md` and the docs-site page are now
deliberately LONGER than what a renderer prints, and both say so.

**WHAT IT RULES OUT — AND THE CLAIM A SEVENTH ROUND OVERTURNED.** The sixth
round shipped this paragraph saying the reduction had made the prose
UNROTTABLE: a sentence with no code claim in it cannot go false when the code
changes, so the class is closed outright rather than merely thinned. A verifier
then read all eleven printed sentences one at a time, and that is false. **TEN
of them still make a claim a code edit falsifies.** Exactly one does not —
`UNGAPPED_FIELDS.gaps` — and it does not because it is SELF-REFERENTIAL: it says
what its field is inside the account, not anything about the request. The other
ten cannot copy that shape, because a sentence that tells a reader something
USEFUL — *may be SHORT*, *absent means unknown*, *the tool list is complete and
the schemas are one short* — is a claim about how the rebuild behaves, and the
rebuild is code. **The reduction changed the VOCABULARY of the claims, not their
CLASS.**

What the reduction really buys is smaller and still worth the rows: the
sentences are short and readable, and the enumerations that went false in five
rounds have nowhere to come back through. It also makes the rule enforceable.
The fifth round's checker was PHRASING-shaped and near-synonyms walked through
it; the sixth round's printed surface admits no code-shaped token — dotted path,
`.ts` file, camelCase, PascalCase, `SCREAMING_SNAKE`, a call with parens, a
quoted option name, a version number — and no mechanism verb from a closed list
of ten. That is close to a whitelist, and a whitelist has no synonyms.

**WHAT ACTUALLY CLOSES THE CLASS IS A RUN, and the evidence is in the same
report that overturned the claim.** Driving one real view per gap, the verifier
recorded seven sentences HOLDING and four MISLEADING — and caught a BRAND-NEW
false sentence in the very round written to end false sentences. No rule caught
it. Measurement caught it. So a gap sentence MAY make a code claim, because a
sentence that makes none cannot inform, and **every claim it makes is now
ASSERTED against a real view in a test that sits beside it**:
`test/lib/time-travel/gap-sentences.test.ts` drives one run per catalogue entry,
decomposes each sentence into quoted clauses, pairs every clause with its own
assertion, and requires the clauses to PARTITION the sentence so no printed word
sits outside a checked claim. The prose rule stays and is no longer sold as the
thing that makes the sentences true.

**THE HONEST LIMIT**, in the new file's header and in the checker's: a claim
nobody wrote an assertion for. The partition guarantees each clause has a test;
it cannot guarantee the test is as strong as the clause. That is a smaller blind
spot than six rounds of rewriting produced, and it is the whole of it.

- **The rule is a checker, not a habit.** `test/helpers/gapProseClaims.ts` is
  the reader-facing sibling of `modelFacingClaims.ts`: **six** banned shapes now
  — the two REDUCTION rows the sixth round added (code shape, mechanism verb),
  then the four PHRASING rows the fifth round wrote (cardinality, benignity,
  discrimination, cross-module), kept as the second line of defence and
  redundant on the printed surface by design. Each carries the reason an edit
  elsewhere falsifies it, and a structurally-required exemption argument (the
  same discriminated union, proven the same way in
  `test/type-regressions/GapProseClaims.assignability.test.ts`). The three
  strong rows stand down on `'prose-doc'`, because naming the mechanism is what
  a doc is FOR; the other three do not, because a cause count goes stale in a
  doc exactly as it does in a constant.
- **Every printed gap sentence is one or two sentences and names nothing.**
  `no-receipt-on-chart` now reads in full: *"Nothing on this view has been
  checked against what went out. Every field below is missing as a whole, and an
  absence among them says nothing about the call — not even that a dial was left
  unset."* `forced-tool-schema` is 26 words. `callRuntimeStageId`'s excuse is 12.
- **TWO SENTENCES WERE FALSE THE DAY THEY SHIPPED, and both were mechanism
  claims, so the rule deletes the category rather than the instances.**
  `cache-transform` said *"only its INPUTS are on the record"* while three of
  the fields it covers are OUTPUTS that are on the record — `cache.transform`
  (the verdict of comparing what the strategy was given against what it handed
  back), `cache.transformHash` (the digest of the result, when they differed)
  and `cache.markersApplied` (the breakpoints actually applied, as against the
  candidates in `scope.cacheMarkers`, which are the inputs). And
  `no-receipt-on-chart` opened *"No receipt was found for this epoch"* and
  closed *"absent here means unrecorded"* — both false under
  `'receipt-shape-rejected'`, where a receipt WAS written and the read refused
  it, which is to say the sentence asserted which cause applied and was wrong
  for one of two. Neither can be written under the new rule. Both are corrected
  in the two doc tables and in the comment beside each entry, where the
  mechanism now lives, rather than merely dropped.
- **`RECEIPT_BOUNDARY` was the one printed sentence exempted from the rule, and
  the exemption is gone.** It named `LLMProvider.complete` and `complete()` — a
  module and a call, printed to a reader who cannot open either. It now reads
  *"A receipt describes the request as this library last saw it. Whatever
  handled it after that could have changed it, and nothing on the receipt would
  show that."* The port, the decorated provider, the vendor adapter and the
  vendor's own defaults are in the comment above the constant. One string, one
  rule set: the walk no longer strips it before judging, and asserts it on its
  own as well as inside the two entries that quote it.
- **THE CAUSE IS A VALUE.** The enumeration was prose doing DATA'S job: a frozen
  constant cannot know which cause applied at the site it is printed beside, so
  it listed them all and hoped. `ServedGap.cause` carries the answer now,
  computed in the one function that has it — the receipt read reports whether
  the key held nothing (`'no-receipt-committed'`) or held something it refused
  (`'receipt-shape-rejected'`). Same "one fact, one owner" move that fixed the
  `read_skill` refusals in 9.86. One test per cause: the first on a real
  `LLMCall` run, the second on a crafted recording whose receipt key holds a
  value with no basis, since no run produces one. The set is closed AT THE SITE
  and deliberately no wider — a pre-9.88 recording, `recordReceipt: false` and a
  chart that mints none all leave the same record, and claiming to separate them
  would be this field repeating the defect it was added to fix.
- **`ServedView.basis`'s docstring claimed the gap discriminates.** It said the
  gap "says which of the three it was", which it never could. It points at
  `cause` now.
- **`receiptAt`'s docstring merged two causes, omitted a third, and denied a
  fourth.** It said `undefined` has three causes, gave `recordReceipt: false` as
  a gloss on "this chart mints none" (two different causes), left out the
  malformed-receipt refusal, and asserted that none of them is "the epoch is
  missing" — which is false, because `epochAt` returning nothing is exactly one
  of the ways it returns `undefined`. It now gives the same account
  `ServedGapCause` gives, so the two exported accounts cannot disagree, and says
  plainly that a missing epoch is a separate answer.
- **`UNGAPPED_FIELDS.omittedForAttention` said "no chart supplies it".** True of
  this library and not of the world: `buildReceipt` is a pure exported mint, so
  a consumer can hand it the fact. Narrowed to **no chart in this library** —
  and then, in the sixth round, moved out of the printed sentence altogether,
  because a dated measurement over a set of charts is a claim about code. The
  measurement is unchanged and still re-taken by the walk on every run; it reads
  the claim from the COMMENT beside the entry now, so a chart that starts
  supplying one still fails the suite instead of aging the sentence. What a
  reader is shown is what the field means: *"Absent means nobody recorded a
  drop, never that nothing was dropped."*
- **`examples/observability/24-receipt-at-the-stop.ts` clipped a printed reason
  mid-version-number.** It printed `reason.split('.')[0]`, so "measured on
  9.88.0" reached a reader as "measured on 9." — a fragment that reads as a
  complete sentence. It wraps on word boundaries now, and prints the gap's
  `cause` beside its kind.
- **The walk's divergence half now STATES ITS MEASUREMENT instead of implying
  coverage.** The header claimed the vacuous-row guard was closed "by driving
  each row on a run that reaches its own fields". Measured, that clause is
  false: `no-fold-base` moves 7 of the 11 fields it names, `no-run-log` 1 of 4,
  `no-receipt-on-chart` 3 of 8, and only `no-conversation-on-record` reaches all
  3. The reasons are structural — a receipt-only field never appears on a view,
  so removing the receipt cannot MOVE it — so the header names the numbers per
  row, a test pins them so the table cannot go stale, and it says plainly that a
  field claim divergence does not reach is carried by the coverage half and by
  `receipt-conformance.test.ts`. No new machinery was built for this; the honest
  fix was to say what was measured.
- **The gap tables in `src/lib/time-travel/README.md` and
  `docs-next/content/docs/debug/time-travel.mdx` were short AND over-broad on the
  day they shipped** — `no-run-log` missing `tools.schemaHashes`,
  `cache-transform` claiming all of `tools.*` when it names two of the four
  fields under `tools`. That is this release's own defect one layer out, where
  the walk was not looking. Both tables now carry the LITERAL field lists, and
  the walk parses them: every row must match `Object.keys(SERVED_GAPS)`, every
  field cell must match that entry's `fields` exactly, and no cell may name a
  field the catalogue has moved to `UNGAPPED_FIELDS`. A doc that restates a
  frozen exported constant is checked against it, not retyped.

#### The seventh round — every sentence is asserted against a real run

The rounds above all ended by READING the sentences. This one drove a view for
each of them, which is how the library closes everything else, and it found a
different class of defect: not prose that names a mechanism, but prose that is
plain, short, rule-abiding and UNTRUE OF THE VIEW IT IS PRINTED BESIDE.

- **`no-run-log` claimed a loss that a run says did not happen.** It read *"The
  fields below could not be fully recovered here"* — an assertion that recovery
  DID fail. Measured on the ordinary view that raises it, a
  `'dynamic-grouped'` agent with one plain tool and its `commitLog` emptied:
  `tools.names`, `tools.schemas`, `tools.forced` and `messages.requestOnly` all
  come back BYTE-IDENTICAL to the intact view. The gap fires and costs nothing,
  because that run has no forced tool name and no `wants` to lose. It now reads
  *"The fields below may be SHORT: a name can be missing from the tool list, and
  a line that went out with the request can be missing too. An absence below is
  not evidence that there was nothing there — read the whole recording rather
  than a piece of it."* **The repair is the sentence and not the condition**,
  and the reason is that the condition cannot be narrowed by anything the read
  can see: whether the run had a constant to lose is recorded in the log whose
  absence raises the gap. Both directions are asserted — emptying the run log
  takes a forced-output run's tool list from one name to none and a staged-refs
  run's request-only line to nothing, and takes nothing at all from the plain
  run. Its second clause was loose as well: it said a line could go missing
  *"from the conversation"*, and a request-only line is by construction in no
  conversation — asserted now against the wire, which carries it as the last
  message of the request while the rebuilt `messages.asSent` does not contain it.
- **Three sentences were true where they were composed and misleading where they
  were PRINTED.** One sentence, several contexts: the library's own Honest
  Sentence law says it has to hold in all of them.
  - `cache-transform` ended by quoting `RECEIPT_BOUNDARY` — *"A receipt
    describes the request as this library last saw it"* — and it is raised on
    EVERY view, including a receipt-less one. Measured: an `LLMCall` view
    carries exactly `no-receipt-on-chart` and `cache-transform`, so the reader
    was told what a receipt describes beside a view that has none. The quote is
    gone from it and the claim survives in the entry's own words, in the
    vocabulary of a view: *"…and nothing on this view would show it."*
    `provider-defaults` keeps the quote and is the only entry that may have it —
    it is pushed inside `if (receipt !== undefined)`, so a view carrying it
    always has a receipt for the sentence to be about. Asserted across every run
    in the new file: a gap whose `why` includes the boundary appears only on a
    view whose `basis` is defined.
  - `no-fold-base` said the view's number *"may differ from the one the receipt
    for this turn carries"* — printed on views that carry no receipt (a
    base-less `LLMCall` recording raises both gaps at once). It now says what
    the NUMBER means: *"The turn number below may be this turn's place in run
    order rather than the number the run itself gave it."* Asserted on the
    resumed run whose base and `iteration` writes are gone: the view calls the
    second turn 1 while the run's own count for it was 2.
  - `no-receipt-on-chart` closed *"their absence here is a gap in the record,
    never a call made without them"*. True of each field AS A WHOLE and false
    one level down, which is the level a reader reads at: a receipt always
    carries `params` and always carries a `cache.transform` verdict, and an
    absence INSIDE `params` — measured `{}` on an agent that set no dials — IS a
    call made without one. It now claims nothing about what is inside a field it
    cannot see: *"…an absence among them says nothing about the call — not even
    that a dial was left unset."*

- **`test/lib/time-travel/gap-sentences.test.ts` — the assertion, beside the
  sentence.** One real run per catalogue entry and per `UNGAPPED_FIELDS` key,
  and an assertion for each claim the sentence makes — not that the gap fired,
  but that what it says about the view HOLDS. Each sentence is decomposed into
  clauses quoted verbatim from the constant, each clause carries its own
  assertion, and three contract tests hold the binding shut: every catalogue key
  is an entry, every quote is verbatim and in order, and **the clauses PARTITION
  the sentence** — strike them out and only punctuation is left, so no printed
  word sits outside a checked claim. Rewrite a sentence and the partition fails,
  which sends the author back to write the assertion for what it now claims. The
  measurements the round took by hand are transcribed into it rather than
  re-derived, and the rest were driven to fill the gaps between them.
- **`UNGAPPED_FIELDS.gaps` is labelled as the one clause class that is not a
  claim about the request.** It is the only sentence no code edit can falsify,
  and it is that way because it is self-referential — a statement about what its
  field is inside the account. The new file carries a flag naming that category
  rather than an assertion pretending to cover it, and a contract test requires
  exactly that one entry to be flagged.

### Changed

### The receipt's three laws

1. **Hashes and references, never bytes.** No message text, no prompt text, no
   schema bodies. Those bytes are already governed — `recordSystemPrompt` is
   opt-in for exactly this reason, redaction scrubs the committed mirror, a
   window strategy decides what survives — and a receipt carrying content would
   quietly reopen all three.
2. **Run-salted digests.** `sha256(runId + '\u001f' + content)`, first 16 hex
   characters. Hashes are NOT redacted, and the salt is why that is safe: an
   unsalted hash of a one-line prompt or a two-word user turn is a dictionary
   lookup away from being read back, and receipts travel inside recordings.
3. **No authority omissions.** A receipt never names — and never counts — what a
   caller's role was not allowed to see. Committed state is readable by the
   trace toolpack's debugging tools, so a receipt carrying `hiddenSkillIds`, or
   even "3 skills withheld", would restate a permission decision one layer down
   where nobody is checking.

### Measured

- `test/lib/time-travel/receipt-conformance.test.ts` — 51 tests over real runs:
  both chart shapes, a pause and a resume in both, a skill-graph hop, a stepped
  skill, a parked map, a wrap-up call, a forced output tool, a staged-refs
  nudge, a cache strategy that rewrites the composition and one that rewrites a
  sampling dial, a marker-applying one, a provider decorated past the port, an
  `LLMCall` chart, a JSON round-trip, a pre-9.88 recording, a recording with no
  fold base on either of its two folds, a recording whose base AND whose
  `iteration` writes are gone (where the view numbers the turn by position and
  its own receipt still says otherwise), a subtree with no run log, a reader
  that tries to edit what it was handed, and the off switch. The vendored
  SHA-256 is checked against `node:crypto` on every shape it hashes. Two
  mutation tests drop a committed piece from the replay and require the law to
  go red naming the epoch and the field.
- `test/lib/time-travel/gap-catalogue-walk.test.ts` — 44 tests, and the reason
  this release has one more file than it planned. `SERVED_GAPS` was hand-checked
  against the two shapes three times and came up short three times, which is not
  a run of bad luck: A HAND-COUNTED LIST IS SHORT THE DAY AFTER. So the
  correspondence is WALKED, the way `userTurnProducers.test.ts` walks every
  `role:'user'` producer and `toolDivergenceWalk.test.ts` crosses every
  claimant. It derives the field list twice — from the declarations, with the
  TypeScript parser, and from five real runs — and requires every field to be
  named by a gap or to be a key of `UNGAPPED_FIELDS` with a written reason;
  requires every `fields` entry and every `UNGAPPED_FIELDS` key to resolve to a
  field that exists, so a rename cannot leave a gap pointing at nothing; and
  requires the kinds `viewOf` can push and the kinds in the catalogue to be the
  same set. Then it DAMAGES a real recording the way each gap describes,
  rebuilds, and requires every field that MOVES to be named by that gap — which
  is the half that catches a field named by the wrong gap for the wrong reason,
  and the half the first three checks could not. Verified by reverting each fix
  and watching the right row go red.

  **The two halves, and what each cannot prove.** The ACCOUNT half (coverage,
  resolution, reachability, and the two doc tables) proves every field of both
  shapes is named by a gap or excused in writing, that every pointer lands on a
  field that exists, and that the prose copies say what the constant says. It
  cannot prove any of it is TRUE: a gap can name a field for a mechanism that
  does not cause its absence, and this release shipped two of those. The
  DIVERGENCE half proves that for the four damages it can apply, no field moves
  without the responsible gap naming it — and, since this round, that each
  damage moves SOMETHING, so a row cannot pass by damaging nothing. It cannot
  prove a row moved everything the gap is about, it has no damage at all for the
  three gaps that are conditions of the RUN rather than of the recording
  (`cache-transform`, `provider-defaults`, `forced-tool-schema` —
  `receipt-conformance.test.ts` drives those), and the damage table is
  hand-listed, so a gap whose damage nobody wrote down still gets only the
  account half. Neither half reads a `why`. That is a person's job, and it is
  where this release's last three defects came from.
- `test/lib/time-travel/keyed-fold-equivalence.test.ts` — 9 tests: every key at
  every commit of a real run, checked against `stateAt` itself, including a
  merge with no `set` anchor (which cannot be folded at all without the base),
  a resumed agent in both chart shapes, and a replayed value that comes back
  frozen.
- **The detachment freeze costs 23%, and the alternative costs more.** Measured
  on a 601-epoch run (10,219 commits): the whole per-epoch scrub is 1,141 ms
  with the freeze and 930 ms without, against 20.8 s two fixes ago; the batch
  form moves 919 ms to 1,116 ms. Copying instead is dearer, not cheaper — on
  the same 1,200-message structure `structuredClone` costs 0.94 ms against
  `freezeDeep`'s 0.26 ms, and a copy would run once per READ where the freeze
  runs once per memoized answer.
- `test/lib/time-travel/served-view-complexity.test.ts` — 2 tests: epochs are
  located once per recording (checked by identity, no clock), and the per-epoch
  scrub stays within 2x the batch form at both 13 and 49 epochs without
  drifting. Against the shape this release replaced those ratios are 2.4x and
  3.7x, climbing to 5.0x at 97 epochs.
- Full suite: 10,348 passing, 20 skipped, 624 files.
- `test/lib/time-travel/served-view-complexity.test.ts` is a RATIO guard, and a
  ratio is immune to the machine but not to contention: it failed once inside a
  fully parallel `vitest run` and passes alone and in a clean full run. The
  freeze does not touch its subject — the scrub and the batch form do the same
  folding in the same order, so both pay it identically (measured: scrub/batch
  is 1.02 with the freeze and 1.01 without). Recorded rather than widened.
- Runnable: `examples/observability/24-receipt-at-the-stop.ts` — three turns, the
  law checked against the rebuild AND against the request the provider really
  received, with the staged-refs nudge printed back from the record, the
  sampling dials, and the port boundary printed where a reader meets it.

### A stated limit

The `@wire` clauses of the conformance test are not an INDEPENDENT witness.
They compare the receipt against the request the provider stub really received,
which catches a rebuild that drifts from the request and a receipt that
describes something the provider never got. It does not catch a defect in the
shared assembly: the receipt and the request are minted from the same locals
inside `callLLM`, a few lines apart, so a change that alters both symmetrically
leaves every `@wire` clause green. A genuinely independent witness would have to
come from outside the process that composed the request — a recorded HTTP body
from a real adapter, or a second implementation written against the vendor's own
schema. Neither exists; the limit is stated in the test's own header rather than
left to be discovered.

Typechecking the whole of `test/` would have closed the redaction hole at its
root. It was tried and surfaces 1,054 pre-existing errors across the suite and
the examples it pulls in — a repair of its own, not a line item in this release.
The hole is closed instead in `test/type-regressions/`, which already compiles
under `npm run test:types`.

`ServedGap.fields` carries TWO relations on one list. Most entries mean *the
rebuild cannot produce this field*; `cache-transform`'s composition fields mean
*it can, and both sides agree, but only up to the cache strategy*. A checker
that granted the second as an excuse would stop checking fields the record
proves perfectly well — which is exactly what happened when the composition
fields were added, and it silently disabled one clause of the law. The clause
now asks its narrower question against its own list of gaps, and the field's
docstring names both relations. A second array on the public type is the
cleaner shape; adding a public field in a fix pass is not, so it is written
down rather than shipped.

**A green `gap-catalogue-walk` proves the fields are ACCOUNTED FOR, not that the
account is TRUE.** A gap can name a field for the wrong reason and the walk will
call it covered; a `why` can be a fluent sentence about the wrong mechanism; an
`UNGAPPED_FIELDS` reason can be wishful. Only a person reading the `why` catches
that — which is how the defect the walk was built for was found in the first
place — three times in this release, the last of them after the walk was
already green. Five narrower blind spots are named in the file's own header
rather than left to be discovered: the reachability half reads `gapOf('…')`
literals out of the source (and so fails on any `gapOf` call whose argument is
not a literal, which is the only shape that could hide one); the static half
follows type references by name and does not expand an alias, a mapped type or
an intersection; the runtime half only produces what its five scenarios reach;
the DAMAGES table is hand-listed, so a gap whose damage nobody wrote down gets
the coverage check and not the divergence one; and A DAMAGE ROW THAT DAMAGES
NOTHING passed as a green row for one release, which is why the loop now
requires each damage to move something and still says that "something" is not
"everything". Three gaps have no damage at all,
because they are conditions of the RUN rather than of the recording —
`cache-transform`, `provider-defaults`, `forced-tool-schema` — and
`receipt-conformance.test.ts` drives each of those on a real run instead.

**A recording stops being JSON-serializable at about 600 epochs**, and it is the
same reader at the same size that the epoch memo was built for. Measured on the
looping run the complexity guard uses: 301 epochs serialize to 145,238,645
characters; 601 epochs throw `RangeError: Invalid string length` — V8's maximum
string length, not a library limit. Nothing here fails before then, and none of
these readers needs `JSON.stringify` to work: `servedAt`, `receiptAt` and
`epochLocations` all read the live object. But a recording that cannot be
written to a file cannot be handed to anybody, so at that size the answer is to
persist per-epoch views rather than the whole snapshot. Stated rather than
worked around, beside the memo it shares a size with.

`flowchartAsTool({ redact })`'s kept inner recording carries an UNREDACTED
`sharedState` while the commit log is scrubbed. It is a different subsystem and
every available fix changes behaviour for runs that work today, so this release
corrects the option's own claim and records the defect — reproduction, cause
and what each fix would cost — as entry 6 of
`docs/design/2026-09-recorded-not-built.md`.

**Three more are RECORDED rather than built**, as entries 7-9 of the same file,
because the last review round's job was to make every printed sentence true
rather than to close every hole: `cache-transform` does not name `tools.forced`
or `tools.withheld` although a strategy that rewrote `toolChoice` could make
both stale (the receipt builds them from assembly's decision, not from the
prepared request); `cache-transform` stays unconditional, because raising it
conditionally would mean inferring "no strategy ran" from a recording; and a
slot's attention drops never reach `Receipt.omittedForAttention`, because no
boundary bubbles `slotCompositions` out of the slot subflow. Each entry carries
its reproduction, its cause, and what a fix would cost.

## [9.87.1] - 2026-09-06

9.87.0 never reached npm. Its publish job failed the docs site's byte budget —
`export bytes: 621.21 MB exceeds 617.00 MB` — after six new routes (the
time-travel guide and the API pages for `milestoneStops`,
`milestoneStopsStrategy`, `milestoneOf`) added 5.47 MB to a static export
that had 1.26 MB of headroom left. No library code changes in this release.

### Fixed

- **The site-budget ceilings are re-baselined** the way the script's own history
  says to: ~2% over the measured export (634 MB, 6,750 files), with the reason
  written beside the number in `docs-next/scripts/check-site-budget.mjs`. The
  gate did its job — growth nobody was watching is exactly what it exists to
  stop — and the number to look at next is the ~0.9 MB a single docs route
  costs, not the ceiling.

## [9.87.0] - 2026-09-06

The agent now supplies the stops for its own runs.

footprintjs 9.17.0 opens a reader's cursor over a finished commit log —
`timeTravel(snapshot, { strategy })` — with a fold at every stop, and a seam that
says where the cursor may rest. It ships one strategy, `commitStops`, which stops
on every executed stage: the truth, and unreadable. A two-turn agent in
`reactMode: 'dynamic'` commits **42** bundles, and most of them are called
`context`, `sf-cache` or `sf-thinking`.

`milestoneFor` has classified stage ids into `iteration` · `slot` · `llm-turn` ·
`tool-call` · `decision` for releases, and every consumer that wanted a milestone
slider mapped that classifier onto commits itself. This release does that join
once, on the seam: the same 42-commit run yields **15** stops — Run start →
Iteration → System prompt → Messages → Tools → LLM turn → Route → Tool call →
Iteration → … → Run end.

### Added

- **`milestoneStops` / `milestoneStopsStrategy` / `milestoneOf`** (`src/lib/time-travel/`,
  exported from the root barrel). A footprintjs `TimeTravelStrategy` built by
  FILTERING footprintjs's own per-stage axis rather than re-deriving it: it calls
  `commitStops` and keeps the stages `milestoneFor` classifies, so the collapsing
  that axis already solved — one stop per `runtimeStageId` at its first commit
  (mounts and fork children commit more than once), the mount set read off the
  execution tree, the `'start'` / `'end'` bookends, the id-less commit that
  carries a subflow's `inputMapper` seed — is used, not repeated. A stage that
  classifies `null` contributes no stop and its commits fold into the stop before
  it, so the survivors still partition the log end to end and `stateAt(stop)`
  stays "the state that existed when the next milestone started".

- **Both chart shapes, one strategy.** The classifier reads the LOCAL segment of
  a stage id, so nothing has to tell it which log it is holding. Under
  `reactMode: 'dynamic'` the `call-llm` bundle is on the run's own log and the
  llm-turn stop is on the outer cursor. Under `'dynamic-grouped'` the same run
  commits **10** bundles outside — the `sf-llm-call` mounts, which become the
  iteration stops — and `drill(mountRuntimeStageId)` opens that turn's own
  cursor, where the same strategy finds **7** stops including its LLM turn.

- **`examples/observability/23-time-travel-milestones.ts`** — the same agent run
  at both chart shapes, printing each axis, the skill the run stood in at each
  turn, `changedSince` between two turns, a mark that survives a jump, and a
  refused jump that leaves the cursor put. It also calls `milestoneStops` on the
  log directly, for the reader who holds a recording rather than a cursor, and
  shows the two axes agree. Offline, mock provider.

- **`docs-next/content/docs/debug/time-travel.mdx`** — why milestones rather than
  one-stop-per-stage, the three questions the cursor answers, and where the LLM
  turn lives in each chart shape.

### Changed

- **`footprintjs` is now `^9.17.0`** (peer + dev), for `timeTravel`,
  `commitStops` and the `TimeTravelStrategy` seam.

### Honest notes

- **The milestone kind is a function, not a field.** footprintjs's `Stop` is a
  closed shape with no slot a strategy may write its own vocabulary into, and its
  `kind` is the port's own `StopKind` (`'commit' | 'mount' | 'start' | 'end'`),
  not ours to overload. So the kind travels the only way it honestly can:
  `milestoneOf(stop)` re-derives it from the stop's `runtimeStageId` with the
  same classifier that put the stop on the axis — one source of truth, read
  twice. If a later footprintjs gives `Stop` an extension slot, `milestoneOf`
  becomes a one-line reader of it.

- **Which keys are visible where, in the grouped shape.** The settled skill
  cursor for turn *k* is on the OUTER axis, at iteration *k* (`currentSkillId`).
  Inside the drill, `currentSkillId` is the value the turn STARTED from — it
  crosses the mount as a read-only input — and the move the turn made is
  `nextSkillCursor`, merged back out by the outputMapper. Both logs are truthful
  about different questions, and `test/lib/time-travel/milestone-stops.test.ts`
  pins both rather than picking the flattering one.

- **`'start'` is not the fold base on this axis.** footprintjs's `'start'`
  bookend is the state before any stage ran; this one also absorbs every stage
  that ran before the FIRST milestone, because the stops must still partition
  the log. Measured on a two-turn `dynamic` run: `commitStops`' start folds
  commits `-1..-1` and 0 keys, `milestoneStops`' start folds `-1..0` and 32 —
  `seed`'s writes have already landed. So `stateAt(startStop)` here is the state
  the first milestone READ, not the run's raw base, and a renderer keyed on
  `kind === 'start'` to show "what the run began with" is showing post-seed
  state. Said in the folder README, on the docs page, and pinned by a test.

- **A resumed run gets an axis of its own.** The cursor reads the snapshot it is
  handed, and a resume is its own execution with its own log: after
  `agent.resume(checkpoint, answer)`, `getSnapshot()` carries the resumed half —
  the axis begins at the stage the resume re-entered and the pre-pause
  milestones are not on it. They are on the snapshot taken at the pause. The
  strategy itself holds across the break (both axes tile, both keep unique
  stops); it is the snapshot that split, not the cursor.

- **A log with no milestones is not an empty log.** A non-empty log the
  classifier recognises nothing in — a non-agent chart handed this strategy —
  yields the two bookends and nothing between them, and every `jumpTo` refuses
  with `'miss'`. An EMPTY log yields `[]`. Two different facts, two different
  answers.

- **The performance guards get a stated allowance under coverage.** v8
  instrumentation is not machine load: it is a per-call counter, so it taxes the
  two sides of a ratio in proportion to how many calls each makes, and a
  comparison between two differently-shaped paths moves even on an idle machine.
  Measured on this suite, the tightest guard sat ~2.8× under its ceiling
  uninstrumented and ~1.1× under it with `--coverage` — which is why it went red
  under whole-suite parallelism and green on a re-run. `test:coverage` now sets
  `AF_COVERAGE=1` and `test/helpers/perf.ts` widens the ceilings by a documented
  3× when it sees it. The plain `npm test` ceilings are unchanged, which is where
  a real regression is still caught; the multiplier is a floor on effort, not a
  promise that instrumentation costs exactly 3×.

- **17 tests over real runs**, not fixtures: the axis in both chart shapes, the
  partition property (every commit belongs to exactly one stop), a jump that
  lands and a miss that names a nearest without moving, the skill graph read
  along the commits with the wire as witness (the skill whose body rode turn *k*
  is the one that iteration's own log settled on), marks that survive jumps and
  never appear in the recording, what `'start'` folds on this axis versus the
  port's, a resumed run's axis, and a log with no milestones in it. One more
  file — 5 tests in `milestone-stops-contract.test.ts` — mocks `commitStops` to check the one
  assumption this strategy makes of footprintjs — that a non-empty log yields
  `[start, …stages, end]` — is refused loudly rather than silently mistaken for
  an empty axis.

## [9.86.1] - 2026-09-06

The release that removed hand-counted lists shipped with one, and with main red.

`chore: release v9.86.0` failed CI (run 34008382993, the `coverage` job) while both
plain `test` jobs and the local release gate passed. Two tests parse every file
under `src/` with the TypeScript compiler and were called fresh inside every `it`
— seven parses in one suite, three in the other — and under the coverage job's
v8 instrumentation each parse took 5.3–6.2 s on the CI runner, past vitest's 5 s
default. Locally the same parse takes about a second. The release script ran
`npm test` and never `npm run test:coverage`, so the one command that would have
shown the failure was the one it did not run. Both walks now parse `src/` once per
suite and carry a 60 s budget of their own, and Gate 4 of `scripts/release.sh` runs
`npm run test:coverage` — the instrumented run is a superset of the plain one, so
the gate now sees what CI sees.

Everything else here is one review of 9.86.0, taken finding by finding.

### Fixed

- **The `'guard'` refusal asserted a fact it did not have.** With no menu
  outstanding, `composeReadSkillRefusal` ended every refusal with "Declared routes
  moved the cursor instead." The gate handed it a boolean derived from two of
  `TurnRoute.by`'s six values, so the clause was composed for the other four too:
  false for `'continuity'` (the cursor was carried over from the previous turn and
  nothing moved it — the verdict every follow-up produces under `{ strictness:
  'guard', continuity: 'conversation' }`), false for `'menu'` resolved by the
  model's own pick, and unprovable for `'none'`. The composer now takes
  `turnStartedBy: TurnRoute['by']` and says one past fact per value — "the turn's
  start had already been resolved decisively", "the cursor had been carried over
  from the previous turn", "the menu had already been resolved by an earlier
  pick", "the menu had been resolved by the configured decider before the turn's
  first call" — and nothing for `'none'`. The old tail is gone from every arm.

- **Two refusals named a cursor the role may not be told about.** The `read_skill`
  description withholds a hidden cursor's name by its own law, and the gate's
  refusal printed the same id raw in two clauses ("was not reachable from 'alpha'.
  Skills reachable from 'alpha' when that call was made: …"). The cursor now goes
  through the same filter as the hops: a hidden cursor is anchored as "the skill
  the cursor stood in" — the skill is real and merely unnamed — and "the turn's
  start" is kept for a genuine cold start, which is a different fact. The
  `propose-transition` refusal in the tool-effects judge had the same leak twice
  over: it composed "(reachable: beta, gamma)" from the raw hop set and "from
  'alpha'" from the raw cursor, and that sentence is appended to the tool result
  the model reads. It reads `scope.hiddenSkillIds` now, names the filtered hops,
  omits the clause when the filter emptied them, and anchors a hidden cursor the
  same way. `skill.rejected.currentSkillId` stays raw on purpose: it is the
  operator's record on the event channel, not a sentence the model reads.

- **A same-batch STAY did not compete for the transition slot.** The law is "first
  ACCEPTED proposal wins; later proposals to OTHER targets are superseded", and a
  stay is accepted — but it `continue`d past the bookkeeping, so a tool that judged
  its data first and said "stay" lost to a sibling later in call order that said
  "move", with two `'accepted'` events in one batch, the cursor moved, and no
  `route_conflict` on the record. A stay judged first now holds the slot (writing
  nothing to `pendingToolTransition`, because a stay moves nothing) and the later
  hop is `'superseded'` with the batch's `route_conflict` naming the stay as the
  winner; a stay judged after an accepted hop is the one superseded. Two stays are
  both accepted, as two hops to one target are.

- **Both 9.86.0 frames pointed with the word the same release repaired elsewhere.**
  The wrap-up instruction read "exhausted before this call … This call was for the
  final answer" and the stepped-skill nudge "This call was for running them". A
  frame is written into the `iteration_end` payload the checkpoint snapshots and
  restored verbatim by `applyContinuation`, so on the next `.continue()` turn —
  tools back on the wire — a model resolves "this call" to the call it is
  answering and reads "no tools were offered on it" about a request that offers
  them. They now name the call: "the wrap-up call this message opened … That call
  was for the final answer", and "This message asked for them to be run". The
  checker only knew `on this call`; a bare `this call` row catches the shape now,
  and it found nine more: seven `inspect_tool_call` result lines and the
  `inspect_tool_run` retention note, all anchored to `call '<id>'`; the coverage
  ledger's `COVERAGE_NOTE` ("ground the call this result answers did not look at";
  `canonical-notes.json` is regenerated by the build); and the runbook
  `recording_note`. The checkIn-resume refusal in the tool-calls stage — "cannot be
  retried this turn … Answer without it, or finish", a forecast plus a standing
  order on a persistent result — is a past fact about the resumed call now.

### Changed

- **The shape rows match the grammar they claim.** A second probe of seventeen
  sentences written AGAINST the rows — the plainest forecast forms, not the ones the
  rows had been derived from — walked past all four 9.86.0 shape rows: the
  effect-verb row knew no future or modal tense ("will move you", "can switch
  you"), the cursor row wanted a quote right after `in ` ("You are in skill
  'alpha'", "You're in 'alpha'", "Your current skill is 'alpha'"), the copula row
  knew six nouns ("is enabled", "is mounted", "are offered", "is off the wire",
  "have been withheld"), and a headed list ("Available tools: calc, probe.")
  has no copula at all. The rows are widened, a headed-inventory row and a
  next-call-forecast row are added, and the seventeen sit beside the fifteen in
  `test/modelFacingSurfaces.test.ts` so the next narrowing fails by name. The
  `src/` walk then flagged **twenty-five** more literals: ten are repaired above,
  fourteen are host-facing errors and check-up warnings now classified, and one —
  an integrity finding's frame line — joins the work list. The ledger stands at
  **ninety-one files / one hundred and seventy-eight literals**, with
  **thirty-four** unrepaired across thirteen entries; the suite computes those
  numbers.

- **A row may no longer exempt every lifetime.** `provableWhen` naming both
  lifetimes compiled, carried an argument, and disabled the row everywhere — the
  exemption-with-no-argument defect in a new coat. The suite asserts a strict
  subset now. The header of `test/helpers/modelFacingClaims.ts` also says what
  "may speak in the present" means beside the `now` row: present TENSE reported
  as the state of the request, not the deictic adverbs, which point at the moment
  of reading on every surface.

- **The divergence walk's summary block is checked whole.** Only `walk.cases` was
  read back; the other five numbers were written on update and never compared.
  All six are now derived from the recorded case outcomes and the row set. The
  placeholder gate also refuses `TODOs` and `to-do`, and the header names five
  defects, not three. A `claim-swallowed` row's auto-composed `cause` says "names
  this claimant as the winner of a wire it never reached" when the shadow report
  names the swallowed claimant itself — the framework's `skill-scoped:self-explain`
  provider — instead of "describes a different pair", which it does not.

- **Anchors in the walk's baseline and the design note name symbols, not lines.**
  The ten 9.86.0 rows cited `buildToolRegistry.ts` line ranges from the 9.85.0 tree
  that the same release had moved by about twenty-nine lines, beside a
  `buildToolsSlot.ts` line from HEAD. They name the checks now (`holders.includes
  (PRESENT_TOOL_NAME)`, the `seenNames` loop, the `sharedSkillTools` backfill), and
  the baseline's `note` says so. The `present-vs-mcp` row no longer claims the MCP
  cell proves the blind spot is the provider channel: both claimants mount through
  `staticTools()`, so the cell shows an MCP catalog inherits that seam unchanged and
  nothing more.

- **`scripts/release.sh` Gate 4 runs `npm run test:coverage`.** See the opening.

### Docs

- `docs/design/2026-09-recorded-not-built.md`: entry 1's "16 baseline rows" is 22
  (18 with a provider's or a skill's tool dead, 4 with the framework's own), the
  appendix lists it as the third corrected sentence, and the row paragraph says
  four-and-six rather than eight-and-two.
- `docs/api-reference/interfaces/AgentOptions.md`, tracked and last regenerated at
  9.58.0, still quoted the pre-9.86.0 wrap-up sentence ("Do not request tools");
  the quote is updated in place, as are the three other copies.
- The skill-graph quickstart says a host wiring its own `read_skill` under a
  `tree()` must set `ReadSkillOffer.treeRouted`, and why.

### Changelog corrections

Four sentences in 9.86.0 are corrected in place, each marked where it stands:

- _"Eight of the ten are already-recorded seams … Two are defects"_ — four rows
  reach recorded seams and six record the two new defects.
- `ToolRegistryArtifacts.toolDeclaringSkills` and `AgentState.hiddenSkillIds` were
  listed under Added as if public; neither type is exported from any door.
- `unknownToolResult` was called "exported"; it is a module export inside the
  tool-calls stage and not on any door.
- The `report-misattributed` bullet did not say that `reported` — a field the
  ratchet compares — changed body on nine unrenamed rows.

### Deliberately not changed

- **The two permission-denied arms** ("This will not change during this run — do
  not call it again") stay on the unrepaired ledger. Making the sentence true means
  latching a denial per run, a behaviour change with no field finding behind it;
  rewording it is that entry's own packet.
- **The `now` row keeps no exemption.** The header now argues the same thing the
  row does, rather than the row being softened.
- **`skill.rejected.currentSkillId`** is not role-filtered — see above.

## [9.86.0] - 2026-09-05

Every hand-counted list in 9.84.0 and 9.85.0 was short by one or two.

"Five classes of `role: 'user'` message are authored by this library" — seven
were. "Reachability OR posture" — three arms refuse. "Sixty configurations,
crossing every source" — the cross skipped four of the seven sources it named,
and there are seventy-six. The rules that catch a sentence which outlives its
moment were a transcript of the wordings that had already escaped: thirteen of
fifteen plausible forward-looking sentences walked straight through them.

None of those was a typo. Each was a fact the library computed in one place and
re-derived, by hand, wherever a second consumer needed it — and a hand-derived
list is a list that is short the day after somebody adds the next case. Three
of them are now WALKS rather than counts: the user-turn producers are parsed out
of `src/` with the TypeScript compiler, every sentence-shaped literal in `src/`
is run through the model-facing rules with a file and a line on failure, and the
offer/dispatch cross iterates its source list whole instead of filtering it.
Two more are single owners: one function answers "is this `read_skill` target
the cursor?", and one scope key answers "which skill ids may this role see?".

### Fixed

- **`read_skill` refusals answered for a fact nobody owned.** Five call sites
  needed to know that `makeReachableSkills` filters the cursor out of its own
  successor set — correct for a MOVE, silent about a READ. Three of them wrote
  their own `requested === cursor` line. Two never heard: a tool proposing
  `propose-transition` back to the cursor's own skill was refused as unreachable,
  and the `skill_read` permission gate was asked to grant a capability the model
  was already exercising, then told the model that its own skill was "not
  available in this context".

  `classifySkillTarget({ cursor, target, hops, open })` now owns it, returning
  `'self' | 'hop' | 'open' | 'unreachable'`. It is a pure function in the
  injection engine, exported through the `agentfootprint/context` barrel and the
  `agentfootprint/skill-graph` door — the same function object through both, so a
  foreign host cannot re-derive it wrongly either. The five consumers switch on
  it: the gate arm, `describeOffer`, the tool-effects judge, the `skill_read`
  permission gate, and the refusal composer. `makeReachableSkills` keeps its own
  exclusion — it is the PRODUCER of the hop set, and excluding the cursor is what
  a move means — and its doc comment now says the exclusion is about movement and
  sends the next reader to `classifySkillTarget`.

- **A refusal could name a skill the caller's own policy hides.** Role visibility
  was a property of one builder: `Agent.hiddenSkillIdsNow()` fed
  `buildReadSkillTool` and nothing else. So the description named nothing hidden
  while the gate, one stage downstream, composed its refusals — and filled
  `skill.rejected.allowed` — from the graph's raw sets.

  The tools slot now resolves the hidden set once per iteration, publishes it on
  `scope.hiddenSkillIds`, and both chart shapes bubble it. The gate keeps two sets
  on purpose: the RAW one it judges with, and the filtered one it speaks with. It
  judges with the raw set because a narrowing may take a schema off the wire and
  may never take a name out of the dispatch map — filtering admission would remove
  a capability, which the monotone rule forbids. In practice a hidden id never
  reaches the gate, because the same checker denies it upstream; the filter is what
  makes that true by construction rather than by coincidence.

- **Two refusal composers that contradicted each other forty lines apart are one.**
  `skillRefusal` and `postureRefusal` are replaced by `composeReadSkillRefusal`,
  and every arm of it is a past fact about the one call it names. Gone with them:
  "from here" (deixis — a different place on every re-read), "Pick one of these, or
  finish" (an exhortation in a string that persists for the rest of the run), and a
  posture arm that named a hop the very next arm would have declined. A refusal now
  opens `read_skill("X") was not granted on that call:` and every clause after it
  refers back to that call.

- **`Unknown tool: X` told the model it was wrong and never what would have
  worked.** Both dispatch doors now compose one `unknownToolResult` (a module
  export inside the tool-calls stage, not on any package door — _corrected in
  9.86.1_), which
  names the dispatch roster: `Unknown tool 'X' on that call. Tool names that
  resolved to an implementation on that call: …`, or, with an empty roster, that
  none did. The leading `Unknown tool` token is preserved, so every matcher on it
  is untouched.

  It says *resolved*, not *could be dispatched*, because two gates sit between
  resolution and a tool running — the `tool_call` permission check and the
  middleware chain — and neither is asked to phrase an error. And the roster is
  role-filtered before it is named: it used to read the dispatch map raw and could
  name a tool belonging to a skill the caller's own policy hides, which is the
  leak the refusals had just closed, one sentence over. `buildToolRegistry` now
  returns `toolDeclaringSkills` (tool name → the skills that declare it) from the
  walk it was already doing, and a name is withheld only when EVERY declaring
  skill is hidden — a tool two skills share stays named. Dispatch is untouched.

- **A filtered-empty list was reported as an empty one — a Lens denying what the
  Fold holds.** Three sentences branched on `length > 0` over an already-filtered
  array, so "the graph held nothing" and "the role filter emptied it" composed the
  same words. A cursor whose only declared hop was hidden answered `read_skill`
  with *"No skill was reachable from 'alpha' when that call was made."* while the
  graph was routing `alpha`; a `'guard'` menu whose every id had been hidden since
  the turn started said *"no menu was outstanding when that call was made.
  Declared routes moved the cursor instead."* — two false clauses in one breath.

  A model told the map is a dead end stops asking for the door it may not be
  shown, and the checker cannot see it: every one of those sentences passes
  `unprovable()`, because the defect is in what the composer was handed, not in
  how it was worded. So the fact is now a type. `SpokenIds` carries both halves of
  a filtered set — `named`, and `held` for whether the unfiltered set held
  anything — and `held` is required, so the compiler asks every caller the
  question every call site forgot to answer. Where a filter empties a set the
  clause is OMITTED. Omission is free and always true; the negative is a denial.

  The fourth sentence was the one the model reads to CHOOSE. `describeOffer`
  computed its columns from an already-filtered catalog, so a cursor whose only
  declared hop is hidden was told *"Nothing is reachable from here — answer with
  the skill you are in, or finish."* while the graph held that edge. It classifies
  the hop set over the unfiltered catalog now and drops the clause when the filter
  is what emptied it; with nothing wired out at all the sentence still stands,
  because that absence is one the description has evidence for. Reaching it meant
  moving `SpokenIds`/`spoken` to `src/lib/spokenIds.ts`: it lived in the tool-calls
  stage, on the wrong side of the skill-graph fence, so the description — composed
  inside `src/lib/injection-engine/`, which may not import the agent loop — was the
  one surface that could not use the fact its own refusals were repaired with.

- **Under a `.tree()` with nothing open, `read_skill` is no longer offered.** A
  tree routes by predicate on every iteration and keeps no cursor, so the tool had
  nothing it could do and a menu of one refusal is worse than no menu. The schema
  leaves the request; the NAME stays in the dispatch map, which is the same law as
  everywhere else. With open skills present, the description explains the tree and
  lists exactly what a pick can open, instead of printing "Nothing is reachable
  from here".

- **Two library-authored user turns were credited to a person.** The out-of-budget
  wrap-up instruction and the stepped-skill nudge both append to `scope.history`
  with `role: 'user'` and took no registered opening, so `isSaidByPerson` said a
  person wrote them. Two things followed. The window's refusal engine could pin
  "the current request" on the framework's own wrap-up instruction and drop the
  real request underneath it. And a routing rule written the documented way —
  `saidByPerson(ctx).some((m) => m.content.includes(…))` — matched on the library's
  own bookkeeping: the wrap-up said "Do not request tools", and the nudge names a
  skill id and every unrun step's tool name.

  Both are registered now, and `LIBRARY_AUTHORED_PREFIXES` holds all six openings
  frozen, so the writer and the recogniser read one constant. Both sentences were
  also rewritten: each was composed once and re-read on every later call of the
  turn, which made their present-tense clauses predictions.

- **Three trace-toolpack results said "this call" and "right now".** They are
  anchored to the call they answer, in the past tense — including the one arm the
  new deictic-container rule caught the first time it was ever composed.

- **`escalation` counts three kinds of refusal, and its docs named two.**
  "Reachability OR posture" is wrong in the JSDoc behind `EscalationPolicy` and
  `SkillGraphOptions`, in the `skill.escalated` payload doc and on the skills page:
  the counter fires beside all three `skill.rejected` emit sites, self-call
  included. No behaviour changed — the self-call site has counted since 9.84.0,
  deliberately.

### Added

- **A WALK over every `role: 'user'` construction site in `src/`.**
  `test/lib/injection-engine/userTurnProducers.test.ts` parses the tree with the
  TypeScript compiler (a `PropertyAssignment` of `role: 'user'`, so type members
  and comments quoting the string are not counted) and requires every site to be
  classified as an authored frame, a person's own words, or never-in-history, each
  with a written reason. **Thirty-five** sites are classified today: six authored
  frames, seven person, twenty-two never-in-history. Sites are keyed by file with
  the per-file COUNT asserted, so a new producer inside an already-listed file
  fails as loudly as one in a new file. One producer the parser cannot see — the
  message an injection delivers, whose role is copied off the `Injection` — is
  named in the header and pinned by its `injectedBy` marker instead.

- **The model-facing checker judges SHAPE, not just remembered wordings.** Four
  new rules: a present-tense copula with a capability noun, deictic-present
  adverbs, second-person effect verbs, and a standing imperative at a clause
  start. Fifteen plausible forward-looking sentences were written out and put
  to the rules: "You are currently in 'alpha'", "Calling read_skill switches you
  to beta", "The following tools are available to you: …", "Nothing is live in
  this scope at the moment". All fifteen are caught by the rules as they stand,
  and the suite asserts exactly that. **Thirteen** of them passed against the
  rule list AS IT STOOD BEFORE THIS RELEASE — the number that motivated the
  work, measured once against a list this tree no longer contains, so it is a
  record of why the rules changed rather than something a run here can
  reproduce. `exemptBecause` is now structurally
  required: `BannedClause` is a discriminated union, and because the root
  `tsconfig.json` excludes `test/`, that is proven where it can actually be
  compiled — `test/type-regressions/`.

- **A WALK over every sentence-shaped literal in `src/`.** The registry's own
  header used to say the gap it could not close was "a scan of `src/` … and this
  is not that". `test/modelFacingScan.test.ts` is that: it parses every `.ts` file
  under `src/` with the TypeScript compiler, folds `+` chains and template holes,
  runs each literal through the rules at the persistent lifetime, and fails with
  `file:line` unless the file's flagged literals are accounted for in a ledger of
  **eighty-four files / one hundred and sixty-three literals**, every entry naming
  where the string is delivered and how many literals it covers. Per-file counts
  are the guard again. Four things it cannot see are stated in its header rather
  than left to be found: a sentence assembled across statements, text that lives
  in data rather than in `src/`, literals under twenty-five characters, and any
  falsehood that avoids all the shapes.

  Its ledger carries an `unrepaired` bucket of **thirty-three** literals across
  thirteen entries that are model-facing, persistent and correctly caught, and
  that were left alone because each needs its own tests. They are named with their
  delivery site, so the bucket is a work list rather than a pardon. The bucket's
  arithmetic is asserted by the suite itself — the counts above come from a run,
  not from a report, which is the failure this whole entry is about.

- **Five live producers are registered and read.** The `read_skill` refusal
  composer (every arm), `unknownToolResult`, the trace toolpack's inspection
  results, and — closing the gap 9.85.0's registry named — the wrap-up and
  stepped-skill frames at a shared `INJECTED_TURN` surface.

- **The offer/dispatch cross iterates its source list whole.** `frameworkCases()`
  re-derived a source list inside the walk —
  `CLAIMANTS.filter((c) => ['static', 'provider', 'skill-active'].includes(c.id))`
  — so a second hand-written list of sources existed with nothing keeping it in
  sync with the first, and 9.85.0's "crosses every source" was false. That mattered
  because the framework's four auto-attach reservations each read a DIFFERENT
  build-time list, so which source holds a contested name is precisely what decides
  whether a reservation can see it. The walk goes from **sixty configurations to
  seventy-six** and from **thirty-six divergence rows to forty-six**; all ten new
  rows carry a hand-written, checked `tolerated`. Three new tests own what was
  previously true only because somebody had typed it: that every claimant is
  crossed against every auto-attach name, that the header's arithmetic equals the
  recorded case count, and that a placeholder `tolerated` is refused (empty,
  `todo`/`tbd`/`fixme`/`xxx` on a word boundary in any case, or under forty
  characters — a floor on effort, not a measure of truth).

  Four of the ten reach seams the 9.85.0 baseline already recorded, through a
  source that had never been crossed, and say so. Six record two defects nobody
  had recorded — four rows for entry 4 and two for entry 5 of
  `docs/design/2026-09-recorded-not-built.md` — rather than papering over them
  (_corrected in 9.86.1: this paragraph said "eight of the ten" and "two", a
  count of defects presented as a count of rows_): `.selfExplain()` reserves its trace-tool names against
  `this.registry` and never `this.injectionList`, making it the one auto-attach
  family with no net at all against a skill's `tools: []`; and the misattributed
  shadow report can now name a `skill-scoped:self-explain` provider — one the
  consumer did not write and cannot open — as the file to go look at.

- **`report-misattributed` rows carry their attribution in the row id.** A shadow
  event's meaning lives in its `schemaFromId`/`dispatchToId`, and the row was keyed
  on case + tool + epoch, so two reports naming different sources in one epoch — a
  strictly worse fact than one wrong report — collapsed into one `Map` entry and
  vanished. The `reported` column of every row now carries the `*Id` halves too
  (`schemaFrom=provider(static) dispatchTo=skill(desk-active)`), which is why nine
  rows whose ids did not move changed body in the same re-record (_added in
  9.86.1; the re-record changed a compared field and the entry did not say so_).

- **`SkillRejectedPayload.allowed` is what the model was actually told.** Role-
  filtered rather than the graph's raw set. Shape unchanged; only agents with a
  `PermissionChecker` governing `'skill_read'` see any difference. The field's own
  JSDoc says so at the call site, which is the doc a consumer actually reads.

- **`ToolRegistryArtifacts.toolDeclaringSkills`** (internal — `ToolRegistryArtifacts`
  is not exported from any door; _corrected in 9.86.1_) — tool name → the ids of the
  skills whose `inject.tools` carry it, recorded on the walk `buildToolRegistry`
  was already doing and thrown away. Empty for an agent whose skills carry no
  tools. Its one consumer is the unknown-tool roster's role filter; it exists so
  that consumer does not walk `Agent.injections` a second time to re-derive what
  this file already knew.

- **`ToolEffectPayload.stay?: true`** on `agentfootprint.tools.effect` — a
  `propose-transition` naming the cursor's own skill is accepted as a no-op.
  Deliberately not a fourth `outcome`, so an exhaustive consumer switch keeps
  compiling.

- **`AgentState.hiddenSkillIds?: readonly string[]`** (internal — `AgentState` is
  not exported from any door; _corrected in 9.86.1_) — the per-iteration
  role-hidden set, written by the tools slot and read by the `read_skill` gate.

- **`ReadSkillOffer.treeRouted?: boolean`** — declares the mounted graph a decision
  tree, which is what lets the descriptor withhold the offer.

### Two decisions worth stating plainly

- **A self-call at a MOUNTED cursor is answered BEFORE the permission gate**, because
  it exercises no capability. `read_skill` naming the cursor's own skill activates
  nothing and moves nothing, so there is no grant for a `PermissionChecker` to make or
  withhold; asking it produced a denial about the one skill whose body was already in
  that call's system prompt. The skip stops at a PARKED cursor, and deliberately: a
  park suppresses a map's contribution without moving the cursor, so the gate below
  reads the same id as a RE-ENGAGEMENT and puts the body and its tools back on the
  wire — which is a capability, and the policy's question to answer. One predicate,
  `atMountedCursor`, is what both gates ask. Every other id still goes to the policy.
  The refusal BUDGET is
  unchanged and still counts the self-call, including the `surfaceMode: 'both'`
  re-read that returns the body — the 9.84.0 argument stands, and it is about the
  loop rather than about the wording: a model that keeps asking the graph where it
  stands instead of working is exactly the stuck run escalation exists for.

- **A `propose-transition` naming the cursor's own skill is a STAY**, accepted as a
  no-op with `stay: true` on the event and no refusal on the result. The tool asked
  for a state the run is already in; there is nothing to move and nothing to refuse.

### Deliberately not changed

- **The three `STATED:` prose pins** in `src/core/agent/buildToolRegistry.ts` are
  untouched, word for word. `test/core/agent/epoch-laws.test.ts` and
  `test/core/agent/toolDivergenceWalk.test.ts` both read them.
- **The escalation budget still counts `'both'`-mode self-call re-reads**, per the
  argument above.
- **The grounding gate** — item 5 of the "Offer, Not Dispatch" review — is a new
  DIAL, not a fix for anything here, and is not in this release.
- **The flat default is not narrowed.** `scopeTools` stays `false` until 10.0.0.
- **`isLibraryAuthoredTurn`** (the evidence gate's exempt corpus) is deliberately
  narrower than `isSaidByPerson` and was not widened to the two new frames. It
  decides who SUPPLIED a value, not who wrote a turn; widening it would change which
  values the evidence gate exempts, with no finding behind it.

### Changelog corrections

A reader auditing this project by its changelog has to be able to trust the older
entries, so six sentences in 9.84.0 and 9.85.0 are corrected in place, each marked
where it stands:

- **9.84.0** — _"Five classes of `role: 'user'` message"_: seven kinds are
  library-authored; the wrap-up instruction and the stepped-skill nudge went
  unregistered until this release.
- **9.84.0** — _"The window's own refusal engine has always applied that rule"_: it
  applied a three-class version, and 9.84.0 widened it to five.
- **9.84.0** — _"a step or park hold-out says the tools were withheld rather than
  naming them"_: the withheld arm names the declared tools, and a parked cursor
  never reaches the notice at all.
- **9.85.0** — the fifth _"model-facing sentence"_ bullet credited 9.85.0 with a
  `read_skill` description fix that shipped in 9.84.0, and quoted a sentence that
  existed only in a source comment. Removed, with the reason left in its place; the
  count above it is now four.
- **9.85.0** — _"drives a real run per configuration. Sixty configurations,
  thirty-six divergences"_: forty of seventy-six are driven, twenty-six are refused
  at build and ten are not constructible; the enumeration it replaced was a
  development draft, never a shipped list.
- **9.85.0** — law 1 was restated unscoped. It is scoped to the tools
  `buildToolRegistry` routes, with the shadow seam and the walk as its recorded
  exceptions.

## [9.85.0] - 2026-09-04

A sentence composed once and read many times is not a fact — it is a prediction.

9.84.0 fixed one such sentence and shipped a checker for the class. The checker
covered two surfaces. Five more sentences matching its own existing rule were
live elsewhere in the tree, unread by it, because coverage was decided by which
suite happened to import the helper. That is the same defect one level up: a
guard asserting a boundary it cannot verify.

### Fixed

- **Four model-facing sentences that outlive the moment they were true.** Each is
  now anchored to one named call, in the past tense, after tracing it to its
  delivery point to confirm it really is re-read:

  - `artifacts/present.ts` and `artifacts/wants.ts` — _"Nothing is live in this
    run's scope right now"_ and its sibling inventory, _"Live refs in scope: …"_.
    A census goes wrong in BOTH directions on re-read: entries sweep, new ones
    appear.
  - `core/codeRunnerTool.ts` — _"staged into this session"_. The anchor resolves,
    and goes on resolving; the defect is that it resolves to a scope holding many
    calls, so a per-call report cannot say which call it describes.
  - `maps/engagement/parkCard.ts` — _"Its instructions and its tools are not being
    sent right now"_, on a card that rides every call while a map is parked. Its
    falsifier is compose order, not staleness: the card is written in the
    injection-engine pass and the tools slot that acts on the park runs after it.

  _Corrected in 9.86.0._ A fifth bullet stood here, crediting 9.85.0 with the
  `read_skill` description fix and quoting _"You do not need read_skill to go on
  using it"_ as a wording it had replaced. `skillToolDescriptors.ts` has no
  non-comment change between `v9.84.0` and `v9.85.0`: that fix shipped in 9.84.0,
  where it is also recorded, and the quoted sentence lived only inside a source
  comment — no release ever put it on the wire. What 9.85.0 added to that file is
  the LENS LAW block above `describeOffer`, which is a Documentation change.

### Added

- **Surfaces carry `channel` and `lifetime` separately.** Where a string is
  delivered and how long it lives do not correlate: system text is rebuilt every
  request, so a present-tense clause in it is a fact; a tool result persists, so
  the same clause is a forecast. The rules judge lifetime. Both existing
  exemptions turned out to be lifetime claims wearing channel clothes and are now
  derivable rather than asserted.

- **A producer registry that fails when a surface is not exercised.** Coverage
  decided by which suite imports a helper is a habit, not a guarantee. Its header
  states what a green run does not prove: hand-maintained rows cannot see a
  producer nobody registered.

- **The offer/dispatch divergence list is walked, not written.**
  `test/core/agent/toolDivergenceWalk.test.ts` crosses every source that can put
  a name on the wire or answer to one — static, provider, MCP, always-on skill,
  active skill, inactive skill, stepped skill — against six narrowing states and
  the framework's auto-attach names. _Corrected in 9.86.0:_ at 9.85.0 the
  auto-attach cross did NOT reach every source — it filtered `CLAIMANTS` down to
  three of the seven — and the walk does not drive a real run per configuration.
  Both are true of the walk as it stands after 9.86.0 widened it, with these
  counts. Of its **seventy-six** configurations, **forty** are driven
  as real runs (thirty-six divergent, four clean), **twenty-six** are refused at
  build — which is the walk exercising a refusal, and its `because` records the
  refusal's first line — and **ten** are not constructible at all, so no run is
  attempted. **Forty-six** divergence rows come out of the forty driven, each
  with a mechanically derived cause and a stated reason it is tolerated. New
  fails. Disappeared fails. Vacuous fails, and is unbaselineable.

  It replaced a hand-written enumeration that claimed completeness and was
  falsified three rounds running — _corrected in 9.86.0:_ that enumeration was
  drafted and falsified during this work, and no released version ever carried
  it, so the walk shipped in place of a draft rather than of a shipped list.

  It then found three classes nobody seeded: a
  provider tool whose name a registry holder already owns is dead in both
  directions and the shadow report cannot see it; the auto-attach names disagree
  about what they refuse; and `selfExplain` is a fourth family whose reservation
  reads only the static registry.

### Documentation

- **Three laws stated where the code lives**, epoch-scoped, after two earlier
  phrasings were false in shipped configurations. Law 1, as the source states it
  and _corrected here in 9.86.0_, is SCOPED: **among the tools `buildToolRegistry`
  routes**, every offered capability resolves to a dispatchable implementation
  with stable identity for that epoch — same-epoch offer implies same-epoch
  dispatch. It is not a claim about the whole wire, and the source names its
  recorded exceptions rather than implying there are none: the SHADOW SEAM (the
  wire list is merged one layer out in `buildToolsSlot` and carries provider
  schemas these maps never hold), with the full enumeration delegated to
  `test/core/agent/toolDivergenceWalk.test.ts`. The second clause is unscoped and
  unchanged: attention may alter the offer, but omission from the offer must not
  be presented as proof of permanent capability loss. Only static skill-registry
  tools are known to remain dispatchable after leaving the offer.

- **`docs/design/2026-09-recorded-not-built.md`** — three real defects with
  reproductions, deliberately not fixed: an inactive skill's tool shadows in
  silence, the shadow report names the wrong source, and `skip_step` is
  claimable by a provider. Each names what deciding to fix it would cost.

## [9.84.0] - 2026-09-03

### Fixed

- **`read_skill` refused the skill the model was already in.** A turn routed
  decisively to `X`, the model called `read_skill("X")` to find out where it
  stood, and the gate answered _"`read_skill("X")` is not reachable from here.
  Reachable skills: …"_ — about the one skill whose body was in that call's
  system prompt and whose tools were in that same call's tool list. The cursor
  is in neither half of `hops ∪ open` by construction: `makeReachableSkills`
  filters it out of its own successor set (a move to where you already are is
  not a move) and `openSkillIds()` excludes every graph-wired skill. Nobody had
  written the case for _"you asked for the room you are standing in."_ Read as a
  claim about AVAILABILITY — which is how a model reads _"not reachable"_ — it
  says the opposite of the request it arrived in. A field report recorded the
  consequence three times in one day: the model concluded its capability was
  gone and answered that it could not help, while the skill's tools sat on the
  wire, loaded and callable.

  A self-call now gets the truth instead of a refusal. It names where the model
  stands and which tools it could call, taken from the merged wire list the LLM
  stage actually sent, intersected with the skill's own declared tools — never
  from the declaration alone. Every configuration that would make that false has
  its own wording: a skill declaring no tools says so, a hold-out names the
  declared tools and states that they were withheld — _corrected in 9.86.0:_ this
  read "a step or park hold-out says the tools were withheld rather than naming
  them", and the withheld arm does name them; a PARK never reaches the notice at
  all, because a self-call at a parked map member is a re-engagement request
  (9.59.0) answered on an earlier arm — and a call whose wire cannot be
  established says nothing about tools at all. Mechanically it is still
  a rejection — no activation, no cursor move, and the refusal budget still
  counts it, because a self-call _loop_ is exactly the stuck model that budget
  exists to escalate.

  **Every clause is anchored to one named call.** A tool result is composed on
  one iteration and re-read on every call after it, including the out-of-budget
  wrap-up that carries no tools under _"Do not request tools."_ So the notice
  makes no forward-looking claim at all: no exhortation to act, no offer of a
  move, no clause conditioned on a budget or a posture that can change after the
  sentence is written. Deixis counts as forward-looking — _"the call you just
  made"_ denotes a different call on each re-read, so the anchor is named once
  in the opening sentence and every later clause refers back to it.

- **The `read_skill` description said the same thing in the other channel.** The
  current skill was listed under _"Not reachable from here (read_skill for these
  will be refused)"_, where it appeared purely as an artefact of that same
  filter. It is in neither column now, and the description names the cursor on
  every call that has one — the positive signal whose absence was the root of the
  field failure, since the system prompt carried the skill's body with nothing
  saying which skill it was. A genuinely unreachable skill is still named there,
  and a genuinely unreachable hop keeps its refusal word for word.

- **The description no longer predicts what `read_skill` will do.** Naming the
  cursor is the fix; every sentence tried alongside it turned out false
  somewhere. _"read_skill MOVES you to a DIFFERENT skill"_ is false at compose
  time under `strictness: 'rails'` (every model hop refused) and under `'guard'`
  off an outstanding menu, where the posture arm contradicts it head-on. Its
  replacement — _"You do not need read_skill to go on using it"_ — was argued to
  be a claim about necessity that no posture, budget or hold-out could falsify,
  and the **park** falsifies it: a parked map member keeps the cursor, loses its
  body and its tools, and `read_skill` is then the only door back. The
  description is composed before the hold-outs run, so it cannot know when such a
  claim would be lying. Outside an outstanding menu it now states the name and
  stops. The menu's stay clause is unchanged.

- **Role visibility now covers the cursor.** The description read the cursor id
  past the `hiddenIds` filter, so a role denied `skill_read` on the skill the
  graph had routed to was still told _"You are in '\<that skill\>'"_ — leaking
  the name of a capability no cursor move would ever grant it. A hidden cursor is
  named nowhere: not as reachable, not as refusable, not as the cursor, and not
  in the menu's stay clause. The security suite's _"a hidden skill is never
  named"_ property is now driven on the `.skillGraph()` path as well as
  `.skill()`; it stayed green through the leak because its agents used `.skill()`
  only, so the leaking line never executed.

### Added

- **`saidByPerson(ctx)` / `isSaidByPerson(msg)` — telling what a person said from
  what the library wrote.** _Corrected in 9.86.0:_ this said "five classes", and
  **seven** kinds of `role: 'user'` message are authored by this library, not by a
  person. Five are registered here: the compaction frame, the drop notice (whose
  text names tools), the schema-check and evidence-check corrections, and any
  injection-delivered message. The out-of-budget wrap-up instruction and the
  stepped-skill nudge were library-authored the whole time and went unregistered
  until 9.86.0. _Also corrected:_ the window's own refusal engine had not "always
  applied that rule" — it applied a THREE-class version (drop notice, compaction
  frame, injection-delivered), and 9.84.0 widened it to the five registered here,
  so a schema-check or evidence-check frame can no longer become the protected
  anchor. That widening changes the anchor only when the run's own message text is
  absent from the window, because the anchor is matched by content first and only
  falls back to the last thing a person said. A `when` predicate could apply no
  version of it, because `InjectionContext.history` exposes only
  `{ role, content, toolName? }`. An author writing an entry rule
  that reads history was silently matching on our own bookkeeping. One
  implementation, reused by both — the rule cannot drift between routing and the
  window.

- **`SkillRejectedPayload.reason`** — `'self-call' | 'unreachable' | 'posture'`,
  optional and additive, so a consumer can tell a self-call from a genuine
  unreachable hop without comparing two fields.

### Documentation

- **`strictness` says what a posture governs, exactly.** A posture governs the
  model's `read_skill` door and nothing else. Two doors stay open under all three
  postures: OPEN skills, already stated, and a tool's `propose-transition`, now
  stated with its reason and its reachability check. `'rails'` means _the model
  never routes_ — never _"nothing but my declared edges routes"_; a tool of yours
  that proposes is a route you declared in code instead of in the graph. No
  behaviour changed: the exemption is recorded in three places and pinned by a
  test whose title is the argument.

- **The injection-engine README's runtime picture matched an older engine.** Its
  diagram drew one box that "evaluates triggers"; the engine is a four-stage
  footprintjs subflow — Gather, Evaluate, Route, Delta — and the cursor, the step
  pointer, the instruction leases and map engagement all advance inside Evaluate.
  The events table placed `context.evaluated` at subflow exit; the code emits it
  in stage 2 of 4. Both corrected, along with three counts that had drifted.

## [9.83.0] - 2026-09-03

### Fixed

- **The evidence gate claimed a boundary it did not measure.** Both of its
  user-facing sentences — the correction it sends the model
  (`buildEvidenceCorrection`) and the warning it prints an operator
  (`evidenceRefusalSentence`) — said the flagged values _"appear in NO tool
  result **from this turn**"_. The index behind them has never been turn-scoped:
  it walks every `role: 'tool'` turn in the history. The library was asserting a
  scope it could not honour, in the two places that assertion is read.

  Both now say what the check really reaches — _"appear in no tool result this
  run read"_ — which is both true and the stronger claim, and the operator
  sentence adds the two facts a reader needs: that the corpus is the LIVE
  WINDOW (a window strategy rewrites `scope.history` in place, so a dropped
  result is not in it), and that `noticePriorTurnEvidence` is what answers the
  recency question. The frame PREFIX is unchanged, so
  `isLibraryAuthoredTurn` and every consumer matching on it are untouched.

### Added

- **`noticePriorTurnEvidence` — the answer is grounded, and nothing this turn
  fetched grounds it.** Default off.

  The measured failure: a consumer's agent answered a data question with **zero
  tool calls**, and the gate approved it — `LLM calls 1 · Tool calls 0 ·
Iterations 1`, then _"All 7 values in the answer were found in what the tools
  returned — the answer stands."_ They were found: in an inventory result from
  four turns earlier, fetched for a different question. The user had asked about
  array performance; the answer recommended enabling a collector that had been
  running for months. Two turns did it back to back. Every rail passed honestly
  — the gate measures GROUNDEDNESS and had no notion of WHEN a value was
  grounded.

  Every indexed form now carries the turn that last served it — one number,
  stamped during the walk the index was already doing (`EvidenceCorpus.values`
  became a `Map<form, turn>`; a TURN starts at each `role: 'user'` message the
  library did not author). When at least one value in the answer is grounded and
  **not one of them** came from the turn being answered, one `advisory` finding
  is filed at the claim seam:

  ```ts
  const agent = Agent.create({ provider, model, noticePriorTurnEvidence: true })
    .tool(arrayInventory)
    .namesAndNumbersFromEvidence() // ← the other half: it owns the extractor
    .build();

  await agent.run('what arrays are there?'); // fetches, answers, files nothing
  await agent.followUp('how is array performance?'); // no tool call, answers from turn 1
  // → prior-turn-evidence: 3 grounded value(s), all last served in turn 1,
  //   and this turn called no tool at all.
  ```

  **The corpus is deliberately NOT narrowed to this turn.** That would have made
  the old sentence true and been the wrong fix: _"and what about that disk?"_
  leans on the previous turn's rows legitimately, and a check that cries wolf is
  a check somebody switches off. ONE grounded value from this turn's own results
  files nothing — not a threshold to tune, but the falsification of the claim
  being tested. A follow-up that calls a tool usually gets that for free,
  because a lookup keyed on an earlier identifier echoes it back.

  A turn that served no tool results at all is the SAME finding with a stronger
  witness, not a second kind: it is a cheaper proof of the identical fact.

  **The ceiling** ships as `PRIOR_TURN_EVIDENCE_CEILING`, exported and quoted
  verbatim into every message: referring back is indistinguishable, by evidence
  alone, from going stale; the ordinals count only the turns still in the live
  window, so the distance is a FLOOR (the boundary itself is exact — the
  current request is un-droppable); and values that reached the model through
  `.memory()` recall or RAG are exempt from grounding and invisible to it, so it
  can under-report and never over-report.

  **Two halves arm it**, and the second is structural rather than a policy
  companion: the dial AND `.namesAndNumbersFromEvidence()`, whose extractor
  decides which tokens in an answer are values at all. It REPORTS — whether an
  answer is advised or refused stays the gate's own `posture` decision, and
  nothing here blocks, revises or rewrites anything. Absent, a run is
  byte-identical save the registered `prior-turn-evidence` row filed
  `not-applicable`, which is the family's law rather than an exception to it.

  Three terminal exits reach a caller without the gate ever producing a
  grounding reading — an empty answer, a middleware denial, and an answer the
  output schema rejected — and each files its disposition rather than leaving
  the armed row untouched. An untouched armed row is what `assertAlive` reads
  as wiring rot, so without this an empty answer under
  `integrityPosture: 'dev'` would have failed a healthy run with
  `CheckerDeadError`.

  Docs: [Prior-turn evidence](https://agentfootprint.dev/docs/monitor/prior-turn-evidence).

## [9.82.0] - 2026-08-30

### Added

- **A runbook can finally name the verdict no rule chose.** `verdict_meanings`
  is generated from what the run itself said: the branch descriptions the chart
  declared, and the rule labels this run's `decide()` evidence carried. For one
  branch, both sources are silent by construction — the DEFAULT. It is the
  branch chosen by _no rule_ (it fires exactly when every rule failed, so no
  `label` describes it), and when the decider lives inside a dynamically
  generated fan-out branch the branch chart does not exist at build time either,
  so there is no declared description to fall back on.

  The library shipped visible proof of the gap: this repo's own worked example
  and the published docs page showed a `"verdict": "protected"` row beside a
  `verdict_meanings` map with no `protected` key.

  The meaning is now declared where the rules are declared — one line at the
  `decide()` call, on `footprintjs` ≥ 9.16.0:

  ```ts
  // before
  decide(scope, POSTURE_RULES, 'protected');
  // after
  decide(scope, POSTURE_RULES, {
    branch: 'protected',
    label: 'no rule fired — last backup within the 7-day threshold',
  });
  ```

  It rides `DecisionEvidence.defaultLabel` and is harvested exactly like a rule
  label — on every decision, including runs where a rule won, so a published
  meanings map does not gain and lose a key with the day's data.

  What deliberately did NOT change: there is **no caller-supplied meanings map**
  at the tool boundary. A map a caller can hand in is a map that can describe
  rules that never ran, and it would be indistinguishable in the answer from
  meanings the run produced. Declare nothing and `verdict_meanings` stays
  honestly silent about that branch — the bridge never invents a sentence from a
  branch id. A blank label (`''`) is recorded as no meaning at all, for a rule or
  for the default.

  Example: `examples/features/68-runbook-as-tool.ts` — its `verdict_meanings`
  now explains every verdict its own rowset shows.

### Changed

- **`footprintjs` peer dependency: `^9.15.0` → `^9.16.1`** — 9.16.0 carries
  `DefaultBranch` / `DecisionEvidence.defaultLabel`; 9.16.1 is the floor because
  9.16.0 threw on a `decide()` call that omits its default (this repo's own
  suite caught it).
- **The engine version stamp on a recording envelope is real again.** footprintjs
  9.15.1 added `'./package.json'` to its `exports` map, so `engineVersion()` can
  resolve the manifest it always tried to read: `producer.footprintjsVersion` and
  a bug report's `environment.footprintjs` now carry the installed version
  instead of the honest-but-useless `'unknown'`. The test that pinned the defect
  (and asked to be tripped when it was fixed) now pins the version instead.

## [9.81.0] - 2026-08-30

### Added

- **MCP from a browser — because the barrier was never the protocol, it was one
  line of ours.** `mcpClient` loads `@modelcontextprotocol/sdk` through a Node
  `require` loader, and that loader does not exist in a browser bundle. The SDK
  itself is fine: its `client/index.js` and `client/streamableHttp.js` bundle at
  `platform: 'browser'` with **zero** `node:` edges and never pull in
  `client/stdio.js`. So the fix is not to reimplement anything — it is to let the
  caller supply what the library would otherwise have loaded.

  **`sdk?: McpSdk`** — hand over the two SDK modules, imported statically by your
  own bundler, and the library **still builds the transport**. Everything the
  transport carries keeps working: `headers`, your own `fetch`, gateway
  vending, `retryOnThrottle`, `_meta` ingestion.

  ```ts
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
  import { mcpClient } from 'agentfootprint/providers';

  const sidecar = await mcpClient({
    name: 'sidecar',
    sdk: { Client, StreamableHTTPClientTransport },
    transport: { transport: 'http', url: '/py/mcp' },
  });
  const tools = await sidecar.tools(); // the same readonly Tool[], _meta and all
  ```

  **`connection?: McpConnection`** — the full escape hatch: you connect the
  client, the library only adapts its tools. Three methods over JSON-RPC and no
  vendor named, so an SDK `Client`, a fake, or a future fetch-only transport all
  satisfy it. `connect()` is deliberately **absent** from the type: you already
  connected it, and the library never calls it. Reach for this when the library
  must not construct anything — a strict CSP, where the SDK's own
  `jsonSchemaValidator` (reachable only here) is what keeps ajv's `new Function`
  off the page.

  **The refusals are the design.** On the `connection` arm the library builds no
  transport, so every option consumed INSIDE one is refused at construction,
  naming where the behaviour went — `retryOnThrottle`, `clientInfo`, `transport`,
  `sdk`, `_client`. Accepting a knob that names a behaviour which no longer
  happens is the defect class this release exists to close, not a convenience.
  `signal` IS honoured on both arms: it rides the SDK's request options.

- **`retryingFetch` is public** (`agentfootprint/providers`), with
  `ThrottleFetch`. It was `@internal`, and on the `connection` arm — the one a
  browser takes — a caller would otherwise have silently lost the HTTP 429
  handling every Node consumer gets ON by default. Same implementation, applied
  where you build the transport: `fetch: retryingFetch(yourFetch, { maxAttempts: 5 })`.

### Fixed

- **A relative `transport.url` resolves against the page.** `new URL('/py/mcp')`
  throws `TypeError: Invalid URL` — correct in Node, wrong in a browser, where a
  same-origin path is the ordinary way to reach a sidecar (and the way to avoid a
  CORS preflight entirely). It now resolves against `globalThis.location.href`
  when there is one, and in Node refuses **by name**, saying which world it is in
  and to pass an absolute URL. An absolute url takes the identical first branch,
  so Node behaviour has not moved.

- **The SDK-load errors stop lying.** Every load sat behind a bare `catch`, which
  cannot tell "the package is absent" from "the LOADER is absent" — so a browser
  that had the SDK installed all along was told to `npm install` it, and nothing
  changed when it did. The failure is now classified at all seven sites (three in
  `mcpClient`, four in `mcpServe`): a resolution failure produces the
  **byte-identical** message every release before this one produced, and anything
  else names the underlying error and the seam that gets past it. `mcpServe` says
  the honest thing instead — it listens on stdio or a Node socket, so that
  direction cannot run in a browser at all.

### Unchanged, deliberately

- **Zero packaging change.** No new subpath, no `browser` export condition (a
  compiler is blind to it), no `typesVersions` row, no `postbuild-esm.mjs` edit.
  `@modelcontextprotocol/sdk` stays an OPTIONAL peer, and there is no literal
  dynamic `import()` of it anywhere — one would be statically resolved by a
  bundler and would hard-fail the build for every consumer who does not have it.

- **Every existing Node consumer.** A call with neither `sdk` nor `connection`
  reaches the same `lazyRequire` on the same specifiers and builds the same
  transport; the difference is a `??` on an `undefined` parameter. `stdio` keeps
  the loader permanently — it spawns a subprocess, so it can never be portable,
  and keeping it there is what keeps the SDK's one Node-importing client module
  off every browser graph.

### Proof, and its limits

`test/lib/mcp/browserGraph.test.ts` bundles the SHIPPED `dist/` the way a browser
build does and asserts: `agentfootprint/providers` still bundles with the MCP SDK
**blocked at resolve time** (the optional-peer property, stated as a build); its
`node:` edges are EXACTLY the two known ones; the builtins hidden behind
`lazyRequire` — which no module graph can see — are exactly the four known ones;
the path a browser walks reaches `node:module` and nothing else and never pulls
in `client/stdio.js`; and everything on that path except the loader bundles with
NO externals and zero node edges. `mcpConnection.real.test.ts` drives both new
arms through a real socket against the real SDK.

**What none of that proves: a browser.** This repo has no browser test
environment, so nobody has yet driven initialize/listTools/callTool from an
actual page. The honest status is _proven in Node, fenced at the graph, not gated
in a browser._ Three costs land on the app, not here: your server must send CORS
headers (every MCP request preflights, and `Mcp-Session-Id` must be in
`Access-Control-Expose-Headers`) — `mcpServe` sends none; SSE through a dev or
production proxy is unproven; and the SDK's client path adds roughly 260 KB
minified, about half of it ajv, whose `new Function` needs `unsafe-eval` the
first time a tool with an `outputSchema` is validated.

## [9.80.0] - 2026-08-30

### Added

- **`presentation` on `runbookAsTool` — a rowset's surface is a fact about the
  CLIENT, and the caller is the only one who knows it.** Seen in production: a
  triage answer opened with a wall of pipe-delimited rows — the entire verdict
  table retyped into the prose — while those same rows were already on screen
  beside it, ticketed as a dataset with a table view and a chart view. The host
  had a standing rule against exactly that. The library was overruling it.

  The instruction was ours. Every verdict projection shipped `table`
  pre-rendered with `VERDICT_RENDER_NOTE`: _"table is PRE-RENDERED over the
  same rows as `verdicts` — output it VERBATIM."_ That note is RIGHT wherever
  the model's words are the rows' only surface — a chat client, a log line, an
  email — because the alternative there is retyping, and a retyped identifier
  that looks right and matches nothing is the failure the note exists to stop.
  It is WRONG in a client that draws the rowset itself: the retype buys
  nothing, runs the same transcription risk, and lands a second, subtly
  different copy of the table beside the real one.

  Nothing in a chart, a `resultKind` or a rule set says whether a human will
  read these rows in prose or in a grid — the bridge cannot know which client
  it is in. So the caller says, in one word:

  ```ts
  runbookAsTool({ /* … */ presentation: 'panel' }); // default: 'prose'
  ```

  - **`'prose'` (the default)** — today's envelope, key for key: `table`
    pre-rendered over the shown rows, `render_note` = `VERDICT_RENDER_NOTE`.
    Every existing consumer is byte-identical, pinned by a test that asserts
    the whole `result` key list in order.
  - **`'panel'`** — the host renders the rowset, so the envelope omits `table`
    ENTIRELY (the key, not an empty string), and `render_note` becomes the new
    exported `PANEL_RENDER_NOTE`, which states the opposite law: the rows are
    already on the reader's screen; do not reproduce them in prose in any form
    — not as a table, not as bullets, not as one sentence per row; when a
    finding names a row, quote the evidence sentence that row carries VERBATIM,
    and cite only the values the finding rests on, copied byte-for-byte.

  `verdicts`, `rows_shown`, `rows_total`, `rows_complete` and
  `verdict_meanings` are identical across both modes for the same run — the
  dial names who RENDERS the rows, never which rows there are. `table` stays
  RESERVED in both modes, so a chart's `report` cannot put a table back into a
  panel answer: the mode's promise outranks the freed name. An unknown value
  THROWS at definition instead of falling back to `'prose'` — a mis-spelled
  dial that silently keeps working is a dial you cannot trust to have been set.

  **The ceiling, stated:** this changes what the envelope SHIPS, never what the
  model does with it. A note is an instruction, not an enforcement —
  `presentation: 'panel'` takes away the table the model was told to output; it
  cannot stop a model that decides to retype rows anyway. And it is silent
  about every other part of the answer: coverage, provenance, rule version and
  the walk are untouched in both modes.

  New exports beside `VERDICT_RENDER_NOTE`: **`PANEL_RENDER_NOTE`** and the
  **`RunbookPresentation`** (`'prose' | 'panel'`) type. Worked example:
  `examples/features/68-runbook-as-tool.ts` now registers the same procedure
  twice, one dial apart, and prints both surfaces. Docs: "Who renders the
  rowset" in `docs-next/content/docs/build/runbook-as-tool.mdx`.

## [9.79.0] - 2026-08-30

### Added

- **`walk: { recording }` — file the runbook's inner chart as a recording, so
  the walk can actually be DRAWN.** A consumer wired the flow components of
  the lens family to a runbook's answer and could not mount anything, and they
  were right not to try. `runbookAsTool` files its walk as
  `recording/chart-walk`, whose payload is a **row projection** — 129 rows of
  `{step, type, depth, stage, stage_id, runtime_stage_id, subflow, text}`. A
  step graph cannot be inferred from sentences about steps; a consumer handed
  those rows can only correctly REFUSE to guess at the edges. The one piece
  that makes a walk drawable is `structure`, the chart's build-time graph —
  which a finished run does not leave behind and no snapshot carries.

  Everything needed was already in the file, a few lines apart: the bridge
  builds a fresh inner executor and ATTACHES RECORDERS (the run was recorded,
  it simply was not filed), `recordingPutInput` was already the mint for the
  agent's own run, `mintWalk` was already the guarded side effect that files
  and never fails the answer, and `WalkDescriptor` was already where the spine
  states what it filed. This connects them.

  Declare `walk: { recording: true }` (or `{ label, maxBytes }`) and the inner
  chart's own **`{ snapshot, events, structure }`** — the `recordRun` contract
  exactly, the shape `observeRecording()` mounts — is filed under the existing
  kind `recording/run`, and its ref rides the SAME spine descriptor as
  `result.walk.recording_ref` beside `recording_kind`, `recording_bytes` and
  `recording_note`. The wire ops that already redeem the walk redeem this with
  **zero new operations**.

  **OPT-IN, and that is the honest default.** A walk carries _sentences about_
  what happened and no payload from it — values are off by construction
  (`narrative({ includeValues: false })`). A recording is the run: shared
  state, the whole commit log, every attached recorder's data — **whatever the
  chart wrote**. Filing one is a materially bigger promise, so it is declared,
  never begun on an operator's behalf. Unset, nothing extra runs — no second
  snapshot, no bytes measured, no store call — and the envelope is
  byte-identical to 9.78.0, pinned by a test that asserts no `recording_*` key
  exists at all. A reader who never asked for a recording does not even get a
  sentence explaining its absence.

  **Redaction means the same for both artifacts.** The recording's snapshot is
  read from the REDACTED MIRROR (`getSnapshot({ redact: true })`), never the
  raw working memory — so the `redact` policy that scrubs the walk scrubs the
  recording by the same rule at the same moment, and a redacted key travels as
  `REDACTED` rather than vanishing (a reader sees that a value existed and was
  scrubbed). With no policy configured the flag is a documented no-op.

  **Size has a declared failure mode, and it is a refusal.** Over
  `walk.recording.maxBytes` (default `DEFAULT_RECORDING_MAX_BYTES` =
  5,000,000 — a walk is tens of KB, this package's own measured `recordRun`
  bundle was 2.76 MB, a fleet sweep is unbounded) the recording is **not filed
  and not truncated**, and `recording_note` names what it measured, the
  ceiling it broke, and the option that raises it. The asymmetry with the
  walk's row cap is the point: walk rows are independently meaningful so a
  projection of them is still true, but `{ snapshot, events, structure }` is
  one bundle — half a commit log under a whole chart draws a picture nobody
  can check.

  **The absence is always SPOKEN.** No store, an over-size refusal, an
  unserializable snapshot, a store that threw — each costs the REF and lands a
  named reason in `recording_note`, following `mintWalk`'s own law. A missing
  ref with no sentence would leave a reader guessing, which is the one thing
  the spine exists to prevent.

  **`events` is empty by construction, and says so.** It is the typed
  _agentfootprint_ stream, fired by an agent turn; what ran here is a
  footprintjs chart on its own executor, which fires none. All three keys are
  present (that is what a viewer reads), the empty array is the honest count,
  and the note states it so nobody reads it as a dropped stream — the walk's
  own story rides `snapshot`, where the narrative recorder's data already
  lives.

- **`recordingPutInput` accepts `toolCallId`** — stamped on `origin.toolCallId`
  the way `chartWalkPutInput` already did. A walk and the recording it projects
  are two views of ONE tool call, so they carry the same join key, and either
  joins back to the call the model made. Absent for an agent's own run
  recording, which is a whole turn and belongs to no single call.

## [9.78.0] - 2026-08-30

### Added

- **`Tool.resultColumns` + `checkColumnTypes` — the column-type contract: a
  tool declares what its rows contain, and the library checks the rows against
  it at the boundary.** Three recorded failures, and they are one shape — _a
  number became something else, and nothing noticed at the seam_:

  1. A mapping report wrote `str(m.get("logical_unit_number") or "")`. **LUN 0
     is falsy**, so LUN 0 was stored as an EMPTY STRING on 2,094 mappings, and
     a host group missing the LUN an initiator probes first became
     indistinguishable from one that had it.
  2. A capacity view rendered `round(mib / 1024, 1)`, so an 8 MiB disk came out
     as `0.0 GB` — which reads as NO DISK, a provisioning failure, during a
     live incident.
  3. A whole family of tools returned their numbers as quoted strings
     (`"1240"`), which silently blanked every chart, because nothing downstream
     could tell a measure from a label.

  Every rail passed, honestly, in all three: nothing errored, nothing was
  ungrounded. The library already lets a tool declare what its result IS
  (`resultKind`, 9.70.0); it did not let a tool declare what its result
  CONTAINS, so a rowset had nothing to be wrong against — and every consumer
  downstream was left SNIFFING types out of the data, where one stray `''`
  demotes a numeric column to text in silence.

  `resultColumns` is the sibling declaration: a column-name → type map, on
  `Tool` beside `resultKind`. Types are `number` / `string` / `boolean` /
  `date` — **the vocabulary this ecosystem's rowset consumers already sniff
  their way to**, not a new one. The one word deliberately left behind is
  `'unknown'`: a sniffer needs it ("I could not tell"), a declaration has no
  use for it. A column maps to a bare word or to `{ type, nullable }` (the
  `CostBudget` two-spellings pattern, normalized once).

  **THE CEILING**, exported as `COLUMN_TYPE_CEILING` and quoted verbatim into
  every finding, the `EMPTY_LOOKUP_CEILING` law: _"This judges TYPE, never
  MEANING — it can see that a column declared `number` holds a string, and it
  can never see that the string should have been 0, or that a 0.0 should have
  been an 8; a column whose every value has its declared type passes here and
  can still be wrong."_ Failures 1 and 3 are caught. **Failure 2 is not, and
  never will be** — `0.0` is a perfectly good number — and the check says so
  out loud rather than letting a green row imply otherwise.

  **TWO finding kinds, because the field bug turned on the difference.** New
  `ContextErrorKind`s at the **write seam**: **`column-type-mismatch`** (the
  column is THERE and holds the wrong thing) and **`missing-column`** (the
  declared column is in NONE of the rows). _"The value is not what it should
  be"_ sends a person to the mapping code; _"the column was never delivered"_
  sends them to the query. A checker that said only "something is off with
  logical_unit_number" would have helped with neither. Each finding names the
  column, the offending value quoted, the rows affected of the rows read, and
  the tool.

  **OPEN, never closed.** A declaration is a promise about what it NAMES — an
  unlisted column is allowed and never judged. A closed schema would punish the
  wrong party the day a backend adds a column, and it is the rule the
  neighbouring boundary (`toolArgsValidation`) already keeps.

  **`nullable`, and what "no value" means.** `null`, `undefined` and a key not
  set on a row are one idea with three spellings, and by default all three are
  violations; `nullable: true` legitimizes them and every finding about an
  absence names that one-word fix in its own message. `nullable` is a promise
  about VALUES, not about the column's existence: a declared column in no row
  at all is `missing-column` regardless.

  **The dial: `AgentOptions.checkColumnTypes`, default `'off'`** —
  `'off'` | `'warn'` | `'enforce'`. **The three words are borrowed, not
  minted:** this boundary is the MIRROR of `toolArgsValidation` (arguments in,
  against `inputSchema`; rows out, against `resultColumns`), and two validators
  at one seam grading themselves in different vocabularies would be a worse
  defect than either could catch — so there is no new `assist`/`guard`/`rails`
  trio here and no new `observe`/`warn`/`refuse` one either. `'warn'` files
  findings and the model reads the rows **exactly** as the tool returned them.
  `'enforce'` REFUSES in the library's own refusal idiom — the `resultCeiling`
  teaching sentence ("…Fix the tool so the column holds what it declares, or
  change the declaration. No data was returned."), the whole payload on every
  channel, delivered status `'invalid'`, never a thrown stack trace.

  **What it refuses to judge** (`readRowset`): a result is read only when it is
  an ARRAY OF PLAIN OBJECTS with at least one row. Prose, a `null`, a bespoke
  `{ rows: [...] }` wrapper, a claim ticket — and the **zero-row** result,
  which has no columns to be wrong about and is `empty-lookup`'s subject next
  door — all file an explicit `not-applicable` ROW and no finding. Filing
  `missing-column` for every declared column of an empty answer would turn one
  honest emptiness into a pile of false accusations.

  **Armed by two halves**: the dial off `'off'` **and** at least one tool
  declaring `resultColumns`. Absent either, the run is byte-identical — no
  finding, no event, nothing on the wire, and a declaring tool with the dial
  off runs byte-for-byte the run it ran before the declaration existed. The one
  visible difference is the two registered rows in the disposition report,
  filed `not-applicable`: registered-but-unarmed is a ROW, never silence.

  **Travels MCP `_meta`** like the library's other tool declarations, both
  directions — a remote catalogue is exactly where a numeric column arriving as
  text goes unnoticed, and leaving the declaration behind would arm the check
  for local tools while leaving every MCP tool a second-class citizen of it. A
  malformed declaration from a foreign server is warned about once and dropped;
  the tool still registers.

  Exports: `COLUMN_TYPE_CEILING`, `COLUMN_TYPES`, `readRowset`,
  `assertResultColumns`, and the types `ColumnType`, `ColumnDeclaration`,
  `ToolResultColumns`, `ColumnCheckMode`, `ColumnViolation`, `RowsetReading`.
  Docs: `docs-next` → Monitor → Column Types, which also names the three
  existing consumers this declaration feeds (chart axis pickers that sniff, the
  panel deciding table-vs-chart by inference, and `compute` staging rows blind)
  — none of those integrations are built here, they are named so the next
  person does not add an eighth sniffer.

## [9.77.0] - 2026-08-29

### Added

- **`noticeEmptyLookups` — the run produced the identifier, and the lookup for
  it came back empty.** A triage agent's reverse-lookup tool filtered a column
  before a pivot, so the column did not exist yet and EVERY reverse lookup
  returned an empty result — for every identifier, always. The tool then
  answered _successfully_ with an empty list, and the agent reported in a
  table, with confidence, that the device was not logged in to any port on any
  collected switch, advising a check of the physical cabling. It was logged in
  the whole time. Every rail passed, and passed honestly: nothing errored,
  nothing was ungrounded, no coverage was overstated. **An empty result from a
  broken filter is byte-identical to an empty result from a genuine absence**,
  and nothing in the framework was responsible for noticing the difference.

  The library was already holding both halves of the answer, separately: that
  the identifier was GROUNDED (it came out of an earlier tool result in this
  run, from a tool the consumer's own author named in `Tool.argumentsFrom` —
  the same declaration that arms `dangling-reference` and
  `unsupported-argument`), and that the lookup keyed on it came back EMPTY.
  Joining them is the whole check: new `ContextErrorKind` **`empty-lookup`** at
  the **write seam**, filed at the tool-dispatch boundary — the one moment a
  lookup's answer becomes a fact in the conversation.

  **THE CEILING, and it is why this can never be an accusation.** An empty
  answer can be perfectly true; the device may exist and simply have no logins
  right now. Nothing here can tell those apart and nothing here pretends to, so
  every finding is an **`advisory: true`** and the IDENTICAL advisory is filed
  for the broken filter and for the honest absence. The bound ships as one
  exported string, `EMPTY_LOOKUP_CEILING`, quoted verbatim into every message
  so it cannot drift out of one doc and leave a reader thinking the library
  knows more than it does: _"An empty result can be perfectly true — the thing
  may exist and simply have nothing to show right now — so this is a place to
  look, never a verdict that anything is wrong."_

  Deliberately NOT `dangling-reference`, whose meaning is the opposite: there
  the ground has left reach; here the ground IS in reach and the lookup found
  nothing.

  **What counts as empty is COUNTED, never interpreted** (`readLookupResult`):
  an array with zero elements is a rowset with zero rows, and an `absent(…)`
  envelope is an author saying the search ran and matched nothing. Every other
  shape — a sentence, a `null`, a bespoke `{ rows: [] }` wrapper, a placement
  claim ticket — is unreadable, and files an explicit **`not-applicable` row
  with no finding**. That row is the point: a check that silently skipped what
  it could not read would be the decoration the disposition ledger exists to
  make impossible.

  **Armed by two halves**, and the second one is why: `noticeEmptyLookups:
true` on `Agent.create` **and** at least one tool declaring `argumentsFrom`.
  The declaration alone is not enough — it already arms two other checks, and
  an advisory that armed itself off a declaration made for something else
  would not be opt-in at all. **Default off is byte-identical**: no finding, no
  event, nothing on the wire, in the history or in the answer changes. The one
  visible difference is the registered `empty-lookup` row in the disposition
  report, filed `not-applicable` — the family's law rather than an exception to
  it, since silence is exactly what let two shipped checks decay into
  decoration. Posture is the family's own (`integrityPosture`), with a dev
  canary like every sibling; the evidence gate's `assist`/`guard`/`rails` trio
  is deliberately absent, because those grade how hard a rail pushes back and
  this check never pushes back on anything.

### Changed

- **One rail for integrity findings, one spelling for an argument leaf.** The
  seen-list dedup that turns "detected many times" into one
  `integrity.context_error` per run moved out of `callLLM` into
  `core/agent/integrityFindings.ts` — the write seam files from a second STAGE,
  and its old header already warned that a second copy of that loop would
  eventually disagree with the first about what "already filed" means.
  Likewise the argument walk, the four-character fence and the quoting length
  moved to `src/integrity/argumentLeaves.ts`, shared by the choice seam and the
  write seam: the second check's whole job is to notice something about a value
  the first one already excused, so the two must agree to the character about
  which leaves are candidates and what their dot-paths are. No behaviour change
  on either move.

## [9.76.1] - 2026-08-28

### Fixed

- **`runbookAsTool`: the honesty spine actually wins.** The envelope's
  contract said "spine keys win" while the assembly spread the chart's
  `report` bag AFTER `af_provenance` and `rule_version` — so a chart writing
  either name into `report` silently replaced the two fields a reader checks
  the answer's boundary with. A procedure could have arrived stamped with a
  provenance it never had, or naming a rule version it never ran under, and
  the envelope would have looked exactly as honest as a true one. Found by
  the first consumer refit, before any chart did it.

  The precedence is now EXPLICIT, not a consequence of spread order: the
  spine and the projection this run assembled are built first, and the
  chart's `report` is admitted against the names they took. Reserved names
  are `af_coverage` (refused inside `result` too — a decoy ledger one level
  under the real one is exactly where a decoy would want to sit),
  `af_provenance`, `rule_version`, `walk`, `report_note`, and, for a
  verdict-shaped run, the projection keys. The reserved set is READ from the
  assembled objects, so a spine field added later is protected the day it
  lands.

  **A refused field is named, not silently dropped** — the same law the rest
  of the envelope already follows (the walk declares its projection, the
  rowset its completeness, declined rows land in the ledger). A collision
  costs one `result.report_note` listing every discarded field and saying the
  values under those names are the bridge's; it is absent whenever nothing
  collided, so the clean path — every real runbook — pays nothing.

## [9.76.0] - 2026-08-28

### Added

- **`runbookAsTool` — turn a written procedure into a tool whose every answer
  is evidence.** Triage is the most-used agent job in a business: run the
  standing procedure, come back with a verdict somebody can act on. The first
  production tool of that shape hand-rolled ~800 lines of envelope around a
  footprintjs chart — a coverage ledger merging every inner source's own
  ledger, a rule-version stamp on every sentence, capped verdict rows beside a
  pre-rendered table, and the recorded walk that lets a reader CHECK the
  verdict instead of trusting it. `runbookAsTool` is that envelope as one
  declaration bag; the smallest legal call is `{ name, description,
procedure }` and it still yields the honest spine.

  **The mandatory honesty spine**, on every answer whatever the runbook's
  shape: `af_coverage` (three lists + a sentence naming the rule set and
  version, with every inner tool's ledger folded upward), `af_provenance`
  re-emitted FIRST (a seeded source's confession survives composition),
  `rule_version` (or the honest `'undeclared'`), and the recorded walk as an
  artifact ticket — new kind **`recording/chart-walk`** beside
  `recording/run`, with `chartWalkPutInput` beside `recordingPutInput`. The
  walk descriptor carries truthful counters, the declared projection (over
  the cap, the CONTROL FLOW survives — stages, forks, and every `condition`
  entry with its decide() evidence — never a head slice that keeps four
  hundred writes and drops every decision), and a `walk_segment` discriminant
  (`'full'` today; the wire is ready for resumed segments before gates land).
  A failed mint costs the ticket, never the answer.

  **The optional verdict projection**, selected by `resultKind: 'verdict/*'`:
  rows off the chart's `verdicts` state key, ONE cap for the structured list
  and the rendered table, truthful `rows_shown/rows_total/rows_complete`, and
  `verdict_meanings` GENERATED from the decider's declared branches plus the
  rule labels this run's evidence carried — never hand-restated, so a rule
  change and its meaning change on the same day. Three outcomes, honestly: a
  clean envelope; an inner absence passed through VERBATIM (the framework
  still reads it as an absence); and `declined` rows counted into the ledger
  as not-checked ground.

  `flowchartAsTool` stays for compatibility (its `resultMapper` users stay
  put, byte-identical); its stale pause message now names the runbook
  program's gate phase instead of a version that shipped years ago.

- **`ctx.tools` — a tool's body can call other registered tools through the
  run's own dispatch.** The `procedure` factory is invoked per call with the
  agent's dispatch (static + skill-carried tools; ToolProvider-delivered
  tools are invisible — there is no build-time list, the stated 9.72.0
  caveat), so stages compose registered sources instead of importing modules
  and building a second query stack. Inner calls run with `hasArtifacts:
false` (one answer, one ticket — never competing chips), a derived
  toolCallId naming the outer call, `needs` resolved on the fail-closed
  non-interactive path, and `checkIn`/`wants` tools refused BY NAME (an inner
  call cannot pause, and must never silently skip a consent gate).

- **`composedOf` + `gates` on `defineTool`.** A composed tool names its
  ingredient tools; the drift gate runs at AGENT BUILD — the one moment the
  catalog is complete — so a renamed ingredient fails the build by name, not
  the first 3 a.m. run. `gates` declares a procedure that can raise an
  approval gate (read by composition-time checks that must keep a gating tool
  out of a fan-out branch). Both pass the declaration bar (consumer-side
  rails read them; nothing governs execution) and travel the MCP `_meta` bag
  in both directions, judged on ingest by the same exported asserts
  `defineTool` uses.

## [9.75.0] - 2026-08-28

### Added

- **Grounded numbers: the staged-refs nudge, and a revise correction that
  names the route.** The field failure this closes, from a consumer's recorded
  run: four tool results carried real numbers, a compute tool that could sum
  them was registered — with `wants` declared over the staged dataset kind —
  and the app's prompt said to use it. The model summed the numbers in its
  head anyway and stated the total; the evidence gate recorded _"appears in no
  tool result"_ and the answer shipped, because the posture only observed. The
  app patched it with more prose. The library-shaped fix is two mechanisms it
  already owns, on the one dial it already has:

  **`nudge: true` on `.namesAndNumbersFromEvidence()`** — when an iteration's
  context holds a tool result staged by reference (an `artifacts.placement`
  ticket) AND a tool the model can currently call declares `wants` over that
  ticket's kind, ONE short line is appended at the very END of that request,
  naming the refs and the spender tool by its registered name: derived numbers
  come from the tool, not from mental arithmetic. Composed entirely from
  declarations (`Tool.resultKind`, `Tool.wants`, matched by the exact-string
  law dispatch uses — never by tool name); no prose surface for apps. The
  placement is the point: the measured failure was recency — the app's own
  instruction sat at the top of a long context and the numbers at the bottom,
  so this one sits beside the data. Request-only (never history, so it never
  enters the gate's exempt corpus), recomposed per iteration so it exists
  exactly while both conditions hold, judged against the tools REALLY served
  this call (the wrap-up's withheld surface arms nothing). Each firing lands
  as `agentfootprint.agent.grounding_nudged` (event 109) with refs and tools
  as data — the line's one record, since the line itself is not conversation.

  ```ts
  const agent = Agent.create({ provider, model, artifacts: { store, placement } })
    .tool(exportRows) // resultKind: 'dataset/rows' — staged over the threshold
    .tool(compute) //    wants: { dataset: 'dataset/rows' } — the declared spender
    .namesAndNumbersFromEvidence({ posture: 'guard', nudge: true })
    .build();
  ```

  **The guarantee stays the postures the gate has had since 9.35.0** — the
  existing value extraction is THE detector, unchanged: `'assist'` records,
  `'guard'` allows the one bounded revision then delivers with both attempts
  on the record, `'rails'` refuses with `UnsupportedValuesError`. What the
  revision gains: when the flagged turn holds staged refs a served `wants`
  tool can spend, the correction now names them — _"pass 'art\_…'
  (dataset/rows) to `compute` — compute the number there and answer with what
  it returns"_ — inside the authored frame, so the quoted values still come
  last and the exempt-corpus fence is untouched. The `revision-asked`
  `evidence_checked` event carries the same facts additively (`stagedRefs`,
  `spenderTools`). Absent everything — no gate, `nudge` unset, or no
  `wants`-declaring tool — every request, record and correction keeps its
  exact bytes, pinned by test.

### Changed

- **A `guard`/`rails` agent that also registers a `wants`-declaring tool will
  see its evidence correction gain the refs clause above when staged refs are
  in context.** That is the fix, not a side effect: a correction that says
  "call the tool that provides it" without naming WHICH tool over WHICH ref
  leaves the model to head-math again. Agents without a `wants` tool — or
  without staged refs in the flagged turn — keep the exact 9.35.0 sentence.

## [9.74.0] - 2026-08-27

### Added

- **The Azure/Foundry column exists — keyless auth and both Microsoft inference
  doors, cloud and on-device.** AWS and GCP each had a full adapter column;
  Azure had a static api-key string and nothing else. This release fills the
  auth + inference tier, and every piece is a vendor adapter over ports that
  did not change — the same seams the AWS and GCP columns already use.

  **`foundry()` — the project-endpoint provider** (`agentfootprint/providers`).
  The JS answer to Microsoft's `FoundryChatClient(project_endpoint, model,
credential)`: point it at a Foundry project endpoint (or let the hosted
  platform's auto-injected `FOUNDRY_PROJECT_ENDPOINT` supply it), name the
  deployment (`AZURE_AI_MODEL_DEPLOYMENT_NAME ?? MODEL_NAME`), and auth is an
  Entra `TokenCredential`, an api key, or — given neither — the platform's own
  blessed default, `DefaultAzureCredential` from the optional `@azure/identity`
  peer. Inference rides the GA api-version-free `/openai/v1` route derived from
  the project endpoint; the deployment name travels as the `model` field; the
  token scope is `https://ai.azure.com/.default` (the management audience is a
  different token — the docs say so out loud). Rotating tokens reuse the
  per-request key-callback seam `openai()` has had since 9.29.0: the client is
  rebuilt only when the token string actually changes.

  ```ts
  import { foundry } from 'agentfootprint/providers';
  // In a Foundry hosted container: zero config — endpoint injected, identity ambient.
  const llm = foundry();
  // Locally: az login, then name the project.
  const local = foundry({
    projectEndpoint: 'https://acct.services.ai.azure.com/api/projects/my-project',
    deployment: 'gpt-4.1-mini',
  });
  ```

  **`foundryLocal()` — the on-device door.** Fetch-only, zero dependencies, the
  `ollama()` discipline applied to Foundry Local's OpenAI-compatible `/v1`
  wire: an alias like `qwen2.5-0.5b` resolves to a concrete variant through the
  service's own catalog (`GET /foundry/list`, priority order, first wins;
  cached per provider), a full variant id skips the catalog entirely, and the
  typed `FoundryLocalUnavailableError` tells the truth a local runtime needs
  telling — `foundry server start` to start it, `foundry server status` to find
  the dynamic port, `foundry model run <alias>` when the model is the missing
  piece, with the machine's actual model list attached when the service could
  answer. No API key is sent because none exists. Streamed usage is read off
  the final empty-choices frame — the exact bug class 9.73.0 fixed, pinned here
  from day one.

  **`entraIdentity()` — Azure credentials for tools**
  (`agentfootprint/security`). The `googleIdentity` anatomy, law for law:
  vends the deployment's identity as a bearer via any `TokenCredential`
  (default: `DefaultAzureCredential`'s chain — env service principal, workload
  identity, managed identity, VS Code, az CLI), scopes the request's way or
  `AZURE_AI_SCOPE` by default, refuses `mode: 'user'` and per-request user
  tokens BY NAME until an OBO surface exists, refuses services outside the
  allowlist, and never lets an SDK's failure text — which echoes request
  detail — reach a thrown message. `AZURE_AI_SCOPE` and
  `AZURE_MANAGEMENT_SCOPE` are both exported because the audiences are not
  interchangeable, and pretending there is one "azure scope" would be a lie
  that 401s at runtime.

  **`azureOpenai({ credential })` — the existing Azure door goes keyless.** An
  Entra credential now rides the SDK's `azureADTokenProvider`; the static
  api-key path is byte-identical to before; both given at once is refused by
  name as the config bug it is. The docs' old caveat — "azureOpenai is the
  wrong door for bearer auth" — is retired. Each keyless door defaults to the
  audience ITS route documents: this one asks for
  `https://cognitiveservices.azure.com/.default` (the classic deployment-scoped
  route's own documented audience, `AZURE_COGNITIVE_SERVICES_SCOPE`), while
  `foundry()` asks for `https://ai.azure.com/.default` (`AZURE_AI_SCOPE`, the
  v1/project route's) — both overridable via `scope`, both pinned on the wire
  by tests that record what the credential was actually asked for.

  **`openai({ legacyEndpoint })` — the dialect dial goes public.** `baseURL`
  has always implied the legacy dialect (`max_tokens`, no `stream_options`)
  because most OpenAI-compatible servers are behind; `legacyEndpoint: false`
  now declares "this baseURL speaks the current dialect" — which is exactly
  what the Azure v1 route is. Default unchanged: `!!baseURL`.

  **`providerFromEnv` learns both doors — upgrade-safely.** `FOUNDRY_LOCAL_MODEL`
  slots directly after `OLLAMA_MODEL` (a model name you typed for a local
  runtime — the same name-beats-leftover-credential law), and
  `FOUNDRY_PROJECT_ENDPOINT` + `AZURE_AI_MODEL_DEPLOYMENT_NAME` — a pair of
  product-specific spellings nobody exports by accident — outranks the
  lingering-credential arms. Two guards keep an upgrade from breaking a
  working environment, because the hosted platform auto-injects the endpoint
  into every container, Foundry-bound or not: an endpoint with **no**
  deployment named HOLDS its refusal (every arm below answers exactly as it
  did before this arm existed, and the held refusal is raised only when
  nothing else resolves), and the generic `MODEL_NAME` alone never carries the
  endpoint past a bootable Azure config — choosing Foundry over working Azure
  takes the deliberate spelling. Every existing precedence is pinned
  untouched; each guard has its own test.

  New from `agentfootprint/providers`: `foundry`, `foundryInferenceUrl`,
  `FoundryProviderOptions`, `foundryLocal`, `FoundryLocalProvider`,
  `FoundryLocalUnavailableError`, `FoundryLocalProviderOptions`,
  `TokenCredentialLike`, `AccessTokenLike`. New from `agentfootprint/security`:
  `entraIdentity`, `AZURE_AI_SCOPE`, `AZURE_MANAGEMENT_SCOPE`,
  `AZURE_COGNITIVE_SERVICES_SCOPE`, `EntraIdentityOptions`,
  `TokenCredentialLike`, `AccessTokenLike`, `AzureIdentitySdkModule`. New
  optional peer: `@azure/identity`. Pinned by 178 new test cases (counted in
  the diff, not the runner) across
  `test/adapters/identity/entra-identity.test.ts`,
  `test/adapters/unit/FoundryProvider.test.ts`,
  `test/adapters/unit/FoundryLocalProvider.test.ts`,
  `test/adapters/integration/foundry-wire.test.ts` (a real `openai` SDK against
  a local fake of the `/openai/v1` wire — Bearer header, `max_completion_tokens`,
  token rotation, and the audience each door asks its credential for, all
  asserted on the wire, not assumed), the extended Azure env/wire suites, and
  the fix pins from the adversarial review below. Deliberately NOT in this train: the App Insights sink,
  Azure AI Search, the Toolbox MCP transport (next trains), and every
  preview-only surface (A2A door, browser/computer-use, managed memory) —
  refusing to ship against previews we cannot verify is a feature.

  The whole train was adversarially reviewed before release (five lenses, every
  serious finding independently re-verified with a reproduction): 1 blocker and
  9 should-fix findings were confirmed and every one is fixed and pinned in
  this release — including a mid-stream failure frame that `foundryLocal()`
  would have reported as a clean stop, an abort signal that never reached a
  streaming body, and an env-detection arm that would have broken working
  deployments on upgrade.

### Fixed

- **`ollama()` inherited four stream/abort defects — found by reviewing the new
  Foundry Local adapter, fixed at the root.** The adversarial review of
  `foundryLocal()` proved its four streaming defects were byte-twin shapes
  copied from `OllamaProvider`, which had shipped them for months: a caller's
  already-aborted `AbortSignal` still sent the request; an abort after headers
  never reached the streaming body (generation ran on, un-stoppable); an early
  `break` leaked the response body (the reader was never cancelled); and a
  mid-stream `{"error": …}` frame was silently dropped, reporting a failed
  generation as a clean stop. All four now match the fixed Foundry Local
  shapes — same helper names, Ollama's NDJSON wire. 10 of the 12 new pinning
  tests fail against the previous source (proven by restoring it); no public
  API change; `OllamaUnavailableError` is byte-identical.

## [9.73.0] - 2026-08-27

### Fixed

- **Every local model was reporting zero tokens, and nothing said so.**
  OpenAI and Azure only emit usage on a stream when asked, via
  `stream_options: { include_usage: true }` — and this adapter deliberately
  withheld that field when `baseURL` was set, because some OpenAI-compatible
  servers reject an unknown field and a hard failure is worse than a missing
  number. That caution was right; its cost was invisible. **Anyone streaming
  through `openai({ baseURL })` — llama.cpp, Ollama, vLLM, LM Studio — read 0
  tokens everywhere usage is consumed:** cost recorders, dashboards, and the
  per-step cost on `agentThinkingTrace`. Nothing was broken, nothing errored,
  and every number was wrong.

  `openai({ streamUsage: true })` opts a custom endpoint back in. The default
  is unchanged, because the servers that reject the field still exist; both
  halves are pinned by tests. Found by wiring a real local model (llama.cpp
  serving Qwen3-4B) behind a four-agent workflow and noticing every card read
  `0 tok` while the server, asked directly, answered usage perfectly well.

## [9.72.0] - 2026-08-27

### Added

- **An app can vouch for ground the run never served — `externalGrounds`, the
  external-ground door for the choice-seam check.** The `unsupported-argument`
  check judges every armed call's identifier-like arguments against what the
  RUN served the model. Some ground the run never serves: a person clicked a
  row in the app's data panel, the app VERIFIED the clicked cells against the
  artifact the panel renders, and the model was told to act on that selection.
  An identifier taken from a human's verified selection is not fabricated —
  and until now the check had no way to be told so.

  ```ts
  const agent = Agent.create({
    provider,
    model,
    // DECLARED, never ambient — this option is the only door.
    externalGrounds: () =>
      viewerSelection.cells.map((cell) => ({
        value: cell.text, // verified by the app against the artifact
        source: 'viewer-selection', // the audit label that travels
      })),
  });
  ```

  The provider is consulted once per LLM response that contains an armed call,
  so entries follow the person's selection between turns. Its entries join the
  grounded corpus (`ChoiceCorpus.external`, each entry `{ value, source }`),
  and every excusal is put on the record as
  `agentfootprint.integrity.external_ground_used` — toolName, toolCallId, the
  argument's dot-path, the value, and the `source` label of the entry that
  excused it. An excusal on the record always means the app's assertion was
  the ONLY thing standing between that value and a finding: values the run
  itself served are never attributed to the door.

  The honesty note, stated where the option is declared: the library records
  what the app ASSERTS — verifying the assertion (against the artifact, the
  click, whatever the app's ground truth is) is the app's duty, done before
  the entry is yielded, and the source label travels precisely so a reader can
  audit that chain instead of trusting it.

  Absent, or a provider yielding nothing, is byte-identical to 9.71.0. A
  provider that throws or returns garbage contributes nothing and never aborts
  a run — an accounting door must never change a run's outcome. New public
  types: `ExternalGroundsProvider`, `ExternalGround`, `ExternalGrounding`, and
  the event payload `IntegrityExternalGroundUsedPayload` (108 typed events
  now).

### Fixed

- **The `toolGrounding` harvest sees skill-scoped tools — the choice-seam
  checks arm for an app whose query tools all ride skills.** Found by a
  consumer's MCP parity work: the harvest read only the STATIC `.tool()`
  registry at chart build, so an agent whose `argumentsFrom` tools were all
  skill-carried (delivered when a skill activates) never armed the
  `dangling-reference` / `unsupported-argument` pair. Its disposition rows
  read `{checked: 0, notApplicable: 1}` forever while the app's own comments
  believed the checks ran — the exact green-that-checked-nothing shape the
  disposition ledger exists to make impossible.

  The harvest now reads the FULL declared catalog (`buildToolRegistry`'s
  dispatch map: static registrations plus every skill-carried tool,
  `autoActivate`/scoped ones included). Arming is computable up front because
  a skill tool's DECLARATION is known at chart build even though the tool
  reaches the model only after its skill activates; the runtime checks stay
  correctly scoped on their own — dangling-reference intersects the map with
  the tools each call actually serves, unsupported-argument with the calls
  the model actually makes. `IntegrityChecksPresent.dangling` still means
  exactly "at least one tool declared `argumentsFrom`" — only the blindness
  changed. CAVEAT, stated rather than papered over: ToolProvider-DELIVERED
  tools remain invisible to the harvest — `list(ctx)` is opaque and
  per-iteration, so their declarations cannot be known at build. (MCP tools
  are not in that hole: `mcpClient(...).tools()` registers them statically,
  and 9.71.0 carries their declarations across the wire.)

- **`CHANGELOG.md` ships in the tarball.** The `files` allowlist named
  `CLAUDE.md` and `AGENTS.md` but never the changelog, so every tarball since
  the allowlist existed — 9.70.0's packaging audit included — shipped without
  it. One line in `files` closes it.

## [9.71.0] - 2026-08-26

### Added

- **An MCP tool server is a first-class agentfootprint citizen — the
  declarations travel, both directions.** A `Tool` is two things at once: an
  EXECUTION half (the handler, the credential it needs, the human it wants to
  check in with) and a DECLARATION half — flat, inert facts no code runs and
  every consumer-side rail reads. Only the execution half used to cross an MCP
  boundary, so a tool that arrived over MCP was thin: the dangling-reference and
  unsupported-argument checks never armed for it, artifact placement minted a
  kind no `wants` could spend, and subject-joined checks had no owner to join
  on. An MCP server could be somebody's whole tool catalogue and still be a
  second-class citizen of every check this library ships.

  `mcpServe` now writes five declarations into MCP's own `_meta` bag, under one
  namespaced key (`MCP_TOOL_EXTRAS_KEY`, the string `agentfootprint`), and
  `mcpClient` / `mockMcpClient` read them back onto the registered `Tool`:

  | field           | what it arms on the consuming side                              |
  | --------------- | --------------------------------------------------------------- |
  | `argumentsFrom` | the dangling-reference and unsupported-argument checks          |
  | `resultKind`    | placement's mint — a placed result a `wants` argument can spend |
  | `owner`         | the identity edge subject-joined checks read                    |
  | `resultClass`   | the per-class `check:semantics` rules                           |
  | `resultCeiling` | the author's refusing ceiling on an oversized result            |

  ```ts
  const fleet = await mcpClient({ name: 'fleet-mcp', transport });
  const agent = Agent.create({ provider, model })
    .tools(await fleet.tools()) // backup_status declares argumentsFrom: ['fleet_report']
    .build();
  // …and the choice seam now files `unsupported-argument` for it, exactly as
  // it would for a local defineTool — pinned end to end, disposition row included.
  ```

  **The inclusion bar, stated where the list lives:** _a declaration a
  consumer-side check or rail reads; nothing that governs execution._ `needs`
  (credentials), `checkIn` (human consent) and the session hooks are excluded
  and always will be — they decide how a tool RUNS, and the tool runs on the
  server. A client holding a consent gate the only executor already held is
  theatre; one holding a gate nobody is holding is worse. A bag that names them
  anyway cannot smuggle them onto the `Tool`.

  **Ingest never throws.** The bag comes from a server this process does not
  control, and one server's typo must not kill a bulk register of forty tools.
  Each field is judged by the SAME rule `defineTool` enforces — literally the
  same exported assert, so `assertToolOwner` and `assertArgumentsFrom` were
  extracted from `defineTool` rather than copied. A field that fails is warned
  about ONCE (naming the server, the tool, the field, and the rule it broke) and
  DROPPED; the tool registers without it and the rest of the bag still lands.
  The warning is unconditional rather than dev-gated, following the
  uncompilable-`pattern` precedent in `toolArgsValidation`: the symptom of a
  dropped declaration is silence, which looks exactly like a rail that ran and
  agreed. A field this library does not recognise is ignored in silence — a
  newer server talking to an older client is not an error.

  **Absent means absent, in both directions.** A served tool that declares none
  of the five gets no `_meta` key at all — not an empty bag. A client that
  receives no bag registers a `Tool` with exactly `schema`, `source` and
  `execute`, pinned field by field by a regression test.

  **The carrier was verified against a real socket, not assumed.** `_meta` is
  declared on the spec's `Tool` object and `@modelcontextprotocol/sdk` 1.30.0
  types it as `z.record(z.string(), z.unknown()).optional()` on `ToolSchema`, so
  it survives the SDK's own `tools/list` validation both ways — proved by
  `test/lib/mcp/mcpToolExtras.real.test.ts`, which serves over Streamable HTTP
  on a real port and reads the five fields back through `mcpClient`.

  New from `agentfootprint/providers`: `MCP_TOOL_EXTRAS_KEY` and the
  `McpToolExtras` type, public so a server this library did not write can speak
  the bag.

## [9.70.0] - 2026-08-26

### Added

- **`Tool.resultKind` — a placed result the `wants` rail can spend.** Two
  rules the library already had, read together, were a gap. `wants` matches
  artifact kinds by **exact string equality** — no wildcards, no hierarchy —
  and artifact **placement** minted oversized tool results under
  `tool-result/<toolName>`. So a consumer declaring
  `wants: { dataset: 'dataset/rows' }` was refused, as a kind mismatch, the
  very ticket placement had just minted for it. Found in the field, where the
  answer was to re-mint by hand at the seam: the framework declining to carry
  its own ref.

  The fix is on the PRODUCING end. A tool declares the kind its placed result
  is minted under, in its consumers' vocabulary:

  ```ts
  const getRows = defineTool({
    name: 'get_rows',
    description: 'Fetch the Q3 sales rows (large).',
    resultKind: 'dataset/rows', // ← what a wants argument names
    execute: () => bigArrayOfRows,
  });

  // elsewhere — resolves now, and would have been a kind mismatch before
  defineTool({ name: 'chart', wants: { dataset: 'dataset/rows' } /* … */ });
  ```

  - **The matcher is untouched.** Nothing here loosens `wants`; exact match is
    what makes a ticket a promise. What moved is the mint.
  - **Declared, never inferred** (the `capabilities` / `resultClass` law).
  - **Refused at `defineTool`** if it could never be redeemed: a blank or
    whitespace-only kind throws by name. `assertResultKind` is exported beside
    `assertResultCeiling` / `assertResultClass` for hand-built `Tool` objects.
    There is deliberately no charset rule — the kind is the consumer's
    vocabulary, not the library's.
  - **Zero-cost when omitted:** the mint is `tool-result/<toolName>`, byte for
    byte. `placedResultKind(toolName, declared?)` gained an optional second
    argument and remains the one place the kind is decided.

- **`canonical-notes.json` — the canonical wire strings, published as data.**
  `absent()`, `coverage()` and `semantic()` mint shapes carrying a static,
  never-interpolated note and a reserved marker key. Those bytes are a
  contract: the strict recognizers take the markers verbatim, and the notes
  are what the docs promise a model reads. A tool that is not written in
  JavaScript has to reproduce them exactly — and the only door this package
  offered was the compiled ESM, which a Python sidecar in the field duly
  regex-scraped out of `node_modules/agentfootprint/dist/esm` at import time.
  Reading the value rather than copying it was the right instinct; the door
  was wrong, and the wrong door was ours.

  The strings now ship as a JSON file at the package root, reachable both as a
  path and through the exports map:

  ```python
  notes = json.load(open("node_modules/agentfootprint/canonical-notes.json"))
  ABSENCE_NOTE = notes["notes"]["ABSENCE_NOTE"]
  ```

  ```js
  const path = require.resolve('agentfootprint/canonical-notes.json');
  ```

  - **Seven strings, in three groups**, keyed by the exported constant names
    so the same value is reachable as a TypeScript import: `notes`
    (`ABSENCE_NOTE`, `COVERAGE_NOTE`, `SEMANTICS_NOTE`), `markers`
    (`ABSENCE_MARKER`, `COVERAGE_MARKER`, `SEMANTICS_MARKER`) and `headings`
    (`COVERAGE_BLOCK_HEADING`). The bar for inclusion is a string a foreign
    process must reproduce or match byte for byte to interoperate — model-facing
    prose the library injects for itself is deliberately out.
  - **GENERATED, never hand-maintained.** `scripts/gen-canonical-notes.mjs`
    runs at the end of `npm run build` and reads the BUILT barrel, so the file
    cannot disagree with the code that ships. It fails loudly if a constant was
    renamed or un-exported rather than emitting a hole.
  - **`./canonical-notes.json` joins `./package.json` as the second DATA entry**
    in the exports map — a plain-string target that publishes a file, not a
    module. Without it Node's exhaustive exports map would refuse
    `require.resolve`, which is the same wrong-door failure again.
  - Also fixes a false positive the change surfaced: the docs-truth advisory
    "prose names a non-existent import path" read only CODE subpaths, so it
    reported a data entry that resolves perfectly well. It now reads the data
    entries from the manifest instead of naming `./package.json` by hand.

## [9.69.0] - 2026-08-26

### Added

- **`BrowserRunner` — a browser an agent drives, and a PERSON can take over**
  (`agentfootprint`, with `agentCoreBrowser()` on `agentfootprint/providers`).
  The port is deliberately small and deliberately not a browser-automation API:
  `click`, `type`, `press`, `screenshot` (→ `BrowserShot`), `stop`, and the one
  that earns the port its keep —

  ```ts
  await session.handControlTo('person'); // the automation stream stops
  // …they sign in, clear the CAPTCHA, approve the consent screen, watching live
  await session.handControlTo('agent'); // and the agent carries on
  ```

  Pair it with a check-in and the agent **pauses** rather than guesses: the
  handover, the wait and the resume are ordinary events in the trace, so "why
  did this run take four minutes" has an answer that names a person and a login
  screen.

  **A session has two doors, and the adapter refuses to blur them.**
  `BrowserSession.automationEndpoint` is a CDP WebSocket, and everything
  page-shaped — navigate, find an element, fill a form — belongs there, driven
  by Playwright or another CDP client. This library takes no dependency on
  Playwright and does no page work; it hands the endpoint over.
  `liveViewEndpoint` is where a person watches.

  **What verification changed.** The `InvokeBrowser` action union, read off a
  real install of `@aws-sdk/client-bedrock-agentcore` 3.1118.0, is exactly
  `mouseClick | mouseMove | mouseDrag | mouseScroll | keyType | keyPress |
keyShortcut | screenshot` — with **no navigate member at all**. An adapter
  written from memory would have invented page verbs for a door that has none.
  The same pass fixed `MouseClickArguments` (`{ x, y, button?, clickCount? }`,
  buttons `LEFT|MIDDLE|RIGHT`), `KeyPressArguments` (`{ key, presses? }`) and
  `ScreenshotResult` (`{ status, error?, data? }`) — which is why an empty
  screenshot refuses with the service's own status instead of answering with a
  blank image that reads as a blank page. All four commands joined the systemic
  name pin.

  `stop()` tolerates a session the backend already reaped: an idle timeout is
  the ordinary case on a managed browser, and a teardown that succeeded must
  not report failure.

  **One contradiction left as AWS wrote it:** the devguide says a session
  defaults to 15 minutes and `StartBrowserSession`'s API reference says 3600
  seconds. The adapter sends no timeout unless `sessionTimeoutSeconds` is
  passed, so the service applies whichever it means rather than this library
  picking a side in somebody else's disagreement.

### Changed

- The AgentCore guide's "Code Interpreter / Browser — wrap as tools" section is
  gone. It advised writing a tool by hand and not building a port "until a
  second backend has real pull" — a second backend arrived (`localCodeRunner`),
  so the advice was followed and then outgrown, which is the outcome it wanted.
  Both are ports with backends behind them now, and the page says so.

## [9.68.0] - 2026-08-26

### Added

- **`agentCoreA2AHost()` — other agents can call yours** (`agentfootprint/hosting`).
  AgentCore Runtime speaks four protocols and **A2A** is the agent-to-agent one:
  another agent (Strands, LangGraph, Google ADK, a Marketplace listing)
  discovers yours through its agent card and calls it. The same split as 9.65.0,
  for the third time:

  - **`a2aWire()`** is the A2A PROTOCOL — JSON-RPC 2.0, `message/send`, text
    parts, artifacts, `A2A_PROTOCOL_VERSION` `0.3.0` — an open protocol with no
    vendor in it, exported in its own right along with
    `a2aAgentCardDocument(card)` for deployments that must serve the discovery
    document from somewhere this wire does not own.
  - **`agentCoreA2AHost()`** is the CONTAINER CONTRACT: port 9000 (its own, not
    HTTP's 8080 nor MCP's 8000), the agent at `/`, `GET /ping` answering
    `{"status":"Healthy"}`, and the session from
    `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id`.

  It needed no new machinery in `httpHost`, and one seam explains why: JSON-RPC
  requires a reply to ECHO the request's `id`, which is possible only because
  `HttpWire`'s body methods receive the request that produced them — a seam
  added in 9.65.0 for an unrelated protocol.

  **The A2A spec is broken here on purpose, and not by us.** The specification
  delivers JSON-RPC errors over HTTP 200; AgentCore returns the real status with
  the JSON-RPC error body. `agentCoreA2AErrorCode` is the runtime's published
  table (`-32051` … `-32055`, `-32603`), exported so a client shares one table
  with the host rather than keeping a copy that drifts — it is what tells a
  caller that `-32054` "Session operation in progress" must be retried with
  backoff, which A2A clients do not do on their own.

  **What it does not do, in the code as well as the prose:** `message/send`
  only. No `message/stream`, no task lifecycle, no non-text parts — each refused
  BY NAME before the agent runs. So the host declares **no** streaming
  capability and its card says `streaming: false`. AWS's own sample card says
  `true`; ours says what is true of ours, and a test pins the two agreeing.

- The host conformance suite gained a **fifth** subject. A2A is the first whose
  envelope is not ours at all — the reply echoes a JSON-RPC id and the answer is
  buried in an artifact rather than named as an output — and every assertion
  held unchanged. Nothing in `src/hosting/types.ts` moved to let it pass.

### Fixed

- **A capability this library claimed and could not honour.** `httpHost`
  declares `['streaming']` by default, so the A2A host inherited it while
  `message/send` has nowhere to put a chunk: `requireCapability(host,
'streaming')` would have passed for a host that then delivered none. The
  conformance suite caught it — it asserts chunks _if and only if_ the
  capability is declared — and the adapter now declares `[]`.

### Changed

- The vendor-name guard on `src/hosting/` grew `/ping`: one runtime's health
  spelling, on the list for the same reason as `/invocations` and `/readiness`.
  Still deliberately absent: `/.well-known/agent-card.json`, which belongs to
  the A2A protocol and to no vendor.

## [9.67.0] - 2026-08-26

### Added

- **`agentCoreGatewayTransport()` — reaching an AgentCore Gateway without
  looking anything up** (`agentfootprint/providers`). `gatewayTransport` says of
  itself that nothing in it is vendor-specific, and that stays true because the
  four facts which ARE AgentCore's now live in one file beside it:

  - **the endpoint** — `agentCoreGatewayUrl({ gatewayId, region })` builds
    `https://{gatewayId}.gateway.bedrock-agentcore.{region}.amazonaws.com/mcp`,
    a hostname nobody recalls correctly, which is why it is a function and not a
    line in a README;
  - **the policy session** — `AGENTCORE_POLICY_SESSION_HEADER`, stamped per
    request from `policySessionId`. AgentCore's temporal policies decide on
    SEQUENCES of actions, and a sequence needs a boundary. **Pass a function on
    any transport more than one person shares:** it is resolved per request, so
    `() => currentSessionId` keeps each caller's history their own, where a
    fixed string would merge everybody into one policy session and make one
    person's earlier actions count against another person's rule. The header
    rides the transport's `fetch` seam precisely so a function is possible;
  - **the catalogue's own search** — `AGENTCORE_GATEWAY_SEARCH_TOOL`, with
    `gatewaySearchTool(tools)` / `hasGatewaySearch(tools)`. Both answer
    permanently rather than transiently: semantic search is enabled when a
    Gateway is CREATED and cannot be turned on later, so absence is a fact about
    that gateway, not something to retry;
  - **the signing name** — `AGENTCORE_SIGV4_SERVICE`.

  Deliberately absent: a `search(gateway, query)` convenience. Executing a tool
  needs a `ToolExecutionContext` that belongs to the agent loop, and a call made
  on a fabricated one appears in NO TRACE — the model would be handed a
  shortlist whose origin nothing can later explain, which is the opposite of
  what this library is for. The search tool is registered like any other tool,
  and the search becomes an ordinary, attributable tool call.

### Changed

- The AgentCore guide and the AWS adapters page now record that a Gateway is
  **also a model router** — it serves `/v1/chat/completions`, so the existing
  `openai({ baseURL, apiKey: async () => … })` reaches it with no new code. The
  callback form of `apiKey` matters there for the usual reason: a gateway token
  expires.

## [9.66.0] - 2026-08-26

### Added

- **`agentCoreEvaluationSpans()` — an agentfootprint agent can now be scored by
  AWS's own evaluators, hosted anywhere** (`agentfootprint/observe`). Since July
  2026 AgentCore Evaluations grades agents that do not run on AWS, from spans
  that reach CloudWatch. Ours were invisible to it, and the reason was two
  fields: their classifier routes on the instrumentation scope name and skips
  anything not under `opentelemetry.instrumentation.*` **silently**, and their
  scorers read the turn's text off attributes we deliberately never emitted.
  Both are now options on the NEUTRAL adapter, because both are OpenTelemetry
  concepts rather than anybody's product: `otelObservability({ scopeName })`
  (default `'agentfootprint'`, unchanged — a rename moves every existing
  dashboard's spans out from under it) and `otelObservability({ captureContent })`
  (default **false** — enabling it exports raw prompt and answer text as
  `gen_ai.task.input` / `gen_ai.task.output`). `agentCoreEvaluationSpans()` is a
  CONFIGURATION of that adapter with the two settings AWS requires, exported
  from the vendor's own file beside `AGENTCORE_EVALUATIONS_SCOPE_NAME`; the
  scope name is deliberately not overridable through it, since honouring an
  override would produce spans the service skips. Per-inference message arrays
  are NOT emitted: `llm_start` / `llm_end` carry model, usage and stop reason,
  never the messages, so there is nothing truthful to put there. The division
  stated plainly, in the docs and here: **their evaluators say what the score
  is; the agentfootprint trace says why it happened.**

- **`agentCoreIdentity` learned the three operations AgentCore Identity grew
  after 9.4.0** (`agentfootprint/security`), each verified against a real
  install of `@aws-sdk/client-bedrock-agentcore` **3.1118.0** — names, request
  shapes and enum values read off the package rather than remembered, which is
  the 9.4.0 law:

  - **`userFlow: 'consent' | 'exchange'`** — `'exchange'` sends
    `ON_BEHALF_OF_TOKEN_EXCHANGE`, trading the person's existing login for a
    scoped downstream token with no consent screen at any point. Default stays
    `'consent'` (`USER_FEDERATION`), and `mode: 'machine'` is untouched: M2M has
    no user to act for. Not simply the nicer flow — the consent screen is what
    asks the person, and choosing `'exchange'` is a deployment saying it does
    not need to.
  - **`apiKeyServices` + `apiKeyHeader`** — services whose credential is an API
    key are vended with `GetResourceApiKey` and come back as an `apiKey`
    credential. No auto-detection: AgentCore holds both kinds in one vault
    behind two operations and the provider name does not say which, so guessing
    would mean a failed call, a retry, and an ambiguous error. A missing
    workload identity token is refused BY NAME rather than sent (the field is
    required on `GetResourceApiKeyRequest` — read off the SDK's types).
  - **`completeAgentCoreAuthorization({ sessionId, userToken | userId })`** —
    the handshake that closes a consent round-trip, exported as a FUNCTION and
    not a provider method because it runs in your web callback route: a
    different process from the agent run, often a different service. Naming the
    person twice, or not at all, is refused before any call, because the field
    it maps to (`userIdentifier`) is a one-of. The shape this takes in
    agentfootprint is a **pause**: first vend answers `authorization-required`,
    the person approves, your route completes the handshake, and the same
    request re-run is `issued` — pinned end to end by a test.
  - The AWS command-name pin grew both new commands; five of AgentCore
    Identity's six data-plane operations are now covered.

- **`otelObservability({ _otelApi })`** — an internal test seam for the path
  where the adapter builds its own tracer, which is the only path `scopeName` is
  observable on. Mirrors the `_client` / `_sdk` seams every SDK-backed adapter
  already has.

### Changed

- **The AgentCore guide stopped describing the 2025 platform.** Policy reached
  GA in March 2026 and `agentCorePolicy()` stays retired — re-enumerating all
  165 commands of `@aws-sdk/client-bedrock-agentcore-control` 3.1118.0 finds no
  `EvaluatePolicy`, `TestPolicy` or `IsAuthorized`, so the 9.4.0 reading
  (enforcement lives inside the Gateway, `LOG_ONLY` is the testing story) was
  the architecture rather than a gap we left open. The Evaluations row now names
  the bridge, and a new note says plainly that AWS ships its own TypeScript
  serve-app: if all you need is the `/invocations` contract, use theirs — what
  this library adds is the part that is not the contract.

- `otelObservability`'s `tracer` docstring named a version constant that has
  never existed in this package. It now describes what the code does.

## [9.65.0] - 2026-08-26

### Added

- **`foundryResponsesHost()` — an agent behind Microsoft Foundry Toolkit's
  Agent Inspector** (`agentfootprint/hosting`). One inbound hosting adapter on
  the same `AgentHost` port every other host uses: `HEAD /responses` answers
  the Inspector's capability probe with 204, `GET /readiness` answers
  `{"status":"healthy"}`, `POST /responses` takes a turn — `input` as text or
  as user-message items with `input_text` parts — on port 8088
  (`DEFAULT_FOUNDRY_PORT`; `FOUNDRY_INVOKE_PATH`, `FOUNDRY_READINESS_PATH` and
  `FOUNDRY_SESSION_FIELDS` are exported beside it, and every knob lives on
  `FoundryResponsesHostOptions`). `stream: true` **in the body** selects the
  nine-event Responses SSE lifecycle, `response.created` through
  `response.completed`, with stable ids and a monotonic `sequence_number`;
  failures end with `response.failed`. The session is read from
  `conversation` / `agent_session_id` / `session_id`, first present wins.
  Image/file input, non-message items, non-user roles, and the `awaiting` /
  `artifact` / `sessions` terminals are refused BY NAME — never silently
  dropped, never an invented success. It is an inbound hosting adapter, not a
  model provider (a Foundry Local model stays `openai({ baseURL })`), and it
  does not feed the Workflow Visualizer — the wire carries the conversation,
  not the agent's topology. Passes the same host conformance suite as
  `nodeHost` and `agentCoreRuntimeHost` over a real socket; the request and
  lifecycle shapes were captured from a real Toolkit 1.6.9 Inspector session.
  Guide: `docs-next` → Infrastructure → Microsoft Foundry; runnable example:
  `examples/deploy/foundry-responses.ts`.

- **`responsesWire()` — the Responses protocol as a plain `HttpWire`, with no
  vendor in it** (`agentfootprint/hosting`). `foundryResponsesHost` is a
  CONFIGURATION of it — Foundry's paths, port, probe body and session aliases —
  so the next runtime that speaks Responses composes
  `httpHost({ wire: responsesWire(), ... })` instead of copying a protocol.
  `ResponsesWireOptions` carries `defaultModel`, `sessionFields`
  (`DEFAULT_SESSION_FIELDS`) and `health`; `readResponsesInput` and
  `readResponsesSession` are the exported readers; `PUBLIC_FAILURE_MESSAGE` is
  what a caller sees when a handler THREW — an uncaught exception's text never
  travels, while `reply.fail(error)`'s chosen words always do.

- **Four provider-neutral seams on `httpHost`,** each defaulting to exactly the
  incumbent behavior: `wire.wantsStream?(facts)` (a dialect that selects
  streaming from the body instead of the `Accept` header), `wire.stream?(facts)`
  returning a per-response `StreamFraming` of `StreamFrame`s (protocols that
  frame a stream as a named-event lifecycle rather than one `chunk` per piece —
  the old shape is itself the default framing), `maxBodyBytes` (bounded body
  reads refusing at the crossing byte with the typed `RequestTooLargeError`,
  413; **default remains unbounded** so no existing deployment starts refusing
  — set it), and `invokeHeadProbe` (opt-in 204 for a bare HEAD on the invoke
  path). `WireRequestRefusal` lets a dialect refuse a request it cannot carry
  from `readRequest`, with its own status and code, before a turn is paid for;
  `FailureOrigin` (`'refused' | 'threw'`) tells a dialect which kind of failure
  it is describing, so sanitization replaces exception text and never a
  handler's chosen words. `nodeHost` and `agentCoreRuntimeHost` are
  byte-for-byte unchanged on the wire.

- **The host conformance suite learned that sanitizing is not a free pass:**
  a subject declaring `sanitizesThrownErrors` must still REPORT a thrown
  handler's failure, and is now asserted NOT to have repeated the thrown words.
  The vendor-name guard on `src/hosting/` grew `foundry`, `microsoft` and the
  `/readiness` route literal (the `/invocations` reasoning, applied again);
  `/responses` is deliberately NOT on the list — it is a protocol's path,
  spoken by more than one runtime and owned by none.

## [9.64.0] - 2026-08-26

### Added

- **`RunConfigFn`, `RunConfigContext` and `RunConfig` are exported from
  `agentfootprint`.** `.configure(fn)` has been a public door since 7.13.0, so
  the shape of `fn` was a public contract that could not be named — a consumer
  who wanted a resolver in its own module had to re-declare it by hand, and a
  hand copy stops matching the moment the context grows a field. `RunConfig`
  ships alongside the other two because it is what a resolver RETURNS: without
  it a factory cannot annotate its own return type. No runtime change; the
  types were already the ones `.configure()` used.

### Fixed

- **An absence's extra keys are evidence now — every field it carries except
  the one that echoes the caller.** A share-lookup tool returned its absence
  with the answer attached: an extra `known_shares` key holding the 40 real
  share names on the filer, and a `try_instead` telling the model to pick one
  of them. The model did exactly that. The evidence gate then called the share
  it picked ungrounded, because the absence projection indexed the coverage
  lists and excluded every other key. Following the tool's own advice produced
  a flagged answer, which makes the advice worthless and the gate wrong.

  The line was never "coverage vs the rest" — it is **tool-authored knowledge
  vs caller echo**. A recognized absence now projects every string and number
  leaf it carries: the coverage lists as before, plus extra keys, plus the
  tool-authored `try_instead` and the library's `note`. `looked_for` stays
  excluded, alone, because it is the one field whose job is to quote the
  REQUEST rather than state what the tool knows — index it and a fabricated
  name grounds itself by being handed to one tool that found nothing, which is
  the cheapest laundering machine there is. The laundering fence has its own
  test and did not move.

  This widens leniency: the gate accuses less. That is the safe direction on an
  accusation boundary — a missed fabrication costs a value nobody checked, a
  false accusation costs a correct answer and a real turn.

### Changed

- **A declared `pattern` is now an ENFORCED pattern. Consumers who already
  wrote one in a tool's `inputSchema` will see calls refused under
  `toolArgValidation: 'enforce'` (the default) that used to dispatch — that is
  the fix, and it is worth reading before upgrading.**

  The field story: a tool result ended with an offer — "I can also map these
  ids to volume names" — and the person answered "yes please". The model bound
  _that sentence_ as the identifier argument and dispatched. The tool's schema
  DECLARED the identifier's shape, in a `pattern` that "yes please" could never
  match; the pre-dispatch validator simply did not read the keyword. So the
  call went out, failed downstream, and cost a round trip — and the consumer
  hand-rolled a blocklist of affirmative phrases to do the job the declaration
  already described. A declared shape a boundary ignores is worse than no
  declaration at all: the author believes it is enforced.

  `pattern`, `minLength` and `maxLength` now join `type` / `enum` / `required`
  in the honest subset — same place in dispatch, same
  `agentfootprint.validation.args_invalid` event, same tool-result shape, and
  the same dial in every mode. `'warn'` emits and executes anyway; `'off'` is
  byte-for-byte what it always was. `pattern` follows JSON Schema exactly:
  unanchored, ECMA-262, strings only. A `pattern` that does not compile is
  IGNORED with one developer warning rather than throwing — a schema author's
  typo must never take dispatch down with it.

  **The refusal teaches.** `expected string, got string` is unactionable when
  the complaint IS the shape, so a string-shape issue names the argument path,
  quotes a capped (80-character) excerpt of the offending value, states the
  declared pattern or bound, and carries the parameter's own `description`
  sentence when the schema has one — that sentence is where an author writes
  what the identifier looks like, and it is usually the whole correction. The
  `value` and `hint` fields are new and OPTIONAL on the event payload's issues;
  structural issues (type, enum, required, additionalProperties) still name
  types and never values, and the OTel span event still projects
  path/expected/got only, so third-party telemetry stays value-free.

## [9.63.0] - 2026-08-25

### Added

- **`unsupported-argument` — the check at the CHOICE seam, and the fifth and
  last of the Context Integrity family.** In a recorded triage turn the window
  dropped the user message holding the true entity id and kept the assistant's
  own rendered answer; asked for "the status for that machine", the model
  resolved the reference out of its OWN prior prose, took a truncated job-name
  fragment for a machine name, called the lookup tool with it, got an honest
  "nothing found", and told the person their protected machine had no backup
  record. Every shipped rail passed honestly — the coverage envelope, the
  absence envelope and the evidence gate all held, because every value in the
  answer really was grounded; the defect was the REFERENT, bound wrong at the
  argument, at the one seam that had no check.

  After each response, every identifier-like string argument of a call to an
  armed tool must appear in the frame the model chose from: the system prompt,
  a USER message, or a TOOL result of the exact request the call was assembled
  from. Assistant messages are deliberately not ground — a value whose only
  source is the model's own rendered prose has been re-derived from a rendering
  rather than read from evidence. Detection only; nothing is blocked.

  **`argumentsFrom` now does double duty**: the one field that arms
  `dangling-reference` at the compose seam (is the ground still in reach while
  the tool is OFFERED?) arms this at the choice seam (did the value the model
  chose come from that ground when the tool was CALLED?). Nothing new to
  declare, and a tool that declares nothing is still never either check's
  subject.

  The fences are the check's honesty and each has a test: non-strings are never
  checked, values under four characters are never checked, a value served
  anywhere in the frame passes, and a value the tool's own `inputSchema`
  declares in an `enum` passes. The two remaining states file DIFFERENT
  messages, because they need different fixes — grounded only in the model's
  own prose (re-fetch the real ground, named), and grounded nowhere at all.

  `find_context_errors` now offers `'unsupported-argument'` in its `kind` menu;
  only `'duplicate-execution'` remains a class the finding type names and no
  check in this build can file. The disposition report grows from four rows to
  five, and one `argumentsFrom` declaration now produces two armed rows at two
  seams.

## [9.62.0] - 2026-08-21

**Three ways a check could be there and do nothing, and nobody could tell.**

### Fixed

- **An unarmed Context Integrity check is now a row, not a silence.**
  `invariant-violation` at the compose seam, `dangling-reference` and
  `unsupported-claim` only ever ran when the application declared their
  precondition — a `.maps()` plan, a tool declaring `argumentsFrom`, a
  `.claims()` contract. An application that declared none of them filed only
  the always-on `wire` row: checked, green, and indistinguishable from a run
  where all three looked and found nothing. This library's own reference
  agent shipped that way for weeks, and a second integration reported
  reaching the same state independently.

  All three now register on every run, and one with no subject this run is
  noted `not-applicable` immediately — the literal truth, in the vocabulary
  the ledger already had. Per check, deliberately: the common shape is
  PARTIAL, where an app arms one and never learns the other two sat out, so
  arming one check must never suppress the rows for the two it didn't.

  No new event and no new field. The rows travel through the existing
  `agentfootprint.integrity.disposition` event and `find_context_errors`'s
  partial-coverage headline, unchanged at every consumer.

- **`integrityPosture: 'dev'` now mints a canary for every registered check,
  not only the armed ones.** Otherwise the fix above would have removed an
  ambiguity at the arming level and reintroduced it one level down: a checker
  that has rotted and a checker that simply had no subject file an identical
  `not-applicable` row. The canary — one pure function against a deliberately
  contradictory fixture — is the only thing that separates them. The cost is
  a function call; the alternative is that whoever finally declares
  `.claims()` inherits whatever state that checker rotted into while nobody
  was looking.

- **A parked map's tools no longer reach the wire through a tool provider.**
  Parking held its owned tools out of the registry list and the skill
  injection list, but provider schemas merged unfiltered — so a provider tool
  sharing a parked member's name still rode the wire, and the compose-seam
  backstop could only report it after the fact. It is now held out on all
  three routes.

  It has to be filtered there rather than by the provider: `ToolDispatchContext`
  carries the active skill but nothing about engagement standing, so a
  `ToolProvider` cannot see that a map was parked. This is not the
  provider-wins-the-wire law, which governs two ACTIVE sources disagreeing
  about a name — a parked map is not competing, it is not talking at all. The
  backstop stays as a regression guard, now expected to be silent.

### Added

- **`repeatedWhen: 'arguments'` on a tool** — fingerprint repeated calls on
  arguments alone, ignoring the result. The detector keys on tool + args +
  result, so a tool that stamps a fresh value into every result (a screen
  tool returning a version, a cursor, a timestamp) never produces two
  matching keys and the detector is silently inert for it, even when the
  agent fires the byte-identical call twice. Declared per tool, because the
  default is right for tools whose results carry meaning.

  It still only NOTES. The documented anti-guarantee holds unchanged — tools
  will execute again, there is no built-in call-id dedup, and durability
  replay depends on that. The note is appended after execution and changes
  nothing about whether the call runs.

## [9.61.1] - 2026-08-20

### Fixed

- **`find_context_errors` no longer reports a greener run than its own rows
  support.** The all-clear headline summed `checked` across every registered
  check, so one busy check could carry the total while another check looked
  at nothing at all — the per-check rows said so underneath (`⚠ ran 0×`), but
  a reader who stops at the headline got the rosier story. The headline now
  states coverage: either _all_ registered checks ran, or it says COVERAGE IS
  PARTIAL and names the checks that checked nothing. Same law as the rows —
  a check that never saw a subject is silent about its seam, never a pass.

### Documentation

- **The claim seam is documented where a reader will find it.** The Context
  Integrity page was written before the claim seam shipped and still named
  two defect kinds and three checks. It now covers `.claims()` (what it
  catches that the evidence gate states it cannot, the `.outputSchema()`
  requirement, and the three fences: latest settles / uncollected is
  unreachable / doubt is an advisory), the third filable kind
  `unsupported-claim`, and the `'claim'` seam.
- **The step nobody told you about.** Findings and the disposition ledger
  ride the event stream, and typed events are dropped when nothing is
  listening — so `find_context_errors` needs a recording. The page now shows
  the whole door (`recordRun` → `openRecording` → `traceToolpack`), and a
  test runs exactly that path so the page cannot teach one that does not
  work.
- **The two switches that turn the feature on are named in the README**:
  `integrityPosture: 'dev'` and `.claims()`, with a link to the page. The
  feature previously appeared there only as a clause in one bullet.

## [9.61.0] - 2026-08-20

### Fixed

- **Six defects in the Context Integrity family, caught by an adversarial
  review before release** — each survived two independent attempts to refute
  it, and each is now pinned by a red-proved regression test.

  - _A claim finding's identity ignored the FIELD_, so a contract naming two
    fields of one entity (the shape `.claims()` itself tells you to write)
    filed one event and swallowed the rest — while the disposition ledger
    counted them all, leaving the two accounts of one run disagreeing.
    `ContextError.predicate` now rides the identity, mirroring the
    substrate's own `assertionKey`; findings that never set it keep exactly
    the identity they had.
  - _The dangling-reference check was DEAD under `reactMode:
'dynamic-grouped'`_ — `compactions` was threaded into the wrong mapper,
    so the check saw an empty window ledger every pass and filed a healthy
    verdict. Chart-shape parity is now pinned by tests that run the same
    trap under both dynamic shapes.
  - _The dev canary structurally disabled the wiring-rot theorem._ A minted
    canary proves the pure function still works; it says nothing about
    whether the pipeline ever calls it (`beginIntegrityRun` mints by calling
    the function directly). Masking theorem (i) with it meant the alarm this
    ledger exists for could never fire.
  - _`workExisted` was hardcoded `true`_ on every exit path, so a run that
    died or paused before its first LLM call reported every registered
    checker as dead. It is now measured from a signal the integrity code
    does not itself write.
  - _An answer agreeing with a settled non-reading was filed as an advisory_
    — `null` reported for a fact whose settled value is `null` is agreement,
    not doubt.
  - _The claim ledger accumulated for agents that never declared a
    contract_, and appended by whole-array spread. It is now gated on
    `.claims()` and appends without the quadratic copy — restoring the
    zero-delta promise for every agent that configured none of this.

- **Two honesty defects in `find_context_errors`**, both caught by the same
  adversarial review and both red-proved by a failing test first.

  - _The green headline never read the counts it claimed to summarise._
    "The checkers below RAN; nothing they cover was violated" was printed on
    the mere absence of a finding, so a run whose every encounter was
    `unreachable` (the check could not see the evidence), whose checks all
    ruled themselves out of scope, or whose registered checks never met a
    subject still got a clean bill of health. The headline is now chosen by
    summing the disposition rows' `checked` counts: real checked encounters
    earn the green sentence and it names how many; zero checked encounters
    read **⚠ NOTHING WAS CHECKED** with the reason (unreachable / out of
    scope / never met a subject); no rows at all, and no registered check at
    all, each keep their own sentence. A checker ROW with zero `checked`
    stopped saying "the checker ran and found nothing at this seam" for the
    same reason.
  - _The tool advertised defect classes no check can file._ The `kind` enum
    was pinned to the whole `ContextErrorKind` union, so it offered
    `unsupported-argument` and `duplicate-execution` — classes no check in
    this build files — and answering one returned a negative verdict about
    something nothing on earth could have produced. The compile-time pin
    stays (a new kind still fails the build until it is listed), but each
    entry now declares whether a check ships for it: only filable kinds
    reach the enum, the description says out loud which two have no checker
    here, and a caller who passes one anyway (args validation is a dial)
    reads **⚠ UNANSWERABLE — nothing ever looked for one, so silence about
    it is not evidence of its absence**.

- **`dangling-reference` no longer accuses a window that HAS been
  re-grounded.** The check compares two name sets, and the two were derived
  by different rules: the dropped side came off the window ledger, which
  names a tool result with `window/toolNames.ts` — the helper that recovers
  the name from the assistant turn that asked when the result itself carries
  no `toolName` — while the present side read `message.toolName` directly.
  `LLMMessage.toolName` is optional, so a conversation restored from an older
  release (or from a host that speaks the wire shape and nothing more) names
  its tools through `toolCalls[].id` alone; the SAME message was then
  evidence-that-left on one side and not-present on the other, and a
  legitimate re-fetch was reported as a dangling reference. The present side
  now asks the same helper the same question. One helper, two sides, so the
  two can never disagree about what a message is — and the check's second
  fence ("a re-fetched ground is silent") holds for windows that never
  carried the field.

### Added

- **`.claims()` — the claim seam: what the answer says vs what the run
  settled.** The evidence gate grounds an answer's names and numbers but
  states its own limit: it cannot catch a false claim assembled from real
  values ("fc1/3 is healthy" when the data says the port is down). This
  closes that for the facts you name. Tools returning `semantic({ facts })`
  now settle typed readings into the run's claim ledger (`scope.claimFacts`
  — the `coverageDeclared` shape, written only when a tool declares
  readings), and `.claims({ nav_count: { entity: 'screen2', field: 'nav' }})`
  declares which validated-answer field claims which fact. Declared, never
  inferred (the `argumentsFrom` precedent). A disagreement files one
  `agentfootprint.integrity.context_error` at seam `'claim'` and changes
  nothing else — it is the last of the four Route judges and the only one
  that never re-routes, because a contradiction on a finished run is a fact
  about the run, not a reason to re-ask the model. Fences: an uncollected
  fact is `unreachable`, an omitted field `not-applicable`, only the latest
  ledger row asserts (earlier ones ride as quoted history), and an answer of
  `null`/`'unknown'` against a verified value files an **advisory**
  (`ContextError.advisory`) counted apart from real defects. Requires
  `.outputSchema()` — refused at build otherwise, since prose has no typed
  stratum and a contract that checks nothing is the decay this family
  exists to prevent.

- **`find_context_errors` — the 11th trace tool: "what did this run
  contradict itself about, and why?"** The Context Integrity checks file
  their findings on the event stream; until now a debugging model had no way
  to ask for them. The new toolpack tool READS that channel — it never
  re-runs a check and never re-judges one — and adds the join a findings
  stream cannot carry: each finding lists its kind, seam, subjects, message,
  the STEP it was filed at (`meta.runtimeStageId`) and its witnesses' steps,
  in the exact shapes `trace_node` / `trace_slice` / `who_wrote` /
  `backtrack` / `find_in_trace` accept; when a subject names a key the
  commit log wrote, the last writer and a bounded value resolve inline. The
  run's disposition rows ride along, so "the checkers ran and found nothing"
  and "no checker was registered for that seam" stay different answers, and
  a registered check with zero encounters is named as wiring rot. Honest
  absence has its own sentences: no event tail is _no finding evidence_, a
  tail with no integrity events says the channel is empty and why, and rows
  reporting findings the tail no longer carries say **evidence missing** —
  none of them ever reads as "no context errors found". Synthetic canaries
  and advisories are shown but counted apart, and a run whose rows count
  more encounters than the deduplicated list shows entries reconciles the
  two out loud. Bounded like the rest of the pack (`limit`, default 10, hard
  cap `TOOLPACK_HARD_CAPS.contextErrorsMax` = 25) and mounted
  UNCONDITIONALLY — a tool that vanished with the event tail could not say
  the evidence is missing. `TRACE_TOOL_NAMES` (and therefore
  `.selfExplain()`'s reserved names) is now eleven.

## [9.60.0] - 2026-08-20

### Added

- **The disposition ledger goes live: every run accounts for its checkers**
  (T9's run lifecycle). Each run registers the applicable checks (wire:
  always; compose invariant: `.maps()` mounted; dangling-reference: any
  tool declaring `argumentsFrom`) and every check now notes one disposition
  per encounter — checked-pass / checked-fail / not-applicable /
  unreachable (a provider stating no wire manifest is UNREACHABLE, which is
  a different fact from clean). The run boundary files the rows ONCE as
  `agentfootprint.integrity.disposition` (107th typed event; every path —
  success, failure, pause — and before the recording stops, so recordings
  carry their run's checker accounting). New `integrityPosture: 'dev'`
  option adds the two liveness theorems: a run-start canary proves each
  pure check still catches its own synthetic defect (quarantined counts),
  and a finished run whose registered checkers demonstrably never ran fails
  with `CheckerDeadError` instead of returning green — because two shipped
  checks in this codebase decayed exactly that way. Default `'observe'`:
  rows only, listener-gated.

- **`Tool.argumentsFrom` + the dangling-reference check** (the closure
  check's decidable fragment). A tool author can now declare where a tool's
  arguments come from — `defineTool({ name: 'screen_fire', argumentsFrom:
['whats_here'] })` — and `callLLM` checks at request assembly that every
  served tool's declared grounds still have results in the window: a ground
  the window ledger says was evicted (`droppedObservations`) with nothing
  re-established files a `dangling-reference` finding at seam `'compose'`,
  once per run. Two fences keep it honest: a never-dropped ground is silent
  (not-yet-grounded is legitimate sequencing), and a re-fetched ground is
  silent however many drops preceded it. The last-tool-result pin remains
  the first line of defense; this covers what the pin cannot — pins off, or
  grounds older than its ceiling. No declaration → byte-identical.

- **The wire seam: the manifest of what actually crossed** (T7b — the check
  the compose seam cannot do). Anthropic and browser-anthropic adapters now
  state `LLMResponse.wireManifest`, tool names read back from the FINAL
  serialized request body after every transform; mock echoes its request.
  `callLLM` compares the manifest against the exact request object it handed
  the adapter (post cache-strategy, so a strategy edit is never blamed on the
  adapter) and files `integrity.context_error` findings at seam `'wire'`:
  one for names that crossed uncomposed (the recorded four-retained-schemas
  defect — invisible to every pre-serialization check), one for composed
  names that never crossed. A provider stating no manifest leaves the call
  incomparable — silence, never a guess; an empty manifest is a stated zero.
  Same identity-dedup rail as the compose backstop: one defect files once
  per run.

- **The first live check: `invariant-violation`, at the compose seam.**
  A parked map whose owned tool names are still on the final merged wire
  list is the recorded two-channels contradiction — one channel says
  inactive, another shows it available, in the same call — and it is
  still reachable today through provider shadowing (the park hold-out
  filters registry and skill lists; provider schemas merge unfiltered,
  and a provider copy of a parked member's name keeps riding). The check
  runs once per parked map over the merged list, hands both channels to
  the shared exclusion algebra (so the quotation/unknown/epoch fences
  apply for free), files ONE `integrity.context_error` per defect per run
  (identity dedup threaded across passes and both chart shapes), and
  never alters the composition — detection converts a silent
  inconsistency into an attributed one. The healthy park stays silent.
  One stated deviation from the design brief, documented in the unit's
  README: the brief placed this at the write seam; in this architecture
  the park's own pass is when both facts first coexist wrongly, so the
  check runs at compose and still names the guilty write.

- **The assertion algebra and the one visible finding type.** An
  `Assertion` is keyed by `(subject, predicate, epoch)` with two rules
  that do the work: _serving is asserting; history is quotation_ (checks
  never fire across the quoted stratum — the whole stale-but-honest
  false-positive class, closed structurally) and _single-valued by
  default_ (you declare exemptions, never rules). Unknown `Claim`s never
  participate in a comparison. `conflictsOf()` is the pure exclusion
  comparison; `ContextError` is the uniform finding — plain kinds
  (`invariant-violation`, `unsupported-argument`, `dangling-reference`,
  `duplicate-execution`, `unsupported-claim`), five seams, witnesses,
  deduplicated by identity so one defect is one finding however many
  passes re-detect it. Findings ride a new typed event domain,
  `agentfootprint.integrity.context_error`, bridged and
  wildcard-subscribable from day one.
- **Tools carry their identity edge.** `defineTool({ owner: { kind, id } })`
  stamps WHO owns a tool at registration — the one moment the code knows
  both ends — and the per-pass record then attributes the tool to its
  owning subsystem instead of deriving `'registry'`. A blank half is
  refused by name; omitted is byte-identical. Integrity checks read
  stamps and never infer; unstamped tools are `unreachable` to
  subject-joined checks, which the disposition ledger counts.
- **`src/integrity/` — the Context Integrity family begins, accounting
  first.** `disposition/` files one of four dispositions per check
  encounter — `checked-pass` / `checked-fail` / `not-applicable` /
  `unreachable` — so "zero findings" and "zero checks ran" are different
  observable states. `assertAlive()` carries both dead-detector theorems:
  a registered check that filed nothing while work existed fails the run
  by name, and a dev-posture canary that was minted and never caught
  fails it even when there was nothing real to find. Synthetic counts
  never touch real ones; `unreachable` is the falsification instrument —
  if it dominates, applications are not stamping identity edges and the
  design's own thesis is failing, measurably. Internal for now; the
  public surface arrives with the finding type.

- **A capped event tail says WHERE its kept window starts** (Context
  Integrity, phase 0). `EventTailSnapshot.firstRetainedIndex` states the
  original stream position of the first retained event — the retained
  window is `[firstRetainedIndex, firstRetainedIndex + events.length)` —
  and rides the live handle (`RunRecorder.firstRetainedEventIndex`) and the
  archive (`RecordingEnvelope.run.firstRetainedEventIndex`). A drop COUNT
  says how much is gone; the offset is what lets a reader align a capped
  tail against another record of the same run. On a bare `Recording` it is
  honestly ABSENT (never fabricated as 0), and a stated value that is not a
  stream position is refused by name.

## [9.59.1] - 2026-08-20

**9.59.0 exists so that an unknown number always carries its own reason. Its
own cache report did not.** Found by the clean-room probe of the published
bytes.

These bytes also carry the two entries still listed under Unreleased above
(the internal `src/integrity/` disposition accounting, and
`firstRetainedIndex` on a capped event tail) — they were already on `main`
when this patch was cut, and they are additive.

### Fixed

- **The cache report no longer invents a cause for a turn it could not
  measure.** _What changed:_ when `cacheRecorder().report()` hands back an
  unknown hit rate, the sentence attached to it is now the one the calls
  themselves gave — most often "no CacheStrategy was given to
  `cacheRecorder()`, so nothing read the usage". When the calls disagree about
  why, the summary says they disagreed and lists the reasons (up to three, then
  a count of the rest) rather than silently choosing one. _Why it was not
  there:_ the summary sentence was typed into the code as a fixed string, "the
  provider reported no cache fields" — true for the case the author had in
  mind, a guess for every other, and it overwrote what each call had already
  stated. _How it improves:_ run without a strategy against a provider that DID
  report cache traffic, and 9.59.0 told you your provider was reporting
  nothing — sending you off to debug a provider that was working fine, instead
  of naming the one line missing from your own setup. You now read the real
  cause. The same fault had a quieter half, also fixed: a provider that cannot
  report cache usage at all was only named when it happened to be the turn's
  FIRST call.

## [9.59.0] - 2026-08-20

**The kernel we shipped in 9.58.0 was right about the law and quiet about
everything else. This release makes it say what it is doing — to the model,
not just to the record — and stops a red gate from ever reaching npm again.**

A clean-room probe found ten defects in 9.58.0. Two further independent
reviews found that several of the first fixes would have been incomplete, or
would have rewritten history. Everything below is the corrected set.

The core law held: **parking never touches the cursor.** That was verified on
the published bytes and is unchanged.

### Fixed

- **A red gate could reach npm, and did.** _What changed:_ the docs-truth
  ratchet now runs inside the build job that the publishing job depends on,
  so a red gate fails the build and the publish never starts — however the
  release was created. _Why it was not there:_ the gate existed, but CI ran it
  on `push` while the publish workflow runs on `release: published`, and the
  release script fires the release seconds after the push. The two raced, and
  npm never waited for a verdict. _How it improves:_ 9.58.0 shipped with a red
  ratchet; that is now structurally impossible.
- **A generated report was being edited by hand.** _What changed:_
  `npm run docs:truth:report` regenerates the report **without** touching the
  accepted-debt baseline, and a test fails if the committed report is not what
  the generator produces. _Why it was not there:_ the report could only be
  regenerated by also re-recording the debt, so an author who merely wanted the
  numbers to match reality had to choose between accepting unrelated debt and
  editing the file. They edited the file: the 9.58.0 release commit changed
  "103 typed events" to "105" and nothing else, leaving the export count stale
  and the ratchet red. _How it improves:_ restating the truth and accepting
  debt are now separate acts, and the file cannot silently drift.
- **The cache meter reported 0% for turns that hit cache on every call.**
  _What changed:_ the strategies read the framework's normalised usage instead
  of raw provider field names, and every number in the report is now a `Claim`
  — a value that says how it knows itself. _Why it was not there:_ the
  strategies parsed `cache_read_input_tokens` off a value that has never
  carried it, so every field read as missing and nothing was recorded; and the
  report typed its totals as plain numbers, so "nobody measured" and "measured,
  and it was zero" looked identical. The test fixtures were themselves
  provider-shaped, which is how it survived a release with a green suite.
  _How it improves:_ a real hit rate, an unmeasured turn that renders as
  unmeasured, and a rate that always states its own denominator ("3 of 20
  calls"). A **silent non-cache** — a prompt below the model's minimum
  cacheable size, which providers process without caching and without an error
  — is now visibly different from a turn nobody measured.
- **A meter was attached to a provider that cannot feed it.** _What changed:_
  the Bedrock strategy declares itself disabled, passes requests through
  untouched, and answers "not applicable" with the reason. _Why it was not
  there:_ it claimed full support and clamped cache markers onto a request
  field our Bedrock provider discards, reporting markers that never reached a
  wire. _How it improves:_ it stays registered, so a Bedrock user is told the
  truth by name instead of guessing. The same honesty is applied to OpenAI,
  which is the costlier gap because it caches automatically.

### Changed

- **A parked map now tells the model it is parked.** _What changed:_ while
  anything is parked, the model receives a short status card naming the cursor
  and the engagement as **separate** things, the reason, and the way back as a
  concrete call. _Why it was not there:_ every honesty signal landed on the
  record, which the model never reads — so re-engagement was reachable in
  principle and unreachable in practice, because nothing told the model that
  re-picking a skill it appears to already be in means anything. _How it
  improves:_ a door the model can see. It also learns the distinction the
  kernel is built on instead of inferring a contradiction.
- **A parked map's tools actually leave the wire.** _What changed:_ parking now
  holds the parked map's tool schemas off the request on its own authority.
  _Why it was not there:_ `.maps()` promised parking stops "the prompt fragment
  and tools", but on the default posture for flat graphs only the fragment
  stopped — the model was shown tools for a skill whose instructions had just
  vanished. _How it improves:_ the promise is true on every posture. This is
  **not** a change to `scopeTools` and does not touch the 10.0.0 ledger: those
  dials answer different questions.
- **A pick of the skill you are already on is now a legal re-engagement.**
  _What changed:_ a `read_skill` pick is routed by intent — of a parked map's
  member it re-engages the map and does not move the cursor. _Why it was not
  there:_ the reachability gate refuses a pick of the node the cursor occupies,
  which is right for a move and wrong for this; since parking never moves the
  cursor, a parked map is parked exactly where the model wants to return. For a
  single-member map that made parking permanent. _How it improves:_ the
  documented recovery door is real.
- **An engagement's founding cause is never rewritten.** _What changed:_ the
  record now keeps three separate facts — why the map is participating at all,
  why the cursor is on this member, and why this contribution is being served
  right now. _Why it was not there:_ one field answered all three, so
  confirming a guess silently overwrote the guess, and a record founded on a
  keyword at iteration 1 later read as system-backed since iteration 1. _How it
  improves:_ an incident review can still ask "was this founded on a guess?"
  and get the true answer, however much has happened since.
- **A declared route no longer forges a user request.** _What changed:_ when a
  declared edge moves the cursor to a different member, that member's
  eligibility is worked out from **its own** evidence rather than inherited.
  _Why it was not there:_ the next member inherited the previous one's
  standing, and an explicit request never decays — so one explicit pick at the
  top of a turn silently warranted every skill the graph walked to afterwards.
  _How it improves:_ a member entered weakly can park, exactly as if it had
  been entered that way from the start. The founding cause still says, forever,
  that the engagement began explicitly on the skill that was actually asked for.
- **A new turn is judged on the new turn's evidence.** _What changed:_ a cursor
  carried into a new turn with nothing explaining it is recorded as `assumed`
  — nobody said why — which is the weakest rung and expires like any guess.
  _Why it was not there:_ an absent explanation was recorded as system-backed,
  the strongest and non-decaying category, so turn one's mistaken keyword match
  became a permanent warrant on turn two and every turn after. _How it
  improves:_ cursor continuity and engagement continuity have independent
  lifetimes, and both are now pinned by tests — half the defect was that
  nothing pinned either.
- **The idle test checks all three of the conditions it documents.** _What
  changed:_ a map only accrues idle when its contribution was actually served,
  none of its tools was called, and the turn went elsewhere. _Why it was not
  there:_ only the last was checked, while the refusal text asserted all three.
  _How it improves:_ nothing claims a fact it did not check. (Making it honest
  needed the kernel to carry its own record of what was served — reading it
  from the existing delta machinery was silently empty in the grouped chart
  shape, which would have disabled parking there entirely.)
- **A map that cannot explain its cursor moves is refused at mount.** _What
  changed:_ `.maps()` requires a map that reports why the cursor moved. _Why it
  was not there:_ without it, no explanation ever arrives, and a kernel whose
  whole job is weighing evidence had none to weigh — silently. _How it
  improves:_ the failure is named at build time instead of at 3am.

### Added

- **`.maps({ nonParkable: true })`** — mount a map as mandatory, so it never
  parks however long the turn ignores it. For a policy, compliance or safety
  map, whose absence is a defect even when unused. It was documented as
  shipped and was unreachable: the kernel's data model carried the field and
  no option ever set it. It suppresses the park, not the measurement — the
  record still shows a map riding every call unused.
- **Two documentation pages**: _Mounted maps_ (the kernel's why, the evidence
  ladder, the idle test, the three facts, turns, and the park card) and _The
  cache meter_ (how to read a report, and exactly which providers can feed it).
- **A measurement, not a fix, for the prompt-cache cost of `read_skill`.** The
  tool's description is rebuilt from the cursor every iteration, and changing a
  tool definition invalidates a provider's entire prompt cache. A shipped test
  measures it: a five-call turn with four cursor moves produces five distinct
  descriptions — one full cache rebuild per call — while a turn whose cursor
  sits still produces exactly one. Fixing it properly is a cross-layer change
  with a routing-quality risk, so it is recorded with its numbers in
  `docs/design/2026-08-recorded-not-built.md` alongside three other deferred
  design questions, rather than rushed.

### Note for anyone reading the cache report in code

`cacheRecorder().report()` returns `Claim` values, not bare numbers. Read them
through `isKnown(...)`, or render them with `describeClaim(...)`. This is a
signature change in a minor release, and deliberately so: the old bare numbers
were not a contract worth keeping, because the value was zero on every turn.

## [9.58.0] - 2026-08-20

**A map's cursor says where it stands. Nothing said whether it had earned
its seat. Now something does — without touching the cursor.**

From the same recorded consumer-integration corpus as 9.55.0–9.57.0. A
person asked to "find the most recent **zone** redundancy run" — where
"zone" was the name of a thing they wanted to find, not a task — and an
entry regex read it as a task. The turn started standing on an audit skill
and never left: the cursor law ("stay until an edge leaves") is correct,
no edge could fire, and the skill's procedure plus its four never-called
tools rode all 30 calls of a 359,000-token turn. 29 of 30 moves were
"stay". The map behaved exactly as specified; what was missing is the
layer that asks whether a contribution is still earning its place.

### Added

- **The maps kernel: `.maps()` — engagement, orthogonal to the cursor.**
  A mounted map (the skill map today) keeps sole ownership of its
  position, always. What the kernel owns is ENGAGEMENT: whether the map's
  prompt and tools ride the next call. An engagement founded on a guess
  (`lexical` keyword, `semantic` classifier) is renewed only by concrete
  evidence — the map's own tool called, a declared route fired, the model
  asking by name — and without corroboration for `renewalGrace`
  consecutive passes (default 3) it is **parked**: skipped by the
  evaluator with the honest reason `'parked'`, on every record, cursor
  untouched. Explicit or structural evidence re-engages it on the spot —
  an accepted `read_skill` pick is the recovery door, so parking is never
  a trap. Engagements the system or the model founded (`structural`,
  `explicit`) never decay; `nonParkable` maps never park. On the recorded
  turn, the kernel parks the wrongly-guessed map on call four, saving the
  remaining ~26 calls from re-serving ~7k characters of the wrong map.
- **A new door, `agentfootprint/maps`** — the kernel's vocabulary as pure
  data and pure functions: the lease machine (`advanceEngagement`), its
  renewal feed (`renewalEvidenceOf`), the strength ladder, and
  **`Claim<T>`** (`known` / `unknown` / `notApplicable`) — a value that
  says how it knows itself, so an unknown count can never render as zero.
- **Two typed events in a new domain**: `agentfootprint.map.engaged` and
  `agentfootprint.map.parked` (with strength, witness, idle count),
  bridged and wildcard-subscribable from day one — a rule whose
  engagements always park with zero renewals is measurable decoration,
  which is the declaration telemetry this ships.
- Runnable example: `examples/context-engineering/21-park-the-wrong-map.ts`
  reproduces the trap and runs it with the kernel off and on.
- **Two entry-evidence rows in `graph.checkup()`** — the build-time half of
  the same repair, replacing the intuitive per-node exit lint (which flags
  0 of 20 on the real failing graph, and 19 of 20 with its reachability
  half dropped — both wrong). `one-way-entries` (warning, once per graph):
  three or more rule-driven entries and more than half declare no outgoing
  edge — the stated precondition of a permanent mis-entry, with the cures
  named. `no-negative-evidence` (warning, once per graph): three or more
  rule-driven entries with zero `examples` and zero `neverRoutes` — the
  proving machinery ships and nothing uses it; the consumer that hit the
  recorded failure declared neither, anywhere. Warnings, not refusals, by
  the zero-delta law; and the recorded trap phrase declared as a
  `neverRoutes` row against its own graph is a build ERROR with a witness
  (pinned by test) — the incident as build-time arithmetic.

Zero-delta when unmounted: no scope key, no events, and the evaluator's
loop is byte-identical — the same guarantee `leaseActiveIds` (9.19.0)
models, of which the park check is the deliberate mirror.

### Fixed

- **The server Anthropic adapter now keeps both halves of the cache
  contract.** `AnthropicCacheStrategy` prepares cache markers for the
  `'anthropic'` provider on every call, and the adapter silently dropped
  them — so on the server path a byte-identical prompt prefix (measured at
  roughly 65% of every call on a real recording) was paid at full rate, and
  the miss was invisible: no `cacheRead`/`cacheWrite` ever came back on
  `usage` for a meter to read. Markers now reach the wire as
  `cache_control` (with the same request→body index translation the browser
  adapter already pins), and the API's `cache_read_input_tokens` /
  `cache_creation_input_tokens` come back as `usage.cacheRead` /
  `usage.cacheWrite` on both adapters, streaming included. Absent stays
  absent — an adapter never invents a zero for a number the provider did
  not report. The marker application itself moved to one shared module,
  `anthropicCacheWire`, so the two adapters cannot drift apart again.

## [9.57.0] - 2026-08-20

**The window used to keep the task and throw away the evidence. Now it keeps
both — and says so either way.**

From a context-gap audit over five real recorded runs of a consumer
integration. An agent drove a screen through tools. One tool result carried the
only list of ids it was allowed to act on. Under a small window that result
survived about two iterations — an assistant message plus its tool results is
ONE turn, so two kept turns are two tool rounds. Since 9.55.0 the user's
REQUEST is undroppable, so the model still knew exactly what it had been asked
to do, and no longer had the evidence to do it.

What it did next was invent. It took an entity name it remembered plus the
shape of an id it had used earlier, assembled one that has never existed, and
was refused — a wasted action out of a small budget. In one archived run the
final answer to the _person_ named a host that appears in no tool result at
all.

Nothing in the conversation said the evidence had gone. That is the release.

### Added

- **The window keeps each tool's most recent result.** For every tool the
  agent is using, its latest result stays in the window — up to
  `keepLastToolResults` (default **2**) beyond the recent-turns window — until
  the agent calls that tool again, or the person asks something new. The turn
  refuses by name, as `'last-tool-result'`.

  It lives in the one shared refusal engine, so all three shipped strategies
  and any strategy **you** wrote inherit it without an edit — the same way they
  inherited the 9.55.0 anchor.

  It cannot run away with your window. One pin per tool NAME, superseded on
  that tool's next call, so the candidate space is your tool roster and not
  your transcript. A parallel batch is one turn and costs one slot. A pin
  already inside `keepRecentTurns` costs nothing at all. Nothing at or before
  the current request is pinnable, so a new user turn releases the whole
  previous loop. The floor is `1 request + keepLastToolResults pins +
keepRecentTurns turns`, whatever your tool count, iteration count or run
  length.

  And a pin that BLOCKS is worse than a pin that misses: when two consecutive
  visits both removed nothing and both named `'last-tool-result'`, the pin
  releases for one visit and files `observations.standDown: true`. Two blocked
  boundaries is the hard bound, under any strategy.

  `keepLastToolResults: false` (or `0`) reproduces 9.56.0 byte for byte.

- **A drop now says whose results it took.** The authored notice gains one
  sentence: _"Tool results are among them (whats_here, pan_view) — call the
  tool again if you need its output; do not reconstruct ids or values from
  memory."_ The drop is now STATED rather than silent — whether that sentence
  changes what a model does next is **not measured here**: the five archived
  runs have not been re-run with it on, so it ships as an honesty fix and not
  as a performance claim. Tool names are the only caller data that reaches it, and they
  are shape-filtered to a plain identifier and **dropped, never truncated**,
  when they are not one — at most four, then `…`.

- **`WindowRecord.droppedObservations`** — the same fact on the record, full
  and uncapped, and filed even when no notice was authored at all. A removal
  further into the window inserts nothing, so the model is told nothing; then
  the record is the only witness.

- **`WindowRecord.observations`** — what the pin KEPT: each held turn, its
  tool, and its exact character count, plus what the ceiling turned away.
  Subtract the pinned chars from `windowCharsAfter` and you have the window
  this run would have had without the feature. A framework that keeps
  something has to be as visible as one that removes something.

- **An instruction can SAY a run-time number, not only gate on it.**
  `defineInstruction({ promptTemplate })` renders on every action from a
  CLOSED three-word vocabulary — `{{action}}`, `{{actionBudget}}`,
  `{{actionsRemaining}}`:

  > You are on action 25 of 30; 5 remain. Finish what you have rather than
  > start something new.

  Measured, not decorative: given its remaining budget a model wrote _"I have 5
  steps left, enough to finish this properly"_ and landed the task, where
  before it spiralled and produced no answer at all.

  The vocabulary is closed rather than a `(ctx) => string` because of
  **absence**. Given a function, an author writes `${ctx.maxIterations}` and
  ships _"23 of undefined"_, or writes `?? 0` and ships a fabricated
  denominator that nothing — and no model — can tell from a real zero. With
  named slots the library owns absence and applies one rule: if any named fact
  is unavailable, the whole instruction is skipped, by name, as
  `skipped: 'unknown-fact'`. Never a gap, never a fake zero, never the literal
  placeholder. A name outside the three is refused at define time, with the
  three listed in the error.

- **`InjectionContext.maxIterations` / `.iterationsRemaining`** — so a
  predicate can gate on how much room is LEFT rather than on a raw iteration
  number that means nothing on its own. They arrive paired: both, or neither.
  `iterationsRemainingOf` is now the one denominator, shared with the cache
  decision and the request assembly, so the three cannot drift by one.

### Fixed

- **A courtesy message could stop a window strategy dropping anything, for the
  rest of the run.** The drop notice was authored whenever a removal reached
  the front of what may leave, and the whole removal was abandoned when that
  notice was not smaller than what it replaced. But the obligation the notice
  exists for is narrower — the window must OPEN ON A USER TURN — and when the
  message that would become the head was already a user turn (the pinned
  request, or an older turn of a restored conversation) no notice was owed at
  all. Its 245–358 characters were nevertheless allowed to veto a legitimate
  drop; and because the removable span is the longest _contiguous_ run, the
  same verdict came back at every boundary while the window grew without
  bound.

  Reproduced by execution in both shapes before the fix: ten boundaries,
  `removedMessageCount === 0` every time, the window climbing from 10 to 28
  messages and still climbing. The decision is now a ladder — the notice
  naming the dropped tools, else the plain notice, else no notice at all — and
  `'replacement-not-smaller'` fires only when the wire genuinely needs a
  message in that position and none of them fits.

### Changed

- `WindowRefusalReason` gains `'last-tool-result'`. A consumer with an
  exhaustive `switch` over the union gets a compile error — deliberately, and
  precedented by 9.55.0: a reason that appears at run time and nowhere in your
  code is a reason nobody reads.
- The drop notice's text changed (one sentence added). It has always been
  library-authored prose; match it with `DROP_NOTICE_PREFIX` / `isDropNotice`,
  never on the full string.
- A **templated** instruction files one `agentfootprint.context.injected` per
  action where a static one files one per run, because `ContextRecorder` dedups
  by content hash and a template's content really is different every action.
  That is more truth, not less — but a consumer counting rows will see N times
  as many. Note also that a never-cacheable injection truncates the cached
  prefix at its declaration position, so **declare templated instructions
  last**.

## [9.56.0] - 2026-08-19

**Running out of budget now ends with an honest summary, not a fragment.**

From two real recorded runs of the same shape. An agent hit `maxIterations`
mid-task, the loop stopped, and the turn's "final answer" was whatever text
happened to ride the last call:

```text
The third finding focus is not settling… Let me check what's on screen now:
```

That sentence went to the person as if it were the answer. The status said
`ok`. Nothing on the record said the budget had run out, and the model never
got a chance to wrap up.

`maxIterations` is a cap on ACTIONS, and the model does not know it is about to
be hit. Every turn that ends against it ends mid-thought — that is not an edge
case, it is what the limit does.

### Added

- **One last call, with the tools withheld.** When `maxIterations` runs out
  while the model is still asking for tools, the run now spends one more LLM
  call carrying one instruction, and hands back what comes back:

  > Your action budget for this turn is exhausted. Do not request tools. Give
  > your best final answer from what you have: what you completed, what remains
  > undone, and anything the person should know.

  The tools coming off is the mechanism, not a courtesy. A model that was
  offered no tools has nothing to ask for, so the call can only answer — which
  is why it is exempt from `maxIterations` by construction rather than by an
  exception someone has to trust. It costs one call, and it is on the record
  like any other turn: its own `iteration_start`, `llm_start` and `cost.tick`,
  and a `wrap-up` box on the chart.

  On by default, because a half-sentence delivered as an answer is never what
  anyone wanted. `wrapUpAtMaxIterations: false` keeps the old behaviour.

- **`agentfootprint.agent.budget_exhausted`** — the budget ran out, and what
  the run then DID about it: `action: 'wrapped-up'` or `'cut-short'`. It fires
  beside `cost.limit_hit` rather than instead of it — that event reports a
  limit being crossed and stays exactly that; this one is the difference
  between an outcome chip that says "answered" and one that says "answered
  after the budget ran out". It fires for a halting `costBudget` too.

- **`stoppedEarly.wrappedUp`** and **`turn_end.stoppedEarly`.** The same fact,
  where each reader already looks: `agent.stoppedEarly()` for proof after the
  run, `turn_end` for the consumer drawing an outcome the moment the answer
  exists. When a turn was wrapped up, `answerWasEmpty` describes the answer the
  caller actually received — not the fragment it replaced.

### Changed

- A turn that runs out of `maxIterations` with tool calls pending now makes one
  more LLM call than it did in 9.55.0, and its answer is that call's answer
  rather than the last fragment. This is the fix, and it is worth stating as a
  change: an agent whose tests assert the old empty-or-fragment answer will see
  the new one.

  It rides the ITERATION budget only. A halting `costBudget` is unchanged —
  there you capped the money, and one more call would spend past the cap you
  set; an action cap says nothing about a call that takes no action.

  **A turn that never runs out of budget is byte-identical**: same calls, same
  events, same committed state down to the key set. So is an agent with no
  tools to withhold, which never mounts the branch at all.

## [9.55.0] - 2026-08-19

**The window can no longer forget what you asked for.**

Caught in a real recorded run, and quiet enough that it finished the task
anyway. A ten-iteration tool loop under a small window dropped the window's
head at iteration 4 — and the head was the user's own request. The context the
model worked from after that held the tool traffic, this line:

```text
[dropped history — 3 earlier message(s) were dropped from this window at iteration 4 …]
```

…and no statement of the objective anywhere. It finished by momentum. A longer
walk forgets what it was doing halfway through, and nothing in the record says
that is what happened.

It was not a bug in one strategy. On a fresh window the request is the OLDEST
message, and every window strategy here removes the oldest thing first — so it
went first, every time, under all of them.

### Fixed

- **The current request is un-droppable, under every window strategy.**
  `slidingWindow`, `tokenBudget`, `summarizeOldest` (`.compaction()`) — and a
  strategy you write yourself, which inherits the rule without knowing it
  exists. The fix is one refusal in the shared refusal engine, which is the
  only place every strategy has to pass through, rather than three fixes in
  three files that could drift apart.

  Other history drops ahead of it. If the budget cannot hold even the request
  plus the recent turns, nothing is removed at all: the window stays big, the
  record says why, and the run proceeds. That is the right way round — a
  request the model can no longer see is not a smaller context, it is a
  different task.

- **The refusal is named, like every other one:** `'current-request'`, a new
  member of `WindowRefusalReason`, filed on the `WindowRecord` with the turn
  and message index. A window that stayed big because of this rule says so.

- **The drop notice says what it kept.** When a drop stops short of the head
  because the request is sitting there, the authored notice takes the position
  just after it and gains one sentence, in the library's own words:

  ```text
  Your current request is kept — it is still in this window, above this line,
  and no window strategy may drop it.
  ```

  A model reading "3 earlier messages were dropped" and then finding a request
  above it should be told which of the two facts to trust. The sentence appears
  only on that path; every other notice is byte-identical to the one 9.54.0
  wrote.

- **Multi-turn is unchanged.** The anchor is the LATEST thing the person said —
  matched against the message the run was started with. Earlier turns of a
  restored conversation stay exactly as droppable as they were; only the turn
  being executed is protected. Three kinds of `role: 'user'` message are
  written by this library and none of them can become the anchor: a drop
  notice, a compaction frame, and a message an injection delivered.

### Nothing new to learn

No new option, no new door, no new event. `WindowRefusalReason` gained a
member, and a strategy you wrote yourself gets the rule without changing a
line — the bound `planRemoval` refuses that turn, exactly as it refuses an
unanswered tool call. The guards stay where they have always been: a strategy
receives the answer, never the guards.

### Unchanged, and pinned

A window that never dropped the request keeps its exact bytes: same span, same
notice wording, same record, same rebase. There is a test that pins it, and a
test that reproduces the original ten-iteration run and asserts the request is
present in EVERY iteration's context.

## [9.54.0] - 2026-08-19

A mid-call tool report now reaches **the person watching**, not only the
record.

`ctx.progress(payload)` shipped in 9.52.0 and did its half of the job well: a
report from inside a still-running tool call became a typed, stamped, ordered
`agentfootprint.stream.tool_progress` event, on the record and in the
envelope. A consumer integration then found the other half missing, and their
sentence is the whole bug report: `tool_progress` "is emitted by the tool-call
stage and in the event registry, but no status strategy consumes it — so it
lands on the record, not in the browser."

They were right. Nothing projected the event onto the surfaces a person
actually watches, so every consumer kept a hand-rolled side channel for the
live middle of a long call — which is half the feature written twice, in every
integration. The record half and the live half must come from ONE call. Now
they do.

### Added

- **The live status line consumes `tool_progress`.** While a tool call is in
  flight, an arriving report updates the sentence
  `agent.enable.liveStatus(...)` hands to whatever renders your chat bubble —
  and the low-level `attachStatus(dispatcher, { onStatus })` door too. No new
  event, no payload change, no wiring on your side: the arm was missing, and
  this adds the arm.

- **The display contract, stated and narrow.** `payload` is author-defined
  `unknown`, so a surface that guessed at it would put words in a tool's
  mouth. One rule, and it is the whole rule:

  | your payload                 | the line a person reads                          |
  | ---------------------------- | ------------------------------------------------ |
  | `{ message: 'Hop 3 of 12' }` | `Hop 3 of 12` — your sentence, verbatim          |
  | `{ done: 3, total: 12 }`     | `` `walk_graph` reported progress (3 so far)… `` |
  | `'a bare string'`            | the same generic line                            |

  A top-level string field named **`message`** is shown verbatim, trimmed, and
  cut at **120 characters** with the cut stated (`… (+N more)`) — `message` is
  the field MCP's own progress notification uses, so a tool already speaking
  that protocol needs no second vocabulary. Anything else — no `message`, a
  non-string one, an empty one, a bare string payload — gets the generic line:
  the tool's name, that it reported, and how many times.

  What it will **never** do is pretty-print your payload into a human
  sentence. A status line is prose, and a tool's JSON is not a sentence anyone
  wrote. One tool's `total` is hops and the next one's is bytes; a line that
  said "3 of 12" about the wrong unit would be worse than one that said
  nothing.

- **One call, two honest faces.** The structured payload rides to the record
  untouched either way. Adding `message` does not remove your numbers — it
  adds the half a person can read, and `getRecording()` still carries the rest.

- **Parallel calls interleave correctly, keyed by `toolCallId`.** Two calls in
  flight each keep their own progress tally; the newest report wins the line
  and names the call that made it; a call ending removes only itself. That
  last one also fixes a quieter imprecision that predates this release: a
  `tool_end` used to be able to clear a SIBLING call's status, leaving the
  bubble blank while a tool was still working.

- **Commentary narrates the middle**, so recordings replay it: _"The
  `walk_graph` tool reported progress while it was still running."_ The
  teaching voice states the fact and never the payload — the same split the
  Lens teaching view keeps, in the same words.

- **Consumers override by template key.** `tool.progress` (the `message`
  case), `tool.progress.generic` (everything else), or per tool with
  `tool.<toolName>.progress` / `tool.<toolName>.progress.generic` — the same
  map `.thinkingTemplates(...)` already takes.

### Changed

- **A tool that already calls `ctx.progress` will see its status line move
  where it did not before.** That is the fix, not a side effect. If you shipped
  a curated `tool.<toolName>` line and want it to stand through a call's
  reports, delete `tool.progress.generic` from your template map: the ladder
  falls through to `tool.<toolName>` and then `tool`, so a template map written
  before this release renders exactly what it always did, and nothing here can
  blank a bubble that used to have a line in it.

- A tool that never reports is **byte-identical** — same events, same status
  lines, nothing to opt out of. Pinned by a test that says so.

### For consumers

If you hand-rolled a side channel to show the middle of a long tool call —
a second event listener, a parallel status store, a bespoke
`tool_progress` → string renderer — you can delete it. Add `message` to the
payload you were already sending and the line says your words; leave it off
and the line still says the call is working.

## [9.53.0] - 2026-08-19

A tool can return **series, facts, and provenance as typed data** — and a
build gate refuses a triage tool that forgets its caveats.

The ask came from a triage-platform team with seventy tools. The things that
make a tool's numbers honest — the collection interval, whether the values
are counters that must never be summed, when the world was actually measured,
which clusters were NOT collected — were re-written by hand inside every tool
and held in place by code review. Culture like that scales to one disciplined
author, not to a hundred tools. This release makes the caveats **data that
travel with the numbers**, and makes forgetting them a build failure with the
tool's name on it.

### Added

- **`semantic({...})` — the semantic tool-result envelope.** A tool's
  `execute` returns typed data instead of prose:

  ```typescript
  return semantic({
    series: [{ t: '2026-08-19T10:00:00Z', entity: 'fc1/3', metric: 'avg_iops', value: 18450 }],
    grain: { interval: '30m', aggregation: 'avg', is_counter: false },
    provenance: { measured_at: '2026-08-19T10:20:00Z', source: 'InfluxDB SwitchPortStats' },
    coverage: {
      checked: ['fabric A: all 48 ports'],
      notChecked: [{ what: 'the peer fabric', why: 'collector scoped to one fabric' }],
    },
    render: { default: 'table', columns: ['entity', 'value'], sort: 'value desc' },
  });
  ```

  Fields: `series` (measured points), `facts` (typed rows, each naming its
  `entity`), `edges` (typed relationships), `grain` (what one value MEANS —
  interval, aggregation, `is_counter`, what was collapsed), `provenance`
  (when the WORLD was measured — not when the tool ran — and from which
  source), `coverage` (the exact three-list vocabulary `coverage()` and
  `absent()` already speak — absorbed, never duplicated), `clarify` (the
  ask-vs-answer decision as data), and `render` (hints for a UI — the tool
  never renders). `semantic()` refuses at the call site anything the
  vocabulary cannot honor: series without grain, data without
  `measured_at` + `source`, a counter-looking aggregation with `is_counter`
  unstated.

- **Two views of one envelope.** The MODEL reads a compact rendering-free
  projection — the data, the grain, the provenance, the composed
  `not_covered` prose (derived from `coverage`, so the two can never
  disagree), and a static note; it never sees the marker, the `render` hints
  or the three-list coverage detail. The RECORD gets everything: the full
  envelope rides the new typed `agentfootprint.tools.semantics_declared`
  event (registry now 102 events / 22 domains) into recordings and UIs — and
  it is filed BEFORE the tool's `resultCeiling` is measured, so an oversized
  result cannot silently delete its own caveats. The ceiling itself measures
  the projection, because that is what the model would have read.

- **The envelope's `coverage` flows through the coverage channel.** Same
  `tools.coverage_declared` event, same tracked state, and
  `.limitsTravelWithTheAnswer()` appends it to the final answer — exactly as
  if the tool had used `coverage()`. The three result envelopes compose on
  one return value: `{ content: semantic({...}), effects: [...], status }`.

- **`defineTool({ resultClass })` — a declared class for a tool's results.**
  `'triage'` (a health/fault verdict) or `'inventory'` (a population
  listing); closed set, validated at definition (`assertResultClass`), never
  inferred.

- **`check:semantics` — the build gate.** Judges sample results (what your
  mock tools return, dumped to JSON — the `check:tools` convention) against
  the envelope rules and the class rules: a `'triage'` or `'inventory'` tool
  whose sample declares no coverage **fails the build naming the tool and the
  field**; a marker-bearing envelope with faults fails under the recognizer's
  own codes; only provable violations error. Ships as the
  `agentfootprint-check-semantics` bin (exit 0/1/2, `--strict`, `--json`)
  with the unit-tested core (`checkSemantics`, `formatSemanticsReport`,
  `coerceSemanticsCatalog`) on `agentfootprint/observe`.

- Zero-cost when unused: recognition is strict (`af_semantics: true` AND the
  whole rule set), so every value any tool has ever returned keeps its bytes.
  Runnable example: `examples/features/66-semantic-envelope.ts`. Guide:
  docs _Build → Semantic tool results_.

## [9.52.0] - 2026-08-19

A tool can now say **"hop 3 of 12 done"** while it is still working.

Until this release a tool call was one atomic thing on the record:
`stream.tool_start` fired, your handler ran for as long as it ran, and
`stream.tool_end` carried the result. For a tool that finishes in 200ms that
is the whole story. For a twelve-hop graph walk that takes forty seconds it is
one long silence — and from outside, a tool that is working and a tool that has
hung look exactly the same. That was the ask, from a team whose agent walks a
dependency graph: nothing in the framework could report the middle of a call.

### Added

- **`ctx.progress(payload)` — progressive tool results.** One new method on
  `ToolExecutionContext`, callable as many times as you like from inside
  `tool.execute`:

  ```typescript
  execute: async (args, ctx) => {
    for (const [i, hop] of hops.entries()) {
      await visit(hop);
      ctx.progress({ done: i + 1, total: hops.length, hop: hop.id });
    }
    return summarize(hops);
  },
  ```

  Each call files one event, in call order, always between that call's
  `tool_start` and its `tool_end`.

- **`agentfootprint.stream.tool_progress` — the new typed event** (registry
  101 events / 22 domains), payload
  `{ toolCallId, toolName, iteration, payload }`. **The framework stamps the
  identity; the author owns the payload.** `toolCallId`, `toolName` and
  `iteration` come from the dispatch the framework is already holding, so a
  report can never claim to be from another call and a UI can correlate
  without trusting the tool; `payload` is your data, forwarded verbatim (any
  shape, as long as it survives `structuredClone`). Because the name starts
  with `agentfootprint.stream.`, it reaches a browser with no wiring of its
  own — `toSSE(agent)` carries it, and so does
  `agent.on('agentfootprint.stream.*')` — and it lands in recordings, so
  "where did those forty seconds go?" is answerable from an archive months
  later.

  It is **telemetry, not a result**: progress never enters the tool result,
  the history, or anything the model reads. The model still sees exactly one
  result, at the end, as it always did.

  Three rules make it safe to call from anywhere: **always present** (never
  `undefined` — the doors with no event stream to file on, a call served over
  `mcpServe` and the offline `callTraceTool` context, supply a no-op, so one
  handler is safe inside an Agent and outside one); **never fatal** (with
  nothing listening the report is dropped; it never throws, never blocks, and
  never changes what `execute` returns); **zero-cost when unused** (a tool
  that never calls it produces exactly the event stream it produced before —
  no `tool_progress` rows, nothing else moved, pinned by a test that compares
  the two streams row for row).

- **Example: `examples/features/65-tool-progress.ts`** — the twelve-hop walk on
  the mock provider: a live progress bar drawn from
  `agent.on('agentfootprint.stream.tool_progress')`, the framework stamps shown
  beside the author's payload, the same reports read back off a real `toSSE`
  stream, and a second agent whose tool reports nothing proving the feature
  costs nothing to anyone who does not use it.

## [9.51.0] - 2026-08-19

Route edges can now declare their condition as **data** — the map shows it,
the checkup checks it, the recording carries it. This completes the
SkillWalker's third mover: the map is data, entry matchers are data,
tool-outcome route arms are data — the general context guard was the last
opaque function on a route edge.

### Added

- **`guard:` on route edges — guards as data (the `when` predicate's declared
  twin).** `.route(a, b, { guard: { riskLevel: { in: ['high','critical'] },
score: { gte: 0.7 } } })` — conditions over the hop (`toolName`, `result`,
  `status`, `iteration`, `userMessage`, `currentSkillId`) and over the tool
  result's own top-level JSON fields, operators `eq/ne/gt/gte/lt/lte/in/notIn`
  (deliberately footprintjs's `WhereFilter` grammar, mirrored door-locally —
  the skill-graph door's no-footprintjs fence holds), every condition ANDed.
  At most ONE of `when`/`guard` per edge, refused at build naming both; a
  guard composes with `onToolReturn`/`onToolStatus` ("this tool, this
  outcome, AND these conditions"). ONE compilation produces the predicate
  that routes, the serializable `SkillGuardData`, and the evidence evaluator
  — so the three can never describe different guards. What being data buys:

  - **the check-up proves contradictions** — new ERROR
    `guard-unsatisfiable`: crossed bounds (`gt: 5, lt: 3`), `eq` a same-key
    `ne`/`in`/`notIn` excludes, a `status` outside the closed result-status
    vocabulary (the typo `'sucess'` is caught at build), or a guard that
    contradicts the edge's own `onToolStatus`/exact `onToolReturn`. Only
    provable breakage errors; nothing is claimed across keys or about
    runtime values.
  - **the map shows it** — `toMermaid()` captions a guard-only edge
    (`when riskLevel in [high, critical]`) and folds the clause into
    tool/status edge captions; guard-only edges draw as the new
    `SkillEdgeKind` `'guard'`; `SkillEdge.guard` carries the data.
  - **the recording carries it** — `skill.graph_declared` edges gain an
    optional `guard` field (additive), so a viewer drawing the SkillMap from
    a recording shows the guard conditions; route provenance
    (`metadata.skillGraph.guard`) rides too.
  - **every decision leaves evidence** — when a guard decides a hop, the
    move on `context.evaluated.cursorMove` carries the full per-condition
    evaluation: `guard` on the taken hop (verdict `true`) and `guardsClosed`
    for refusals (verdict `false`, at most one per edge per iteration, on
    whatever move resulted — a stay says `score gte 0.7 — saw "0.2" →
failed`). Agents without guards keep byte-identical events.

- **SkillMap & SkillWalker are now the official names.** You declare the
  **SkillMap**; the agent is the **SkillWalker**; the recording carries both.
  `defineSkillMap` ships as a permanent, reference-equal alias of
  `skillGraph` (and `SkillMap` of the `SkillGraph` type) on the
  `agentfootprint/context` and `agentfootprint/skill-graph` doors — both
  names forever, nothing renamed. There is deliberately no `SkillWalker`
  export: the walker is the agent itself, moving the cursor by exactly three
  movers — **llm** (the model picks, the gate bounds), **guard** (your data
  decides, evidence recorded), **linear** (no choice, every time).

- **Example: `examples/features/64-skill-map-guards.ts`** — the whole story
  on the mock provider: one SkillMap with all three movers, a guard that
  passes (evidence on the taken hop), a guard that refuses (the stay explains
  itself), the declared map carrying the conditions, and a contradictory
  guard refused at build.

## [9.50.0] - 2026-08-19

Three recording-surface facts, all born the same way: a debugger built over
recordings (the lens's SkillGraph views, echoed by an external triage-platform
design) kept finding facts the framework KNEW at run time but put on the
record only as prose — or not at all. "Don't parse reachability out of prose"
is the whole release.

### Added

- **`agentfootprint.skill.graph_declared` — the author's map, on the record.**
  Fired ONCE per run, right after the run-configuration manifest, for every
  agent whose `.skillGraph()` mount can state its map: the declared nodes
  (id, kind, catalog description verbatim, caption) and edges (from — `null`
  for the synthetic START — to, kind, caption), straight from the built
  graph and never inferred from runtime hops. Until now a recording's only
  declared-edge source was per-hop `routing[]` provenance, which names an
  edge once it FIRES — a lower bound every topology view had to caption
  "partial" or paper over by making the consumer pass the built graph in by
  hand. Same dispatch discipline as the manifest (the `createExecutor`
  funnel both `run()` and `resume()` share; stated pseudo-stage
  `graph-declared#0`; listener-gated), so a resumed run's fresh runId carries
  its own copy and `recordRun`'s `'*'` subscription archives one in every
  recording. No graph — or a structurally-typed graph that carries no
  `nodes` — files nothing: absent, never guessed. The projection is the pure
  `buildSkillGraphDeclared` (`src/core/agent/skillGraphDeclared.ts`).

- **`context.evaluated.cursorMove.reachable` — where the run could go next,
  typed on the move itself.** The routing gate has always known this set: it
  rebuilds `read_skill`'s menu prose from it every iteration and writes the
  refusal messages from it. Now every cursor move carries it as data — the
  declared hops out of the LANDED cursor plus the open skills, composed from
  the SAME two resolvers the gate uses, so the recorded set can never drift
  from the verdicts (`skill.rejected.allowed` — already typed — is the same
  set, on the refusing iteration). `[]` is a fact (a dead end); absent means
  the mounted graph predates `reachableSkills` and the set was honestly not
  on the record. The prose menu is untouched — this is additive data beside
  it, not a replacement.

- **`stream.llm_start.systemPromptText` — the assembled system prompt,
  opt-in.** New dial `recordSystemPrompt: true` on `AgentOptions` and
  `LLMCallOptions` (the `contextBudget` twin pattern). When on, every
  `llm_start` carries the joined injection pieces byte-for-byte as the
  provider received them — the one artifact the model actually read, which
  recordings until now only described piecewise (each injection is on the
  record; the assembled whole was an honest "not in this recording" card).

  **The default is OFF, and the default is the feature.** The assembled
  prompt is as sensitive as everything injected into it — skill bodies, RAG
  passages, memory recalls, per-user instructions — and it can be large,
  once per iteration. With the dial on it rides into every attached
  recorder, every vendor sink, every `recordRun` recording and every
  persisted envelope: treat those artifacts as being as sensitive as the
  prompt itself. Off, `llm_start` keeps its exact prior bytes —
  `systemPromptChars` still reports the length, and the envelope tests pin
  that the archived BYTES contain no prompt text by default.

- **Example: `examples/features/63-recording-carries-the-map.ts`** — one
  skill-routed mock run archived with `persistRecording`, all three facts
  read back off the JSON file, and the privacy default proven on the
  archived bytes of a second, un-opted-in run.

### Notes for recording consumers (the lens, triage platforms)

- Draw the declared topology from `skill.graph_declared` (`declaredSource:
'recording'` can now mean COMPLETE); filter `from !== null` for
  node-to-node edges, exactly as with a built graph's `edges`.
- Fill per-beat reachability from `cursorMove.reachable` first; the refusal's
  `allowed` and declared-edge folds remain as fallbacks for older eras.
- Render the assembled prompt from `llm_start.systemPromptText` when present;
  its absence still means "not in this recording", which stays the honest
  card for every run that did not opt in.

## [9.49.0] - 2026-08-18

### Added

- **The `sf-` subflow prefix is RESERVED, and the doors refuse it.** Framework
  composition segments are named `sf-*` (`sf-llm-call`, `sf-tools`, `sf-cache`,
  and more each release), and every downstream reader — commentary, step
  graphs, the OTel bridge, trace fingerprints — tells library plumbing from
  consumer structure by that prefix alone. So a consumer branch named
  `sf-billing` never failed like a name clash: it was silently read as
  plumbing and vanished from the very views it was built to appear in. Two
  different facts sharing one namespace with no law.

  `Parallel.branch()`, `Conditional.when()/.otherwise()` and `graph()` node
  ids now refuse the prefix at declaration time, teachingly. `Sequence` and
  `Workflow` are immune by construction (they mount `step-…`, so a consumer
  name can never start the segment) and a pin fails if that ever changes.
  `RESERVED_SUBFLOW_PREFIX` and `isReservedSubflowSegment` are exported so a
  viewer or pattern miner imports the law instead of hardcoding the string.

  The edge stated plainly: a composition that already used an `sf-*` name was
  already broken in every viewer — this turns silent misreading into a
  build-time refusal.

### Changed

- **`exportBugReport` packs a `RecordingEnvelope` instead of a bare recording.**
  The repo has ONE archive contract and several presentations over it; the
  bug-report zip predated the contract and was quietly a second one. The bundle
  now carries `envelope.json` — built through `buildRecordingEnvelope`, never
  re-implemented — and `environment.json` keeps only the facts the envelope does
  not stamp (Node, platform, architecture, the reporter's prose). The producer
  versions it used to repeat are stamped once, in the envelope's `producer`.

  - **Bundle layout version bumped: `manifest.manifestVersion` is `2`.** A
    versioned artifact that cannot say its version is the defect class this
    program exists to close.
  - **New `run` option on both entry points** (`BugReportRunFacts` —
    `{ complete, droppedEvents? }`). These are the two facts a frozen recording
    cannot answer; every other envelope fact is derived per recording from its
    own events, because a bundle may hold several runs and one `runId` or
    `startedAt` stated once cannot be true of all of them. Pass the **handle**
    from `recordRun(agent)` rather than its recording and the drop count is
    read rather than stated — a proven count always beats a stated one.
  - **A missing run fact refuses IN PLACE rather than throwing.**
    `persistRecording` is right to throw: its caller asked for an archive. A bug
    reporter asked for a filable bundle, and losing it because the library could
    not name a start time helps nobody. So that conversation rides as
    `recording.json`, the manifest carries a note naming the fact and the line
    that supplies it, and `BugReportUnit.enveloped` says per conversation which
    shape it got. Nothing is stamped that was not known.
  - **The evidence is never packed twice** — an envelope _or_ a bare recording,
    never both. The zip is store-only, so a duplicated recording is duplicated
    bytes against the size ceiling the trim hints exist to keep a reporter under.
  - The GitHub issue body names the file that is really in the bundle, including
    the mixed case (one conversation stamped, another not).

## [9.48.0] - 2026-08-18

**A run becomes an artifact, and an agent's setup becomes a named thing.** The first
wave of the Pattern program: the recording contract everything downstream will
consume, and the recipe surface that says which composition produced an agent.
Design decisions and their audit are recorded in `docs/design/pattern-program.md`.

### Added

- **`RecordingEnvelope` — the versioned contract a finished run leaves in.** Format
  `RECORDING_ENVELOPE_FORMAT`, wrapping the `recordRun` Recording unmodified and
  stamping only facts that are TRUE:

  - Identity is inherited, never derived — an anonymous run yields an envelope with
    the identity keys genuinely absent, and a session id is never promoted to a
    principal.
  - `droppedEvents` is read off the live recorder, which really counts cap drops; a
    bare `Recording` carries no count, so it is **refused** rather than reported as
    0 — "we did not look" must not collapse into "none were dropped".
  - `startedAt` is refused once the cap has discarded the head of the stream (the
    earliest retained event is when recording overflowed, not when the run began).
  - A recording spanning two runIds is refused — `resume()` mints a fresh runId, so
    filing the archive under the first would mislabel it.
  - Privacy v1 is `'full'` only; `'structure-only'` and `'redacted'` are refused BY
    NAME before the sink is reached — a false `redacted` label gets bytes handled
    with less care than bytes that admit they are raw.

- **`persistRecording` + `RecordingSink` + `fileRecordingSink`.** One JSON file per
  envelope, written atomically. The filename derives from the runId over an
  asserted safe domain — refuse-by-domain, the one acceptable alternative to the
  shared encoder, because a refusal cannot collide. Uppercase is refused outright:
  macOS and Windows fold case, the exact defect class fixed in 9.44.0.

- **`defineAgentRecipe` + `AgentBuilder.recipe()` — a declared, versioned
  composition over the existing builder.** No plugin system, no lifecycle, nothing
  hidden: recipes apply in declaration order, a duplicate tool or injection fails
  at build time naming BOTH sources, and the run manifest gains additive
  `recipes: [{ id, version }]` rows under the manifest's names-only discipline.
  Ids are plain names (no version suffixes — the version field exists); versions
  are strict SemVer, refused with the specific mistake named, never repaired.
  New door: `agentfootprint/recipes`.

- **Docs and examples for both**, including `examples/features/62-recording-envelope.ts`
  (runs end to end, zero network, and teaches two refusals on purpose) and
  `monitor/recordings.mdx` — which documents the refusals as the feature.

### Fixed

- **The capability index was missing `exportBugReport`** — and that omission is why
  the repo quietly grew three producer-owned archive shapes with their relationship
  stated nowhere. The row exists now, and the observability README states the rule:
  the envelope is the contract; `Trace` and the bug-report zip are presentations
  over it.

- **The committed TypeDoc tree had drifted 282 files behind** and was regenerated
  as its own deliberate commit.

### Notes

- `producer.footprintjsVersion` stamps honestly-`unknown` until your installed
  footprintjs is ≥ 9.15.1, whose exports map yields `./package.json`.

## [9.47.0] - 2026-08-17

**The answer gets a typed half, two more identifier folds close, and an index so
none of this gets rebuilt.**

### Added

- **`DecisionValue` — what a person CHOSE, not just whether they approved.**
  `AskComponent` gave the QUESTION a typed half in 9.24.0: which registered screen
  component collects the answer, and the props it renders with. The ANSWER never
  got one. A decision was `approved` plus a free-text `note`, so a picked row or a
  brushed date range had to travel as PROSE for the model to parse back out —
  precisely the failure the typed ask exists to prevent, surviving on the return leg.

  Shaped like the ask for the same reasons: `kind` is consumer vocabulary, `value`
  is small inline JSON because it rides the resume, and `from` is the artifact the
  choice was made AGAINST — a row id means nothing alone and something in a dataset.

  **`coverage` is the field people skip.** Somebody who filtered 5,000 rows to 3
  and picked one has not chosen from 5,000, and without it that pick is
  indistinguishable from an informed choice over the whole set. That difference is
  the entire value of a human in the loop. It is `coverage()` applied to the
  person, because on that turn the person IS the tool. It rides a DECLINE too:
  "none of these" is an answer with coverage, not the absence of one.

  Optional everywhere — an existing approve/decline is byte-identical.

- **`StaleDecisionError` — an answer must be about the thing that was asked.**
  When the ask pinned a `propsRef` and the answer names a different artifact,
  `resume` refuses by name, naming BOTH so a reader can tell which way it drifted.

  The cause is ordinary: time passes. A refresh lands, a filter moves, rows
  re-sort — and "the third row" now names something else. Accepting it resumes the
  run with a value the person never chose, and nothing downstream can notice: the
  id is well-formed, the type checks, the loop continues. Silent unless BOTH sides
  claim an artifact, because inventing that claim would refuse good answers.

- **A capability index in `CLAUDE.md`, keyed by what you would CALL a thing.**
  Three times in one day a capability was re-proposed after it had shipped — the
  typed-HITL ask, element bindings by role and name, the artifact-kind renderer.
  `CLAUDE.md` was a feature-work map, organised for CHANGING the library, and
  nothing was organised for "does this already exist". A reader searches with the
  words THEY have, finds nothing, and designs what is already there.

  `test/docs/capability-index.test.ts` fails if a row names a file or symbol that
  does not exist — it caught three wrong rows on its first run. The table can go
  stale by OMISSION and can never LIE about what it names; that limit is stated in
  the test rather than papered over.

### Security

- **The AgentCore code adapter stopped shipping raw identifiers to a vendor
  console.** `sessionName` carried the RAW tenant, RAW principal and RAW hosting
  session id to AWS's control plane — and so to its console, CloudTrail and logs —
  on every session open. `hashSessionKey` has existed in this repo all along with
  the docstring "Publishing it on the event wire would put a user identifier into
  every exporter's payload": two opposite decisions about one string, and the wire
  got the wrong one.

  Now `af-${hashSessionKey(key)}`, byte-identical to the `keyHash` already
  published on `agentfootprint.tools.session_closed`, so the console-to-trace join
  the old comment promised actually works instead of being a claim. The label loses
  its human-readable form, which is right: that form was lying whenever two keys
  folded.

  The reported fold in the same line is real and crosses no boundary — the address
  is the service-assigned `sessionId` and nothing looks a session up by name.

### Fixed

- **Two staged files no longer become one.** `safeFileName` folded illegal
  characters onto one filler, so staging `a/b.csv` and `a_b.csv` wrote ONE path:
  the second clobbered the first, the manifest pointed both logical names at it,
  nothing threw, and reading `a/b.csv` returned the other file's bytes. Wrong data
  handed to generated code with no signal — how a computation quietly answers about
  the wrong dataset.

  Fixed with the two-arm shape used elsewhere here: an already-legal name under the
  cap is returned BYTE FOR BYTE, so every `codeRunnerTool`-derived name lands where
  it did; anything else becomes `_enc_` plus an escaped form. The AgentCore code
  adapter had no behavioural test at all before this release.

## [9.46.3] - 2026-08-16

### Fixed

- **`checkSkillContract` threw instead of reporting when the tool list it was
  handed had a hole in it.** A non-string in `knownToolNames` reached a regex
  helper and produced `Cannot read properties of undefined (reading 'replace')`,
  naming neither the skill under check nor the missing tool — so every instinct
  led to auditing the skills, which were all fine. The hole is in whatever
  COMPOSES the tool list, and that is now what the message says.

  It reports rather than skipping, because a tool with no `schema.name` is a real
  defect somewhere and swallowing it would hide it; and rather than throwing,
  because a contract checker that dies without naming the contract is the exact
  failure this module exists to catch in other people's graphs. Reported from the
  field on a graph that had just grown its twentieth skill.

## [9.46.2] - 2026-08-15

### Fixed

- **`codeShape` erased the operation name, which was the entire signal.** It
  replaced every callee with a placeholder, so `groupBy(rows, x)` and
  `sortBy(rows, x)` hashed identically and the backlog the shape hash exists to
  build collapsed into one meaningless bucket. Callee names are kept now; a
  function name is code, not data, and the data lives in the literals and
  variable names that still go.

  Found by the clean-room probe on the published package, using a minimal pair.
  The unit test missed it because its two examples differed in their tails as
  well as their operation — a test that varies more than the thing under test
  cannot fail for the right reason, which is the same defect this release train
  has now found in a conformance case, a listing check and a normaliser.

## [9.46.1] - 2026-08-15

### Fixed

- **`codeShape` and `codeRunsOf` are exported.** 9.46.0 registered the event and
  shipped the normaliser it is built on without a public door to it, which the
  clean-room probe caught and the test suite could not: the tests import from
  source. The event alone covers runs from now on, because it carries the hash —
  but the whole point of the loop is reading recordings you ALREADY have, and
  normalising months of logged `args.code` needs the same function that produced
  those hashes. Shipping the second half of a loop is not shipping the loop.

## [9.46.0] - 2026-08-15

**Three gaps that had all been written down and left open.** Each one existed as a
comment naming itself — which is the small lesson here: a comment that names a gap
is not a mechanism for closing one.

### Added

- **`agentfootprint.tools.code_run` — every program a model writes is a request for
  a tool nobody built.** The code has always been in the recordings as an ordinary
  tool argument, so the loop worked for anyone who knew to go looking. This makes it
  discoverable, and the payload carries what makes it countable: language, how many
  artifact inputs were staged, output size, whether the output was truncated, and a
  **normalized shape hash**.

  `codeShape()` reduces a program to its CALL SHAPE — strings, numbers, comments and
  identifier names removed, operations kept. `groupBy(rows, 'wwn')` and
  `groupBy(items, 'serial')` hash to one value, so a totals-then-threshold written
  eleven times this month is one number eleven times rather than eleven clever
  answers. Rank the shapes by frequency and the top of the list is a build queue in
  build order; two things outrank frequency, and both are about consequence rather
  than volume: shapes on the path to a verdict somebody acts on, and shapes that
  re-derive an identifier a tool should have returned directly.

  **The code itself is never on this channel.** Generated code quotes the data it was
  handed, and this payload reaches every attached exporter — so the program is the
  one part of a code run that must not travel. Same rule the tool-session events
  already follow by publishing `keyHash` and never the key. The load-bearing test
  asserts a script containing an address and an email produces a shape holding
  neither.

  Emitted from the dispatch loop rather than from the tool, because that is the layer
  holding `typedEmit`; the facts are left on a symbol-keyed map under the
  `toolCallId` and taken there, since two tool calls in one iteration run
  concurrently and a single "last run" slot would file one call's facts under the
  other's name.

- **`agentfootprint.validation.*` and `agentfootprint.reliability.*` wildcards.**
  `dispatcher.ts` has carried the sentence "`validation.*` and `reliability.*` are
  still missing here" since 9.4.0, when the credential domain taught that a wildcard
  ships WITH its domain. Both domains emit; both were subscribable one event at a
  time; neither could be watched as a group — so an operator asking "is anything
  failing validation?" had to know every member name in advance.

### Fixed

- **`toolSessionKey` composes through the shared identity encoder.** It joined
  `tenant`, `principal` and the session or run id with `/` and `=` markers and no
  escaping, so a value could donate a marker and shift a boundary: tenant `acme/p=bob`
  with principal `x` composed the same string as tenant `acme` with principal
  `bob/p=x`. The absent-versus-`_` pair was the same collision `identityNamespace`
  was fixed for in 9.40.0, one module over, and `encodeIdentityField` is the encoder
  that fix produced.

  This key holds a live interpreter sandbox, so two identities that produce one key
  share a filesystem. **No reachable attack was found** — the markers are
  prefix-anchored, so a caller controlling only the trailing field cannot shift a
  boundary into somebody else's key. Closed anyway: "no attack today" is a property
  of the current call sites, not of the function.

## [9.45.0] - 2026-08-15

**Two more of the same defect, and the release that carries a correction 9.44.0 missed.**
An independent audit of the 9.44.0 GCP work found a documentation claim that had shipped
only halfway, a ledger that contradicted its own source of truth, and two live ownership
defects in the Agent Engine adapter. All four are here.

### Security

- **A losing writer could append into the winner's session.** `persist` reads who owns a
  session before writing, and `signedBy` answered `undefined` for two different facts:
  there is no session, and there IS one whose owner cannot be read. Before a create those
  are the same thing. They are not the same after another writer has created the session —
  and given a service that exposes a created session before its fields settle, the loser
  saw "no owner", appended, and left `ownerOf` naming the winner while `hydrate` returned
  the loser's conversation. That is the split brain closed across four stores in 9.37.0.

  `signedBy` now refuses when a session exists and its `userId` is unreadable. The service
  REQUIRES `userId` at create and an anonymous conversation carries an explicit placeholder,
  so an absent `userId` means the row is not readable yet — not that nobody owns it. This
  is the port's own `unreadable-is-not-absent` law applied to OWNERSHIP instead of to the
  conversation, because only one of those two answers is safe to write on. The refusal is
  transient and says so.

  **The first attempt guarded only the ALREADY_EXISTS branch and the regression went on
  failing**, which is how the reasoning was corrected: the losing writer never reaches that
  branch. The session exists, so its append succeeds on the ordinary path long before any
  create is attempted.

- **`safeResourceId` mapped two different session ids onto one conversation.** The function
  was idempotent ON PURPOSE — its own output came back unchanged — because
  `listByUser` answered with composed resource ids and callers fed those to `hydrate`.
  That property IS an arm overlap: if `f(x)` is a fixed point then `x` and `f(x)` are two
  different ids addressing one conversation, and the second is a value the store itself
  published. A caller who adopted a listed id as their own session id landed on somebody
  else's conversation.

  The fold's output is now excluded from the pass-through arm, and nothing needs the fixed
  point any more: a session carries the caller's OWN id beside its envelope
  ({@link SESSION_ID_KEY}), and the listing answers with that. Sessions written before this
  release do not carry it and fall back to the resource id, exactly as before.

### Fixed

- **The TTL correction that 9.44.0 did not actually ship.** The measurement — appending an
  event does not renew `expireTime`, so a conversation expires on the clock its FIRST turn
  started — landed after the 9.44.0 tag. It is in this release, and it was also incomplete:
  it had been written into the `ttl` option and nowhere else, so two other places went on
  saying the question had not been measured. One of them was `retention().enableWith`,
  which is not a comment but a **string this library returns to callers** as guidance on
  configuring expiry.

- **The status ledger claimed one declared limitation where the battery declares two.**
  `docs/ADAPTER_STATUS.md` omitted the limitation a field trial had confirmed — the service
  pins `userId` at create, so an anonymous-first session cannot move into a signed user's
  listing. The cause was a correction that moved a mis-attributed limitation OFF the wrong
  adapter and never ONTO the right one: deleting a true fact rather than relocating it.

### Added

- **`an-id-the-store-hands-back-is-not-a-second-address`** — a sixteenth conformance case,
  and the generic form of a collision a fold-pair table cannot express. A pair like
  `a_b`/`a-b` catches a store that folds punctuation; nothing catches a store whose mapping
  is idempotent, because only the store knows what its `f` is. So the case does not guess:
  it reads the id the store's own listing hands back and tries to use it as a second
  address. A caller doing that is the ordinary case — a sidebar lists conversations and
  opens the one that was clicked.

- **A regression test for the ownership race**, with a control. The control is the half that
  makes it worth having: with the winner's fields readable the re-check works, so the
  recorded failure was the adapter's assumption rather than the double's licence.

- **A test that the status ledger names every declared limitation.** It reads
  `docs/ADAPTER_STATUS.md` and fails if any declaration the harness makes is missing from
  it. A declaration a reader cannot find in the ledger is a limitation nobody will ever
  argue with.

### Status, stated plainly

`agentEngineSessions` is **not** promoted. The ownership race is closed in code and pinned
by a deterministic regression, but a double cannot establish that the window exists on the
real service, nor that closing it here closes it there. It stays at
`contract-shaped and tested` until a fresh live trial says otherwise.

## [9.44.0] - 2026-08-15

**Two cross-tenant collisions, and the batteries that certified them green.** Both
defects are the same shape as the two fixed in 9.37.0 and 9.40.0 — a mapping that
turns two different identifiers into one key — and in both cases a shipped
conformance battery passed the broken code, because each battery's collision case
varied the identifiers in a dimension the defect did not live in. The fixes are
here; so are the missing cases, and a test that proves those cases catch what they
claim to.

### Security

- **Artifact scopes no longer share a directory with their own case variants.**
  `scopeSegment` encoded a scope field with `encodeURIComponent`, which is
  injective as a STRING — and macOS/APFS and Windows/NTFS are case-insensitive by
  default, so a tenant of `Acme` and a tenant of `acme` encoded to two distinct
  segments and landed in **one directory**. Since an artifact ref is a content
  address that both scopes then resolve, the neighbour could `get`, `list` and
  `delete`. All three scope fields were affected, and all three are caller data:
  `standingAgent` sets `principal` from the request's user id and
  `conversationId` from the caller-chosen session header, so flipping one letter's
  case was enough to enumerate a neighbour's refs and then read their bytes.

  An uppercase ASCII letter is now escaped before `encodeURIComponent` (`~` as the
  escape, doubled when it appears in the input — the same escape-the-escape shape
  `identityNamespace` uses, so a decoder is a left inverse). Every output letter is
  then lowercase except the hex inside `%XX` escapes, which is always uppercase and
  never otherwise preceded by a lone `%`, so no two distinct outputs can differ by
  case alone.

  **What re-keys:** only a scope field containing an uppercase ASCII letter. Every
  all-lowercase field — including one carrying `/`, spaces, dots or non-ASCII —
  encodes to exactly the bytes it did before. A field that DID contain an uppercase
  letter was sharing a directory with its case variants on two of the three major
  platforms, which is the condition being fixed.

- **`agentCoreSessions({ store: 'memory' })` no longer folds distinct session ids
  onto one conversation.** `safeSessionId` replaced every character outside
  `[A-Za-z0-9_-]` with `-`, so `a:b`, `a/b` and `a-b` were one storage key. The id
  arrives in `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id`, making it caller data,
  so this was a session someone could choose their way into. Logged as "Known, not
  fixed" in 9.42.0; fixed now.

  The mapping encodes instead of sanitising, in two arms whose outputs cannot meet:
  an id already legal (and not already claiming to be an encoded one) is returned
  byte for byte, and anything else becomes `_enc_` plus an escaped form. Past the
  provider's 99-character ceiling no mapping can stay injective, so that arm ends
  in a SHA-256 digest of the whole raw id rather than the previous 32-bit FNV-1a —
  a 32-bit hash over caller-controlled input is a collision somebody can go and
  find.

  **What re-keys:** only ids containing a character outside `[A-Za-z0-9_-]`, ids
  over 99 characters, or ids beginning `_enc_`. Every UUID and every `user_123`
  keeps the exact key it had. Ids in the first group were already sharing a key
  with everything that folded onto them.

### Fixed

- **A session id that names a prototype member is data, not a lookup.**
  `agentCoreSessions({ store: 'session-storage' })` keeps sessions as properties of
  one JSON object, so `hydrate('constructor')` on a store that had never been
  written to reached `Object.prototype` and refused BY NAME — telling a caller their
  session held "a STORED conversation this runtime cannot read", permanently, for
  any id naming a prototype member. Now an own-property test. Writes were always
  fine; this only ever bit the never-written case.

- **`agentfootprint.resilience.output_fallback_triggered` and
  `…output_canned_used` are registered events.** Both have been emitted since
  8.18.0 through a loosely typed `emit(type: string, …)` parameter that reached the
  dispatcher via `as never` — a cast that erased the only check that could have
  objected — so neither had a registry entry, a payload type, or a wildcard. They
  are now in `AgentfootprintEventMap` and `ALL_EVENT_TYPES`, the emit site is typed
  against the registry rather than taking a bare string, and
  `agentfootprint.resilience.*` joins `DomainWildcard`: the credential-domain lesson
  from 9.4.0 says a wildcard ships WITH its domain.

### Changed

- **The session battery's collision case is a fixture of fold BUILDERS, not
  suffixes.** It previously drew its base twice from a counter, so the two ids of a
  pair differed in their prefix as well and no fold could collapse them — the case
  could not fail. Rebuilt around one shared base and thirteen fold classes,
  including two whole families it could not previously express: mappings that
  discard the HEAD (a store keying on a last path segment, because its backend
  refuses `/` in a key) and Unicode normalisation (NFC/NFD, NFKC, non-ASCII case
  folding, zero-width stripping).

- **A store that REFUSES an id it cannot hold faithfully is conformant.** The
  collision case persisted ids of 1000+ characters and required every one to
  round-trip, so a store with an honest column width — one that raises rather
  than truncating, which is the SAFE behaviour, because truncation is exactly how
  two ids become one — was reported non-conformant for doing the right thing. Its
  only escape was declaring the whole case and losing every fold check with it.
  The law is now "every id the store ACCEPTED comes back as its own
  conversation": a write that never happened collides with nothing. Found by a
  field trial running this battery against a store with `NVARCHAR(400)` session
  ids.

- **The artifact battery gained the case pair it never had**, in all three scope
  fields. It carried pairs for separators, absence markers and pre-escaped values,
  and ran 106 green for months with the leak above live in the same process.

- **`list-by-user-is-newest-first` is its own case.** The paging case was holding
  three separate laws — no cross-user rows, complete paging with an honest cursor,
  and newest-first order — and a declaration is all-or-nothing, so a store with one
  real ceiling had to forfeit checks it passes, including the cross-user leak check.
  One case, one law. Its check now runs twice with the confounders inverted, because
  ids count up and writes are ordered: in any single arrangement the newest row is
  also the later-written row and the larger id, so a store that never reads the
  timestamp lands on the right answer for the wrong reason.

  `agentEngineSessions` **declares** it: the service orders a listing by its own
  immutable `update_time`, which the adapter also reports as `savedAt`, so a
  timestamp inside the envelope cannot influence the order.

### Added

- **`SECURITY.md`** — private reporting route, what to expect and when, and a
  finding-class list specific to this library (isolation, scope escape, ownership,
  redaction, evidence integrity) plus an explicit list of what is NOT a
  vulnerability, so nobody spends a weekend on a report that comes back "working as
  designed".

- **An adapter-trial issue form** that asks for `formatConformanceReport()` output
  rather than prose, and treats a declared provider limitation as a first-class
  outcome rather than a failure to excuse.

- **`docs/ADAPTER_STATUS.md`** — every hosting adapter, its rung
  (`contract-shaped and tested` → `field-validated` → `field-corrected`), and the
  rule that any rung above the first cites the evidence that earned it. No adjective
  without a link.

- **A test that the collision case catches what it claims to** — twelve stores,
  each non-injective in exactly one way, each of which must fail it, plus a control
  that must pass so the file cannot be satisfied by a case that always fails. It
  caught two of the new fold pairs being decorative on its first run: a pair meant
  to catch a tail-window store is useless unless its difference sits far from the
  end.

## [9.43.0] - 2026-08-15

**Five primitives that came out of field use.** A review of a live incident-triage
agent found its operator had independently invented things this library had no
answer for. These are ours, built from that evidence. The credit is the field's.

### Added

- **`absent()` — an absence that names its own coverage.** A tool that finds
  nothing returns a value saying WHAT was searched, what was not, what can never
  be covered, and that a retry returns the same. It carries a reserved key the
  framework recognises, because a convention cannot set a status, stop a loop, or
  keep a value out of the evidence corpus.

  It delivers a seventh `ToolResultStatus`, `'absent'`, routable by
  `onToolStatus`. Folding it into `'failure'` IS the confusion this exists to
  prevent; folding it into `'success'` leaves nothing to route on. The direction
  of error is the point: a nothing-found read as an outage sends someone to
  investigate a collector, and an outage read as nothing-found declares a system
  healthy that was never checked.

  **And it opened a hole in the evidence gate, which is why the gate now indexes
  an absence's COVERAGE ONLY.** An absence says what was looked FOR — which
  quotes the model's own arguments. Indexed whole, an invented identifier becomes
  grounded by the one operation that proves nothing about it. A failed lookup is
  the cheapest laundering machine there is, and this primitive would have made it
  cheaper.

- **`coverage()` — a ledger of what a clean answer does not rule out.** Sibling
  of the evidence gate: the gate catches invented VALUES, this catches unstated
  LIMITS. Recording is unconditional. Survival is enforced by construction and
  opt-in — `.limitsTravelWithTheAnswer()` APPENDS the folded block, so the model
  does not write it and therefore cannot drop it. What is deliberately NOT
  enforced: any check that the model stated its limits in prose, because deciding
  what counts as "stated" needs a second model, which is the one thing a guard
  here may not depend on.

- **`neverRoutes` — pin that a phrase must route NOWHERE.** Over-triggering is
  the failure that hurts: a skill claiming a turn it has no business in shapes
  the whole answer with the wrong instructions and the wrong tools. Declared at
  the GRAPH level, because a phrase pinned to a skill is deleted the day that
  skill is — which is exactly when a graph gets re-partitioned and over-triggering
  appears. It asserts that no declared start rule claims the phrase, and it ships
  its own boundary: intent scorers and `read_skill` are not covered, said on the
  checkup rather than left implied.

- **Three partition advisories in `graph.checkup()`.** A graph's partition is its
  highest-leverage design decision and a poor one is visible from names alone:
  tools in a skill all sharing a prefix, many skills declaring almost no edges, a
  one-tool skill with a body that adds nothing to its schema.

  The calibration is the feature. The verb exclusion came from surveying real
  tool names — `get`, `read`, `issue`, `lookup`, `run` are the commonest first
  segments and together outnumber every system prefix, so without excluding them
  the check would fire on the best-named skills here. And all three are silent
  below five skills, because without that floor they fired on small fixtures and
  would have taught authors to stop reading the checkup. Advisories, never
  errors: every one has a legitimate exception, and each message states the fact
  and names the design it is also consistent with.

- **`runbookFromDir` — routes on disk.** The ingest door carried prose, tools and
  steps but not the edges, so a runbook still had to be hand-wired. A file may
  now declare its exits using the two DATA guards a route already has — a tool
  return and a status — and refuses a `when` predicate by name, because that is
  code and nothing in these files is ever evaluated. The file PICKS, it never
  DEFINES: an unresolved id fails the whole load rather than producing half a
  graph. `skillsFromDir` REFUSES a file declaring routes and points here — a door
  that silently dropped routing would hand back a graph you believed was on disk.

### Fixed

- Two unanchored `coverage` rules in `.gitignore` matched `src/core/agent/
coverage/`, so a new source directory would have been absent from every clone.
  Anchored to the vitest report directory, with a comment naming why.

## [9.42.0] - 2026-08-15

**Comparing strategies with statistics, and retention on the session port.**

### Added

- **`compareStrategyArms`** — the counterfactual engine can now compare
  STRATEGIES, not only ablate context sources. It is a sibling type rather than
  a fifth `AblationSpec` arm, and the reason matters: `bisectCulprits` runs
  ddmin over SUBSETS OF REMOVALS, and substitutions do not compose that way —
  a scorer swap plus a window swap is a third arm, not a bigger removal. A
  union arm would have printed "minimal culprit set = {scorer swap}", and an
  arm is an alternative configuration, not a culprit. Every statistic is
  shared; no search machinery is. `ablation.ts`, `bisect.ts`, `rerun.ts` and
  `localize.ts` are untouched.

  The placebo band does not transfer and is not faked. Leave-one-out needs a
  POPULATION of peer suspects; two arms are not a population, so leaving one
  out leaves an experiment rather than a control. The arm tier uses the inert
  intervention that does exist — re-running the same configuration — on two
  axes, and the band deliberately does NOT gate when a custom comparator is
  supplied, because vetoing a real decision flip with an embedding statistic
  would suppress a true finding.

  **It catches an experiment that lies about itself.** A runner that believes
  it varies the model but builds the same one for both arms produces flips 2/2
  — the numbers say there is a difference — and the run manifest from 9.41.0
  contradicts the declaration, so the verdict is INCONCLUSIVE naming the facet.
  Manual bookkeeping would have shipped that as a finding. Absence in a
  manifest is a contradiction, not a wildcard.

- **`SessionLifecycle.retention?()`** — optional and feature-detected, like
  `listByUser`/`ownerOf`, reached through `sessionRetention(sessions)`.

  A discriminated union on `deletedBy`, because both arms are real: a store
  that holds its own rows SWEEPS (`forgetOlderThan`, clocked by the envelope's
  own `savedAt` rather than wall time, owner index deleted with it, bounded);
  a managed backend states its POLICY and deletes nothing on our behalf,
  because a query plus one billed delete per row duplicates what the service
  does free — and a store answering "0 deleted" from a backend that deletes
  plenty lies by omission.

  The shape rejected, and recorded as rejected: an expiry argument on
  `persist`. It demands something of the one REQUIRED method, and it cannot be
  feature-detected — `typeof persist === 'function'` is true whether or not a
  third argument is read — so a store that ignored it would keep everything
  forever while its caller believed retention was on.

  Sweep: `memorySessions`, `sqliteSessions`, AgentCore session-storage. Policy:
  `firestoreSessions`, `agentEngineSessions`. Honestly cannot: AgentCore memory
  mode, which has no delete on its surface and refuses by name.

- **Firestore gains `expiresAt`**, a Timestamp a native TTL policy can act on,
  beside `savedAt` rather than replacing it. Firestore orders values BY TYPE
  before value, so converting would have split old and new documents into two
  blocks and silently reordered somebody's history. Every stored document stays
  readable, listable and cursorable — pinned by a test seeded with a pre-9.42
  row. Old documents do not start expiring; an active conversation gains an
  expiry on its next turn, an abandoned one must be deleted by hand. Said in
  the header and the docs, and the docs now carry the operator command the
  code itself prints rather than a promise.

### Fixed

- `agentCoreSessions` is enrolled in the session conformance battery, in both
  modes. It was a fifth shipped store appearing zero times in it, undeclared.
  It needed no declarations, and the pass/not-applicable splits are asserted as
  NUMBERS so a proof cannot decay into a skip. A roster guard now fails if any
  exported `*Sessions(` factory is missing from that file.

- The `firestoreSessions` status lines said "nothing here has been run against
  a live Firestore" after a trial ran seven of eight areas live — including a
  third stale copy the audit had not named. Promoted honestly, naming the
  eighth area the trial did not cover: the foreign-signer refusal, which
  post-dates it and is held by tests and by nothing in the field.

### Known, not fixed

- `safeSessionId` (`src/adapters/hosting/agentcore.ts`) is non-injective —
  the same defect class 9.40.0 fixed in `src/adapters/memory/agentcore.ts`,
  and worse here because the session id arrives on a caller-controlled header.
  The conformance battery cannot see it: its collision case draws both ids
  from a counter, so they differ in their prefix and the fold never shows.
  Fixing it re-keys stored sessions. Next release.

## [9.41.0] - 2026-08-15

**Comparing strategies, and proving a store.** An audit asked whether this
library can support research-grade work — run one workload under several
strategies and collect comparable data. The verdict was that swapping a
strategy is easy and ATTRIBUTING an outcome to it is not: 93 typed events with
a correlated envelope, real numbers, and a genuine counterfactual engine in
`context-bisect` — but no record of what varied. This release adds the join
key, and the proof obligations for the second port.

### Added

- **`agentfootprint.agent.run_configured`** — one event at run start naming the
  strategies in play: provider and model, memories with their declared
  strategy / retrieval / embedder, window, skill-graph routing and scorer,
  evidence-gate posture, artifact dials. Because `runId` is already stamped on
  everything else, that single event turns N runs into N labelled ARMS with no
  workload type, no arm type and no sweep runner. Fired from the one funnel
  `run()` and `resume()` share, so a resumed run is its own arm.

  **Names and ids only, never values.** A manifest that leaks an endpoint is
  worse than no manifest, and a test configures obviously-secret values and
  asserts none appears. Absence is omission — never `'unknown'`, never a
  guessed `'default'`.

  What it deliberately does NOT name, because naming it would be a lie: which
  memory or artifact store (the ports declare no id, and shipped stores are
  factory-returned literals, so one arm would be labelled and another not);
  `entryBy()` scorers (the graph hands the Agent a bound function, never the
  scorer); and the "effective" model, since `.configure()` resolves after this
  point and calling a consumer's resolver twice per run to find out would be a
  cost the event does not justify. It names the starting model plus
  `modelOverrides`, and leaves per-call truth to `llm_start`.

  Gated on a listener check and pinned by SIZE rather than absence: a
  fully-configured agent serializes under 1,000 characters.

- **`runArtifactStoreConformance`** — the `ArtifactStore` port now ships its own
  proof obligations, the way `SessionLifecycle` has since 9.37.0. 19 cases, run
  against all five in-tree stores. Exported from the door the port ships from,
  so a third party writing their own store can prove it.

  The battery imports NO test framework — a case throws to fail — which is what
  lets it run under any runner or none. That rule is itself a test now: one
  walks all of `src/**` looking for a test-framework import.

  Three non-pass outcomes stay distinct: `not-applicable` (an optional member
  is absent), `declared` (cannot satisfy, BY NAME with a reason, and the case
  STILL RUNS so a declaration that starts passing is reported STALE), and
  `failed` — which includes needing a harness hook nobody supplied and nobody
  declared. Nothing can quietly disappear.

  It reads the STORE's clock through a probe artifact rather than `Date.now()`,
  because a case computing expiry from wall time would silently never expire
  anything on an injected-clock store, and pass forever.

  Two findings it surfaced rather than smoothed: `inMemoryArtifacts` must
  declare the digest-corruption case, because its payloads live in a
  closed-over Map with no seam to stage corruption through; and `fileArtifacts`
  cannot hold a scope value longer than a filesystem component, where the
  sibling session battery uses 1,000-character values freely.

  Migration, not duplication: the old test-only suite became fixtures, and its
  laws moved into the battery. A second copy of the same laws is exactly the
  drift this exists to prevent.

- `MemoryDefinition` gains optional declared `strategy`, `retrieval` and
  `embedderId` — additive, and what makes a memory row worth grouping on.

## [9.40.0] - 2026-08-15

**Two identity bugs, one of which had been paying out zeros since v2.8.**

### Fixed

- **`agentcore` actorId and sessionId were not injective.** `safeId` slugged
  every character outside `[A-Za-z0-9_-]` to `-`, so `a.b`, `a/b` and `a b`
  were one actor; and tenant and principal were joined with `_`, so `a_b`+`c`
  collided with `a`+`b_c`. Two different scopes became one actor at the AWS
  session boundary.

  Same law as the 9.37.0 identity encoder, different alphabet — and the
  difference is the point: AgentCore ids admit `/` and `:` but NOT `%`, which
  is precisely what that encoder escapes to, so it could not be reused. Every
  code UNIT outside `[A-Za-z0-9-]` now becomes an introducer plus four hex
  digits, fixed width, so a decoder is a left inverse. Code units rather than
  code points, so a lone surrogate does not flatten to U+FFFD.

  `sessionId` had the same lossy slug and is fixed too — leaving it would have
  kept the (actor, session) pair non-injective regardless.

  Byte-preserved for well-behaved values: `{tenant:'acme', principal:'alice'}`
  is still `afp-acme_alice`. What re-keys is exactly what was already sharing
  an address.

  **Hash truncation is gone, and that is a behaviour change worth stating.**
  Truncation maps infinitely many identities onto finitely many ids, so it
  cannot be injective — a hash tail makes a collision unlikely, not impossible.
  Over-long ids now refuse by name, with the ceilings raised to AWS's real
  maxima (255 / 100, from a shared 99), so the refusal is rarer than the
  truncation was.

- **Every cost strategy has been receiving zeros.** `attachCostStrategy` read
  `cumulativeInputTokens`, `recentInputTokens`, `cumulativeCostUsd` and
  `recentCostUsd` off `CostTickPayload`. `git log -S` shows those names NEVER
  existed on it — the projection has read fields nothing emits since v2.8, so
  every strategy attached through `enable.cost()` got `0` for all six numbers.
  `iteration` and `runtimeStageId` were read off the payload too; they ride
  `event.meta`, so they were always absent as well.

  The cause is worth recording: the test built its payload BY HAND, in the same
  wrong shape as the projector. Code and test shared one false assumption, so
  nothing could fail. The new test calls `emitCostTick` itself, and asserts
  non-zero values — a test that only checks "a number arrived" passes against
  zeros, which is how this shipped.

  `provider` now flows through to `CostTick` as well (optional; absent stays
  absent rather than becoming `'unknown'`).

## [9.39.0] - 2026-08-14

**Three false doors closed, and two promises the recording did not keep.** An
architecture audit asked whether a third party could add a new strategy without
forking. Mostly yes — but the first thing such an author reads was wrong.

### Fixed

- **`CLAUDE.md` listed 13 import paths that do not exist**, and omitted 5 that
  do. An exports map is exhaustive, so the very first line a strategy author
  writes — `import { skillGraph } from 'agentfootprint/injection-engine'` —
  failed to resolve. The line even framed the wrong list as a correction to
  older docs, so it read as freshly verified. Corrected to the 13 real doors,
  with the 16 paths 9.0.0 removed named as removed. A new pin test asserts set
  equality against `package.json` in both directions, with an anti-vacuity
  guard so a reworded line fails loudly instead of silently scraping nothing.

- **`MemoryRetrievedPayload` did not name the strategy that produced it**, while
  `RetrievalStrategy.name`'s own docstring promised "Stable name — appears in
  the recording". It now does, on both the evidence and the event, sourced at
  the one place they are constructed so the empty-query record carries it too.

- **`CostTickPayload` carried neither provider nor model**, so cost could not be
  attributed from the cost event alone. `model` is required — it was always
  known at all three emit sites. `provider` is OPTIONAL and deliberately so: at
  the window stage it comes from `WindowStrategy.billing`, which the seam
  allows to be absent, and an absent value means "the strategy did not say"
  rather than "the agent's provider". The pair travels as one argument so the
  two cannot drift.

### Removed

- **`src/strategies/registry.ts` was dead code advertised as an extension
  point.** `registerObservabilityStrategy` and its three siblings had exactly
  one non-declaration reference in the repository: a comment admitting the path
  was reserved. Not re-exported from any barrel, not an `exports` subpath, not
  in the shipped-surface baseline — so nothing public could reach it and its
  removal is not breaking. The by-instance door (`agent.enable.observability({
strategy })`) is real, first-class, and now the only one. The cache twin
  legitimately has two doors; this one had one door and a sign pointing at a
  wall.

### Deprecated

- `ContextSourceAdapter`, `RiskDetector` and `EmbeddingProvider` are exported to
  consumers and have zero implementations and zero call sites — nowhere to plug
  in. They cannot be removed in 9.x, so they are marked for 10.0.0 along with
  their satellite types. `EmbeddingProvider` is a dead duplicate of the live
  `Embedder` port.

  Two more were examined and left alone, because they are not dormant:
  `ReliabilityProvider` is read from `config.providers` at five sites, and
  `CheckInDriver` is a produced output type filled by the configured scorer.

### Known, not fixed

- `attachCostStrategy` reads four field names that do not exist on
  `CostTickPayload` (`cumulativeInputTokens`, `recentInputTokens`,
  `cumulativeCostUsd`, `recentCostUsd`), so every cost strategy attached through
  `enable.cost()` receives zeros. This release makes `model` resolve there; the
  numbers are still zero. Its own ticket.

## [9.38.0] - 2026-08-14

**Two behaviour changes that are bug fixes — a call that used to run now
refuses, and a call that used to mint an orphan now carries its lineage —
plus two gaps named rather than papered over.**

### Fixed — a declared `wants` was optional at runtime, if an unrelated dial was turned down

A tool declaring `wants: { dataset: 'kind/x' }` for an argument its own
schema marks REQUIRED would still execute when the model omitted it — but
only when `toolArgValidation` was `'off'` or `'warn'`. Under the default
`'enforce'` the args gate already caught it. So the artifact guarantee was
resting on a dial that has nothing to do with artifacts: turn down argument
validation for an unrelated reason and the `wants` contract quietly stopped
applying.

`resolveToolWants` now reads the tool's own `inputSchema.required` and
refuses a required-but-omitted ref by name, with the same teaching shape
the other refusals use (it lists the live refs of the wanted kind).
Optional arguments are untouched — declaring `wants` for an optional
argument and omitting it is legitimate, and stays legitimate.

Threaded at BOTH dispatch doors. Worth recording why that matters: the
mutation check found the resume door was UNPINNED — dropping the fix there
failed zero tests, because nothing exercised an approved check-in resuming
into a wants-declaring tool. The test that now covers it is named for the
rule it protects: an approved call is not a waived one. A human approving a
check-in does not waive the artifact contract.

Also: a `null` argument was refused with "not a object". It now says
`null`.

### Fixed — derived artifacts carry their lineage

`mintProducedFiles` minted code-produced artifacts with no `parentRefs`,
while the framework was holding the resolved input refs on `ctx.wanted`
from the very same call. So a derived artifact arrived with no ancestry —
and lineage broke at exactly the step that exists to demonstrate lineage.

Input refs are now stamped automatically, deduped, and ABSENT rather than
`[]` when nothing was resolved, because an empty array reads as "derived
from nothing". Kept strict: a parent that expired mid-call fails that mint,
contained per entry and stated in the model's line, rather than silently
dropping the lineage.

### Documented — two gaps named rather than papered over

**No shipped runner populates `CodeResult.artifacts`.** `localCodeRunner`
cannot collect outputs honestly today — its staging directory is for
inputs, and the child's cwd is the caller's own working directory, so
"files the code wrote" is not a set it can identify without guessing which
of a developer's files mattered. A dev-loop runner that uploaded whatever
appeared beside your source would be the worse failure. The field is now
documented as ABSENT rather than empty, with the three things a runner owes
to produce outputs: a declared output location the model is told about, a
bounded read-back, and a real byte count.

**`getStream` does not verify the digest, and `get` does.** Verifying a
stream would require buffering the whole payload — the digest is one-shot
over the canonical payload, chosen so one primitive works in Node and a
browser — and a second incremental implementation would be a different
promise under the same field name. So the loss is named in all three
places a caller meets it, and pinned by a test that demonstrates the
difference: tamper the bytes, `get` throws, `getStream` hands them over,
and `meta.digest` still rides so a caller can check it themselves.

### Documented — placement and routing predicates, at both ends

Automatic placement rewrites the tool-result string that `rule` triggers
and skill-graph `when` edges read, so raising or lowering `maxInlineChars`
can change which edge fires. This is intentional — predicates reading a
string the conversation never contained would be worse — but it was
undocumented, and it is a real footgun. Comments now sit at both ends and
name each other, so neither can be changed without meeting the other, and
a test runs the same tool with placement off and on to pin what a
predicate actually sees.

## [9.37.1] - 2026-08-14

**A hygiene release — nothing new is exported, nothing behaves differently.
Three things that already shipped are now trustworthy: a doc comment that
could not compile, a measurement that lived only in prose, and an
agent-facing file that had drifted two eras behind the library.**

### Fixed — the `/skill-graph` door's `@example` could not compile

It named `entry:` where the flat config takes `start:`, chained
`.route()`/`.build()` off a value that returns neither, and passed skill ids
to a `.route()` that takes `Injection` objects. Three ways broken and nothing
caught it: doc comments are not typechecked, and this repo has no twoslash
over `src/**`, so the block just shipped into `dist/**/*.d.ts` and became
what a consumer read on hover.

The fix is structural, not textual. The example is now a real file —
`examples/context-engineering/19-skill-graph-host.ts` — typechecked by the
examples typecheck and executed by the example runner, and the docblock is
pinned byte-for-byte to a region of it
(`test/lib/injection-engine/skill-graph-doc-example.test.ts`). The pin also
refuses if a second `@example` ever appears above this one, since it would
otherwise guard the wrong block silently.

### Added — evidence for the 18,225-tuple migration claim, and a correction to it

The 9.37.0 entry stated "18,225 realistic tuples identical" for the
identity-namespace re-encoding. That number lived only in prose — no
committed script, no fixture, nothing re-runnable. It is now a test
(`test/memory/identity/identity-migration.test.ts`), expressed as the LAW
rather than the count: a tuple whose fields carry none of the three reserved
characters encodes byte-identically to the pre-9.37 encoder, AND — the half
that makes the class tight rather than merely sufficient — every tuple that
DID change has a field in that class. Asserted as a biconditional, with a
non-vacuity guard. The pre-9.37 encoder is carried inline as a reference
implementation with its bugs deliberately intact, because a "cleaned up"
copy would prove nothing about the bytes actually on disk before this
release.

**And it corrects the published claim** — said plainly, because a quiet
correction to a shipped changelog is exactly the thing that must not be
quiet. The stated class was incomplete: the old encoder gave `tenant` and
`principal` an absence spelling (`_`) but never gave `conversationId` one —
a missing conversation id became the literal string `"undefined"`, an empty
one an empty segment, and both re-key. Neither was in the stated class. The
law here therefore requires the conversation id to be actually given, and
the sweep exercises both absent forms explicitly rather than excluding them
quietly.

The corpus is labelled a RECONSTRUCTION, sized to reproduce the published
figure, since the original was never committed. What makes it evidence is
that every tuple in it is checked, not that the total looks familiar.

### Fixed — the shipped agent instructions were two eras stale

`ai-instructions/claude-code/SKILL.md` — already in the published tarball,
already what `agentfootprint-setup` copies into a consumer's editor — still
described the v1/v2-transition library: it told readers to import only from
the top-level barrel (the door list has grown to 13 since), listed a
roadmap that had already shipped, and pointed at commands and subpaths that
no longer exist.

It now carries the current agent-facing reference, including a "what does
NOT exist" section built from four measured hallucinations: there is no
`startRun`; `RunStep` is the flowchart topology slider, not skill/route
history; the LLM classifier is a tier-2 strategy, not tier 3; skill tools
are not scoped by default. These are not guesses about what a model might
get wrong — they are what a capable author, working from a correct mental
model, actually got wrong. The negative space is the highest-value content
in the file, and now it ships instead of living on one laptop (`.claude/`
is gitignored).

Anti-drift is dogfooded rather than invented: this is the ONE canonical
source, and the `.claude/skills` copy is its INSTALL — byte-identical, the
same relationship a consumer gets from the setup command — pinned by
`test/agent-skill-ships.test.ts`, which prints the exact `cp` to run when it
drifts. Two more tests in the same file pin the doc's FACTS to the source of
those facts: the `RunStep` row must list exactly `RunStepKind`'s members,
and every `CursorMoveCause` must be named with the count the union actually
has.

**Known and NOT done, said here rather than left implicit:** five sibling
files in `ai-instructions/` — for Cline, Copilot, Cursor, Kiro, Windsurf —
still carry the same stale document and still ship. They were deliberately
left alone this release: copying the content into five places is how five
documents diverge, and the real fix — one source with a per-tool render — is
a design decision, not a mechanical edit.

## [9.37.0] - 2026-08-14

**A security release — three cross-tenant defects, and the suite that
would have caught one of them.**

### Fixed — scope namespaces were not injective (cross-tenant read)

`identityNamespace()` joined tenant/principal/conversationId with a raw
`/`, so two DIFFERENT scope tuples produced ONE namespace —
`{tenant:'acme/hr', principal:'alice'}` and
`{tenant:'acme', principal:'hr/alice'}` both became `acme/hr/alice/c1`.
No race required. An audit read another scope's artifact payload in
full. Reachable in practice: a JWT `sub` is frequently a URI containing
slashes. It also collapsed absent/empty/`'_'` onto one shelf, so a
tenant literally named `_` shared the anonymous scope, and
`conversationId: undefined` became the literal string `"undefined"`.

The encoding is now injective, and the proof is structural: `%` is
escaped before `/`, so a decoder undoing them in reverse is a left
inverse — and a left inverse existing IS injectivity. Pinned by a
39,304-tuple adversarial cross-product plus decoder round-trips.

MIGRATION IS THE DESIGN, not an afterthought: a value containing neither
the separator nor the sentinel keeps its EXISTING namespace byte-for-byte.
Measured against a corpus of realistic ids — **18,225 tuples identical,
and zero of the changed ones fall outside the class {contains `/`,
contains `%`, is exactly `_`}**. Only keys that were already colliding
move, and those were broken. Well-behaved deployments migrate nothing.
Eight literal expected strings are pinned so a refactor cannot silently
re-key anyone.

Affected far beyond artifacts: the memory subsystem keyed on the same
function. Three modules that had re-implemented the encoder by hand are
fixed directly rather than patched around.

### Fixed — `redis.forget()` deleted across tenants (cross-tenant destruction)

It built `${prefix}:${namespace}:*` and handed it to `SCAN MATCH`, then
`DEL`ed the results — with glob metacharacters unescaped. Redis's `*`
spans `/`, so an identity carrying `*` matched and destroyed other
scopes' keys. The pattern is now escaped at the point of use (`\`, `*`,
`?`, `[`; `]` correctly needs none, confirmed against Redis's own
`stringmatchlen_impl`), backslash first, for the same left-inverse
reason as above. Nothing is re-keyed — the escape is on the PATTERN, not
on stored keys, which is why this fix is free.

Worth recording as a lesson: the test mock implemented `MATCH` as a
RegExp `replace(/\*/g,'.*')`, which does not implement `?` or `[a-z]`
and reads `\` as a regex escape — so a glob-injection test against it
would have proven nothing. Redis's real matcher was ported into the
harness and pinned against the documented examples first. And one
`forget` case was initially passing by luck, because the bystander's id
was too long for `?` to reach; a one-character sibling was added. A
double that cannot model the bug makes the whole suite theatre.

### Fixed — session ownership split-brain (cross-tenant read, all four stores)

Write-once ownership preserved the FIRST writer's owner while storing the
SECOND writer's envelope — and the envelope carries its own identity. The
index said Alice, the stored conversation said Bob, so Alice listed it,
opened it, and read Bob's conversation. Found live by a field trial
running two real concurrent transactions; confirmed present in
`memorySessions`, `sqliteSessions`, `firestoreSessions` AND
`agentEngineSessions`.

A different non-empty signer on an owned session is now refused by name
(`SessionOwnershipConflictError`), carrying no principal, owner,
conversation text or credential. A LEANER turn is still accepted — the
contract blesses it explicitly, an absence claims nobody so it
contradicts nobody, and the composer cannot even produce the divergent
state because identity is inherited through `continueFrom`.

BEHAVIOUR CHANGE, stated plainly: a `persist` that used to succeed now
refuses. It cannot fire at a verifying door (the composer refuses
foreign turns before the store is reached). It CAN fire at a
header-trust door with no verifier — which is exactly the split-brain
producer — and there the model has already seen the prior conversation
before the write refuses. A verifier is what closes that door.

### Added — `SessionLifecycle` conformance suite

The real deliverable. This bug lived in four adapters at once because
every store is tested against its own doubles, so a flaw in the PORT's
semantics is invisible to all of them simultaneously.
`runSessionLifecycleConformance({ name, createStore, … })` is exported
from `agentfootprint/hosting` so an out-of-tree store can run it too. 13
cases × 4 stores.

A store that legitimately cannot satisfy a case DECLARES it by name with
a reason — and the case still runs, so a declaration that starts
passing is reported STALE. "Needed a harness hook nobody supplied and
nobody declared" counts as FAILED, not skipped. There is no way to make
a case quietly disappear, and that rule is itself a test. Exactly one
declaration exists today: `agentEngineSessions` cannot fill in ownership
on a later signed turn, because the service pins `userId` at create.

### Known and NOT fixed — named, not buried

- `agentcore`'s `actorId` slugs non-alphanumerics and joins
  tenant+principal with `_`, so `a_b`+`c` collides with `a`+`b_c`. A
  genuine cross-tenant collision at the AWS session boundary, left alone
  deliberately: any fix re-keys durable remote rows in a customer's
  account, and underscores in ids are common. Migration is unavoidable,
  so it is the operator's decision, not ours.
- Composition seams (`redis` `:`, `s3Vectors` `#`): a `:` or `#` inside a
  conversation id lets two (scope, entry) pairs compose one key.
  Escaping them in the encoder would close it but would re-key
  `urn:`/`sub:`-style ids, breaking the byte-preservation property that
  makes the fix above migration-free.

## [9.36.0] - 2026-08-14

**The adoption release — two changes aimed at the same moment: the first
skill graph someone builds who has never built one.**

### Added — `.toolsFromActiveSkill()`

An agent-level lever that scopes every skill's tools to the active skill,
so a tool reaches the wire only while its skill is current.

Why a boolean and not a third posture: the wire has no middle. A tool's
schema is either in the request or it is not, and "record that we sent
it" is just sending it. `strictness` and the evidence `posture` earn
three values because routing and evidence each have a real record-it
middle; this does not. A three-value dial here would ship one behaviour
under two names.

What it is not: authority to run. It governs what the model is SHOWN, not
whether an inactive skill's tool may dispatch — that is a different axis,
and the library already ships the composable answer (`gatedTools`,
`PermissionChecker`, `skillScopedTools`). The boundary is documented on
the method and pinned by a test, so nobody reads this as an execution
gate.

Why agent-level and not graph-level: `skillGraph({ scopeTools: true })`
already stamps every WIRED skill and already names 10.0.0 for its default
flip. The gap is everything outside that — listed-but-unwired graph
skills, skills registered beside the graph, and every skill on a
graph-less agent, including a whole `skillsFromDir` directory.

How it interacts with the per-skill flag: they cannot disagree.
`autoActivate` has exactly one legal value, so a skill can ask to be
scoped and can never ask to be exempt. The stamp is a default, never an
override, and the result is monotone — turning it on only ever removes
names from the static list. Pinned two ways: object identity, and a
value-level pin using a reserved future mode, so the law still holds the
day further modes ship.

Default unchanged, pinned by a wire-level test asserting the exact tool
list on iteration 1 without the call. The flip is on the same 10.0.0
ledger as `scopeTools`.

### Changed — `skillsFromDir` carries the whole runbook

A directory-defined skill can now declare its tools and its steps, not
only its body. It reuses the existing `key: value` grammar one level in
(`tools:` as an inline list or a `- name` block; `steps:` as `- tool: why`
items; `onSkip:`), and steps reuse the shipped `skillSteps` grammar
verbatim rather than a parallel one.

Why this is not a code-execution vector — said plainly: nothing in the
file is evaluated, imported, required, or resolved as a path. `tools:` is
a list of strings, and the only thing a string can do is MATCH a tool
name in a registry the caller built in their own source from their own
imports. The file PICKS; it never DEFINES. The capabilities a directory
can reach are a strict subset of what the calling file already handed
in. Pinned by a security test feeding `rm_rf`, `node:child_process`, and
`../../../etc/passwd`.

Unresolved names refuse at load, never a half-skill — because a silently
tool-less skill looks exactly like a working one. Every message names the
file (steps name the line) and carries the fix: an unknown name lists
what IS available; `tools:` with no registry prints the exact call to
paste. Also refused: a step naming an undeclared tool, a step with no
why, `steps` without `tools`, empty or duplicate `tools`, `onSkip`
without `steps`, an unknown `onSkip`, and a registry with two different
tools under one name.

No new checkup code, deliberately: the checkup is an advisory build-time
report, and a code there would have downgraded "never load a half-skill"
to a warning.

Additive: a directory that declares only prose loads identically — and
that constraint is guarded by the entire pre-existing `skillsFromDir`
suite as well as a new pin (breaking it fails 20 tests), which is the
strongest evidence it cannot silently rot.

## [9.35.0] - 2026-08-14

**`.namesAndNumbersFromEvidence()` — every name and number in the final
answer must appear in a tool result this turn actually read.**

Two engineering conventions, arrived at independently, said the same thing
about the same failure. The architecture guide promised this as a runtime
invariant — twice — and the runtime did nothing of the kind. On a
production consumer app, the same class of question produced a fabricated
port row: an alias and an FCID that appeared in no tool result, stated as
fact. That app's own house rule reads "facts come from data, never from
labels." Ours had no rule at all. Now it does, and it is enforced, not
promised.

### What it is, and — because the name invites the wrong reading — what it

### is provably not

It is a **fabrication detector, not a correctness judge.** If a value in
the answer never appeared in anything a tool returned this turn, the model
typed it rather than read it, and that is all this checks. It cannot catch
a false claim built entirely from real values — _"fc1/3 is healthy"_ when
the data says the port is down uses two grounded tokens and sails through.
It cannot catch a real value attached to the wrong thing, a fabricated
quantity under the digit threshold, or a fabricated name spelled in
letters only — which is exactly what declaring a `shapes` pattern is for.
`.factsFromEvidence()` was the name on the table first and was rejected
for promising exactly this: "facts" says more than the check can stand
behind. Say the limits plainly, because a reader who assumes this is a
hallucination checker will trust it for the one job it cannot do.

### The governing constraint: deterministic, on purpose

The check is set membership over normalized tokens — no LLM judge, no
embedding, no model call of any kind. This library's whole thesis is that
structure lets a small model perform like a large one; a gate that needed
a strong model to police a weak one would invert that thesis at the exact
place it is supposed to hold. The clean-room probe proves this the hard
way, not the easy one: the gate still flags a fabricated value after the
mock provider's scripted responses are exhausted.

### Three postures — a SEPARATE dial from routing strictness

Same vocabulary as `.skillGraph({ strictness })`, because it reads the
same way, but a different setting: routing authority and evidence
enforcement are different concerns, and an app may want strict routing
with loose evidence, or the reverse.

- `'assist'` (**default**) — record and flag; the answer ships unchanged.
  Every agent that does not ask for this feature is byte-identical to
  9.34.0 — no branch mounted, no event emitted.
- `'guard'` — the unsupported values are named back to the model for ONE
  bounded revision, tools still on the wire, so it can go fetch what it
  guessed instead of restating it. This is the posture that makes a
  smaller model behave like a bigger one, and it is the recommended one
  for a weaker model.
- `'rails'` — the same one revision, then a refusal: `run()` raises
  `UnsupportedValuesError`, naming the values, rather than return an
  answer that still carries them.

Every check lands on the emit channel as `agentfootprint.agent
.evidence_checked`, in every posture — `assist` is not a silent mode, it
is a recording one.

### The mechanism — a SIBLING branch, not a second loop

Evidence-checking rides the exact machinery `outputRetry` already built:
the same `{loopTo}`, the same conditional mount (nothing changes shape for
an agent that never calls `.namesAndNumbersFromEvidence()`), its own
`iteration_end`, its own cost tick. The route decider judges in a fixed
order — schema, then declared steps, then evidence — so an answer already
being replaced by a schema retry is never evidence-judged, a denied answer
is not judged at all, and a turn the iteration or cost limit just cut
short does not get to spend a revision it can't afford. It composes with,
rather than collides with, `.reliability()`: reliability governs whether a
call is retried before anything is committed; this governs an answer
_after_ it has already been committed to the transcript.

### Measured, not assumed

Two corpora, both built from real SAN inventory shapes, written the way
models actually write:

- **12 correct answers, 32 distinct grounded values — 0 false positives.**
- **4 fabricated answers, including the field's own port-row example, 5
  planted unsupported values — 5 flagged, 0 missed, 0 collateral.**

Both directions are pinned so an extractor going blind and an extractor
over-flagging fail equally loudly; a gate that only proved the flag proves
nothing about the answers it must leave alone.

### The lesson the mutation check found mid-development

The `guard` correction is a user-role turn that quotes the flagged values
back at the model — which means, before this was caught, the exempt
corpus exempted exactly the values it had just accused. The second pass
always came back clean, and `rails` could never refuse anything, because
by its own second look nothing was ever wrong. Library-authored evidence
frames are now excluded from the exempt corpus by construction, pinned by
a regression test. A gate that absolves itself on the second attempt is
worse than shipping no gate at all.

### Also in this release

- `UnsupportedValuesError` joins `run()`'s terminal-typed-error list. A
  verdict is not a crash, and wrapping it in something generic would bury
  the named values one `.cause` deep exactly when a caller most needs
  them at the top.
- Three anti-drift registries were extended, none weakened: the event
  registry (`agentfootprint.agent.evidence_checked`), `STAGE_IDS`
  (`evidence-recheck`, a boundary-local milestone — the run telling the
  model it made a value up is the single most interesting stop a reader
  can find), and the silent-success classification (this builder method
  refuses a second call by name, like every other one-shot configuration
  method on `AgentBuilder`).

## [9.34.0] - 2026-08-14

**The skill-graph purity fence, and the `agentfootprint/skill-graph` subpath it makes safe to ship.**

The skill-graph layer was already framework-neutral — by accident. Nothing
enforced it, and three recent minors had each pushed a little loop
vocabulary into it, because inside one package an import costs nothing to
write and nothing to notice. A comment saying "this stays pure" is not a
forcing function. A CI test that fails on the crossing is.

### Added — `test/lib/injection-engine/skill-graph-fence.test.ts`, 30 tests

Walks the TRANSITIVE import graph with the TypeScript parser itself, so
`import type`, `export … from`, `await import()`, and an inline
`import('…').T` are all seen — grepping for `from 'footprintjs'` would have
missed all four shapes. Two zones, both named in the test, not inferred
from directory structure: a PURE CORE of 18 files that may import nothing
from `footprintjs`, the agent loop, `core/tools.ts`, or an adapter; and a
PROVIDER LAYER of exactly two files — `constrainedEnumPick.ts` and
`llmClassifier.ts` — allowed to see `adapters/types.ts` and nothing else,
because a constrained-enum pick genuinely IS a model call and pretending
otherwise would be a fake abstraction, not a purer one. Every allow-listed
leaf is separately asserted to be a true leaf (zero imports of its own), so
an allow-listed file can't smuggle the loop in behind it. Every file that
lives in `injection-engine/` must land in exactly one zone, so a new file
dropped in later can't go unclassified. A refusal names the file, the
import, the reason, and the seam to use instead.

Independently mutation-checked before release: adding
`import { isDevMode } from 'footprintjs'` to `routingPolicy.ts` failed 11
tests, each naming the file, the import, and the fix — and the fence caught
it a second, harder way too, transitively through `evaluator.ts`, which
never touched `footprintjs` itself but imports the file that now did.

### Fixed — the leaks the fence found, each closed behind a pure seam

- `isDevMode` (a `footprintjs` import) → a bound `devWarn()` / `devMode()`
  reader that the host supplies; every existing warning reads verbatim, and
  the existing `enableDevMode()` tests pass unchanged — the proof that
  nothing about _what gets warned_ moved, only _how it's asked_.
- `ToolResultStatus` → pulled out to a zero-import leaf and re-exported from
  its old home, so the envelope grammar is unchanged for every existing
  caller. The fence caught four more inline crossings of this type that
  hadn't made it into the original list.
- `Tool` → replaced with a structural `SkillTool` (`{ schema, execute }`)
  that the real `Tool` satisfies without changes — zero compiler errors
  from the swap, because the graph never needed more than the shape.
- `LLMToolSchema` / `CachePolicy` → structural mirrors, pinned two
  independent ways: an AST field-list comparison in the fence test, and
  real two-way assignability in a compiler test — so the mirror can't drift
  silently in either direction.
- `defineTool`'s value imports → the graph now exports pure descriptors;
  one host-side file does the wrapping into a runnable `Tool`.

### Added — `SkillGraphHost`

A type, not a runtime — it names what a host owes the graph: advance the
cursor exactly once per iteration using the same `ctx` its triggers read,
enforce reachability at pick time, set the pending pick only after
acceptance, carry the cursor across iterations, emit the skill events.
There is no second run door behind it; `buildInjectionEngineSubflow.ts` is
now labelled what it always was — one reference implementation of this
contract, not the contract itself.

### Added — `agentfootprint/skill-graph`

A new subpath that ships the pure core on its own. Proven on the built
output, not asserted: loading `dist/doors/skill-graph.js` pulls zero
modules of the `footprintjs` package, where `require('footprintjs')`
explicitly pulls 104.

**The honest cost:** `footprintjs` is still a REQUIRED peer of this
package, so a host on another framework installs it even though this
subpath never loads a line of it — the fence buys you the import, not the
install. And nobody has run this door from outside agentfootprint yet.
Say so rather than imply otherwise.

### Changed — additive only

`buildReadSkillTool`, `buildListSkillsTool`, and `buildSkipStepTool` are
byte-identical and still return `Tool`; turning them into descriptors
directly would have been a breaking change, so the descriptor shape was
added beneath them instead. Three existing test files changed, and all
three changes are mechanical: two are export-map inventories that needed
an eleventh door listed, one moved an import between internal modules. No
assertion changed.

## [9.33.0] - 2026-08-14

**A fourth rung on the session ladder — Firestore — built around the one
query decision a field trial of a different adapter proved matters.**

### Added — `firestoreSessions()`

The `SessionLifecycle` port on Google Cloud Firestore, beside
`memorySessions()` and `sqliteSessions()`: a fleet-shared conversation store
with no instance to size, no connection pool to tune, and a free tier.
`@google-cloud/firestore` is an optional peer — a deployment that never
constructs this store installs nothing, and one that tries to without the
package is refused by name with the install line, alongside the fact that
`memorySessions()`/`sqliteSessions()` need nothing installed:

```
npm install @google-cloud/firestore
```

`listByUser` is SERVER-SIDE, INDEXED and CURSORED —
`where(owner) + orderBy(savedAt desc) + orderBy(__name__ desc) +
startAfter(...) + limit(n+1)` — and that query shape is the point of the
release, not a detail of it. An independent field trial of a different,
hand-written Firestore adapter passed eight ownership and history checks
against a real Firestore, and named its own defect in the report: it read
every document for one owner, sorted them in the client, and applied an
offset cursor. That is correct until somebody has had a lot of
conversations, and then it reads all of them to show ten.

Ownership is DERIVED from the stored envelope and established ONCE, inside
a transaction. Firestore has no `COALESCE`, and `set({ merge: true })` is
not a stand-in for one: mentioning `owner` at all lets the last writer win,
and leaving it out means a conversation that gains an identity on turn two
never records one. `ownerOf` returns `undefined` for both "no such session"
and "a session nobody signed for" — a deliberate ambiguity, so the method
can never be read as an oracle for which session ids are real.

Document names are the full sha-256 of a NUL-separated domain and the raw
session id, not the id itself, so a session id containing `/`, unicode, or
an awkward length still addresses cleanly. Worth saying plainly because the
shape invites the wrong reading: this is ADDRESSING, not encryption — the
conversation is stored in the clear, and the raw id rides alongside the hash
in its own field so a console reader can still see whose document it is.

A missing composite index refuses by name with the exact `gcloud firestore
indexes composite create` line, `--database` included even for `(default)`
— because an operator on a named database who follows a command without
that flag creates the index on the wrong one and gets the identical failure
back with nothing to suggest why.

**Status: contract-shaped and tested, NOT field-validated.** Nothing here
has been run against a live Firestore by this repository. The 18 pinned SDK
members were read off a real `@google-cloud/firestore` 9.0.0 install
outside this repo and hand-verified there. The reality assertion — the
check that every pinned member really exists on the real package — SKIPS
here: installing the package hoists `@opentelemetry/api`, which would
disarm the test proving `otelObservability()` refuses when that package is
absent. So CI machine-checks the SHAPE pin instead: this adapter dispatches
exactly the members its pin names and no others, every run, everywhere —
not the reality pin.

A rule worth stating because this file just relearned it, not just an
adapter's footnote: a comment claiming what a test proves is itself a
claim, and the only way to know is to break the code and watch. Mutation
checks during this work twice caught a claim a careful read had not.

## [9.32.0] - 2026-08-14

**Three questions we could not answer without an account came back
answered — and the honesty vocabulary got audited against its own
evidence.**

An independent field trial and an independent reviewer, both 2026-08-13
and 2026-08-14, drove this release.

### Answered — a Node service IS deployable on Vertex Agent Engine

Through the custom-container door: a Node image was accepted with
`agentFramework: 'custom'` and served, and the recipe is documented on the
Google Cloud page. We still ship no `agentRuntimeHost()`, and the reason is
now stated rather than guessed — our `httpHost` serves one invoke path
framed as SSE, while that contract's second route is NDJSON at its own
path. That is a port-shape change, tracked as its own release rather than
half-shipped here.

### Answered — the gateway

A plain-HTTP bearer gateway works end to end: tool discovery, a tool call,
fresh credentials vended per request, never stored. Google's own identity
path for Agent Gateway — mTLS + DPoP — was **not** expressible through our
transport, and still isn't; what changed is where the seam to build one
lives (below).

### Added — `gatewayTransport({ fetch })`

A consumer's own mTLS/DPoP `fetch` now composes UNDER the per-request
credential vending, instead of forcing a drop to the generic `http`
transport and losing rotation to get a client certificate. The credential
is vended and applied first; your function is called with the final
request and has the last word over the bytes. Zero vendor code lands here
— bring-your-own, offered as a seam, never described as support.

### Answered — streaming works end to end from a deployed service

Over the network, from a live host, with usage totals correct — including
the thinking count.

### Added (security) — an ingress decision record, `onIngressDecision`

`auditExport()` is a record of runs; a 401 out of `identity.verify` and a
429 out of `admission.decide` both happen before a run exists, so neither
was in the bundle — an empty bundle read as "nobody was turned away" when
it only meant "nobody ran." `standingAgent({ onIngressDecision })` now
hands your sink one `IngressRecord` per request, filed at the terminal the
reply actually reached. The honest contract is stated in the type itself:
`'served'` means **delivered**, not _admitted_ — a request the door let
through whose run, store or provider then broke files as `'failed'`, and
the record carries the admission verdict (`allow` / `queue` / `refuse`)
either way. It is a stream you chain into your own sink, not a join onto
the audit hash chain — saying otherwise would make this fix the exact
failure it exists to close.

### Added — circuit-breaker state transitions on the resilience report

`withCircuitBreaker` now emits `agentfootprint.error.circuit_changed`
(`{ state, reason, providerName }`) on every transition, so a trip is
visible on the same timeline as the tool calls it stopped instead of only
as the `reason` string on an enclosing `withFallback`. It reports
transitions, not calls — an open breaker rejecting a hundred requests
produces zero events. `onStateChange` and the event are complements: the
hook fires wherever the breaker lives, in a run or not; the event fires
only inside a run, where it can carry real correlation ids.

### Changed (status vocabulary audited) — five statuses corrected against their own evidence

A review found five statuses claiming **field-validated** on evidence that
was a deterministic local run. Corrected:

- **Stay field-validated** — `jwksIdentity`, the `identity: { verify }`
  door, and the 9.26 session-ownership / session-history contract. A real
  remote JWKS and a real Firestore participated in the trial that earned
  the rung.
- **Split out** — `turnsPerHour` is now **contract-shaped and tested**,
  not field-validated: a minutes-long run cannot cross an hour, and the
  shipped helper was never itself named as the policy under test, only its
  per-process bound.
- **Moved to contract-shaped and tested**, each naming what was NOT
  exercised — `withRetry`, `withFallback`, `withCircuitBreaker` (no live
  provider outage retried, no failover between live providers, no live
  breaker trip); `PermissionChecker`-as-execution-guard (no external
  authorizer answered a `check()`); `auditExport`'s hash chain (no bundle
  re-read from a durable store).

### Docs — a dependency advisory beside `gcsArtifacts`

`@google-cloud/storage`'s optional peer tree carries five transitive
**moderate** advisories, rooted in `uuid` (`GHSA-w5hq-g745-h8pq`) through
`gaxios` → `teeny-request` → `retry-request`. Not a defect in this
adapter, and no line here would fix it. `npm audit fix --force`'s
resolution installs `@google-cloud/storage@5.18.3` — a major downgrade to
a client several majors behind the service — and we refuse to pin you to
it. Pin the newest 7.x yourself and watch the upstream chain.

## [9.31.0] - 2026-08-14

**Two doors the field found shut: an Azure config our own docs advertised
could never boot, and streaming that no test could see was broken.**

An independent field trial and one production consumer drove this release.

### Fixed — the Azure door

The SDK's `AzureOpenAI` constructor defaults `baseURL` from `OPENAI_BASE_URL`
and refuses an `endpoint` alongside it — so the `OPENAI_BASE_URL` spelling
named in our own docs, help text and examples handed the SDK the value
twice and could not boot at all. We now compute the base URL ourselves and
never pass `endpoint`, so both spellings work and produce a byte-identical
request URL — asserted from what a fake Azure server actually receives,
including the `/openai` segment, trailing slashes, and an endpoint already
ending in `/openai`.

### Fixed — streaming was broken for every OpenAI-door consumer, not just Azure

The stream path iterated the SDK's `create()` return without awaiting it,
and the real SDK returns a promise that resolves to the async iterable.
Every test double in the repo returned the iterable directly, which hid the
defect from the entire suite. A non-iterable now yields a teaching refusal
instead of a `TypeError`.

### Changed — `providerFromEnv`'s Azure arm

Returns the DEPLOYMENT as `model` (it previously returned the literal kind
label `'azure'`), and refuses by name when Azure credentials arrive with no
deployment. Note for consumers who read that field: the value changed from
a constant to your deployment id.

### Added — examples on start rules

`examples: [...]` on a start rule declares the phrasings that rule claims,
and the check-up proves three things by running the compiled predicates in
declaration order — a witness, not regex theory: a rule whose example its
own matcher rejects; an example an earlier rule claims first; and an example
nothing claims at all. The last is coverage, which no matcher-vs-matcher
analysis can prove. Severity tracks provability — a data matcher rejecting
its own phrase is an error, an opaque `when` predicate that merely did not
match this turn is a warning; where the cold walk and the cascade would read
an unconditional entry differently, it reports a warning naming both
readings rather than asserting one. Tier note: on a tier-1 data rule,
examples are test material read once at build; in `match: { intent,
examples }` they remain scoring material read by the classifier at run
time — declaring both on one rule is refused.

The check-up now carries notes on the report itself: examples prove things
about the phrases you declared and nothing about the phrases nobody wrote —
no warning is not proof of coverage.

## [9.30.0] - 2026-08-14

**The field answered back: one adapter wrote with the wrong verb, another
dropped what it was handed — both corrected, and four statuses now say what
the trial proved.**

A second independent field-trial round on live Google Cloud, 2026-08, drove
this release.

### Fixed — `agentEngineSessions` persist: the service refuses a patch of session state

`sessions.patch({ updateMask: 'sessionState,ttl' })` stored the first turn of
a conversation and then answered every later turn with `HTTP 400 — "Can't
update the session state for session …, you can only update it by appending
an event."` Every injected-client test passed, because a double will patch
anything it is handed; only a live call could find this. `persist` now
appends a `SessionEvent` whose `actions.stateDelta` carries the envelope —
creating the session when it does not yet exist, appending again on a race
between two writers. The pin no longer names `patch` on the sessions path,
and the test double throws the service's own 400 for a `sessionState` patch,
so a regression here fails offline, not in somebody's production project.

### Fixed — `memoryBankStore` fidelity: source identity and caller metadata preserved

A memory's `source` and the caller's own `metadata` went in and did not come
back — silently dropped, even though the port documents `MemorySource`
fields as ones a storage adapter "MUST preserve verbatim on every
read/write." Both, plus `decayPolicy`, are now carried under prefixed
metadata keys and restored verbatim on read. A caller's own value under one
of the three keys this adapter generates (`source`, `resourceName`,
`distance`) is refused by name rather than silently shadowed — recognized by
_identity_, not shape, so a caller's own `distance: 12` cannot be mistaken
for this adapter's. An oversized carried field is refused rather than
truncated: provenance that came back shortened would be provenance nobody
could tell was shortened.

### Changed — engine naming: a project-number-shaped name beside a project id is refused teachingly

`reasoningEngine` naming one project's canonical (numeric) engine name
beside a `project` that names the same project by its textual id was already
refused as two disagreeing spellings; that refusal now teaches when the two
strings could plausibly be the same project spelled two ways. A project
number is not provably the same project as an id without a Resource Manager
lookup this library deliberately does not make — resolving them as equal on
a guess is how one project's conversations get written into another's. The
refusal names both fixes: pass the engine id alone beside `project` and
`location`, or pass the full name and drop `project`.

### Status — four promotions the trial earned

The Gemini 3.x tool loop and thought-signature round trip is
**field-validated**: the same trial re-ran it live against
`gemini-3.1-flash-lite`, tool call, signature echoed back byte for byte, and
a correct second answer. `agentEngineSessions` and `memoryBankStore` are
**field-validated with the corrections above** — a third honest word joins
the status matrix, **field-corrected**: the trial ran the shipped code, the
service refused it, and the code changed to what the service actually
accepts; neither repair has itself been re-run live yet, and the docs say
so. `googleIdentity` is **field-validated for machine identity** — a real
bearer credential from ADC authorized a live Vertex request, `mode: 'user'`
and a disallowed service both failed closed — with expiry-triggered refresh
still explicitly unproven; the trial vended twice minutes apart and did not
wait out an hour.

### Docs

The Google Cloud page carries per-adapter outcomes in place of one blanket
rung, the session method table matches the service (`appendEvent`, not
`patch`), and the Memory Bank mapping rules — what's carried, what's
refused, what's still dropped — are stated rather than implied. The deferred
unified ingress-audit story (a 401 or 429 before a run exists, so
`auditExport`'s hash chain never sees it) is named as a tracked gap rather
than half-shipped quietly, with the seams that can record a refusal today:
your own `verify` and `admission.decide` functions see every refusal they
hand back, and an empty audit bundle is not evidence nobody was turned away
— **absence of a refusal is not consent.**

## [9.29.0] - 2026-08-13

**The Google column tells field truth: signatures echo, doors default
honestly, thinking is counted, and keys can rotate.**

An independent field trial on live Google Cloud, 2026-08, drove this
release — a real Vertex + AI Studio account, not a mock.

### Added — thought-signature echo, so a 3.x tool loop survives its second call

A current Gemini model does not merely prefer its `thoughtSignature` back on
the next turn — the trial hit `400 INVALID_ARGUMENT — "Function call is
missing a thought_signature"` on the second call of an ordinary tool loop,
AFTER the tool had already run. `GeminiProvider` now carries the signature:
read off the `functionCall` part it belongs to, parked on the port's
vendor-neutral `toolCalls[].providerMeta`, and written back onto the
reconstructed part on the next request — byte for byte, never synthesized.
An unsigned call (a 2.5-series turn, or any turn a model chose not to sign)
carries no `providerMeta` key at all and is byte-identical to today's wire.

### Changed — per-door model defaults on `gemini()`

Vertex keeps the field-proven `gemini-2.5-flash` default (Google states its
retirement for **October 16, 2026**). The AI-Studio key door now REFUSES the
bare `'gemini'` shorthand: the trial's key-door call to that same model
answered `404 — "no longer available to new users"`, so shipping a second
silent default nobody has run would be this library guessing on a service's
behalf. The refusal quotes the 404 and names both fixes — `defaultModel` on
the factory, or a named model per call. Fires ONLY on the shorthand; a
request naming a real model id is unaffected on either door.

### Added — typed thinking usage on `llm_end`

`usage.thinking` (`LLMEndPayload`) — reasoning tokens the provider reports,
was already flowing off Gemini's `usageMetadata` and the payload's type
dropped it. The trial's own numbers made the gap visible: a 256-token stream
came back `input 21, output 9, thinking 243` — 243 billed tokens outside both
fields. Deliberately unpriced: `cost.tick` does not fold it in, because
`PricingTable` prices four kinds and thinking isn't one — inventing a rate
would be guessing at somebody's invoice. Undefined on providers that don't
report it, which is most calls; consumers estimating cost from
`input + output` alone should know that under-counts a thinking model by
whatever it thought.

### Added — `apiKey` as a callback, on the Google connection and the OpenAI-compat door

```ts
apiKey?: string | (() => string | Promise<string>)
```

on `GoogleGenAIConnectionOptions` (`gemini`, `geminiEmbedder`) and
`OpenAIProviderOptions` (`openai`, including Vertex's OpenAI-compatible
endpoint). The trial measured the boundary this closes: an OAuth token that
worked returned `401` once expired, with no place in either adapter's options
to put a fresh one. The callback is called once per request, before the
request is built; the SDK client is rebuilt only when the answer changed, so
a cached token costs one function call; a stream keeps the key it started
with — nothing re-authenticates a socket that's already open. Redaction
follows the key actually in force, not the one construction started with, so
a rotated credential never leaks into an error message under the old key.

### Added — the because-clause for rules-only graphs

`cursorMove`'s witness (9.28.0) now narrates identically to the cascade's
`skill.turn_routed` line — one sentence, shared by construction
(`ENTRY_WITNESS_LINE`), so the two records can never drift into two stories
about the same fact. Before this release the sentence only reached readers of
a cascade graph; a rules-only graph (no cascade, entry rules only) fired the
witness on the hop and said nothing. Also fixed: a cascade graph double-
narrating the same routing line once from `turn_routed` and once from the
hop that carried it.

### Docs

The door/model matrix, the Agent Engine Node recipe, and the billing
boundary; `fileObservability` promoted to field-validated (trial cited); the
Google adapter docs corrected where the trial contradicted them — the trial
read two different users' Agent Engine sessions by name under one ordinary
ADC principal, presenting neither identity, so `Session.userId` is metadata,
not authorization. The ownership check is OURS to enforce (above the port,
against `envelopeOwner`), not the service's — stated plainly rather than
implied.

## [9.28.0] - 2026-08-13

**The record quotes the evidence: routing carries the words that decided it,
and every story sentence knows its author.**

### Added — `RouteWitness` on `turn_routed` + `cursorMove`, for data-matcher routes

```ts
witness?: { text: string; keyword?: string }
```

A tier-1 route decided by a DATA matcher (`match:` — RegExp / `{ keywords }` /
`{ all }`) now records what it matched: `text` is the matched substring of the
**user message only**, whitespace-collapsed and bounded to 80 characters
(ellipsis included) — a greedy `/[\s\S]+/` rule cannot paste the whole message
into every record. `keyword` names WHICH declared keyword hit, for the
`{ keywords }` arm. A conjunction (`{ all }`) witnesses its leading part —
every part matched, so any part's text is true evidence, and the first is
deterministic. A zero-width or whitespace-only match records nothing rather
than quoting `""`.

Nothing is recorded for the routes whose evidence differs: a `when` predicate
is opaque code the library cannot quote, an intent match's evidence is already
its `scores`, and an unconditional entry matched nothing. `turn_routed` and
that hop's `cursorMove` carry the same value — the cascade extracts it once,
on the winning rule only, and the hop repeats it rather than re-deriving it.

The commentary layer renders it as evidence, not assertion — `routed this turn
to \`billing\` because the message said "chargeback"` — falling back to
today's sentence, byte-for-byte, when no witness is present.

### Added — `brainSource` on story-trace return beats

```ts
brainSource?: 'model' | 'framework'
```

A tool-result return beat's `brain` line is framework narration (the
commentary engine describing the mechanics), not the model's own words —
`AgentThinkingTraceRecorder` now stamps `brainSource: 'framework'` on it so a
notepad stops prefixing a sentence nobody's LLM said with "LLM reasons —".
Absent means model-authored, the pre-9.28.0 default, so existing readers are
unaffected. Pairs with `agentthinkingui` 0.26.

## [9.27.0] - 2026-08-13

**The story learns the artifact vocabulary, and the Google Cloud column gets
its sessions, memories, and identity.**

### Added — commentary templates for the artifact age

The prose layer (`commentaryTemplates.ts` + the new `artifactPhrases.ts`)
learns the events several recent releases shipped without a sentence:
`artifacts.minted` / `.presented` / `.resolved` / `.refused` / `.expired`,
`tools.result_refused`, the repeated-call nudge (9.26.0), and typed tool
effects. Every line follows the same rules as the rest of the layer:

- **Honest, not inferred.** `tools.result_refused`'s sentence never claims a
  retry happened — no event attests one, so the words don't either.
- **Sizes humanized, and the two units told apart.** `humanizeBytes` /
  `humanizeChars` — `41.0 KB` where the ceiling counts bytes, `240,000
characters` where it counts characters (`tools.result_refused` counts
  characters, because that's what the limit does).
- **Refs and digests stay out of prose.** They identify a row for the details
  panel; a reader doesn't read them. The repeated-call nudge's fingerprints
  are the same story: the sentence says "identical," never the digest that
  proves it.
- **Absent field, absent clause.** A tool effect with no `reason` renders no
  quote — through 9.26.0 it rendered an empty pair of quotes, a sentence
  claiming words nobody spoke.
- **`head` and `get` read as different decisions, not one hedge.**
  `artifacts.resolved.head` says a ticket was described; `.get` says it was
  redeemed and paid for — "described without paying for the payload" is the
  render-by-ref distinction the two sentences exist to carry.
- **Unknown events still fall through.** `selectCommentaryKey` answers
  `undefined` for anything without a template and the caller renders it raw —
  nothing new here is dropped on the floor, and nothing old changed shape.

### Added — Google Cloud Phase B: `agentEngineSessions`, `memoryBankStore`, `googleIdentity`

All three sit on one shared REST layer (`adapters/google/aiPlatform.ts`) over
the split `@googleapis/aiplatform` package (27 MB) rather than the `googleapis`
mega-package (209 MB) that carries every Google API for the same generated
code — contract-shaped and tested; awaiting field use.

```ts
import { agentEngineSessions } from 'agentfootprint/hosting';
import { memoryBankStore } from 'agentfootprint/memory';
import { googleIdentity } from 'agentfootprint/security';
```

- **`agentEngineSessions`** — a `SessionLifecycle` over Vertex AI's own
  session service (the API resource is still spelled `reasoningEngines`): the
  session id IS the resource id, so `hydrate` is one `get` by name. Writes
  wait for their long-running operation to report done before returning, or
  refuse — a `persist` that returned early never reports a landing nobody can
  see yet.
- **`memoryBankStore`** — a `MemoryStore` over Memory Bank, a natural-language
  memory service, not a vector store (`supportsVectorSearch: false`,
  `ranksBy: 'server-text'`, so `indexCorpus`/`indexFolder`/`indexDocuments`
  refuse it by name rather than embedding a corpus nothing will ever rank).
  The service answers a Euclidean distance, not a similarity; this adapter
  converts it so ordering comes out right, and keeps the raw distance in
  `entry.metadata.distance` rather than hiding it. `minScore` is refused by
  name — that number is calibrated to a cosine scale this service doesn't
  use, and reinterpreting it would look like a working threshold. Writes are
  scoped, and a stored row under a foreign scope is refused rather than
  overwritten. `forget()` really deletes: it walks and deletes every matching
  memory itself rather than calling the SDK's `memories.purge`, whose `force`
  flag defaults to false — the service's own documented behavior for that
  default is "validated but not executed," which would make a compliance
  erasure report success and delete nothing. (The sibling `AgentCoreStore`
  now declares the same `ranksBy: 'server-text'` for the identical reason, so
  the two server-ranked stores no longer disagree on how they say what they
  are.)
- **`googleIdentity`** — a narrow `CredentialProvider`: it vends _Google_
  access tokens for _Google_ APIs from whatever credential the environment
  already has (ADC, workload identity, an impersonated service account).
  `mode: 'user'` is refused by name rather than quietly served a machine
  token, since Google's user-token equivalent has no Node surface yet.

Five SDK traps found by pinning against a real install and handled once,
here, rather than per adapter:

1. **Regional vs. global host.** The generated client defaults to the global
   `aiplatform.googleapis.com`; sessions and memories are regional resources,
   so the client always sets a regional `rootUrl` derived from `location`.
2. **Operation races.** `sessions.create/delete` and `memories.create/patch/delete`
   answer with a long-running Operation, not the resource — every write
   awaits it to `done` rather than reading a resource that isn't there yet.
3. **Maskless patch replaces.** A `patch` with no `updateMask` replaces the
   whole resource — on a session, clearing the immutable `userId`. Every
   patch here names its mask.
4. **Purge's dry-run default.** Covered above under `forget()`.
5. **Typed metadata.** Memory Bank's metadata map is not free-form JSON; it's
   typed scalars (`stringValue`/`doubleValue`/`boolValue`/`timestampValue`),
   pinned on the wire type rather than assumed.

Every SDK error is sanitized the same way as the rest of the identity/memory
surface: the operation and the error's name travel, never the SDK's own
message, which echoes the request — and a request here can carry conversation
state and an access token.

### Docs

The [Google Cloud](doc:google-cloud) infrastructure page is filled in to the
same template every other provider column follows: a service → adapter map
(door, peer dependency, ops covered, status), the two renamed-product
callouts, and the three new adapters' boundaries stated in the same voice as
the rest of the page.

## [9.26.0] - 2026-08-13

**The server-brain deployment completes: verified identity at the door, spend
bounds per user, recordings and history served over the wire, refs inside
code sandboxes, and one nudge that ends retry loops.**

### Added — recordings as artifacts

```ts
const agent = Agent.create({
  provider,
  artifacts: { store: fileArtifacts({ dir }), recordings: true },
}).build();
```

Every completed run mints its own `{ snapshot, events, structure }` — the
same shape `recordRun` has produced since 8.x — into the artifact store under
kind `'recording/run'`, after the answer is final. No new wire operation: the
existing `{ op: 'artifact-get', ref }` redeems it, so any screen that already
speaks the artifact wire can replay a run it never held a reference to
before this shipped.

It is deliberately **awaited**, not fired-and-forgotten — stated as a cost.
The answer cannot change by the time the mint runs, but a container that
exits the moment it returns a reply would lose a fire-and-forget write, and
that is precisely the serverless deployment recordings are for. The cost is
one store write per turn, on the option. A mint failure (a full store, a
snapshot that will not serialize) can never fail the run: the reason lands as
`agentfootprint.artifacts.refused` and the turn's own answer returns
unchanged.

### Added — `verifyIdentity` + `jwksIdentity`

Bearer-token verification at the hosting door, BEFORE the run's scope is
composed — the ordering is the feature, since that composed scope is what
memory namespaces on, what artifacts isolate on, and what a credential
provider scopes a vault on:

```ts
await standingAgent({
  agent,
  sessions,
  host: nodeHost({ port: 8080 }),
  identity: {
    verify: jwksIdentity({
      jwksUrl: 'https://idp.example.com/.well-known/jwks.json',
      issuer: 'https://idp.example.com/',
      audience: 'my-api',
    }).verify,
  },
});
```

**Configured is closed-by-default.** A request with no `Authorization`
header is refused (401) unless `allowAnonymous: true` is set, and a request
that _names_ a `userId` without proving it is refused either way — a door
that verifies a token when offered and waves the request through when it is
not is a door anybody opens by sending less. `jwksIdentity` is the one
adapter this release ships (`jose`, loaded lazily, pinned against a real
install): signature, `iss`, `aud`, `exp`, `nbf` — not an authorization
decision and not revocation-checked, both stated on the export.

Failure travels as a named class (`expired`, `wrong-audience`,
`wrong-issuer`, `not-yet-valid`, `claimed-another-user`, `unverifiable`, plus
`keys-unavailable` → 503 rather than 401, since an unreachable IdP is this
deployment's outage, not the caller's bad token) — **never the token text**,
in the message, the event, or a log line. Verified `roles` / `claims` flow to
exactly two places: admission's policy decision and the session-history ops.
Nothing else reads them; they do not enter the run's own identity tuple.

### Added — admission / spend

```ts
await standingAgent({
  agent,
  sessions,
  host,
  identity: { verify },
  admission: turnsPerHour({ limit: 60 }),
});
```

`AdmissionPolicy.decide()` answers `'allow'`, `{ queue: true }` (run behind
this session's own in-flight turn instead of refusing it), or `{ refuse:
'<sentence>' }` — the policy writes its own words, because a limit and its
reset are facts only the operator has. The shipped reference,
`turnsPerHour`, is fed by `spendLedger`: a rolling-window, per-caller
accountant built from the token and cost events every run already emits
(`turns`, `inputTokens`, `outputTokens`, and `usd` — **absent, not zero**,
unless a `pricingTable` is configured; "we did not measure that" is a
different fact from "you spent nothing").

Honest boundary, stated on the type: **per-process** accounting. Two
replicas keep two windows; a restart forgets. A deployment that needs one
number across a fleet writes its own `AdmissionPolicy` reading its own
store — same seam, wider ledger.

### Added — code staging-in

```ts
Agent.create({ provider })
  .tool(codeRunnerTool({ runner: localCodeRunner(), wants: { dataset: 'dataset/rows' } }))
  .build();
```

`CodeRunnerTool` gains `wants`, declared exactly like any other tool's
artifact arguments. The model passes the `art_…` ref; the framework resolves
it under the run's own scope (the same `wants` machinery, the same teaching
refusals for a stale, unknown, or wrong-kind ref) and stages the resolved
bytes into the code session as a file — named in the new `AF_STAGED_INPUTS`
environment variable, a JSON object of argument name → path — before the
code runs. Data reaches the interpreter without ever entering the context
window, matching the outbound leg (`CodeResult.artifacts`, 9.22.0) with an
inbound one.

The port grew one optional member, `CodeSession.stageInputs()`; `localCodeRunner`
implements it, `agentCoreCodeRunner` does not yet. Declaring `wants` on a
session that cannot stage refuses BY NAME at dispatch rather than running
code against a file that was never written.

### Added — session-history wire ops

```
{ op: 'session-list' }                       → the caller's own sessions, newest first
{ op: 'session-transcript', sessionId }       → that session's messages, if the caller owns it
```

Both REQUIRE `identity: { verify }` on the door — a listing of "your"
sessions read off an unverified header is enumeration with a friendly
interface, so a door with no verifier refuses the op by name (501) rather
than serving it under a claimed identity. A transcript for a session the
verified caller does not own is one indistinguishable 404 — same law, same
reason, as the artifact wire's not-found.

Transcripts carry `{ role: 'user' | 'assistant', content }` only — never
tool call arguments or tool results, stated explicitly, because a transcript
that quietly dropped the tool leg would be one somebody reconstructs a
decision from and gets wrong. `memorySessions` and `sqliteSessions` both
grew `listByUser` / `ownerOf`; the SQLite adapter adds two nullable columns
via idempotent `ALTER TABLE` with **no schema-version bump** — an older
reader ignores columns it never asked for. The owner is derived at persist
time from the stored conversation's own identity and is write-once: no later
turn can erase or reassign it.

### Added — repeated-call nudge

Field-motivated: a traced production run called one tool three times in a
row with byte-identical arguments and got a byte-identical result each time,
and nothing in the loop could tell the model so. On the second identical
(tool, args) → identical result landing in one turn, the framework appends
one teaching sentence to that result — the call still runs, the result is
otherwise unchanged, nothing is refused, and a third or fourth repeat adds no
further note. `agentfootprint.tools.repeated_call` fires alongside it,
carrying only non-cryptographic fingerprints of the arguments and the
result, never the values.

It is a note, not a wall: a poll-until-status-changes loop is legitimately
identical calls returning identical results, and only the model knows which
kind it is doing. Opt out with `Agent.create({ …, repeatedCallNudge: false })`.
The counters live beside the dispatch loop, keyed by `runId`, never on
tracked scope — an agent that never repeats a call is byte-identical to
9.25.0 in state, snapshot, and every recording.

## [9.25.0] - 2026-08-13

**The reference architecture is complete: artifacts reach the clouds, and
skills declare what data they feed each other.**

### Added — `s3Artifacts` + `gcsArtifacts`

The five-verb store, on S3 and Cloud Storage, law-for-law with the three
shipped adapters: scope-partitioned traversal-proof keys (a tenant of
literally `..` is a name, never a hop), the digest verified on `get`,
retention stated at mint and swept on read, one indistinguishable miss for a
wrong scope, a foreign object, or an expired one. SDK surfaces are pinned
against real installs, and both are optional peer dependencies loaded lazily
at construction — a browser bundle sees nothing. Status: contract-shaped and
tested; awaiting field use.

S3-compatible on-prem stores (MinIO and the like) work the same way: build
the client against your own endpoint and path style and pass it as `client` —
the adapter dispatches the same five commands either way. Documented on the
[AWS](doc:aws) and [on-premises](doc:on-premises) pages.

### Added — skill artifact vocabularies

`defineSkill` and `SkillStep` gain `produces` / `consumes` (artifact kinds).
The checkup gains `artifact-kind-unsatisfied`: a declared consumption that
nothing on the path produces warns at build time — honest about what static
analysis cannot see (a tool's undeclared mint, an artifact from an earlier
run, from another agent, from outside the process). The skill graph and the
artifact store now speak the same vocabulary.

### Added — optional streaming on the port

`putStream` / `getStream` as feature-detected members (`canStreamArtifacts`,
`canPutArtifactStream`, `canGetArtifactStream`) — a store that cannot move
bytes without holding them whole leaves them absent rather than faking one.
`fileArtifacts` and both cloud adapters stream natively; `inMemoryArtifacts`
and `sqliteArtifacts` honestly do not.

### Quality notes

One shared contract suite now runs all five adapters — a cloud column that
drifts from the port fails the shipped adapters' own tests. A vendor-
neutrality guard covers the artifact port (the two adapter files are exempt
by name; everything else may not name a cloud). Raw SDK errors are sanitized
on every path — the operation, the exception's name and the HTTP status
travel; the vendor's own text, which echoes the bucket and the scoped key,
does not.

## [9.24.0] - 2026-08-13

**A person answers through a typed panel: the ask carries a component, the
options ride the store, and the decision returns as a fact.**

### Added — `AskComponent` on all three ask doors

```ts
component?: { componentId: string; props?: Record<string, unknown>; propsRef?: ArtifactRef }
```

`askHuman({ component })`, middleware `ask({ component })`,
`defineTool({ checkIn, checkInComponent })`. `componentId` is FE-registry
vocabulary — never markup. Big option sets ride `propsRef` through the
artifact store so the checkpoint stays lean (pinned: a 200-option ask via
`propsRef` keeps the payload out of the checkpoint entirely).

### Added — raise-time validation, one gatekeeper

Shape → store attached → the ref resolves in the run's own scope — refused
loudly AT THE SOURCE (a pause with a dead ref would strand the human).
Declaration-time refusals: `checkInComponent` without `checkIn`; a static
`propsRef` on a storeless agent.

### Added — the surface that collected a decision is on the record

`componentId` stamped additively on `checkin.decision`, `middleware.decision`,
and `pause.resume`. The decision itself is unchanged — structured, never
parsed from prose.

### Behavior note (recorded honestly)

The `component` key in an `askHuman`/`pauseHere` `pauseData` bag was
previously uninterpreted; it is now reserved and read as `AskComponent`
(validated at raise). Any consumer that used that key for private data must
rename it.

### Zero-cost when unused

Componentless asks are byte-identical — payload key sets, checkpoint, and
events all pinned.

## [9.23.0] - 2026-08-13

**The screen redeems claim tickets: artifact resolution joins the hosting
wire, scoped to the session that asks.**

### Added — two wire operations on the existing invoke path

```ts
{
  op: 'artifact-head', ref;
} // → meta
{
  op: 'artifact-get', ref;
} // → meta + data
```

Resolved under the requesting session's identity-composed scope — exactly
the scope the run's own tools used. A ref from another session, the wrong
identity, an expired artifact, and a never-minted ref all return one
indistinguishable 404 (`ERR_ARTIFACT_NOT_FOUND`) — pinned byte-identical.

### Added — port surface

`HostRequest.artifact` + a fourth `HostReply` terminal, `artifact(result)`
(with a named not-carried fallback, `ERR_ARTIFACT_NOT_CARRIED`). Both
shipped wire dialects carry the ops; the grammar has one owner
(`artifactWire`) exported for custom dialects. `Agent.getArtifactStore()`
is the composer door.

### Read-only by design

No put, delete or list over the wire — a screen redeems tickets, it does
not mint, sweep, or ENUMERATE a scope. Citable.

### Teaching refusals

No store attached → 501 naming `Agent.create({ artifacts })`. No session →
400 (there is no bare-ref mode). Malformed op → 400. An op-carrying body
never falls through to a model turn.

### Behavior note (recorded honestly)

A legacy invoke body that carried a top-level `op` field previously fell
through to a model turn; it now answers 400 `ERR_INVALID_WIRE_OP`. `op` is
reserved on the invoke path from this release.

### Events

Wire redemptions ride the existing `agentfootprint.artifacts.resolved` /
`.refused` events exactly once; the `tool` field is now honestly optional
— the redeemer was the hosting door.

## [9.22.0] - 2026-08-13

**The model routes claim tickets: tools receive resolved data, screens
receive described refs, and six megabytes of freight costs two metadata
lines.**

### Added — ref arguments (`wants`)

```ts
defineTool({
  name: 'summarize',
  wants: { dataset: 'dataset/rows' },
  execute: async (args, ctx) => {
    /* args.dataset is the RESOLVED DATA */
  },
});
```

The model passes a ref string; dispatch resolves it under the run's own
scope and kind-checks it BEFORE the tool runs. The handler receives the
data — never the ticket — plus the meta on `ctx.wanted`. A stale,
unknown, or wrong-kind ref never reaches the tool: the model reads a
teaching refusal listing the live refs of that kind in its own scope, so
the correction is what to pass, not a retry of the same dead ref.

### Added — the `present` tool

Auto-attached only when a store is attached. `present({ ref, as, label })`
verifies the ref and returns a description snapshot — `{ kind,
mediaType, bytes, label }` — INSIDE the tool result. The claim ticket
describes the parcel, so a conversation reloaded after the artifact has
expired can still render an honest placeholder from history. New typed
event `agentfootprint.artifacts.presented`. The model never serializes
what the screen will show.

### Added — the placement threshold

```ts
Agent.create({ artifacts: { store, placement: { maxInlineChars: 10000 } } });
```

A tool result over the threshold is checked into the store (kind
`tool-result/<toolName>`, the exact displaced text) and the model
receives the ticket: ref + meta + how to consume it. Ceiling precedence,
stated and pinned: the tool's own `resultCeiling` (the author's refusal)
first, placement second, the agent-level truncation net last. A placed
result is a ticket, not a refusal — it still advances steps and keeps
its declared effects.

### Added — code results join the store

Files a code run produces are minted into the same store — deterministic
`file/<ext>` kinds, origin stamped — and `CodeResult.artifacts` entries
gain a `ref`. Staging refs INTO code sessions is deferred and stated as
such.

### Security

Every new door is scope-locked: another session's ref resolves to
nothing, and refusal listings name only the caller's own live refs.

### Zero-cost when unused

Pinned on all four legs — no `wants`, no `present`, no `placement`, no
code-result minting, and behavior is byte-identical to 9.21.0.

This is Phase 2 of the reference-architecture work, the data legs, on
top of the artifact store shipped in 9.21.0.

## [9.21.0] - 2026-08-13

**Data stops riding the conversation: the artifact store — a tool checks
its result in and hands back a claim ticket.**

### Added — the ArtifactStore port

Five verbs: `put`, `head`, `get`, `delete`, `list` — scope is always the
first argument. `get` returns `null` for missing-or-expired, never a
thrown error; a store is a claim-check, not a query engine, so there are
no query or transform verbs, by design and citable.

Refs are opaque minted ids (`art_…`) — never content-addressed. The
sha-256 digest of the payload rides as metadata and is verified on
`get`; a mismatch is a named integrity error, never silent corruption.
`parentRefs` are derivation facts validated at mint time — a parent ref
that doesn't resolve is refused on the spot, a foreign key that cannot
dangle at birth.

### Added — three adapters

`inMemoryArtifacts`, `fileArtifacts`, `sqliteArtifacts`. `inMemoryArtifacts`
is always bounded per scope (32MiB / 256 rows, LRU, drop-counting) — there
is no unbounded mode. `fileArtifacts` is scope-partitioned with
traversal-proof paths. `sqliteArtifacts` pairs with the existing
`sqliteSessions` adapter — same lazy-dependency loading and the same
schema-refusal laws.

### Added — `ctx.artifacts`

Shaped like `ctx.credentials`: always present on the tool context,
fail-closed with a teaching refusal naming `Agent.create({ artifacts })`
when no store is attached. Scope is composed by the framework from the
run's own identity — a tool can never widen it — and origin (`runId`,
`toolCallId`) is stamped from the run's own facts, never supplied by the
tool.

### Added — four typed events

`agentfootprint.artifacts.minted` / `.resolved` / `.expired` / `.refused`
— meta-only payloads, the bytes never enter the event. A ref alone opens
nothing: resolution requires the session's scope, so the same ref under
another tenant resolves to `null`, on the record.

### Retention

A `ttl` stamps `expiresAt` at mint time — stated, never sprung on a later
read. Budget evictions are reported by the `put` call that caused them
and counted, not swallowed.

### Zero-cost when unused

No store attached is a byte-identical agent — same behavior, same
events — pinned by regression test.

This is Phase 1 of the reference-architecture work. The data legs — ref
arguments at dispatch, the present tool, the placement threshold — come
next.

## [9.20.0] - 2026-08-13

**Two-dimensional rules as data, and oversized results that teach instead
of truncate.**

### Added — conjunction matcher

```ts
{ match: { all: [/zone/i, { keywords: ['audit', 'sweep', 'all'] }] }, use: 'audit-skill' }
```

A rule fires only when EVERY member of `all` matches. The 2D routing case
— "zone AND audit-shaped" — becomes declared data instead of a hand-rolled
condition: drawn by `toMermaid()` with its parts joined by `AND`, compared
by the checkup (provable shadows only — a conjunction sitting above its
own broader fallback is a supported design and is never warned about),
and stored on provenance. An `intent` condition inside `all` is refused at
compile time, with the alternative named; a nested `all` is flattened
rather than refused — AND is associative, so the stored data describes
what actually runs.

### Added — the refusing result ceiling

```ts
defineTool({
  resultCeiling: { maxChars: 10_000, narrowBy: ['vsan', 'wwpn'] },
});
```

Over the ceiling, the model reads a teaching refusal — `Result too large
(N chars). Narrow and call again: pass vsan or wwpn. No data was
returned.` — and the oversized payload never enters context, history, or
recorders. The record keeps the TRUE size via the new typed event
`agentfootprint.tools.result_refused`; delivered status is `'invalid'` so
`onToolStatus` edges route on it; a tool's declared effects still apply (a
proposed transition survives its own oversized payload); a procedure step
never advances on a refusal.

A truncated result the model cannot tell is partial produces confident
summaries of the wrong subset. A refusal that names the narrowing
parameters produces a clean retry — field-verified.

Composes with the agent-level `maxToolResultChars` (the operator's net,
truncate-with-verbatim-head): the per-tool ceiling is the tool author's
teaching layer underneath it.

## [9.19.0] - 2026-08-13

**The cursor picks the brain, and tools stop smuggling control through
prose.**

### Added — per-skill brains

```ts
defineSkill({
  id: 'refund',
  provider: strongProvider,
  model: 'refund-strong-model',
  tools: [issueRefund],
  body: 'Check the order, then issue or deny.',
});
```

A skill graph already decides WHERE the run is; a per-skill brain lets
that same position decide WHO answers. `defineSkill({ provider, model })`
keeps the choice beside the skill it serves; `.skillGraph(graph, {
providers })` keeps a fleet's choices in one place at the mount — same
meaning, two homes. While the cursor holds a skill with a declared brain,
every LLM call in that tenure runs on it; which brain answered is stamped
on `llm_start`.

Precedence is stated and enforced, most specific wins: **escalation
brain > per-skill brain > `.configure()`'s run model > the build
default.** A brain naming only a model inherits the agent's own provider;
a brain naming a _foreign_ provider without a model is refused at
`Agent.build()` — the run's configured model belongs to another vendor's
namespace and would fail mid-turn, on exactly the iteration the cursor
enters the skill.

### Added — escalate-on-evidence

```ts
.skillGraph(graph, { escalation: { provider: strongProvider, afterRefusals: 2 } })
```

`N` gate refusals in one turn (`skill.rejected` — reachability or
posture, real recorded refusals, never vibes) flip the rest of that turn
onto the escalation brain. Recorded once as
`agentfootprint.skill.escalated`. The flip resets at the start of the
next turn — evidence, not a standing setting.

### Added — the tier-3 decider

```ts
.skillGraph(graph, { decider: { provider: smallProvider } })
```

An out-of-band constrained-enum pick (a declared skill id, or `'stay'`
— never free text) resolves a routing menu the earlier rungs left
outstanding. `turn_routed.by` gains `'decider'`. It is the sanctioned
resolver for a `rails`-posture menu, which otherwise proceeds on the base
prompt with nothing decided.

### Added — typed tool effects

A tool may return `{ content, effects, status }` instead of a bare
value. Plain `{content}`-shaped returns — and every string a tool
returns today — stay byte-identical; the envelope is recognized only by
its own strict shape.

- **`propose-transition`** — `{ kind: 'propose-transition', targetSkillId,
reason }`. The typed replacement for a string routing marker: the
  _graph_ decides. A same-batch declared edge still wins; an unreachable
  target is refused out loud, not silently dropped.
- **`require-instruction`** — `{ kind: 'require-instruction',
instructionId, deliveryLease: 'next-call' | 'until-skill-exit' }`.
  Pushes a _registered_ instruction into the coming call(s) —
  `read_skill` stays the pull door for optional knowledge; this is the
  push door for mandatory procedure, and it only pushes what was
  registered at build. An unknown id is refused, never improvised.

Every acceptance or refusal is a typed `agentfootprint.tools.effect`
event carrying a teaching note the model can act on.

### Added — route on meaning

`onToolStatus` route edges match a tool result's declared outcome —
`success | failure | denied | invalid | partial | pending` — so a denied
call can never route like a success. Composable with `onToolReturn`;
drawable in `toMermaid()`.

### Law

Push mandatory procedure. Pull optional knowledge. Never let arbitrary
text promote itself into control authority.

## [9.18.0] - 2026-08-12

**Procedures become data: the framework holds the step pointer, the model
fills in the blanks — and the whole cascade now narrates itself.**

### Added — steps as data

```ts
defineSkill({
  id: 'refund',
  tools: [findOrder, checkHistory, verifyIdentity, approveRefund, issueRefund, fileReceipt],
  steps: [
    { tool: 'find_order', note: 'find the order before touching money' },
    { tool: 'check_history', note: 'confirm the duplicate charge' },
    { tool: 'verify_identity', note: 'verify the caller owns the card' },
    { tool: 'approve_refund', note: 'a person approves before money moves' },
    { tool: 'issue_refund', note: 'refund the duplicate charge only' },
    { tool: 'file_receipt', note: 'file the receipt for audit' },
  ],
  onSkip: 'advance', // or 'hold'
});
```

While a stepped skill is active the framework injects the current step
every iteration ("step 3 of 6: …"), offers that step's tool plus every
intact escape hatch (`read_skill`, other active skills' tools), advances
the pointer on the tool's return — including across pause/resume, so a
human-in-the-loop step resumes with the pointer intact — and records
everything as it happens. A skill declared without `steps` is
byte-identical to before (pinned by regression tests).

### Added — `skip_step`

The model may decline the current step with a required reason. The reason
is recorded, never silently lost. `onSkip: 'advance'` (the default) moves
the pointer on; `'hold'` keeps the step current so the model can retry it,
work around it, or finish and explain. A premature stop with steps
unfinished gets one teaching nudge — never a forced continue — and a second
stop is honored.

### Added — three typed events

`agentfootprint.skill.step_advanced`, `agentfootprint.skill.step_skipped`,
`agentfootprint.skill.steps_unfinished` — every pointer move, skip, and
early stop lands on the record.

### Added — build-time teaching refusals

Four combinations that could never honor a declared procedure are refused
at `Agent.build()`, naming both the problem and the fix, instead of
silently activating a procedure that never engages:

- a step naming a tool the skill doesn't carry (refused at `defineSkill`,
  where the tool list and the steps arrive together);
- `steps` on an OPEN skill of a mounted skill graph (activated by
  `read_skill`, never receiving the cursor a procedure's tenure depends on);
- `steps` on a decision `.tree()` leaf, or beside one (a tree never writes
  a cursor);
- `steps` on a skill with no mounted graph and a non-`llm-activated`
  trigger (no `read_skill` activation, so no tenure ever begins);
- `reactMode: 'classic'` with `steps` (classic freezes the tools slot after
  turn 1 — the per-step narrowing would freeze with it).

### Added — the story rail narrates routing

Commentary templates render `agentfootprint.skill.turn_routed` verdicts
with the numbers behind them (intent scores + runner-up, a near-tie hold's
two closest shares), `route_conflict` suppressed hops (which tool result
lost and why), `strictness` posture refusals (`guard`/`rails`), and a
model pick's divergence from the menu it was offered. The trace recorder
taps these into the next beat as they happen, not after the fact. Pairs
with the Why Lens 0.33.x narration.

### Deprecation reminder

`refreshPolicy` remains deprecated (9.16.0) and still does nothing —
per-step injection is its successor. Dev mode now also warns when a skill
sets both `refreshPolicy` and `steps`.

## [9.17.0] - 2026-08-12

**The turn starts where the conversation is — a routing cascade that
consults the model last, and says exactly which rung decided.**

### Added — the turn-start routing cascade

Every turn now resolves through one ordered cascade instead of one rule:

```
sticky cursor (continuity: 'conversation')
  ← declared rules
  ← a scorer over declared intents
  ← the model's menu
```

Each rung is tried in that order; the first one that produces a verdict wins.
Every verdict — win or fall-through — is recorded on a new typed event,
`agentfootprint.skill.turn_routed`: `by`, `from`, `to`, ranked scores
including losers, the runner-up gap, the policy numbers verbatim, and
`droppedResume`. Near-ties fall through to the next rung rather than
argmax-ing a coin flip. The cascade runs once per turn, off the hot loop —
the cursor resolver itself stays synchronous.

### Added — intents as data

```ts
{ match: { intent: 'refund_request', examples: ['I want a refund', 'charge me back'] }, use: 'billing' }
```

`match: { intent, examples }` joins regex and keywords as a third matcher
arm. It is scored by a pluggable `IntentScorer` port that scores **every**
candidate — never hands back a bare winner. Built-ins:

- `keywordScorer()` and `embeddingScorer(embedder)`, both widened to the new
  scorer shape.
- `llmClassifier(provider, { window? })` — new: a constrained enum
  (declared skill id, or `'none'`), never free text feeding routing
  decisions.

`skillGraphCheckup` gains intent audits — duplicate examples across skills,
leave-one-out overlap — with their honesty boundaries stated up front
(what the audit can and cannot promise).

### Added — `strictness` on `.skillGraph(graph, options)`

```ts
.skillGraph(graph, { strictness: 'guard' })
```

Three postures:

- `'assist'` — today's behavior, and the default.
- `'guard'` — picks are pinned to the offered set (or stay) while a menu is
  outstanding.
- `'rails'` — the model never routes; a menu under rails proceeds on the
  base prompt, recorded as `by: 'none'`. That's the honest cost of the
  posture, and it's documented as one.

Un-honorable combinations (`rails` × `entryByRead`, `conversation` ×
`tree`) are refused at build time with a teaching message, not a silent
no-op.

### Added — `continuity: 'conversation'`

```ts
.skillGraph(graph, { continuity: 'conversation' })
```

The previous turn's cursor becomes the default entry on a resumed
conversation, riding the existing run checkpoint — no second cursor, and
opt-in only. If the graph was redeployed and no longer recognizes the
inherited cursor, it is dropped and the drop is recorded, never silently
kept.

### Safety

Role-hidden skills stay out of candidates, scores, menus, and the envelope
end to end. A hidden-skills resolver that throws fails **closed** — nothing
is offered that turn, and dev mode warns. A lone non-finite score reads as
unmatched, never as an uncontested winner.

### Zero-cost when unused

Graphs that use none of the new options are byte-identical in behavior
_and_ events to 9.16.0 — pinned by regression tests. 78+ new tests cover
the cascade, the scorers, the strictness postures, and continuity.

### Deferred

Per-skill model/provider switching and escalate-on-evidence are named in
the design but not in this release; they land in a later minor.

## [9.16.0] - 2026-08-12

**Parallel tool batches stop lying to the router, and a combination that could
never be honored now says so at build time.**

### Fixed — batch routing

Before this release, when a turn's tool batch came back with more than one
result, only the **last** tool call in the batch drove skill-graph routing and
`onToolReturn`. Two identical parallel calls could route to different skills
depending only on where each one happened to land in the message — an
ordering bug with no error, no log line, just a silently different cursor.

- Every result in the batch now routes, in call order. The first match wins
  the cursor; the loop still stops there (a router picks one skill per turn).
- A later result that would have matched a **different** skill is suppressed,
  not silently dropped — it emits `agentfootprint.skill.route_conflict`:

  ```ts
  {
    winner:  { toolCallId, toolName, target },
    losers:  [{ toolCallId, toolName, target }],
  }
  ```

  On the record, so a trace answers "why didn't the second call route?"
  instead of leaving the reader to guess.

- Single-tool iterations are byte-identical to 9.15.0 — this only changes
  behavior when a batch actually contains more than one result.
- New `AgentState.toolResults` / `InjectionContext.toolResults`: the full
  batch, in call order, each entry carrying its `toolCallId`. `lastToolResult`
  is unchanged (it is now defined as the last entry of `toolResults`). Rule
  `when` predicates can read `ctx.toolResults` directly via the new
  `toolResultsOf(ctx)` helper.

### Added — build-time teaching refusal: classic + a skill graph

`reactMode: 'classic'` caches the system prompt and tool list after turn 1.
Wiring a `.skillGraph(...)` onto a classic agent meant the graph would still
route and the trace would still show an activation — but the model never saw
the newly-active skill's prompt or tools, because the slot it would have
changed was already frozen. The configuration _looked_ like it worked and
didn't.

`Agent.build()` now refuses this combination outright, naming both the
problem and the fix:

```
AgentBuilder.skillGraph: reactMode 'classic' cannot honor a skill graph.
Classic caches the system prompt and tools after turn 1, so a route-driven
activation would move the cursor and the trace but never reach the model.
Use reactMode: 'dynamic' or 'dynamic-grouped', or drop .skillGraph(...) and
compose the always-on skills you need directly.
```

Classic without a graph is unaffected, and so is a turn-1-only composed set
of always-on skills. This was previously only a dev-mode console warning;
the docs already said not to do this. Now the build says it too.

### Deprecated — `refreshPolicy` on `defineSkill`

`refreshPolicy` has been accepted and stored since it was added, and never
read by anything — it did not do what its name promised. It is now marked
`@deprecated` and triggers a one-time dev-mode warning naming the
replacement direction (a coming steps-as-data feature) when set. It will be
removed in the next major.

### Verified & pinned

Two lifecycle questions settled with end-to-end probes rather than left as
folklore:

- An open skill picked via `read_skill` stays active until the turn ends —
  by design, and now documented as a test rather than tribal knowledge.
- The batch-order routing bug above has a repro that fails on the old
  behavior and passes on the fix, so it cannot silently come back.

## [9.15.0] - 2026-08-12

**The skill graph's front door, simplified.** Same graph, fewer things to type,
and the routing table becomes something the library can read, lint, and draw —
not a bag of opaque functions.

### Added — `scopeTools` on the flat graph (one line, not one per skill)

```ts
const graph = skillGraph({
  skills: [billing, shipping, returns],
  start: { rules: [{ match: /refund|charge/i, use: 'billing' }] },
  scopeTools: true, // every wired skill's tools appear only while it is active
});
```

What previously required `autoActivate: 'currentSkill'` typed on every single
skill is now one graph-level line. A skill's own explicit `autoActivate` always
wins — the graph sets a default, never an override. Only _wired_ skills (named
by an entry or a route) are stamped: an unwired skill's tools would otherwise
never appear at all. With the dial absent or `false`, compiled skills are
byte-identical to 9.14.0 (pinned by test). On a `tree()` graph the flat-arm
dial is refused with a pointer to `tree(root, { scopeTools })` — one dial, one
home. The default stays `false` in 9.x; it flips in 10.0.0.

### Added — matchers as data: `match` beside `when`

```ts
start: {
  rules: [
    { match: /refund|charge/i, use: 'billing' },          // RegExp form
    { match: { keywords: ['track', 'package'] }, use: 'shipping' }, // keywords form
    { when: (ctx) => ctx.iteration > 1, use: 'triage' },  // predicates still work
  ],
}
```

A rule now takes `match` (data) or `when` (function) — exactly one; both or
neither is refused at the type level and at runtime with the fix in the
message. Keywords are case-insensitive escaped literals (any present matches,
whole-word at word-character edges — `refund` never fires on `refunds`);
stateful regex flags (`g`/`y`) are dropped at compile so the same message can
never alternate answers. Because the matcher is data, it is **drawn**
(`toMermaid()` captions the entry edge), **stored** (`SkillMatchData` on the
skill's provenance and the entry edge), and **compared** (below). The union is
extensible by design — a future `{ intent, examples }` arm lands without
reshaping.

### Added — three check-up codes for the rules form

- `rule-id-exists` (ERROR): a rule routing to a skill not in `skills[]` refuses
  to build under every `check` mode, listing all bad ids and the known catalog.
- `overlapping-rules` (warning): two data matchers provably overlap (a shared
  keyword) and declaration order decides those messages.
- `rules-shadowed-by-order` (warning): a later rule provably can never win —
  an identical regex earlier, or an earlier keyword superset.

The comparisons claim only what data proves: `when` predicates are opaque and
the messages say they were not checked. And the `multi-entry-fanout` warning no
longer fires on routers where every entry carries a `when` or `match` —
deterministic rule-routing is a supported design, not a smell.

### Changed — `knownTools` is now automatic at agent build

A graph built without `knownTools` defers its two body-contract checks
(`body-foreign-tool`, `body-unknown-tool`) instead of guessing: the deferral
note travels on each compiled skill's metadata, and `Agent.build()` — the one
point that sees the full tool registry — runs the checks exactly once, whether
the graph arrived via `.skillGraph(graph)`, `.skills({ list: () => … })`, or
`.skill()`. Passing `knownTools` by hand still works and keeps today's
graph-build-time behavior. `graph.checkup()` is unchanged. ToolProvider tools
cannot be enumerated at build time — pass those via `knownTools`.

### Docs

The object-literal form is now the canonical taught form (the fluent builder
remains fully supported). New module README for the skill-graph family
(`src/lib/injection-engine/README.md`) and a new runnable example
(`examples/features/54-skill-graph-front-door.ts`).

## [9.14.0] - 2026-08-12

**A strategy that said so now does so.** `defineMemory({ strategy: { kind:
SUMMARIZE } })` has required an `llm` since it existed and never called one: the
compression stage sat in `src/memory/stages/summarize.ts` composed into no
pipeline, so `EPISODIC × SUMMARIZE` behaved as `WINDOW(recent)` — eight turns
through it with a counting provider made **zero** `complete()` calls. 9.5.0
wrote that fact down honestly, in `listMemoryStrategies()`, the docs table,
MENTAL_MODEL.md, AGENTS.md and the factory arm. This release deletes all five
sentences by making them false.

### Added — SUMMARIZE compresses, once per span, and keeps the originals

```ts
const memory = defineMemory({
  id: 'long-chat',
  type: MEMORY_TYPES.EPISODIC,
  strategy: {
    kind: MEMORY_STRATEGIES.SUMMARIZE,
    recent: 6, // the 6 newest entries stay verbatim
    size: 20, // how much history to load per turn
    llm: anthropic(), // its OWN instance, not the agent's
    model: 'claude-haiku-4-5', // named explicitly — no fallback
  },
  store,
});
```

What runs, per turn: load `size` entries, keep the newest `recent` verbatim
(seam rounded outward to a whole turn, so a question is never folded away from
its answer), fold everything older with **one** call to `model`, and **write the
summary back to the same store** under `msg-summary-{fromTurn}-{toTurn}`. Recall
becomes `[summary, ...recent verbatim]`. Because the summary is stored, a span is
compressed **once in the life of a conversation** rather than once per recall —
that write-back is the entire cost model, and it survives a fresh `Agent` (or a
fresh process) per turn, because the store is what remembers.

**The folded originals are never deleted.** They stay in the store byte-identical
and are excluded from recall by the summary's coverage metadata
(`metadata.summarizes.coveredIds`) and by nothing else — delete the summary entry
and the next recall is verbatim again. A summary is a claim ABOUT the
conversation, the same law `.compaction()` follows in the live window.

### Added — the summarizer names its model, and may not be the agent itself

`model` is **required** on the strategy, with no `?? agentModel` fallback: the
8.14.0 `.compaction()` law, applied to the second door that spends money on your
behalf. The deleted default had no correct case — the same provider family
quietly bills your MAIN model for compression, and a different vendor is sent a
model id it has never heard of, mid-conversation, on a paid run.

`Agent.memory()` now also refuses a summarizer that is the agent's own provider
**instance** at the agent's own model (the narrow 8.14.0 rule; a _second
instance_ of the same vendor at the same model is allowed and sometimes right).
`defineMemory` cannot make that check — it has never heard of an agent — so a
`MemoryDefinition` now declares `billing: { provider, model }` and the builder
reads it, the same field and shape `WindowStrategy.billing` already used. One
refusal, three doors.

Two more refusals at build, both naming the line that fixes them: `recent >=
size` (a verbatim tail as large as the window means nothing older is ever
loaded, so the summarizer could never fire — a paid dependency wired to a stage
that cannot run), and `readOnly: true` (the write-back IS the cost model, so
"nothing is ever stored back" and SUMMARIZE contradict each other).

`listMemoryStrategies()` reports `requirements: ['llm', 'model']`. They are two
requirements rather than one because a deployment can hold a provider and still
have no answer for which model compression should run on — and a library that
picked one would be picking your invoice.

### Added — three ways it declines, all of them out loud

Every one emits the existing `agentfootprint.memory.strategy_applied` with a
reason a reader can act on, plus the model and the token usage the call
reported. **Not `cost.tick`** — the USD channel needs a `pricingTable` and the
run's cumulative counters, both of which live on the Agent's scope, and a memory
pipeline is a subflow with neither; a tick reading `estimatedUsd: 0` would be a
cheaper-looking lie than saying nothing, so the tokens ride the memory event and
the fold is never silent:

- **not worth a call** — fewer foldable entries than the floor. No call, no
  change. (`defineMemory` sets the floor to the size of the verbatim tail: never
  fold less than you keep.)
- **summarizer failed** — one `console.warn` per stage instance, one event, and
  recall proceeds **VERBATIM**. A broken compressor degrades this strategy to
  `window`; it does not fail the turn. (Through 9.13.0 the stage re-threw. A
  memory that cannot recall because its optional compressor is down is a worse
  answer than an uncompressed one.)
- **replacement-not-smaller** — the summary plus its authored label is no shorter
  than the span it would replace, so the fold is dropped and the span is
  **latched** by its own entry ids: the same question is not bought twice. A span
  that has GROWN is a different key and is asked again, on purpose (the 8.14.0
  latch, same reasoning, same shape).

Prompt-injection boundary, both directions, as `.compaction()` has it: going out,
the span is rendered as DATA between delimiters the authored instruction names;
coming back, the summary is appended after an authored label the library wrote,
so an entry always says in the library's own words and first that what follows is
a model's claim and that the originals are retained.

### Changed — behaviour worth knowing before you upgrade

- `EPISODIC × SUMMARIZE` now makes LLM calls where it previously made none. It is
  the same config shape plus a required `model`, so an existing definition
  fails at `defineMemory` (naming the fix) rather than silently starting to spend.
- The `summarize` stage's `llm` accepts an `LLMProvider` (+ `model`) as well as
  the 2.x `(messages) => Promise<string>` callback. The callback keeps its exact
  contract; passing `model` with a callback is refused, since nothing would read
  it. The provider form is what reports token usage to the event.
- A summary entry is filed at the time of the **material it stands for** —
  strictly one millisecond after the newest entry it covers — not at the time of
  the fold. Order depends on it (a claim about turns 1–7 stamped `now` sorts
  after turn 12 and reads as if it happened last), and so does correctness: being
  strictly newer than everything it covers is what guarantees a recency-limited
  load can never admit a covered original while dropping the summary that
  excludes it. Anchoring on the tie instead lets a page boundary separate them,
  and the span is then folded a second time under an overlapping id — found by
  the end-to-end probe, and pinned by test.
- A write TTL applies to the summary on the **span's** clock, so a retention
  window ("delete chat history after 30 days") expires the summary with the turns
  it compressed instead of days after them. Decay scores it by the same age, so a
  summary of last month fades on last month's schedule rather than passing as
  fresh.
- `defaultPipeline` takes a `summarize` config and composes a `Summarize` stage
  directly after the load — before decay and before the budget picker, because
  both decide against what recall CONTAINS. Absent config means an absent stage,
  never a stage that runs and does nothing (the `decay` precedent).

### Removed — the caveat, everywhere it was written

The 9.5.0 honest-caveat text is gone from `listMemoryStrategies()`'s description,
the `missingRequirement('llm')` refusal, the `defineMemory` dispatch table and
arm comment, `docs/MENTAL_MODEL.md` (§7, the latent-gap block and §14),
`docs-next` (`build/memory.mdx`, `infrastructure/memory-and-stores.mdx`) and
`AGENTS.md`. A test now asserts the description does NOT carry it, in both
directions — a caveat that outlives the gap is the same kind of lie as a gap that
outlives its caveat.

### Tests + example

`test/memory/summarize-wired.test.ts` is the 9.5.0 probe with its assertion
inverted (8 turns, counting provider, `complete()` calls > 0), plus: the summary
lands in the store under its deterministic id, covered originals are excluded
from recall AND still present, the recent turns survive verbatim, no two folds
ever cover the same entry, a fresh Agent per turn keeps the same books, the loud
degradation, and every refusal. `test/memory/stages/summarize.test.ts` grew to 31
tests across the 7 patterns. `examples/memory/03-summarize-strategy.md` and its
runnable example — which passed a summarizer that never ran — now assert that it
did, that the summary was stored, and that every summarized-away original is
still in the store.

## [9.13.0] - 2026-08-12

**The third provider column opens, and it opens with the two adapters that can be
built honestly today.** Google Cloud joins AWS and on-premises as a documented
column. What ships is `gemini()` — a native `LLMProvider` over `@google/genai`,
not a `baseURL` on the OpenAI one — plus `geminiEmbedder()`, plus a pin that
checks its own claims against the really-installed SDK. What does not ship says
why, with a date.

### Added — `gemini()`: the native provider, on both of Google's doors

```ts
import { gemini } from 'agentfootprint/providers';

const vertex = gemini({ project: 'my-project', location: 'us-central1' }); // ADC
const studio = gemini({ apiKey: process.env.GEMINI_API_KEY! }); // one key
```

Two doors, one adapter, and neither is guessed: a project selects Vertex, a key
selects the Gemini API, and configuring **neither** is refused at construction
naming both — because the SDK's own behaviour in that case is to warn on stderr,
construct anyway, and fail on the first call with something that reads like a
network problem. An empty environment variable reads as absent.

Four things this adapter can do that `openai({ baseURL })` against Google's
OpenAI-compatible endpoint cannot, and they are the whole reason it exists:

- **Honest cached and reasoning tokens.** `usage.cacheRead` ←
  `usageMetadata.cachedContentTokenCount` and `usage.thinking` ←
  `thoughtsTokenCount`, each its own number. The compat endpoint's documented
  response has neither field, so a cost dashboard behind it can only ever show a
  total.
- **Tools stay JSON Schema.** `FunctionDeclaration.parametersJsonSchema` takes
  your schema untranslated. The compat endpoint's `function.parameters` is an
  **OpenAPI** subset, where `$ref`, `oneOf` and `additionalProperties` mean
  something else or nothing — a divergence you discover from a model that ignored
  half your constraints.
- **`carriesForcedToolChoice: true`, earned.** `toolConfig.functionCallingConfig`
  with `mode: 'ANY'` and one `allowedFunctionNames` entry really does constrain
  the answer to that function, on both doors — so
  `.outputSchema(parser, { strategy: 'tool-forced' })` works rather than refusing.
- **Auth that does not expire in an hour.** ADC refreshes itself; the compat
  endpoint takes an OAuth bearer with a 60-minute life and no refresh home in
  `OpenAIProviderOptions`.

Also on the wire: `systemInstruction` as a top-level field, which makes this an
**Anthropic-family wire** — `carriesInMessages` is `['user', 'assistant']`, so a
`slot: 'messages'` injection with `role: 'system'` is refused at run start rather
than silently dropped; `thinkingConfig.thinkingBudget` from `.thinking({ budget })`;
`abortSignal` threaded; `stopSequences`, `temperature` and `maxOutputTokens`.

Three decisions worth reading before you rely on them:

- **Tool-call ids are sometimes invented, and never sent back.**
  `FunctionCall.id` is optional on Gemini's wire and Vertex routinely omits it,
  while the agent matches a tool result to its call BY id — so an absent id is
  synthesized (`gemini-call-N`, in call order, per provider instance, the
  `ollama()` precedent). Sending an invented id back would be a
  `functionResponse.id` the service never issued, so it is stripped on the return
  trip; Gemini matches by NAME, which is always present.
- **No thought summaries are requested.** `usage.thinking` is reported, but
  `includeThoughts` is deliberately unset: Gemini's thought parts carry a
  `thoughtSignature` that must be echoed byte-exact on the next turn, and there is
  no Gemini `ThinkingHandler` in this release to round-trip them. Asking for
  content nothing can carry back would be a leak, not a feature. A thought part
  that arrives anyway is kept out of the visible answer on both paths.
- **A stream that reports no usage reports ZERO, never an estimate.**
  `models.countTokens` is on the namespace, is not called, and is named in the pin
  as not called: it answers what a request _tokenises to_, not what the call was
  _billed for_. Same law as `openai()` and `ollama()`. (Usage is read off the
  closing chunk BEFORE any content guard — the bug that made streamed turns bill
  as zero on two earlier adapters.)

Stop reasons are mapped only where the mapping is unmistakable — `STOP`,
`MAX_TOKENS`, and the four safety refusals — and everything else passes through in
Google's own spelling. Gemini has **no `tool_use` finish reason**, so the presence
of function calls is what produces one.

### Added — `geminiEmbedder()`

```ts
import { geminiEmbedder } from 'agentfootprint/providers';

const embedder = geminiEmbedder({ project: 'my-project', dimensions: 768 });
```

The same two doors, over `models.embedContent`. `gemini-embedding-001` by default
(3072 dimensions, Matryoshka-shortenable, a 2,048-token window, the full
`task_type` vocabulary) and `gemini-embedding-2` known by name (an 8,192-token
window and **no** `task_type`, which is refused rather than sent and ignored).
`embed()` sends `RETRIEVAL_QUERY` and `embedBatch()` sends `RETRIEVAL_DOCUMENT`,
because that is what this library's two call sites are — the `bedrockEmbedder`
Cohere lesson, applied. The id carries the size (`gemini:gemini-embedding-001:768`)
for the reason `bedrockEmbedder`'s does: one model id at two sizes is two embedding
spaces, and `embeddingModel` stores the id alone.

Two Google-specific traps became refusals:

- **One text per request.** `gemini-embedding-001` accepts exactly one input, so
  `embedBatch` is honestly N sequential calls. Libraries that batched it like an
  OpenAI client send oversized requests that fail on every batch of more than one.
- **`onTruncation: 'refuse'` is the default.** Over its window Gemini does not
  refuse — it clips, and a full-looking vector comes back for the opening of the
  passage, which is the exact failure `maxInputChars` was added for in 9.1.0. This
  is the first embedder whose backend TELLS us (`statistics.truncated`), and it
  turns that into an error naming the text's length and both fixes, so a passage is
  never indexed by a prefix of itself. `'allow'` is there when a prefix embedding
  is genuinely what you want.

### Added — the Google surface pin, and the assertion AWS never needed

`test/adapters/google/googlePin.ts` carries the AWS pin's dispatch, reality and
completeness assertions, re-aimed at method-based clients — plus a third that is
new and load-bearing.

- **API-VERSION reality.** `@google/genai` 2.16.0 defaults to **`v1beta1` on
  Vertex** and **`v1beta` on the Gemini API** — not `v1`. An adapter or a docs page
  claiming "GA, v1" while the client dials `v1beta1` is the 9.4.0 bug class in a new
  costume: it compiles, it passes, and the calls go somewhere else. The registry
  records the version each door resolves to and the test asks the installed
  package. `apiVersion` is the option that overrules it.
- **Method-name reality enumerates the INSTANCE, not just the prototype.**
  `@google/genai` assigns `generateContent`, `generateContentStream` and
  `embedContent` as instance fields; only `countTokens` is a prototype method. A
  prototype-only check — the shape the AWS pin would have suggested — would report
  three of our four pinned methods as missing.
- **`@google/genai` and `google-auth-library` are REAL devDependencies**, unlike
  the AWS SDKs. That trade is deliberate and reversed on purpose: AWS keeps its
  SDKs uninstalled so six adapters prove their missing-peer-dep refusals by real
  absence, which makes the AWS reality check vacuous in CI. Google is mid-rebrand
  and its Node surface lags, so version drift is where the bugs live and the
  reality checks have to actually run. The missing-peer-dep refusals are proved by
  stubbing module resolution instead.
- **One row is `documentedOnly`.** The Cloud Trace recipe on the new provider page
  tells readers to call `GoogleAuth.getClient` / `getAccessToken` /
  `getRequestHeaders`, so those names are a claim this package makes about someone
  else's library. It is reality-checked and never dispatched — a claim in prose is
  not a weaker claim than one in code.

### Added — Gemini's over-long-request sentence joins the typed error

`ContextWindowExceededError` (9.6.0) now translates _"The input token count
(1200293) exceeds the maximum number of tokens allowed (1048576)."_ — a word order
the existing patterns did not match — and reads both numbers out of it, including
the case where Google ships the first parenthesis empty. Detection stays
conservative: "INPUT token count" is what keeps it off a `max_tokens` validation
error, and Google spells a rate limit "quota exceeded".

### Added — Google Cloud as a documented provider column

`docs/infrastructure/google-cloud.mdx` is the third column, and it states what is
NOT there as plainly as what is: the service map, the surface pin, the required
concurrency-and-sessions section, and a status row per boundary.

- **Cloud Trace is a recipe, not a factory.** `googleCloudTracer()` was designed
  and then not built: Google's Telemetry API accepts **standard OTLP** (and Google
  recommends it over their own exporter), `otelObservability()` already takes the
  tracer, and a factory would be twenty lines of wiring behind four new optional
  peer dependencies — each needing its own pin row and version-drift story. The
  page ships the complete copy-paste recipe instead, including the two
  `OTEL_SEMCONV_STABILITY_OPT_IN` environment variables that make Google's own
  console render our `gen_ai.*` attributes as GenAI views.
- **Named absences, with dates.** Agent Retrieval (ex-Vector Search 2.0) and the
  Agent Identity auth manager are **parked as of 2026-08-12** — no Node SDK
  published for either. Vertex AI Extensions is deprecated (shutdown after
  2026-11-26). `@google-cloud/vertexai` is past its own removal date and nothing
  here builds on it.
- **Agent Runtime hosting is gated on one live probe.** Google's deploy page says
  "only supports Python" while its runtime-contract page says any language and
  ships a Node build script. Designing on an unresolved contradiction is how three
  AWS adapters once shipped calling operations that did not exist. Cloud Run with
  `httpHost` needs nothing new meanwhile.
- **No `SecurityStrategy` for Model Armor or Semantic Governance**, and that is a
  finding rather than a gap: Google enforces policy at the Gateway, in front of the
  process, exactly as AgentCore does — the architecture that retired
  `agentCorePolicy` in 9.4.0.
- **Memory Bank carries three silent-wrongness vectors** and the page names them
  before anybody writes an adapter: it returns LLM-extracted facts rather than your
  entries, its score is a **Euclidean distance where lower is closer** against our
  cosine contract where higher is, and `scope` is an exact match that is
  **immutable after write**.

### Changed

- `createProvider({ kind: 'gemini' })` joins the by-name factory; `ProviderKind`
  gains a member (additive).
- `@google/genai` joins `peerDependencies` as **optional** — installing
  agentfootprint installs nothing, and the SDK is lazily required on first use.
- A `gemini()` error never prints the API key it was constructed with. The
  redaction is narrow by design — the exact string you passed, removed from the
  message, the stack and the wrapped cause — and is not a heuristic scrubber:
  a thrown provider error reaches the model as a tool result _and_ the commit log
  _and_ every observability sink, so one interpolation would leak to all of them.

## [9.12.0] - 2026-08-12

**The per-user identity chain, both ends.** 9.11.0 put `EventMeta.principal` on
every event of a run whose caller named an identity — and then a served agent had
no way to name anybody, so the actor half of every hosted audit trail was empty by
construction. Two gaps closed here join the ends: the runtime's own header on the
way in, and the user's own token on the way out. Both are opt-in by
construction — a request that names nobody behaves exactly as it did in 9.11.0.

### Added — `GetWorkloadAccessTokenForJWT`: proof, where there was only an assertion

An agent that authenticated a real person could pass a userId **string**
downstream. AWS took its word for it. Now it can pass what the person's identity
provider signed:

```ts
const credentials = agentCoreIdentity({
  region: 'us-west-2',
  workloadName: 'workflow_assistant_agent',
  requireUserToken: true, // optional — refuse a delegated call with no proof
});

// inside a tool
await ctx.credentials.getCredential({ service: 'google', mode: 'user', userToken: callersJwt });
```

- **`CredentialRequest.userToken`** is the new port field, and it rides the
  **request** rather than the provider: the person calling is per call, and a JWT
  in a provider's construction options would be one user's live session serving
  everybody. Its presence selects the exchange — a proof that arrived is never
  downgraded to an assertion, even when an `identity` is beside it.
- **Nothing downstream changed.** `GetWorkloadAccessTokenForJWT` answers with the
  same `workloadAccessToken` the by-userId exchange does, so it feeds the same
  `GetResourceOauth2Token` call, the same `Credential`, the same `toHeaders()`.
  The vault entry at the end belongs to the _person_ rather than to the agent,
  which is what makes revoking their access actually revoke it.
- **Verified against the real SDK before it shipped**, names and shapes both:
  `{ workloadName, userToken }` in, `{ workloadAccessToken }` out. It joins the
  command-name pin, taking `agentCoreIdentity` to **three of AgentCore Identity's
  six data-plane operations** — the docs name the other three and say they are not
  covered.
- **`requireUserToken`** is the deployment-level opt-in for a front door that
  really does authenticate everybody: a `mode: 'user'` request with no
  `userToken` is refused by name instead of quietly falling back to something
  weaker. `mode: 'machine'` is never affected — M2M has no user to prove.
- **Refusals that teach, in both directions**: a JWT with no `workloadName` to
  exchange it against, a JWT on an M2M request, and an injected `_client` that
  cannot exchange are each named with the fix rather than resolved by guessing.
- **The framework does not thread the JWT for you, on purpose.** The only routes
  from your door to a tool are tracked scope and the run input, and both flow to
  the commit log, the recorders and every observability exporter. A tool captures
  it at the door in its own closure.

### Fixed — a token in an SDK error message

The new secrecy suite found a leak on the path it was written to guard, and the
fix covers every command this adapter dispatches, not only the new one.

- AWS clients report transport and validation failures by **echoing request
  detail into the message**. Every input this adapter sends is a secret — the
  user's JWT into the exchange, a workload access token into the vend — so a
  failed call handed the caller a message with a live token in it, and a
  `getCredential` message is read by the model, emitted on
  `agentfootprint.credential.failed`, and kept by every sink attached to it.
- A failed SDK call now keeps its exception **name** (`AccessDeniedException`,
  `ThrottlingException`, …) and HTTP status, and loses its text. The original is
  deliberately not attached as `cause`, which would travel with it into every
  serializer that walks own properties.
- A malformed exchange response is described by its **shape** — how many fields
  came back and what they are called — never by its content, because every field
  of a token-exchange response is a token. One shared refusal now covers both
  exchanges.

### Added — `HostRequest.userId`: WHO, beside which conversation

`agentCoreRuntimeWire` reads `X-Amzn-Bedrock-AgentCore-Runtime-User-Id` — the
header that runtime forwards from its front door, spelled the way the SDK spells
it (`InvokeAgentRuntimeRequest.runtimeUserId` binds to exactly this name, as
`runtimeSessionId` binds to the session header beside it). Matched
case-insensitively, through the same helper.

- **A session is a thread; a user is a person.** They are two fields because they
  are two facts, and an audit trail that reports the first where the second
  belongs names the wrong party. No wire derives one from the other.
- **`standingAgent` composes it into the run's identity**: the principal comes
  from this request, the conversation id from the conversation already in play
  (else the session — the 9.10.0 derivation, composed rather than replaced), and
  the tenant from whatever the stored conversation carried, since no transport
  field supplies one. With 9.11.0's actor-in-meta that puts a real person on every
  event, in `ctx.identity` inside a tool, and in the identity a credential
  provider scopes its vault on — the whole chain, unconfigured.
- **Turn two reports turn two's caller.** Preferring a stored principal would pin
  a whole session to whoever spoke first; a turn that names nobody continues the
  conversation's own identity, which is 9.2.0's rule and not a new one.
- **The generic wire reads no such header, and a test asserts it doesn't.** The
  header is worth reading where a front door sets it; on a container you expose
  directly it is a string anybody can send. Absent stays absent everywhere: with
  no session and no stored conversation there is no conversation to name, and
  nothing is fabricated to carry a principal.
- The ports still name no vendor — the header name lives in the adapter, and the
  existing grep-for-vendor-names guardrail proves it.

Status, plainly: both halves are **contract-shaped and tested** — the JWT
exchange against the installed SDK's own request/response shapes, the header
mapping over a real socket — and **awaiting field use**. No live account has
exercised the exchange path yet, and no page on the site says otherwise.

## [9.11.0] - 2026-08-12

**The enterprise batch.** An external enterprise-readiness review asked four
questions of this library. Three of them turned out to be right, and the fourth
turned out to be a misreading worth correcting in public. Everything shipped here
is **opt-in by construction** — no existing agent composes a different prompt,
refuses a call it used to allow, or emits a field it did not before.

### Added — `maxToolResultChars`: a ceiling on ONE tool result

```ts
Agent.create({ provider, model, maxToolResultChars: 20_000 });
```

Over the cap, the result is **replaced** by a marker that tells the model what
happened and what to do about it:

```json
{
  "truncated": true,
  "reason": "orders_export returned 812431 chars, over the 20000-char cap. Narrow the request and call again.",
  "head": "id,customer,total\n1001,…"
}
```

- **The marker IS the result.** It is what the model reads on the `role: 'tool'`
  message AND what `agentfootprint.stream.tool_end` carries — so a run that
  capped an 800KB result does not then ship that same 800KB to a log sink, and a
  trace shows the truncation instead of hiding it.
- **`head` is verbatim and proportional.** It gets whatever the cap has left
  after the sentence explaining it, so the serialized marker stays inside the cap
  and a bigger cap buys a bigger head. When the cap cannot afford its own
  explanation, `head` is dropped rather than the explanation — a lesson truncated
  in half teaches nothing, which is the failure this exists to prevent.
- **Every dispatch path is measured**: the ordinary loop, a resumed middleware
  `ask`, a check-in decision, a credential-consent resume, and a `pauseHere`
  answer a person typed.
- **It composes, and replaces nothing.** A tool's own paging keeps working,
  `CodeResult.truncated` still means what it means, and an `onToolResult`
  middleware that summarizes runs FIRST — the cap measures what the chain
  produced. When big tool DATA is normal rather than accidental, the answer is
  still the `CodeRunner` port ("summarize prose, compute data"), not a bigger cap.
- **No default, and there will not be one.** A default would silently modify tool
  results, and a tool that returns 200KB is doing what somebody wrote it to do.
  Omitted, results are never measured and never replaced. `0` is not "off" — it
  is refused at construction, naming the value and pointing at the omission.
- Read it with the exported `isTruncatedToolResult(value)` guard.

### Added — WHO the run was for, on every event

`EventMeta.principal` and `EventMeta.tenant` (9.11.0) join `sessionId` (9.4.0) on
every event's meta. The stream has always said _what_ happened and _when_; this
is the _who_, and the three together are an audit record rather than a debug log.

```ts
await agent.run(message, {
  identity: { tenant: 'acme', principal: 'alice@acme.test', conversationId },
});
```

- **Stamped only from an identity a caller NAMED** — `run({ identity })` or
  `run(input, { identity })`, the same tuple memory and the permission gate scope
  on. Never from the run's internal identity, which is always populated and
  defaults to `{ conversationId: '<runId>' }` (or, since 9.10.0, to
  `{ conversationId: sessionId }` on a session-bound run).
- **A conversation id is not an actor.** A session-derived run leaves both keys
  ABSENT. `sessionId` is caller data — anyone who can reach the host can send any
  string, including somebody else's — and promoting it to "who did this" would
  produce an audit trail that looks complete and names the wrong party.
- **`conversationId` is deliberately not carried.** It is a thread, not a person,
  and `sessionId` beside it is the fact the transport delivered.
- **Which sinks carry it, checked rather than assumed.** `fileObservability`,
  `cloudwatchObservability`, `agentcoreObservability` and `auditExport` serialize
  the whole envelope and inherit it for free — in `auditExport`'s case _inside_
  the hash chain, so editing who breaks the same verification as editing what.
  `otelObservability` maps signals onto spans rather than serializing, so the
  actor is PLACED there: `agentfootprint.principal.id` /
  `agentfootprint.tenant.id` on the `invoke_agent` run span. `xrayObservability`
  does not map it, and the docs say so rather than implying a coverage it lacks.

### Added — capability enforcement, where both sides speak

`PermissionRequest.capability` has carried five values since v2.4. Until now
**only `'tool_call'` was ever sent**: every construction site passed it, and
`PermissionPolicy` read the field only as a fallback target id that a tool call
never reaches. Four fifths of the vocabulary was defined and dead.

It is enforced now, under one rule — **a tool DECLARES what it touches, a checker
DECLARES what it governs, and enforcement happens where both speak**:

```ts
const fetchInvoice = defineTool({ …, capabilities: ['external_net', 'user_data'] });

const policy = PermissionPolicy.fromRoles(roles, 'support', {
  capabilities: { support: ['user_data'] },   // external_net is not listed → denied
});
```

- **`Tool.capabilities`** (`'memory_read' | 'memory_write' | 'external_net' |
'user_data'`) is a declaration, never an inference. A tool's reach is not
  knowable from its name, schema or description, and guessing would rest a policy
  decision on a heuristic.
- **`PermissionChecker.governs`** is an optional, feature-detected member —
  **absence is NO**. A checker written before 9.11.0 is asked exactly what it was
  always asked. `checkerGoverns(checker, capability)` is exported so a custom
  checker's tests can assert the same answer the framework will get.
- **`PermissionPolicy.fromRoles(roles, role, { capabilities, skills })`** derives
  its own `governs` from the rules, so "unconfigured" and "never asked" cannot
  drift apart. Configuring capability rules for any role means the policy governs
  all four — governing only what some role listed would let an unlisted
  capability pass unasked.
- **Said plainly instead of implied: the memory pipeline is NOT gated by this
  port.** No recall or write stage builds a `PermissionRequest`, so
  `'memory_read'` / `'memory_write'` reach a checker only for a TOOL that declared
  them. Memory isolation is `MemoryIdentity` scoping — a different mechanism, not
  this one under another name.

### Added — per-role skill-catalog visibility

The same composition, applied to the skill catalog. A checker that declares it
governs `'skill_read'` is asked about each skill, target `skill:<id>`:

```ts
PermissionPolicy.fromRoles(roles, 'support', {
  skills: { support: ['refunds', 'lookup'], hr: ['payroll'] },
});
```

- A refused skill's row **disappears** from the `read_skill` menu the model reads,
  and activating it anyway is refused with the policy's own message — one rule,
  both ends, so the menu and the verdict cannot disagree.
- The refusal lands **before `execute`**, so a `surfaceMode: 'tool-only'` skill's
  body is never even computed.
- **Hidden means unnamed.** The graph offer lists unreachable skills as "not
  reachable from here" because a cursor can move; a hidden skill is about _who is
  asking_, and naming it would tell one role about another role's capabilities.
- **The enum stays the full catalog.** `toolArgValidation` runs before the gate,
  so narrowing it would turn a policy refusal into a generic schema error and the
  model would never read the policy's own message — the reasoning 8.5.0 recorded
  for the graph offer, applied again.
- `skillTarget(id)` / `skillIdFromTarget(target)` / `SKILL_TARGET_PREFIX` are
  exported from `agentfootprint/security` so a custom checker spells the target
  exactly as the agent produces it. Scope: this governs the `read_skill` surface
  the Agent mounts; a `list_skills` tool you register yourself is your own catalog.

### Documentation — the sqlite "50,000 chunk ceiling" is guidance, not a limit

The review read `sqliteVectorStore`'s documented ceiling as an enforced cap. It
is not, and nothing changed in code because nothing needed to: **no counter, no
refusal at 50,000, no deliberate degradation** — chunk 50,001 is stored and
searched exactly like chunk 3. The number is the point on the measured curve
where this implementation stops being obviously the right tool, published so the
decision is yours and dated rather than discovered in production. The docstring
and the capability page now say that in as many words, and list what the store
really does refuse.

### Documentation

Every item above lands on its capability page under the provider-column template
— [Governance & policy](https://footprintjs.github.io/agentfootprint/docs/infrastructure/governance-and-policy),
[Identity & credentials](https://footprintjs.github.io/agentfootprint/docs/infrastructure/identity-and-credentials),
[Observability sinks](https://footprintjs.github.io/agentfootprint/docs/infrastructure/observability-sinks),
[Tools & gateways](https://footprintjs.github.io/agentfootprint/docs/infrastructure/tools-and-gateways) —
plus a new decision-table row ("audit who did what") on the Infrastructure index.
The actor-in-events row appears **identically** on both provider pages, AWS and
on-premises, because it is the same field and the same rule on both columns.

## [9.10.0] - 2026-08-12

**Multi-user, made easy.** Three things a self-hosted deployment had to build
itself — who is asking, whose memory is whose, and how two people get answered
at the same time — are now one option each.

### Added — `standingAgent({ agentFactory })`: one agent per active session

An `Agent` holds per-run state on itself, so one instance can only be in one run
at a time. `standingAgent` shared ONE instance across every session and
serialized globally to keep that safe — correct, and a hard ceiling of one
person at a time per process. The law has not changed; its SCOPE has:

```ts
await standingAgent({
  agentFactory: () => Agent.create({ provider, model }).system('…').build(),
  sessions: sqliteSessions({ file: './sessions.db' }),
  host: nodeHost({ port: 8080 }),
  maxActiveSessions: 200, // default 100
});
```

- **Sessions run in parallel.** Each active session gets its own instance from
  the factory; two people asking two questions are two runs and neither waits.
  Pinned by a test where both sessions are inside the model call before either
  returns, and their wall-clock intervals overlap.
- **One session still serializes** — on its own instance, under the same
  `onConcurrentInvoke` policy — so the Agent's own `RunInFlightError` never
  reaches a caller.
- **Bounded and LRU.** A new session at a full pool retires the least recently
  used IDLE one: its tool sessions close with the existing `'evicted'` reason
  (9.7.0's vocabulary — no new event invented), its agent is shut down, and its
  CONVERSATION stays in the session store. The next request re-hydrates onto a
  fresh instance and the person never knows.
- **A running session is never evicted.** The pool grows past the bound rather
  than ending somebody's turn, and comes back under it when a run finishes.
- **Anonymous requests share one fallback instance** — there is no conversation
  to isolate, and an instance per anonymous request would be an instance per
  request.
- **`{ agent }` is unchanged, to the byte.** Same global queue, same refusals,
  same durability wiring; the shared shape is a pool of exactly one lane, so
  there is one implementation rather than two that can drift.
- **Two refusals, both by name.** `agent` AND `agentFactory` together is refused
  at construction (two spellings of one decision, and the winner would be
  invisible). A factory that returns an instance it has already returned is
  refused on the spot — that mistake type-checks perfectly and destroys the only
  property the pool exists for. `maxActiveSessions` without a factory, and a
  non-positive bound, are refused too. The mutual exclusion is enforced at the
  TYPE level as well (`StandingAgentSharedOptions` | `StandingAgentPoolOptions`).

### Added — a session IS a conversation: honest memory identity

A run that carries a `sessionId` and **no** `identity` is now scoped to
`{ conversationId: sessionId }`.

`standingAgent` has passed the session id on every run and resume since 9.4.0,
so **a served session now gets durable per-user memory with zero
configuration**. Before this it got the per-run default — `{ conversationId:
'<runId>' }`, with a fresh runId every turn — which meant a registered
`.memory()` wrote one namespace per turn and recalled nothing across a
conversation. The turn always looked right; only the recall was missing.

- An `identity` you pass **always wins**, including the one a continued
  conversation carries.
- A run with no session and no identity is **unchanged** — same namespace, same
  committed keys.
- The derivation is **recorded, not inferred**: it commits
  `runIdentitySource: 'session'`, written on that path only, so a trace can tell
  a namespace somebody chose from one this library derived.
- It is still **not** published to `tool.execute` as `ctx.identity`. A
  synthesized namespace is not something anybody named, and "absent" keeps
  meaning "nobody named one"; a tool that wants the session has `ctx.sessionId`.

### Added — the two halves of "which session is this?"

```ts
// client (main barrel — browser-safe, zero deps)
import { browserSessionId } from 'agentfootprint';
fetch('/invoke', { headers: { 'x-session-id': browserSessionId() }, … });

// server
nodeHost({ sessionHeader: 'x-conversation' });   // default 'x-session-id'
nodeHost({ sessionCookie: 'af_session' });       // …or no client code at all
```

- `browserSessionId({ storageKey? })` mints a UUID once and keeps it in
  `localStorage`, falling back to memory when storage is missing or throws
  (private mode). On the MAIN barrel, not `agentfootprint/hosting`: the rest of
  that door is Node, and a browser bundle must not reach through it.
- `nodeHost({ sessionHeader })` names the header the adapter has always read.
  `jsonWireWith({ sessionHeader, sessionCookie })` is the dialect as a factory;
  `jsonWire` is `jsonWireWith()` and behaves exactly as before.
- `nodeHost({ sessionCookie })` reads the cookie and, only when the request
  carried no session at all, issues one: `Path=/; HttpOnly; SameSite=Lax`. No
  `Secure` flag — this host cannot know whether it is behind TLS, and the
  docstring says so rather than pretending. A caller that already sent a session
  is never handed a competing one.
- `HttpWire.readRequest` may now return `responseHeaders`, which is how a pure
  wire issues a cookie without touching the socket. `content-type` set there is
  ignored: the framing (one JSON body vs SSE) is the host's decision.

### Changed — docs

- **Hosting & runtime** gains a **Concurrency & sessions** section: the
  three-strategy table (platform-per-session · agent pool · process-per-worker),
  the pool's laws, both session recipes, and the memory-identity ladder.
- **On-premises** and **AWS** each state where their parallelism comes from —
  AWS gets it from the platform (a container per session), an on-premises box
  chooses the pool or a fleet.
- **Conversations** documents the identity ladder and both session recipes.
- `redisSessions` is **not built**, and the hosting page says so with the
  ten-line sketch: `SessionLifecycle` is two methods, and a shipped adapter
  would have to decide key prefix, TTL and client for everybody.
- New example: `examples/deploy/multi-user.ts` — two people served at once,
  proving the overlap in wall clock and that neither saw the other's memory.

## [9.9.0] - 2026-08-12

**A bug report IS the evidence.**

The usual bug report is a person's memory of a run: "it said the wrong thing, I
think it called the search tool twice". The run itself — the timeline, the
state, the chart, the narrative — was sitting in the process the whole time and
never left it. This release turns that around: the report is the run, packaged,
with the prose attached.

### Added — `describeBugReport()` / `exportBugReport()`, the consent seam

`agentfootprint/observe`. Two calls, because consent needs two:

```ts
import { describeBugReport, exportBugReport } from 'agentfootprint/observe';

const offer = describeBugReport(recording); // measure — nothing has left yet
// …show offer.units to the human; they tick some…
const report = exportBugReport(recording, {
  include: ['conv-1', 'file-conversation', 'file-environment'],
  title: 'Agent answered with a stale price',
  stepsToReproduce: '1. ask\n2. update\n3. ask again',
  expected: 'the updated price',
  actual: 'the price from before the update',
});
fs.writeFileSync(report.filename, report.zip); // a real .zip
```

- **The manifest is SELECTABLE UNITS, not a blob.** Each conversation is a unit
  — keyed by session id when the run was session-bound, else by run id, so
  several `run()` calls in one session are ONE unit, which is what a person
  means by "the chat that went wrong" — carrying its size, event count and turn
  count. Each derived file (`conversation.json`, `narrative.txt`,
  `environment.json`) is a unit too. A dialog cannot ask about a blob it has not
  measured, which is the whole reason `describeBugReport` exists separately.
- **A deselected unit is out of EVERY file.** The transcript and the narrative
  are rebuilt over the selected conversations, not filtered afterwards — the
  first cut of this shipped a conv-2 that was out of its own file and quietly
  inside `conversation.json`, and the property test that caught it is now the
  pin.
- **What was left out is STATED.** `manifest.excluded` counts the conversations,
  files, events and turns that were withheld, names their unit ids, and the
  issue body repeats it. A maintainer reading turn 4 must be able to tell that
  turns 1–3 were _withheld_, not _lost_.
- **Redacted keys, BY NAME.** The recording arrives already redacted (footprintjs
  scrubs at commit time), so nothing here scrubs anything — it would be too late
  to matter and a second policy could only disagree with the first. Instead the
  manifest lists the key names whose values are placeholders, derived from the
  evidence itself, so a human can consent knowing which secrets were protected.
  An empty list is explained rather than left to look like "nothing secret here".
- **`environment.json` is versions and nothing else** — library, engine, Node,
  platform, arch, plus the reporter's prose. No username, no hostname, no
  working directory, no environment variables, no file paths. A bug report should
  not be how an internal directory layout leaves a company.
- **Over 20 MB, the trim hints name real unit ids** ("Drop conv-2 (14.0 MB) →
  6.1 MB."), never "make it smaller", and never the last remaining conversation.
- **An agent works as input too**, honestly: a finished runner gives up its
  snapshot and its chart but not its events (the dispatcher drops events nobody
  subscribed to), so that arm produces a note saying the timeline is missing and
  naming the one-line fix, rather than a silently empty panel.

The zip writer is **~150 lines, zero dependencies, STORE-only (no compression)**,
and says so in its docstring. Deflate would shrink a JSON bundle well and would
cost either a dependency or `node:zlib` — which would make the export Node-only,
when building a bundle in the browser is exactly the flow the consent dialog is
for. Verified two ways: a structural parser written against APPNOTE walks the
central directory and re-checks every CRC, and the system `unzip` opens the real
file (`unzip -t` verifies the CRCs, `unzip -p` prints back the exact bytes,
extraction recreates the tree). Zip-slip names (`..`, a leading `/`, a drive
letter, a backslash) are refused by name — an archive is extracted on the
machine of the person it was filed against.

### Added — `githubBugReporter()`, with TWIN TARGETS

`agentfootprint/observe`. Commits the evidence zip and files the issue, over
plain `fetch` with no SDK:

```ts
const reporter = githubBugReporter({
  issueRepo: 'footprintjs/agentfootprint', // public — the conversation
  evidenceRepo: 'acme/af-bug-evidence', // private — the run
}); // token: GITHUB_TOKEN, or `token`
const { issueUrl, zipUrl } = await reporter.file(report);
```

- **The issue and the evidence may live in different repos.** The case that
  shipped it: a field tester files a LIBRARY bug — the issue into the library's
  public repo, the evidence zip into a private repo the maintainers can read.
  The issue links the bundle and says plainly that the evidence is private.
- **The doctrine's teeth.** Before committing evidence, the repo's metadata is
  read; a PUBLIC evidence repo is **refused** unless
  `acknowledgePublicEvidence: true` says so out loud. If the metadata call itself
  fails (a token that can write contents but not read metadata is a legitimate
  configuration) the report proceeds and the result carries
  `checkedVisibility: false` — a permissions quirk must not block a bug report,
  and skipping the check silently would be worse than either.
- **Size is refused BEFORE the upload** (24 MB), quoting the manifest's own trim
  hints rather than inventing advice.
- **A name already taken is suffixed, never overwritten** — no `sha` is ever
  sent, so this reporter cannot replace somebody else's evidence even by
  mistake.
- **The two-clause secrecy law, applied.** The token appears in no message, no
  error and no log, and neither does a byte of the bundle: a failure names the
  status and GitHub's own `message` field only — never the request, never the
  headers, never the response body (which can echo both). Transport failures are
  re-wrapped rather than rethrown, because a `fetch` implementation is free to
  put the whole request into the error it throws. Pinned by a suite that forces
  ten failure paths — including a transport error carrying the auth header
  verbatim — and greps the message, the stack and the JSON projection.
- **Refusals teach where each thing goes**: a missing token names both the env
  var and the option AND the fine-grained-token page, scoped to exactly the two
  repos with exactly two permissions; a 403 names those permissions again; a 404
  explains that GitHub answers 404 for a private repo a token cannot see.
- **`apiBase` for GitHub Enterprise Server**, so the whole path works on a
  network that never reaches github.com.

### Added — `githubDeviceSignIn()`, filing as yourself

`agentfootprint/observe`. GitHub's OAuth **device flow** in three plain `fetch`
calls — browser-safe and server-safe, no client secret, no dependency:

```ts
const signIn = await githubDeviceSignIn({ clientId }); // returns at once
show(`Open ${signIn.verificationUri} and enter ${signIn.userCode}`);
const { token, login } = await signIn.completed; // resolves on approve
```

A server PAT files every report as the _application_; this files it as the
_reporter_, which is what a field tester filing upstream needs. It honours
`slow_down`, respects the code's expiry, takes an `AbortSignal`, and fetches
`/user` for attribution (a `/user` that refuses is not fatal — the token still
works, the login is simply absent). The token it returns is handed to
`githubBugReporter` with no special-casing: a token is a token.

Two things the docstring says plainly rather than leaving to be discovered:
classic OAuth scopes are **coarser** than a fine-grained PAT (GitHub's design,
not ours — `public_repo` is the default here and grants write across every
public repo the account can reach), and the token must live **in memory for the
session only** — never `localStorage`, never a log line. The collaborator caveat
is stated too: a reporter who is not a collaborator on a private evidence repo
gets a 404, and the honest fallback is attaching the zip by hand.

### Docs

- New page: **File a bug with the run attached** (`debug/file-a-bug`) — the
  browser consent flow with the manifest shown before anything leaves, the
  server wiring (two endpoints, hand-written: there is deliberately no route
  helper, because consent needs two round trips and the run-id → recording
  lookup is state the application owns), the token-provisioning section
  (fine-grained, two repos, two permissions, an expiry), the "filing upstream"
  twin-target recipe with the private-evidence pattern, the two-mode table
  (server PAT vs device sign-in), and the cross-organisation doctrine.
- **On-premises & self-hosted** gains a row: the whole path runs on a network
  that never reaches github.com, GHES included, via `apiBase`.
- New example: `examples/observability/22-file-a-bug.ts` — records two
  conversations, prints the consent manifest, exports a subset, parses the zip's
  central directory back off disk, and files it against a scripted GitHub. Exits
  non-zero if any of that stops being true. No key, no network, no token.

### Internal

- `libraryVersion()` moved from `adapters/observability/audit.ts` to
  `lib/libraryVersion.ts` and gained `engineVersion()` beside it. Two copies of
  "which version produced this?" would be two answers to a question that must
  have exactly one; `auditExport` now imports the shared one.
- `narrativeFrom()` (the shape-detector for a narrative recorder's snapshot row)
  is exported `@internal` from `lib/trace-toolpack/openRecording.ts` and reused
  by the bundler, for the same reason.

## [9.8.0] - 2026-08-11

**The on-premises column, finished.**

Every port on this library is vendor-neutral, and every one of them has had an
AWS adapter for months. The deployments that own their own machines have been
filling the same ports the whole time — a model on a GPU, a corpus in Postgres,
sessions in a SQLite file — and two of them had nowhere to go but "write it
yourself": telemetry with no collector to ship to, and secrets in a vault.

This release fills those two, and writes the column down.

### Added — `fileObservability({ path })`, telemetry for a shop with no collector

`agentfootprint/observe`. An `ObservabilityStrategy` that appends the typed
event stream to a local file as NDJSON — one `JSON.stringify(event)` per line,
in **the same envelope `cloudwatchObservability` puts in a log event**, so a
query written against one reads the other. Zero dependencies: `node:fs`, lazily
required, so importing the door stays browser-safe.

```ts
import { fileObservability } from 'agentfootprint/observe';

agent.enable.observability({
  strategy: fileObservability({
    path: '/var/log/agentfootprint/events.ndjson',
    maxBytes: 64 * 1024 * 1024,
  }),
});
process.on('SIGTERM', async () => {
  await agent.shutdown();
}); // flushes
```

Four things it is deliberate about:

- **Rotation is ONE generation.** With `maxBytes`, a batch that would cross the
  ceiling renames the file to `<path>.1` — replacing any previous `.1` — and
  starts fresh. No `.2`, no compression, no schedule, no cross-process
  coordination. It exists so an unattended agent cannot fill a disk, and for
  nothing else: **retention is a log-management daemon's job**, and omitting
  `maxBytes` (the default) means the adapter never renames anything, which is
  right when `logrotate` already owns the file. The docstring says exactly this
  rather than implying a rotator.
- **An unwritable path is refused at construction**, naming the path and the
  three ordinary causes — not at the first event, hours later, into nobody's
  console.
- **The hot path never throws.** `exportEvent` serializes, buffers and returns;
  even an unserializable event is reported rather than raised. Write failures
  follow the 8.11.0 `deliveryErrors` pattern — loud but not fatal, rate-limited
  on the console, every failure to a wired `onError`, and the batch **dropped
  rather than requeued** so a full disk cannot grow the buffer without bound.
- **Nothing is bounded or redacted on the way out**, and the page says so.
  Narrow it with `eventTypes` (which becomes the strategy's
  `relevantEventTypes`, so the dispatcher does not even forward the rest),
  `tier` / `sampleRate`, or a footprintjs `RedactionPolicy`. For a record
  bounded by construction, `auditExport({ payloadMode: 'bounded' })` is still
  the adapter that does that job.

`flush()` / `stop()` follow the 8.12.0 lifecycle laws exactly, including the
8.11.1 one that cost a shutdown spin: a `flush()` after `stop()` still writes
what was already accepted, and the drain is bounded by construction.

### Added — `vaultCredentials({ address })`, a CredentialProvider over KV v2

`agentfootprint/security`. A `CredentialProvider` for HashiCorp Vault and
anything Vault-API-compatible (OpenBao, and the Vault-API modes of several
managed stores). No SDK: one `GET` per resolution through the runtime's own
`fetch`, with a `_fetch` test seam.

```ts
import { vaultCredentials } from 'agentfootprint/security';

const credentials = vaultCredentials({
  address: 'https://vault.internal:8200',
  paths: { github: 'ci/github' }, // …or resolve(service), or neither
}); // token: `token`, else VAULT_TOKEN
```

The tool code does not change from the `staticTokens` version — same port, same
`ctx.credential!.toHeaders()`.

**V1 is one shape, and every other shape is refused by name.** Token auth only;
KV v2 only; no leases, no renewal (`getCredential` re-reads the secret every
call, which is the library's model since 9.7.0). AppRole, Kubernetes, JWT/OIDC,
AWS IAM, userpass and LDAP each refuse at construction **naming the options a
login would arrive on** — `roleId` + `secretId`, a role plus a projected
service-account token — because an auth method nobody has exercised against a
real cluster would be a guess wearing an adapter's clothes. A KV v1 mount is
named as such the moment its response shape gives it away (`data` with no inner
`data`), and names `kvVersion` as the option a v1 reader would arrive on.
Passing `paths` **and** `resolve` is refused: two spellings of one rule can
disagree, and the loser would do so silently.

A secret's fields become a credential by the first rule that matches — `token` →
`bearer`, `api_key`/`apiKey`/`key` (+ optional `header`) → `apiKey`, `username` +
`password` → `basic`, `headers` → `headers` — with `toCredential(secret,
service)` as the seam for a shop whose field names are its own.

### Security

- **A plain-`http://` address is refused unless `allowHttp: true`.** The Vault
  token travels in the `X-Vault-Token` header: on plaintext, anyone on the path
  reads a token that can usually read every secret it can reach, and a leaked
  read token is not revoked by rotating one secret. The refusal names that,
  rather than saying "use https".
- **No secret can reach a message.** Every error `vaultCredentials` raises names
  the service, the mount path and the HTTP status — and nothing from the
  response body, nothing from the token, not even the field names the secret
  carries. This is the 8.6.0 law applied one adapter down: a thrown message
  reaches the model as a tool result _and_ rides
  `agentfootprint.credential.failed` to every observer. It is pinned by a
  grep-shaped test that walks every failure path — unknown service, 401, 403,
  404, 503, a non-JSON reply, a KV v1 response, an unrecognised field set, and a
  transport error whose own text echoes the request headers — and asserts that
  no secret and no `x-vault-token` survives into any message, stack or JSON
  projection of the thrown error.

### Documentation

- **`infrastructure/on-premises.mdx`** — the provider page beside AWS. The
  local-first ladder (mock → local model → your gateway → a paid API) as the
  opening frame, then a service-by-service map: LLM (`ollama`, `openai({
baseURL })` for vLLM / llama.cpp / a corporate gateway, or the two-method
  port), stores (`sqliteVectorStore`, `pgVectorStore`, `staticVectorStore`,
  `RedisStore`), embedders (`localEmbedder`, `staticEmbedder`), hosting
  (`httpHost` / `nodeHost` + `sqliteSessions` / `memorySessions`), code
  execution (`localCodeRunner` — _isolation, not a sandbox_), telemetry
  (`otelObservability` to any OTLP collector, `fileObservability` when there is
  none, `auditExport` for evidence), credentials (`staticTokens`,
  `vaultCredentials`, and the port for everything else) and tools (`mcpClient`
  over stdio or Streamable HTTP). It ends with **what is NOT here** — no
  Kubernetes-native anything, no second secret-manager adapter, no metrics
  exporter, no retention policy, no air-gapped model distribution.
- **The status vocabulary gained one honest rung.** _Contract-shaped and tested;
  awaiting field use_ is what `fileObservability` and `vaultCredentials` carry:
  their ports and refusals are pinned by tests, and neither has met a real
  production disk or vault. _Verified in a production field deployment_ now
  appears in exactly one place, describing a deployment **shape** — a standing
  agent over `httpHost` + `sqliteSessions` against an OpenAI-compatible gateway,
  the shape several past releases exist because of — and never an adapter. The
  infrastructure index and the AWS page were both updated to say so rather than
  keep an absolute claim that had stopped being true.

## [9.7.0] - 2026-08-11

**Tools have somewhere to hold a session.**

`ToolExecutionContext` was six fields and none of them said WHO or WHICH RUN, and
a `Tool` had no end-signal at all. So a tool backed by a session service — a
managed code interpreter, a headless browser, a leased connection: Start →
Invoke ×N → Stop — could only pay session start-up on every single call, or hold
the session in a module-level map.

The second option is the one people take, and it is an isolation failure rather
than a performance trick. A `Tool` is a singleton: built once, shared by every
run and every session the process serves. In a standing agent, the session in
its closure is shared too, so person B gets person A's files, environment and
half-run state. No test with one user shows you this.

Everything needed to fix it already existed within one object-hop of the
dispatch site. What was missing was the wire, and any signal a tool could be
handed that said "this is over".

### Added — run/session identity on `ToolExecutionContext`

Three optional fields, sourced from what the engine already stamps:

| field           | source                                         | absent when                                                             |
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------- |
| `ctx.runId`     | the run in flight                              | there is no run — a call served over `mcpServe` is one call, not a turn |
| `ctx.sessionId` | `run({ sessionId })` ← `HostRequest.sessionId` | the run is not session-bound                                            |
| `ctx.identity`  | the identity the CALLER passed                 | the caller passed none                                                  |

Every one is **absent rather than invented**, which is the 9.4.0 rule applied one
layer down. `ctx.identity` is deliberately _not_ the run's internal
`runIdentity`: that is always populated, defaulting to
`{ conversationId: '<runId>' }`, and handing a tool a synthesized conversation as
"the identity" would let it isolate a live sandbox on a fiction.

`toolSessionKey(ctx, scope)` is exported because the derivation IS the isolation
boundary — one implementation, or two that disagree:

```
session →  t=<tenant|_>/p=<principal|_>/s=<sessionId>     requires sessionId
run     →  t=<tenant|_>/p=<principal|_>/r=<runId>         requires runId
call    →  c=<toolCallId>                                 always available
```

**A `sessionId` alone never keys a live session.** The hosting port already said
why in its own words: it is caller data, and anyone who can reach the host can
put any string there, including someone else's.

### Added — a real teardown contract

`ctx.onTeardown(cleanup, { scope, key })`. Not `Tool.dispose()` (a singleton
cannot dispose one caller's session) and not a lifecycle port the consumer wires
(the tool that knows the key is the one that cannot reach it). Execute time is
the only moment the key and the resource are both in hand.

`ctx.teardownScopes` is a FACT to branch on, exactly like `hasCredentials` — a
tool that wants a run-scoped session needs to know it is at a door with no runs
BEFORE it opens one. Asking for a scope a door cannot honour throws, naming the
door.

| scope        | fires                                                                                 |
| ------------ | ------------------------------------------------------------------------------------- |
| `'call'`     | when `tool.execute` settles — resolve **or** throw. Every door, including `mcpServe`. |
| `'run'`      | at a run terminal that is **not a pause**.                                            |
| `'session'`  | `agent.closeToolSessions({ sessionId })`.                                             |
| `'shutdown'` | `agent.shutdown()`.                                                                   |

Seven laws, each pinned: at most once ever · idempotent by `(tool, scope, key)`
with the FIRST registration winning (it holds the live handle) · reverse
registration order · bounded by `toolTeardownTimeoutMs` (default 5s, because
teardown is on the SIGTERM path) · never throws into the run and never silent ·
tolerates "already gone" · nothing live is ever persisted into a checkpoint.

**A pause is not a terminal.** `'run'` teardown deliberately does not hang off
`finally`, which also runs on both pause shapes. A `checkIn` on a code
interpreter stops the run so a person can approve the code; tearing the sandbox
down there destroys the exact state the resume needs, and it fails _quietly_ — as
a resumed run that "just re-ran everything". An error IS a terminal.

### Added — `agent.closeToolSessions({ sessionId })`

The mechanism is the library's; the **timing is your composition root's**.
Nothing here can know when a request/reply session is over: a `HostRequest`
carries a `sessionId` and no end, `SessionLifecycle` stays `hydrate`/`persist` by
design, and managed backends do not tell you either — an idle timeout is the
reality. Guessing would tear down a live sandbox mid-conversation.

On the conversation door it is one line, now shipped in
`examples/deploy/echo-conversation.ts`:

```ts
conversation.onClose(() => void agent.closeToolSessions({ sessionId }));
```

Never calling it is survivable, not silent: a lazy idle sweep (no timers — a
library that installs an interval keeps your process alive), a bounded live count
that evicts the coldest, and `shutdown()`.

### Changed — `shutdown({ stop: false })` now closes tool sessions

**Behaviour change, stated loudly.** `stop` governs BORROWED strategies: a host
shutting down without owning the agent it was handed drains telemetry without
releasing what the caller still holds. A tool session is not borrowed — this
runtime opened it, on behalf of runs it executed, and nobody else holds a handle
to close it. Leaving it open under `{ stop: false }` would leak every sandbox on
`standingAgent`'s DEFAULT (`shutdown: 'flush'`) path.

Nothing live is cut: by the time a composer reaches shutdown its host is closed
and in-flight runs have finished. And it stays true to "the agent itself remains
usable afterwards" — the next run opens a fresh session, exactly as the first one
did. If you were relying on `{ stop: false }` to keep a tool's session alive
across a shutdown, hold it yourself and register `'shutdown'`-free cleanup.

**Behaviour change, second one:** a tool that registers teardown now has it
FIRED where previously nothing did. That is the feature; it is named here because
a `close()` that never used to run now runs.

### Added — four typed events

`agentfootprint.tools.session_started` · `_reused` (with `calls`) · `_closed`
(with `reason`: `call-end` · `run-end` · `session-end` · `shutdown` · `idle` ·
`evicted`) · `_close_failed` (with `errorClass`). 73 typed events → **77**.

They ride the EXISTING `agentfootprint.tools.` prefix, which is the compat gift:
`toolsRecorder` already bridges the whole prefix and the wildcard arm already
exists, so a new domain would have re-opened the two-part trap 9.4.0 spent a
release climbing out of.

`session_started` / `_reused` fire inside `tool.execute` and carry the real
`tool-calls#N` stage. `session_closed` / `_close_failed` fire after the last
stage committed and carry a STATED pseudo-stage, `tool-teardown#0`, built with
`buildEventMeta` from the run context — never `minimalMeta()`, which hardcodes
`runId: 'consumer-scope'` and would make a teardown unjoinable to the run that
opened the session.

Payloads carry a `keyHash`, never the key: the key composes tenant, principal and
`sessionId`, and publishing it would put a user identifier into every exporter's
payload.

### Added — `CodeRunner`, and the first consumer that proves the contract

**Summarize prose, compute data.** A tool whose honest answer is 40,000 rows has
not given the model data; it has spent the context window — the failure 9.6.0
named (`ContextWindowExceededError`, from a real 879,073-token request) is the
one this makes unnecessary. The model writes the aggregation, the runner holds
the rows, and what comes back is the finding.

- **`CodeRunner` / `CodeSession` / `CodeResult`** (main barrel) — Start →
  Execute ×N → Stop. `CodeResult.truncated` is load-bearing doctrine: **an
  unstated slice is a silent success**, pinned in
  `test/api-conformance/silent-success.test.ts`.
- **`localCodeRunner()`** (`agentfootprint/providers`) — a child process, and
  the name says what it is. **Isolation, not a sandbox**: separate process and
  heap, kill-on-timeout, no inherited stdin, an env ALLOWLIST (`process.env` is
  not inherited — only `PATH`, so the OS can find the interpreter). No filesystem
  jail, no network jail, no CPU/memory limit. In-process `eval` / `node:vm` is
  refused outright — Node documents `vm` as not a security mechanism, and
  shipping it as one is theater.
- **`agentCoreCodeRunner({ region, identifier })`** (`agentfootprint/providers`)
  — AWS Bedrock AgentCore Code Interpreter, a real managed sandbox behind the
  same port. Dispatches `StartCodeInterpreterSessionCommand`,
  `InvokeCodeInterpreterCommand`, `StopCodeInterpreterSessionCommand` via
  `client.send(new Command(...))`, pinned in `test/adapters/aws/awsCommandPin.ts`
  and **verified against a real install of the SDK before shipping** — including
  two shapes a design could only have guessed at: `Invoke` answers with an EVENT
  STREAM, and seven of its nine union members are modelled _exceptions_ (folded
  in as empty output, an `AccessDenied` would have reported a clean run that
  "printed nothing"), and `Stop` takes the session id, not a URI.
- **`codeRunnerTool({ runner, scope })`** (main barrel) — holds one session per
  isolation key, reuses it across calls, registers its own teardown. Degradation
  is REFUSED, never silent: a wider key is the cross-binding bug, a narrower one
  is a hidden ~30× latency change.

Worked end to end in `examples/features/52-run-code.ts`.

### Compatibility

All additive. `ToolExecutionContext` gains three optional data fields and two
optional members — every existing tool compiles and behaves identically, and one
that ignores them pays nothing. `Tool` and `defineTool` are unchanged: session
behaviour is a FACTORY, not a Tool-shape change. `SessionLifecycle`,
`HostRequest`, `HostReply` and `AgentHost` are untouched. No new subpath.

An agent whose tools never register a cleanup never allocates a teardown tier;
its run terminals are one `undefined` check.

## [9.6.0] - 2026-08-11

**Three findings from one production blowup — an 879,073-token request against
a 272,000-token limit — fixed at the root.**

A field deployment ran an agent with `.memory()` over a stable
`conversationId`, tools returning large inventory dumps, and a fresh agent per
turn. It failed with a provider 400. Investigating it turned up three separate
things the library was doing quietly: memory that remembered one exchange
however large the window said it was, a provider refusal that reached the
caller as an opaque 400 naming none of the fixes, and a checkpoint that
advertised a resume which could only reproduce the same failure.

### Fixed — episodic memory retains the turns you asked for

`.memory(defineMemory({ strategy: { kind: WINDOW, size: 12 } }))` recalled
exactly ONE prior turn, silently, forever. The seed stage wrote
`turnNumber = 1` on every `run()`, and `writeMessages` keys entries
`msg-{turn}-{index}` — so every turn overwrote `msg-1-0` / `msg-1-1`. Six
turns of conversation left two entries in the store. Nothing threw. Every
write reported success. The only symptom was an agent that kept forgetting,
which reads exactly like a model problem.

The turn is now resolved **once per run**, from the two sources that can know
it:

- the conversation the run was handed — how many user turns the history
  already contains, which is right for `followUp()` and
  `run({ continueFrom })`, and
- **the stores the memory writes to**, which is the only anchor that survives
  the shape the field deployment actually uses: a fresh `Agent`, in a fresh
  process, per turn.

The rule is `max(hostTurn, highestStoredTurn + 1)` — a host that tracks turns
honestly keeps its numbering, a stale counter is raised to the next unused
turn, and neither can drag a conversation backwards. It is the rule
`writeSnapshot` has applied to causal snapshots since 9.1, generalised so
every memory kind shares one definition of which turn this is; the causal
stage keeps its own narrower scan as self-defense for hand-composed pipelines
and now shares the paging with the general one.

**Behaviour change, named loudly: multi-turn memory starts actually
retaining.** A six-turn conversation stores twelve message entries where it
stored two, and the window injects up to `size` of them instead of the last
exchange — so prompts get longer and stores get bigger _because the agent is
now remembering what it was asked to remember_. Turn it down deliberately
(`size`, `DECAY`, `.compaction()`) rather than by accident.

Cost: one paged `list()` per store per run, and only when a memory actually
writes — an agent with no memory, a read-only memory, or a corpus-only
`.rag(...)` makes no extra call and its seed stage stays synchronous.
`resolveTurnNumber({ stores, identity, hostTurn })` and
`maxStoredTurn(store, identity)` are exported from `agentfootprint/memory` for
hosts that mount the memory subflows into their own flowchart, and
`MemoryDefinition` now carries the `store` it was built with so the Agent can
ask it this question.

### Added — `ContextWindowExceededError`: the refusal that names its fixes

Every vendor refuses an over-long request in its own words and none of them
says which dial moves the number. What the field saw was:

```
[openai] 400 Input tokens exceed the configured limit of 272000 tokens.
Your messages resulted in 879073 tokens.
```

True, and useless: it looks exactly like a transient 400, so the natural next
move is a retry that re-sends the same oversized history. The `openai`,
`anthropic`, `bedrock`, `browser-openai` and `browser-anthropic` adapters now
translate that class of refusal — and only that class — into one typed error
carrying `provider`, `limitTokens`, `actualTokens` (when the vendor stated
them), `status`, the original as `cause`, and the code
`ERR_CONTEXT_WINDOW_EXCEEDED`. Its message carries the three fixes in the
order they are worth trying: cap oversized **tool results** where they are
produced; `.window(slidingWindow({ keepRecentTurns }))` to drop older rounds;
and the one that is not obvious until it fails — **`.compaction()` cannot fold
a span bigger than the window**, because the summarizer call sends that span.

Detection is deliberately conservative. Only unmistakable wording translates
(`context_length_exceeded`, `maximum context length`, `prompt is too long`,
`exceed context limit`, `Input is too long for requested model`, `Input tokens
exceed the configured limit`); a **rate** limit and a `max_tokens` validation
error are different failures with different fixes and are never translated;
everything else passes through as the provider error it always was. The
browser adapters translate in `wrapStatus` as well as `wrapError`, because an
HTTP refusal never reaches the latter. `isContextWindowExceeded(err)` asks the
question without importing the class.

### Fixed — a checkpoint stops advertising a resume that cannot work

`RunCheckpointError` said `Pass to agent.resumeOnError(checkpoint) to
continue` for every failure. Measured on the field case: resume re-sent the
same 540k-token history and got the identical 400 — a deterministic loop, and
the library was recommending it.

`resumeOnError` replays the checkpointed conversation to the same provider
with the same credentials, which is right for a transient fault and hopeless
for a failure the request itself causes. So three classes now get a checkpoint
message that says plainly why resume cannot help and what to do instead: a
context-window refusal (the item above, with its three fixes — recognised
both as the typed error and as the vendor's own sentence, because the field
case came from a custom `LLMProvider` in front of a gateway and never passed
through an adapter at all), rejected credentials (401 / 403,
`UnrecognizedClientException`, `invalid api key` — matched on the vendor's
exception NAME rather than loose prose), and a malformed request
(400 / 404 / 422). Everything else keeps today's hint word
for word, because wrongly telling a caller not to resume costs them a run that
would have succeeded.

The checkpoint is still built and still carries the whole conversation for
every class — `checkpoint.history` is what was about to be sent, which is what
a post-mortem wants. The message now says that too: evidence, not a retry
handle. `canResume(error)` is the same classification as a boolean, so a retry
loop can branch on it instead of reading prose.

## [9.5.1] - 2026-08-09

### Fixed — a malformed strategy is a sentence, not a `TypeError` from inside the library

Field report: `defineMemory({ type: EPISODIC, strategy: { kind: HYBRID, size: 5 }, store })`
— a hybrid written with the fields of a window — failed with

```
TypeError: Cannot read properties of undefined (reading '0')
```

A hybrid IS its `strategies` list, and that config has none. 9.5.0 added the
requirements walk, which checks what each sub-strategy NEEDS but reads the
sub-strategy list before anything establishes there is one; the pipeline
dispatch reads `strategies[0]` even earlier. Both are field reads off a shape
that never had the field, so the caller got the library's internals instead of
the name of the option they got wrong.

`defineMemory` now checks the strategy's SHAPE first, before the dispatch and
before the requirements walk, and every refusal names the field, shows a line
that would have worked, and points at `listMemoryStrategies()`:

```
defineMemory[chat]: the `hybrid` strategy needs a non-empty `strategies` array
and none was passed — a hybrid is defined by the strategies it composes, so an
empty one has nothing to compose and no behaviour of its own.
  Fix:  strategy: { kind: 'hybrid', strategies: [{ kind: 'window', size: 20 },
        { kind: 'topK', topK: 3, embedder }] }
  Or:   drop `hybrid` and name the one rule you meant, e.g. { kind: 'window', size: 20 }.
  `listMemoryStrategies()` describes all seven kinds — what each one does,
  which memory TYPES accept it, and what it needs supplied.
```

Four shapes that used to end in a raw `TypeError` or in nonsense now end in
that kind of sentence:

- **`hybrid` with no `strategies`** — the reported one. It threw on EPISODIC
  from the dispatch, and on SEMANTIC / NARRATIVE from the requirements walk
  (`strategy.strategies is not iterable`).
- **`hybrid` with an EMPTY `strategies: []`** — EPISODIC refused it tersely;
  SEMANTIC and NARRATIVE **accepted** it, because the pipeline they build
  never reads the list. An empty hybrid composes nothing, so it is now refused
  uniformly.
- **`strategies` that is not an array** — `strategies: 'window'` indexed the
  STRING, took the character `'w'`, and refused it as an unknown strategy kind.
- **A malformed ENTRY in the list** (`null`, a nested `hybrid` with no list of
  its own) — refused at its own path, e.g. `strategy.strategies[1]`.

Two more shapes one level up, found by sweeping the other six kinds:

- **No strategy object at all** (`strategy: undefined` / `null`) — was
  `Cannot read properties of undefined (reading 'kind')`.
- **The bare-string form** (`strategy: 'window'`) — refused with a bare
  "unknown strategy kind", which does not tell a caller what is wrong with a
  perfectly reasonable-looking line. It now teaches the object shape and
  echoes the kind that was reached for: `strategy: { kind: 'window', size: 20 }`.
  (Strings are still not accepted — the message teaches, it does not widen the
  API.)

The sweep found no other raw-`TypeError` gap: `summarize` without its `llm`,
`topK` without its `embedder`, `decay` without its `halfLifeMs` and CAUSAL
without an embedder already refuse by name, and the remaining absent-field
cases (`window` without `size`, `topK` without `topK`, `extract` without
`extractor`) fall through to a stage default rather than throwing — behaviour
that has shipped for many releases and is deliberately left alone in a patch.

The new guard judges SHAPE only. It does not rule on whether a kind is real or
legal for the type — the dispatch refuses those and names what that type does
accept, which is the better message, and it still speaks. Nothing that built
before builds differently: every refused shape either threw already or built a
definition with no coherent behaviour.

## [9.5.0] - 2026-08-09

**Memory strategies tell the truth.**

`MEMORY_STRATEGIES` advertised seven strategies and `defineMemory` built six —
`DECAY` was in the const, in the type union, in the docs table, and threw "not
yet wired" on every config. A host building a strategy picker off that const
offered a choice that could only fail. This release wires `decay`, and makes
every strategy DECLARE what it needs so a host can check before it offers,
rather than learning from an exception mid-run.

### Added — `DECAY` is wired: old memory fades

`defineMemory({ type: EPISODIC, strategy: { kind: DECAY, halfLifeMs, minScore } })`
now builds a real pipeline. A `FilterByDecay` stage sits between the load and
the budget picker: every entry loaded this turn is scored `2 ^ (-age /
halfLifeMs)` against its `lastAccessedAt` and dropped below `minScore`
(default `0.1`, ≈ 3.3 half-lives). A day-old entry scores `0.5` against a
one-day half-life; a week-old one scores `0.008` and is gone. Free — no LLM,
no embedder, no store round-trip beyond the load that was happening anyway.

Three decisions worth stating, because each is a thing it deliberately does
not do:

- **Age, not use.** `computeDecayFactor` also has an access term, and this
  passes a neutral `1` for it. `accessCount` is incremented by `store.get()`,
  and no shipped read path calls `get()` — they `list()` or `search()`. A knob
  for it would be a dial wired to a counter that never moves.
- **Nothing is deleted.** Decay is a read-time judgement; the entry stays in
  the store, and a shorter half-life or a lower floor lets it back in. `ttl`
  is still the way to say "stop storing this".
- **Dropped before the picker, not by it.** What has faded is not a budget
  question, and a stale entry that fits should still not be injected.

An entry whose `lastAccessedAt` is missing or non-finite is **kept**: a store
that does not date its entries has not told us they are old, and the
alternative is `NaN`, which fails every comparison and would silently drop
everything such a store returns. `halfLifeMs` must be finite and non-negative
— a negative half-life scores OLDER entries higher, which no config means, so
it is refused by name. Runnable: `examples/memory/11-decay-strategy.ts`.

### Added — `listMemoryStrategies()`: strategies declare their requirements

Seven bare strings are enough to WRITE `strategy: { kind: … }` and not nearly
enough to OFFER the choice. `listMemoryStrategies()` returns the same seven
described — `{ kind, description, types, requirements }` — following the
`listInfluenceStrategies()` exemplar already in this package. `requirements`
is what the HOST must supply (`'embedder'`, `'vector-store'`, `'llm'`); empty
means it runs anywhere at $0. `types` is the other half of "can I offer this?"
— `decay` on a SEMANTIC store is refused however good your credentials are.
`memoryStrategyInfo(kind)` looks up one.

```ts
const canSupply = new Set(embedder ? ['embedder', 'vector-store'] : []);
listMemoryStrategies()
  .filter((s) => s.types.includes('episodic'))
  .filter((s) => s.requirements.every((r) => canSupply.has(r)));
// → window, budget, decay, hybrid
```

The declaration is enforced rather than decorative — the pairs it calls
supported are built in a test, and the requirements it declares are removed
one at a time to prove each is refused.

### Fixed — a missing dependency is a sentence at build, not a `TypeError` mid-run

`defineMemory` now refuses at BUILD when a strategy's declared requirement is
absent. Two gaps this closes, both of which used to build a definition that
could not do its job:

- **`SUMMARIZE` without its `llm`** built silently for JavaScript callers and
  casts (TypeScript already required it).
- **A `HYBRID` whose sub-strategy needs something the host does not have** was
  accepted and the sub-strategy list then quietly ignored — a `hybrid`
  containing `topK` with no embedder built a pipeline that embedded nothing.
  Every sub-strategy is now checked where the caller's words still exist.

The check runs AFTER the pipeline dispatch on purpose: the existing arms know
more (the `ranksBy: 'server-text'` exemption, that it does not apply to
CAUSAL, that `EXTRACT`'s `llm` matters only for `extractor: 'llm'`), so they
speak first and this is the backstop that leaves no declared requirement
unchecked. A throw where there was a silent no-op is a fix, not a break:
nothing that worked stops working.

### Fixed — `SUMMARIZE` says what it actually does

The `summarize` stage exists in the source and is composed into **no**
pipeline. `defineMemory({ strategy: { kind: SUMMARIZE, recent, llm } })` loads
the last `recent` turns and stops — verified by counting: eight turns through
that pipeline with a counting provider produced zero `complete()` calls, while
the docs table promised "LLM compresses older turns" and the example passed a
summarizer that was never called.

The compression is not wired in this release — it needs things the strategy
does not carry (a model name, cost accounting, the separate-instance law
`.compaction()` enforces), and the Agent already has that door. What changes
is that the library now SAYS so: the caveat is in the strategy's own
`description` from `listMemoryStrategies()`, in the docs table, and in the
factory's dispatch table. `.compaction({ summarizer, model })` is the
compaction that runs.

## [9.4.0] - 2026-08-09

**AWS adapters tell the truth.**

A production field report tested 9.3.0 against a real account and found two
adapters _dispatching calls that were never made against AWS_ — one sending a
command that does not exist, one calling a method that a command-based client
does not have. Both compiled. Both had green tests. Every one of those tests
injected a double past the SDK, which is exactly why the bug class survives: the
one part of an adapter a type-checker cannot see is **which operation of the
vendor's API it dispatches**, and a fake answers whatever it is asked.

This release fixes both, seals the class across every AWS adapter in the
package, and closes three more things the same report measured. No breaking
changes to any type.

### Fixed — `agentCoreIdentity` never worked at all

`agentCoreIdentity({ region })` — the documented path, in the README and in its
own docstring — failed **100% of the time, on the first call**, with a message
blaming the SDK version:

```
agentCoreIdentity: the SDK client has no getResourceOauth2Token. Confirm the
@aws-sdk/client-bedrock-agentcore version, or pass `_client`.
```

It built a `BedrockAgentCoreClient` and then duck-typed
`client.getResourceOauth2Token` off it. A bare `@aws-sdk/client-*` **Client** is
command-based: its prototype carries `send` and `destroy` and nothing else. The
per-operation shortcuts live on the AGGREGATED client (`BedrockAgentCore`),
which is a different class. Verified on 3.1066.0 — the installed
`BedrockAgentCoreClient` prototype is exactly `[constructor, destroy]`.

It now goes through `sdk.send(new GetResourceOauth2TokenCommand(input))`, which
is the pattern the **memory adapter in this same package** has used all along.
Two more things the shim was getting wrong, found while fixing it:

- **`sessionId` came back empty on every consent round-trip.** The service
  reports a 3LO session as `sessionUri`; the adapter read `sessionId`, which is
  not a field of `GetResourceOauth2TokenResponse`. Now mapped.
- **`workloadIdentityToken` is REQUIRED on the request** and was sent as
  optional, so a call without one bought an opaque `ValidationException`. It is
  now refused by name before the call, naming both ways to supply one
  (`workloadIdentityToken`, or `workloadName` for per-user scoping).
- `expiresAt` is documented as never reported by AgentCore, because the response
  has no expiry field.

The `_client` seam is untouched: an injected client is somebody else's mapping
and keeps it.

### Added — one registry pins the AWS command names, for every adapter

The systemic half. `test/adapters/aws/` holds ONE registry naming, per adapter,
the SDK package, the client constructor and every command constructor it
dispatches — and three assertions over it:

1. **Dispatch.** Each adapter is driven through an `_sdk` double whose only
   exports are the pinned names. Reach for anything else and the adapter's own
   "missing command" refusal fires. Runs offline, always.
2. **Reality.** Wherever the peer dep is resolvable, every pinned name must be a
   real function export of the real package. (The AWS SDKs are deliberately NOT
   devDependencies: six adapters prove their missing-peer-dep refusals by the
   packages genuinely being absent. The check runs on any machine that installs
   them, and reports its own reach rather than skipping silently.)
3. **Completeness.** Every source file that LOADS an `@aws-sdk/*` package must
   have a row. A new AWS adapter cannot ship unpinned.

Coverage: `agentCoreIdentity`, `AgentCoreStore`, `agentCoreSessions`,
`BedrockAgentMemory`, `s3VectorsStore`, `cloudwatchObservability` /
`agentcoreObservability`, `xrayObservability`, `bedrockEmbedder`, the `bedrock`
LLM provider — and `agentCorePolicy`, whose row records that it now dispatches
nothing. CloudWatch, X-Ray and the Bedrock provider gained an internal `_sdk`
test seam so their command names are assertable at all; nothing about their
public behaviour changed.

### Changed — `agentCorePolicy` is retired, and says why

It dispatched `EvaluatePolicyCommand`. **That command does not exist** in
`@aws-sdk/client-bedrock-agentcore`, in any version, under any name. AgentCore
has no data-plane authorization operation: the policy surface is control-plane
only, and **AgentCore enforces policy AT THE GATEWAY**, in front of the tool,
before a request reaches your process. So every evaluation ended in the
adapter's own `catch`, and fail-closed turned that into a denial of every tool
call while reporting the engine unreachable.

The factory and all of its types remain exported and still type-check; calling
it now throws `AgentCorePolicyRetiredError` (`ERR_AGENTCORE_POLICY_RETIRED`)
naming the phantom command, the Gateway, and the alternatives —
`PermissionPolicy.fromRoles(...)` for local rules, `.toolMiddleware()` for
conditional ones. Deleting an export breaks a build with a module-resolution
error that explains nothing; a factory that refuses breaks it with a paragraph.
Whether the symbol is removed is a decision for 10.0.

**Gateway-enforced denials arrive as MCP errors** on the tool call made through
`mcpClient(...)`, and land in the loop as that tool's result. Surfacing them
honestly is the library's job; pre-evaluating a second copy of the rule
in-process never was. The `PermissionChecker` port and every local policy on it
are untouched.

### Changed — a fail-closed refusal now READS final

When a `PermissionChecker` throws, the call is denied. What the model was _told_
was the checker's own thrown message — and those are written for operators:
_"not available right now"_, `ECONNREFUSED`, _"timed out"_. **Measured in
production: a real model read that as weather and retried the same tool to
`maxIterations`, then returned the empty string.** Against the local policy's
long-standing bracketed form the same model adapted cleanly on the first
refusal. So the denial now uses that form and states that it is terminal:

```
[permission denied: Tool 'refund' could not be authorized. This will not change
during this run — do not call it again. Continue without it, or say what you are
unable to do.]
```

The thrown message stays on `agentfootprint.permission.check`'s `rationale`,
where operators already look — which also keeps infrastructure detail
(hostnames, internal IPs) out of the transcript. A checker that ANSWERED still
speaks for itself: an explicit `deny` carries its own `rationale` / `tellLLM`
unchanged.

### Fixed — credential failures were invisible to `agent.on(...)`

`agentfootprint.credential.*` has had payload types, registry entries and real
emit sites since 6.11.0 — and **no dispatcher bridge and no domain wildcard**.
Every one of those events stopped on footprintjs's raw emit channel:
`agent.on('agentfootprint.credential.failed', …)` observed nothing however
correctly the event fired. That is how an identity adapter failing 100% of its
calls did so in a silence that read like health.

- `credentialRecorder` is attached on every run (zero-cost with no listener), so
  the domain reaches the typed dispatcher at last.
- `agentfootprint.credential.*` joined `DomainWildcard`, so an operator can
  subscribe to the domain as a group.
- `CredentialFailedPayload` gained **`tool`** (what actually stopped working)
  and **`errorClass`** (routable without parsing prose). Both optional and never
  invented; still never the token, and never a consent URL.
- A tool that resolves its **own** credential via `ctx.credentials` now reports
  failures too. That path emitted nothing at all: the throw was caught by the
  generic tool catch and became one indistinguishable `error: true`.

### Added — `EventMeta.sessionId`: which CONVERSATION an event belongs to

`meta.runId` is per `run()` / `resume()`; a session spans many. A shipped
telemetry stream could answer _"what happened in this run?"_ and not _"what
happened in this conversation?"_ — the question a session-oriented host is built
around, and one the events alone cannot be joined back into afterwards.

`standingAgent` now threads the caller's own session id onto every event the run
emits, and `agent.run(input, { sessionId })` sets it outside a host. The
CloudWatch and AgentCore adapters serialize the whole envelope, so it arrives
without their knowing it exists.

**Absent when there is no session, never fabricated.** An anonymous request has
none (the host's internal `#anonymous-N` concurrency latch is not published as
telemetry); a bare `agent.run()` has none.

### Fixed — `s3VectorsStore` never verified the index it was given

The same field report, on the same run. This store dispatched exactly
`PutVectors` and `QueryVectors` and **never looked at the index** — trusting
three preconditions it had no evidence for, all three of which were wrong at
once:

- **A euclidean index was accepted.** The construction refusal read
  `options.distanceMetric`, a _claim by the caller_ about an index the store did
  not create, so the default (undeclared) sailed through. Measured live: a
  vector queried against itself returned **0.9991630113800056**, where a true
  cosine self-similarity is exactly `1.0` — the very "number that READS like a
  cosine and is not one" this adapter's own header warns about.
- **A missing `nonFilterableMetadataKeys: ['af']`** surfaced as a raw AWS
  `ValidationException` — _"Filterable metadata must have at most 2048 bytes"_ —
  **mid-import**, with documents already written and success already reported
  for them.
- **The index dimension was never compared with the embedder.**

One `GetIndex`, lazily on first use and memoized, now answers all three: the
metric is verified (refused by name, quoting both the index's answer and the
caller's declaration when they disagree), `af` is verified **before the first
byte is written**, and the dimension is checked against the vectors being
written or searched — which also catches the first search of a fresh process,
which the per-process fingerprint could not. This is the discipline
`sqliteVectorStore`, `pgVectorStore` and `staticVectorStore` already applied at
open; this store had nothing to open, so it opened nothing.

A `GetIndex` that fails is refused teachingly — naming the index and what to
check — never passed through, because an index this store cannot read is an
index whose metric, layout and dimension it would be guessing at.

**Requires a new IAM permission: `s3vectors:GetIndex`.**

## [9.3.0] - 2026-08-09

**Three promises the code had already made, kept.**

`MemoryStore`'s own docstring has named its backends since 2.x — _"Every storage
backend (InMemory, Redis, DynamoDB, **Postgres**, Bedrock AgentCore) implements
this interface"_ — and named the queries too, in the two places an implementer
would look: _"**Postgres**: multi-row INSERT … ON CONFLICT DO UPDATE"_ for
`putMany`, and _"**pgvector**: `ORDER BY embedding <=> query LIMIT k`"_ for
`search`. Every one of those sentences was true about the design and false about
the shipped package. Two of the three items below are the same shape: a
documented promise with nothing behind it.

### `pgVectorStore` — the backend the port has named since 2.x

```ts
import { Pool } from 'pg';
import { pgVectorStore } from 'agentfootprint/memory';

const store = pgVectorStore({ client: new Pool({ connectionString: process.env.DATABASE_URL }) });
await indexDocuments(store, embedder, docs, { embedderId: embedder.id });
```

Postgres is the database most teams already run; a corpus that lives in it
inherits the backups, the failover, the access control and the migrations you
already have. `putMany` is the multi-row upsert the docstring promised and
`search` is `1 - (embedding <=> $query::vector)` — pgvector's cosine distance
turned into the cosine similarity the port reports.

Three decisions worth knowing:

- **It does not create the table.** A `vector(N)` column fixes N at creation and
  N is a fact about your embedder; picking it implicitly would pick it forever,
  in a migration nobody reviewed. The `CREATE TABLE` is in the docs and in the
  adapter's own docstring, and a **missing table is refused**
  (`PgVectorSchemaError`) rather than read as an empty corpus — an unreadable
  index and an empty one are different facts, and only one is safe to answer
  with "no matches".
- **Nothing is ever two statements pretending to be a transaction.** A `pg.Pool`
  hands each `query()` its own connection, so everything that must be atomic is
  ONE statement: `putMany` is one upsert, `putIfVersion` is one conditional
  upsert, `forget` is one statement with CTEs across all four tables.
- **Identifiers are validated, not escaped.** Every table and column name is an
  option (so this fits a schema with conventions), and a name that is not a
  plain SQL identifier is refused — a store configured from an environment
  variable is one `DB_TABLE` away from being an injection point.

### `s3VectorsStore` — a corpus you can add to at 14:00

`sqliteVectorStore` (8.9.0) made a corpus survive a restart, and a corpus bundle
(8.20.0) made it survive a runtime with no disk. Both leave the same gap, and a
field report named it: **a bundle can only change when you redeploy.**

```ts
const store = s3VectorsStore({ bucket: 'my-corpus', index: 'docs', region: 'us-east-1' });

// From a cron job. No deploy, no restart — the agent sees it next turn.
await indexDocuments(store, embedder, newDocs, { embedderId: embedder.id });
```

`search()` maps 1:1 onto QueryVectors and `put`/`putMany` onto PutVectors, so
`indexCorpus`, `indexFolder` and `indexDocuments` run against it unchanged —
that is the point of writing it rather than only reading it. It refuses a
euclidean index at construction (a rescaled euclidean distance reads like a
cosine and is not one), refuses the three operations a vector index cannot do
(`putIfVersion`, `recordSignature`, `feedback`) rather than faking them, and
answers their read halves truthfully.

Both adapters carry the embedder-fingerprint refusals `sqliteVectorStore` has
had since 8.9.0 — and `EmbedderMismatchError` is now ONE class in `lib/`, shared
by all three, so `catch (e) { if (e instanceof EmbedderMismatchError) }` cannot
depend on which store threw. The import path is unchanged.

### The store says what its `search()` ranks

`defineRAG` has always required an `Embedder`, because somebody has to turn the
question into a vector. A managed knowledge-base service does not work that way:
it embeds and ranks on ITS side, and its retrieval API takes TEXT. Wired to one
of those, the embedder was still constructed, still called once per turn, still
billed — and the vector it produced was discarded on arrival. The wiring read
exactly like a working one.

```ts
// The store declares `ranksBy: 'server-text'`, so this is the whole wiring:
.rag(defineRAG({ id: 'docs', store: managedKnowledgeBase }))
```

`MemoryStore.ranksBy` is `'vector' | 'server-text' | undefined`. Passing an
`embedder` (or `embedderId`) to a server-text store is **refused**, not ignored:
an ignored embedder reads, from the wiring, exactly like a working one — same
line, same id in the recording, no way to tell that nothing was embedded. Such a
retriever is read-only by construction, and no
`agentfootprint.embedding.generated` event is emitted because none happened.

It is a second member rather than a widened `supportsVectorSearch`, because a
boolean that grew a third value would slip past every `!== false` already
written against it. The two must agree, and a store that declares both and
contradicts itself is refused by name rather than silently resolved.

**Absence still means undeclared.** Every store written before this release, and
every store you have written yourself, behaves exactly as it did.

### `bedrockEmbedder` speaks each model's own body shape

`InvokeModel` is one operation over vendor-specific JSON: Titan takes
`{ inputText }` and answers `{ embedding }`; Cohere takes `{ texts, input_type }`
and answers `{ embeddings }`. This factory sent Titan's body to everything, so
`bedrockEmbedder({ model: 'cohere.embed-english-v3', dimensions: 1024 })` —
a call the previous docstring suggested — constructed fine and failed at the
first embed, against the real service.

The model id now selects a FAMILY, and the family owns the request body, the
response field and the batching. Four models are known by name (Titan V2, Titan
V1, Cohere Embed English/Multilingual v3), each with its own size rule and its
own documented input window: **32,000 characters for Titan, 2,000 for Cohere v3**
— the same runtime, the same factory, and the same 2,500-character chunk read
whole by one and truncated by the other. That gap is the argument for a
per-model ceiling. Cohere also batches for real: 500 chunks are 6 round-trips
(96 texts a call), not 500.

`input_type` is a real parameter, not a hint — the v3 models embed a QUERY and a
DOCUMENT into deliberately different places. `embed()` sends `'search_query'`
and `embedBatch()` sends `'search_document'`, matching this library's own two
call sites; `inputType` pins both when yours differ.

The unknown-model refusal stays: a model this library has never met must state
its `dimensions`, and now may also state its `family` and `maxInputChars`. An id
that WRAPS a known model — `us.amazon.titan-embed-text-v2:0`, or an ARN ending
in the model id — now resolves to the model it names instead of being refused.

### Compatibility

- Additive across the board except one refusal that got stricter:
  `bedrockEmbedder({ model, dimensions })` where the model has ONE fixed size
  (Titan V1 at 1536, Cohere v3 at 1024) and `dimensions` disagrees now throws at
  construction. It previously reported a length that could never come back —
  which is the value a vector store fingerprints on. Drop `dimensions`; the
  factory knows the model's size.
- `EmbedderMismatchError` moved to `lib/embedderMismatch.ts` and is re-exported
  from its original module, so both import paths work and `instanceof` is now
  one class rather than one per store.
- `pg` and `@aws-sdk/client-s3vectors` are OPTIONAL peer dependencies, lazily
  required at the first call. Importing `agentfootprint/memory` costs nothing
  for consumers who never build one of the new stores.

## [9.2.0] - 2026-08-08

**The agent stops forgetting the conversation it is in and calling it success.**

9.1.0 fixed one call that was accepted and quietly did something else — an
indexer that half-read a chunk. This release went looking for its siblings
across the whole `Agent` / `LLMCall` surface, and the seed was a field find:

```ts
await agent.run({ message: 'Book me a table for two.' });
await agent.run({ message: 'Make it three.' });
// → "I don't have any earlier booking from you — this is your first message."
```

`run()` is one turn. It seeds history from the message you pass and nothing
else, so the second call started a **new conversation**. Nothing threw, the
trace was perfect, and the model was honest about what it had been shown. The
door that really continued a conversation was `checkpoint()` + `resumeOnError()`
— a conversation primitive wearing an **error** name, which is exactly why a
competent implementer missed it.

Thirteen shapes were probed on the shipped build. Four were already right and
are now pinned; the rest are below.

### The conversation has a door with its own name

```ts
await agent.run({ message: 'Book me a table for two.' }); // one turn
await agent.followUp('Make it three.'); // the next one

// …or hand the conversation around — plain JSON, any store, any machine:
const conversation = agent.checkpoint();
await agent.run({ message: 'Make it three.', continueFrom: conversation });
```

`followUp(message)` is sugar for `run({ message, continueFrom: this.checkpoint() })`
and nothing else — one restoration path, so the convenience cannot drift from
the mechanism. `standingAgent` used to assemble that continuation by hand
(append the turn, rewrite `originalInput`, hand the result to `resumeOnError`);
that hand-assembly **is** the door now, so a server and a script continue a
conversation the same way.

`run()` itself is unchanged and stays single-turn. What changed is that it says
so, in the first paragraph of its own docstring, and names both doors.

### A continued turn no longer re-namespaces its own memory

The quietest bug in the batch, and the one most likely to be live in somebody's
product right now. `resumeOnError` called `this.run({ message })` with **no
identity**, and `AgentRunCheckpoint` had nowhere to keep one. So every continued
turn ran under a fresh `conversationId` derived from its own runId: turn two
wrote its memory somewhere turn three could not read it. Nothing threw. The only
symptom was an agent that kept forgetting — which reads exactly like a model
being flaky.

`AgentRunCheckpoint` now carries `identity`, `AgentRunOptions` accepts one, and
a continued turn runs under the conversation's own namespace unless the call
names another. Version stays **1**: an optional field is not a format change,
and a runtime that has never heard of it continues the conversation correctly
(the same reasoning `folded` shipped under in 8.2).

### One agent no longer answers another agent's conversation

A transcript can be replayed on any agent, and usually that is the point — a
deploy that adds a tool or edits a prompt must still continue yesterday's
conversations. Replaying the **billing** agent's conversation on the **support**
agent is a different mistake, and it used to be accepted in silence.

The rule is the one the embedder fingerprint already uses: **ids decide only
when both sides named themselves.** A checkpoint records `agent: { id }` only
when that agent was given an explicit `Agent.create({ id })`; a default
(`'agent'`) is not naming yourself, so the majority of callers are never
refused. Two sides that both chose a name and chose different ones get
`ConversationMismatchError`, by name.

### Two behavior changes — refusals replacing silent corruption

Both of these used to **succeed**. They are refused in a minor, not held for a
major, on the same reasoning 8.13.0 and 8.14.0 used: the refused path was
already broken in effect, so the throw is a fix, not a new restriction.

**`RunInFlightError` — two overlapping `run()` calls on one instance.** An
`Agent` keeps its last executor, run context, answer and pause on itself; that
is what makes `checkpoint()`, `getLastSnapshot()` and `followUp()` possible.
Two overlapping runs both finished, both returning plausible answers, and the
state afterwards belonged to whichever finished last — so `checkpoint()` could
hand back the _other_ run's conversation, with nothing in either recording
saying so. That is corruption, not concurrency. `standingAgent` has serialized
runs since it existed and calls it "a correctness requirement rather than a
tuning choice"; the guarantee now lives in the primitive. Two turns at once:
build two agents (charts are per instance, instances are cheap), or
`standingAgent({ onConcurrentInvoke: 'enqueue' })`.

**`PendingQuestionError` — a new message while a person still owes an answer.**
A paused run is unfinished work with a person on the other end. Sending a
different message used to start a fresh run and orphan the pending question —
a consent gate any later message could walk around. Answer it with
`resume(checkpoint, decision)`, or say plainly that it is being dropped:

```ts
const dropped = agent.abandonPause(); // { toolName, toolCallId, question }
await agent.run({ message: 'never mind, different question' });
```

`abandonPause()` _returns_ what it dropped, so the abandonment can be logged
rather than performed blind.

**A pause belongs to a session, not to the instance.** `standingAgent` shares
one `Agent` across every session, so the instance guard alone would have let
session A's unanswered question refuse session B's _first_ message — a
different conversation, a different person, an answer they were never asked
for. The composer now releases the instance at the moment ownership moves: once
the pause is in the store, the store owns it, and a later request carrying a
decision continues it without consulting the instance at all. On the
cannot-be-carried path it releases for the opposite reason — blocking every
other session on a question nobody can ever answer is strictly worse. Pinned by
`test/hosting/pausedSession.test.ts`.

**`.system()` twice now refuses**, on both `Agent` and `LLMCall`. It was the
last silent last-wins setter among the policy doors — eight siblings
(`.toolProvider()`, `.configure()`, `.act()`, `.window()`, `.outputSchema()`,
`.reliability()`, `.thinking()`, `.thinkingHandler()`) already refused. The
first prompt was never sent and nothing said so. The refusal names the three
things a caller might have meant: `.steering(...)` for a second always-on
block, `.configure(...)` for a per-run prompt, or joining the strings yourself.
No shipped example, test or doc called it twice.

### `agent.canExplain()`

`.selfExplain()` was already honest to the **model** with no record bound — the
trace tools answer _"No completed run is available yet"_ and the skill body says
to say so plainly. It had no answer for the **program**. `canExplain()` returns
`false` for two honest reasons — not built with `.selfExplain()`, or built with
it and no turn completed — so a caller can route a why-question before spending
one.

### Docstrings that were not true

- `getLastSnapshot()` said "undefined before the first run completes". It is
  **live during a run**: the executor is assigned at run start, so reading it
  from a listener or a tool returns the in-flight run, partially filled. That is
  deliberate (Lens scrubs a running agent through it) and is why `.selfExplain()`
  captures at the terminal flush instead. Fixed on `Agent` and `RunnerBase`.
- `attach()` now says **when** it starts observing: the next run. A recorder
  attached mid-run is not dropped, it is early.
- `AgentInput.identity` said "multi-tenant memory scope". It names all five
  consumers now, says it does **not** reach `tool.execute`, and states plainly
  that `conversationId` is a namespace key rather than a session handle.

### The seal — `test/api-conformance/silent-success.test.ts`

34 tests, one per audited shape, so no future release can reintroduce a member
of this class quietly. Three kinds of pin:

1. **Refused** — pinned by message substring, so a refusal cannot decay into a
   bare `throw`.
2. **Adapted** — pinned by what reaches the model / the store / the caller,
   observed on the wire, never by mocking internals.
3. **Stated** — pinned **twice**: the behavior, _and_ the sentence in the source
   that states it. A stated behavior whose statement was deleted is back to
   being a silent success, and only the second assertion catches that.

Plus a **doctrine sweep**: every public `AgentBuilder` method is classified as
refuses-a-second-call (14), repeatable-by-design (14), a named last-wins
exception (4: `appName`, `commentaryTemplates`, `maxIterations`,
`thinkingTemplates` — scalars and display strings that decide nothing about
what is sent), or not-a-setter (2). A method that is none of the four fails the
suite, so a setter added in a later release cannot join `.system()`'s old club
unnoticed. The exception list is pinned by exact content: growing it is a
decision somebody makes in a diff.

### Also

- `examples/features/51-conversations.ts` — runs the trap and both doors and
  prints the messages the provider actually received for each. The model's own
  "this is your first message" reply is the evidence.
- `examples/features/49-self-explain-live.ts` — turn 2 now goes through
  `followUp()`. It was a second `run()`, and the scripted mock read the _first_
  user message, so the demo's own scripting was masking the restart. Live, the
  why-question used to arrive with no subject.
- `docs-next/content/docs/build/conversations.mdx` — the conversation, the two
  doors, what travels on a checkpoint, and the three things that look like
  conversation memory and are not (`identity`, memory recall, the trace).

### Migration

Additive except for the three refusals, and all three fire on code that was
already wrong:

| if you…                                                         | you now get                                      | do this                                                   |
| --------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------- |
| call `run()` twice expecting continuity                         | the same behavior as before (a new conversation) | `followUp(message)` or `run({ message, continueFrom })`   |
| overlap two `run()` calls on one agent                          | `RunInFlightError`                               | await the first, or build a second agent                  |
| send a message while a pause is open                            | `PendingQuestionError`                           | `resume(checkpoint, decision)`, or `abandonPause()` first |
| call `.system()` twice                                          | build-time throw                                 | join the strings, or `.steering()` / `.configure()`       |
| continue a stored conversation on a differently-**named** agent | `ConversationMismatchError`                      | continue it on the agent whose id recorded it             |

`checkpoint()` payloads written by 9.1.0 and earlier continue to work; they
simply carry no `identity` or `agent`, which is the honest answer for a
conversation stored before either existed.

## [9.1.0] - 2026-08-08

**The index stops half-reading a chunk and calling it success.**

Round three of the same production field report. `indexCorpus` defaulted its
`maxChunkChars` — how much of a chunk the embedder actually reads — to **2,000
characters for every embedder**, because that is the measured cliff of the
on-device `localEmbedder`. The integration was running `byHeading({ maxChars:
2500 })` against an embedder whose real window is 8,192 tokens. Result:
**6 of 26 chunks embedded CLIPPED** — indexed by their opening, stored and
served whole as the passage — so retrieval could not find wording plainly
visible in the `<source>` block the model was shown. Nothing threw. Nothing
scored zero. The box's own two defaults simply disagreed with each other, and
the disagreement was invisible.

### The ceiling is declared by the embedder — `Embedder.maxInputChars`

The number lives where the knowledge is. An indexer's own default is a guess
about a backend it has never met, so it has to be the smallest ceiling any
embedder might have — which then cuts every larger embedder short.

`Embedder` gains an optional `maxInputChars`: the longest input, in
characters, that this embedder represents faithfully. Every shipped embedder
fills it in:

| embedder            | `maxInputChars` | where the number comes from                                    |
| ------------------- | --------------- | -------------------------------------------------------------- |
| `localEmbedder()`   | `2000`          | measured — the default model's 512-wordpiece-token cliff       |
| `openaiEmbedder()`  | `32000`         | the documented 8,191-token window, at 4 characters a token     |
| `bedrockEmbedder()` | `32000`         | Titan's documented 8,192-token window, same conversion         |
| `staticEmbedder()`  | `1000000`       | no transformer, so no context window — nothing is ever clipped |
| `mockEmbedder()`    | `1000000`       | reads every character in a loop                                |

`indexCorpus`, `indexFolder` and `indexDocuments` read the embedder's declared
ceiling **in preference to** their own 2,000-character default. An explicit
`maxChunkChars` on the call still wins over both — you are allowed to know your
corpus is denser than the arithmetic assumes. An embedder that declares nothing
gets today's behaviour exactly, unchanged.

Three deliberate limits, stated rather than hidden: the characters-per-token
figure is an **assumption** (4, the English-prose rule of thumb — code, tables
and CJK tokenise denser, which is what `maxChunkChars` is for); a model this
library does not know declares **no** ceiling rather than a guessed one, the
same rule `.dimensions` already applies; and `localEmbedder({ maxInputChars })`
is accepted because the cliff belongs to the **model**, not to the factory.

### Truncation became visible

A run that clipped anything now says so — **once**, on `console.warn`, naming
the count, the ceiling in effect _and where that ceiling came from_, and the
two fixes (re-split smaller, or raise `maxChunkChars`). `IndexReport` gains
**`truncatedCount`** beside the existing `truncated` list: the list is what you
debug with, the count is what you assert on and what a dashboard row can hold.
`indexDocuments` — which returns a count and has no report — warns on the same
terms, with fix advice appropriate to a door that does not split.

The list has been in the report since 8.10.0. Nobody reads a report that says
everything went fine, which is exactly how this survived a week of real
traffic: an invisible failure is indistinguishable from success.

### Behaviour changes

- **A corpus indexed with a hosted embedder and a raised splitter ceiling now
  embeds whole where it used to clip.** Chunk content hashes are unchanged, so
  an incremental re-index will NOT re-embed on its own — force a re-index (or
  change the `embedderId`) if you were affected, since the stored vectors are
  the clipped ones.
- **`console.warn` fires from `indexCorpus` / `indexDocuments`** when something
  was clipped. Runs that clip nothing are as silent as before.
- **`IndexReport` has one more field.** Additive; code reading the report is
  unaffected unless it constructs one.

### Docs

The splitter's `maxChars` and the indexer's `maxChunkChars` are now documented
**together**, on the [indexing](docs-next/content/docs/build/indexing.mdx) page,
in the [RAG guide](docs-next/content/docs/build/rag.mdx), on the
[embedders](docs-next/content/docs/build/embedders.mdx) page and in both
docstrings — because each is safe alone and they only collide when a consumer
raises the splitter's ceiling, which is precisely what the field did.

## [9.0.0] - 2026-08-08

**The 8.x deprecation ledger, executed. Nothing new; nothing behaves
differently. Names that had a replacement now have only the replacement.**

8.0.0 consolidated 26 export subpaths into 10 doors and promised every old path
would keep working for all of 8.x. It did. This release collects on the other
half of that promise: sixteen alias subpaths leave `package.json`, and every
option, string, method and field that shipped an 8.x deprecation notice is
removed with it.

There is no new capability here and no changed behaviour. **If your code has no
deprecation warnings on 8.20.0, it compiles and runs unchanged on 9.0.0.**

### Removed — the sixteen door aliases

Each removed path re-exported the _same symbols_ the door carries, never copies,
so this is a find-and-replace on import lines. No name moved; no name was lost.

| you were importing from                             | import from                |
| --------------------------------------------------- | -------------------------- |
| `agentfootprint/llm-providers`                      | `agentfootprint/providers` |
| `agentfootprint/embedders`                          | `agentfootprint/providers` |
| `agentfootprint/tool-providers`                     | `agentfootprint/providers` |
| `agentfootprint/thinking`                           | `agentfootprint/providers` |
| `agentfootprint/memory-providers`                   | `agentfootprint/memory`    |
| `agentfootprint/observability-providers`            | `agentfootprint/observe`   |
| `agentfootprint/strategies`                         | `agentfootprint/observe`   |
| `agentfootprint/stream`                             | `agentfootprint/observe`   |
| `agentfootprint/status`                             | `agentfootprint/observe`   |
| `agentfootprint/locales`                            | `agentfootprint/observe`   |
| `agentfootprint/debug`                              | `agentfootprint/observe`   |
| `agentfootprint/debug/finders`                      | `agentfootprint/observe`   |
| `agentfootprint/observability/contextError/finders` | `agentfootprint/observe`   |
| `agentfootprint/hosting-providers`                  | `agentfootprint/hosting`   |
| `agentfootprint/injection-engine`                   | `agentfootprint/context`   |
| `agentfootprint/identity`                           | `agentfootprint/security`  |

What ships now is exactly: the root barrel, the ten doors (`/providers`,
`/memory`, `/rag`, `/cache`, `/observe`, `/events`, `/context`, `/resilience`,
`/hosting`, `/security`), one retained alias (`/reliability`, below), and
`./package.json`. `typesVersions` was trimmed in lockstep — a stale row there is
the quiet failure mode where the editor types a path Node refuses.

`test/api-conformance/door-aliases.test.ts` pins the absence of all sixteen and
drives the TypeScript checker over the shipped `.d.ts` files to prove each door
still carries every name it absorbed; `test/api-conformance/subpath-exports.test.ts`
pins the two manifest tables and proves, by object identity, that each absorbed
implementation barrel is served by its door as the same object.

### Removed — options, strings, methods, fields

| removed                                                | replacement                                                                                                                | since                      |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `AgentBuilder.recorder(rec)`                           | `AgentBuilder.watch(rec)` — same list, same order, same attachment, and variadic                                           | deprecated 8.0.0           |
| `defineSkill({ viaToolName })`                         | drop it — `'read_skill'` is the only activation tool the library builds; gate on a `rule` trigger or a `skillGraph()` edge | deprecated 8.7.0           |
| `skillsFromDir(dir, { viaToolName })`                  | drop it — same reason                                                                                                      | deprecated 8.7.0           |
| `WindowRefusalReason` member `'summary-not-smaller'`   | `'replacement-not-smaller'`                                                                                                | renamed 8.14.0             |
| type `FoldRefusal`                                     | `WindowRefusal`                                                                                                            | renamed 7.17               |
| type `FoldRefusalReason`                               | `WindowRefusalReason`                                                                                                      | renamed 7.17               |
| `WindowStrategy` exported from `agentfootprint/memory` | `MemoryWindowStrategy`                                                                                                     | renamed 7.27.1             |
| `CompactionRecord.foldedStageIds`                      | `WindowRecord.removedStageIds`                                                                                             | family name published 7.17 |
| `CompactionRecord.foldedMessageCount`                  | `WindowRecord.removedMessageCount`                                                                                         | family name published 7.17 |
| `ContextBudgetPressurePayload.capTokens`               | `cap`, read with `unit`                                                                                                    | renamed 8.14.0             |
| `ContextBudgetPressurePayload.projectedTokens`         | `projected`, read with `unit`                                                                                              | renamed 8.14.0             |
| `BudgetPressureRecord.capTokens`                       | `cap`, read with `unit`                                                                                                    | renamed 8.14.0             |
| `BudgetPressureRecord.projectedTokens`                 | `projected`, read with `unit`                                                                                              | renamed 8.14.0             |

Three of those are worth a sentence each, because the _reason_ is the migration:

- **`viaToolName` named a door that was never built.** The evaluator activates
  an `llm-activated` skill by matching `ctx.activatedInjectionIds`, only
  `read_skill` writes that array, and nothing ever read the field. 8.7.0 made a
  non-`'read_skill'` value a mount-time refusal; 9.0.0 deletes the option. Even
  `viaToolName: 'read_skill'` is refused — the option is gone, not narrowed, and
  a caller passing the default is still a caller who believes it does something.
  The **mount** refusal from 8.7.0 stays, because an injection can reach an agent
  without passing through the factory (a hand-built object, or one deserialized
  from an 8.x artifact).
- **`capTokens` / `projectedTokens` asserted a unit the channel does not use.**
  The three context slots emit `agentfootprint.context.budget_pressure` counting
  CHARACTERS, a window strategy emits the same event counting TOKENS, and
  `contextBudget` is on by default — so one subscriber routinely got both, and
  "cap 200" could mean either. 8.14.0 added `unit` + `cap` + `projected` beside
  the old pair; 9.0.0 keeps only the honest three. `cap` / `projected` are now
  **required** on `BudgetPressureRecord` (a record carrying neither pair would be
  a record with no numbers on it); `unit` stays optional there and required on
  the payload, so a third-party slot builder still compiles and a consumer can
  always answer what was counted. The strategy-facing seam
  (`WindowStrategyResult.budgetPressure`) keeps its own `capTokens` spelling on
  purpose — a strategy declares its own `unit`, so there the name is honest.
- **`foldedStageIds` / `foldedMessageCount` were fold-flavoured names on a family
  field.** They live on `WindowRecord`, which every window strategy writes, and
  only one of the three shipped strategies folds anything — so the alias made
  `slidingWindow` and `tokenBudget` read like they were missing a field.

### Three grace errors, deleted in 10.0.0

`AgentBuilder.recorder()`, `defineSkill({ viaToolName })` and
`skillsFromDir(…, { viaToolName })` keep their NAMES for one major as throwing
stubs. Each throws at build/definition time — before any run, so the failure is
deterministic and lands in development — with a message that names the
replacement and says when the signpost comes down.

Deleting the type member alone would have been a silent DOWNGRADE for the two
`viaToolName` cases: an object literal gets an excess-property error, but an
options bag arriving through a variable does not, and the value would then be
_ignored_ where 8.7.0 refused it. So the field is read at run time exactly once
more, to say it is gone.

### Two things deliberately kept

- **`agentfootprint/reliability` survives.** It is the only home of the
  reliability GATE's `CircuitOpenError` — a different class from the provider
  decorator's `CircuitOpenError` that `/resilience` carries, with a different
  constructor and a different `instanceof` answer. Removing the path would not
  rename that class; it would make it unreachable, and a consumer could no
  longer `instanceof`-check the error their own gate throws. The full alias
  discipline still applies to it, scoped to that one exception plus
  `CircuitState`, which is declared in both breaker files as
  `'closed' | 'open' | 'half-open'` — two declarations, one type, pinned as such.
- **`buildRunSteps(events)` survives, still `@deprecated`.** Its deprecation is
  a _preference_, not a migration: live consumers should attach
  `runStepRecorder()` and read `getSteps()` (O(N), the house pattern) instead of
  re-walking an event log (O(N²) across repeated calls). But the shim is the only
  way to build steps from a saved event list — replay, post-hoc analysis, tests —
  and no recorder can do that job, so removing it would delete a capability
  rather than a spelling. It stays until something replaces the use case.

### Two ledger items that needed no work

Named here so the ledger is closed honestly rather than quietly:

- **Typestate builder one-shots.** No `@deprecated` typestate marker exists in
  `src/`. The builder's "set twice" guards (`.compaction()`, `.window()`) are
  live refusals, not deprecations — nothing to remove.
- **Budget-picker ordering.** No deprecated ordering option exists in `src/`.
  `RetrievalEvidence.selectionOrder` is a documented current field, not a
  transitional one — nothing to remove.

### Upgrading

1. Rewrite import lines against the door table above. Nothing else in the file
   changes — every name is the same symbol on the new door.
2. `.recorder(` → `.watch(`.
3. Delete every `viaToolName`.
4. `capTokens` → `cap`, `projectedTokens` → `projected` (and read `unit`);
   `foldedStageIds` → `removedStageIds`, `foldedMessageCount` →
   `removedMessageCount`; `'summary-not-smaller'` → `'replacement-not-smaller'`;
   `FoldRefusal[Reason]` → `WindowRefusal[Reason]`; `WindowStrategy` from
   `agentfootprint/memory` → `MemoryWindowStrategy`.

## [8.20.0] - 2026-08-07

**The corpus stops promising what it does not contain.**

Round two of the same production field report that drove 8.19.0 — the same
integration, now running all four of those fixes, coming back with what the
next week of real traffic found. Three findings again, and the first one is
the reason this release exists: the default splitter could manufacture the
exact chunk shape that makes a model fabricate a citation.

### A heading is a label, not a passage — the splitter floor

`byHeading()` emitted heading-only and heading-plus-preamble chunks, and those
chunks do not retrieve badly — they retrieve **too well**. Similarity is a
density measure: a heading plus one preamble sentence concentrates its topic's
vocabulary with none of its substance. Measured in the field: a 180-character
chunk (`## Findings` plus one sentence promising them) outranked the
1,032-character body of its own section at 0.430. The model was handed a
passage that PROMISES findings, contains none — and fabricated a plausible
file path to fill the gap. A fabricated citation born entirely from a
chunking default.

The fix is one coherent rule across the structural splitters:

- **`minChars` — a floor, merged FORWARD.** `byHeading` and `byParagraph`
  take a `minChars` option, default `min(250, maxChars / 4)` — 250 at the
  default target, ~60 tokens, comfortably above the measured 180-character
  failure and a quarter of the target so merged chunks (short section + full
  neighbour ≈ 1,250 chars ≈ 310 tokens) stay far inside the measured
  512-wordpiece embedder cliff. A section whose body is under the floor joins
  the NEXT chunk **under its own heading** — the preamble sentence survives,
  leading the chunk it introduces, and the citation still names the section a
  reader would look up. Nothing is ever dropped. Adjacent shorts whose bodies
  together clear the floor become one chunk of their own; a trailing short
  merges backward (the one edge with no next); a document that is one short
  section ships whole.
- **Heading-plus-whitespace is never emitted, unconditionally.** Even at
  `minChars: 0`. A chunk with no body is a coordinate, not a passage — the
  same distinction `indexDocuments` enforces one layer up when it refuses a
  passage-less document. A document that is nothing but headings yields no
  chunks at all.
- **The long-section variant of the same bug is closed.** A section too long
  to fit whole used to pack its heading LINE as its own paragraph unit — and
  when the first body paragraph could not pack with it, the heading shipped
  alone. The heading is now glued to the first body paragraph; it can never
  again be a chunk by itself.
- **The family was inspected, and two members are exempt by design.**
  `fixedWithOverlap` cuts uniformly sized chunks _by request_ — imposing a
  250-char floor on `fixedWithOverlap({ chars: 120 })` would repeal the
  caller's own choice, and its only runt (the file tail) has always folded
  backward. `wholeDocument` is one chunk per document by definition.

**LOUD behaviour change: re-indexing an existing corpus produces different
chunks.** Incremental re-index will re-embed where boundaries moved (content
hashes change) — that is the fix working, not a regression. Today's default
can fabricate citations; the new default cannot ship the chunk shape that
did. Pin `minChars: 0` only if you must reproduce the old cuts, and know that
heading-only chunks are refused regardless. Two smaller consequences, named:
a chunk may now exceed `maxChars` by up to the floor (in addition to the
overlap) when a short neighbour merged into it, and `minChars ≥ maxChars` is
refused as the configuration contradiction it is.

### A recording keeps a vector's shape, not its bytes

One retrieval turn's recording measured **2.76 MB — about 1.1 MB of it
embedding floats.** The memory-read subflow's boundary output carries each
retrieved entry, and each entry carried its full vector; the snapshot's
subflow results then carried the same entries again through every state
mirror the engine keeps. Nobody reads those floats: retrieval debugging needs
the score, the passage, the document, and the rejected candidates — all in
the retrieval evidence, none of it a vector.

Recordings now keep `{ dims, norm }` where a vector was — dimensionality and
L2 norm, the two facts that make a vector recognisable without shipping its
bytes. Applied uniformly at the recording boundary: `BoundaryRecorder`
subflow/run payloads at capture time, and `recordRun`'s snapshot and event
tail at freeze time, covering both spellings the memory layer writes
(`embedding` on entries, `embeddings` on write-side batches). The projection
is copy-on-write (payloads without vectors pass through by reference,
shared entries stay shared) and idempotent. Live run state, stores, and the
retrieval evidence are untouched — this is about what a RECORDING retains,
not what the run computes with.

**Behaviour change:** recordings are smaller and their `embedding` fields are
summaries. `recordEmbeddings: true` on `recordRun` or `boundaryRecorder`
restores raw vectors for the rare consumer that replays them offline.
`summarizeEmbeddings` / `summarizeVector` / `EmbeddingSummary` are exported
from `agentfootprint/observe` so recording post-processors can apply or
recognise the same projection.

### The corpus as a build artifact — `exportCorpus` / `staticVectorStore`

The field deployment runs on a runtime whose disk does not survive the
process — while its build machine holds both the embedding credentials and a
durable disk. The corpus therefore wants to be an artifact of the BUILD, and
this release gives that shape first-class words (vendor- and runtime-neutral;
any immutable or serverless runtime has this problem):

- **`exportCorpus(store, identity?)`** (`agentfootprint/rag`) — every entry
  of a corpus namespace as one plain-JSON `CorpusBundle`:
  `{ entries: [{ id, text, vector, metadata }], embedder: { id, dimensions },
namespace }`. Plain JSON on purpose — the runtime that needs this is
  exactly the runtime that cannot open a database file. It refuses an empty
  namespace (naming the identity-mismatch cause), entries with no vector or
  no passage (a bundle never ships an unservable or uncitable entry), and a
  namespace that mixes embedding spaces (no single query embedder could
  search both).
- **`staticVectorStore(bundle, embedder?)`** (`agentfootprint/memory`,
  re-exported from `/rag`) — a read-only `MemoryStore` over the bundle,
  `supportsVectorSearch: true`, ranking by the same cosine as the reference
  store. Every write method refuses teachingly (a static corpus that
  silently accepted writes would lose them with the process). Pass the
  runtime's embedder and a fingerprint mismatch is refused **at load** —
  the `sqliteVectorStore` rule, applied at the door: dimensions always
  decide; ids decide only when both sides named themselves. At search time a
  wrong-length query or a mismatched `embedderId` throws by name instead of
  ranking to an empty page — the loud version of the mismatch machinery
  that already caught this integration's own embedder-id format change.
  Entries are served in the exact shape the retrieval formatter reads
  (passage on `value.content`, provenance under `value.metadata`) — the
  8.19.0 blank-citation lesson, enforced at the seam.
- **`importCorpus(store, bundle, identity?)`** — the inverse, into any
  writable vector-capable store: seed an in-memory corpus at boot, or
  migrate between machines without re-embedding (and re-billing) anything.
- **CLI:** `agentfootprint-index ./docs --to ./corpus.json` builds a bundle
  directly — same pipeline, one JSON artifact for the deploy to carry.

### The threshold docstring learns another embedder's numbers

The `threshold` guidance on `defineRAG` / `topK` (and the rag guide's
threshold section) now carries field-measured score bands for Amazon Titan
Text V2 alongside the existing sentence-transformer note: 0.55–0.57 for a
direct hit, ~0.49 for the right section diluted, 0.36–0.42 for noise — **the
0.7 default retrieves NOTHING on that embedder, silently**; ~0.5 separates
its signal from its noise. One vendor's measured example of the general
rule: the right threshold is a property of the embedder, and the rejected
candidates on `agentfootprint.memory.retrieved` are how you read yours.

## [8.19.0] - 2026-08-07

**The corpus says what it retrieved.**

Four findings from a production RAG deployment — a first-contact report, from
someone building against the published package on a cloud runtime. Three of
them are the same shape: a call that accepted something it could not honour and
reported success anyway. The first is the worst kind of bug this library can
ship, because the run looked like it worked.

### A retrieved passage reaches the prompt with its text in it

The formatter read a chunk's passage from `value.content`. A `Chunk` from this
library's own `agentfootprint/rag` door — `indexCorpus`, `indexFolder`, the
`agentfootprint-index` CLI — keeps its passage on **`text`**. So every corpus
built with the indexing door rendered like this:

```
<source id="refunds.md#3" doc="refunds.md" heading="Refund timing" score="0.87">

</source>
```

Right document, right heading, right score, **no passage**. The model was handed
the coordinates of an answer and not the answer, and nothing in the run said so:
the report said `embedded: 8`, the retrieval record listed the right chunks with
the right provenance, and the agent answered from its own weights while looking
grounded. It was reported from production as "the citation is perfect and the
body is empty", and it was never a corner case — it was every corpus the `rag`
door built.

`chunkText` now reads `content` **or** `text`, so both shapes render (`content`
wins if a value somehow carries both). `indexDocuments` accepts `text` as well
for the same reason, and the repo's own RAG example now prints the passages
alongside the citations, because the two are different claims and only one of
them was being checked.

**Behaviour change:** a document passed to `indexDocuments` carrying **neither**
key is now refused, before anything is embedded:

```
indexDocuments: 1 document(s) carry no passage — neither `content` nor `text`
holds a non-empty string: notes.md#4.
  Indexing them would embed the empty string and store a citable id with
  nothing to cite …
```

An unrenderable passage and an absent one are different facts. The first is now
rendered; the second is refused where it can still be fixed, rather than
discovered months later as a blank citation.

### `maxChars` — a count bound is not a size bound

`topK` says how MANY passages reach the prompt and nothing about how much text
that is. In the field, ten chunks cut by `byHeading()` off ordinary
documentation measured **11,153 characters** against a `systemPrompt` slot whose
default budget is 4,000 — an overflow produced entirely by defaults on both
sides.

`defineRAG({ maxChars })` is the missing bound: a character budget spent across
the retrieved passages in rank order, tail dropped.

```ts
defineRAG({ id: 'docs', store, embedder, topK: 5, maxChars: 2000 });
```

The spend is **recorded, never silent**. Passages past the budget are refused
with the new `reason: 'over-char-budget'` on
`agentfootprint.memory.retrieved`, and the record carries `maxChars` and
`charsUsed` beside the counts it already carried.

`maxChars` has **no default**, and that is a decision rather than an omission.
Defaulting it would mean this release quietly stops injecting passages that
8.18.0 injected — a retrieval regression that reads to a user as "the model
doesn't know that", which is the failure class this library exists to make
loud. Nothing truncates today either: the slot warns once and emits
`agentfootprint.context.budget_pressure`, so a run is already honest about an
over-run. What it lacked was a way to bound one. The two numbers now sit side by
side in the RAG guide, because they live on different objects and the
arithmetic between them is easy to miss: `topK` defaults to **3**,
`contextBudget.systemPrompt` defaults to **4000 characters**, and retrieved
passages share that slot with the system prompt, steering, facts and skill
bodies.

It composes with `retrieval` rather than excluding it (unlike
`topK`/`threshold`): the strategy picks the candidates, `maxChars` bounds their
size. `defineMemory` refuses it on the CAUSAL type, which has no passage pool to
spend a budget across.

It is **not** the splitters' `maxChars`, which bounds one chunk at index time
and defaults to 1000 — that one is why the arithmetic lands where it does. Ten
chunks off a 1000-character splitter is ten thousand characters before a single
`<source>` tag is added, which is the 11,153 above almost exactly.

### A store now declares whether it can serve vectors back

`search()` is optional on `MemoryStore`, so "can this store do vector search?"
was answered by asking whether the method exists. That answers the wrong
question. `AgentCoreStore` **has** a `search()` — it ranks server-side, over the
records the backend's own extraction strategies derived, and never over the
embeddings written into it. Handed one, `indexCorpus` type-checked, ran, embedded
the whole corpus, billed for it, and reported `embedded: 214` over an index
nothing could ever read.

`MemoryStore` gained one declared bit, `supportsVectorSearch`, and the
corpus-building calls read it:

```
indexCorpus: `AgentCoreStore` cannot serve vectors back, so indexing a corpus
into it would report success and retrieve nothing.
  It declares `supportsVectorSearch: false`: its search() ranks on the SERVER's
  side, over a population the backend derived itself …
  Fix:  index into a vector-capable store — InMemoryStore (dev/tests) or
  sqliteVectorStore (durable, one file), …
  Keep `AgentCoreStore` for what it is good at: conversation memory through
  `defineMemory`, where the backend's own retrieval is the point.
```

`indexCorpus`, `buildIndexChart`, `indexFolder` and `indexDocuments` all refuse
it, before a byte is embedded. `InMemoryStore` and `sqliteVectorStore` declare
`true`; `AgentCoreStore` and `RedisStore` declare `false`.

**Behaviour change:** building a corpus into `AgentCoreStore` or `RedisStore` is
refused where it used to run. Neither could ever serve those vectors back —
`RedisStore` has no `search()` at all, so the same mistake already failed one
layer later, when `defineRAG` refused the store _after_ the whole index had been
embedded and billed. The refusal moved to the call that starts the spending.

**Absence is not a `false`.** A store that declares nothing behaves exactly as it
did before this existed — which is every adapter written against an earlier
release, including yours. Reading it is opt-in on the adapter's side, and the
refusal only ever fires on a store that asked for it.

### `bedrockEmbedder()` — Titan Text Embeddings V2

A fourth shipped embedder, on `agentfootprint/providers`, next to
`openaiEmbedder` / `localEmbedder` / `staticEmbedder`. Lazy-requires
`@aws-sdk/client-bedrock-runtime` (an optional peer dep, like every other AWS
adapter here), and takes no `apiKey` — Bedrock authenticates through the normal
AWS credential chain, and a second way to configure that is a second way to get
it wrong.

```ts
import { bedrockEmbedder } from 'agentfootprint/providers';

const embedder = bedrockEmbedder({ region: 'us-east-1' }); // 1024-d
const small = bedrockEmbedder({ region: 'us-east-1', dimensions: 512 });
```

Titan V2 returns 1024 dimensions by default and supports 512 and 256. The size
you ask for is sent to the model **and** reported as `.dimensions`, so the two
cannot disagree; a size Titan does not produce is refused, and a model this
library does not know the size of has to state its own.

**Its `id` carries the dimension count** — `bedrock:amazon.titan-embed-text-v2:0:512`
— and that is deliberate, against the usual rule that an embedder id leaves the
size to the store's `<id>@<dims>` fingerprint. Titan V2 at 512 and Titan V2 at
1024 are different embedding spaces from one model id, and the size alone
cannot separate them either, because V1 and V2 both answer at 1024. Entries
store the id **alone** in `embeddingModel`, and that is the only thing the
read-side `embedderId` filter compares — so the size belongs in the id, and a
fingerprint that restates it once is the cheaper mistake.

## [8.18.0] - 2026-08-07

**Output contracts are loud, and a message is always a message.**

Ten findings, one shape between them: this library accepted something it could
not honour, and said nothing until the failure surfaced somewhere that could
not explain it. Eight are the "output contracts" batch of the act/window audit.
Two came out of clean-room probing of the published bytes, and one of those is
the worst kind — a mistake that LOOKED like it worked.

Six of these change what a run does; four refuse a configuration or an input
that used to be accepted. All ten are below.

### `agent.run('go')` — a bare string IS the message

`AgentInput` has one required field, so a lone string has exactly one possible
reading, and every chat SDK takes it. This library took it two different ways:

- **`Agent.run('go')`** reached the messages slot as `content: undefined` and
  threw `TypeError: Cannot read properties of undefined (reading 'length')`
  from five frames inside the engine, naming nothing.
- **`LLMCall.run('go')`** did **not** throw. It called the model with an
  **empty conversation** and returned the answer, so the mistake was
  indistinguishable from working code.

A bare string is now adapted — `run('go')` ≡ `run({ message: 'go' })` — on
`Agent`, `LLMCall`, `Sequence`, `Parallel`, `Conditional`, `Loop` and
`LlmRouter`, through one shared door (`src/core/runInput.ts`). The `Runner`
port and `RunnerBase` declare the union, so a custom runner shares it too.

Everything that is **not** a message is refused before the run starts, with a
typed `InvalidRunInputError` (`ERR_INVALID_RUN_INPUT`) naming the door and the
shape that arrived — never the value, because a refused input is still the
caller's data:

```
Agent.run: `message` must be a string — pass a message: run('your message')
or run({ message: 'your message' }) (received an object with keys: text,
whose `message` is undefined).
```

**Behaviour change:** an empty or whitespace-only message is refused. It is not
a shorter question — `Agent` used to send a `content: ''` turn (which real
provider wires reject) while `LLMCall` sent no turn at all, so the two runners
disagreed about what it meant and neither answer was right. To run on the
system prompt alone, say so in the message. Nothing is billed and no half-run
has to be explained: the refusal happens before the executor is created.

### A turn with no text never enters the conversation

The `undefined` content above had six other ways in, all landing on the same
line — `buildMessagesSlot` composing `truncate(m.content, 80)`. Each source now
normalizes (one sane reading) or refuses teachingly (more than one). None of
them was fixed at the crash line, because hiding it there would have hidden
which source leaked.

- **A tool that returns nothing** — `safeStringify` returned
  `JSON.stringify(value)`, which is `undefined` for `undefined`, a function and
  a symbol, while its signature promised `: string`. An `async execute()` that
  did its work and forgot to `return` killed the run one turn later. It is now
  total: a value-less return becomes the self-describing
  `(this tool returned no value)` — not `''`, because the model has to be able
  to tell "no value" from "the empty answer". `stream.tool_end` still carries
  the real return value, `undefined` included.
- **A human-answer pause resumed with nothing** — `agent.resume(checkpoint)`
  after `askHuman()` / `pauseHere()` now raises `PauseAnswerRequiredError`
  (`ERR_PAUSE_ANSWER_REQUIRED`), naming the tool and both spellings
  (`resume(cp, 'the answer')` vs `resume(cp, '(no answer)')`). That pause
  exists to collect a value and the value becomes the tool's result, so "no
  answer" and "carry on" are two different conversations. Nothing runs before
  it raises and the checkpoint is unchanged.
- **A middleware that rewrites a message to something that is not text** —
  denied, naming the middleware, per this file's own law that a middleware
  which throws is a denial and never a pass. It used to be assigned, producing
  `s.slice is not a function` at the input phase and an unattributed
  "unexpected result shape" at the output phase.
- **A declared `slot: 'messages'` injection with missing or empty content** —
  refused at the declaration funnel, beside the existing role refusal. Two
  contentless declarations also collided on the delivery ledger's key, so one
  was silently swallowed.
- **A restored checkpoint carrying a hole** — `validateCheckpoint` checked
  `Array.isArray(history)` and never looked inside, one field away from
  `originalInput.message`, which has been string-checked since it was written.
  It now checks every turn, at the door a persisted artifact comes in.
- **The slot itself** is the net, and it does not coerce: a message that gets
  through anyway is named — position, role, and where it came from (an
  injection, a tool call, the run input, the history).

### A middleware `ask` at the message boundary was silently an ALLOW

`MessageOutcome` has no `ask` arm, so TypeScript refuses it at the call site.
A JS consumer, an `as any`, or a link written for the tool chain and reused
here reached the runtime — where the ask fell straight through the value test
and filed an **allow** row. A rule that believed it had paused for a person had
approved the message, and the ledger agreed with the rule.

It is now a denial naming the middleware, which is the answer
`askPolicy: 'refuse'` already gave the tool chain wherever no pause exists to
carry an ask. Ask at a tool moment, where a pause exists, or decide with
`allow()` / `deny()`.

### A malformed provider stream chunk says which provider, and what the contract is

`LLMProvider` is a port anyone can implement. A non-terminal chunk with no
`content` died on `chunk.content.length` as
`Cannot read properties of undefined (reading 'length')`, naming neither the
provider nor the shape it missed. The commonest way to get there is ending the
stream with a marker of one's own instead of `done: true` — so the refusal says
what the terminal chunk looks like.

### `.outputSchema(parser)` now JUDGES by default

`retries: 0` — the default, and the whole of what `.outputSchema(parser)` means
on its own — used to mount nothing in the loop. The chart was byte-identical to
an agent with no contract at all: no judging, no `outputAttempts` row, no
event. A `run()` caller received a contract-violating string with nothing
anywhere saying that a contract had been declared, let alone missed.

`retries: 0` now means **judge, do not re-ask**. No retry branch is mounted, no
extra turn is spent, no extra token is billed — the request bytes are
unchanged. What changes is that the run knows, and says.

### The run says when the contract is not met

Three channels, the same three a limit that cuts a turn short uses, because
before this there were none:

1. **`agent.outputContractUnmet()`** — committed state carrying the stage, the
   validator's own words, the attempts, the re-asks that were billed, whether a
   fallback is configured, and `brokenBy`. `undefined` when the answer passed.
2. **`agentfootprint.agent.output_contract_unmet`** — one new typed event
   (73 across 20 domains now). A rise in `'json-parse'` is a model that stopped
   honouring the instruction; a rise in `'schema-validate'` is drift.
3. **One `console.warn`**, naming what to do next.

`run()` still returns the raw answer and `runTyped()` still throws
`OutputSchemaError`. Neither changed: a caller who wants a raise asks for one,
and a caller who does not should still be able to find out.

### An output rule that breaks a good answer is named, and stops the re-asking

**Behaviour change.** An `act({ output })` middleware runs before the schema is
judged — correctly, because the string it produces is the one the caller
receives. But a rule that rewrites a valid answer into an invalid one used to
burn **every** retry chasing its own damage: the model answered correctly, the
rule broke it, the run paid for another turn, and the model answered correctly
again. The ledger read `[retried, retried, exhausted]` with the validator
complaining about text the model never wrote.

The run now judges the pre-chain answer too, but only when a link actually
changed something. If the model's answer passed and the rewrite is what failed,
the run stops re-asking and names the rule — in the `outputAttempts` row
(`brokenBy`), in the event, in `outputContractUnmet()`, and in the warning.
Re-asking cannot fix a rule: a deterministic one breaks the next answer
identically, so the retries buy a repeat of the same ending. An answer that was
_already_ bad still spends its retries — the stop applies only when the
middleware is the cause.

### `.outputFallback()` says which door reaches its tiers

The tiers produce a typed `T`, so `runTyped()` and `parseOutputAsync()` engage
them and **`run()` cannot** — substituting a fallback into a string return
would hand a caller a different answer than the model gave, invisibly. An agent
consumed through `run()` (a server route, a queue worker, `standingAgent`)
therefore gets no fallback, and nothing used to say so. The unmet-contract
warning and event now carry `fallbackConfigured`.

With `canned` set, `runTyped()` is **structurally unable to throw** — which is
the point of a safety net, and also why N billed re-asks ending in a static
object could not be observed at all.
`agentfootprint.resilience.output_canned_used` now carries `retriesSpent`, and
one warning fires when the canned value lands after re-asks that were paid for.

### `.outputFallback()` is checked at `.build()`, in either order

The requirement is set MEMBERSHIP — a fallback is degradation for a contract,
so an agent with one and not the other is incoherent. Order was never the
requirement, but the refusal was on order: `.outputFallback().outputSchema()`
threw while `.outputSchema().outputFallback()` was fine, and both end with the
same agent. The coherence check and the `canned` validation now run at
`.build()`, where the parser is guaranteed to exist. Same fail-fast guarantee,
one moment later, and the two lines can be written in whichever order reads
better.

### "Already set" names the door that set it

`.window()`, `.compaction()` and `.act({ window })` are three doors into one
setting, and how much you were told depended on which direction you approached
from: `.window()` named the strategy and then talked about `.compaction()` even
when `.act({ window })` had set it, while `.act()` said "set by .window() or
.compaction()" — an `or` that was sometimes neither. The door is now recorded
where it becomes true and named in all three refusals, in every direction.

### Compatibility

- **New:** `run(string)` on every runner; `InvalidRunInputError`,
  `PauseAnswerRequiredError`, `agent.outputContractUnmet()`,
  `agentfootprint.agent.output_contract_unmet`, `OutputAttempt.brokenBy`,
  `retriesSpent` on the two fallback events.
- **Refused where it used to be accepted:** an empty/whitespace-only message; a
  non-message `run()` input (these crashed or silently mis-ran before); a
  resume with no answer on a human-answer pause (crashed before); a middleware
  `ask` or non-text `allow` at the message boundary; a contentless declared
  message injection; a checkpoint whose history carries a turn without text; a
  malformed provider stream chunk (crashed before).
- **Moved, not changed:** `.outputFallback()` coherence and `canned` validation
  from the call site to `.build()`.
- **Changed on a working path:** `.outputSchema(parser)` with default options
  now judges the answer, may warn, and may write `outputAttempts` /
  `outputContractUnmet`. The answer, the request bytes, the chart shape, the
  turn count and the bill are unchanged. And an output rule that breaks a
  passing answer no longer spends the run's retries.
- No renames, no removals, no signature narrowings.

## [8.17.0] - 2026-08-07

**The record goes through the tool boundary.**

8.16.0 let an agent answer "why did you do that?" from its own recorded turn.
`inspect_tool_call` resolved a tool call end to end and then stopped at a wall:

```
⚠ boundary: what happened INSIDE the tool is not traced — this is the envelope
  (arguments in, result out) plus what the run itself decided about it.
```

That is the true answer for a tool that reaches into a payments API. It was
needlessly true for a tool that IS a footprintjs flowchart: that tool recorded
every stage it ran, and then threw the recording away, because nobody was
holding it.

### `flowchartAsTool({ keepRecord: true })` — the tool keeps its own record

Each invocation's inner record is now filed under the `toolCallId` the outer run
already uses to name that call. One id, two levels — which is what makes the
descent a lookup rather than a correlation puzzle.

```ts
flowchartAsTool({
  name: 'weather_advice',
  description: 'Decide whether to bike tomorrow.',
  flowchart: adviceChart,
  keepRecord: true, // ← off by default
  keepRecordLimit: 20, // ← bounded LRU window (this is the default)
  redact: { keys: ['apiKey'] },
});
```

- **Off by default, and zero-cost off.** No store, no extra recorder, no
  capture — the byte-identical path the tool had before the option existed.
- **Bounded.** The last `keepRecordLimit` invocations (default 20), least
  recently USED dropped first; reading refreshes recency so a record under
  investigation is not evicted by a turn happening beside it. Records dropped
  to stay under the cap are COUNTED, and the session is told.
- **All three exits are recorded.** A run that threw and a run that paused are
  complete records of what happened, and "why did it fail?" is the question
  most likely to come next.
- **Capture failure is filed, not swallowed.** If the record cannot be taken,
  a row is kept carrying the reason — a missing row is indistinguishable from
  a call that was never made.

`redact` is new on the same options object: a `RedactionPolicy` applied to the
inner executor before every invocation. footprintjs scrubs at commit time, so a
covered key never enters the inner commit log at all — which is what makes a
kept record safe to serve back to a model.

### `inspect_tool_run` — the descent rung

`inspect_tool_call` now ends with the call that opens the inside, in place of
the boundary marker, whenever a record exists:

```
inside: this tool kept its own record of the run — 4 step(s), ok.
        Descend with inspect_tool_run({ toolCallId: 'c1' }).
```

The new tool serves the inner run with the SAME drill vocabulary, one level
down — overview by default, plus `find` (free text → inner ids), `variable`
(why is an inner value what it is), `runtimeStageId` (one inner step), and
`runtimeStageId` + `key` (that field in full). The inner views are the pack's
own tools built over the inner artifact bag through `openRecording`, so there
is no second implementation of "what did this step write" to drift.

```
INSIDE TOOL CALL c1 — 'weather_advice' ran a recorded flowchart (4 committed step(s), ok).
SLICE for 'advice' — reads via: custom-fn
Advise transit (rain#3) [wrote: advice, because]
  Validate the forecast (validate-forecast#1) ← via rainChancePct [wrote: checks, usable, rainChancePct]
    Fetch the forecast (fetch-forecast#0) ← via forecast [wrote: forecast]
  Weigh the rain (weigh-the-rain#2) ← [control: Rain chance at or above the 60% bike threshold]
⚠ the ids above are INNER ids — they name steps of weather_advice's own chart, not of the
  run that called it. trace_node / get_value / trace_slice do not accept them.
```

Two id namespaces, said out loud on every answer: a model that pastes an inner
id into `trace_node` should meet a boundary, not a mystery.

An inner record carries one thing a saved recording never can — **control
edges**. `openRecording` has to say "⚠ control edges unavailable" because a
lookup function does not serialize; an inner record is live in process, so the
wrapping tool attaches a fresh `controlDepRecorder()` per invocation and the
inner slice shows the decision RULE that routed execution.

`inspect_tool_run` mounts **unconditionally**, like `inspect_tool_call`, and
answers honestly when there is nothing to open — naming `keepRecord` (nothing
keeps records), listing the calls that CAN be opened (wrong id), or naming
`keepRecordLimit` and the drop count (evicted). A tool that vanishes when the
answer is "none" cannot say which switch turns it on.

`TRACE_TOOL_NAMES` gains `inspect_tool_run` — the builder's `.selfExplain()`
name reservation is read from that one list, so the new name is reserved the
day it ships. Ten tool definitions now land on the tools slot for the one
activated iteration; an agent that opts into `.selfExplain()` should raise
`contextBudget: { tools: 7000 }`.

### Wiring is the builder's job

`.build()` collects the inner-record store off every statically registered
(`.tool()` / `.tools()`) and skill-declared chart tool, and hands one merged
lookup to the trace artifacts. Mounting the tool and calling `.selfExplain()`
is the whole configuration. A chart tool delivered through a `.toolProvider()`
is resolved per iteration against a live context, so there is no build-time
moment at which the list exists — register it statically as well if you want
the descent, and `inspect_tool_run` says so meanwhile.

New exports on `agentfootprint/observe` (and `/debug`) for consumers assembling
artifacts by hand: `innerRunStore`, `innerRunsOf`, `mergeInnerRuns`,
`INNER_RUN_RECORDS`, `DEFAULT_INNER_RUN_LIMIT`, and the `InnerRunRecord` /
`InnerRunLookup` / `InnerRunStore` / `InnerRunSummary` / `InnerRunOutcome` /
`KeepsInnerRuns` types. `TraceToolpackArtifacts` gains an optional `innerRuns`.

### The demo

`examples/features/50-through-the-tool-boundary.ts` — a weather-advice agent
whose ONE tool is a 4-stage footprintjs chart. Turn 1: _"Should I bike to work
in Chicago tomorrow?"_ Turn 2: _"Why did you say it'll rain?"_ — answered
through visible `find_in_trace` → `inspect_tool_call` → `inspect_tool_run`
calls that cite the inner stage (`validate-forecast#1`), the exact field
(`rainChancePct = 82`) and the rule that consumed it. The chart's own stage
counters are printed either side and are unchanged: explaining the decision did
not re-make it. Fixture data and a scripted model by default ($0, no network);
`AGENTFOOTPRINT_DEMO_LIVE_DATA=1` fetches a real forecast from Open-Meteo (a
keyless public API), `ANTHROPIC_API_KEY` switches on a live model
(`claude-haiku-4-5` by default).

## [8.16.0] - 2026-08-07

**The agent can now answer "why did you do that?" without re-doing it.**

`.selfExplain()` shipped in 8.12.0 with six tools over the previous completed turn.
Used in anger, it had four gaps — and every one of them was the same shape: the
evidence existed, and the model had no way to reach it.

### `find_in_trace(query)` — free text in, step ids out

Every other trace tool needs a name you already have: a step id, a state key, a
variable. But a follow-up question arrives in the user's words — _"why did you say
order 7712 was out of warranty?"_ — and the model's only options were to guess a
state key or read the whole narrative.

This searches stage names and descriptions, state keys, every committed value and
the narrative, and hands back **pointers**. Every hit line ends with the exact call
that opens it, so a search result is a menu of drills rather than an answer to be
believed:

```
FOUND 3 match(es) for '7712' in 12 stage(s), 36 state key(s), 40 committed step(s) …
- tool-calls#22 wrote 'history' (append): …"Order 7712: sku KB-88…  → get_value('tool-calls#22', 'history')
- narrative line 41: … wrote lastToolResult …                       → read_narrative({ offset: 41 })
```

Case-insensitive substring; `maxHits` defaults to 10 and hard-caps at 25; each hit
serves a bounded **window** around the match, never the value. Values are serialized
one `(step, key)` payload at a time and discarded — a search never builds a
serialization of the whole log to look through, and one that exhausts its scan
budget says so rather than reporting "no match".

It searches the redacted commit log, because that is the only copy that exists. A
miss names what never enters the record at all — run input, env, pre-run state,
closures, and anything redaction removed — so "not found" cannot be misread as
"did not happen".

### `inspect_tool_call(toolCallId)` — four records, joined

A tool call is the most-asked-about thing in an agent run and the most scattered.
The args the model **proposed** are on an assistant turn; the args it **actually ran
with** are in the middleware ledger (and only when a rule changed them); the result
is a `role:'tool'` turn; the timing exists only in the event stream. One id, four
lookups — so the tool does the join:

```
TOOL CALL c2 — check_inventory
step: tool-calls#22 — drill with trace_node('tool-calls#22')
proposed by the model: {"sku":"KB-88"}
ran with: {"sku":"KB-88","limit":5} — CHANGED at before-tool by 'clamp-limit': "page size capped at 5"
result: "Stock for KB-88: 12 units available…"
outcome: ok
duration: 4ms
```

The proposed/ran-with split is the reason it exists. A governance rule that rewrites
args is invisible in the conversation: the model reads its own proposal in history
and the tool ran on something else, and that difference is often exactly what "why
did it do that?" is asking about.

Every source that is missing says so with ⚠ rather than being guessed at: no event
tail means `duration: ⚠ unavailable` with the reason (the commit log records what
each step WROTE and has no clock), not a fabricated number. A bad id never throws —
it comes back naming the run's real tool call ids. Outcomes are `ok` / `error` /
`denied by '<rule>'` / `paused`, and `⚠ unknown` when nothing supports a claim.

### Behaviour change — the captured turn now carries three things, and both new parts default ON

`SelfExplainBinding` captured the snapshot. It now also captures, at the same
terminal flush so all three describe one turn:

- the run's **narrative** (`getLastNarrativeEntries()`), and
- a bounded **tail of the run's typed events** (default 2,000 per turn).

Both are controlled by a new option and **both default to `true`**:

```ts
.selfExplain({ include: { narrative: true, events: true }, maxEvents: 2000 })
```

That default is the behaviour change. An agent built with `.selfExplain()` now
subscribes a wildcard event listener and retains a per-turn event tail it did not
retain before — a few hundred KB for a typical turn, bounded and rotated per run so
turn N+1 never carries turn N's events. `include: { events: false }` makes **no**
subscription at all rather than one that is ignored; `include: { narrative: false }`
skips the narrative read.

The default is `true` because the tools that read these parts are on the catalog
either way, and a tool that answers "⚠ no evidence" by default is a tool that
teaches the model not to call it.

`bindTo()` accepts the bare `getSnapshot` function it always accepted, plus a new
object form carrying all three sources at once. One call, not three connections —
the `BoundaryRecorder` lesson in this codebase is that a seam needing three separate
wirings gets two of them.

### Behaviour change — `read_narrative` now mounts under `.selfExplain()` inline mode

`read_narrative` was eager-only: `traceToolpack` mounted it when the artifacts
carried a narrative, and the lazy pack behind `.selfExplain()` never did, because
narrative presence is a property of a run that has not happened yet at build time.

The lazy pack's template is now built over an artifact bag that declares every
optional part, so it mounts the same nine tools every time. That fixed list is what
the tool catalog and the builder's name reservation are both keyed on. When a
resolved run turns out not to carry a part, the tool says which switch turns it back
on rather than quietly disappearing from the catalog or answering emptily.

**`read_narrative` is therefore now a reserved tool name in inline mode.** A
consumer tool named `read_narrative` on an agent with `.selfExplain()` now fails at
build with the existing teaching refusal — previously it would have been allowed,
and would have shadowed nothing. Same for `find_in_trace` and `inspect_tool_call`.

The full inline reservation is now nine names: `run_overview`, `find_in_trace`,
`trace_node`, `trace_slice`, `backtrack`, `who_wrote`, `get_value`,
`inspect_tool_call`, `read_narrative` (delegate mode still reserves only
`explain_run`). The list is **derived** from the pack (`TRACE_TOOL_NAMES`) rather
than retyped beside it, so a tool added to the pack is reserved the same day it
ships.

### `run_overview` gains a COST line

When the run priced itself, the overview now ends with what it spent:

```
COST: ~$0.012300 (in 900 / out 120 tokens) — estimated by the run's pricing table,
not a bill — via get_value('call-llm#14', 'cumEstimatedUsd').
```

Only when `cumEstimatedUsd` is committed and **greater than zero**. It is seeded to
0 on every agent run and only moves under a configured `pricingTable`, so a zero
means "this run was not priced" — and printing `$0.00` for it would be a number that
lies.

### `openRecording(recording)` — a saved run, reopened as evidence

`recordRun(agent)` freezes a run into `{ snapshot, events, structure }`, the shape
the viewers read. The trace toolpack reads a different shape. Until now a team with
a recording on disk from last Tuesday and a question about it had to reassemble the
bag by hand — differently in every integration, losing the narrative and the events
on the way.

```ts
const tools = traceToolpack(openRecording(JSON.parse(fs.readFileSync('run.json', 'utf8'))));
await callTraceTool(tools, 'find_in_trace', { query: 'order 7712' });
```

Pure: no engine, no agent, no I/O. Exported from `agentfootprint/observe` (and
`agentfootprint/debug`). Honest about the two things a serialized run cannot carry
back — `controlDeps` is a lookup _function_ and does not serialize (slices say
`⚠ control edges unavailable`, the marker that already existed), and the narrative
survives only if a narrative recorder was attached, since `recordRun` deliberately
attaches none. Two teaching refusals name `recordRun` as the producer: a bundle with
no snapshot, and a snapshot missing `commitLog` or `executionTree`.

### Internal — one bounded event tail, not two

`recordRun`'s ring buffer and drop counter moved into a shared `eventTail` helper
that both it and `SelfExplainBinding` now use. Written twice, the two would
eventually disagree about what "dropped" means and one would stop reporting it —
which is the failure that matters, because a tail that silently starts mid-run reads
as the whole run. `recordRun`'s public surface (`eventCount`, `droppedEvents`,
`maxEvents`) is unchanged.

### New example

`examples/features/49-self-explain-live.ts` — an order-support agent looks up a
damaged order, checks inventory, and **skips the refund** because the item is in
stock and under warranty. Turn 2 asks "why did you skip the refund?" and it answers
through visible `find_in_trace` → `inspect_tool_call` → `run_overview` calls. It
prints per-tool execution counters either side of the explanation to prove nothing
re-ran, that no business tool was called in turn 2, that `issue_refund` executed zero
times across both turns, and the total spend against the budget. Runs on a scripted
mock by default ($0, deterministic, CI-safe); `ANTHROPIC_API_KEY` switches it live
with `DEMO_MODEL` (default `claude-haiku-4-5` — a small model reading its own record
is the claim), and `AGENTFOOTPRINT_DEMO_OFFLINE=1` forces the mock.

### A note on the tools slot

Nine tool definitions land on the tools slot for the one activated iteration. That is
a real bulge past the 2000-char `contextBudget.tools` default, which is a **signal,
not a limiter** — nothing is ever truncated. An agent that opts into `.selfExplain()`
should raise it (`contextBudget: { tools: 6000 }`); example 49 does, and says why.

## [8.15.0] - 2026-08-07

**One skill's turn at a time.** A skill graph is a state machine, and every node in
it obeyed that except one: an entry that carries a `when`. Such an entry stayed
loaded beside the skill it had just handed off to — two skill bodies in the system
prompt, two tool sets on the wire — and the check-up then warned about a fan-out
using advice the author had already taken.

Both come from the same leftover clause, and both are fixed here.

### Behaviour change 1 — a handoff ends the previous skill's turn

A conditional entry compiled to `when(ctx) || nextSkill(ctx) === id`. 8.3.0 added the
cursor half and left the rule half standing, and the rule half is the bug: an entry's
`when` reads the user's message, which does not change mid-turn. So when entry `S`
routed to `T`, `S`'s rule still matched and `S` and `T` were both active. Measured on
a two-skill support graph:

| iteration       | active             | tool menu                                    | skill bodies    |
| --------------- | ------------------ | -------------------------------------------- | --------------- |
| 1               | `triage`           | `read_skill`, `lookup_order`                 | triage          |
| 2 — the handoff | `triage`, `refund` | `read_skill`, `lookup_order`, `issue_refund` | triage + refund |
| 3               | `triage`, `refund` | same                                         | triage + refund |

Note iteration 3. This was never a one-iteration blip: with the cursor parked on
`refund`, `triage`'s rule kept matching, so it came back and stayed. The overlap was
the steady state.

**A conditional entry is now active exactly while the cursor is on it** — the same
compiled expression a route target and an exclusive entry already used. One law for a
flat graph: _a skill is active iff the cursor is on it, or it declared itself
unconditional._ `when` chooses where a turn STARTS.

This finishes 8.3.0 rather than reverting it. Both failures 8.3.0 named — a declared
step INTO an entry skill, and a `read_skill` pick onto one — are carried by the cursor
clause, which survives untouched. A throwing entry predicate still surfaces as
`predicate-threw`: the rule is still evaluated, its answer is just no longer allowed
to override the cursor.

**What is unchanged:** an entry with no `when` (`{ kind: 'always' }` — the persistent
base, and the declared way to be co-active beside the cursor); `.entryBy()` /
`.entryByRelevance()` / `.entryByRead()` (already cursor-exclusive); decision `tree()`
graphs; route targets; every injection registered beside the graph (`.fact()`,
`.steering()`, `.skill()`, memory, RAG); and cold start for the entry whose rule wins.

**Who has to change something.** One shape: a conditional entry used as an always-on
overlay — `.entry(base, { when: () => true })`, or a locale/persona predicate that
stays true for the whole turn. `when: () => true` used to be a synonym for omitting
`when`; it is not any more. Two-line migration, pick one:

```ts
.entry(base)                                    // drop the `when` → `always`, on beside the cursor
.steering(defineSteering({ id, prompt, ... }))  // or move the predicate to the flavor built for
.skill(defineSkill({ id, ... }))                // "on whenever this matches, wherever the graph is"
```

An entry is a position in a state machine. If a skill is not a position, it was never
an entry.

**A second consequence, deliberate:** `agentfootprint.skill.reroute_superseded` now
fires in one case it used to stay quiet for — a `read_skill` pick that lost the cursor
to a declared edge but happened to be an entry whose own rule matched. It was active
by accident, so the promise looked kept. It is now reported like every other
superseded pick.

### Behaviour change 2 — a rule-router is not a fan-out

`multi-entry-fanout` fired whenever a graph declared two or more entries, including
when every one of them carried a `when` — a deterministic rule-router, which is a
taught shape. Worse, the advice it gave was _"give the extras a `when`"_, to entries
that already had one. It computed which entries were unconditional and then used that
only to soften the middle of the sentence.

- Every entry conditional → **silent**. The entries take turns; there is nothing to
  warn about, and after behaviour change 1 that is literally true at runtime.
- Some entries unconditional → still a warning, and the advice now names **only**
  those. `problem.skill` points at the first unconditional entry rather than at
  whichever entry happened to be declared first.

The rationale comment in the check-up claimed an entry's compiled trigger was
"cursor-INDEPENDENT". That stopped being true in 8.3.0 and is wholly false now; it is
why the check over-fired. Rewritten.

### New — `supersededIds` on `agentfootprint.context.evaluated`

A suppression the run cannot name is a silent drop. When a conditional entry's rule
matched and the cursor law kept it off the wire, the entry's id is now reported on the
per-iteration evaluation event, beside the `cursorMove` that says where the graph went
instead. Together they answer _"why isn't my entry loading?"_ without anyone
re-running a predicate to guess.

Omitted when nothing was suppressed, and for every non-skill-graph run — so an
ordinary iteration is byte-identical to 8.14.0. It rides the per-iteration event
rather than `skill.reroute_superseded` because it is a **continuous** condition (an
entry whose rule stays true while the cursor is parked elsewhere is suppressed every
iteration), while that event means a **discrete** broken promise. `skill.reroute_superseded`
keeps its meaning exactly.

`SkillGraph` gains `supersededEntries(ctx)` — pure, deterministic, empty for a graph
with no conditional entries and for a decision `tree()`. It is threaded through
`.skillGraph(graph)` like `explainNextSkill`, optional at every hop, so a graph built
before it existed routes identically and simply emits no `supersededIds`.

### Docs

`SkillEntryOptions.when` and the skill-graph module header rewrote the law they were
describing wrongly. The v2 design note that blessed the additive reading
(`docs/design/skill-graph.md`, "orthogonal to a base") is kept as the record with a
SUPERSEDED block naming what it got wrong. `examples/features/42-skill-graph-model-pick.ts`
now writes a real entry predicate instead of `when: () => true`.

`README.md` and `AGENTS.md` taught `import { mock } from 'agentfootprint'`, which has
never compiled against the 8.x exports map — `mock` lives on
`agentfootprint/providers` (and `InMemoryStore`/`mockEmbedder` on
`agentfootprint/memory`). The snippets now import from the doors that exist. The
repo's own agent skill file (`.claude/skills/agentfootprint/SKILL.md`) is rewritten
against the current `.d.ts` for the same reason.

## [8.14.0] - 2026-08-07

**Budgets tell one truth.** Eight ways this library reported a number, a limit
or a bill that did not match what it actually did. A budget you cannot read
correctly is not a budget; a limit that says it stopped the run and did not is
worse than no limit at all.

Four of these change what a run DOES, three refuse a configuration that used to
build, and one changes a string on the wire. All eight are below.

### Behaviour change 1 — `.compaction({ summarizer })` now REQUIRES `model`

`model` used to default to the agent's own model, and that default had no
correct branch:

- **same provider family** — it billed your MAIN model for every fold. The
  refusal three lines above it in the same file promised _"the library will not
  quietly bill your main model for compaction"_, and then did.
- **different provider** — it sent your agent's model id to a vendor that has
  never heard of it, so the fold died mid-run, on a paid run, in a file whose
  own header promises "everything fails at `.build()`, never mid-run".

There is no third branch, so the default is gone. `model` is required whenever
`summarizer` is set, refused at `.build()` through **both** doors
(`.compaction({...})` and `summarizeOldest({...})`) with a message that names
both failures and the two-word fix. `ResolvedCompaction.model` is now `string`.

One visible consequence: `FoldedSpan.model` — the summary's recorded author —
now names the summarizer's model rather than the agent's. It always described
what was really billed; what was really billed has changed.

### Behaviour change 2 — a stale token count is no longer a token count

`CompactionMeter` returned early on malformed usage **without clearing what it
was holding**. A provider that reported usage once and then stopped — a proxy
that drops the field, an OpenAI-compatible endpoint that omits it while
streaming, a flaky gateway — left that first number standing forever, and every
later window decision was made on it. In the probe that found this, one count
taken at iteration 1 drove three separate decisions and evicted six messages at
iterations 3 and 4.

Readings are now stamped with the iteration whose call produced them and expire
one boundary later. An expired reading is `undefined`, which every strategy
already treats as "do not act" — so a window strategy **stands down** instead of
deciding on a number nobody took, and says so once on the console rather than
going quiet. "Counted, never guessed" has to mean counted _recently_.

An agent whose provider reports usage reliably is unaffected.

### Behaviour change 3 — `maxIterations` reached with pending tool calls now says so

The loop stopping while the model was still asking for tools produced `""` and
nothing else: no event, no committed record, and a `route_decided` rationale
nobody was subscribed to. An empty string reaching a user is indistinguishable
from a bug.

Now it emits `cost.limit_hit { kind: 'max_iterations', action: 'abort' }` — the
kind `CostLimitHitPayload` has reserved since it was written, so no new event
type — commits `AgentState.stoppedEarly`, and warns once on the console **when
the answer is empty**. Read it with the new `agent.stoppedEarly()`.

It deliberately does **not** throw. 8.6.0 raises for an outstanding credential
consent because that is a fault: the run hands back a plausible answer for work
a tool never did. A limit you configured firing is the limit working, and the
answer is sometimes real — a model can return content alongside its tool calls,
and that content is a genuine partial answer. Raising would reject good answers
to fix a bad one.

`stoppedEarly` is absent on every normal finish, including a turn that spent its
whole iteration budget and then genuinely finished.

### Behaviour change 4 — a `replacement-not-smaller` refusal is paid for once

A fold abandoned because the summary came back no smaller than the span it would
replace was re-asked every iteration: same span, same summarizer, same verdict,
a real billed call each time. The refusal is now latched by span CONTENT, and a
skipped visit files its record with `summarizerSkipped: true`, no call and no
cost tick — a call the library decided not to make is evidence, not silence.

Two things it deliberately does not latch. A span that has **grown** is asked
about again: the test is `summaryChars >= windowChars(span)` and a larger span
makes that less likely to hold, so a fold refused at four turns can genuinely
succeed at six. And `summarizer-failed` is still retried — an outage may end, a
comparison of two string lengths will not.

### `context.budget_pressure` now carries its `unit`

Two emitters share this event name, this `slot: 'messages'` value, and — until
now — one indistinguishable payload:

| emitter                                                      | counts                         | `unit`     |
| ------------------------------------------------------------ | ------------------------------ | ---------- |
| the three context slots (`contextBudget`, **on by default**) | `String.length`                | `'chars'`  |
| a window strategy (`.window()` / `.compaction()`)            | provider-reported input tokens | `'tokens'` |

So one subscriber routinely received both, and `cap 200, projected 258` could
mean 258 characters or 258 tokens — a roughly 4× difference in the same field,
with nothing in the payload to tell them apart.

`unit`, `cap` and `projected` are new and **required on the event payload**;
`capTokens` and `projectedTokens` are deprecated, still written, and carry
identical values. On `BudgetPressureRecord` — which slot builders, including
ones you wrote, produce — the three are **optional**; `ContextRecorder` fills
`unit: 'chars'`, which is a fact about that channel rather than a guess.
`WindowStrategyResult.budgetPressure` gains an optional `unit` defaulting to
`'tokens'`, which is what all three shipped strategies already meant.

No OpenTelemetry mapping was added, on purpose: `adapters/observability/otel.ts`
has never consumed this event and has no catch-all that would turn one into a
span event, so there was no ambiguity there to fix. Adding a mapping would be
new surface, not a unit correction. The commentary renderer likewise still
returns `null` for it (slot mechanics are plumbing, not pedagogy). Neither is an
oversight — please do not "fix" them.

### `costBudget` can now stop the run

`costBudget` was warn-only, while `commentaryTemplates.ts` narrated
_"{{appName}} hit a cost limit and stopped."_ and `docs/monitor/deployment.mdx`
claimed the agent _"halts when the per-run USD budget is hit"_. It did neither.
`docs/monitor/observability.mdx`, on the same site, correctly said the library
never auto-aborts.

```ts
costBudget: 0.50                              // warns — unchanged, byte for byte
costBudget: { usd: 0.50, onExceed: 'halt' }   // stops
```

`'halt'` ends the loop at the next iteration boundary — the same boundary
`maxIterations` uses. Never mid-call: the call that crossed the budget
completes, is billed and is recorded; halting only decides there will not be
another one. `stoppedEarly()` then reports `reason: 'cost-budget'`.

There is still exactly ONE `cost.limit_hit` per crossing, from the same place
it has always come — `emitCostTick`, which is the only code that knows the
budget. It now reports `action: 'abort'` rather than `'warn'` when the budget
halts. The route decider does not emit a second one; it emits
`kind: 'max_iterations'` only, for the limit that has no other voice.

The commentary line reads its outcome off the payload, so prose and event can no
longer disagree. It keeps its single `cost.limit_hit` key — splitting it into
`.warn` / `.halt` would have read better and silently orphaned every consumer
who had overridden the base key. Both docs pages are corrected.

`LLMCall` refuses `onExceed: 'halt'` at build: one call has no next boundary to
stop at, and accepting it would be a stop button wired to nothing.

### A summarizer that is the agent itself is refused

`.compaction({ summarizer: theSameProviderInstance, model: theAgentsModel })`
now refuses, at both doors.

Not about money — requiring `model` already ended the quiet billing. It is that
those two calls are configured identically and provably behave differently: the
agent's call runs through `reliability` retries, any
`withRetry`/`withFallback`/`withCircuitBreaker` decorator and the cache subflow;
`runSummarizer` calls `provider.complete()` bare and gets one attempt, no
fallback, no cache. A difference nobody typed.

The refusal is deliberately narrow. A **different instance** at the same model is
allowed — "use the strong model to write the summary, because a bad summary
poisons every turn after it" is a real choice — and passing a second instance
also ends the shared per-instance state that made this pairing bite. In the
probe that found it, one `mock()` serving both roles had the summarizer eat the
reply scripted for the agent: the agent's own final answer became the summary
text, and the run then crashed on an exhausted script.

`WindowStrategy` gains an optional `billing` descriptor so the builder can make
this check through `.window(summarizeOldest({...}))` too. Enforcing it at only
one of two doors onto one policy would make it advice rather than a rule.

Both the option's docstring and the `runSummarizer` call site now state plainly
that the summarizer call is un-decorated.

### Wire change — `'summary-not-smaller'` → `'replacement-not-smaller'`

`slidingWindow` and `tokenBudget` never call a summarizer — a drop makes no LLM
call at all — and both reported `summary-not-smaller` when the authored drop
NOTICE came back no smaller than the turns it would replace. The type's own
docstring already described the general case; only the name lagged.

**A runtime from 8.14.0 writes only the new string.** The old one survives as a
deprecated member of `WindowRefusalReason` so code written against 7.17–8.13
still narrows and compiles, but it is never emitted — two spellings of one fact
is the disease, not the cure. If you match on the string in a
`WindowRecord.refusals`, update it.

### A crash checkpoint now names where it crashed

Field report: a WebKit `fetch` failure surfaced as
`[agent run] failed at iteration 3 (unknown)`. WebKit's entire message for a
failed fetch is `TypeError: Load failed` — no code, no vendor name, nothing
`classifyFailurePhase`'s regexes can match — while the run itself knew the LLM
call was open at the time.

The checkpoint tracker now follows the run's own `stream.*` brackets, so the
phase is OBSERVED; `classifyFailurePhase` becomes the fallback for failures
between brackets, where the error's own text really is the best evidence there
is. `AgentRunCheckpoint.failurePoint` gains an optional `stage`, and the message
reads `failed at iteration 3 during the LLM call (stage: call-llm)`.

`stage` carries the literal `'call-llm'` or a **declared tool name**, and
nothing else — never a URL, a header or a request body. A checkpoint is
persisted to Redis / Postgres / S3 and read by whoever is on call. A test
asserts no URL reaches it.

Still `version: 1`: an optional field is not a format change, and bumping the
version would make an older deployment refuse a session it can serve.

### Also

- The `cost.*` bridge is now attached unconditionally. It was gated on
  `pricingTable`, which was correct while `cost.*` only ever meant money —
  `emitCostTick` returns on its first line without a table, so the gate could
  hide nothing. An iteration limit has no price, and behind the old gate
  `cost.limit_hit { kind: 'max_iterations' }` would have reached the dispatcher
  only for agents that happened to be costing themselves. The bridge drops
  events with no listener, so an agent that subscribes to nothing pays nothing.
- `CompactionMeterHandle.lastCall()` takes the current iteration (internal —
  not on the public barrel) and gains `unmeteredSinceLastGood()`.
- `CompactionRecord` gains optional `summarizerSkipped`.
- `AgentState` gains `stoppedEarly` and `costBudgetOnExceed`.

## [8.13.0] - 2026-08-07

**Governance never silently drops — and never silently invents.** Eight ways a
rule you configured could decide nothing, and you could only find out by reading
a quiet run. Seven are now refused at build time with a message that names the
fix; one was a rule that ran everywhere except the one path where a _person_ had
just typed the value.

Two of these change what a run DOES. Both are called out below.

### Behaviour change 1 — redaction now runs on a resumed `askHuman` / `pauseHere`

`onToolResult` now fires when a run resumes from `askHuman()` / `pauseHere()`.
It already fired on the other four dispatch paths; that one was skipped.

The tool ran — `pauseHere` throws from inside `execute`, so it started and may
have done half its work — and the value you hand `agent.resume()` **is** that
tool's result everywhere else in the run: it lands in the history under the same
`toolCallId`, `stream.tool_end` reports it, `on-tool-return` triggers fire off
it. Its before-tool chain had already walked. So the ledger carried an opening
row and no closing one, and every `onToolResult` rule sat unapplied on the one
channel where a person can paste a secret.

What changes for an agent that has `onToolResult` rules:

- a redaction rule now scrubs the human's answer before the model reads it;
- `deny(reason)` now replaces it with the reason;
- an `after-tool` ledger row now appears for that call;
- `stream.tool_end` still reports the raw value — unchanged, and the same split
  the other four paths keep.

An agent with **no** `onToolResult` anywhere is byte-identical: the chain
early-returns when nothing in it governs results.

`AgentState` gains one optional field, `pausedToolArgs`, so the rule receives the
args the tool was actually running with rather than the model's proposal. A
checkpoint written by 8.12.0 does not carry it; those args are recovered from the
assistant turn in the history — real values, identical to the running args unless
a before-tool middleware transformed them, and that one difference is not
recoverable from a checkpoint that never recorded it.

### Behaviour change 2 — a selective `checkIn` no longer blocks the calls it was written to let through

When a `toolMiddleware` answered `ask`, the tool was approved, and the tool ALSO
declared `checkIn`, the resumed dispatch refused the call — by noticing that a
`checkIn` field existed, without ever evaluating it. A tool written
`checkIn: (args) => args.amount > 1000` refused the £5 refunds too, and the
refusal claimed a consent gate would have run when it provably would not have.

The guard now evaluates the demand, against the args the tool would run with and
the same conversation shape the loop's own gate uses. **A tool whose predicate
does not trip now executes** where it was previously refused.

When the predicate DOES trip, the refusal stands, and it stands on purpose. The
two gates ask different questions: a middleware `ask` carries the rule's own
free-text question; a `checkIn` carries the tool's demand with the evidence pack
attached — `willDo`, what the run read, what drove the choice, the trail — none
of which the person who approved the ask ever saw. Letting one approval satisfy
both would file a `checkin.decision` for a question nobody was asked.
`checkIn: 'always'` is unaffected in both directions.

The refusal text is rewritten: model-actionable first ("was not executed and
cannot be retried this turn… answer without it, or finish"), then the author's
fix, naming the middleware.

### `agent.resume()` refuses a consent gate answered with a value

Resuming a check-in or a middleware-`ask` pause with anything that is not a
`CheckInDecision` used to DECLINE, silently, and file the decision against
`by: 'unknown'` — a consent record naming a person who was never asked. That is
worse than dropping one: the run reads as consented-and-refused when nobody
consented to anything.

It now raises `DecisionRequiredError` (`code: 'ERR_DECISION_REQUIRED'`) at the
API boundary. **Nothing executes and the checkpoint is unchanged**, so the same
one can be answered properly and resumed again. The error names the gate
(`gate: 'checkIn' | 'ask'`), the tool, the middleware that asked, and `received`
— the _shape_ that arrived, never its contents, because a resume payload is
caller data and an error message ends up in logs.

Discriminated by the **pause**, never by the input, via the new
`pauseDemandsDecision(pauseData)` — the one reader of that shape, shared with the
code that builds `outcome.checkIn` / `outcome.ask`, so what a consumer is told
and what the library enforces cannot drift. A plain `askHuman()` / `pauseHere()`
pause is untouched: there the human's answer IS the tool's result, and any value
is accepted. The 3LO credential-consent pause is untouched too — it re-asks the
provider and ignores its input by design.

Over `standingAgent` it is a **400**: `ERR_DECISION_REQUIRED` joins
`STATUS_BY_CODE`, because the session is in a perfectly consistent state and it
is the request that is wrong.

New on the main barrel: `DecisionRequiredError`, `pauseDemandsDecision`,
`ConsentGate`, `ConsentGateKind`.

### Five refusals for configuration that decided nothing

Each of these refuses code that was **already a total no-op** — no working
program changes behaviour, which is why they ship in a minor, on the same
precedent as 8.5.0's delivery refusal and 8.7.0's `viaToolName`.

- **Two different observers with one `id`** — `build()` refuses, naming the id.
  footprintjs de-duplicates attached recorders by id, so of two objects carrying
  one name only the LAST ever fired and the first reported nothing, which reads
  exactly like an observer whose events never happened. Keyed on object
  identity: handing the SAME object to `.watch()` twice (or to `.watch()` and
  the deprecated `.recorder()`) is fine and stays one attachment. The
  `agentfootprint.` id namespace is deliberately NOT reserved — the factories on
  `agentfootprint/observe` live there and are meant to be watched.

- **`costBudget` without `pricingTable`** — `Agent` and `LLMCall` both refuse.
  The budget is USD and only a pricing table turns tokens into USD, so the pair
  emitted nothing at all: no `cost.tick`, and no `cost.limit_hit` however much a
  run spent. The message says what a `pricingTable` is
  (`{ name, pricePerToken(model, kind) }`, USD for one token) and why the
  library ships none. Both runners take the identical pair and had the identical
  silence, so both are fixed by one shared check.

- **`onAuthorizationRequired` without `credentials`** — `Agent` refuses. The
  mode is read at exactly one place, after a declared credential comes back
  `authorization-required`; with no provider that call never returns at all (the
  fail-closed stand-in throws), so the branch it governs is unreachable.

- **`.checkIn()` with no tool declaring `checkIn`** — `build()` refuses. The gate
  is never consulted, the agent never pauses for consent, and the evidence
  settings decide nothing. Scans the `.tool()` registry AND every skill's own
  `inject.tools`, since a skill tool's demand is a real gate. NOT refused when a
  `.toolProvider()` is wired: its tools arrive per iteration and may declare it,
  and refusing what build time cannot know would break a correct agent.

- **`.checkIn({ evidence: 'minimal', scorer })`** — refused. The scorer ranks
  `drivers`; the minimal pack builds only `willDo`, so the scorer was resolved
  and then never called. Only the literal `'minimal'` preset is refused — a
  custom assembler is handed the scorer and may legitimately call it.

### Also

- `historyForCheckIn` is now one helper shared by both check-in gate sites, so a
  `CheckInDemand` predicate reading `ctx.history` cannot be judged against a
  different conversation depending on which door the call arrived through.
- Fixed a stale comment: the after-tool moment's header said it was called from
  "all three dispatch sites"; it has been four since 8.6.0 and is five now.

## [8.12.0] - 2026-08-07

**Somebody has to flush the exporter, and until now nobody did.** 8.11.0
documented that honestly; 8.11.1 fixed the two bugs underneath it. This release
finishes the job: the framework calls `flush()` and `stop()` for you, in the one
correct order, without ever stopping a strategy somebody else is still using.

### The handle that comes back from `enable.*`

`agent.enable.observability({ strategy })` still returns the `Unsubscribe`
function it always returned — calling it still detaches, and detaching still
never stops your strategy. It now also carries two methods:

```ts
const telemetry = agent.enable.observability({ strategy: cloudwatch });

await telemetry.flush(); // drain: driver queue first, then the buffer
telemetry(); // detach (unchanged)
telemetry.stop(); // release — timers, clients, buffers
```

`flush()` enforces the ORDER, which is the part no consumer could write from
outside: an event scheduled on a `detach` driver has not reached your strategy
yet, so flushing the strategy alone ships nothing. The handle knows both halves
because it owns both.

`await using` works too, where your runtime has `Symbol.asyncDispose`:

```ts
await using telemetry = agent.enable.observability({ strategy });
```

Existing code is unaffected: the return type widened, and a function that
carries extra properties is still that function.

### `agent.shutdown()`

```ts
await agent.shutdown();
```

Drains and releases everything enabled on that runner. **The agent itself
remains usable afterwards; `shutdown()` drains and releases what was enabled on
it.** Every strategy flushes before anything stops, so a strategy two handles
share is never stopped with data still in it. Pass `{ stop: false }` to drain
without releasing.

### `standingAgent` flushes when it closes — the one behaviour change

`close()` now drains the agent's telemetry by default (`shutdown: 'flush'`).
Nothing about the API changed and no call site needs editing; what changes is
that a batch which used to be dropped when the server stopped now arrives. It
does not RELEASE the strategies, because this composer only borrowed the agent
— say `shutdown: 'flush-and-stop'` when the agent's life ends with the host, or
`shutdown: 'none'` for exactly what 8.11.x did.

### `shutdownOn` — signals, asked for rather than assumed

```ts
await standingAgent({ agent, sessions, host, shutdownOn: ['SIGTERM', 'SIGINT'] });
```

Closes the host, drains telemetry, removes its own listeners, then **re-raises
the signal** so the process dies the way the platform meant it to.

Off by default, and that is a refusal rather than an omission. `process.on('SIGTERM', …)`
is not observation: Node's default action for that signal is to terminate, and
adding any listener suppresses it — a library that installs one behind your back
can turn a container's graceful stop into a wait for SIGKILL in an application
that never asked. Handlers are process-global, only Node has signals at all, and
no library can know your exit policy. A composition root may ask; it must not
assume.

### `flushOn: 'run-end'`

```ts
agent.enable.observability({ strategy, flushOn: 'run-end' });
```

Fires a flush when a run ends — for scripts, cron jobs and functions that may
vanish right after answering. **It fires the flush; it does not gate `run()`.**
A process that exits in the same breath can still outrun it; the honest closer
is `await agent.shutdown()`. Nothing was made awaitable inside `run()` because
telemetry must never become a term in run latency. Default `'manual'`.

### Who may stop a strategy

One `WeakMap`, three laws. An `Unsubscribe` never stops a strategy — that is
what lets one instance be enabled, released and enabled again (the audit-export
pattern; `examples/features/19-audit-export.ts` is unchanged, byte for byte, and
is the compatibility proof). A strategy is stopped only once the last
subscription pointing at it is released, so one runner's shutdown cannot blind
another runner that shares it. And `stop()` reaches a strategy **at most once,
ever**, whoever asks.

### Also

- `docs:truth` gained a rule (`strategy-lifecycle-consumer-only`) that fails the
  build if any doc line goes back to saying the framework never calls these.
  Seven sites were rewritten; the rule is what keeps them rewritten.
- New example: `examples/features/48-graceful-shutdown.ts` runs all three doors
  and asserts the laws, including that `run()` still does not flush.
- `ESNext.Disposable` added to the TypeScript `lib` (whole repo typechecks clean).

## [8.11.1] - 2026-08-07

**Two bugs in the shutdown path — one hangs the process, one loses the
telemetry while reporting success.** Both are failures against behaviour this
project documented, found by auditing the lifecycle 8.11.0 wrote down. Neither
changes an API; both change what happens when a process stops.

### `flush()` after `stop()` spun the event loop forever

`cloudwatchObservability` and `xrayObservability` buffer events and drain them
in `flush()`. Their `flush()` looped until the buffer was empty, while their
internal drain refused to do anything once `stop()` had been called. Those two
rules cannot both be satisfied, so a shutdown that stopped before it flushed
entered **an infinite microtask loop**: 100% of a core, and — because a
microtask loop never yields to the event loop — no timer could fire, no
in-process shutdown deadline could expire, and nothing short of `SIGKILL` ended
the process. Measured on both adapters and on any `compose([...])` containing
one.

The order documented on the strategy interface (flush, then stop) avoided it,
which is why every test passed. The reverse order is not a misuse worth
punishing with a hang.

Fixed at the root: **`stop()` means stop ACCEPTING events — it never meant
discard the ones already accepted.** The same stance `auditExport()` has always
taken ("stop observing; never destroy collected evidence"). A `flush()` after a
`stop()` now ships the tail batch and returns. Events exported after `stop()`
are still dropped, and the timer is still cleared — that half is unchanged.

The drain loops are now bounded by construction: every pass must remove at
least one buffered event, and a pass that removes none ends the drain instead
of trying again. A drain that cannot finish must return, never retry forever.

### `flushAllDetached()` could not see detached exports

With `enable.observability({ detach })`, each export is scheduled onto a
footprintjs detach driver. Scheduling happened inside a promise continuation,
so the detach handle reached footprintjs's registry a microtask _after_ the
event was dispatched. `flushAllDetached()` drains until that registry is empty
— and it was still empty when it looked.

So the shutdown recipe this project documents returned
`{ done: 0, failed: 0, pending: 0 }`, a clean bill of health, while events were
still in flight. Worse, **no consumer could fix it from outside**: the pending
work lived in a `.then()` chain that nothing exposed. Measured both cold and
warm — a resolved promise still defers.

Scheduling is now synchronous, so the handle is registered in the same tick as
the event and `flushAllDetached()` drains it exactly as documented. The
deferral bought nothing in the first place: this module already imports
`footprintjs` statically, so the dynamic import it was waiting on had loaded
the package either way.

### Also

- The no-op returned when `enable.observability()` is called without a strategy
  now carries no-op `flush` / `stop` alongside the unsubscribe. Nothing you can
  reach today (the declared type is still `Unsubscribe`); it exists so the
  no-subscription case is not the one path that breaks when that type widens.
- Removed a dead loop condition in the CloudWatch drain (`lastFlushPromise !==
Promise.resolve()` compares against a freshly minted promise and is always
  true).

## [8.11.0] - 2026-08-07

**Telemetry that fails invisibly is indistinguishable from telemetry that
works.** That sentence is the whole release. Three of the things fixed here
were not bugs in the ordinary sense — nothing threw, nothing crashed, every
test passed. They were promises the documentation made that the code did not
keep, in the one layer whose entire job is to tell you the truth about what
happened.

Found by a production AgentCore integration, the way these things are always
found: on real infrastructure, at the last hop.

### ⚠️ Behavior change: a throttled MCP tool call now retries

`mcpClient` retries an HTTP **429** up to 3 times, honouring the server's
`Retry-After` header, capped at 10 seconds of waiting in total. It was on
nothing before. Opt out with `retryOnThrottle: false`.

**No call that succeeds today behaves differently** — this changes only calls
that currently fail. But a throttled call that used to fail in milliseconds can
now take seconds before it fails, so it is a behavior change and it leads this
entry rather than hiding in a bullet.

### The log stream that was never created

`agentcoreObservability` and `cloudwatchObservability` called `PutLogEvents`
directly. CloudWatch rejects a put into a log stream that does not exist, so
**any stream name that had not been created by hand dropped every event,
forever, in silence.** The failure had no error, no warning and no partial
delivery — just an empty log group.

The docstring for `logStreamName` had been promising `"Created on first put if
it doesn't exist"` since the adapter shipped. It was never true. Worse, the
convention the docs themselves recommended — `` `${HOSTNAME}/${Date.now()}` ``
— produces a name that _cannot_ pre-exist, so following the documentation
guaranteed the bug on every deploy. The only configuration that worked was the
undocumented one.

The stream is now created on first delivery, tolerating the
`ResourceAlreadyExistsException` of two processes racing, and the failed batch
is re-sent once. **The log group is still yours to provision** — a group
carries retention and encryption decisions that belong to whoever owns the
account (a group created with default retention never expires, which is an
unbounded bill created by a telemetry library), and the docstrings now say so
instead of implying otherwise.

### The silence underneath it

The missing stream was one delivery failure. It turned out **every** delivery
failure was silent: an IAM denial, a throttle, a rejected batch. Each adapter
installed its console fallback lazily _inside_ its own `_onError` method — so
the delivery path, which read the hook rather than calling the method, found
`undefined` and dropped the error on the floor. `cloudwatch`, `xray` and `otel`
all had it.

Three changes, one shape: the fallback is armed at construction; delivery
failures route through whatever `_onError` **is at call time**, so assigning it
actually works; and there is now a real front door —

```ts
cloudwatchObservability({
  logGroupName: '/myapp/agent-prod',
  onError: (err) => log.warn({ err }, 'telemetry export failed'),
});
```

The default sink is loud on the first failure and then logarithmically quieter
(failures 1, 2, 4, 8 … carrying the running count), because an hour-long
CloudWatch outage must not become a second outage in your logs. A sink you
supply is never rate-limited — you asked for every failure.

**The test that let this ship** reassigned `strategy._onError`, the very code
under test, and then wrapped its only assertion in `if (captured)`. It could not
fail. It is replaced by six that assert unconditionally.

### A knob the warning told you to turn, that did not exist

An over-budget context slot warned: _"Raise `budgetCap` on the slot config."_
`budgetCap` was reachable from no public door. `buildMessagesSlot()` was called
with no arguments at all four of its call sites, so its 10000-character cap was
unreachable by construction. A warning you cannot act on is worse than no
warning — it teaches people to ignore the channel.

```ts
Agent.create({ provider, model, contextBudget: { messages: 40_000 } });
```

Characters, per slot, named for the three slots the context model already has.
Defaults unchanged (`systemPrompt` 4000, `messages` 10000, `tools` 2000), and
**nothing is ever truncated** — the full content still reaches the LLM. The
budget is a signal, not a limiter. `LLMCallOptions` takes the same option
(two slots; an LLMCall has no tools).

### Why retrying a 429 is safe, and why it will never widen

A 429 is a **pre-execution rejection**: the rate limiter refused the request at
the edge and the server never ran the tool, so a retry cannot double-execute
anything. That is exactly what is _not_ true of a 500 or a timeout, where the
call may have half-run and a retry could charge a card twice.

That asymmetry is the entire license for this feature, so the policy is 429 and
nothing else — pinned by a property test that walks twelve other statuses and a
thrown transport error and asserts a single attempt for each. Managed gateways
rate-limit per principal by design; without this, a designed and self-clearing
condition reached the model as a thrown tool error it reads as _"this tool is
broken"_, whereupon it apologises, picks another tool, or invents an answer.

It lives at the `fetch` seam because that is the only place `Retry-After` still
exists — the MCP SDK reads the response, throws `StreamableHTTPError(status,
text)` and drops the `Response`, so by the time a throttle reaches
`Tool.execute` the header is gone and the status survives only as `err.code`.
The retry wraps the **outermost** fetch, so every attempt is re-signed and
re-vended: a token that would have expired during the wait is simply never the
one reused. The gateway secrecy invariant is untouched, and a test asserts no
token appears in any retry report.

Per-attempt visibility is the `onRetry` callback — the contract `withRetry` and
`withCredentialRetry` already use. No new event types.

### Documentation that was describing a different library

- **`ObservabilityStrategy`'s hot path is `exportEvent`.** Six doc lines in
  `strategies/types.ts` called it `onEvent`, including one two lines above the
  interface that declares `exportEvent`. That text shipped in the `.d.ts`, so it
  is what an IDE showed.
- **Nothing calls `flush()` or `stop()` for you.** The docstrings promised
  `"Called before agent.run() resolves"`; across the whole library the only
  caller is `composeObservability` fanning out to its own children. The
  `Unsubscribe` from `enable.observability()` only detaches the dispatcher. They
  are consumer-called, the docs now say so, and a batching exporter loses its
  final batch and leaks its timer if you skip them:
  ```ts
  process.on('SIGTERM', async () => {
    await telemetry.flush();
    telemetry.stop();
    stop();
  });
  ```
  Wiring them into the framework lifecycle would change `run()` timing and
  misbehave for a strategy shared across two `enable` calls, so it is a design
  question on the ledger rather than a silent default.
- **A Skill's tools are visible from iteration 1.** `DefineSkillOptions.tools`
  said they were _"added to the tools slot once activated"_. They are added to
  the registry at build time; activation adds the Skill's **body**, not its
  tools. Gating is opt-in via `autoActivate: 'currentSkill'` (which
  `skillGraph().tree()` sets for you on every leaf) — and the docs said
  otherwise in twelve places, including a `process_refund` example claiming a
  tool was _"locked away"_. That example now sets `autoActivate` and the prose
  no longer implies a security boundary the default does not provide.
- **`autoActivate` stopped calling itself a forward-compat marker** awaiting
  "v2.5 runtime wiring" — that wiring shipped in 2.5.0, six majors ago.
  `refreshPolicy`, by contrast, is still genuinely unwired, and now says so
  without promising a version.
- **The observability `tier` is not a privacy control.** No tier redacts
  anything, and a lower tier is not a safer one: `'minimal'` still ships
  `agent.turn_start` (`userPrompt`), `agent.turn_end` (`finalContent`) and
  `agent.iteration_end` (the whole conversation `history[]`) — measured, it
  carries user content in a _higher_ share of its events than `'standard'` does.
  The docstring says this plainly now, points at `auditExport()` (bounded by
  default) and `otelObservability()` (omits `userPrompt`), and warns that
  `redactContent` does **not** apply to this channel — it operates on the
  offline `serializeTrace` universe, and wiring it here is a silent no-op.
- **In AWS, start with `xrayObservability`** — one optional peer, one IAM
  permission (`xray:PutTraceSegments`, previously undocumented), no collector to
  run. Reach for `otelObservability` when the destination is not AWS.

### Four defects that shipped because nothing checked

`npm run docs:truth` gained a doc-text rule class — a gate, never ratcheted,
because the steady state of each rule is zero: `onEvent` under `src/strategies/`,
activation claims on `DefineSkillOptions.tools`, and stale version promises
(the general shape — a docstring promising a version that is now in the past,
not the literal string `v2.5`).

### Added

- **`CloudwatchObservabilityOptions.onError`** / **`XrayObservabilityOptions.onError`**
  / **`OtelObservabilityOptions.onError`** — a constructor door for delivery
  failures. Equivalent to assigning `_onError`, but visible at the call site.
- **`CloudWatchLikeClient.createLogStream`** — optional, so an existing
  `_client` test double that only implements `putLogEvents` still type-checks.
- **`McpClientOptions.retryOnThrottle`** — `boolean | { maxAttempts?, maxWaitMs?, onRetry? }`.
  Default on. Ignored for `stdio`, which has no HTTP status to read.
- **`AgentOptions.contextBudget`** / **`LLMCallOptions.contextBudget`** —
  `{ systemPrompt?, messages?, tools? }`, in characters.

### Fixed

- CloudWatch/AgentCore observability delivered **zero events** to any log stream
  that did not already exist, silently. The stream is now created on first
  delivery.
- Delivery failures in `cloudwatch`, `xray` and `otel` reached nobody unless
  something had already called `_onError` for an unrelated reason.
- A failed CloudWatch batch now reports how many events were dropped, and names
  the likely cause (missing group vs missing `logs:CreateLogStream`) when a
  stream cannot be created. A create that fails for a non-recoverable reason is
  attempted once and latched off, so it can never loop — while every dropped
  batch is still reported.
- MCP `tools/call` turned a designed rate limit into a tool error the model
  reads as a broken tool.
- The slot over-budget warnings named `budgetCap`, which no public door reached,
  and disagreed with each other about where it lived.

### Compatibility

Minor. Every new option is optional and every default is unchanged. The one
behavior change is the 429 retry described above; the CloudWatch fix changes
only a path that delivered nothing. New IAM requirement — `logs:CreateLogStream`
— applies only where delivery is currently failing.

## [8.10.0] - 2026-08-06

**A folder of documents becomes an answering agent.** 8.8.0 made retrieval tell
the truth about what it read; 8.9.0 made the index survive a restart. Both
assumed you had already turned your documents into chunks — which meant writing
loaders, splitters and re-index logic yourself, and which is the actual reason
"add RAG to my app" was a week rather than an afternoon.

`agentfootprint/rag` is that missing half.

```ts
import { indexFolder } from 'agentfootprint/rag';
import { sqliteVectorStore } from 'agentfootprint/memory';
import { staticEmbedder } from 'agentfootprint/providers';

const report = await indexFolder('./docs', {
  to: sqliteVectorStore({ file: './corpus.db' }),
  embedder: staticEmbedder(),
});
// { discovered: 3, loaded: 3, chunks: 14, embedded: 14, skipped: 0, removed: 0, … }
```

### A new door, and why it earns one

`agentfootprint/rag` is the eleventh door. Index time is a different PROCESS
from run time: it happens at boot or on a cron, it touches the filesystem, and
for PDFs it reaches for an optional peer. None of that belongs in the bundle of
an agent that only answers questions — a browser or edge runtime importing
`agentfootprint` should never resolve `node:fs`. A door is the unit at which a
bundler can cut.

**`defineRAG` deliberately stays on the main barrel.** The retriever is
run-time wiring — registered on an agent, running every turn — so it belongs
beside `defineTool`. This door is the half that runs once, before any agent
exists.

### Loaders

`textLoader`, `markdownLoader` and `htmlLoader` need no dependency. `pdfLoader`
needs one, and it was picked by measuring rather than by reputation:

| package        | installed  | packages | verdict                                           |
| -------------- | ---------- | -------- | ------------------------------------------------- |
| **`unpdf`**    | **2.5 MB** | **1**    | chosen — zero transitive deps, per-page text      |
| `pdf-parse@2`  | 86 MB      | 3        | a native binary (`@napi-rs/canvas`), to read text |
| `pdf-parse@1`  | 34 MB      | 4        | unmaintained since 2018                           |
| `pdfjs-dist@6` | 62 MB      | 2        | 25× the size for the same engine                  |

It is an optional peer, lazily loaded, refusing with an install line when a PDF
is actually met. Per-page text is why a PDF citation can name a page you can
turn to rather than saying "somewhere in this file".

Markdown keeps its markup: stripping `#` throws away the one splitting signal
that is not a heuristic, and rewriting text breaks the offsets every citation
depends on. The HTML loader is a tag stripper and says so — for a
single-page app you will get navigation labels, and the honest fix is a real
extractor feeding `{ text, uri }`.

### Splitters, and the measurement behind the defaults

`byHeading()` · `byParagraph()` · `fixedWithOverlap()` · `wholeDocument()`, as
factory functions in the same shape as the window and retrieval families.

Defaults are **1000 characters with 150 of overlap**, chosen by constraint.
`localEmbedder`'s default model silently truncates at 512 wordpiece tokens —
measured directly: at 508 base tokens an appended tail still moves the vector
(cosine 0.9965); at 596 it does not (0.999999). The tail was discarded and
nothing said so. 1,000 characters is ~250 tokens, comfortably inside that cliff
and inside the length the model was trained at. Chunks that exceed a stated
ceiling anyway are RECORDED in `report.truncated`, not silently clipped.

**The invariant every splitter holds:** `doc.text.slice(charStart, charEnd) ===
chunk.text`. `splitDocuments` checks it rather than trusting it, because a
custom splitter is a supported thing to write and a chunk that cannot be located
in its own document produces citations pointing at the wrong words — worse than
no citation, because it looks checked.

### `indexCorpus` is a chart, and the commit log IS the report

```
discover → load → split → plan (DECIDER)
         → take-window → embed (FAN-OUT + retry) → tally → more? ⟲
         → remove → report
```

A `for` loop answers nothing once it has finished. This answers "why is this
chunk here, why was that one skipped, what did this run cost, which document
went missing" months later from its own log, without the caller having saved
anything.

`plan` is a real decider because "skip this chunk" is a decision with evidence —
same content hash AND same embedder fingerprint — and its branches
(`full-index` / `incremental` / `nothing-to-do`) are named in the trace. The
embed stage fans out with `addParallelForEach` and a declarative `retry`, so a
rate-limited attempt is in the record instead of vanishing inside a hand-rolled
loop.

### Incremental re-index

```text
run 1  (first index)   discovered 3 · loaded 3 · chunks 8 · embedded 8 · skipped 0 · removed 0
run 2  (no changes)    discovered 3 · loaded 3 · chunks 8 · embedded 0 · skipped 8 · removed 0
run 3  (edit + delete) discovered 2 · loaded 2 · chunks 5 · embedded 1 · skipped 4 · removed 3
```

A chunk is reused when its content hash and the embedder fingerprint both
match. Delete a document and its chunks go — an index that still answers from a
file you deleted is worse than one that cannot answer. An **empty walk never
prunes**: a typo in a path must not delete a corpus.

### The CLI

`agentfootprint-index` is the third bin, after `agentfootprint-setup` and
`agentfootprint-lint-tools`:

```bash
npx agentfootprint-index ./docs --to ./corpus.db
npx agentfootprint-index ./docs --to ./corpus.db --embedder local --split paragraph
npx agentfootprint-index ./docs --to ./corpus.db --dry-run --json
```

### Two bugs the tests found, worth naming

- **The fan-out was silently dropping work.** `maxBranches` TRUNCATES extra
  items rather than queueing them, so a single fan-out over every batch would
  have embedded only the first `maxConcurrentBatches` of them and reported
  success — an index quietly holding a fraction of the corpus, which is the
  worst thing this release could have shipped. The fan-out now runs over a
  WINDOW that can never exceed the ceiling and loops for the rest, and a test
  pins that 12 batches through a window of 2 index all twelve.
- **The report counted intent, not outcome.** `embedded` came from the plan's
  queue, so an embedder that failed every attempt still reported `embedded: 3`.
  It is counted from what the batches actually wrote, and a failing batch now
  fails the run (`failFast`) rather than leaving a half-indexed corpus that
  keeps answering and quietly cannot see what did not land.

Also found while writing the splitter tests: runt-folding merged short chunks
**across heading boundaries**, producing a chunk labelled with one section's
heading and containing the next section's text — a citation that names the
wrong section. Folding now only merges within a section. And page attribution
read the overlapped start rather than the chunk's own, so a chunk could cite
the page its 150-character run-up borrowed from.

### Requirements

- **footprintjs `^9.15.0`** (was `^9.13.0`): the indexing chart uses
  `addParallelForEach` (9.14.0) and per-stage declarative `retry` (9.15.0).
- **`unpdf`** as a new optional peer. Only `pdfLoader` touches it, only when a
  PDF is actually read.

## [8.9.0] - 2026-08-06

**The durable index.** 8.8.0 made retrieval tell the truth about what it read.
This one is about what it costs to have anything to read at all: `InMemoryStore`
is a `Map`, and its price is the one nobody notices until the bill arrives —
**restart the process and the whole corpus is re-embedded.** Fine for three
documents, absurd for ten thousand, and until now the only step up was "bring a
vector database".

`sqliteVectorStore` is the step between. One file, zero dependencies (SQLite is
inside Node), exact cosine search, and the vectors still there on the next boot.

```ts
import { sqliteVectorStore } from 'agentfootprint/memory';

const store = sqliteVectorStore({ file: './corpus.db' });
await indexDocuments(store, embedder, docs, { embedderId: embedder.id });
// …a restart later, in a new process: the vectors are already there.
```

### The two-phase cost model, now a number you can read

Embedding cost is not one number, and the split is the whole argument for a
file. **Index time** embeds the corpus: once, scaling with how much you store.
**Query time** embeds the user's question: per retrieval, scaling with traffic.
A 10,000-chunk corpus is 10,000 embeddings _once_ and one per question
thereafter — with a `Map` it is 10,000 embeddings _per restart_.

`agentfootprint.embedding.generated` has carried an `inputKind: 'document' |
'query'` field since 2.x and nothing ever emitted it, so any dashboard built
against it has been reading a flat line that meant "not wired", not "no cost".
Both halves fire now. The domain also had a payload type, a registry entry and a
`DomainWildcard` arm and **no bridge recorder**, so the event could not have
arrived however correctly it was fired; `embeddingRecorder` is that bridge, and
it is attached on every run.

`indexDocuments` runs at startup, outside any run, so it has no emit channel to
ride — no scope, no dispatcher, no `runtimeStageId` to correlate against. Rather
than pretend otherwise it hands the same payload to an `onEmbedding` callback.

### Exact search, and the ceiling said out loud

Vectors are hydrated into one resident `Float32Array` matrix on the first search
of a namespace, normalised, and every later query is an exact dot product.
**There is no approximate index and no pretence of one**: it returns the true
top-K or it does not answer.

Measured against this implementation on Node 22.16, Apple silicon:

| corpus          | query | resident matrix | file   | first search (hydration) |
| --------------- | ----- | --------------- | ------ | ------------------------ |
| 10,000 × 384-d  | 6 ms  | 15 MB           | 21 MB  | 45 ms                    |
| 50,000 × 384-d  | 31 ms | 77 MB           | 105 MB | 251 ms                   |
| 100,000 × 384-d | 65 ms | 154 MB          | 211 MB | 939 ms                   |
| 10,000 × 1536-d | 16 ms | 61 MB           | 83 MB  | 122 ms                   |
| 50,000 × 1536-d | 89 ms | 307 MB          | 413 MB | **5.7 s**                |

**The documented ceiling is 50,000 chunks** — under 100 ms per query at every
embedder this library ships, under ~300 MB resident. It degrades linearly to
about 100,000; above that, `MemoryStore` is the seam to a managed vector
database and nothing else in your code changes.

Measuring it turned up something worth naming rather than burying: **hydration,
not the query, is what you plan around.** At 50,000 × 1536 the first search
costs 5.7 seconds, and left lazy that lands on whoever asks the first question
after a deploy. `store.warm(identity)` moves the cost somewhere you chose; it is
optional, idempotent, and changes no result.

A loadable extension (sqlite-vec) was measured and deliberately not taken: ~2×
faster at these sizes, at the price of a native binary on a five-platform matrix
(no musl, no Windows/arm64) and a pre-1.0 dependency. Two times, where we are
already under 100 ms, does not buy that.

### One embedder per namespace — the refusal that keeps it honest

`Embedder` gains an optional `id`, and every shipped embedder sets one. Together
with `dimensions` it forms the fingerprint the store records per namespace —
`'<id>@<dims>'` — and refuses on when it changes, **at write and at query
both**.

This is the behaviour-adjacent part of an otherwise additive release, so it is
worth being plain about why it is a refusal and not a warning. Cosine similarity
between two embedding spaces is not a weak signal — it is not a signal, and it
comes back as a confident number in the same range as a real one. No threshold
separates them and nothing downstream can tell them apart. A store that let the
mix through would corrupt every ranking it touched and log nothing.

```text
[memory] cannot write to the namespace '_/_/_global': it was indexed by
'static:@yarflam/potion-base-8m@256' and this vector is from
'openai:text-embedding-3-small@1536'. Vectors of different lengths cannot be
compared at all. Re-index this namespace with one embedder (delete it and build
it again), or point this store at a different file. This refuses rather than
re-embedding your corpus on your behalf — that is a bill you did not agree to…
```

The named fix is an explicit re-index (`store.forget(identity)`, then index
again), and it is never a fallback. Dimensions always decide; model ids decide
only when **both** sides named themselves, so the majority of callers who never
pass an `embedderId` are not blocked by a name nobody supplied.

`indexDocuments` now defaults `embedderId` to the embedder's own `id`, so a
namespace is fingerprinted even when the caller passes nothing.

### What it refuses, and why it never falls back

The law `sqliteSessions` states for a session file, one domain over: **an
unreadable index and an empty one are different facts, and only one of them is
safe to answer with "no matches".**

- **Node 20** has no `node:sqlite`. `SqliteUnavailableError` names your version,
  the 22.5 floor, the `--experimental-sqlite` flag for 22.5–22.12, and
  `InMemoryStore` — and refuses rather than falling back, because an index that
  silently forgot every document on restart looks, from the outside, exactly
  like a corpus that was never built. It is now ONE class shared with
  `sqliteSessions`: catching it should not require knowing which store was being
  constructed.
- **`':memory:'`** is refused, pointing at `InMemoryStore` — which says so in
  its name.
- **A file that is not a database**, **someone else's `af_vectors` table**, and
  **an index written by a newer agentfootprint** each raise
  `UnreadableIndexFileError` with a distinct `problem` to branch on.

### Also

- `putMany` is ONE transaction. The port does not require it, and most callers
  are append-idempotent — but a half-indexed corpus is a specific kind of bad:
  retrieval keeps working and quietly cannot see what did not land, which reads
  as "the model does not know that" rather than as a failure. A file can offer
  all-or-nothing cheaply, so it does. `putIfVersion` reads and writes inside one
  `BEGIN IMMEDIATE`, which is the entire point of a compare-and-set.
- Tables are `STRICT`; `journal_mode = WAL` is executed first and **read back**,
  so `store.journalMode` reports what the file actually got rather than what was
  asked for. A network filesystem can silently downgrade it, and that is only
  ever discovered under load.
- The BLOB on disk keeps the **original** vector, so `get`/`list` round-trip
  exactly what was written; normalisation happens once, into the resident
  matrix, so search is a dot product without changing what is stored.
- Found while writing the schema-identity check: it originally ran _after_ the
  indexes were created, so a foreign `af_vectors` table failed on a missing
  column and was reported as `'cannot-open'` — the right refusal for the wrong
  reason, telling the reader to check file permissions when the real problem was
  that the file belonged to something else. It runs before them now.

## [8.8.0] - 2026-08-06

**Retrieval tells the truth.** A retrieval computed a cosine score for every candidate
and then threw all of them away one line later. The prompt carried the passages;
nothing carried the reason. "Why did the agent read this passage" had no answer in the
recording, and "why did it NOT read that one" had no answer anywhere — a
below-threshold candidate was filtered inside the store and never came back. A full
recording of a RAG run was 140,501 bytes and contained the substring `"score"` exactly
**zero** times.

Three defects came out of the same root, and all three are fixed here.

### THE behavior change: a corpus is read-only

`defineRAG` also mounted the semantic pipeline's WRITE half. Every conversation turn was
embedded and stored **in the same namespace as your documents**. The consequence is not
subtle: the user's own question, re-embedded, is the single best-scoring "document" in
the corpus — cosine 1.0 against itself — so it came back as retrieval hit #1 and spent
a slot in the top-K budget that a real passage should have had.

```text
before:  store after one turn → msg-1-0, msg-1-1, refunds.md#0, pricing.md#0, security.md#0
         top-5 for the question → [{"id":"msg-1-0","score":1}, {"id":"pricing.md#0","score":0.842}, …]
after:   store after one turn → refunds.md#0, pricing.md#0, security.md#0
```

A corpus is not a conversation log. `defineRAG` compiles no write subflow at all — not
"writes are skipped at runtime", but no stage, no commit, nothing to re-enable by
accident. **Conversation memory alongside a corpus is what `defineMemory` is for**, and
the two are registered separately, each with its own store:

```ts
const agent = Agent.create({ provider })
  .rag(defineRAG({ id: 'product-docs', store: corpusStore, embedder }))
  .memory(
    defineMemory({
      id: 'chat',
      type: MEMORY_TYPES.EPISODIC,
      strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
      store: conversationStore,
    }),
  )
  .build();
```

This is filed as a fix in a minor because nothing was depending on it deliberately: a
corpus that ranks the user's own question as its best document is broken, not
configurable.

### The documented example retrieved nothing at all

`indexDocuments` writes under `{ conversationId: '_global' }` by default. A memory reads
under the identity passed to `agent.run()` — and a run with no explicit identity gets
`{ conversationId: 'run-<timestamp>-<n>' }`. So the write side and the read side never
met, and the snippet in this project's own README and JSDoc returned **zero results,
silently**. The example file in `examples/` worked only because it passed
`identity: { conversationId: '_global' }` by hand.

A corpus does not belong to a conversation. `defineRAG` now takes `corpus` — the
namespace it reads from — defaulting to the same `'_global'` the indexer writes to, so
index with no options and retrieve with no options and the documents are found. Pass it
explicitly for a per-tenant corpus, on both sides.

And a namespace that holds nothing is now _reported_ rather than answered around:
`corpusEmpty: true` on the retrieval event, plus a once-per-process warning naming the
namespace it searched and the usual cause.

### The score survives now — and so do the rejections

`loadRelevant` did `results.map(r => r.entry)`. That one `.map` was the whole loss.

- **`MemoryState.retrieved`** — every candidate with its score, its rank, whether it was
  admitted, and if not, which of `below-threshold` / `over-budget` / `over-max-entries`
  refused it. Lifted to ROOT state as `retrievalEvidence_<id>` by the read mount, so a
  backward slice from the answer reaches the passage instead of stopping at the memory
  subflow boundary — a subflow's own scope never reaches the root commit log.
- **The quality floor moved out of the store.** It was passed as `search({ minScore })`,
  so rejected candidates were filtered server-side and never came back. It is applied in
  the stage now, over a pool of `k + rejectWindow`. **This cannot change which entries
  are admitted**: `search` returns score-descending, so either the whole pool clears the
  floor (admitted = first `k`, as before) or some entry fails it (every later entry fails
  too, so the pool already holds every entry that clears it). `rejectWindow` only controls
  how many near-misses can be _shown_.
- **`agentfootprint.memory.retrieved`** (new, 72 typed events) — one per retrieval,
  carrying every candidate. `candidates: undefined` means the store ranked server-side
  and returned nothing comparable; it never means there were none.
- **`agentfootprint.memory.attached`** — declared since 2.x, emitted by nothing until
  now. One per chunk that reached the prompt, with its score and rank.

```ts
agent.on('agentfootprint.memory.retrieved', (e) => {
  for (const c of e.payload.candidates ?? [])
    console.log(c.admitted ? '✓' : '✗', c.id, c.score.toFixed(2), c.reason ?? '');
});
// ✓ refunds.md#0   0.80
// ✓ pricing.md#0   0.79
// ✗ security.md#0  0.75  over-max-entries
```

### One injection record per passage, and not one byte of prompt changed

A retrieval that put three passages in the prompt produced ONE `InjectionRecord`. So
ablating a single passage was impossible, the context ledger credited the block rather
than the passage, and `ContextInjectedPayload.retrievalScore` / `.rankPosition` /
`.threshold` — declared since 2.x — had nowhere to come from, because one record cannot
honestly carry three scores. All three are written for the first time in this release.

The recall now splits into one injection per admitted chunk, keyed by the chunk's own id.
**The prompt is byte-identical.** The formatter records the exact fragment each chunk
contributed (header on the first, footer on the last), and `callLLM` assembles the system
prompt by joining every record's `rawContent` with `\n\n` — the same separator the
formatter joined its blocks with, so splitting and re-joining is the identity function.
A property test pins it across every `k`, and the split refuses itself if the fragments
do not rebuild the recall exactly (a custom `renderEntry`, say), falling back to the
single record, which is always correct and merely coarser.

Two orderings are now recorded because they are two different facts: `rank` is how the
chunk scored, `promptPosition` is where the budget picker put it. Under the default
recency ordering the best-scoring chunk can land last.

### Chunks the model can cite

A retrieved page of a PDF rendered as `<memory role="unknown" turn="0">` under the header
_"Relevant context from prior conversations"_ — three claims that were not true of a
document, and no way to cite it. `defineRAG` renders a corpus as what it is:

```text
Relevant passages retrieved from the document corpus. Cite the source id when you use one.

<source id="refunds.md#0" doc="refunds.md" heading="Refund timing" score="0.80">
Refunds are processed within 3 business days of approval.
</source>
```

Attributes are omitted when unknown — a fabricated page number is worse than a missing
one — and both the document metadata and the chunk text are escaped, because a corpus is
untrusted input. Conversation memory keeps its `<memory>` rendering byte for byte.

### `source: 'rag'` is a value something finally emits

`ContextSource` has carried `'rag'` since 2.x and nothing ever produced it; RAG
injections reported `source: 'memory'`, while the docs claimed `'rag'`. The vocabulary
was right and the emitter was wrong, so the emitter changed: a `defineRAG` retriever
carries `flavor: 'rag'` and its injections compose as `source: 'rag'`.

### The retrieval seam

`topK({ k, threshold, rejectWindow })` is the rule every release before this one applied
without naming it, now written down as a `RetrievalStrategy` and passable as
`retrieval`. `topK` + `threshold` remain as shorthand for exactly that rule, and the two
spellings **exclude** — in the type and again at runtime for JavaScript callers — because
they could disagree and the recording would then name a `k` the run did not use.

A cross-encoder re-ranker and a diversity (MMR) selector are the next two adapters behind
this same interface. Neither ships here, and they are named so the destination is on
record rather than implied: a re-ranker with no shipped re-ranking model is a config with
nothing to configure.

### Also

- `defineRAG({ embedderId })` has been accepted since 7.x and never forwarded to
  anything — an option no run read. It reaches `search({ embedderId })` now, which is
  where it filters out a stale embedding space.
- `pickByBudget` names cap-rejections separately from budget-rejections. The two are
  fixed by different changes: one by raising `maxEntries`, the other by shortening the
  corpus.
- What did **not** change, deliberately: the budget picker still orders by recency, not
  by relevance. Reordering it would change which passages reach the prompt under budget
  pressure, and this release carries one behavior change. The record says
  `selectionOrder: 'recency'` so a reader can see it rather than assume otherwise.
- `fnv1a` moved to `lib/fnv1a.ts` and is re-exported from `core/slots/helpers.ts` under
  the same name. The memory layer needed the same hash and `memory/` cannot import
  `core/`; a second implementation is how two recordings of the same bytes end up
  disagreeing about their id.

## [8.7.0] - 2026-08-06

**The check-up stops being quiet, and a dead option stops pretending.** 8.4.0 stopped a
skill graph from throwing away what the author declared; 8.5.0 stopped it telling the
model things that were not so. This one is about the configurations the library
_watched you build and said nothing about_ — an entry menu with no way to choose from
it, a transition the cursor can never take, a tool name two sources claim, a scoped
tool provider that returns nothing forever. Nine findings, one shape: the library knew,
and did not say.

### An entry menu with no way to choose from it

Declare two entries and no `.entryBy()` / `.entryByRead()`, and both of them load on
every call. An entry's compiled trigger is cursor-_independent_ — no `when` compiles to
`{ kind: 'always' }` — while exactly ONE of them can be the cursor: the first whose
`when` passes. So the extras pay for their body and their tools on every iteration and
route nothing, which is the opposite of what a skill graph is for.

Worse, and exactly decidable: the cursor resolver returns at the **first entry with no
`when`**, in declaration order. Every entry after that one can never be the cold-start
cursor — so a `step` declared out of it never fires from there. A graph could pass
`check: 'throw'` with a transition that had no way to happen.

```text
[warning] multi-entry-fanout: The graph declares 2 entries ("triage" and "billing") and
no way to choose between them … Only ONE of them can be the cursor …
[warning] dead-entry-step: Entry "billing" is declared after "triage", which has no
`when` and therefore always wins the cold-start cursor … The one remaining path is a
read_skill pick onto "billing" mid-run (entries are always offered) …
```

Both are **warnings**, and the second one deliberately. The stranded entry is still in
`reachableSkills()` from every cursor, so a `read_skill` pick really can put the cursor
there — and refusing to build something the model can reach would claim more than the
declaration supports. The message names that path instead of pretending it does not
exist.

### A bare `.route(a, b)` was counted as reachability

A bare edge compiles to **no trigger at all**: the target keeps its `llm-activated`
default, and the model has to ask for it by name. The check-up's BFS walked it anyway,
so a skill nothing in the graph could activate was reported as reachable — the exact
question the check exists to answer, answered wrongly.

The BFS now walks deterministic edges only (`when` / `onToolReturn`), and bare-edge
targets get their own code, which says what is actually true — including the one cursor
position the gate will grant the jump from:

```text
[warning] model-edge-only: Skill "incident" has no deterministic edge into it — only a
bare route from "triage" … the model has to ask for it with read_skill, and the gate
grants that only while the cursor is on "triage" (never at cold start).
```

`unreachable-skill` and `model-edge-only` now partition cleanly: nothing incoming at
all versus only bare edges incoming.

### `unreachable-skill` is told per trigger kind

The sentence _"it can only be reached by the model via read_skill"_ is true for an
`llm-activated` trigger and for no other kind — `Agent.openSkillIds()` admits an open
pick only for that one. But `deriveTrigger` returns null for an unwired skill, so a
skill that arrived carrying a hand-authored `rule` trigger **kept it**, and the warning
promised a `read_skill` the gate would refuse. Three messages now, one per case: the
open skill, the `always` skill (which loads on every iteration rather than being
routed), and the rule-gated one (which `read_skill` cannot open at all).

### A tool name two sources claim, and which one actually runs

A `ToolProvider` and an active Skill can both declare `shared_tool`, and the two lose in
**opposite directions** — each by a rule of this codebase, not by a race:

- the tools slot merges `[static, provider, skill]` first-wins, and an
  `autoActivate: 'currentSkill'` skill's tools are deliberately kept out of the static
  registry — so the **provider's** schema, description and `inputSchema` are what the
  model reads;
- `lookupTool` resolves `registryByName` first, which holds every skill tool and no
  provider tool — so the **skill's** implementation is what executes.

The model reads one tool's contract and calls another's, and nothing anywhere said so.
Now `agentfootprint.tools.shadowed` fires every iteration, plus a latched dev-mode
console line. Not a refusal: a provider's list is resolved per iteration, so there is no
build-time moment at which this is knowable — a dynamic provider can begin shadowing on
iteration 9 of a run nobody is watching, which is exactly why the event is not
dev-gated. (The static `.tool()` ↔ skill-tool pair is unchanged: it is still refused at
build time, which is the better answer when the answer is available that early.)

### `skillScopedTools` returning `[]` forever

`ctx.activeSkillId` is the tail of `activatedInjectionIds`, which only `read_skill` ever
writes. A skill that activated because an entry rule matched or a `skillGraph()` edge
routed into it does not set it — so the provider returned an empty list on the very
iterations its skill was loaded. Its own docstring said so; nothing said it at the
moment it happened, and the tools simply never appeared.

`ToolDispatchContext` now carries **`activeSkillIds`** — every skill active this
iteration, however it got there. That makes the mismatch detectable from inside the
provider (composed into a larger provider or not), where it dev-warns; and it hands
every provider the graph-position signal `skillScopedTools`' own header called
impossible:

```ts
const graphScoped = (id: string, tools: Tool[]): ToolProvider => ({
  id: `graph-scoped:${id}`,
  list: (ctx) => (ctx.activeSkillIds?.includes(id) ? tools : []),
});
```

### Added

- **`multi-entry-fanout`, `dead-entry-step`, `model-edge-only`** — three `GraphProblemCode`
  values, all warnings. `GraphProblem.kind` gains no third member: widening that union
  would break an exhaustive consumer `switch`, and the codes carry the distinction.
- **`graph.checkup({ knownTools })`** (and the same field on `.build()` /
  `skillGraph({ knownTools })`) — the agent's baseline `.tool()` names. A graph knows
  only the tools its own skills carry, so a body saying `lookup_order(id)` was reported
  as naming a tool that exists nowhere. A `knownTools` name is now neither
  `body-unknown-tool` (it exists) nor `body-foreign-tool` (it is not somebody else's) —
  it is callable from every skill, which is the whole point. `checkSkillContract` /
  `checkSkillContracts` take the same option.
- **`formatCheckup`** is public on `agentfootprint/context` — the formatter the library
  itself uses to render a check-up for a thrown error, so a consumer printing
  `graph.checkup()` in CI stops writing their own. `checkupGraph` stays private: its
  input is the graph's internal wiring shape, and `graph.checkup()` is already the door.
- **`skillGraph({ tree, scopeTools })`** — parity with `.tree(root, { scopeTools })`.
  The object form hard-coded `true`, so the fluent form's only opt-out had no twin.
- **`ToolDispatchContext.activeSkillIds`** — the real active set for this iteration.
  Optional, so a provider written before 8.7.0 sees `undefined` and behaves as it did.
- **`agentfootprint.tools.shadowed`** (71 typed events now) — `{ toolName, iteration,
schemaFrom, schemaFromId?, dispatchTo, dispatchToId? }`. Names only: never args, never
  results, never a description body.
- **`skillScopedToolsTarget` / `SKILL_SCOPED_TOOLS_ID_PREFIX`** — the provider-id
  convention, readable by anyone composing providers.
- **Dev-mode warnings** for two inert configurations: a `defineRelevanceHint()` mounted
  on a graph with no entry scorer (its trigger reads `ctx.entryScores`, which only
  `.entryBy()` / `.entryByRelevance()` writes — so it could never fire), and a
  `skillScopedTools(id, …)` aimed at a skill that already scopes its own tools with
  `autoActivate: 'currentSkill'`.
- **Examples** `features/46-skill-graph-checkup-deepens.ts` and
  `features/47-skills-from-dir-graph.ts`.

### Changed

- **`skillGraph().build()` defaults to `check: 'throw'`** (was `'warn'`), matching the
  object-literal form since 8.4.0. **Behavior change.** A fluent graph with an
  error-level problem — `no-entry` or `unknown-skill`, i.e. a graph that cannot start a
  turn at all — built in silence outside dev mode and surfaced as a run that entered no
  skill. What still builds: every graph whose check-up has no _error_ (warnings never
  throw, however many); every call passing `check: 'warn'` explicitly, which still never
  throws, so the mode keeps its name and its meaning; `check: 'off'` skips entirely.
  Only code that was already shipping a graph the library could not start is affected.
- **`defineSkill({ viaToolName })` other than `'read_skill'` is refused when the skill is
  mounted.** **Behavior change.** It read as a promise — name a tool, and that tool
  activates the skill — and no such tool has ever been built. The evaluator activates an
  `llm-activated` skill by matching `ctx.activatedInjectionIds`, which only `read_skill`
  writes, and it has never read the field. A skill declaring `viaToolName:
'open_playbook'` activated through `read_skill` exactly like every other skill, so the
  declaration described a door that does not exist. Nothing that worked stops working;
  a silent no-op becomes a named one, at `Agent.injection()` — the one funnel `.skill()`,
  `.skills()`, `.skillGraph()`, `skillsFromDir()` and a hand-built Injection all pass
  through. A graph that COMPILES the trigger away (an `.entry()` becomes `always`) has
  nothing left to refuse and is unaffected. The option is `@deprecated` on both
  `DefineSkillOptions` and `SkillsFromDirOptions`, and is removed in 9.0.0.

### Fixed

- **Six documentation files imported skill-graph symbols from doors that do not export
  them** — `README.md`, `docs/skill-graph-guide.md` (×5), `docs/design/skill-graph.md`,
  `docs/design/skill-graph-spec.md`, `docs/proposals/002-skill-graph.md` and
  `docs/guides/caching.md`. `src/index.ts` does not re-export the injection engine, so
  every `import { defineSkill } from 'agentfootprint'` in prose was broken; two also
  named `decide`, which was renamed `decideSkill` in 7.0.0 to stop colliding with
  footprintjs's own `decide()`, and one named `agentfootprint/observe`. All 18 runnable
  examples were already correct — they are compiled by `test:examples`, and markdown
  fences are not, which is precisely how this drifted.

### Documentation

- **The cursor is per RUN**, on `SkillGraph.nextSkill` and
  `InjectionContext.currentSkillId`. "Persisted across iterations" meant across the
  iterations of ONE `agent.run()`; a second run starts cold, at the entry, whatever the
  first ended on. Deliberate — a graph declares how one turn is routed, and a surviving
  cursor would make turn 2 start somewhere nobody declared — but never written down.
- **What a `SKILL.md` can and cannot carry.** `skillsFromDir` loads `name`,
  `description` and the body, and that is the entire per-file surface: no `tools` (a
  tool is code with an `execute`, and markdown has none), no `autoActivate`, no per-file
  `surfaceMode` (settable for the whole directory or not at all). Every loaded skill is
  body-only, with the pattern that limit points at — mix the loaded list with
  `defineSkill`-authored, tool-carrying skills and hand both to `skillGraph({ skills })`
  — shown in example 47.

### Tests

- +60, including the audit probes that found each of these as named regression seeds,
  and the two laws of the tool shadow pinned separately (which schema the model reads,
  which implementation dispatch resolves) so a change to either one fails loudly.

## [8.6.0] - 2026-08-06

### Consent is work, not conversation

A tool declares `needs: { credential: 'billing', mode: 'user' }`. The vault
answers `authorization-required` — a person has to click a link. Until now this
library wrote that link into the tool result and handed it to the model.

The model is the one party in the room that cannot click it. So it did what
models do with a refusal: it adapted, wrote a plausible final answer, and the run
returned `"done"` — 200, complete, invoice unpaid, nobody asked. That is not an
error path. It is a success report for work that never happened, and it is the
exact failure the `checkIn` gate, the middleware `ask`, and `PendingAsk` were all
built to prevent. The machinery to ask a human was already here; the credential
seam was the one door that never used it.

**A run now pauses when a declared credential needs consent.** `agent.run()`
returns a pause outcome; a `standingAgent` answers **202 Accepted** with
`{ awaiting }` carrying the service, the session id and the authorization URL;
`agent.resume(checkpoint)` re-resolves the credential and runs the tool that was
waiting. Same run, same conversation, work actually done.
`onAuthorizationRequired: 'tell-model'` keeps the model in the loop for callers
who want it — and even then the turn cannot report a completion it did not earn:
it raises `CredentialConsentRequiredError`.

### Added

- **`Agent.create({ onAuthorizationRequired })`** — `'pause'` (default) or
  `'tell-model'`. Governs what a run does when a tool's DECLARED credential comes
  back `authorization-required`.
- **`CredentialConsentRequiredError`** (`ERR_CREDENTIAL_CONSENT_REQUIRED`, from
  `agentfootprint/identity`) — carries `service`, `sessionId`, `authorizationUrl`,
  `tool` and `iteration`. The error _message_ deliberately omits the URL, because
  a message is the one string that reliably reaches a log line.
- **`pauseData.authorization`** — `{ service, authorizationUrl, sessionId }` on a
  consent pause, surfaced by `standingAgent` as `PendingAsk.pauseData`. The
  hosting layer needed no change at all.
- **Example** `features/45-credential-consent.ts` — the pause, the caller's URL,
  the resume that does the work, and a grep over the whole recording proving the
  URL is in none of it.

### Security

- **An OAuth consent URL was written into the conversation, the trace, and every
  recording. It is a bearer capability, and it should never have been in any of
  them.**

  A 3-legged consent URL carries a `state` parameter that correlates the
  authorization session. Anyone holding the URL can complete the consent flow.
  Since **6.11.0** the library interpolated it into the tool result string, and
  from there it was copied — correctly, by design — into every channel that
  preserves tool output: the conversation history, `stream.tool_end`,
  `agent.iteration_end` (once per remaining iteration), `context.injected`, the
  footprintjs commit log and snapshot, the narrative recorder's `rawValue`, and
  any `recordRun()` recording. A single blocked tool call put the URL in a
  recording **78 times**.

  This is the mirror image of a guarantee the library kept carefully everywhere
  else. `agentfootprint.credential.authorization_required` was designed to carry
  `{ service, sessionId }` and never the URL; OTel and X-Ray record the tool
  result's _type_ and never its value; the audit bundle's default `bounded` mode
  maps `tool_end.result` to `[type: string]`. Every observer channel was
  disciplined. The one channel nobody thought of as an observer — the
  conversation — was not, and it feeds all the others.

  The URL is now delivered only to the caller: on `PendingAsk` / the pause
  outcome under `'pause'`, and on `CredentialConsentRequiredError` under
  `'tell-model'`. What the model reads names the service and never the URL.
  `mcpServe` does the same at the served boundary, where the string previously
  crossed a process line into another agent's transcript.

- **Existing recordings and logs may contain consent URLs.** If you ran a tool
  with `needs: { mode: 'user' }` against a provider that returned
  `authorization-required` on **6.11.0 through 8.5.0**, the authorization URL —
  `state` parameter included — is in the artifacts of those runs. Consent URLs
  are normally short-lived and single-use, which bounds the exposure, but the
  durable sinks are worth walking:

  - **`sqliteSessions` / `agentCoreSessions` rows** — the conversation is
    persisted as plaintext JSON and survives restarts. `forget(sessionId)`
    removes it.
  - **CloudWatch log groups** (`cloudwatchObservability`) — the full event is
    written with no bounding layer. Log-group retention applies.
  - **`recordRun()` recordings written to disk** — both `snapshot` and `events`
    carry it.
  - **Audit bundles exported with `payloadMode: 'verbatim'`** — these are
    hash-chained and tamper-evident, so the record **cannot be redacted after the
    fact without breaking the chain**. Rotating the affected OAuth grants is the
    remedy, not editing the bundle. The default `'bounded'` mode was never
    affected.
  - **`consoleObservability()`** prints the payload to stdout — check whatever
    captures it.

  OTel spans, X-Ray segments, audit bundles in default `bounded` mode, and the
  context ledger were never affected.

- **Where the URL lives now, stated plainly.** Under `'pause'` it is on the
  pause outcome, on `PendingAsk`, and inside the engine checkpoint — so a
  `standingAgent` that persists a paused run writes it into **your** session
  store, alongside the conversation and shared state that store already holds.
  That is the caller's own durable store, chosen by the operator, and it is a
  strictly smaller exposure than the transcript sitting next to it; `forget(sessionId)`
  clears both. What changed is that the URL no longer travels to places nobody
  chose: the model's context, the narrative, the typed event stream, exported
  telemetry, and recordings meant to be shared with a viewer.

- **A blocked tool call is now flagged as an error.** In the main dispatch loop
  the `authorization-required` result set no `error` flag, so `stream.tool_end`
  reported it as an ordinary result and the OTel tool span closed **OK**. A tool
  that was refused a credential and never ran is not a success. This is a payload
  change in every mode, including `'tell-model'`.

### Changed

- **One behavior change, and it is the point of the release.** A tool whose
  declared credential comes back `authorization-required` no longer hands the
  consent URL to the model and lets the turn finish. By default the run
  **pauses** — `agent.run()` returns a `RunnerPauseOutcome`, a `standingAgent`
  answers **202** with `{ awaiting }`, and `agent.resume(checkpoint)` re-resolves
  the credential and runs the tool. Callers who relied on the model adapting
  in-loop set `onAuthorizationRequired: 'tell-model'`; the model then reads a
  refusal (never a URL) and the run raises `CredentialConsentRequiredError`
  rather than reporting a completion it did not earn.

  **Nothing that worked stops working.** The tool never ran under the old
  behavior either. What stops is the run's claim to have finished.

  **Unchanged:** `CredentialProvider`, `CredentialResult`, `CredentialNeed`,
  `ToolEndPayload`, `PendingAsk`, and every `credential.*` event payload. Issued
  credentials, machine mode, and provider-failure handling are byte-identical.
  `agentfootprint.credential.authorization_required` carries
  `{ service, sessionId }` exactly as before.

- **Docs stop saying the consent URL is surfaced to the LLM.** The AgentCore
  guide and `examples/features/17-identity.ts` both described the old flow in
  prose; both now point at the pause and at example 45.

### The judgements these rest on

**Consent is unfinished work, and unfinished work is a pause.** Every other place
this library needs a person — `checkIn`, a middleware `ask`, `askHuman` — stops
the run and hands the question to the caller through `PendingAsk`, which by
construction carries no checkpoint and no conversation. Consent was the one case
that instead wrote the question into the transcript and asked the model to solve
it. Routing it onto the same wire is not new machinery; it is the credential seam
finally using the machinery that was already load-bearing everywhere else.
`standingAgent`, `httpHost`, the session stores and the resume path needed no
change at all — the test that proves it is a copy of the `askHuman` one.

**Nothing that worked stops working.** Under the old behavior the tool did not
run. It still does not run. What stops is the run's claim to have finished — the
same judgement 7.19.1 and 7.20.0 rest on.

**The event was always right; the sentence was always wrong.**
`credential.authorization_required` has carried `{ service, sessionId }` since
6.11.0 precisely because a URL is not telemetry. The fix is not to redact a
channel — it is to stop putting a capability in a string that every channel is
built to preserve.

**A pause payload is not automatically private.**
`agentfootprint.pause.request` mirrors the whole `pauseData` into its
`questionPayload`, which is right for a check-in's evidence pack and wrong for a
bearer URL. The consent URL is withheld from that payload by name — the same
discipline the audit adapter's bounded fields already apply to
`tool_end.result` — and the token-secrecy suite now pins both halves as one law:
**no credential material, vended token or consent capability, reaches the
conversation, the snapshot, the narrative, the event stream, or a recording.**
The security tests are written as a grep over the serialized artifacts rather
than a field-by-field check, so a NEW sink that starts copying tool output fails
them too. That property is what was missing: the old clause only ever looked for
an issued token, and the consent URL walked straight past it.

**The `'tell-model'` record travels off tracked state.** A tracked write is a
commit-log entry, which is the snapshot, the narrative and every recording —
so a scope key holding the URL would have rebuilt the exact leak this release
removes. It rides a private field on the Agent for the length of the run, and
leaves only as the typed error.

**A resume cannot pause again, and the design does not pretend otherwise.**
footprintjs's `ResumeFn` returns `void`. If consent is still ungranted when a run
resumes, the tool gets the URL-free refusal with `error: true`, the loop carries
on, and a further attempt can checkpoint afresh through `execute` — a new consent
round with a new URL on `PendingAsk`. The resume input is ignored throughout: the
person's answer is "I authorized it", not a result, which is the same reasoning
that gave the middleware-ask outcome union no `result` arm.

### Deferred (documented follow-ups)

- **Per-tool `needs: { onAuthorizationRequired }`** — an agent-level default is
  what ships, because one canonical path first. The asymmetric case is real and
  named here so the destination is on record: a payment tool must pause, while an
  optional enrichment tool may legitimately want the model to route around a
  consent block. Resolution would be `need.onAuthorizationRequired ?? agentDefault`
  — three lines — and it earns its knob on field evidence, not on this argument.
  (6.11.0 deferred "auto-pause-on-3LO" in exactly this way and it was the right
  call; what was missing was anyone reading the note for five minors.)

## [8.5.0] - 2026-08-06

**`read_skill` tells the whole truth.** 8.4.0 stopped a skill graph from throwing
away what the author declared. This one stops it from telling the _model_ things
that were not so. Five findings, all of the same shape: the library said a thing had
happened, or offered a thing it would refuse, or recorded a cause that was not the
cause. One is a build-time refusal, one is a gate refusal, three are fixes.

### A decision `tree()` cannot be jumped — and now says so

A `tree()` routes by predicate on every iteration. It has no cursor, so `read_skill`
has nothing to move. But `graph.reachableSkills()` reported **all the leaves**, so
the gate accepted a leaf pick and `read_skill` answered _"Skill 'x' activated for the
next iteration"_ — and nothing happened. A leaf compiles to a `rule` trigger; a
`read_skill` call writes only `activatedInjectionIds`; no `rule` trigger reads that.
The leaf never activated, the tree re-decided by predicate, and the run then emitted
`agentfootprint.skill.reroute_superseded` naming a winner that **did not exist** —
tree mode never writes a cursor at all, so the payload carried neither `wonId` nor
`fromSkillId`. Three sentences of the library, false at once.

Honouring the pick was the other option, and the tree's own rules refuse it: exactly
ONE leaf fires per iteration (the library ships a dev-mode monitor that warns
otherwise), each leaf's tools are scoped on that basis (`TreeOptions.scopeTools`),
and `toMermaid()` draws only predicate branches — a model lever over that routing is
not on the drawing. And 8.4.0 already settled the general rule: a pick is admitted
only for the one trigger `read_skill` can fire, `llm-activated`. A leaf's trigger is
a `rule`. Tree mode was the single set that had escaped that rule.

So the gate refuses, in terms that teach:

```text
read_skill("capacity") cannot move a decision tree. A tree routes by predicate on
every iteration — it has no cursor to jump, so this skill would not activate even
though the tool accepted the name. Answer with the skill the tree routed to, or finish.
```

**Behavior change:** `graph.reachableSkills()` now returns `[]` for a decision
`tree()`, from every cursor. Its contract is "what `read_skill` may jump to", and
all-leaves was the lie; use `graph.skills` to enumerate leaves, which is what it was
always for. `read_skill` is not dead under a tree — anything registered _beside_ the
graph (`.skill(x)`, `.skills(reg)`, `.selfExplain()`) is **open** and still admitted
from anywhere, because those really do activate by `read_skill`. Two docstrings that
promised "read_skill stays a full escape hatch there" are corrected.

### `read_skill` offers what the gate will actually grant

The tool enumerated every registered skill — in its enum and in the catalog inside
its own description — while the gate admitted only `reachableSkills(cursor) ∪ open`.
The model was handed a menu the library already knew it would reject: a route target
the cursor cannot reach was advertised on every iteration and refused on every call,
which costs tokens and can burn a whole run on re-asking.

The description is now rebuilt each iteration from the same two functions the gate
itself calls, so the menu and the verdict cannot disagree:

```text
Reachable from here:
  - volume-lookup: Resolve a volume by WWN
  - escalation: How to page the on-call engineer

Not reachable from here (read_skill for these will be refused):
  - capacity-report: Report free capacity per volume
```

**The enum stays the full catalog, deliberately.** `toolArgValidation` defaults to
`'enforce'` and runs _before_ the gate; an off-enum id is rejected with a generic
schema error and never reaches it. Narrowing the enum would therefore have retired
the gate's teaching refusal, the `agentfootprint.skill.rejected` event,
`routeRecorder`'s rejection hops and the rejected-cap governor's only input — four
honesty mechanisms traded for one.

Under `reactMode: 'classic'` the tools slot is composed on turn 1 only, so a
cursor-scoped menu would freeze at the cold-start cursor and keep advertising it: the
full catalog stays there (the honest fallback), and dev mode warns and names the fix.
Agents with no skill graph keep the byte-identical description they always had.

### `surfaceMode: 'tool-only'` is refused when nothing activates by `read_skill`

`'tool-only'` means "suppress the body from the system slot and deliver it as the
`read_skill` tool result." That channel exists only when the model calls
`read_skill`. A skill the **graph** activates never gets that call — so for a route
target, a graph entry or a tree leaf, the tool result never happened, the system slot
suppressed the body anyway, and the body reached the model **nowhere at all**. Its
tools still arrived, which is worse than the skill not loading: the model was handed
the tools of a procedure nobody described.

Refused now at agent build time, keyed on the compiled trigger — the same
`llm-activated` clause 8.4.0's gate turns on — and naming every offender with its
routing, so a `skillsFromDir({ surfaceMode: 'tool-only' })` directory refuses as a
readable list rather than one mystery:

```text
Agent: This skill sets surfaceMode: 'tool-only', … its body would reach the model
NOWHERE …
  • "beta" — a route target (the graph routes "alpha" → "beta")
Use 'both' (system prompt AND tool result) or 'system-prompt'.
```

Refusal rather than a quiet fall back to the system slot: the author wrote
`'tool-only'` to keep the body _out_ of the system prompt, and silently putting it
back would honour the activation while breaking the declaration — a different lie,
not a fix. `'both'` already means "deliver it either way".

Unaffected: `.skill()` outside a graph, a bare model edge target (`.route(a, m)` with
no `when`/`onToolReturn`), and every open skill — all keep `llm-activated`. A new
`resolvedSurfaceModeOf()` helper routes both the current question and the
provider-resolution question through one function, so wiring the `'auto'` cascade
into the runtime later cannot reopen this hole for non-Claude providers by accident.

### `routeRecorder` stops attributing a model pick to a declared edge

A `read_skill` hop the gate accepted was recorded as `outcome: 'route'` wearing the
caption of whatever declared edge happened to point at the same skill — so the trace
asserted that edge had fired when its predicate never ran. The cause was being
inferred from `routing[]`, which is per-**skill** build-time provenance ("how is this
skill reachable at all"), not per-**hop** runtime truth.

The graph's one cursor resolver now reports the clause that won. `nextSkill(ctx)` is
unchanged and is the `.to` projection of it, so there is no second implementation to
drift:

- `SkillGraph.explainNextSkill(ctx)` → `{ from?, to?, by }` where `by` is
  `'entry' | 'route' | 'model-pick' | 'stay' | 'none'`;
- `agentfootprint.context.evaluated` carries it as the new optional `cursorMove`;
- `routeRecorder()` reads it, and a `'model-pick'` hop carries **no** `edgeLabel`.

This settles the one case no observer could reconstruct: an edge and a same-turn pick
naming the _same_ skill resolves to `'route'` (`D1 > D2`), and only the resolver
knows. Without `cursorMove` (an older graph, an older recording) the previous
inference still stands.

**Type widening:** `RouteOutcome` gains `'model-pick'`. An exhaustive `switch` over
it will fail to compile until the case is added.

### `routeRecorder({ maxRejectedRetries })` can finally trip

`consecutiveRejected` reset on every `context.evaluated` — and one fires between
every pair of rejections — so the count never passed 1 and the governor was
unreachable outside a single iteration's parallel tool batch. Five consecutive
out-of-reach jumps against a cap of two produced zero trips.

It now resets only when the cursor actually **moves**, which is precisely what a
model stuck re-asking never achieves; a `'stay'` no longer clears the run. Each run
of rejections trips **once** and re-arms on the next real move, instead of pushing a
duplicate trip on every iteration past the cap.

## [8.4.0] - 2026-08-06

**Dead skills.** Five combinations a skill graph accepted and then silently gutted.
Four of them threw away part of what the author declared at build time and said
nothing — two even reported `checkup() → { ok: true, problems: [] }` afterwards. The
fifth threw away a capability at run time: the gate that keeps the model inside the
graph also refused every skill the graph does not route, including the library's own
`.selfExplain()` debug skill. Three are now refusals that name the fix, one is a
refusal on the agent, and the gate one is a fix, not a refusal.

### The `read_skill` gate stops refusing skills the graph never routed

`.skillGraph()` bounds `read_skill` to `graph.reachableSkills(cursor)`, so the model
cannot move the graph somewhere the graph doesn't go. That set is about the CURSOR,
but it was being used as the whole catalog, so three shapes were dead:

| you wrote                                                    | before                                                                                                         | now                                                              |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `.skillGraph(g).selfExplain()`                               | `read_skill('self-explain')` rejected on every call — the debug skill and its six trace tools could never load | activates; the trace tools reach the model on the next iteration |
| `.skillGraph(g).skill(x)` / `.skills(reg)`                   | `x` was listed in `read_skill`'s own menu and refused every time; its body was unreachable                     | activates                                                        |
| `skillGraph({ skills: [..., x] })` with `x` wired to nothing | refused — while its check-up warning said _"it can only be reached by the model via read_skill"_               | activates; the warning is true again                             |

A skill is **open** when its trigger is `llm-activated` (the trigger `read_skill`
actually activates — a rule-gated injection is still refused, because admitting it
would just be a different lie) **and** the graph declares no incoming edge to it. An
open pick **activates but never moves the cursor**: a skill the graph does not route
is not a node, so it cannot be a hop, and the graph stays exactly where it was.

What stays bounded is everything the graph wires — including a **bare model edge**
`.route(a, m)`, which is a declared, drawn affordance and remains reachable only from
`a`. `graph.reachableSkills()` is unchanged: it still answers about the graph alone,
and the union with the open skills happens at the agent's gate, which is the only
place that knows what else is registered. The gate's re-prompt and the
`agentfootprint.skill.rejected` payload both report that union — what the gate
accepts — so a graph with no open skills reports byte-identically to before.

Named plainly: if you registered unrelated skills beside a graph expecting the graph
to hide them, they are now reachable by name. They were already being advertised to
the model in `read_skill`'s menu and then refused, which cost tokens and could burn a
whole run on re-asking; the cursor still cannot leave the graph, and nothing routes
from an open skill.

### Four refusals, each naming the fix

**A `.tree()` and the flat wiring are two declarations of one thing.** Only the tree
compiled; every `.entry()` and `.route()` — and, in the config form, `start` and
`steps` — was dropped in silence.

```text
skillGraph: .tree() and .entry()/.route() both declare the routing and only one can
compile — the tree wins, so the 1 entry declared here would be silently dropped.
tree() owns the graph: remove the .entry()/.route() calls, or drop .tree() and route
with the flat entry/route form.
```

`SkillGraphConfig` is now a **union** of a tree arm and a flat arm, so
`skillGraph({ tree, start })` is a compile error as well as a build-time refusal.
Valid tree-only and flat-only configs typecheck exactly as before; the two arms are
exported as `SkillGraphFlatConfig` / `SkillGraphTreeConfig` (plus `SkillGraphStart`
and `SkillGraphStep`) for consumers that name the shape.

**A tree routes to its leaves and nothing else.** A skill listed in `skills[]` that
was not a leaf was compiled out of the graph entirely — it never reached the agent,
so it had no trigger, no `read_skill` row, no body, ever.

```text
skillGraph({ tree }): skill "alpha" is listed in skills[] but is not a leaf of the
tree, so it would never load — a tree routes only to its leaves. Add it to the tree
as a leaf, drop it from skills[], or register it on the agent with .skill(alpha) to
keep it read_skill-reachable.
```

That last option is the escape hatch this release makes real.

**Two skills, one id.** `skillsFromDir` refuses a collision inside its own directory
and `Agent.injection()` refuses one on the agent; the graph was the only place where
two skills could quietly claim one id — the id `read_skill` dispatches by and every
edge routes by. Last write won in the flat form, FIRST write won under a tree, and
the loser vanished. Now refused, naming both by description. Re-registering the SAME
object is still fine: `.entry(a).route(a, b)` and one skill at two tree leaves are
how the builder is meant to be used.

**One agent, one graph.** A second `.skillGraph()` replaced the cursor, the reachable
set and the entry scorer while the first graph's skills stayed registered and active
— so graph 1's route targets could never activate again (their ids are absent from
graph 2's reachable set, so even a model pick was refused) and only its unconditional
entries survived, as always-on bodies with dead wiring. Refused, with the merge as
the fix.

### The tree check-up stops being a no-op

Tree leaves are now registered as they compile. Two things follow: the duplicate-id
refusal covers leaves, and the **skill-contract checks finally run for a fluent
`.tree()` graph** — before, `skillsById` stayed empty there, so `checkup()` answered
`{ ok: true, problems: [] }` for a tree whose body called a tool that exists nowhere,
while the byte-identical config-form graph reported it. A valid fluent tree may
therefore report contract WARNINGS it never reported before. That is the fix working;
contract findings are warnings only, so `check: 'throw'` is unaffected.

Everything else about a valid graph is unchanged: same `skills`, `edges`, `nodes`,
triggers, `toMermaid()` and `reachableSkills()`.

## [8.3.0] - 2026-08-06

**`read_skill` stops lying.** When a skill graph offered the model `read_skill`
and the model called it, the tool answered:

```text
Skill 'esxi-inventory' activated for the next iteration.
```

For most graphs that sentence was false. The id was appended to
`activatedInjectionIds`, which only a bare `llm-activated` skill ever reads — so a
skill whose activation was cursor-gated (a route target, an exclusive entry) or
rule-gated (an intent entry whose rule didn't match) simply never loaded. The
agent was told a thing had happened that had not happened, in its own context,
and then reasoned on top of it.

The worst shape of it: a `start: { rules: [...] }` graph, and a user phrasing no
rule anticipated. Nothing matches, so nothing activates; `read_skill` is the only
tool offered; the model calls it, is told the skill is active, and gets the same
empty tool list on the next iteration — **forever**. No skill, no tools, no error,
no event. Our own guide listed that fallback under "shipped and usable", and the
design doc had specified the missing step (§4A.1 "D2 — validated volunteer
reroute") three versions ago. The gate was built; the move it gated was not.

### An accepted pick now moves the cursor

`read_skill` is already bounded to `graph.reachableSkills(cursor)` — the declared
successors of where the model stands, plus the entries. A pick that gate **accepts**
now moves the graph's cursor exactly like a declared edge does, so the skill's
body and tools land on the next iteration and the graph's own `steps` run from
there. What the gate allows and what takes effect are the same set.

```
a declared edge that fired   >   the model's pick   >   stay where you are
```

The author's determinism is untouched: an edge that fires on the same turn
outranks the pick (a model guess never overrides a route the author pinned). The
dropped pick is reported rather than swallowed — see the new event below.

### `agentfootprint.skill.reroute_superseded` (new typed event — 70 total)

Fires in exactly one case: a `read_skill` the gate accepted did not end up active
because a declared edge won the same turn (the model emitted a domain tool _and_
`read_skill` in one message). Payload: `{ volunteeredId, wonId, fromSkillId,
iteration }`. It is derived from the real active set, not from which clause won,
so it cannot fire for a pick that did take effect.

### Two behavior changes worth naming

Both are activations that previously no-opped and now work. Nothing that worked
before stops working; a graph whose rules match is byte-for-byte unchanged (the
rule is evaluated first and short-circuits), and an agent with no `skillGraph()`
never engages any of this.

1. **A declared `step` INTO a skill that is also a rule entry now activates it.**
   This was live and silent: `steps: [{ from: 'esxi', to: 'volume-lookup' }]`
   where `volume-lookup` is also an entry rule moved the cursor and left the
   skill dark, because its compiled trigger was its own entry rule — written for
   the user's message, not for the hop. The cursor and the active set disagreed,
   which the skill-graph module's own keystone says can't happen. Now an intent
   entry is active when **its rule matches OR the cursor is on it**.
2. **A `when`-gated entry the model explicitly picks now loads.** `when` gates the
   AUTOMATIC pick; it was never authorization (no identity is in scope for it).
   Previously the pick was accepted, reported as activated, and dropped —
   documented as a caveat on `.entryByRead()`, which is just this bug wearing a
   disclaimer. Use `when` to say "don't route here on your own", not as a lock.

### Also

- `skillScopedTools()`'s doc was stale in a way that cost people wiring time: it
  said the runtime populating `ctx.activeSkillId` was still to come (it ships),
  and never said that `activeSkillId` is the last **`read_skill`** activation
  only — a skill activated by a rule or by a graph edge does not match it — nor
  that an agent takes **one** `ToolProvider` (a second `.toolProvider()` call
  throws). It now leads with the case where you don't need the provider at all:
  `defineSkill({ tools, autoActivate: 'currentSkill' })`.
- `graph.nextSkill(ctx)` reads the new `InjectionContext.pendingSkillPick`, and
  the compiled triggers share one memoized view of the resolver per evaluation
  pass — a 15-entry regex router costs the same per iteration as it did before.

→ runnable + tested: **`examples/features/42-skill-graph-model-pick.ts`**.

## [8.2.0] - 2026-08-06

**Durable compaction.** An agent that has been up for a week folds week one
into a summary. Then it gets deployed over. It comes back, is handed the same
conversation — and now it can still tell you what week one was about, _and_
show you week one, word for word.

The window half of that already worked: a summary is an ordinary message, so
`agent.checkpoint()` carried it and `resumeOnError()` restored it. What did not
survive was everything behind it. The folded turns lived in the run's commit
log, which is memory, and memory ends when the process does.

### The one behavior change: the summary stops making a promise it cannot keep

Through 8.1 every compacted frame ended with a fixed sentence:

```text
The folded messages are retained verbatim in this run's commit log.]
```

True while the run was alive. False the moment it ended — which is exactly when
a standing agent reads that message back out of storage and hands it to the
model. A library asserting something false inside the model's own context is
worse than a library saying nothing, so the sentence is now written from the
retention policy and can only say what actually happened:

```text
The folded messages are retained verbatim with this conversation and can be
produced on request.]                                    ← retain: 'conversation'

The folded messages were not retained beyond the run that folded them; only
this summary carries them forward.]                      ← retain: 'discard'
```

This changes the bytes sent to the model for every agent using `.compaction()`.
`COMPACTED_FRAME_PREFIX` is **unchanged**, so `isCompactedSummary()` and every
reader matching on the prefix keep working exactly as before.

### `.compaction({ retain })` — the originals ride the conversation

```ts
.compaction({
  thresholdTokens: 120_000,
  summarizer: anthropic(),
  retain: 'conversation',   // the default, spelled out
})
```

`agent.checkpoint().folded` is now one `FoldedSpan` per fold — the summary's
fingerprint, the run whose commit log held the originals, how many there were,
which stages wrote them, the policy, and (under the default) the messages
themselves. It accumulates across every turn, restart and deploy, and rides
into whatever store you chose: `sqliteSessions`, a Redis, your own table.

- **`'conversation'` is the default.** Losing the originals takes a deliberate
  `retain: 'discard'` — the honest behavior is not something you opt into.
- **A discard is still recorded.** Under `'discard'` the span is filed anyway,
  naming what left and how much of it; only `messages` is absent. An absence is
  a fact, and this family has always filed absences.
- **One commit.** The window change and its span are written together, so there
  is no state in which messages left the window and the record of what they
  were did not follow. A summarizer that throws still folds nothing at all.
- **No format bump.** `folded` is an optional field on `conversation-v1`. An
  older runtime reads the checkpoint, ignores the field and continues the
  conversation correctly; a newer runtime meeting a pre-8.2 conversation finds
  no spans and says so rather than inventing them.

**The trade, stated plainly: compaction shrinks the wire, not the record.** A
stored session grows as it folds, by roughly the size of everything it has ever
folded. That is the right way round — the model's context window is scarce and
a session row is not — but it is a real cost on disk and should not be a
surprise.

### New exports

| export                                                      | what it is                                                                                                                                                                                                                                              |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `foldedSpanFor(conversation, message)`                      | The span behind one summary, joined by **content fingerprint** rather than index — a later fold swallows an earlier summary and every index after it moves. `undefined` means "no fold was recorded for this message", never "there were no originals". |
| `foldedMessages(conversation)`                              | Every retained message from every span, oldest fold first.                                                                                                                                                                                              |
| `FoldedSpan` · `CompactionRetention` · `FoldedConversation` | The types.                                                                                                                                                                                                                                              |

The fingerprint is also what makes the join **forgery-proof**: `isCompactedSummary`
answers "this _looks_ like a frame", which is all a prefix check can see, and a
model that copies the frame's opening words passes it. `foldedSpanFor` answers
the stronger question — different content, different fingerprint, no match.

The strategy seam grew with it: `WindowStrategyResult.folded` lets any custom
strategy that replaces messages with something standing for them carry the
originals the same way, and `WindowStrategyInput.runId` names the run for it.

- Docs: [Durable compaction](docs-next/content/docs/build/compaction.mdx)
- Example: `examples/context-engineering/14-durable-compaction.ts` — folds, stores
  the session in a real SQLite file, and continues it on a brand-new agent that
  answers from week one and can still print week one verbatim.

## [8.1.0] - 2026-08-06

**The middle rung of the ladder now holds weight.** `ollama('llama3.2')` runs a
real model on your own machine: no API key, no bill, and — new in this release —
no vendor SDK.

The adapter ladder is `mock()` → a local model → a paid API, and the strongest
version of "the test run and the production run are the same code path" is one
where the middle step costs nothing. A mock proves your control flow; it cannot
tell you whether a real model calls your tool, or what it makes of a tool
description you wrote in a hurry. A local model can — and if finding out is
free, you'll actually do it before you pay for it.

### `ollama()` now talks Ollama's native API

Through 8.0.0, `ollama()` was a thin wrapper over `openai({ baseURL })`. Three
things followed from that, and all three contradicted what the name promises:

- it required `npm install openai` — **the free rung depended on a paid
  vendor's SDK**;
- its failures were labelled `[openai]`, on a provider named `ollama`;
- it reported **zero tokens on every streamed call**, which silently disarmed
  `.compaction()` and any cost budget counted from adapter-reported usage.

It now speaks `/api/chat` directly over `fetch`. Same public contract (the
`LLMProvider` port), higher fidelity, one fewer dependency.

**Your code keeps working.** The object form shipped in 8.0.0 is still accepted
as an overload — `host`, `baseURL`, `defaultModel`, and `apiKey` (accepted and
ignored; there is no key to send) all keep their meaning. The shorter positional
form is new:

```ts
ollama({ host: 'http://localhost:11434', defaultModel: 'llama3.1' }); // still fine
ollama('llama3.1'); // new, and the one to reach for
```

**If you specifically want the SDK path, it never went away** — ask for it by
name, which is also how you reach any other OpenAI-compatible server:

```ts
openai({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
```

### Refusals that contain the fix

Two things go wrong with a local runtime, and each has a one-command answer. Both
now raise a typed `OllamaUnavailableError` (discriminated by `reason`) whose
message _is_ the instruction — never a raw `ECONNREFUSED`, never a bare `404`,
and never a hang: a deadline bounds the wait for the daemon to answer (not
generation, so a slow model is untouched).

- **Daemon not running** — names the address it tried, `ollama serve`, the
  install link, and how to point somewhere else.
- **Model not pulled** — names `ollama pull <model>`, and asks `/api/tags` so it
  can also list what this machine _does_ have.

### Also in this release

- **Real token counts, streaming included.** `/api/chat` reports
  `prompt_eval_count` / `eval_count` on every response with no opt-in flag, so
  compaction and cost budgets work against a local model.
- **Thinking blocks from local reasoning models.** `ollama('deepseek-r1', { think: true })`
  asks Ollama to lift reasoning out of the answer; the new `ollamaThinkingHandler`
  auto-wires by provider name and normalizes it. When a model was _not_ asked and
  writes `<think>…</think>` into the answer instead, the library **recognizes the
  shape and surfaces the blocks, but does not edit the answer** — silently
  rewriting model output is a change of meaning, and that belongs to the
  application.
- **`providerFromEnv()` gains a local arm.** `OLLAMA_MODEL=<model>` selects it,
  and is checked **first**. Every other arm triggers on a credential, and
  credentials linger in a shell; `OLLAMA_MODEL` is a name someone chose and typed
  for this run, so honoring a leftover API key over it would ignore the intent and
  charge for the privilege. (`OLLAMA_HOST` alone is not a trigger.) No probing —
  the function reads environment variables and never opens a socket, so its answer
  stays instant and identical on a laptop and in CI.
- **Honest ceilings, stated rather than worked around.** Tool calling is
  model-dependent (no capability preflight — a wrong refusal is worse than a weak
  answer); `carriesForcedToolChoice` stays `false` because Ollama supports no
  `tool_choice`; tool-call ids are synthesized because most local models emit
  none; no multi-modal, no prompt caching, no `providerRef`.
- Docs: [Ollama](https://footprintjs.github.io/agentfootprint/docs/build/ollama)
  rewritten, with an upgrade note. Worked example:
  `examples/features/41-local-model.ts` runs one agent across all three rungs and
  runs offline.
- Tests: +116, including an adapter-swap law (same agent on `mock()` and
  `ollama()` produces the same answer, tool dispatch, iteration count and event
  sequence) and a live-daemon suite behind `AGENTFOOTPRINT_OLLAMA_LIVE` that skips
  loudly when the flag is absent.

## [8.0.0] - 2026-08-05

**26 doors become 10.** Nothing about how agentfootprint behaves changed. What
changed is how many places you have to know about to import from it.

Through 7.x the `exports` field grew one subpath per internal concern, and the
names came from how the library is built rather than what you are doing with
it: `llm-providers` and `memory-providers` and `tool-providers` and
`hosting-providers` and `observability-providers`; `resilience` next to
`reliability`; `injection-engine` describing our machine instead of your task;
five separate doors for observability alone. A person deciding where to import
from had to learn our filing system first.

The doors are now named for the job:

`agentfootprint` · `/providers` · `/memory` · `/observe` · `/context` ·
`/resilience` · `/security` · `/hosting` · `/events` · `/cache`

**Every old import path still works, unchanged, for all of 8.x.** They are
marked `@deprecated` so your editor points at the new door; nothing is logged,
nothing breaks, and each one re-exports the _same symbols_ the door carries —
not copies. `test/api-conformance/door-aliases.test.ts` drives the TypeScript
checker over the shipped `.d.ts` files to prove it, name by name, so the
aliases cannot drift. They are removed in 9.0.0.

### Migration

| you were importing from                             | import from                 |
| --------------------------------------------------- | --------------------------- |
| `agentfootprint/llm-providers`                      | `agentfootprint/providers`  |
| `agentfootprint/embedders`                          | `agentfootprint/providers`  |
| `agentfootprint/tool-providers`                     | `agentfootprint/providers`  |
| `agentfootprint/thinking`                           | `agentfootprint/providers`  |
| `agentfootprint/memory-providers`                   | `agentfootprint/memory`     |
| `agentfootprint/observability-providers`            | `agentfootprint/observe`    |
| `agentfootprint/strategies`                         | `agentfootprint/observe`    |
| `agentfootprint/stream`                             | `agentfootprint/observe`    |
| `agentfootprint/status`                             | `agentfootprint/observe`    |
| `agentfootprint/locales`                            | `agentfootprint/observe`    |
| `agentfootprint/debug`                              | `agentfootprint/observe`    |
| `agentfootprint/debug/finders`                      | `agentfootprint/observe`    |
| `agentfootprint/observability/contextError/finders` | `agentfootprint/observe`    |
| `agentfootprint/reliability`                        | `agentfootprint/resilience` |
| `agentfootprint/hosting-providers`                  | `agentfootprint/hosting`    |
| `agentfootprint/injection-engine`                   | `agentfootprint/context`    |
| `agentfootprint/identity`                           | `agentfootprint/security`   |

`agentfootprint`, `agentfootprint/memory`, `agentfootprint/observe`,
`agentfootprint/security`, `agentfootprint/hosting` and
`agentfootprint/resilience` keep their names and carry more than they did.

### Two doors that stayed put, on purpose

- **`agentfootprint/cache`** is not folded into `/memory`. Importing it RUNS
  the vendor cache-strategy registrations — it is the one side-effectful
  barrel in the package. Folding it in would mean `import { defineMemory }`
  executing those registrations and carrying them in every bundle. Side-effectful
  code stays behind its own plainly-named door.
- **`agentfootprint/events`** is not folded into `/observe`. It is the typed
  wire vocabulary observers _read_, not a tool for watching — and concretely,
  its `ContextSource` (the injection-flavour union: `'rag' | 'skill' | …`) is a
  completely different type from the `ContextSource` `/observe` already carries
  (the context-bisect record). Two incompatible shapes cannot share a door.

### One name that exists twice, said out loud

`CircuitOpenError` is TWO classes: the provider decorator throws one, the
reliability gate throws another, and they differ in constructor and in
`instanceof`. The merged `agentfootprint/resilience` door carries the
**decorator's** — the one that escapes a provider call. The gate's stays at
`agentfootprint/reliability` for all of 8.x.

If you `instanceof`-check the error the reliability gate throws, keep importing
it from `agentfootprint/reliability`. Every other name on that path moved to
the door. Merging the two classes would have changed the error message on one
path, and this release changes packaging only.

(`CircuitState` also exists twice, but the two are byte-identical —
`'closed' | 'open' | 'half-open'` — so the door carries one and no consumer can
tell. Both facts are pinned as test literals that cannot silently grow.)

### Added — `.watch()`

`.act()` says what an agent may do. `.watch()` says who is looking while it
does it. The loop's own source has described this pair in prose since the
moments were written down — "an observer reports, a rule changes what happens
next" — while naming an API that did not exist. It does now.

```ts
const agent = Agent.create({ provider, model })
  .watch(toolChoiceRecorder({ embedder: staticEmbedder() }), routeRecorder())
  .act({ beforeTool: [budgetGuard] })
  .build();
```

Variadic, because observers come in sets. Build-time attach, so the observer
sees the very first run. `agent.attach(observer)` is unchanged — that is the
runtime door, and it still returns the `Unsubscribe` you own.

`Watcher` is exported from the main barrel: the plain name for footprintjs's
`CombinedRecorder`, which is what `.watch()`'s signature reads as.
`CombinedRecorder` keeps its export too.

There is deliberately **no** `WATCH_MOMENTS`. `.act()`'s keys are a closed,
compiler-pinned list because a rule has to be _told_ where it may speak; an
observer attends the whole stream, and a list we published would be a
vocabulary we then had to keep true against every event ever added.

### Deprecated

- `AgentBuilder.recorder(rec)` — use `.watch(...)`. Same list, same order, same
  attachment; `.watch()` takes more than one. Still works for all of 8.x.
- The seventeen import paths in the migration table above.

### Fixed

- `AGENTS.md` and six shipped `ai-instructions/` files still advertised
  `agentfootprint/memory-redis` and `agentfootprint/memory-agentcore` —
  subpaths **removed in 4.0.0** — including as live `import` statements. They
  taught coding assistants to write imports that cannot resolve. Now pointed at
  `agentfootprint/memory`.
- The published architecture page's subpath table listed the same two dead
  aliases plus a `./providers` row describing the 4.0.0 alias, which no longer
  means what it says.

### Packaging

- `exports` goes 26 → 28 entries: 10 doors + 17 deprecated aliases +
  `./package.json`. Every entry keeps the four-condition shape (per-condition
  types for `import` and `require`); `typesVersions` mirrors it entry for entry,
  now asserted rather than assumed.
- `"type": "commonjs"` declared, and `repository.url` is a full `git+https://`
  URL — publint's two outstanding suggestions, cleared.
- Door barrels live in `src/doors/`. The implementation barrels did not move,
  which is what makes the aliases identity-preserving rather than parallel
  copies.

## [7.28.0] - 2026-08-05

A paused agent is a promise you made to a person. Until this release the library
handed you that promise as JSON and wished you luck: _store it anywhere._
Anywhere was the whole of the offer.

`sqliteSessions({ file })` is the first battery included — the same
`SessionLifecycle` port `memorySessions()` implements, backed by a real file, on
Node's built-in `node:sqlite`. **No dependency to install, no service to run.**

```ts
import { standingAgent, nodeHost, sqliteSessions } from 'agentfootprint/hosting';

const handle = await standingAgent({
  agent,
  sessions: sqliteSessions({ file: './sessions.db' }), // ← the whole change
  host: nodeHost({ port: 8080 }),
});
```

**Two gaps, and they turn out to be the same gap.** A conversation had two homes
and no middle: `memorySessions()` is a `Map`, exactly as durable as the process,
and the next step up was "bring a Redis" — a service to run, secure, back up and
pay for, to keep a few kilobytes of chat. Everyone in between wrote the same
little file store themselves and each one re-decided what a half-written file
means. A pause had no home at all: a question outstanding is the one piece of
agent state that _must_ outlive the process, because the answer arrives on human
time — after lunch, after the deploy, tomorrow. Both land in one table here,
because `CheckpointEnvelope` was already a union of the two and a session store
has no business caring which half it is holding.

**What it is, stated as a ceiling rather than left to be discovered.** One
process (or a few) on ONE machine, writing ONE file. It survives anything that
ends the process and leaves the disk alone. It is **not** a distributed store:
two machines do not share a session by both opening a file over a network
filesystem. WAL gives many readers plus **one writer at a time**, and that is
the ceiling — a second writer waits for the lock up to `busyTimeoutMs` (default 5000) and then fails loudly rather than queueing forever. When you outgrow it,
one argument to `standingAgent` changes and nothing above it moves.

**A refusal where a fallback would have been easier.** `node:sqlite` ships with
Node 22.5+ (as-is from 22.13 and 23.4; behind `--experimental-sqlite` on
22.5–22.12). `engines` did **not** move for one optional adapter — it stays
`>=20`, the module is loaded only when you construct a store, and its absence
raises `SqliteUnavailableError` naming the Node you are on, the flag, and
`memorySessions()` as the honest alternative. There is deliberately no silent
degrade to memory: a store that quietly forgot every conversation on restart
passes every smoke test and looks, from the outside, exactly like a brand-new
user.

**"Unreadable is not absent", one level up.** The envelope law already said an
unreadable stored conversation and an absent one are different facts, and only
one is safe to answer with a fresh start. A file store can break that promise
higher up — point it at a log file and a careless adapter opens it as an _empty_
store. So the file is checked at construction and refused with
`UnreadableSessionFileError`, whose `problem` field is the fact to branch on:
`'cannot-open'`, `'not-our-schema'` (somebody else's table of that name), or
`'newer-schema'` (written by a newer runtime — refused, never half-read). Only a
session that was never written hydrates as `undefined`.

**The file is inspectable on purpose.** `format` and `saved_at` are columns as
well as fields inside the JSON, so during an incident `sqlite3` answers "which
sessions are waiting on a person, and since when?" with no JSON parser and
without this library. `journalMode` on the returned store reports what the file
_actually got_ rather than what was asked for — a silent downgrade from WAL on a
network filesystem is the kind of thing only ever discovered under load.

Added, all on the existing `agentfootprint/hosting` door — no new subpath:

- `sqliteSessions(options)` → `SqliteSessions` (`hydrate` / `persist` from the
  port, plus `forget`, `close` and `journalMode`)
- `SqliteSessionsOptions` — `{ file, busyTimeoutMs? }`; the file and its parent
  directories are created if missing, and `':memory:'` is refused because it
  looks durable and is not
- `SqliteUnavailableError` (`ERR_SQLITE_UNAVAILABLE`)
- `UnreadableSessionFileError` (`ERR_UNREADABLE_SESSION_FILE`)

Docs: [Sessions in a file](https://footprintjs.github.io/agentfootprint/docs/infrastructure/sqlite).
Runnable: `examples/deploy/sqlite-sessions.ts` — serves a conversation, throws
away everything but the file, serves the same session again, holds a
human-in-the-loop turn across that boundary, and proves an unreadable store is
refused rather than restarted. A resume is not a replay, and the example counts
the side effect to prove it.

## [7.27.1] - 2026-08-05

A test that fails when the machine is busy is not a guard. It is a coin flip
with a stack trace, and this suite had a hundred and thirteen of them.

**The story is the root cause, and it is not "CI is slow."** Every one of these
assertions had the same shape — `expect(elapsed).toBeLessThan(200)` — and that
shape cannot express the thing it was written to defend. It measures how long
the machine took, and the machine is shared: the suite runs beside a build, a
coverage pass, and two other vitest workers. Identical code takes three to five
times longer under that load with nothing about the code having changed. So the
assertion cannot tell "we got slower" from "the box was busy", and it fires
_exactly_ when CI is busiest. Five of them had already been logged as flakes —
`xray` P6, `withCircuitBreaker` P6, `locales/messages` Block D,
`consumer-domain-events`, `SkillRegistryOptions` — always under concurrent
build load, always passing in isolation. The failure mode of a guard nobody
trusts is not a red build. It is that everyone re-runs it without reading it,
and the day it means something, nobody notices.

**Every wall-clock budget in the suite is now stated in a form that machine load
cannot move.** Not the five that had been caught — all of them, found by
sweeping for `performance.now()` / `Date.now()` differences and millisecond
ceilings rather than by waiting for the next one to fire. `test/helpers/perf.ts`
holds the three honest forms, in order of preference:

- **A ratio between two runs of the same operation** (`expectScalesLinearly`) —
  the operation at ten times the input must cost about ten times as much. This
  is the strongest form because it states the actual claim ("no quadratic
  rescan"), and it is immune to machine speed entirely: whatever slows the large
  run slows the small one it is measured against. Seventy-eight of the
  assertions became this.
- **A ratio against a sibling** (`expectWithinTimes`) — the same run without the
  recorder, without the pricing table, without the permission checker, timed
  moments earlier on this machine. Used wherever the claim was already the word
  "negligible", which is a comparison that most of these tests never actually
  made.
- **A CPU yardstick** (`expectWithinReferenceUnits`) — where there is no natural
  sibling, the budget is N units of a fixed slab of CPU work timed in the same
  process, moments before the assertion. A runner that is four times slower
  today gets a four-times-larger ceiling; a real regression still trips it.
  Twenty-four assertions; eleven more are sibling ratios.

Seven sites kept a millisecond ceiling on purpose and say so where they stand,
because their number is a CONFIGURED VALUE rather than a guess about the machine
— a mock's own thinking band, a slow branch's own delay, a retry's own backoff
budget, `realistic()`'s own default floor. And a handful lost the clock
altogether in favour of the count that was the real claim: the skill loader now
proves it reads every file exactly once instead of proving the disk was fast
that morning.

**A ratio alone was not enough, and finding that out is half the work in this
release.** The first cut of the helper compared two single measurements, and it
still flaked when actually run under the reproduction condition. Three things
had to be added, each because the proof run said so:

- **Repeat until the sample is worth timing.** Below a scheduler quantum, one
  preemption _is_ the measurement: a 0.2ms operation that gets descheduled
  reads as a hundred times its real cost, while the 100ms operation beside it
  absorbs the same theft as a rounding error — and load stops cancelling. Each
  operation is now repeated inside one sample until the sample clears 20ms, and
  what is compared is cost per repetition.
- **Take the fastest of several samples, alternating.** The fastest sample is
  the one the scheduler interrupted least. Alternating between the two sides
  means a slow patch of machine time lands on both, not on whichever ran
  second — and it disarms the classic false pass where the small run is served
  from a cache the large run had to fill.
- **Say when the machine itself moved.** The yardstick is timed on both sides
  of every measurement. If the machine's own speed drifted 4× mid-comparison,
  the ceiling is widened by exactly that much and the failure message says
  "contention, not code". On a quiet machine the factor is 1 and nothing
  changes, which is where a real regression gets caught. A guard that knows
  when it cannot measure beats one that guesses.

Sampling costs time, so it buys the loop counts back: measurements that used to
brute-force 50 agent runs now run 8 and sample instead, and a per-side time
budget stops an expensive operation from being run five more times just to
satisfy the sampler. Total suite time moved from ~42s to ~48s.

Where the honest answer was a **count**, the clock is gone entirely: a cached
translator is invoked exactly once however many times you read it; an open
circuit breaker calls its provider exactly once across eleven thousand
rejections; a sync provider never returns a Promise; a no-op structure recorder
hears each stage exactly once. Those facts are true on any machine under any
load, which is what makes them worth asserting.

**No perf claim was deleted. The form changed; the meaning stayed** — and in
four places the meaning got sharper, because writing the claim down properly
exposed what it had actually been asserting. Three sites keep a millisecond
ceiling on purpose and say so at the site: they are stated against a _configured
delay_ (a mock's own thinking band, a slow branch's own timeout, a strategy's
own per-event block) rather than against a guess about the machine, because
"did not sleep longer than it was told to" has no cheaper form.

**Two of the conversions found real things**, which is the argument for the form
better than any prose could make it:

- `assignCostVerdicts` is quadratic in suspect count. Not a defect — the
  leave-one-out placebo band re-derives itself per suspect, which is what
  "leave one out" means — but it is bounded only by
  `CONTEXT_BISECT_DEFAULTS.maxSuspects` (12), and nothing said so. The test now
  guards a bounded size at twenty-five times the shipped cap and explains why
  there is no ratio to assert.
- `CommitRangeIndex.enclosing()` is a full array walk with a sort, by design, in
  footprintjs. A ratio there would have looked like a lookup claim and meant
  nothing. That test is a bounded-size CPU budget now, and says which library
  owns the cost shape.

A third was a claim of ours that was simply false: `gatedTools.list()` was being
compared against `staticTools.list()`, which hands back a stored array. "One
extra pass" is infinitely more than zero, so that ratio could never mean what it
said. It is a linearity claim now.

A fourth is written down rather than fixed, because it is a real measurement and
not a test problem: **`Parallel`'s per-branch cost stops being flat somewhere
past a hundred branches.** On a quiet machine, per branch: 10 → 0.59ms, 30 →
0.57ms, 100 → 0.87ms, 300 → 2.57ms. A three-hundred-branch fan-out costs about
thirty times a thirty-branch one, not ten. Whether that is the engine's fan-out,
the merge, or simply three hundred promises in flight is a separate question;
the scaling test now guards the range where the claim holds and records the
curve at the site, instead of hiding it inside a slack multiplier that would
have made a bigger test pass while meaning nothing.

**The composition tests lead with a count, not a clock.** A Sequence of forty
steps runs forty completions; a Loop of forty iterations runs forty; a
thirty-two-branch Parallel runs thirty-two. A quadratic walk that re-enters
children shows up in that number on any machine at any load — the ratio beside
it is there for the quadratic that costs without re-executing.

Proven the only way it can be proven: the full suite three times
**concurrently**, with `npm run build` looping beside it — the reproduction
condition, which oversubscribes the machine several times over. Every
conversion was measured under it and revised until it held there, which is how
each of the three refinements above got found in the first place.

Two admissions, both at the site. The converted tests state their own
**timeout**, because sampling costs time and the runner's five-second default is
itself a wall-clock budget with the same defect. And they carry **`retry`**,
which is the last mile: everything above removes the systematic distortions, but
not the chance that one run drew three unlucky samples. Retrying costs no
strength — a real regression is deterministic and fails every attempt, while
contention has to win three times in a row — and it was added only after the
conversions already held on their own.

### Also in this release

**`WindowStrategy` meant two different things, and the release gate was right to
refuse it.** The package root exports the conversation-window seam
(`{ name, plan(input) }`, public and frozen since 7.17.0). `agentfootprint/memory`
exported a memory config record under the same name (`{ kind: 'window', size }`).
Same word, incompatible shapes, two entry points — a trap for anyone importing
from the wrong one. The memory one is now **`MemoryWindowStrategy`**, and
`agentfootprint/memory` still exports `WindowStrategy` as a deprecated alias, so
no import breaks. The rename went to the memory side because that name was never
meaningfully public there: it appears in no guide, no example, no release note,
and not in the generated API reference at all (typedoc covers the root barrel
only). A compile-level regression test pins all three facts — the new name, the
old alias, and that the two types were never assignable to each other.

**AgentCore's browser bearer handshake was reading a spelling nobody
documented.** The `/ws` door looked for `bearer` and `bearer.<token>` —
words this library invented. AWS documents one scheme and one only: the token
base64url-encoded and prefixed with `base64UrlBearerAuthorization.`, followed by
the sentinel subprotocol `base64UrlBearerAuthorization`, with "subprotocols
other than `base64UrlBearerAuthorization` … not yet supported". So a real
browser handshake matched neither spelling and the mapping returned `{}` — the
credential silently dropped, which is the same failure shape as a stored session
that reads back as nothing. Now: the documented pair is read, the base64url
wrapper is undone before the token becomes `Bearer <jwt>`, and the **sentinel**
is echoed in the client's own spelling because RFC 6455 lets a server select
only what the client offered — never the dotted value, which would put the
credential in a response header. Two shapes refuse the upgrade by name rather
than degrade: a dotted value that is not valid base64url (a token that does not
decode is not a credential), and a dotted value offered without the sentinel
(there is then nothing safe to echo). The tests pin AWS's own literal example
strings, so the day their spelling moves, the suite says so. The invented
spellings are gone rather than kept beside the real one — a door nobody can walk
through should not be advertised as one.

**The AgentCore Memory docs described an API that has not existed since 7.15.**
The mapping table named `PutMemoryEvent` / `GetMemoryEvent` /
`DeleteMemorySession` — none of which the adapter calls — and said `search()`
was "not exposed", which stopped being true in 7.15. Rewritten against the
shipped adapter: `CreateEvent` / `ListEvents` / `DeleteEvent`, the identity split
across `actorId` and `sessionId`, the two operations that cost O(events in
session) because AgentCore assigns its own event ids, `putIfVersion` emulated,
no `stream()`, and `search()` documented for what it actually is — server-side
retrieval that takes the query as **text** in `options.text`, throws rather than
returning an empty list when it is missing, and returns AgentCore's own extracted
records rather than the entries you `put()`. Three sibling pages carried the same
stale "AgentCore has no `search()`" claim and are corrected too, including one
that promised a build-time throw that no longer happens — causal memory on an
AgentCore store now passes the build check and fails at the call instead.

### Fixed

- **`/ws` browser OAuth on AgentCore Runtime.** The bearer subprotocol mapping
  now reads the vendor's documented scheme; the previous spellings could not be
  produced by any browser through that front door.
- Duplicate exported type name `WindowStrategy`, which blocked the release
  gate's duplicate-type check.

### Changed

- **Tests only:** every wall-clock performance budget is now a ratio, a count,
  or a CPU-yardstick budget. No public behaviour changes.
- **Tests only:** a handful of long-running tests state their own timeout
  instead of inheriting the runner's 5-second default. That default is a
  wall-clock budget like any other, with exactly the defect this release is
  about: a property sweep over real agent runs, a 44,850-pair lint, a
  200-iteration loop and a barrel import all do seconds of honest work, and on a
  contended runner the default was failing them for being on a busy machine.
  The assertions in those tests were already counts.
- `agentfootprint/memory` exports `MemoryWindowStrategy`; `WindowStrategy`
  remains as a deprecated alias.

## [7.27.0] - 2026-08-05

A production integration ran an agent on a shared socket, with a co-listener of
their own reading requests beside it. The co-listener called
`req.setEncoding('utf8')` — an ordinary thing for a framework to do before it
decides a path is not its own — and the container died.

Not the request. The container.

`readJson` collected chunks and called `Buffer.concat`. With an encoding set,
node delivers those chunks as STRINGS, and `Buffer.concat` on strings throws —
inside the `'end'` listener, which node calls from its own stack. A throw there
is not a rejected promise anybody awaits and not a failed request anybody
answers: it is an uncaught exception. One perfectly ordinary request took down
every other request in flight, every open conversation, and everything else that
container was serving.

The two-line fix is to coerce the chunk. **The release is the law it exposed.**

> **Nothing in a request's lifecycle may ever be the process's failure.**

That sentence is now stated at the site where the field found it and enforced
across every listener body in the hosting layer that computes. Not just the line
that broke — the whole class. A dialect that throws while reading a request, a
health body that throws, a body that will not stringify, a handshake dialect
that throws, an abort listener that throws while being told the caller left:
each of those is now the failure of the thing that caused it. A 400, a 500, one
refused upgrade, one ended conversation. Never the process.

One of them was not even a throw. `serveOne`'s promise is held in a Set and
voided at the call site, so anything that escaped it was an _unhandled
rejection_ — which on node's defaults is the same dead container reached by a
different road. It is total by construction now, and says so.

**The audit's other finding is that the conversation door was already safe, and
for a reason worth writing down.** The same co-listener cannot do this to an
upgraded socket: node itself refuses `setEncoding` there
(`ERR_HTTP_SOCKET_ENCODING`, "not allowed per RFC7230 Section 3"). So the frame
reader reads bytes by the transport's own rule rather than by this door's hope —
and because that reason lives in node and could change there, it is pinned by a
test instead of asserted in a comment.

**And the inverse seam, from the same report.** `{ server }` — lend the host a
socket you bound — is the right answer when you have a protocol of your own to
serve. It is a great deal of ceremony when all you wanted was a `/debug/trace`
beside the agent on the one port the container was given.

```ts
nodeHost({ port: 8080, onUnhandled: (req, res) => myRouter(req, res) });
```

Same single port, opposite direction: the host binds the socket as it always
did, and every path it does not own is handed to your code **instead of** its 404. The host still never answers for your application — with this hook it no
longer has to 404 for it either.

What it never receives is the interesting half. The paths the host owns —
`invokePath`, `healthPath`, `conversationPath` — never reach it, _including a
wrong method on one of them_, because a hook that could claim `POST /invoke`
would be a second door wearing the first one's name. And it is refused at
construction beside `{ server }`, by name: there, unmatched paths already fall
through to your own `'request'` listeners, so a second way to answer them would
make the winner depend on the order two listeners were registered in. Two
answers to one question is the confusion the refusal prevents.

**Upgrades deliberately do not travel through it**, and the reasoning is on the
page rather than in somebody's head. `onUnhandled` is handed a `ServerResponse`
to write; an upgrade has none — it has a raw socket and a handover to perform by
hand. The field case is diagnostic HTTP routes, so an unclaimed upgrade on a
private socket keeps exactly the answer it always had (400, socket closed), and
a differently-shaped second hook for a case nobody has asked for is how a port
grows a surface it cannot explain. `{ server }` remains what it always was.

**Nothing changes for anyone who does not opt in.** No `onUnhandled` means the
same 404 as before, byte for byte, and that is pinned by comparing two live
hosts rather than by care.

### Added

- **`onUnhandled` on `HttpHostOptions`**, threaded through `nodeHost` and
  `agentCoreRuntimeHost` — `(req, res) => void`, called in private-server mode
  for any path the host does not own, instead of the host's 404. Your code, on
  the host's socket, with the request and response exactly as they came off the
  wire. Refused at construction beside a caller-owned `server`. A throw inside
  it is that request's 500; a hook that answers nothing leaves the request
  hanging until it times out — the same price, for the same reason, as the
  missing 404 on a caller-owned server.

- **`examples/deploy/own-routes.ts`** — the field scenario, runnable: the agent
  on `/invoke`, the conversation door on `/conversation`, and a `/debug/trace`
  of the caller's own, all on ONE port with no server to create. It breaks a
  route on purpose to show what that costs (one 500) and what it does not (the
  agent, still answering).

- **`CLOSE_CODE.internalError`** (1011), internal to the conversation door — the
  RFC's own word for "this end could not fulfil the request", used where the
  fault is demonstrably not the peer's, so a close code never blames them for
  something that happened on this side.

### Fixed

- **A request body read on a shared socket no longer kills the process.** String
  chunks are coerced back to bytes, and no bytes are lost doing it: `setEncoding`
  decodes through a `StringDecoder`, which holds a partial multi-byte sequence
  across a chunk boundary rather than splitting it. Pinned by writing a body in
  two TCP writes with the split placed _inside_ a four-byte character and
  asserting it round-trips. Reachable only through `{ server }` — the mode built
  for co-listeners — and reproduced there, with a real second listener on a real
  shared socket rather than a stubbed request.

- **Every listener body in the hosting layer that computes is contained.**
  `'request'`, `'upgrade'`, `'data'`, `'end'`, `'close'` and the response's own
  `'close'`: a throw in any of them is answered to the request, the upgrade or
  the conversation that caused it. `serveOne` no longer rejects at all — an
  unhandled rejection was the same crash by another name. Where the ordinary
  reply path is itself what broke, the refusal is written in the one shape
  nothing can refuse: `{ error }` with a 500.

- **A conversation-handshake dialect that throws refuses that upgrade** with a
  `500` naming the host, and the door keeps carrying everybody else's
  conversations. Previously it was an uncaught exception on node's stack.

### Notes

- **With a framework that installs a catch-all handler (Fastify, Express), the
  framework answers first and the attached host never sees the request** —
  register the framework's routes as a delegation to the host, or let the host
  own the socket and use `onUnhandled` for your own routes. This is now beside
  the hang-cost callout in the hosting guide, where the same class of surprise
  already lived.

- **The conversation door needed no fix, and that is a finding rather than an
  omission.** Node refuses an encoding change on an upgraded socket, so the
  frame reader cannot be handed text. The test that says so exists because the
  guarantee is node's, not this library's.

## [7.26.0] - 2026-08-05

`toolArgValidation` has spent several releases doing something quietly
remarkable at the other end of a tool call: when the model writes arguments
that do not match the schema, the call is not dispatched and the model is
handed a structured explanation — path, expectation, received type — which it
reads on the next iteration and corrects. Validation that TEACHES rather than
merely refuses.

`outputSchema` could only refuse. It judged the agent's final answer after the
run was over, at the caller's boundary, and turned a bad shape into an
exception. That is a fine place to reject an answer and a useless place to fix
one: the loop has stopped, the model is gone, and all the caller can do is
throw or substitute a canned value.

**This release gives the answer side the same teaching that the arguments side
has had all along.**

```ts
.outputSchema(Refund, { retries: 2 })
```

A failed answer and an authored correction join the conversation, and the ReAct
loop turns again.

**The failed answer goes back with the correction, and that finding shaped the
feature.** Nothing writes the answering turn into `history` — the loop appends
an assistant turn only when it carries tool calls, and the turn that ends the
run carries none. So a correction sent on its own would have arrived at a model
that could not see what it said: teaching into the void. Both messages go, in
the order they really happened, which is also what makes the retry legible
afterwards — the conversation says what was answered, what was wrong with it,
and what came back.

**Each retry is a REAL turn, and that is the entire architectural argument.**
The re-ask is a third branch of the Route decider carrying the same `{ loopTo }`
the tool branch carries, so it re-enters the ordinary loop: the injection engine
re-evaluates, the slots recompose, the cache decides, the model is called. The
attempt therefore gets its own `stream.llm_start` / `llm_end` bracket, its own
`cost.tick` against `costBudget`, and its own row in the ledger.

Compare the in-stage schema retry the reliability gate has done since v2.13,
which is kept and still recommended when the rules also need failover or
circuit breaking: N attempts there share ONE bracket, and `emitCostTick` fires
once carrying only the last attempt's usage. Those retried attempts were
genuinely billed and completely invisible. A library whose product is the
recording does not get to bill you for turns it does not show you.

A retry consumes an iteration, and that is documented rather than worked
around. The alternative was a parallel counter, which would have meant two
`iteration_start` events with the same `iterIndex` — and every recorder that
synthesizes steps, plus the crash-checkpoint tracker, counts on that pairing.
The loop already had a word for "one more turn"; inventing a second one to
avoid admitting the cost would have been an accounting trick.

**The corrective message is an authored frame with the validator's error as
DATA.** The library's own words come first and say that what follows is a
report about the answer rather than an instruction; the error is quoted
verbatim; and _nothing authored follows it_, so there is no trailing sentence
for injected text to pre-empt. A schema whose error message reads "IGNORE ALL
PREVIOUS INSTRUCTIONS" produces a message that still says, first and in the
library's voice, what it is. This is exactly the compaction frame's rule
pointed at the other untrusted string this library quotes, and it is pinned by
a test that sends a hostile error through the whole loop.

**The event was asked for, and that is why it exists.** The state-vs-event rule
says committed state is the default and an event needs a reason; here the
reason is a person asking to be able to watch retries happen. So both:
`agentfootprint.agent.output_schema_retry` fires per failed attempt, and
`snapshot.sharedState.outputAttempts` keeps one row per final-answer attempt —
joined by the corrective message's hash, so a live subscriber and a stored
recording are talking about the same message.

**And the second half: `strategy: 'tool-forced'`,** which presents the schema
as a synthetic tool and forces the provider's tool choice, so the shape is
constrained at generation instead of requested in prose. Two refusals guard it,
and both were the interesting design work.

Forcing the choice BY NAME means no other tool can be called on any turn — so
an agent with tools would go silently single-shot: config that lies in the
other direction. That combination is refused at build, naming both honest
paths. A `tool_choice: 'any'` variant would keep the tools working, but it
would not keep the guarantee on any given turn; it is a different feature
wearing the same name, and if evidence ever asks for it, it arrives under its
own word.

The second refusal is about the shape itself. A tool carries its input schema
as JSON Schema, and this library has never converted a validator into one.
A parser that can render itself (ArkType's `toJsonSchema()`) is asked; anything
else must be handed `jsonSchema` explicitly. Guessing what somebody's schema
means is not a thing the library gets to do.

`jsonSchema` and the parser can disagree, and nothing here prevents it —
because the system stays honest when they do. The forced shape satisfies the
wire, the parser still judges the answer, and a disagreement surfaces as an
ordinary validation failure that the retry loop corrects with the validator's
words. The schema constrains generation; the parser remains the judge.

**Nothing changes for anyone who does not opt in.** `retries` defaults to `0`,
enforcement is a conditional mount, and an agent without it has no branch in
its chart, no key in its commit log, no event, and byte-identical request
bytes — pinned against 7.25 by test, not by care.

### Added

- **`.outputSchema(parser, { retries })`** — corrective re-asks, capped and
  stated. On failure the loop routes to a new `'output-retry'` branch which
  appends the failed answer plus an authored correction and loops back to the
  same target the tool branch uses. Exhaustion leaves the last answer standing
  and `runTyped()` throws `OutputSchemaError` exactly as before;
  `.outputFallback()` composes on top unchanged. The ceiling is 10, and the
  refusal above it says why: a model that has missed the shape ten times
  running is not about to find it.

- **`.outputSchema(parser, { strategy: 'tool-forced', jsonSchema })`** — the
  schema as a synthetic tool with the provider's choice forced. The tool is
  assembled at request time and exists nowhere else: not in `.tools()`, not in
  the tools slot or `tools.offered`, not on an MCP server's served list, not in
  the dispatcher that runs tools and files middleware rows. It DOES appear in
  `stream.llm_start`, whose claim is what the model actually saw.

- **`LLMRequest.toolChoice`** (`{ type: 'tool', name }`) and
  **`LLMProvider.carriesForcedToolChoice`**. One arm on the port, one dialect
  per wire: Anthropic `{type:'tool',name}`, OpenAI
  `{type:'function',function:{name}}`, Bedrock Converse
  `toolConfig.toolChoice.tool.name`. **Absence of the capability means NO** —
  the opposite of `carriesInMessages`, whose absence means the floor — because
  a tool choice that quietly vanishes costs the guarantee the strategy was
  selected for. Declared by Anthropic (+ browser), Bedrock, real OpenAI/Azure
  (+ browser) and the mock; deliberately NOT declared behind a custom `baseURL`
  (Ollama, vLLM, Together), since what an OpenAI-compatible server does with
  `tool_choice` is that server's promise to make. `withRetry` and
  `withCircuitBreaker` forward it; `withFallback` publishes the **AND** of its
  pair, since either side may serve the call.

- **`agentfootprint.agent.output_schema_retry`** — one per failed attempt,
  carrying `{ attempt, retriesRemaining, iteration, stage, error, path?,
correctiveMessageHash }`. 69 typed events across 20 domains. It sits in the
  `agent` domain beside `output_schema_validation_failed`, its in-stage
  sibling; a new domain for one event that has a family home would have been
  taxonomy for its own sake.

- **`snapshot.sharedState.outputAttempts`** — `OutputAttempt[]`, one row per
  final-answer attempt: `attempt`, `iteration`, `outcome`
  (`'passed' | 'retried' | 'exhausted'`), and on a failure the validator's own
  message plus the corrective message's hash. Written only by an agent that
  opted in.

- **`SCHEMA_CHECK_FRAME_PREFIX`, `SCHEMA_TOOL_NAME`, `isSchemaCheckMessage()`,
  `OutputAttempt`, `OutputSchemaStrategy`** on the main barrel — so a reader,
  a test or a UI can recognise the two things this feature puts into a
  conversation without matching on prose.

### Changed

- **`agent.route_decided.chosen` widens with `'output-retry'`.** It appears
  only on an agent that opted into retries, and only on a turn whose answer
  failed with retries left. Reporting `'final'` for a turn that is about to ask
  again would have been the one thing this event must never do.

- **`STAGE_IDS.OUTPUT_RETRY`** joins `conventions.ts` as a `boundary` role and
  a `'decision'` milestone labelled "Schema retry" — the run deciding its own
  answer was not good enough is exactly what a reader came to see, so it is not
  muted as plumbing.

### Notes

- **`.reliability()` and `{ retries }` layer rather than collide**, and the
  ordering is pinned: the in-stage gate decides FIRST, about a response before
  it is committed, and `retries` governs answers that WERE committed and turned
  out invalid. With reliability configured and no rule for `'schema-fail'`, a
  bad shape still fails the run as it always did — `retries` does not rescue it,
  because the gate never committed an answer for the loop to govern. The one
  case where both genuinely fire is an output `messageMiddleware` that rewrites
  the answer after the in-stage check: the decider judges what the CALLER will
  receive, which is the only honest place to judge it.

- **A denied answer is never judged and never re-asked.** When an output
  middleware returns `deny`, the run raises as before; asking the model for a
  better-shaped version of a withheld answer would be the library routing
  around a rule the app wrote.

## [7.25.0] - 2026-08-04

A production integration deployed an agent that operates the user's browser. The
browser cannot host an inbound endpoint, so it dials out and parks a connection
that the agent pushes tool calls down — and they said the thing that became the
whole design brief: **"`HostRequest → HostReply` is one exchange, and this door
is a conversation."**

They were right, and the honest answer was not to widen the request port until
it could pretend. A request has one reply and then it is over. A conversation
has neither side taking turns by rule, no reply count, and an end that either
side can call. So this release ships a second port beside the first.

```ts
const host = nodeHost({ port: 8080 });

await standingAgent({ agent, sessions, host }); // POST /invoke
await host.serveConversations((conversation) => {
  // WS   /conversation
  conversation.onFrame((frame) => conversation.send(answer(frame)));
  conversation.onClose(({ by, reason }) => log(by, reason));
});
```

**The anti-bias law, applied harder than last time.** The request port stayed
honest because it was designed against local adapters first and the cloud
adapter arrived as wire config. This one was designed against **three** consumers
at once — a browser-parked tool channel, a standardized agent↔UI protocol, and
agent-to-agent task serving — with the rule that a decision which only makes
sense for one of the three is wrong. That rule is why frames are **strings**
(what they mean is the consumer's contract, not the port's), why
`ConversationClose` says `{ by: 'far-side' | 'host' | 'transport' }` and carries
no transport's numeric code, and why a credential that one runtime spells as a
WebSocket subprotocol arrives in your handler as an ordinary `authorization`
header rather than as a field on the port.

**Declared ceilings, not hidden ones.** `host.conversationLimits` says what a
door caps — `maxFrameBytes`, `idleMs`, `maxPendingBytes` — and the port neither
chunks nor heartbeats. Hiding a 32KB cap inside auto-chunking would have the
adapter deciding, for every consumer at once, how a message is split, how the
pieces are numbered and how the far side knows the last one landed. Those
answers differ per consumer, so the ceiling is made VISIBLE and the layer above
acts: chunk above the port, heartbeat above the port. A frame past the ceiling
refuses by name (`FrameTooLargeError`, carrying the number), and a fragmented
message counts in total, so fragmentation cannot walk around it.

**A real WebSocket server, with nothing to install — and here is why that was
worth writing.** The obvious shortcut was an optional peer dependency. It was
rejected on a property of this library rather than on lines of code:
`capabilities` is declared at construction and static thereafter, so a host
whose door only works when an optional package happens to be installed can
either claim `'conversation'` and then refuse to do it — the library declaring a
promise it cannot keep, which is the one thing the capability union forbids — or
probe `node_modules` and make feature detection depend on install state. Neither
is a thing this library gets to do. So the codec is ours, its scope is closed
(handshake, text, continuation, ping/pong, close; no extensions, no compression,
no binary, no client role), and it is verified against **the byte sequences RFC
6455 §5.7 publishes** — the encoder against bytes the specification wrote, the
decoder against bytes a real client sends, rather than against a client we also
wrote. It is **not** run against the Autobahn suite, and the docs say so in the
same breath.

**One socket, two doors** — which is the release's one change to existing
behaviour, and the finding that made it necessary. `serve()` and
`serveConversations()` on the same host now share a refcounted socket: first door
in creates and listens, last door out drains and closes. Before this, a second
`serve()` bound a second socket, so "an agent and a conversation on one port" —
the deployment this entire feature exists for — was `EADDRINUSE`. The doors are
independent in both directions and only the last close releases the port; three
tests pin exactly that.

### Added

- **The conversation port** — `HostConversation` (`sessionId?`, `headers?`,
  `send`, `onFrame`, `onClose`, `close`), `ConversationHandler`,
  `ConversationHost`, `ConversationClose`, `ConversationLimits`, and
  `'conversation'` in `HostCapability`. The capability joined the union because
  two shipped adapters honour it, not because it was imagined — the same law
  7.14 wrote down.

- **`host.serveConversations(handler)`** on every adapter built on `httpHost`
  that was given a `conversationPath`. `nodeHost` serves `/conversation` (its
  own word, chosen the way its other two paths were) and declares
  `{ maxFrameBytes: 1048576, maxPendingBytes: 1048576 }`. A host built without a
  `conversationPath` does not declare the capability and refuses by name — no
  default path here, for the same reason `invokePath` has none.

- **`agentCoreRuntimeHost` serves the runtime's `/ws` door**, on the same socket
  as `/invocations`, with that runtime's facts declared in that runtime's
  adapter: ceilings `{ maxFrameBytes: 32768, idleMs: 900000 }`, session affinity
  from the shipped header **or** the query string (a browser's WebSocket API
  cannot set a header; the header wins when both arrive), and a
  `Sec-WebSocket-Protocol` bearer mapped into `headers.authorization`. Both
  browser-expressible spellings are read, and the echoed subprotocol is the word
  `bearer` and never the token. `readAgentCoreConversation` is exported so the
  mapping is reviewable without binding a socket.

- **`ConversationClosedError`** (`ERR_CONVERSATION_CLOSED`) and
  **`FrameTooLargeError`** (`ERR_FRAME_TOO_LARGE`, carrying `bytes` and
  `maxFrameBytes`). A send that is accepted and dropped looks identical to one
  that worked, from the only side that could have noticed.

- **`HttpWire.readConversation(facts)`** — the handshake half of a deployment's
  dialect, returning `{ sessionId?, headers?, protocol? }`. There is no body in
  a handshake, so a dialect that wants a session id has to name where it looks.

- **The conversation conformance suite**, run against three subjects: `nodeHost`'s
  upgrade door, an in-process conversation host with different declared
  ceilings, and `agentCoreRuntimeHost`'s `/ws`. One handler constant, three
  hosts, byte-identical answers. `/ws` is plain WebSocket with no SDK on its
  path, so **that result is real verification** — the same sense 7.15 used the
  phrase for `/invocations`.

- **[The conversation door](https://footprintjs.github.io/agentfootprint/docs/infrastructure/hosting-and-runtime)**
  in the hosting guide, and the `/ws` wire on the AgentCore adapters page. Plus
  `examples/deploy/echo-conversation.ts`: two turns over one open channel, an
  ordinary `POST /invoke` on the same socket, both refusals, and `onClose`
  reporting who ended it.

### Fixed

- **An upgraded socket that the far side abandons is reported, and released.**
  Node delivers `'end'` on a half-open upgraded socket and then waits for this
  side to end too — no `'error'`, and no `'close'` until something acts. A door
  listening only for `'close'` would never fire `onClose` for a caller that
  walked away, and would hold a socket that keeps every shutdown sharing that
  port waiting. Found by the test that asserts a dropped connection is reported
  as `by: 'transport'`.

- **`examples/deploy/one-port.ts` said something untrue about upgraded sockets.**
  `server.close()` does not end one for you — that part was right — but it does
  **wait** for it, and `closeIdleConnections()` does not count it as idle. The
  comment now says both halves, because a graceful shutdown has to destroy its
  own upgraded sockets.

### The judgements these rest on

**`standingAgent` is NOT conversation-aware, deliberately.** The three consumers
push different things down a channel — tool calls out, UI events in, task
updates both ways — so baking one loop into the composer would be exactly the
bias the port was designed to avoid. This release is a port plus two adapters;
the browser-tool loop, the agent↔UI framing and agent-to-agent serving are each
their own release, each consuming this same door. Three proofs, three cars, one
door. The absence is pinned twice: a type-regression test fails the build the
day a conversation key appears on the composer's options, and a runtime test
serves a standing agent on a host whose conversation door throws if it is ever
touched.

**The pre-subscribe buffer is a ceiling too, so it got a number.** Frames that
arrive before the handler's first `onFrame` subscriber are held and delivered —
otherwise an `async` handler that looks something up before it starts listening
loses the far side's opening frame, which on a channel whose first frame is a
greeting is every conversation. But a queue somebody else fills and this process
pays for is a way to kill the process. So it is bounded by `maxPendingBytes`,
declared beside the other ceilings, and overflow ends the conversation naming
the bound. Bounded in **bytes** rather than frames: a frame count would still
admit `count × maxFrameBytes`, which is the same unbounded queue with an extra
step.

**A binary frame is refused by name rather than stringified.** The port carries
text; binary is a capability that gets minted when a consumer produces evidence
for it, not guessed at now. A binary frame ends the conversation with a reason
that says exactly that.

**`{ server }` was not made redundant.** A conversation door means you no longer
need a caller-owned server merely to get a WebSocket beside the agent. It
remains what it always was: the way to serve anything the ports do not express.
A port is a paved road, not a wall.

## [7.24.0] - 2026-08-04

This library keeps measuring the same disease and shipping cures for it one
organ at a time: **a capability nobody can find is a capability nobody has.**
The skill router exists because a tool the model cannot see is a tool that does
not exist. The context ledger exists because a piece of context nobody can
account for might as well not have been sent. And an agent's own governance had
the same problem, in the place where it costs the most: the answer to "what
does this agent do at each moment of its turn?" was four unrelated builder
calls, scattered through a chain, with no place to be answered.

So this release does two things, and the second is the reason for the first.

**`.act()` — one block, five keys, one per moment of the loop.**

```ts
Agent.create({ provider, model })
  .act({
    input: [scrubSSNs], // the message, before the run commits it
    beforeTool: [refundCeiling, fourEyes], // every call, before it is dispatched
    afterTool: [stripPII], // every result, before the model reads it
    window: slidingWindow({ keepRecentTurns: 12 }), // what the live window keeps
    output: [noCodenames], // the answer, before the caller gets it
  })
  .build();
```

Governance at a glance: one thing to read in review, one thing to diff, and
autocomplete on an empty `{}` that teaches the loop rather than requiring you
to already know it. It builds nothing new — every key is forwarded to the door
that already owned it, and the equivalence is pinned **per key** against the
hand-written spelling, the way `.compaction()`'s was: same requests on the
wire, same rows in the ledger.

The canonical path is preserved by **demoting the doors, not deleting them**.
`.toolMiddleware()`, `.messageMiddleware()`, `.window()` and `.compaction()`
are unchanged and stay open, and they are now documented under _Composing
incrementally_ — because adding one rule to an agent somebody else built is a
real job, and a bundle that must be written all at once cannot do it. That
division is the one-sentence answer to "which spelling": **`.act()` for an
agent you own, a door for a piece you are adding to somebody else's.** A second
`.act()` throws, since two posture blocks put the answer in two places with the
later one silently winning.

**And the keys cannot fall behind the loop.** `LoopMoment` is exported, and the
bundle's keys are type-locked against it in both directions, camel-cased by
type-level string manipulation rather than a hand-written pair table. Ship a
sixth moment without a key and OUR build fails naming it. The runtime validator
that decides which keys `.act()` accepts is derived from the same list, so it
cannot drift from the type it is validating. A surface that claims to be
complete has to be made unable to fall behind, or the claim is just a sentence
in a doc.

**The after-tool moment, which 7.18 deferred.** A tool middleware may now carry
an `onToolResult` hook that runs once the tool has executed and **before its
result enters the history or reaches the model**. Two verbs there — `allow()`,
`allow(value, why)`, `deny(reason)` — and the two halves are ONE chain walked
in onion order: the first-declared rule gets the first word going in and the
last word coming out.

There is deliberately **no `ask` at the after-tool moment**, and that is a refusal rather
than an omission. The machinery is right there; the tool has already run, so a
person woken to answer cannot prevent anything. Every reviewed business case —
authorize before, hide from the model, annotate the result, attach a fact
through a trigger — needed the two verbs and none of them needed a person. The
absence is recorded at the type and in the docs so evidence can promote it: a
case that genuinely needs a human at that moment gets the arm, on the pause
machinery that already exists.

### Added

- **`.act({ input?, beforeTool?, afterTool?, window?, output? })`** — the
  canonical door. Pure sugar over the five existing ones, callable once, with
  unknown keys refused by name rather than ignored (a key nobody reads is a
  governance rule that silently never runs). A bundle that fails validation
  leaves the builder exactly as it found it.

- **`ToolMiddleware.onToolResult`** — the result moment. The moment is named
  `'after-tool'` (and `.act()`'s key after it) because that is WHEN it happens;
  the hook is named for WHAT IT RECEIVES, which is how it pairs with
  `onToolCall`. Its context is the call
  context plus `result`, plus `error: true` when the tool threw, with `args`
  as the tool ACTUALLY RAN WITH them. `toolSource` is present here too, so a
  rule about a server's answers is as writable as a rule about its calls.
  `ToolMiddleware` is now a union: at least one of `onToolCall` /
  `onToolResult`, so a rule with a name and no hook does not compile. A link
  with only `onToolResult` takes no part in dispatch — no walk, no ledger row at the call moment, since it
  decided nothing there.

- **`LoopMoment`, `LOOP_MOMENTS`, `actKeyFor`, `ActKey`, `ActOptions`,
  `ACT_KEYS`** — the moment vocabulary and the bundle, exported so a UI that
  renders "what does this agent do at each moment?" can enumerate the moments
  instead of hard-coding five strings.

- **`MiddlewareDecision.moment`** and the same field on the
  `agentfootprint.middleware.decision` payload — every row now says WHERE IN
  THE LOOP it came from, in the same words the key is named for. The 7.18
  `at` / `phase` fields are committed state, are still written, and still mean
  what they meant; `moment` is the newer spelling and the one to narrow on.

- **`allow(undefined, why)`** — a pass-through that carries a reason. The row
  still reads `changed: false`, because nothing moved. It is what an
  approve-once rule needs: a call that sails through on a remembered decision
  files a row saying **whose** decision it sailed through on, so "why did this
  run without asking?" has an answer in the record rather than in somebody's
  memory.

- **[The moments of the loop](https://footprintjs.github.io/agentfootprint/docs/build/loop-moments)**
  — a new docs page with the hero diagram, and THE TABLE: every lifecycle
  moment × the watch event that reports it × the act seam that can change it
  (or "observation only", which two moments are, on purpose — a rule that could
  rewrite the model's answer would be a rule that could answer for the model).
  Plus the session-trust recipe and the whole-steering-wheel example.

- **Examples** `features/38-act.ts` (all five moments on one agent) and
  `features/39-approve-once.ts` (ask once, remember the answer, keyed by
  tool + source + args — with the loosening shown and its cost named).

### The judgements these rest on

**Deny at the after-tool moment hides an answer; it does not undo a side effect.** The model
reads the reason instead of the result, and the run still commits the real
result — because it happened. A record that dropped it would describe an agent
that called a tool and got nothing back, which is not what occurred. That makes
the row the only copy of a withheld result, exactly as the `'input'` phase row
is the only copy of pre-scrub text, and it gets the same answer: redaction over
`middlewareDecisions` scrubs the value while the refusal survives. The 7.18
security test grew a second half that pins it.

**`onToolResult` NEVER runs for a call that never executed.** Denied before dispatch,
waiting on a person, rejected by arg validation, blocked on a credential, or
naming a tool that does not exist — none of those have a result to decide
about, and asking a rule about a result that does not exist is the same
fabrication the outcome union removes. It DOES run on the far side of an
approval, because that is where the tool ran.

**`stream.tool_end` still reports what the TOOL returned.** The event stream is
about the tool; the history is about the model; the ledger row between them
says which is which. Making the event report the model's copy would have made
one of the two facts unrecoverable.

**The buckets are for reading; the hooks decide.** A rule with both hooks runs
at both moments whichever key it was written under, and a rule named under both
tool keys is the same object attached once. The other rule — buckets that
restrict — would mean a governance rule silently not running because it was
filed in the wrong place, which is the failure this whole surface exists to
prevent. What IS checked, at build time, is that a bucket's entries have the
hook the bucket names.

**`.act({ input })` restricts a rule to one phase; `.messageMiddleware()` does
not.** The bundle wraps each entry in exactly the `msg.phase === 'input' ? … :
allow()` guard a person writes today — including its pass-through row at the
other phase, which is what keeps the per-key equivalence honest. Name a rule
under BOTH `input` and `output` and it is attached once, unguarded: exactly
`.messageMiddleware(rule)`.

**`WindowRecord` did not gain a `moment`.** It was considered and declined:
required, it breaks every third-party `WindowStrategy` in a minor; optional, it
is a discriminant that always holds the same value, added to committed state.
A window record is already the window moment by virtue of the key it lives in.

## [7.23.0] - 2026-08-04

Three seams that each stopped one layer short of the person who needed them.

That is the whole shape of this release, and it is worth naming because it is a
failure mode rather than a feature gap. In all three cases the hard part was
already built and already correct. The transport already had a fetch hook — for
our gateway, one line above the branch that did not pass yours. The protocol
already had two result shapes, and the SDK's own type already said so — our shim
narrowed one of them away. The tool wrapper already knew which server it was
wrapping — and spent that knowledge on an error string. Everything worked right
up to the last hop, and the last hop is where the consumer stands.

**Sign your own requests.** `McpHttpTransport` now takes a `fetch`. Some
endpoints do not want a header, they want a signature — SigV4, DPoP, an HMAC
over the body, a digest of the bytes about to be sent — and none of those can be
decided when the connection is built, because they are computed FROM the request.
A header fixed at construction cannot express them. So this release implements
none of them: it forwards the hook the MCP SDK already has, and **zero vendor
code lives in this library**. Your signer runs on every request the transport
makes — `initialize`, `tools/list`, `tools/call`, the event stream — and a
scheme this repo has never heard of works on the day you write it.

**Read the servers that answer the old way.** `tools/call` has two result
shapes. Today's carries `content` blocks; the 2024-10-07 shape carries a bare
`toolResult` and no `content` at all. Our shim promised only the first, so
`result.content.map(...)` compiled against a value that can arrive without one —
and then failed two different ways, neither visible to the compiler and neither
reproducible against a modern mock. Where the raw legacy object reaches the
reader (a caller-supplied `_client`) it **crashed**. Where the SDK's own result
schema normalises it — defaulting `content` to `[]` beside the `toolResult` it
could not express — it returned **an empty string**, which is worse: a real
answer, silently replaced with nothing. Both are fixed, and the shim now mirrors
the real union, so the next person to write this bug is stopped by the compiler
rather than by a support ticket. That is the 7.13.0 lesson applied to a second
shim: a shim's fidelity is not cosmetic, it is what makes an error class
impossible.

**Say which server a tool came from.** `wrapMcpTool` knew the server's name and
used it once, in the text of an error. Governance could not reach it, so a
policy matching a bare `call_aws` silently governed a second server's
identically-named tool — a hole with no error message, in the layer whose entire
job is to have no holes. Tools from an MCP server now carry `Tool.source`, and
it reaches the decision point as `ToolMiddlewareContext.toolSource`. **Absence
is the other half of the fact**: a tool you wrote yourself carries none, because
"this agent's own" and "served by somebody I chose not to name" are different
situations and only one of them should match a rule about somebody else's
server.

Thank you to a production integration for all three, found the way these things
are always found: on real infrastructure, against real servers, at the last hop.

### Added

- **`McpHttpTransport.fetch`** — your own `fetch` for every request the HTTP
  transport makes. It composes with `headers`: the SDK folds static headers into
  the `init.headers` your function receives, so **you see them and you have the
  last word** — set the same header name and yours is what reaches the wire.
  Omit it and behaviour is byte-identical to 7.22. The library never reads,
  stores, logs or records what your signer produces; the token-secrecy suite
  extends to this seam with the four pins `gatewayTransport` already holds (a
  hostile logger sees nothing, the transport descriptor holds nothing,
  a downstream error carries nothing, and each request is signed separately).
  Pinned against a real socket, because signing is a question about bytes.

- **`McpCallToolResult`** (`agentfootprint/tool-providers`) — the `tools/call`
  union, modelled rather than narrowed away: today's `content` arm, or the
  2024-10-07 `toolResult` arm. `McpSdkClient.callTool` now returns it, which
  makes narrowing a compile-time obligation.

- **`Tool.source`** — where a tool came from. Set by `mcpClient` and
  `mockMcpClient` to the client's `name`; never set by `defineTool`, so it
  cannot be spoofed by accident. A hand-written `Tool` may set it deliberately
  when it is genuinely relaying another source's tool.

- **`ToolMiddlewareContext.toolSource`** — that provenance at the decision
  point, on the same context type both chains use, so `mcpServe`'s serving-side
  middleware gets it too (carrying the **served** tool's provenance, never the
  calling client's — a client does not get to declare where a tool came from).
  Present only when the tool has a source, so `'toolSource' in call` is a
  question with a real answer.

- **[Connect to an MCP server](https://footprintjs.github.io/agentfootprint/docs/build/mcp-client)**
  — a new docs page for the consuming side: the three transports, a complete
  per-request signer written with `node:crypto` and no vendor SDK, what each
  result shape converts to, and governing by server.

### Fixed

- **A legacy `tools/call` answer is no longer lost or fatal.** A `toolResult`
  becomes the tool's text — a string verbatim, anything else JSON-stringified,
  a conversion now stated in the docs rather than inferred from a mangled
  answer. A `toolResult` beside an EMPTY `content` is read as the legacy answer
  it is (the empty array is the SDK's default, not the server's answer); a
  NON-empty `content` always wins, because a server that sent blocks meant the
  blocks. An answer with neither arm is a corrective tool error naming the
  SHAPE that arrived — its type, or its keys — and never the payload, which is
  still somebody's data.

A store that kept every conversation and could read none of them — and, one
grep later, a second store doing the same thing to memories.

`agentCoreSessions({ store: 'memory' })` wrote the checkpoint envelope into an
event payload as a **raw object**. The service stores its own host language's
`toString()` rendering of an object it is handed, and returns that string —
`{format=conversation-v1, data={...}}`. Not JSON. The reader accepted only
objects, so that string decoded to nothing, and `hydrate` answered the only way
it could: **"no session"**. Every turn wrote a conversation nobody could read,
and every next turn started fresh on top of it. Nothing failed. Nothing logged.
The store filled up with perfectly-preserved, permanently-unreadable
conversations, and the first person to find out was a user whose chat vanished
at a deployment boundary.

Two things had to change, and only one of them is about a cloud.

**The encoding.** The shim now writes `JSON.stringify(envelope)` and parses a
string blob back on read, still accepting an object blob because a
caller-supplied `client` may legitimately return one. An envelope is defined as
something a store can hold as text — `toEnvelope` round-trips through
`JSON.stringify` by construction — so the encoding was always ours to pick, and
the honest pick is the one whose bytes come back unchanged.

**The law, which is the part that outlives this vendor.** In the words of the
field report that bought it:

> _An unreadable stored conversation and an absent one are different facts, and
> only one of them is safe to answer with a fresh start._

A session nobody has used is absent, and answering it fresh is right. A session
whose bytes are present and unreadable is not, and answering THAT fresh is
invisible from every angle that matters — the reply looks perfect, the run looks
clean, the store looks full. So the reading path now refuses it by name:
`UnreadableEnvelopeError`, carrying `ERR_UNREADABLE_ENVELOPE`, the session id,
and a short prefix of what came back (a prefix only — the rest of those bytes is
somebody's conversation). It sits at `readFormat`, the one place a stored value
is inspected, so every reader and **every store adapter, including ones nobody
has written yet**, inherits it rather than re-deciding it. `standingAgent`
surfaces it as the request's failure, naming the session, and never falls
through to the fresh-start path.

**Then the same pattern, in the other organ.** Diagnosing the session store
meant reading how this repo writes an event blob, and `AgentCoreStore` — the
`MemoryStore` adapter — wrote them identically: `payload: [{ blob: entry }]`,
read back as objects only. Same service, same mangling, same silence, different
loss: an entry that decodes to nothing was _skipped_, so `list()` came back one
memory short and `get()` came back `null`. Memory that silently stays empty is
indistinguishable from memory that works, until somebody notices the assistant
has forgotten a customer's address. Shipping the cure for one organ while the
same disease sat documented in the other would have been the polite cousin of
the silent failure we had just made loud, so both are fixed here.

The memory half gets the same two changes — JSON text on write, `JSON.parse` on
read, objects still accepted — and the same law in the shape this port allows.
`MemoryStore` has no envelope and no shared reading path: there is no
`readFormat` here to inherit from. So the refusal lives at the adapter's own
decode step, the one place raw bytes become an entry, and says so in a comment;
if a `MemoryStore` reading path ever grows a choke point, the law moves there.

**Say this part plainly, so nobody spends a day on archaeology: everything
written by 7.15.0–7.22.0 into AgentCore event blobs is UNRECOVERABLE** — session
envelopes under `{ store: 'memory' }` and every `AgentCoreStore` entry alike.
The mangling is lossy; the returned string is not JSON and cannot be turned back
into a conversation or an entry by any parser, ours or yours. There is no
migration, and this release does not pretend to offer one. What it does offer is
that those sessions and entries now announce themselves loudly instead of
quietly becoming a stranger's blank slate. Delete them, point the store at a
fresh memory resource, or start those conversations again.

Thank you to **a production integration** for finding this on real
infrastructure, reproducing it precisely, and verifying the fix through the
public client seam. Their deployment also closes the outstanding real-cloud item
from 7.15.0 for `{ store: 'session-storage' }` — which behaved exactly as
documented — and, with this release, for `{ store: 'memory' }` as well. Both
session modes have now met the service they were written for; `agentCorePolicy`
and `AgentCoreStore.search()` remain contract-mapped and injection-tested only,
and are still described that way.

### Fixed

- **`agentCoreSessions({ store: 'memory' })` wrote an envelope the service could
  not give back.** The SDK shim now sends `payload: [{ blob: JSON.stringify(envelope) }]`
  and decodes a string blob with `JSON.parse` on the way in. Object blobs are
  still accepted on both the `_sdk` and `client` seams. A blob that is present
  and undecodable — a pre-7.22.1 envelope, or anything mangled in future — is
  refused loudly rather than decoded to `undefined`.

- **`hydrate` no longer answers "no session" for a session that HAS one.** The
  adapter's decode step now distinguishes _no blob at all_ (an absence, which
  hydrates as `undefined`) from _a blob it cannot read_ (which travels on to the
  shared reading law and is refused by name). Both file and event modes pass the
  session id into `checkEnvelope`, so a refusal names the conversation.

- **`AgentCoreStore` (`agentfootprint/memory-providers`) wrote memory entries
  the same way, and lost them the same way.** Same fix: entries go in as
  `JSON.stringify(entry)`, a string blob is parsed back, objects are still
  accepted. And the same distinction, in this port's own shape — an event
  carrying **no blob** is an absence and is skipped (AgentCore writes events of
  its own into the same log), while a blob that is present and undecodable now
  raises `UnreadableMemoryEntryError` instead of being dropped from the page.
  Dropping it was the bug: `list()` came back one memory short, `get()` came
  back `null`, and an agent answered as if it had never been told.

  The refusal reaches every read path that walks events — `get`, `list`,
  `delete`, `forget` — because a read that cannot see a memory must not report
  "not there" on any of them. `search()` is untouched: it reads AgentCore's
  derived records, not the blobs this store writes.

### Added

- **`UnreadableEnvelopeError`** (`agentfootprint/hosting`) — the refusal every
  reader and store adapter inherits when a stored session is present but cannot
  be read. Carries `code: 'ERR_UNREADABLE_ENVELOPE'`, `sessionId` when the
  refuser knows it, and `storedPreview`: at most 64 characters of what the store
  handed back, which is enough to recognise a mangled encoding and not enough to
  leak what was being said. `withSession(id)` returns a copy naming the session,
  for a reader that knew the bytes were bad but not whose they were.

  It extends `TypeError`, which is what this refusal already threw — the error
  gained a name, a code and a session; it did not change kind, so an existing
  `catch` keeps working.

- **`UnreadableMemoryEntryError`** (`agentfootprint/memory-providers`) — the
  same law where `MemoryStore` can carry it. This port has no envelope and no
  shared reading path to inherit from, so the refusal lives at the adapter's own
  decode step and says so in a comment; if a `MemoryStore` reading path ever
  grows a choke point, the law moves there. Carries
  `code: 'ERR_UNREADABLE_MEMORY_ENTRY'`, the `eventId` and the `sessionId`, and
  `storedShape`.

  **`storedShape`, not a preview, and that difference is the point.** Both
  refusals obey one rule — never the stored content — through one shared helper,
  and they answer it differently because the two shapes differ in where content
  begins. A `CheckpointEnvelope` opens `{ format, data, savedAt }`, so a capped
  prefix is metadata. A `MemoryEntry` opens `{ id, value, … }`, so its second
  field is the thing somebody asked the agent to remember, and even a short
  prefix prints it — a test caught exactly that. So the memory refusal quotes
  nothing at all: type, length, JSON-ness and the opening character, which is
  still enough to recognise "an object stringified by something that was not
  JSON".

- **`checkEnvelope(value, sessionId?)`** — an optional second argument so a
  store's refusal names the conversation. Omit it and nothing changes.

### Changed

- **`standingAgent` fails the request on an unreadable stored session**, naming
  it, instead of hydrating `undefined` and starting a fresh conversation over
  the top. If your store has ever handed back bytes this runtime cannot read,
  you will now see it — that is the point. A store that never has is unaffected.
  Over HTTP it is a **500**, and deliberately: every other hosting refusal is a
  conflict or a shutdown, while this one really is something broken on the
  server's side.

- **[`examples/deploy/durable-sessions.ts`](examples/deploy/durable-sessions.ts)
  gained a third part**, run end-to-end like the rest: a store holding an
  unreadable session, the refusal arriving over real HTTP, the model never
  called, nothing written over what is there, and a brand-new session still
  answered fresh.

- **Documented, where an integrator actually looks:** `runtimeSessionId` must be
  **at least 33 characters** (the service validates it, so a short tidy id is
  rejected before your container sees anything), and a **direct-code (zip /
  `NODE_22`) deployment serves `/ws` fine** — the vendor documentation describes
  WebSocket support for container deployments only, which reads as a restriction
  and is not one. Both are field facts, on the AgentCore adapters page, the
  step-by-step page and the repo guide.

## [7.22.0] - 2026-08-03

Give the socket back.

A production integration hit a wall that had nothing to do with agents: its
runtime gives a container exactly one port, and that container has to answer a
WebSocket upgrade as well as the agent. Every HTTP host here privately created
its own server and listened on it, so "the agent" and "everything else this
process serves" were competing for the one port the platform allowed. The agent
framework was, in effect, demanding a port of its own — a demand no library has
standing to make about a process it does not own.

So `httpHost({ server })` takes a `node:http` server **you** created and
**attaches** to it rather than binding one. That works because `node:http` calls
every `'request'` listener for every request, which makes sharing a port a
matter of taking turns rather than of routing. Every adapter built on `httpHost`
inherits it in the same breath: `nodeHost({ server })` and
`agentCoreRuntimeHost({ server })` are the same option, and the field case — the
runtime adapter next to a `/ws` upgrade — is one line.

The interesting part is what a host may do on a socket it does not own, and the
answer is: **less**. It never writes a 404, because a path it does not own is
the caller's, and a refusal from the agent host on somebody else's route is the
library answering for the application. It never writes to a response an earlier
listener already answered. `close()` detaches and drains and then stops — it
does not close a socket it never opened, does not touch the caller's
connections, and leaves the upgrade beside it connected. That restraint has one
consequence worth stating out loud rather than discovering: node does not 404 an
unhandled request, it leaves it hanging, so on a shared server an unrouted path
hangs unless the caller routes it. Documented on the option, in the guide, and
in the example, because a quiet hang is the worst thing to learn at 3am.

Two things are refused rather than guessed. A `port` or `hostname` beside
`server` throws at construction: a server you own already has an address, and a
port that names a socket this host does not bind leaves a caller believing
something untrue about where their agent answers. And a server that is not
listening yet is refused by `serve()`, because the handle promises the `url` and
`port` it is answering on — reporting an address that does not exist yet, or
inventing `0`, would break the one thing that handle is for. Listen first, then
attach; attaching after `listen()` is safe and is the intended order.

Also in this release: **the MCP-on-a-runtime question, settled by a test rather
than by an opinion.** See below.

### Added

- **`HttpHostOptions.server`** — a caller-owned `node:http` server to attach to.
  The caller keeps `listen()` and keeps the shutdown; the host answers its two
  routes, writes nothing on anyone else's path, and `close()` detaches + drains
  while the socket stays up. With no `server`, behaviour is byte-identical to
  7.21: the host binds, and `close()` closes.

- **`NodeHostOptions.server` and `AgentCoreRuntimeHostOptions.server`** — the
  same option on both shipped adapters, passed straight through rather than
  re-implemented. The runtime adapter does not smuggle its contract's `:8080`
  default in either: with a caller-owned server it binds nothing, so it names
  nothing.

- **[`examples/deploy/one-port.ts`](examples/deploy/one-port.ts)** — an agent, a
  WebSocket echo and a `/metrics` route on ONE port, run end-to-end as its own
  integration test: the upgrade handshakes beside the agent, and after
  `handle.close()` the socket is still listening with the caller's routes and
  the upgrade intact.

- **A conformance test for MCP-on-a-runtime**, in
  `test/lib/mcp/mcpServe.real.test.ts`, judged by the SDK's own client over a
  real socket. **The verdict: `mcpServe`'s streamable-HTTP transport does not
  satisfy the container contract this repo documents for a managed agent
  runtime, and is not trying to.** It serves MCP — statelessly (it neither
  issues nor demands a session id, so replicas are interchangeable), on the path
  and port you choose — and it answers _neither_ of the contract's two routes:
  `GET /ping` and `POST /invocations` are 404s from it. Two protocols, two
  paths, two adapters: serve the container contract with
  `agentCoreRuntimeHost` and MCP with `mcpServe`. The one thing that is NOT
  reachable in this release is both on a single port — `mcpServe`'s HTTP
  transport always owns its listener and has no `{ server }` option, which is
  now stated in the MCP guide rather than left to be discovered.

## [7.21.0] - 2026-08-03

The refusal becomes acceptance.

Three releases ago the messages slot was a lie: content declared for it was
recorded as injected, counted in the slot composition, routed by the engine, and
never sent. 7.19.1 refused the declaration by name rather than deliver it badly,
and said why in the same breath — the wire has no system role _inside_ the
message list on the Anthropic family (system is a separate top-level field)
while the OpenAI family carries it, so wiring the slot straight through would
have replaced one uniform gap with a **provider-dependent** one that nothing in
the recording could distinguish. Worse than uniformly false, because at least a
uniform gap can be found.

This release is the acceptance that refusal held the door for. A declared
message now reaches the model, and it reaches it the only way that keeps the
recording true: **it becomes part of the conversation.** A new `Deliver` stage
appends it to `scope.history` at the injection-engine boundary — the same window
the window strategies govern, the same window the request is built from. There
is no second list spliced in at send time, so the trace, the slot projection,
the token count, the eviction ledger and the wire are all describing one past.
Everything that was already true of a message is now true of a delivered one for
free: a window strategy can fold it, `traceVariable` can slice it, the commit log
names the stage that let it in.

What survives from the refusal is the part that was always about the wire, and
it survives split in two, each decided against something real. **Role**: every
provider now declares `carriesInMessages`, and a role it does not carry is
refused when the run starts, naming the provider and the roles it does. The role
is never rewritten to one that fits — changing who appears to speak is a meaning
change the app must make, not the library. **Position**: a delivered message goes
at the end of the window, and if its role would repeat the turn already there, it
is _deferred_ to the next boundary with a sentence on
`messagesDelivery.deferred`, never dropped and never reordered, and never
inserted between a tool call and its result.

One consequence of that rule is worth saying out loud rather than discovering:
sequencing is judged on the strictest wire, where a tool result counts as a user
turn. Inside a tool-using loop the window ends on the user's turn (first
iteration) or on tool results (every one after) — so **a `role: 'user'`
injection will typically never deliver.** Use `'assistant'`, use `'system'` on a
provider that carries it, or return the words from the tool. Judging collision
per-provider would deliver on one wire and defer on another with nothing in the
recording to tell them apart, which is the same falsehood this feature exists to
end. An honest limitation stated loudly beats a clever one hidden.

### Added

- **`slot: 'messages'` delivers, with a role you name.** `defineFact` and
  `defineInstruction` accept it again, and `Agent.injection()` routes it instead
  of refusing. `role` is **required and has no default** — before 7.19.1 it
  defaulted to `'system'`, the value that reached the model on OpenAI-family
  providers and vanished on Anthropic-family ones, so the default was the bug.
  `slot` and `role` are one decision and the type makes them one choice: a
  discriminated pair, so `slot: 'messages'` without a role does not compile,
  `role` without it does not compile, and `role: 'tool'` never compiles (a tool
  message answers a specific call; an injection has none).

- **`LLMProvider.carriesInMessages`** — an optional
  `readonly ('system' | 'user' | 'assistant')[]` declaring what a wire carries
  inside its message array. All first-party adapters declare it; the three
  resilience decorators forward it, and `withFallback` publishes the
  **intersection** of its pair, because a role only one side carries is a role
  the call might drop. **Absence is not permission**: a provider that omits the
  field is treated as `['user', 'assistant']`, the floor every known wire
  supports. Stated in the custom-provider guide, alongside the wrapper trap.

- **The `Deliver` stage**, mounted only when something could target the slot (a
  registered injection declaring `inject.messages`, or any `.memory()` whose
  recall might). An agent with nothing to deliver has no stage, no write, and no
  delivery record — the chart and the commit log it had in 7.20.

- **`messagesDelivery` on the run's state** — per iteration, what was delivered
  (with the wire index and content hash) and what was deferred (with the reason
  in a sentence). It rides visible stage state rather than a new event contract,
  the way the injection engine's route and delta already do, so it is in the
  commit log and in `snapshot.sharedState` with nothing new to subscribe to. It
  is the committed answer to "why is my declaration not on the wire?".

- **`LLMMessage.injectedBy`** — the marker naming who let a message into the
  window, so the slot attributes it to its injection instead of inferring a
  source from its role. It is stripped in `callLLM` before the request exists,
  so it can never reach a provider — not "ignored by our adapters", removed. A
  consumer-authored adapter that serializes a message wholesale cannot leak it.

### Fixed

- **The cache marker for `field: 'messages'` pointed at the wrong message.** It
  counted entries in a per-slot list of _injections_ and handed that count to
  providers who read it as a position in `request.messages` — two index spaces
  under one name. It was unreachable while nothing could target the slot, and
  delivery makes it reachable, so it is recomputed against the actual wire array
  after windowing and delivery, and pinned: a marker's index names the message it
  claims to name.

  The same mismatch existed one step further out, and is fixed with it: the
  Anthropic body is not `request.messages` either — system messages are dropped
  and consecutive tool results are coalesced into one user turn — so the browser
  adapter was placing `cache_control` by raw ordinal into a shorter, differently
  ordered array. It drifted one position per tool round-trip, which put the cache
  breakpoint on the turn that changes every iteration: the one place a prefix
  cache can never be reused. The transform now yields an index map and the marker
  is translated through it. A marker naming a message that does not survive the
  transform marks nothing, rather than marking its neighbour.

- **A declared `cache` policy never reached the cache decision.** The
  `ActiveInjection` projection drops `metadata` (it is the scope-safe POJO), and
  `computeCacheMarkers` read the policy from exactly there — so in every real run
  every injection resolved to `'never'` and only the base system prompt could ever
  be marked cacheable. The projection now carries the one key the decision reads.
  Without this the marker fix above would have been true and still unreachable:
  the same falsehood, one layer down.

### Changed

- **`dynamic-grouped` recordings gain one commit per iteration.** Delivery made
  `history` writable inside the grouped chart's turn subflow for the first time,
  and the turn seed now writes it unconditionally — so a `dynamic-grouped` agent
  records one additional (often empty-delta) commit per iteration whether or not
  anything is delivered. No wire bytes and no committed values change; only the
  commit count. The flat chart is byte-identical to 7.20.0 when nothing declares
  the messages slot. A reader diffing recordings across versions deserves this
  sentence, so here it is.
- **The messages slot stops recording what it does not deliver.** It no longer
  walks `activeInjections` looking for `inject.messages`; a delivered injection
  is already in the window when the slot runs, so it is projected like any other
  message and credited to its injection by the marker. One wire message, one
  `context.injected` record, one name — and the content hash is the same formula
  the window stage uses for `context.evicted`, so injected and evicted refer to
  the same piece of context by the same id.

- **`history` is writable inside `sf-llm-call`** (`reactMode: 'dynamic-grouped'`).
  It used to cross that boundary as a read-only input, which was fine while
  nothing inside changed it; delivery does. It now takes the same `prior*`
  round-trip the token accumulators take, and a test pins the law it protects:
  the window an iteration sent is the window the next iteration continues.

- **The two docs 7.19.1 missed become correct rather than corrected.**
  `docs/INSTRUCTION_ARCHITECTURE.md` and `docs/guides/instructions.md` were still
  teaching the old promise — "the messages slot is the recency window, highest
  attention" — three releases after it was withdrawn. They now describe what
  actually happens, limitation included.

### Notes

- **Delivered once.** An always-on injection enters the window on the boundary
  it activates and is not re-appended every iteration. Dedupe reads the run's
  ledger **and** the markers already in the window, which is what makes replay
  safe: `resumeOnError` restores a conversation into a fresh scope, so a
  ledger-only check would deliver a second copy of a message already sitting
  there.

- **Not resurrected.** A window strategy that drops a delivered message has made
  a decision; re-delivering it at the next boundary would be the library
  overruling that decision forever.

- **`defineMemory({ asRole })` stays refused — for a different reason.** 7.20.0
  refused it as never-read and blamed a limitation: honouring it meant routing
  recall through a messages slot that could not deliver. That limitation is gone
  now, so the sentence would have been resting on a retired fact — the same class
  of stale claim the refusal exists to remove. It now says the true reason: the
  machinery exists, a recall formatter emitting a non-system role becomes a
  `slot: 'messages'` injection like any other, and **nobody has asked for it.**
  Building it because the mechanism arrived is how a library grows options nobody
  reads. Field evidence decides.

- **A typed `agentfootprint.context.deferred` event is the named next step**, if
  field evidence asks for it. The deferral is committed and queryable today; an
  event is an additive change that costs a permanent contract, and the evidence
  rule decides that, not the convenience of having one.

- **Filed, not fixed:** `CacheMarker{field: 'tools'}` has the identical index
  mismatch — it counts injections that contribute tools and is stamped onto the
  request's tool array. It is out of this release's scope, and it is now the only
  one of the pair left.

## [7.20.0] - 2026-08-03

Three small honesty fixes. No new machinery, no delivery change, no wire bytes
different for anyone.

7.19.1 fixed a recording that disagreed with the wire. This one fixes a
recording that disagreed with **itself**. Two events describe a memory or RAG
recall — the memory stage's own `agentfootprint.context.memory.injected` and
the slot composer's `agentfootprint.context.injected` — and they named
different slots for the same bytes. One of them was wrong, and it stayed wrong
in every guide that repeated it. A trace whose own events contradict each other
cannot be reasoned about at all: there is no way, from inside the recording, to
tell which half to believe.

The second fix is the same shape one layer up. `defineMemory({ asRole })` and
`defineRAG({ asRole })` took a role, stored it on the definition, and were read
by nobody — every formatter this library ships writes `role: 'system'`, so
recall has always been injected as system whatever the option said. `defineRAG`
defaulted it to `'user'` and documented the reasoning at length, which made the
lie legible: you could pick a role, read it back off the definition, and be told
a role the run would never use. **A throw where there was a silent lie is a fix,
not a break: nothing that worked stops working, and something that never worked
stops pretending.**

### Fixed

- **`context.memory.injected` reported the wrong slot.** The event now says
  `slot: 'system-prompt'`, which is where recall actually lands, and it is
  checkable end to end: `formatDefault` writes one `role: 'system'` message,
  `memoryRecallInjections` routes system-role recall to `inject.systemPrompt`,
  `buildSystemPromptSlot` records it as a `slot: 'system-prompt'` injection, and
  the request carries it in `systemPrompt` — never in the message list.

  This is a **consumer-visible payload change**: anyone branching on
  `slot === 'messages'` from this event will stop matching. They were branching
  on a lie — the value was never true of this stage, which has always formatted
  recall as system — and the fix is to branch on `'system-prompt'`, or on the
  `context.injected` event that has been saying so all along. Nothing about
  where the content goes changed; only the claim did.

  A permanent test pins the law: the slot named by `context.memory.injected`,
  the slot named by `context.injected`, and the request field the bytes are
  actually in must all agree. It is written as an equality between the three
  surfaces rather than a hard-coded `'system-prompt'`, so if recall ever lands
  somewhere else the test fails on **disagreement**, not on change.

- **`asRole` was stored and never read.** `defineMemory({ asRole })` and
  `defineRAG({ asRole })` now refuse the declaration by name. The option is gone
  from both public option types, so TypeScript reports it at the keystroke, and
  both factories throw for JavaScript callers and casts. The refusal states the
  truth (never read; recall is always injected as system), and points at what
  would change it: role-differentiated recall means putting recall into the
  messages slot, which does not reach the model (7.19.1), so it arrives with the
  messages-delivery feature if field evidence asks for it.

  The refusal fires on **presence, not value** — an explicit `asRole: 'system'`
  named the role the run happens to use, but it was just as unread, and letting
  it through would teach that the option is honoured.

  The dead `asRole` field is also gone from `MemoryDefinition`. A definition
  field that no run consults is a claim the recording cannot back. `defineRAG`'s
  documented `'user'` default goes with it; every other default (`topK: 3`,
  `threshold: 0.7`) is unchanged, and so is every run.

### Changed

- **Docs stop saying recall lands in the messages slot.** The RAG, grounding,
  memory, semantic-retrieval, fact-extraction, auto-memory and narrative-memory
  guides, the RAG example (`examples/context-engineering/07-rag`), and the RAG
  snippets in `AGENTS.md` + `ai-instructions/` all said "the messages slot" for
  content that has always gone to the system prompt. The last two matter most,
  because an assistant reading them was being taught to write the placement
  claim the event has just stopped making — and, in the same snippet, the
  `asRole` line that now throws.

## [7.19.1] - 2026-08-03

A recording that overstates what the model saw is worse than no recording, and
one of ours did. An Injection declared for the **messages slot** was recorded as
injected — `context.injected` fired with its content, the slot composition
counted it, the engine routed it — and never sent: the request's message list is
assembled from the conversation, and the messages slot is the observability
projection of that conversation, not a wire. The docs promised the delivery in
detail ("higher attention weight", "appears alongside fresh tool results"). The
promise was never kept.

It is refused now, at the declaration, in a sentence that names the three
placements that do reach the model. **A throw where there was a silent lie is a
fix, not a break: nothing that worked stops working, and something that never
worked stops pretending.**

Delivering it instead was the other option, and it was rejected on evidence.
The wire has no system role INSIDE the message list — the Anthropic and Bedrock
adapters drop such a message, because system is a separate top-level field,
while the OpenAI adapters carry it — and `'system'` was this option's default
role. Wiring the slot up without a per-provider notion of what each provider can
carry would have replaced one uniform gap with a **provider-dependent** one, and
nothing in the recording could have told the two apart. Honest delivery needs a
wire-carrying key out of the slot, that provider capability, and a
message-sequence rule; it is queued as a feature, with this gap as its evidence.

### Fixed

- **`slot: 'messages'` injections were recorded and never sent.** `defineFact`,
  `defineInstruction` (and `defineInjection` routing to either) now refuse the
  declaration, and `Agent.injection()` — the one funnel `.skill` / `.steering`
  / `.instruction` / `.fact` all pass through — refuses a hand-built `Injection`
  carrying `inject.messages`, so the refusal cannot be walked around. The
  message names the limitation and the working alternatives: `slot:
'system-prompt'` (the default, delivered by every provider), a tool's return
  value (a tool result IS a recent message, at the recency the option was
  reaching for), and the text passed to `agent.run({ message })`.

  `slot` narrows to `'system-prompt'` on both factories and the dead `role`
  option is gone, so TypeScript reports it at the declaration; the run-time
  refusal still fires for JavaScript callers and casts. A declaration that used
  to build, run and silently do nothing now fails where it is written.

  **No wire bytes change for anyone**, and a permanent test now pins the law
  this violated: every `context.injected` the messages slot emits must appear in
  the request the provider was handed.

- **`mcpServe({ transport: 'http', port: 0 })` bound a port the caller could not
  discover.** "Any free port" was unusable from the outside: the number the OS
  chose lived inside a listener nobody could see, so every caller had to name a
  port up front and race whoever else wanted it. The handle now reports
  `port` and `address` for the HTTP transport — absent (the KEYS absent, not
  undefined) for stdio, which has no socket. An explicitly chosen port is
  reported back unchanged, so code can read it without branching on how the
  port was set. No URL is assembled: a wildcard bind is not an address a client
  can dial, and a handle that handed one out would be inventing reachability.

### Changed

- **The messages slot's own comment now says what it is** — the observability
  projection of the conversation, with the window governed upstream by
  `scope.history` and the loop-head window stage (7.16–7.17), which is where the
  windowing and summarizing that a stale "arrives in Phase 5" note still
  promised actually landed.

- **Docs stop promising messages-slot delivery**: the Instructions guide, the
  Instruction and Fact examples, the trigger table in the README, the Injection
  row of the skills comparison, and the slot tables in `AGENTS.md` +
  `ai-instructions/` — the last two matter most, because an assistant reading
  them was being taught to write the one declaration that now throws. Where the
  guide recommended the messages slot for recency, it now recommends the tool
  result — the placement that reaches the model at the same position.

Two things separate an agent you demo from an agent that is up: what a crash
costs you, and what happens when it needs to ask a person something.

7.18 and everything before it answered both the same way — badly. A standing
agent wrote once, at the end of a turn, so a restart lost every tool call the
turn had made. And a run that stopped to ask a person **failed the reply and
stored nothing**, because `'conversation-v1'` held a conversation and a paused
run is a conversation plus an engine checkpoint. That was the honest thing to do
with no format to put it in. There is one now.

**`durability: 'sync'` gives the replay bound a number.** Iteration N's tools do
not execute until iteration N−1's write has landed. Not "we write often" — a
bound: the work a crash can re-run is the current iteration, and nothing before
it.

**A paused run is stored as `'flowchart-v1'` and the reply says so as data.**
`HostReply` grew its third terminal — `complete`, `awaiting`, `fail` — and a
pause leaves through `awaiting`, 202 over HTTP, never through `fail` again. A
later request carrying a `decision` continues the run from exactly where it
stopped, and the tool that asked does not run twice.

### Added

- **`durability: 'exit' | 'async' | 'sync'` on `standingAgent`.** Default
  `'exit'` — one write when the run finishes, which is what every release before
  this one did, now spelled out rather than implied. Under `'exit'` **nothing is
  installed**: no observer on the agent, no barrier, no per-commit work.

  `'async'` starts a write whenever the conversation changes and never waits on
  it; the run does not slow down. At most one write is in flight and the newest
  snapshot supersedes any queued one, so what a crash leaves is always a PREFIX
  of the run, never a mixture.

  `'sync'` is persist-then-proceed, and it holds the tool dispatch — see the
  judgement below for why it had to.

- **`'flowchart-v1'` — the format the version field was kept for.**
  `CheckpointEnvelope` is now a union discriminated on `format`, so a reader
  that switches on it is exhaustive by construction. `toPausedEnvelope(run)` /
  `readPausedRun(value)` pack and unpack a **`PausedRun`**: the engine
  checkpoint, the conversation as of the pause, and the outstanding ask.

  The two readers refuse **each other's** format and each points at its sibling.
  A reader that quietly returned the conversation inside a paused run would hand
  back a session that looks finished while somebody is still waiting to be asked.
  `checkEnvelope(value)` is the third door, for STORES: it validates either
  format and hands the envelope back, because a store's job is to notice
  unreadable bytes, not to care which half of a session is inside them.

- **`HostReply.awaiting(pending)` — the third terminal.** Optional on the type,
  exactly as `emit` is, so a minimal adapter still satisfies the port; every
  shipped adapter implements it. `nodeHost` answers **202 Accepted** with
  `{ awaiting }`, the AgentCore runtime wire answers its own dialect, and both
  stream an `awaiting` frame under SSE. A host without it still gets the paused
  run STORED and a named refusal on the wire — the store is not the transport's
  business.

- **`PendingAsk` — the part of a pause that is safe to hand back.** The tool
  that asked, the question in plain words, the typed `checkIn` with its evidence
  pack, the middleware `ask`, and the raw `pauseData` uninterpreted. It carries
  **no checkpoint and no conversation**, and a type-regression test fails the
  build the day either appears.

- **`HostRequest.decision` — the resume discriminant.** A request carrying it
  answers the outstanding question; a request without it is a new message. The
  port never interprets it: it goes to `agent.resume(checkpoint, decision)`
  exactly as it arrived, answered with the shipped `checkInApproved()` /
  `checkInDeclined()` vocabulary for a check-in or a middleware ask, and with
  whatever the tool's author documented for a plain `askHuman`.

- **`AwaitingDecisionError` / `NoPendingAskError`** (`ERR_AWAITING_DECISION`,
  `ERR_NO_PENDING_ASK`, both 409). A new message while a question is outstanding
  is refused NAMING the pending ask — the message is not run and the pause is
  not discarded. A decision when nothing is pending is refused too, rather than
  run as if a person had typed an approval into the conversation.

- **`WakeReason` gained `'resume'`**, now that something can produce it.

### The judgements these rest on

**`'sync'` had to hold the tools, not just the reply.** The cheap reading —
"await every write before answering" — is honest and bounds nothing that
matters. Under it, iteration N's tools execute while iteration N−1's write is
still in flight, so a crash re-issues MORE than one iteration of side effects
and "how much can re-execute?" has no answer. The market meaning of durable
execution, and the expectation the word `'sync'` imports, is precisely that
bound. A `'sync'` that did not deliver it would be documented-but-misleading,
which is the polite cousin of config that lies.

**So the barrier is real, and it is private.** footprintjs never awaits an
observer — the inline recorder path discards the hook's return value, and only
the deferred tier tracks a promise, one beat behind. There is no way to apply
back-pressure to a traversal from outside it. The barrier therefore lives INSIDE
the agent's tool dispatch, asked once per iteration, and is installed through a
module-private WeakMap keyed on the runner: `standingAgent`'s session writer is
the only thing that can install one, and it appears on no barrel and no subpath.
A general "run something between my stages" hook would have been a new public
extension point on seams that are deliberately closed — it would let any
consumer inject latency, ordering and failure into a traversal, and every later
feature would have to reason about it. When nobody has installed a barrier the
accessor returns `undefined`, so the dispatch loop does not await, does not
schedule a microtask, and is byte-identical in timing.

**The bound is per ITERATION, and that is exact rather than convenient.** The
agent dispatches all of one iteration's tool calls inside one stage body, and a
commit is a whole stage. So a crash part-way through re-runs that iteration's
tools — the same idempotency requirement `resumeOnError` has always carried, now
with a boundary instead of a warning. A test pins that an iteration which ran two
tools stores both results or neither, never one.

**It writes where the conversation MOVES, not on every commit.** A
two-iteration turn commits about forty times; exactly two of those change the
conversation — the user's message landing, and each tool-call stage. The other
thirty-eight would store bytes identical to the last write. So the trigger is
"this commit wrote `history`", which is not an optimisation but the honest
reading of the question: the conversation moved iff `history` moved.

**A mid-run write can only be a conversation, and that is enough.** footprintjs
builds a `FlowchartCheckpoint` only at a pause, so there is no engine snapshot to
store mid-run. What the commit boundary can hand over is the same
`AgentRunCheckpoint` a finished turn stores — which is exactly what the next turn
resumes from.

**A store that refuses fails the request.** Fail-closed: under `'sync'` the next
tool does not run, and the reply reports the store's error rather than an answer.
A store that would not take the run's progress has not made it durable, and
proceeding as if it had is the dishonesty this dial exists to remove.

**Mid-run writes settle BEFORE the terminal envelope.** Ordering, not tidiness:
an `'async'` conversation write still in flight would otherwise land after the
pause envelope and overwrite it — a stored question quietly demoted back to a
plain conversation, and the person who was asked never answered. A test pins it.

**A `FlowchartCheckpoint` is JSON-safe to resume from and NOT byte-identical
through JSON.** `JSON.stringify` drops every property whose value is `undefined`,
and a real paused run has about a dozen — all of them in the engine's diagnostic
halves (`executionTree`, `subflowResults`). `sharedState`, the half `resume()`
actually reads, round-trips unchanged, because footprintjs already
JSON-round-trips every object write on its way into committed state. Three tests
pin it: the resume-relevant fields survive byte for byte, every difference at all
is a dropped `undefined`, and a run resumes from a checkpoint that came back off
a string-keyed store.

**A pause is never delivered through `fail` again.** It was, for one release,
because there was nowhere to put it. An error standing in for unfinished work
tells every dashboard downstream something untrue: nothing broke, nothing needs
retrying, and there is a person to ask. `PauseNotCarriedError` survives with its
code intact for the one case where a pause genuinely cannot be carried — a
request with no session id, so there is nowhere to store it and no later request
that could ever answer it.

### Changed

- **A paused hosted turn now answers `202` and stores a `'flowchart-v1'`
  envelope, where 7.18 answered `409` and stored nothing.** This is a behaviour
  change a deployer has to read. During a rolling deploy, an instance still on
  7.18 that hydrates one of those sessions **refuses it by name** — the
  unknown-format law working exactly as designed, loudly rather than silently.
  Drain or roll forward rather than running both versions against one store.
- `agentCoreSessions` validates through `checkEnvelope`, so an AgentCore-backed
  store keeps paused sessions as readily as conversations; the AgentCore runtime
  wire reads `decision` and answers `awaiting` in its own dialect.

Docs: [Hosting](https://footprintjs.github.io/agentfootprint/docs/infrastructure/hosting-and-runtime) ·
Example: `examples/deploy/durable-sessions.ts`.

## [7.18.0] - 2026-08-03

Every agent framework lets you wrap a tool call. Most of them let the wrapper
_answer_ — return a canned string, a cached value, a "simulated" result — and
the moment one does, the trace is fiction. The model was told a tool ran.
Nothing ran.

This release ships a typed chain around every tool dispatch and around the
message boundary, and the wrapper **cannot answer**. The outcome union has
three arms — `allow`, `deny`, `ask` — and no arm for a result. There is no
spelling of "here is what the tool would have returned". So whatever a chain
decides, what the model finally reads is the real tool's output or a refusal.
That is not a convention a reviewer enforces; it is the absence of a field, and
a type-regression test fails the build the day someone adds one.

The same law decides what an `ask` resumes with. A middleware suspends the run
to put a question to a person — on the pause machinery `askHuman` and `checkIn`
already ride, not a second one — and the answer that comes back is a
**decision, not a result**. Approve and the chain continues from the next link
and the REAL tool runs. Decline and it becomes a denial the model reads and
adapts to. Nobody, not the middleware and not the person who approved it, gets
to write the tool's answer.

And a transform says so. `allow(value, why)` **requires** the `why`, and the
run commits both the value it received and the value it produced. A prompt
scrubbed by a middleware that hid its own scrubbing would poison every slice
taken afterwards: the trace would show text nobody ever sent.

### Added

- **`.toolMiddleware(...middleware)` — a chain around every tool dispatch.**
  Each link answers `allow()`, `allow(args, why)`, `deny(reason)` or
  `ask({ question })`. Order is call order and each link sees the previous
  one's output; the first non-`allow` answer wins and the rest of the chain
  does not run, because a refusal a later rule could overturn is not a refusal.

  A `deny` reason **reaches the model verbatim**, as the tool result, on the
  same synthetic-tool-result path every other refusal in the dispatch loop has
  always used — so the loop continues and the agent adapts in flight. A denial
  is data, not a crash.

  The chain sits **after the permission gate** (an existing `PermissionChecker`
  still decides first, and a call it denies never reaches a middleware) and
  **before arg validation** (so validation judges the args that will actually
  be sent, not the ones the model proposed).

- **`.messageMiddleware(...middleware)` — a chain around the message
  boundary.** The `'input'` half runs at the very top of the run, BEFORE the
  message is committed; the `'output'` half at the moment the run knows this
  turn is the final answer.

  The input placement is the whole point. Everything downstream reads the
  committed history — the window strategies, the injection engine, all three
  slots, the bytes on the wire, and every slice taken afterwards — so the
  transformed text is what the entire run agrees was said. Transform any later
  and the trace shows one message while the model answered another.

- **`allow` / `deny` / `ask`, flat on the package root.** They are the verbs of
  this domain, written several times inside every middleware body.

- **`ask` suspends on the SHIPPED pause machinery.** `isAskPause(outcome)`
  narrows a paused run and `outcome.ask` carries `{ question, detail?,
middleware }`. Resume with `checkInApproved` / `checkInDeclined` — the same
  human-answer vocabulary check-ins use, deliberately, because a person
  approving is a person approving and one word for one thing beats a synonym.
  A malformed resume DECLINES, so a governed call can never execute because a
  message was mis-shaped.

  Because it is the shipped wire and not a new one, hosting needed no change at
  all: over a `standingAgent` an outstanding middleware question is refused as
  unfinished work with `PauseNotCarriedError`, naming the tool, session
  untouched — byte for byte what `askHuman` already did. A test pins it.

- **`scope.middlewareDecisions` — the ledger.** One row per decision, in order,
  carrying the middleware's name, the outcome, whether the value changed, the
  `why`, and — for a transform — both the `before` and the `after`.

  Every decision files a row, **including the pass-throughs**: "the rule looked
  and was fine with it" and "the rule never ran" are different facts about a
  run. Written only by an agent that configured a chain; absent otherwise.

- **`MessageDeniedError`** — raised when a message chain refuses, at either
  phase, carrying `reason` / `phase` / `middleware` and never the refused
  content.

- **`mcpServe(tools, { toolMiddleware })`** — the governance law extended to
  the served boundary. It runs before `execute` and before credentials resolve,
  exactly as inside an agent. An `ask` there answers the client with a tool
  error **naming the middleware** rather than executing ungoverned, in the same
  wording the `checkIn` refusal uses.

- **One typed event, `agentfootprint.middleware.decision`** (68 events, 20
  domains). It carries the fact — who, where, which outcome, whether the value
  changed — and deliberately **not** the values.

### The judgements these rest on

**A middleware cannot fabricate a result, and that is a type, not a rule.**
Every framework that lets a wrapper return a value eventually has a run whose
trace says a tool answered when nothing did. Removing the arm removes the
class. It is also why `ask` resumes with a decision: had the human's answer
become the tool result, a middleware would have found the fabrication door
again, through a person.

**`ask` exists only where a pause exists.** `ToolOutcome` has it because tool
dispatch runs inside a footprintjs pausable stage. `MessageOutcome` does not,
because the message boundary is a plain stage — and inventing a second pause to
give it one would be a worse answer than not offering it. The type says so, so
nobody discovers this at run time.

**footprintjs 9.14's `interrupt()` was considered and declined.** It re-enters
the stage from its TOP on resume, and tool dispatch runs N tools in one stage
body — so resuming would re-execute every tool already dispatched that
iteration, which is precisely the class of side effect a middleware exists to
govern. (It would also have forced a peer bump inside a minor.) The pausable
handler's execute/resume split does not re-run the loop, so `ask` rides that.

**At most one human question per resume.** A resumed dispatch has no second
checkpoint to offer. If a later link also asks — or the tool itself declares
`checkIn` — the call is **not executed** and a named refusal reaches the model,
matching the rule already applied to a tool that tries to pause during an
approved check-in resume. Executing a call whose consent gate was silently
skipped would be the worse failure by a wide margin.

**The output chain runs in the Route decider, not in PrepareFinal.**
PrepareFinal is where `finalContent` is set, which makes it look like the seam.
It is not: it lives inside the Final BRANCH subflow, whose state does not merge
back into the run, so ledger rows filed there would sit in an isolated commit
log and split one ledger across two places. The decider is one stage earlier,
in the main chart, and it is the first moment the run knows this turn is the
answer.

**`allow` / `deny` / `ask` are flat exports, and that was a deliberate call.**
They are generic words on a package root and the collision surface is real.
They are also the verbs of this domain, written constantly inside middleware
bodies, and the ergonomic value of `deny('writes to prod need a ticket')` over
a namespaced ceremony is worth more than the theoretical clash. The names were
unclaimed.

**The ledger keeps the original, and for the `'input'` phase that row is the
only copy.** The seed stage commits the transformed message and nothing else
holds what arrived. That is the honest trade: the transform is legible
precisely because the run kept what it replaced. If the original must not
survive in the commit log, that is a redaction question and footprintjs already
answers it — configure redaction over `middlewareDecisions` and `before` /
`after` scrub at write time while the decision row survives. A security test
pins both halves, because honesty and protection are both laws and that is
where they meet.

**Absent middleware is byte-identical.** No chain walk, no committed key, no
bridge attached, the same request bytes, and seed stays the synchronous stage
it always was. A test pins the wire and the committed key set against an agent
built without one.

Docs: [Middleware](https://footprintjs.github.io/agentfootprint/docs/build/middleware) ·
Example: `examples/features/37-middleware.ts`.

## [7.17.0] - 2026-08-02

7.16.0 shipped compaction. It shipped it on a seam — an internal
`WindowStrategy` interface with exactly one implementation behind it, pinned
private, because a seam nobody has used twice is a guess.

It has been used three times now, so it is public, and its two market siblings
ship with it.

**`slidingWindow({ keepRecentTurns })`** keeps the last N turns and drops the
rest. **`tokenBudget({ thresholdTokens })`** keeps compaction's trigger
discipline — counted from the provider's own reported usage, never estimated —
and drops instead of summarizing. Neither makes an LLM call. Both go through
the SAME turn segmentation and the SAME refusal engine as compaction, which is
the whole point: a message-counting trimmer splits an assistant's `tool_use`
from its `tool_result` and the vendor rejects the request. Here that turn
refuses **by name**, and the strategy takes the next oldest instead.

The one-sentence differentiator, true of all three: **every strategy here
records what it removed, by id.** The removed turns stay in the commit log
byte-identical — footprintjs's log is append-only, so nothing can edit them —
and each strategy files its own recorded step naming every `runtimeStageId`
whose messages left, plus one `context.evicted` event per message with its
measured lifetime. Removing is not forgetting.

### Added

- **`.window(strategy)` — the general door.** Pass any `WindowStrategy`.
  `.compaction({ ... })` stays exactly as it shipped and is now sugar for
  `.window(summarizeOldest({ ... }))`; a test pins that the two spellings send
  byte-identical requests and file identical records. Exactly one strategy per
  agent — a second through either door throws at build time, because a window
  policy that quietly changed is a policy you cannot audit.

- **`slidingWindow({ keepRecentTurns })` — keep the last N turns.** No
  summarizer, no LLM call, and **no usage requirement**: it triggers on turn
  COUNT, so it runs on any provider, including the OpenAI-compatible endpoints
  that send no usage while streaming. Nothing about it is unmeasurable, so it
  never throws. It also emits **no** `context.budget_pressure` — it has no
  token budget, and reporting a `capTokens` nobody configured would be exactly
  the invented number this family refuses.

  `keepRecentTurns` is required and has no default. It _is_ the policy.

- **`tokenBudget({ thresholdTokens, keepRecentTurns? })` — counted, then
  dropped.** Reads the input tokens the adapter reported for the last call and
  drops the oldest contiguous removable span when they exceed the threshold.
  A provider that reports no usage gets the same named refusal compaction
  makes: **`CompactionUnmeasurableError` is now thrown by both token-triggered
  strategies** and kept its 7.16 name rather than gain a synonym for the same
  refusal.

- **The strategy seam is public**: `WindowStrategy`, `WindowStrategyInput`,
  `WindowStrategyResult`, `WindowEviction`, `RemovalFacts`, `RemovalPlan`,
  `Turn`. Two things are deliberately not left to an implementer. The refusal
  rules arrive **pre-bound** as `input.planRemoval(...)` — a strategy never
  receives the guards, only the answer, so it cannot forget that an unanswered
  tool call must not leave the window. And `input.removalFacts(...)` resolves
  provenance, so a strategy cannot file a removal it is unable to name. The
  TRIGGER, by contrast, is entirely the strategy's own: `plan()` is called at
  every iteration boundary and returns `undefined` when it did not engage.

  Each factory is its own module and registers nothing at import — a bundle
  that never mentions `summarizeOldest` never carries the summarizer
  machinery. A test pins that importing them mutates no registry, and that
  none of them needs an entry in the `sideEffects` allowlist.

- **`WindowRecord` — one record shape for the family**, carrying `strategy`,
  `removedStageIds`, `removedMessageCount`, exact `windowCharsBefore/After`
  and the named `refusals`. `CompactionRecord` now extends it, and
  `SlidingWindowRecord` / `TokenBudgetRecord` add each strategy's own facts.
  All three land on the same `scope.compactions` array; narrow by `strategy`.

  The key stays `compactions` — the name the family's first member gave it.
  It is committed state, which is public surface for everyone reading a run,
  and renaming a committed key one release later would break those readers for
  a better word.

- **`DROP_NOTICE_PREFIX` / `isDropNotice(msg)` — what a drop leaves behind.**
  When a drop removes the window's HEAD, one authored `user` message takes
  that position. The first reason is the wire, not the prose: an agent window
  is `user, assistant+tool, assistant+tool, …`, so dropping the oldest turns
  leaves an assistant message at the head, and the providers that care require
  the window to open on a user turn. Something must occupy that position —
  and a message we are forced to author should say what happened. Unlike the
  compaction frame, **no model wrote a word of it**.

  It appears only at the head (a removal in the middle leaves the opening turn
  in place, so there is nothing to fix and a spliced `user` message is its own
  risk); it never accumulates (the next drop absorbs it); and if it would not
  be _smaller_ than the span it replaces, the whole drop is abandoned under
  `summary-not-smaller`, whose meaning generalizes to "the replacement came
  back no smaller than the span" rather than growing the closed reason union.

### Changed

- **`WindowRefusal` / `WindowRefusalReason` are the canonical names** for what
  7.16 called `FoldRefusal` / `FoldRefusalReason`. Refusals are shared by all
  three strategies and only one of them folds. The old names remain exported
  as deprecated **aliases of the same types** — code written against 7.16
  compiles unchanged, and they are not going away in 7.x. Same for
  `CompactionRecord.foldedStageIds` / `foldedMessageCount`, which are still
  written and now sit alongside `removedStageIds` / `removedMessageCount`.

- **Compaction records now carry `strategy: 'summarize-oldest'`** and the two
  family field names described above. Everything 7.16 wrote is still written.

- **The window stage now asks its strategy at every iteration boundary**,
  because the strategy owns its own trigger. For a compaction agent that means
  the reads channel records a read of `history` on iterations that change
  nothing — a read which genuinely happens. Request bytes, committed values
  and the commit-log structure are unchanged; only the recorded reads differ,
  and anyone diffing recorded reads across 7.16 → 7.17 will see it.

- The one-per-run dev warning from a broken summarizer is now prefixed
  `[agentfootprint window:summarize-oldest]` rather than
  `[agentfootprint compaction]`, so the log line names the strategy that
  produced it. The chart's `compact` stage keeps its id (every lens and
  matcher binds to it) and now describes which strategy is mounted.

Docs: [Window strategies](https://footprintjs.github.io/agentfootprint/docs/build/window-strategies) ·
Example: `examples/context-engineering/12-window-strategies.ts`.

## [7.16.0] - 2026-08-02

Every agent framework compacts a long conversation the same way: summarize the
old turns, drop them, carry on. It works, and it costs you the run. The next
day, when you have to explain what the agent did, the middle of the
conversation is gone — replaced by a paragraph a cheap model wrote, presented
as if it were what happened.

A summary is a **claim about the past**. This release files claims as claims.

**The law: compaction edits the WINDOW, never the LEDGER.** The window is
`scope.history`, the array that goes on the wire. The ledger is the run's
commit log, which is append-only — the stages that wrote those turns committed
them before the fold existed, so a fold physically cannot erase them. What it
can do is stop re-sending them, and say so: the summary enters as **its own
recorded step**, naming every `runtimeStageId` it folded, what the last call
actually measured, and every turn that refused to fold and why. A compacted run
is still a provable run. The lens draws a fold seam, not a hole.

The example prints both halves of that sentence — the small window the model
now sees, and the original 1,150-character tool output pulled back out of the
commit log, verbatim.

Two properties make it a compactor you can trust rather than one you hope
about. It is **counted, not guessed**: the trigger reads the input tokens the
provider itself reported, and a provider that reports none gets a named
refusal instead of an invented number. And it **never folds an open question**:
a turn holding an unanswered tool call, a paused tool, or a pending check-in
refuses by name, and the fold takes the next oldest instead — because folding
an unanswered question destroys the referent of the answer that has not
arrived yet.

### Added

- **`.compaction({ thresholdTokens, summarizer, model?, keepRecentTurns? })` —
  keep the live window inside a token budget without ever losing the record.**
  At each ReAct iteration boundary the compaction stage compares the last
  call's **adapter-reported** input tokens against `thresholdTokens`. Over
  budget, it folds the oldest foldable span of the conversation into one
  summary message and sends that instead.

  `thresholdTokens` is **required, with no default**. The right budget depends
  on your model and your bill; a number the library invented would be inherited
  silently by every run. `summarizer` is required and explicit for the same
  reason in the other direction — the library will not quietly bill your main
  model for compaction.

  Never folded: the system envelope (it is not in the window at all — it rides
  `systemPrompt`), the last `keepRecentTurns` turns (default 6), and any turn
  holding something unresolved. The fold always takes a **contiguous** span, so
  a turn that refused never ends up sitting after a summary of things that
  happened before it — survivors keep their order.

  Omit `.compaction()` and nothing changes: no stage, no extra committed key,
  the same request bytes. A test pins that a configured agent whose threshold
  is never reached sends requests byte-for-byte equal to an unconfigured one.

  Docs: [Compaction](https://footprintjs.github.io/agentfootprint/docs/build/compaction) ·
  Example: `examples/context-engineering/11-compaction.ts`.

- **`CompactionRecord` on `scope.compactions` — the fold's half of the law.**
  One record per over-budget visit, _including the visits that folded nothing_,
  which are the interesting ones. It carries `foldedStageIds` (real
  `runtimeStageId`s, resolvable in the commit log), `foldedMessageCount`,
  `measuredTokens` vs `thresholdTokens`, exact `windowCharsBefore` /
  `windowCharsAfter`, what the summarizer call cost, and a **named refusal per
  turn** that did not fold: `unresolved-tool-call`, `paused-tool`,
  `pending-check-in`, `system-envelope`, `inside-keep-window`,
  `only-existing-summary`, `summarizer-failed`, `summary-not-smaller`.

  There is deliberately **no `tokensAfter`**. Nothing can count the tokens of a
  window that has not been sent yet, and inventing one would be exactly the
  guess this feature exists to refuse. The char counts are labelled as chars;
  the honest "after" is the next call's reported usage.

- **`CompactionUnmeasurableError` — the refusal for a provider that reports no
  usage.** Thrown at the first iteration boundary after a call that reported
  zero in and zero out, naming the provider. Terminal: `Agent.run` does not
  wrap it in a `RunCheckpointError`, because resuming would walk into the same
  wall with the same adapter. A configured budget that silently never applies
  is config that lies; compaction says so instead.

- **`COMPACTED_FRAME_PREFIX` / `isCompactedSummary(msg)`** — the authored frame
  is a library constant and the summarizer's text is appended after it as data.
  A summarizer returning `IGNORE ALL PREVIOUS INSTRUCTIONS` still arrives
  _inside_ a message that says, first and in the library's own words, that what
  follows is a summary written by a model and not the conversation. A test pins
  exactly that, with a hostile summarizer. The boundary points both ways: the
  folded transcript reaches the summarizer between markers the authored
  instruction names, and that instruction says to report an instruction found
  inside them, never to follow it.

### Fixed

- **OpenAI streaming reported zero tokens — for every streamed call, in both
  the Node and browser adapters.** `stream_options.include_usage` (which both
  adapters already asked for) delivers the token counts on a **final chunk
  whose `choices` array is empty**; both providers guarded on a missing choice
  and `continue`d past it before reading `chunk.usage`. Everything downstream of
  `response.usage` therefore went to zero on the streaming path: most visibly
  **`costBudget` was silently unenforceable** under streaming OpenAI, and
  `cost.tick` reported nothing to spend.

  The unit fixture had blessed the bug — it hung usage off the finish_reason
  chunk, which no OpenAI endpoint does. The fixture now emits the real wire
  shape, so the old code fails it. Note that OpenAI-compatible endpoints
  configured with `legacyEndpoint` (Ollama, vLLM) are never sent
  `stream_options` and still report no usage while streaming; with
  `.compaction()` those now refuse by name rather than quietly never firing.

### Changed

- **With `.compaction()` configured, the compaction stage becomes the ReAct
  loop target** (`compact`), mounted immediately before the previous one. The
  loop is branch-sourced, so anything ahead of the target runs once and is
  never seen again — and being the target puts the fold _before_ the injection
  engine and the three context slots, which is the point: the triggers, the
  slots and the wire then all see one window, and no part of the run reasons
  over a past the model was not shown. Without `.compaction()` the loop target
  is exactly what it was.

- **`docs/internals/README.md` is marked HISTORICAL.** It described a source
  tree that does not exist and two seams that never shipped — `MessageStrategy`
  (with `fullHistory` / `slidingWindow` / `charBudget` / `summaryStrategy`) and
  `PromptProvider`. Nothing exports them and nothing ever did. An advertised
  seam that does not exist is a documentation defect whether or not it is on
  the published site, so the file now says so at the top and points at what
  actually governs the window.

## [7.15.0] - 2026-08-02

7.14.0 shipped two hosting ports that name no cloud, plus a conformance suite,
and made a promise: _a cloud adapter is vendor paths and a header mapping on a
port that already worked; if writing one needs a change to a port, the port was
wrong._ A promise like that is worth nothing until somebody writes the adapter.

This release writes it. `agentCoreRuntimeHost` is a real cloud runtime's
container contract — different paths, different body fields, the conversation id
in a header instead of the body — and it **passes the same conformance suite as
`nodeHost`, over a real socket, with no change to any port type**. It is now the
third subject in that suite, so if a future adapter changes an answer, the file
goes red.

The rest of the car is the same shape: an AgentCore session store, an AgentCore
policy store behind the existing permission port, per-request credential vending
for Gateway tools, and the memory adapter's `search()` finally wired.

Three seams did have to move, and none of them was a port. They are listed under
"Changed" rather than buried, because _where an adapter needs more than paths
and headers_ is the interesting result of an exercise like this — and two of the
three turned out not to be about this vendor at all.

### Added

- **`agentfootprint/hosting-providers` — `agentCoreRuntimeHost()` and
  `agentCoreSessions()`.** The AgentCore Runtime container contract as adapters
  on the 7.14.0 ports: `POST /invocations`, `GET /ping`, port 8080, `0.0.0.0`,
  `{ prompt }` in / `{ response, status }` out, and the conversation read from
  the `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` header (matched
  case-insensitively — a proxy in front of the container is free to re-case it).

  `busy` is a function rather than a flag, because the runtime polls `/ping` to
  decide whether to send you more work: "am I busy" is a live fact about the
  process, not a setting.

  `agentCoreSessions({ store })` picks the checkpoint's home at construction and
  never per call. `'session-storage'` is a JSON file in the runtime's own
  storage — no AWS SDK at all, survives a container stop/resume, written
  then renamed so a kill mid-write leaves the previous conversation rather than
  rubble. `'memory'` is one AgentCore Memory event per persist, outliving the
  session entirely. Both refuse an unknown envelope `format` **by name** through
  the same `readEnvelope` the composer uses — that law is inherited, not copied.

  Its own subpath rather than `agentfootprint/hosting`, on purpose: a test greps
  the hosting sources for vendor names to keep "these ports name no cloud"
  literally true, and the barrel is a hosting source.

  Docs: [AgentCore adapters](https://footprintjs.github.io/agentfootprint/docs/infrastructure/aws/agentcore-adapters) ·
  Example: `examples/deploy/agentcore-runtime.ts`.

- **`httpHost({ name, wire, invokePath, healthPath, port?, hostname?,
capabilities? })` — the HTTP work, parameterised by the JSON dialect it
  speaks.** Draining on close, aborting when the caller hangs up, failing a
  handler that throws, failing a handler that answers nothing, mapping refusal
  codes to status codes, and choosing between one JSON body and Server-Sent
  Events — all of it happens once. An `HttpWire` re-decides five body shapes and
  nothing else: `readRequest`, `health`, `output`, `failure`, `chunk`.

  `invokePath` and `healthPath` are **required, with no defaults**. A default
  here would be inherited by every adapter ever built on this file.

  `jsonWire` (nodeHost's own dialect) and `headerValue(facts, name, ...alts)`
  are exported too — the latter so no adapter re-derives case-insensitive header
  matching and gets it subtly wrong in exactly one deployment.

- **`agentCorePolicy({ policyStoreId, region?, onUnavailable?, onWarning?,
principalFor?, name?, cacheSize? })` — an AgentCore policy store behind the
  existing `PermissionChecker` port** (`agentfootprint/security`). Every
  attempted tool call becomes one evaluation.

  It **fails closed**: an engine you cannot reach has not said yes, so an
  evaluation that throws is a `deny`. So is a verdict the adapter does not
  recognise — an unfamiliar future value is not a permission. The denial reaches
  the model **as data** (`tellLLM`), so it re-decides with the refusal in front
  of it rather than the run dying; telemetry stays out of it, because a model
  should not be taught the shape of your rule space. Decisions are cached per
  (tool, principal, conversation, **iteration**), so a policy that changes
  mid-conversation lands on the next turn instead of after the run, and the
  cache is bounded.

  `onUnavailable: 'allow-with-warning'` exists for a gradual rollout and is
  deliberately awkward to type. It applies only to a failure to CHECK — an
  explicit denial is always a denial.

  It composes with `gatedTools` unchanged, and neither knows the other exists:
  the gate decides what the model is shown, the checker decides what runs. A
  test pins that. Note that only a local policy can double as a `gatedTools`
  predicate — that predicate is synchronous and a remote engine is not.

- **`gatewayTransport({ url, credentials, service?, scopes?, mode?, headers? })`
  — an MCP transport whose auth headers are vended per request**
  (`agentfootprint/tool-providers`). A third member of `McpTransport`; `stdio`
  and `http` are untouched.

  **The token is used once and dropped.** Not cached between requests, not
  stored on the transport object, not in an event, a log, or any error this
  module throws — including the errors thrown while it is holding one. A test
  asserts exactly that, with a hostile logger capturing every console channel,
  the serialized transport, and every thrown error. Static `headers` are applied
  first so a vended auth header always wins.

  When consent is needed it throws `GatewayAuthorizationRequiredError` carrying
  the authorization URL: a transport cannot run a consent flow mid-request, so
  it says so instead of hanging.

- **`AgentCoreStore.search()` — server-side semantic retrieval over
  `RetrieveMemoryRecords`.** `searchStrategyId` and the namespace reach
  AgentCore's side as filters; `k`, `minScore` and `tiers` are applied to what
  comes back. A `tiers` filter excludes everything, because records carry no
  tier and silently ignoring a filter you asked for is worse than returning
  nothing.

  Results are marked `metadata.source: 'agentcore-memory-record'`, because
  `search` reads a genuinely different population than `list`: the records
  AgentCore's extraction strategies _derived from_ your events, whose ids belong
  to AgentCore, so `store.get(result.entry.id)` will not find them.

  There is **no `stream()`**. AgentCore Memory has no streaming data-plane
  operation and `MemoryStore` has no streaming method — inventing one for a
  single backend is how a port stops being a port.

### Changed

- **`nodeHost` is now a configuration of `httpHost`.** Same name, same options,
  same wire, same behaviour; the HTTP implementation is shared rather than
  duplicated, so two adapters can never quietly drift apart on what `close()`
  drains. This is the one seam this exercise found in the hosting layer, and it
  was in the FIRST ADAPTER, not the port: `nodeHost` had hard-coded its own JSON
  dialect, which was fine while it was the only HTTP adapter and wrong the
  moment there was a second. No public type changed.

- **`SearchOptions.text?: string` — one new optional field on the memory port.**
  `search()` takes a vector because the reference backends rank locally by
  cosine; several managed stores embed and rank server-side and their retrieval
  API takes text, so a vector is the one thing they cannot use.

  Stores that rank locally **ignore** it and their results are unchanged, so
  passing both is always safe. `AgentCoreStore.search()` requires it and throws
  a corrective error naming what is missing — not an empty array, which reads as
  "no matches" when it means "wrong query form", and nobody investigates a
  plausible empty result.

- **MCP headers were connection-lifetime only.** That is a real gap and it is
  not AgentCore's — any endpoint behind an expiring token hits it — so the fix
  landed on the generic transport layer and the vendor adapter for it is zero
  lines.

- **`examples/deploy/agentcore-runtime.ts` now USES the adapters** instead of
  carrying its own hand-written `node:http` handler. One source of truth for the
  contract. It still self-tests and exits, still serves forever under
  `AGENTCORE_SERVE=1`, and now also proves a two-turn conversation continuing
  through the session header alone.

### Verification, stated plainly

`agentCoreRuntimeHost` is plain HTTP with no AWS SDK on its path, so its
conformance-suite result is **real verification** of the wire.

Everything that talks to AWS — `agentCoreSessions({ store: 'memory' })`,
`agentCorePolicy`, `AgentCoreStore.search()` — is **contract-mapped and
injection-tested**: every SDK interaction is exercised through the adapters'
`_client` / `_sdk` seams, and no test in this repo reaches AWS or pretends to.
Confirm command and field names against your installed
`@aws-sdk/client-bedrock-agentcore`. Real-cloud verification lands with a field
deployment.

## [7.14.0] - 2026-08-02

An agent in a script answers once and forgets. A deployed one has to do two more
things — be reachable, and continue a conversation it started before the last
restart — and those two things are usually where a framework quietly becomes one
vendor's framework. The request shape arrives from whoever you deployed on, the
session store is theirs too, and by the time a second runtime matters the
"portable" agent has a container contract baked into its type signatures.

So this release adds the two things as **ports that name no cloud**, and proves
they are ports rather than asserting it: a conformance suite runs one handler
against the shipped HTTP adapter and against a second, minimal host written in
the test file, and compares the answers. Both adapters here are local. The
cloud ones come later and have to pass the same suite — that is the whole design,
and if writing one ever needs a change to a port, the port was wrong.

### Added

- **`agentfootprint/hosting` — `AgentHost` + `SessionLifecycle`, the two ports
  between an agent and the place it runs.** `HostRequest` carries an `input`, an
  optional `sessionId`, `headers` and a `signal`; `HostReply` has `complete`,
  `emit` and `fail`; `HostHandler` maps one to the other and `HostHandle` has
  one method. That is the entire surface, and it is the vocabulary every
  transport already has and nothing else.

  Capabilities are **feature-detected, never assumed**: `AgentHost.capabilities`
  is a list of `HostCapability`, today exactly one name — `'streaming'` —
  because that is what a shipped adapter can actually honour. No names were
  pre-minted for transports that do not exist yet; a capability nobody
  implements is a promise the library cannot keep, and inventing one in
  anticipation of a particular runtime would bake that runtime in before it
  arrived. `requireCapability(host, cap)` throws a corrective error naming the
  adapter you are actually holding.

  A handler emits freely and completes once. A host that streams delivers each
  piece as it arrives; a host that cannot buffers them and lets the
  authoritative `complete(output)` settle the buffer — the pieces were a preview
  of the same text, never an addition to it. **The handler cannot tell the
  difference and does not need to.**

  Docs: [Host it](https://footprintjs.github.io/agentfootprint/docs/infrastructure/hosting-and-runtime) ·
  Example: `examples/deploy/standing-agent.ts`.

- **`nodeHost({ port?, hostname?, invokePath?, healthPath? })` — plain
  `node:http`, zero dependencies.** `POST /invoke` takes `{ input, sessionId? }`
  and answers `{ output }`; `GET /health` answers `{ status: 'ok' }`. Send
  `Accept: text/event-stream` and the same handler produces Server-Sent Events
  instead — the caller chooses, not the server.

  Both paths are options, and the defaults were chosen rather than inherited.
  `POST /invocations` is one cloud runtime's container contract and it very
  nearly became the default here by momentum; a default that silently matches
  one vendor is that vendor leaking into a library that promises not to know
  about it. A test greps the hosting sources for cloud vendor names **and for
  that path literal** — crude on purpose, because the failure it guards against
  is somebody in a hurry adding `region` to a port "just for now".

  `serve()` resolves to a `NodeHostHandle` reporting the `url` and `port` it
  actually bound, which is the only way to find out when you asked for port `0`.
  `close()` drains: in-flight work finishes, later arrivals get a
  `HostClosedError` (`503`).

- **`standingAgent({ agent, sessions, host, onConcurrentInvoke? })` — the
  composer.** Per request: wake and hydrate the session, resume that
  conversation or start a fresh one, persist what the run left behind, reply.
  Persist happens _before_ the answer goes out, so a queued next turn can never
  read state older than the answer already given.

  It restates the `resumeOnError` tool re-execution caveat **verbatim** in its
  own docs. A composition that hides the caveat of the thing it composes is
  worse than no composition.

  **Runs are serialized globally, and that is a correctness requirement rather
  than a tuning choice.** An `Agent` holds per-run state on itself and this
  composer shares one instance across every session; two overlapping runs do not
  crash, which is exactly the danger — they both finish, and the state read
  afterwards belongs to whichever started last, so one session's envelope can end
  up holding another session's conversation with nothing in the recording to say
  so. `ConcurrentInvokePolicy` is the separate question of a second turn of the
  _same_ conversation: `'reject'` (default) refuses with a `ConcurrentRunError`
  naming the active run (`409`), `'enqueue'` queues it FIFO behind the run whose
  state it will then read. A request for a **different** session is never
  refused — it waits its turn.

- **`CheckpointEnvelope` + `toEnvelope` / `readEnvelope` / `memorySessions()`.**
  What crosses a restart is `{ format: 'conversation-v1', data, savedAt }`, and
  an unknown `format` is **refused by name**: a store outlives the code that
  wrote to it, someone will deploy a newer runtime, and an older instance still
  running will meet its output. Restoring what it can and hoping means an agent
  answering from a conversation with pieces missing. Formats are added, never
  redefined.

  `SessionLifecycle` is `hydrate` + `persist` + an optional
  `onWake(sessionId, reason)`; `WakeReason` has one member, `'invoke'`, because
  that is the only thing in this release that can fire it.

- **`agent.checkpoint()` — the conversation the last completed run leaves
  behind**, as the same `AgentRunCheckpoint` that `resumeOnError` accepts. Read
  from the run's own recording, cloned on the way out, with the final assistant
  turn appended from the answer `run()` returned.

  That last clause is the load-bearing one. Nothing writes the final assistant
  turn back into the agent's history — the loop appends assistant turns only when
  they carry tool calls, and the turn that ends a run carries none. An agent that
  stored the conversation without it would drop its own reply **every turn** and
  answer the next one having forgotten what it just said: still fluent, still
  wrong, invisible until someone reads a transcript. It is pinned by a test that
  asserts on the provider's actual wire that turn 2's request contains turn 1's
  assistant reply verbatim, and deleting the append turns six tests red.

  Adds no events, no scope writes and no capture — recordings are byte-identical
  to an agent that never calls it.

### The judgements these rest on

**A pause is unfinished work, never a failed run.** If a run pauses to ask a
person something, `standingAgent` answers with a `PauseNotCarriedError` and
writes **nothing** — the session keeps exactly the conversation it had before the
request. Over HTTP that is a `409`, not a `500`, because the agent did not break
and every dashboard that sees a 500 will conclude otherwise. `'conversation-v1'`
stores a conversation; a paused run is a conversation _plus_ an engine
checkpoint, and storing half of it would be worse than storing none. Carrying a
pause would be a NEW format name in the same envelope — which is precisely what
the version in the format is for.

**A failed run writes nothing either.** The session keeps its last good state
rather than inheriting the shape of whatever went wrong.

**A request with no `sessionId` is answered and not stored.** There is nothing to
hydrate and nowhere to persist it that the caller could ask for again. And
`sessionId` is documented as caller data, not identity: anyone who can reach the
host can send any string there, including someone else's.

**The conformance suite is the deliverable, not the tests for it.** One handler
constant, served by `nodeHost` and by a minimal in-process host that declares
_no_ capabilities so the buffering path is exercised rather than assumed, with a
final pair of cases invoking both and comparing directly. A future adapter —
including a cloud one — is measured against that file.

## [7.13.0] - 2026-08-02

Three small things that were each one step short of usable. Skills could be
declared but only in TypeScript, so the prose a support lead should own lived in
a template literal three imports deep. MCP worked in one direction only: you
could consume anyone's tools and expose none of your own. And an agent was built
once with one model, so serving two tenants meant either rebuilding it per
request or mutating it — and a mutated agent makes its own trace wrong.

None of them needed a new mechanism. Each is a loader, an adapter pointed the
other way, and a value committed where run-level values already commit.

### Added

- **`skillsFromDir(dir)` — Skills authored as files.** A directory of `SKILL.md`
  files, frontmatter `name` + `description` as the disclosure stub, everything
  after the closing fence as the body. The same convention Claude Code made
  familiar, so a skill folder is portable between the two.

  It is a **loader, not a second mechanism**: each file goes to `defineSkill`, so
  the description is still all the model sees until it calls `read_skill(<id>)`,
  and the body still arrives only after it does. What changes is who can edit a
  playbook, and whether changing the refund policy shows up as a reviewable diff.

  A skill body is _instructions to a model_, so where it came from is a security
  property rather than a convenience — content fetched at run time is content
  someone else can change after you reviewed it. The loader therefore accepts a
  local directory and nothing else: a URL is **refused by name**, not fetched.
  Malformed frontmatter is refused naming **the file**, and two files claiming
  one skill name are refused naming **both** — a loader that says "malformed
  frontmatter" over forty files has told you nothing. A directory with no
  `SKILL.md` is an error too, not an empty array.

  Node-only, but `node:fs` is imported lazily inside the call, so importing
  `agentfootprint/injection-engine` in a browser bundle is still safe.

  Docs: [Skills](https://footprintjs.github.io/agentfootprint/docs/build/skills) ·
  Example: `examples/context-engineering/09-skills-from-dir.ts`.

- **`mcpServe(tools, opts)` — MCP, the other direction.** `mcpClient` pulls
  someone else's tools in; this pushes yours out, over stdio (default) or
  stateless Streamable HTTP. `close()` on the returned handle stops it, and is
  idempotent.

  The promise the feature rests on: **a served tool is the object you passed
  in.** `mcpServe` holds your `Tool` by reference and calls
  `tool.execute(args, ctx)` — it never copies the schema and re-implements the
  body, never unwraps a decorator, never reaches past a wrapper to an inner tool.
  So a permission check inside `execute` is still what runs when a remote client
  calls it (pinned by a test that serves a `PermissionPolicy`-guarded tool and
  watches the denial come back over the protocol). Serving is not a back door
  into your tool; it is the same door with a longer corridor.

  A hostile client is the tool's problem, never the server's: unknown tool names,
  `null`/numeric/array arguments and tools that throw all return `isError: true`
  and the loop answers the next call. Arguments are forwarded **verbatim** —
  re-validating them here would be a second, weaker copy of the contract the tool
  already enforces.

  Both transports are exercised for real in the test suite — a spawned child
  process for stdio, a bound socket for Streamable HTTP, and the MCP SDK's own
  `Client` on the other end — rather than only through an injected server. That
  is how the HTTP path's shape got settled: the SDK's stateless transport is
  single-use by design, so the listener mints a server and a transport per
  request. One shared pair initializes fine and then answers `500` to everything
  after it, which no amount of injected testing would have shown.

  Docs: [Serve your tools over MCP](https://footprintjs.github.io/agentfootprint/docs/build/mcp-serve) ·
  Example: `examples/context-engineering/10-mcp-serve.ts`.

- **`.configure((ctx) => ({ model?, instructions? }))` — per-run config that the
  trace can be trusted about.** Resolved ONCE per run at the start of the run,
  with the run's message, identity, runId and the agent's build-time `defaults`
  in hand.

  What it resolves is **committed**: `resolvedModel` and `resolvedInstructions`
  ride the same commit that already carries the run's other run-level facts
  (identity, iteration budget, turn number), landing in the commit log before the
  first LLM call — and the LLM call reads them from there, so what was called,
  what `stream.llm_start` reports and what cost is priced against are one value.
  A run that switched models without recording it would produce a recording that
  says the built-in model answered, which is a lie about the run's most expensive
  fact.

  Omit it and everything is byte-identical: no extra scope writes, no extra scope
  reads, same request bytes (pinned three ways). This is the RUN axis only —
  tools are the iteration axis and `.toolProvider()` already owns it.

  Docs: [Agent](https://footprintjs.github.io/agentfootprint/docs/build/agent) ·
  Example: `examples/features/36-per-run-config.ts`.

### Fixed

- **`McpClientOptions.signal` cancels a hung MCP tool call again — it never
  did.** The signal was being sent as part of the `tools/call` request
  _params_, where an `AbortSignal` JSON-serializes to `{}`: the server received
  a meaningless field and the caller received no cancellation. The SDK takes
  per-request options in a separate trailing argument, which is where the signal
  now goes; it is threaded to `connect()` and `listTools()` for the same reason,
  so the option covers the connect / list / call paths it always claimed to.

  This one is worth naming as a class rather than a typo. The mock the tests
  injected accepted the signal wherever it was put, so every test passed while
  the shipped behaviour was a silent no-op. It surfaced the moment a socket was
  on the other end.

### The judgements these rest on

**`mcpServe` refuses rather than degrades.** Two `Tool` capabilities cannot
survive a request/response protocol, and both fail at construction naming the
tool. `checkIn` asks a human to approve a call before it runs, and MCP has no
pause to carry that ask — serving it anyway would drop a consent gate silently,
which is worse than not serving at all. `needs` without a credential provider
would run the tool with `ctx.credential` undefined; passing `credentials`
resolves it before `execute`, fail-closed, exactly as the Agent does.

**`mcpServe` builds on the SDK's low-level `Server`, not `McpServer`.** Tools
already carry JSON Schema and MCP wants JSON Schema, so the mapping is the
identity function. `McpServer.registerTool` takes zod schemas, which would mean
adding a dependency to convert a JSON Schema into zod in order to convert it
straight back.

**`.configure()` writes only what the resolver actually returned.** Always
stamping `resolvedModel` would make every agent's commit log change shape, and
"absent option = byte-identical" has to mean the recording too, for a library
whose product is the recording. For the same reason, reading `scope.resolvedModel`
in the LLM stage is gated on a build-time flag rather than done unconditionally
with a fallback — an unconfigured run records the same reads it always did.

**`.configure()` is synchronous.** The seed stage where run-level facts commit is
synchronous, and making it async to accommodate a resolver would shift every
agent's timing for a feature most agents do not use. A resolver that needs I/O
can do it before `run()` and close over the result.

Every debugging session starts at a _variable_ — "where did that instruction come
from?", "which loop wrote the history it answered from?" — and both halves of the
answer already existed, in vocabularies that did not meet. footprintjs 9.13 records
a variable's whole life in commit indices and runtimeStageIds; the localizer thinks
in loops, injected sources and counterfactuals. Translating between them was left to
whoever was debugging, and each of them did it differently.

Joining them turned out to buy something bigger than a nicer read-out. The backward
walk narrows each loop with embedding similarity — a proxy that points at a
neighbourhood and cannot separate a planted instruction from an innocent same-topic
sibling. But where the recording carries per-write provenance, one part of that guess
is unnecessary: the commit log _says_ which write produced the value this loop read.
So the walk stops guessing exactly there — and keeps saying so everywhere else.

### Added

- **`traceVariable(artifacts, key)` — a variable's recorded life, in agent
  vocabulary.** One call over footprintjs's `keyTimeline` + `forwardSliceForKey`:
  every write and read in commit order, each labeled with the **loop** it happened
  in, each recognizable write labeled with the injected fact or tool result it
  introduced, and a `VariableAblationHook` per classifiable writer carrying the
  `AblationSpec` that would remove it. `joinVariableSlice(slice, trajectory, opts)`
  is the same join for a timeline you already hold.

  Pure assembly — no new capture, no scorer, no embedder, no LLM. Every field is a
  re-label of something the run already recorded, and footprintjs's honesty notes
  ride out verbatim rather than re-worded.

  Docs: [Variable recall](https://footprintjs.github.io/agentfootprint/docs/debug/variable-recall) ·
  Example: `examples/observability/21-variable-recall.ts`.

- **`walkToRoot({ variables })` — the hop the log can prove.** When a narrowed
  suspect rode in on a state key whose dataflow coverage is `'exact'`, the descent
  target is taken from that key's recorded ancestry instead of the proxy's
  provenance scrape, and the hop is stamped `narrowedBy: 'dataflow'`. Everything
  else stamps `'text-similarity'`. **Omit the option and the walk is unchanged** —
  same hops, same order, same verdicts (pinned by a deep-equal test).

  The proxy still picks WHO; dataflow picks WHERE. A stage-level edge never becomes
  an exact hop, and a recorded edge outranks the _inferred_ proximate-tool hop —
  better evidence wins, and the hop record says which kind it used.

- **`AgentOptions.writeProvenance`** (`'off'` default, `'reads-prefix'` to enable) —
  the fourth executor dial, alongside `readTracking` and `commitValues`. On, every
  write also records the keys read before it, which is what upgrades a variable to
  `coverage: 'exact'`. Off, every recording is byte-identical to 7.11.

- **`variableToBacktrackTrace`** — a variable's life on the same `BacktrackTrace`
  board the localizer report uses (one card per write, custody hops for the rewind
  player). `mode` is always `'correlational'`: nothing here was ablated.

### The judgement this rests on

`coverage: 'exact'` requires **positive** evidence — at least one recorded per-write
edge — not merely the absence of a conservative one. A key nothing ever reads back
(the agent's `lastToolResult`: written by tool-calls, never read by `call-llm`) has
an empty edge set, so "no conservative edges" is _vacuously_ true; scoring that as
exact would hand the walk its most confident hop on its least-evidenced key. Absence
of dataflow is `'unknown'`, never exactness.

The gate is also deliberately stricter than the hop strictly needs: the read→value
attribution the hop rests on is recorded independently of the write dial. Requiring
per-write fed-edge exactness on top is a conservative choice, so the deterministic
narrow ships behind the strongest available evidence — and a later release can relax
it with measurements instead of re-deriving why it was strict.

### Changed

- `RootCauseHop.narrowedBy` widens from the literal `'text-similarity'` to
  `HopNarrowedBy` (`'text-similarity' | 'dataflow'`). Additive: without `variables`
  every hop still stamps `'text-similarity'`.
- `ProximateToolSource` gains `stateKey` — the key its value was materialized from,
  so the walk joins on recorded data instead of a hard-coded string. Walk-only, as
  the rest of that record already was: L3's narrow and its measured recall are
  untouched (pinned).
- footprintjs peer dependency: `^9.13.0` (was `^9.10.1`) — `forwardSliceForKey` and
  `keyTimeline` are what this release consumes.

## [7.11.0] - 2026-08-02

A pipeline whose steps form a _shape_ rather than a line — one step feeding two
independent lookups, a third waiting for both — had no home here. You could nest
a `Parallel` inside a `Sequence`, but then you were scheduling it by hand, and
the values did not survive the trip.

### Added

- **`graph()` — a fixed DAG of runners, with the concurrency worked out for
  you.** Declare `nodes` and `edges`; Kahn levelization at BUILD time groups the
  nodes that do not depend on each other, and every node in a level runs at the
  same time. The result is every node's output keyed by node id. Roots (nodes
  with no parents) receive the graph's own input, and an edge carries the
  producer's OUTPUT to the consumer unchanged — there is no shared mutable scope
  between nodes.

  A broken shape never runs. A cycle, an edge pointing at a node that was never
  declared, and a duplicate node id each throw at construction, naming the
  offender (`graph: cycle detected — edge 'c' -> 'a' closes a loop.`). So does a
  node with two or more parents and no `join`: a silent merge is a wrong merge,
  so `join` is REQUIRED at fan-in, and it receives `upstream` keyed by parent
  node id.

  A failed node is reported with its name and its real reason
  (`graph 'support': node 'billing' failed: upstream is down`) — one sentence
  regardless of which of the two internal mounts the level used.

  `graph()` is a `Runner` like everything else, so it nests: a graph can be a
  node in another graph or a step in a `workflow()`, and every node's events
  land under ONE run so the causal log composes.

  Docs: [Graph](https://footprintjs.github.io/agentfootprint/docs/build/graph) ·
  Example: `examples/core-flow/06-graph.ts`.

### The trap this was built around

The obvious design — `graph = Sequence(Parallel(level0), Parallel(level1), …)` —
does not work on this codebase, and the second half of that was **not** already
known. v7.10.0 shipped `workflow()` because `Sequence`'s step contract is
`{ message: string } -> string` and its step `outputMapper` coerces a structured
step output to `''`. Verifying `Parallel` the same way turned up the identical
limit one layer down: its branch type is literally
`Runner<{ message: string }, string>`, and its branch `outputMapper` coerces a
non-string branch output to `''`. A `Parallel` level cannot carry a structured
value either. So `graph()` is built on the pass-through model `workflow()`
established — its own composition, its own mappers, the same recorder wiring and
the same `composition.enter` / `exit` events — rather than on top of
`Sequence`/`Parallel`.

Two further engine behaviours were verified rather than assumed, and are pinned
in tests:

- Values merged into parent state by a subflow's `outputMapper` are present in
  shared state but do **not** read back through TypedScope's nested property
  proxy — `scope.results` returns `{}` while `scope.$getValue('results')`
  returns the real record. `graph()` reads through `$getValue`. (`Parallel`
  never met this because it coerces every branch output to a string.)
- A level with ONE node is mounted sequentially rather than as a fork of one.
  Resuming into a fork child completes that child and stops, whereas a
  sequential mount resumes and carries on — so a pausing node that is alone in
  its level resumes through the rest of the graph. A pause inside a genuinely
  concurrent level still resumes only that node; that limit is documented and
  pinned rather than papered over.

### Note

`graph()` reports itself as composition kind `'Sequence'` — a graph's levels ARE
a sequence, and `CompositionKind` stays a closed union so consumers' exhaustive
switches keep compiling. Same reasoning as `workflow()` in 7.10.0.

## [7.10.0] - 2026-08-02

Two routing-shaped gaps closed. Both were things the docs told you to hand-roll,
and both were fiddly in the same way: the wiring is easy to get _nearly_ right,
and nearly right fails quietly — at run time, several steps away from the
mistake.

### Added

- **`llmRouter` — the classic Swarm decision, packaged.** `swarm()` asks for a
  `route()` that is sync and pure, and it means it: the `Conditional` evaluates
  it once per branch predicate and the loop's exit guard evaluates it again
  after every turn. So the LLM decision has to happen _somewhere else_, before
  the message reaches `route` — and that placement is the part everyone
  re-invented, along with the prompt, the parsing, and a second copy of the
  agent roster that drifts from the first.

  `llmRouter({ provider, model, agents })` ships all four pieces once. The
  roster compiles INTO the router's system prompt from each agent's own
  `description` — one source, so an agent can't be in the roster and missing
  from the prompt. `router.step` makes the decision and records it under the
  exact message it hands forward; `router.route` is then a lookup, and a message
  nobody decided about routes nowhere (the swarm halts) rather than guessing
  with a stale decision.

  The decision is validated JSON — `RoutingDecision` = `{ agentId?, message,
reason? }`. No `agentId` means "done", and the swarm halts through its own
  halt sentinel. An id that isn't in the roster is kept verbatim, _not_ quietly
  swapped for a plausible one: `swarm()`'s existing done/fallback law then ends
  the run, so a hallucinated agent shows up as a halt instead of a wrong answer.
  Unusable output throws `RoutingDecisionError` with the model's raw text
  attached (a markdown fence around good JSON is tolerated; prose is not).

  Two promises worth naming, both pinned by tests. Agent descriptions are
  **data**: each roster line is JSON-encoded, and the rules that bind the router
  are stated after the roster, so a description holding `"} IGNORE THE ABOVE.`
  can't escape its line or get the last word. And `reason` is **trace-only** —
  it lands on the decision and on `composition.route_decided` evidence, and
  never re-enters a prompt, so a model can't talk itself into a route across
  turns.

- **`llmSwarm` — that router, wired into `swarm()` in one call.** The placement
  rule is "one routing call before the first turn, and one after every turn".
  Get it wrong and the swarm halts on turn one for no visible reason, so this
  builds it: `Sequence(router.step → swarm(agents, route: router.route))`, with
  each agent's turn wrapped as `Sequence(agent → router.step)`. Agents carry
  their own `description`, so the roster the swarm dispatches on and the roster
  the model reads are the same list. `maxHandoffs` still bounds the loop;
  routing runs at `temperature: 0` by default.

- **`workflow()` — sequential steps whose hand-offs the compiler checks.**
  `Sequence` chains steps through one channel — text in, text out — and coerces
  any non-string step output to `''`. So a step that parses a ticket into
  `{ orderId, angry }` hands the next step nothing at all, and you learn about
  it three steps later as a reply addressed to order `undefined`. Nothing
  throws.

  `workflow(s1, …, s8)` closes that from both ends. At compile time, step N's
  output type must be what step N+1 accepts (`NextStepInput`: a `string` output
  feeds the next step's `{ message }`, the house convention every LLM runner
  speaks; anything else must match exactly) — a chain that doesn't line up is a
  compile error, pinned by `@ts-expect-error` fixtures under `npm run
test:types`. At run time, values are handed over **unchanged**: objects stay
  objects. `workflow(draft, edit)` over two `LLMCall`s reads exactly as it
  always did.

  Three limits are documented and tested rather than papered over: only plain
  data crosses a step boundary (a `Date`/`Map`/class instance arrives as `{}`,
  `undefined` fields drop), a step must RETURN its output, and the workflow's
  own input stays visible to later steps (footprintjs `getArgs()` inheritance) —
  a key the previous step actually produced always wins.

  It reports itself as composition kind `'Sequence'`, because it is one; the
  public `CompositionKind` union is unchanged so consumer switches keep
  compiling.

### Docs

- New guides: **LLM routing** (`llmRouter` / `llmSwarm`, the roster-as-data
  argument, the wire-it-yourself recipe) and **Workflow** (the chain rule, how
  to author a typed non-LLM step, the three limits). The Swarm guide's "Why
  route is sync" section now points at `llmSwarm` instead of telling you to
  build it yourself.
- New runnable examples: `examples/patterns/07-llm-swarm.ts` and
  `examples/core-flow/05-workflow.ts`.

## [7.9.0] - 2026-07-29

### ⚠️ Behaviour change — `openaiEmbedder({ dimensions })` now actually shortens the vectors

**If you pass `dimensions` today, your vectors change length.** They were the
model's native length all along; now they are the length you asked for. Anything
you have already embedded and stored was written at the _old_ length, so a store
built with `openaiEmbedder({ dimensions: 256 })` on 7.8 holds 1536-long vectors
and will not match new 256-long queries. **Re-embed, or drop `dimensions` to
keep the old lengths.**

Why: `src/embedders/index.ts` read the option, reported it as `.dimensions`, and
then built the request body as `{ model, input }` — the parameter was never
sent. Asking for 256 returned 1536 numbers from an embedder claiming 256, so any
vector store trusting `.dimensions` was corrupted silently and consistently.

Also changed, in the same spirit of "`.dimensions` must not lie":

- `.dimensions` now reports each known model's **documented native size** when
  you pass nothing — 1536 for `text-embedding-3-small` and
  `text-embedding-ada-002`, 3072 for `text-embedding-3-large`. It used to report
  1536 for everything, so `text-embedding-3-large` under-reported by half.
- An **unknown model with no `dimensions` is now a construction-time error**
  instead of a silent 1536. This is the breaking edge: `openaiEmbedder({
baseURL, model: 'nomic-embed-text' })` against a gateway, an Ollama server or
  an Azure deployment name now throws until you state the length. That
  population is exactly the one that was being lied to. One option fixes it:
  `{ dimensions: 768 }`.
- `dimensions` is sent **only when you explicitly pass it**. It is ["Only
  supported in `text-embedding-3` and later
  models"](https://developers.openai.com/api/docs/api-reference/embeddings/create),
  so sending a default would have broken every `text-embedding-ada-002` caller
  who asked for nothing. With the option unset the request body is byte-identical
  to 7.8.

### Added

- **`localEmbedder({ backend })` / `staticEmbedder({ backend })` — pass an
  already-imported module, and the on-device embedders work in a browser.** To
  keep the heavy peer deps optional, both factories import them through a
  _variable_ specifier — which no bundler can see through. The bare name
  survived a production build and reached the browser unresolved:
  `TypeError: Failed to resolve module specifier '@huggingface/transformers'`.
  The capability was there all along; only the packaging blocked it. Now the
  host can do the static import its own bundler resolves and hand the module in:

  ```ts
  import * as transformers from '@huggingface/transformers';
  const embedder = localEmbedder({ backend: transformers });
  ```

  Verified in a real production bundle with no import map: 384-dimension
  vectors, L2 norm 1.0, near-text cosine 0.55 vs far-text −0.03. Same shape as
  the `client` option on the store adapters — the library states the surface it
  needs (`TransformersBackend`, `Model2VecBackend`, both exported), the host
  owns the construction. The string-specifier path is unchanged, and both peers
  stay optional. (`@yarflam/potion-base-8m` itself reads its weights from disk
  with `fs`/`__dirname`, so `staticEmbedder` remains Node-only in practice; the
  option is there for a browser-capable Model2Vec build.)

- **`agentfootprint/embedders` exports the `Embedder` type it returns.** The
  subpath declared it locally and never re-exported it, so naming the return
  type of its own factories meant importing from `agentfootprint/memory`
  (`TS2459: declares 'Embedder' locally, but it is not exported`).

- **An embedders guide.** [docs-next/content/docs/build/embedders.mdx](docs-next/content/docs/build/embedders.mdx)
  — the module had zero documentation coverage and had never been used by
  anyone, which is why all three defects survived. Covers what each embedder
  needs, which ones run in a browser, and what they cost: `localEmbedder`'s
  first browser call pulls ~27 MiB over the wire (~45 MiB decompressed) from
  **two** third-party origins — `huggingface.co` for the model and
  `cdn.jsdelivr.net` for the ONNX Runtime WebAssembly binary, which nothing
  previously mentioned — and `@yarflam/potion-base-8m` puts ~30 MB of weights on
  disk at install.

- **Docs-truth check — an ongoing, honest answer to "do the docs describe what
  the code actually does?"** `npm run docs:truth` (new CI job `docs-truth`)
  answers three _separate_ questions for every capability the package exposes,
  because their combinations are different bugs: DECLARED (in the published
  surface), DOCUMENTED (described in prose on the site), EXERCISED (a real run
  produces it). Declared/documented/never-exercised is the shape a dead or
  unimplemented feature has — exactly the state the resilience events sat in
  for months. Declared/exercised/undocumented is the classic doc gap.
  Documented-but-not-declared is the worst case for a reader, and has zero
  tolerance.
  - The DECLARED column is built from the real export map (`package.json`
    `"exports"` → shipped `.d.ts`, enumerated with the TypeScript checker), not
    from TypeDoc: TypeDoc runs from the single `src/index.ts` entry point and
    therefore cannot see a single `agentfootprint/<subpath>` symbol. The
    surface is reported per subpath, since root-barrel-vs-subpath is itself a
    known source of user confusion. Events come from `ALL_EVENT_TYPES`.
  - The DOCUMENTED column counts _only_ prose on the 63 hand-written pages
    under `docs-next/content/docs`. Both TypeDoc trees
    (`docs-next/content/docs/api/`, `docs/api-reference/`) are excluded — they
    are generated from source, so every symbol appears in them by construction
    and counting either would mark 234 undocumented symbols "documented" and
    report a clean bill of health that means nothing. Code-sample-only and
    written-in-the-repo-but-not-published are reported as their own states
    rather than silently counted either way.
  - The EXERCISED column comes from real credential-free runs:
    `npm run docs:truth:exercise` runs all 95 `examples/` scripts (95/95 green)
    with credential-shaped env vars stripped by name, and taps the event bus
    with the listener gates forced open. Anything it cannot observe is UNKNOWN,
    never "absent".
  - **Ratchet, not gate.** Pre-existing gaps are recorded in
    `docs/docs-truth/baseline.json` and pass; CI fails only when the gap grows.
    `npm run docs:truth:baseline` re-records and regenerates the report, so new
    debt lands as a reviewable diff. The docs-promise-nothing class is never
    baselined.
  - Human-readable findings: [docs/DOCS_TRUTH_REPORT.md](docs/DOCS_TRUTH_REPORT.md).

### Fixed

- **Three broken imports in the published docs.**
  `docs-next/content/docs/reference/strategy-everywhere.mdx` told readers to
  import `composeObservability` / `consoleObservability` from
  `agentfootprint/observability-providers` and `ObservabilityStrategy` from the
  root barrel; all three are exported from `agentfootprint/strategies`, so
  every copy-paste failed. They lived in plain `typescript`-tagged fences,
  which the docs build's twoslash gate does not compile — only 7 of the 183
  TS/JS blocks on the site are twoslash-marked, and 137 package imports sit in
  blocks the compiler never sees. Found by the new docs-truth check.

### Known issues (surfaced, deliberately not changed)

- **Two events are emitted but absent from the typed registry.**
  `src/core/outputFallback.ts:169` and `:203` emit
  `agentfootprint.resilience.output_fallback_triggered` and
  `agentfootprint.resilience.output_canned_used` through a loosely typed
  `emit: (eventType: string, …)` parameter, bypassing the typed registry. There
  is no `resilience` domain in `EVENT_NAMES`, no payload interface, no
  `ALL_EVENT_TYPES` entry and no `agentfootprint.resilience.*` wildcard — so
  `runner.on(...)` cannot accept either name, even though
  `docs-next/content/docs/monitor/reliability.mdx` calls them "two typed events
  for observability". Registering them changes the public event contract and
  the pinned event count, so it is left as an owner decision; the docs-truth
  check ratchets the class so no new one can appear unnoticed.
- **Nine import paths named in prose do not exist in the export map** — see
  section 8 of the report. Notably `docs/…/dependency-graph.mdx` documents
  `agentfootprint/providers`, `agentfootprint/memory-redis` and
  `agentfootprint/memory-agentcore` as legacy aliases "still exported in v3.x",
  but `package.json` `"exports"` has no such keys, so they do not resolve.

## [7.8.0] - 2026-07-28

### Added

- **`recordRun(runner)` — save a run so a viewer can show it later.** A
  recording is exactly three things: `events` (the typed stream, in order),
  `snapshot` (the footprintjs run snapshot — state, commit log, every
  attached recorder's data) and `structure` (the build-time chart). Each
  lights a different surface and a missing one darkens exactly that surface.
  Nothing in the stack produced that bundle. It was DEFINED five times —
  footprintjs's `RuntimeSnapshot` (no structure, no events), our `Trace` (no
  snapshot), `ChatTurn.artifacts` (no structure), `ContextBugArtifacts` (no
  structure), and lens's `Recording` (complete, and produced by nothing) — so
  every integration assembled it by hand and each one omitted a different
  field. Most often `structure`, because a finished run does not leave it
  behind: it lives on the chart, and `getSnapshot()` deliberately never
  carries it. `recordRun` is the producer, and it emits exactly the shape
  lens's `observeRecording()` consumes:
  `fs.writeFileSync('run.json', JSON.stringify(recordRun(agent).toRecording()))`
  after the run. It also wires the boundary recorder's three connections,
  which is the part hand-rolling gets wrong — `attach` (no boundaries at all),
  `subscribe` (boundaries with nothing in them) and `getCommitCount` (every
  boundary stamped at commit 0, silently). None of the three can be
  reconstructed from a completed run, so it must be called BEFORE `run()`.
  It attaches no other recorders: `narrative()` and `metrics()` stay the
  consumer's choice, and each viewer says on screen when their data isn't
  there. Options: `maxEvents` (default 10,000, with `droppedEvents` counting
  what a long-running server shed) and `boundaryDetail: 'lean'`. Exported
  from `agentfootprint/observe`.
- **`runner.getCommitCount()`** — the run's time axis, forwarded from
  footprintjs's executor. Two JSDoc blocks (`runner.ts`, `RunnerBase.ts`)
  had described this accessor as if it existed since the observability
  work landed; `grep` found it implemented nowhere. It is real now: `0`
  before the first run, one commit per executed stage, cumulative across
  `resume()` on the same executor. Sample it live — a closure over the
  runner, never a captured number. `Runner` also now declares
  `getLastSnapshot()`, which `RunnerBase` has always implemented and the
  interface never mentioned; both are on the interface so a consumer
  holding a `Runner` can record a run without casting.
- **`Trace.snapshot`** — a Trace can now carry the run's footprintjs
  snapshot, via `enable.localObservability({ includeSnapshot: true })` or
  `serializeTrace(events, { snapshot })`. A Trace was the only documented
  recording format in the ecosystem and it structurally could not feed the
  full viewers: the commit axis, ExplainableShell's memory and provenance
  panels, and WhereFrom all read the commit log, which is a footprintjs
  artifact and appears nowhere in the event log. It is OPT-IN, and that is a
  redaction decision rather than a size one: `redact` runs per domain event
  and cannot reach inside a snapshot, whose `sharedState` is the run's raw
  working memory — filling the field automatically would have widened what
  a carefully redacted Trace exports without one call site changing. Redact
  that half at run time with footprintjs's `setRedactionPolicy()`. For a
  viewer, prefer `recordRun()`.
- **`ContextBugArtifacts.structure`** — the localizer's evidence bag calls
  itself "the frozen evidence of one completed run" and could not draw the
  run it described. The field is unread by the localizer (which works
  entirely off the commit log); it is there so the same literal that
  localizes a bad answer is also a complete recording. `recordedChat` now
  fills it on every turn, captured from the per-turn agent at record time —
  that agent is discarded immediately after, and nothing can reproduce a
  chart from a finished run. `ChatTurn.artifacts` gains the same field.
- **`boundaryRecorder({ snapshot: 'lean' })` — ship the shape of a run
  without shipping its content.** `toSnapshot()` is the copy that leaves the
  process: it lands in `runtimeSnapshot.recorders` and gets written to disk,
  posted to a viewer, or kept as a run artifact. It has always carried every
  field of every `DomainEvent`, and the captured content — entry and exit
  payloads, tool arguments and results, assistant text, injected-content
  previews — is most of its bytes. Sized on the demo turn this was built
  for — four ReAct iterations, four LLM calls, three tool calls, 25 subflow
  boundaries, run end to end against a mock provider with the recorder
  attached — the bundle `toSnapshot()` handed back measured 69.7 KB raw /
  4.6 KB gzipped and its lean projection 21.7 KB / 1.9 KB: content was 69% of
  the raw bytes and 59% after gzip, a 3.2x cut raw and 2.5x gzipped. That is
  one run measured twice, not two runs compared. Two things bound how far it
  generalizes, both in the conservative direction: the content on that run
  was the entry and exit payloads, because only the recorder's boundary side
  was attached (`agent.attach`) and not the typed-event side
  (`subscribe(dispatcher)`) whose events carry assistant text and tool
  results; and a live provider's longer replies ride inside those payloads,
  so a real-traffic turn has a larger content share, not a smaller one.
  The consumer that rebuilds a commit-range index offline reads five fields
  per event (`type`, `runtimeStageId`, `commitIdxBefore`, `subflowPath`,
  `depth`) and none of the content. `snapshot: 'lean'` drops exactly the five
  content-carrying fields — `payload`, `args`, `result`, `content`,
  `contentSummary` — and keeps every field that says where, when and of what
  kind each boundary was, so the index rebuilds range for range from the lean
  bundle alone. The point is not only bytes: a stored artifact stops being a
  second, redaction-bypassing copy of everything the agent read and wrote,
  the same reasoning `BoundaryRangeLabel` already applies to the live range
  index. Two short capture-time annotations do survive by design —
  `rationale` on `decision.branch` and `reason` on `context.injected` — so
  read lean as "no captured payloads", not "no free text at all": a decider
  whose rationale interpolates run values still puts that text in a lean
  artifact. Join back on `runtimeStageId` against the run's own snapshot when
  you want the content. The new `LeanDomainEvent` type (exported from
  `agentfootprint/observe`) names the shape.
  The suite measures a different, larger number, and it is not the one to
  quote: it replays a captured turn, and the capture kept each subflow's
  final state only, so the replay serializes that state twice per boundary —
  once as the entry it has no seed for, once as the exit. That doubling is
  87% of its 276 KB full bundle, which is why the size test asserts a loose,
  fixture-relative bound rather than a headline ratio.
- **`'full'` stays the default, deliberately.** This bundle has carried
  content since it shipped in 2.x, and `buildStepGraphFromEvents()` — the
  offline path behind `<Replay>` — restores its `entryPayload`,
  `exitPayload`, `contentSummary`, `assistantText`, `toolArgs` and
  `toolResult` from this bundle and nowhere else. A lean default would have
  quietly emptied every existing replay of an already-stored run, with no
  error to notice. Nothing changes unless you ask for `'lean'`. The live
  stream is unaffected in both modes — `getEvents()`, `buildStepGraph()` and
  `aggregateForBoundary()` still see full events; only the snapshot
  projection differs. The bundle's `description` records which mode produced
  it, so a stored artifact stays self-describing.
- **`agentfootprint.fallback.triggered`, `agentfootprint.error.retried` and
  `agentfootprint.error.recovered` now actually fire — you can finally ask
  which provider served this call.** All three were fully declared (registry,
  payload types, `ALL_EVENT_TYPES`, domain wildcards) and emitted by nothing:
  a repo-wide grep found zero emitters. They were legal to subscribe to, typed
  end to end, and permanently silent. The reason is structural rather than an
  oversight — `LLMProvider` is a deliberately minimal port (`{ name, complete,
stream? }`) with no emit channel, and the resilience decorators are
  constructed by you _before any run exists_, so a decorator can never reach a
  scope. Routing them through a consumer callback would not have been
  equivalent: an event pushed back in via `runner.emit()` lands with
  synthesized meta (`runtimeStageId: 'consumer-emit#0'`, `runId:
'consumer-scope'`, `runOffsetMs: 0`) and correlates with nothing else in the
  run, which is the entire value of having it. The seam is an optional
  per-call second argument, **`hooks?: LLMCallHooks`**, on `complete()` and
  `stream()` — the channel rides the CALL, not the factory. A decorator
  reports plain data (**`ResilienceReport`**, a three-arm union: `'fell-back'`
  | `'retried'` | `'recovered'`) and knows nothing about runs, scopes or
  events; the in-run LLM call sites translate each report into the
  already-declared typed event from INSIDE the traversal, so it carries the
  real `runId` and `runtimeStageId` footprintjs stamped before the stage ran
  and sits on the same timeline as the tool calls around it. Every payload
  field is filled from a report field of the same name — nothing is
  synthesized. Not a field on `LLMResponse`, because an exhausted retry
  _throws_: there would be no response to carry it. One producer per fact —
  `fell-back` ← `withFallback`, `retried` / `recovered` ← `withRetry`, nothing
  ← `withCircuitBreaker` — so a stack of three decorators produces one
  concatenated stream and de-duplication is structurally unnecessary rather
  than policed. `Agent`, `LLMCall` and `Parallel` attach the new
  **`resilienceRecorder()`** bridge for you, so
  `agent.on('agentfootprint.error.*', …)` works with no setup; a bare
  `FlowChartExecutor` running the exported message-api charts must attach it
  (or any `onEmit` recorder) itself, because these events travel footprintjs's
  emit channel and reach recorders only — never the commit log. Non-breaking:
  TypeScript lets an arity-1 `complete(req)` satisfy the widened signature, so
  every shipped adapter and every consumer test double still assigns unchanged (pinned by
  `test/type-regressions/LLMCallHooks.assignability.test.ts`), and outside a
  run nothing passes hooks so every report site short-circuits and standalone
  decorator behaviour is byte-identical. No new event type was added; the
  67-event count guard is untouched. `LLMCallHooks` and `ResilienceReport` are
  exported from `agentfootprint/llm-providers`, `resilienceRecorder` from
  `agentfootprint/observe`. The honest limits are stated on the [resilience
  page](https://footprintjs.github.io/agentfootprint/docs/monitor/resilience)
  rather than papered over — chiefly that `error.retried.reason` classifies
  the ERROR and not the retry predicate's reasoning (a bare boolean cannot
  expose that), that `withCircuitBreaker` has no event of its own and a trip
  is visible only as the enclosing fallback's `reason`, that a
  fallback-sourced recovery is not expressible in `ErrorRecoveredPayload` so
  `withFallback` never claims `recovered`, and that the LLM-backed memory
  extractors call `complete()` from a port with no scope and stay a blind
  spot. Runnable proof:
  `examples/features/35-resilience-visibility.ts` — four scenes, offline, no
  API key, exits non-zero if any claim stops holding. **If you write your own
  provider wrapper, forward the second argument.** `complete(req, hooks?)` is
  optional in its second parameter and TypeScript never rejects an
  implementation for declaring fewer parameters than its signature, so a
  wrapper that calls `inner.complete(req)` type-checks, runs, passes its
  tests — and silently swallows every report from anything beneath it. Every
  wrapper this library ships forwards.

### Changed

- **The boundary snapshot bundle says which mode produced it, in a field
  code can read.** `toSnapshot()` now returns `meta: { mode: 'full' |
'lean' }`. Full and lean were distinguishable only by the prose in
  `description`, and string-matching a sentence is not a thing offline
  readers do — they render the empty detail panel instead, because
  `buildStepGraphFromEvents()` restores step content from this bundle and
  nowhere else. With `meta.mode` a consumer can SAY "this recording carries
  structure only". The field reaches `getSnapshot().recorders[i].meta` on
  footprintjs 9.12+, which added the pass-through; on older engines the row
  is rebuilt without it and the field is dropped in transit. It is always on
  the value `toSnapshot()` itself returns.
- **`boundaryRecorder().subscribe()` accepts a runner.** It took an
  `EventDispatcher` — which a `Runner` does not hand out — so wiring the
  recorder's typed half from a public runner meant reaching for internals.
  It now takes anything offering a wildcard subscription (`TypedEventSource`),
  which both a `Runner` and the internal dispatcher satisfy:
  `boundary.subscribe(agent)`. Existing `subscribe(dispatcher)` calls are
  unaffected.
- **The "wire it at RECORD time" requirement now lives in the library that
  owns the recorder.** That a `BoundaryRecorder` must be attached AND
  subscribed AND given a commit count, all before the run, was documented
  only in agentfootprint-lens's README and in `observeRecording`'s JSDoc —
  in the consumer, where someone writing the recording side has no reason to
  look. It is on `BoundaryRecorder` itself now, with each of the three
  connections and how each one fails, and `getCommitCount`'s own JSDoc
  states the consequence of omitting it (a recording whose step strip cannot
  be rebuilt) instead of describing it as a legacy mode.
- **Docs: "Offline replay" is now "Replay a saved run", written around the
  path that works.** It was the ecosystem's only replay page and it taught
  the weakest route — capture a `Trace`, render `<Replay>` — leaving readers
  with an artifact that has no snapshot and a viewer that draws a static
  shape. It now leads with the three fields and `recordRun()`, shows both
  destinations (`observeRecording` → `<Lens>`, and `ExplainableShell` with
  its snapshot / graph / overlay props), states the by-hand wiring
  requirement with a table of how each missing connection fails, and keeps
  `<Replay trace>` as what it is: the chart-only subset. Plus
  `examples/observability/20-record-and-render.ts`, which runs a two-step
  pipeline, writes the recording to disk, reads it back, and checks the file
  against what each viewer reads — failing if a core surface would go dark,
  so the path is CI-verified rather than prose.
- **`toSnapshot()` now returns `data: DomainEvent[] | LeanDomainEvent[]`**
  (it used to infer `DomainEvent[]`). The union is the honest type — the
  method really does return one or the other, decided by the option you
  passed — but it is a compile-time break for TypeScript callers that read a
  content field straight off snapshot data: narrowing an element to
  `'run.entry'` and then reading `.payload` no longer type-checks, because
  the lean member of the union has no such field. Narrow on the mode you
  configured, or cast when you know you configured `'full'`
  (`toSnapshot().data as DomainEvent[]`). Nothing changes at runtime: the
  default is still `'full'`, and a full bundle still carries every field it
  carried before.

### Fixed

- **The eight adapter wrappers dropped the `hooks` argument, so anything
  decorated underneath one would have gone dark.** `OpenAIProvider`,
  `AnthropicProvider`, `BedrockProvider`, `BrowserOpenAIProvider`,
  `BrowserAnthropicProvider` and `BrowserAzureOpenAIProvider` (plus the two
  `azure` / `browser-azure` factories) each declared `complete(req)` and called
  `this.inner.complete(req)`. Nothing was lost in practice — each of them wraps
  a leaf vendor provider that reports nothing — but the SHAPE was the trap, and
  it is the one failure in this design that produces no compile error, no
  runtime error and no failing test: `hooks` is optional, and TypeScript never
  rejects an implementation for declaring fewer parameters than its signature.
  All eight forward now, so every wrapper the library ships is transparent and
  the trap can only be introduced by a consumer-authored wrapper. Documented at
  the seam (`LLMCallHooks`'s JSDoc) and in the resilience guide's honest limits,
  since it cannot be enforced by types.
- **A false observability claim inside the honest-absence ledger: the
  message-api charts' resilience emits do NOT "reach the commit log always".**
  Both `buildAgentMessageApiChart`'s call-site comment and MENTAL_MODEL §14
  item 6 said they did. They cannot: footprintjs's `ScopeFacade.emitEvent`
  dispatches only to recorders' `onEmit`, never touches the stage's transaction
  buffer, and fast-returns outright when zero recorders are attached — so an
  emit can never become a `CommitBundle`. The truth is worse than the wrong
  version admitted: on a bare `FlowChartExecutor` with no `onEmit` recorder the
  report is **discarded entirely**, not stored somewhere quieter. Verified end
  to end — a real fallback on a bare executor left no trace in the run return,
  the 14-bundle commit log, `sharedState`, the execution tree or the recorder
  snapshot, while the same chart with one `onEmit` recorder surfaced it
  immediately with a real `runtimeStageId`. Corrected in both places and in the
  resilience guide, and both arms are now pinned by tests rather than prose.
- **Two dead-code claims corrected.** `buildReliabilityGateChart`'s header said
  the chart "is mounted as a subflow in the agent's chart at `Agent.build()`
  time"; `buildAgentChart`'s header said the same mount "lands in the next
  commit". Neither happened: the builder is exported from no barrel and its only
  consumer in the repository is its own test, `.reliability()` is implemented
  inline in the CallLLM stage by `executeWithReliability`, and the
  `TranslateFailFast` stage both headers reference was never written. Comments
  only; no behaviour change. The file is kept rather than deleted because it is
  the only implementation that honours the `providers` failover list.
- **Every run recorded through `enable.flowchart()` or
  `enable.localObservability()` had a flat commit axis — silently.**
  `attachFlowchart` built its `BoundaryRecorder` with no options, so
  `getCommitCount` was undefined and every boundary event in every such run
  was stamped `commitIdxBefore: 0`. Both blessed observability entry points
  go through it, so this was the default for the ecosystem. Nothing errored
  and the events looked complete; what broke was one step removed — the
  boundary range index stayed empty (deliberately: zero-width `[0, 0]`
  ranges would read as real), and an offline consumer rebuilding the step
  strip from the recording found no positions to place, so the strip stayed
  quiet on a run that had plenty to show. Unrecoverable downstream, too: the
  commit log records what each stage WROTE, never when a boundary was
  CROSSED, so nothing can derive the axis after the fact. The runner now
  passes `() => this.getCommitCount()` through to the recorder. Recordings
  made before this carry zeros and cannot be repaired — re-record them.
- **`localObservability`'s header told you to pass its handle to `<Lens>`.**
  "pass the handle to `<Lens recorder={handle} />`" — a
  `LocalObservabilityHandle` is a `FlowchartHandle` plus `getTrace()`, and
  Lens wants a `LensRecorder`: `selectRunTree`, `selectEventLog`,
  `selectSummary`, `liveState`, `runtime`, `boundary.boundaryIndex`. It has
  none of them. The header now shows the path that works — record the run,
  hand the recording to lens's `observeRecording()`, pass THAT to `<Lens>` —
  and says plainly that this handle is not a Lens recorder.
- **`Trace`'s docs claimed `<Replay>` overlays the events.** "`<Replay>`
  rebuilds the flowchart from this and overlays `events`, so an offline
  replay matches the live `<Lens>` exactly" appeared in `trace.ts` and again
  in the offline-replay page. `Replay.tsx` never reads `trace.events`; it
  renders the chart's shape, and its own JSDoc calls time-travel "a planned
  refinement". A consumer following the official doc got an artifact with no
  snapshot and a viewer that could never light the executed path. Both
  places now say what it does: `<Replay>` draws the chart and stops there,
  and is a strictly smaller view than the live `<Lens>`.

- **The root barrel no longer claims to re-export `mcpClient`.** A comment in
  `src/index.ts` told readers that `mcpClient` and the tool-dispatch
  primitives were reachable from the top-level import. They have not been
  since 6.0.0 collapsed the convenience mirrors: `mcpClient`,
  `mockMcpClient`, `staticTools`, `gatedTools` and `skillScopedTools` live
  only under `agentfootprint/tool-providers` — one subpath for everything
  tool-related, which is the intended design. The comment now says so, as
  does its mirror in `src/tool-providers/index.ts`, which made the same claim
  from the other side. Comment-only; no export changed.

## [7.7.0] - 2026-07-27

### Added

- **`parallelToolCalls` on `anthropic()` and `browserAnthropic()` — one tool
  per reply, enforced by the API.** Claude may ask for several tools at once:
  one assistant message carrying many `tool_use` blocks, all executed inside a
  single agent iteration. That is the right default for speed, and the wrong
  one when the SHAPE of the loop is part of what you are measuring. Per-
  iteration analysis reads one tool source per iteration —
  `localizeContextBug` seeds one `'tool'` suspect from that iteration's
  `lastToolResult`, and `removableSources` de-duplicates by tool name — so a
  batched reply is attributed to the LAST tool of the batch; the others never
  appear as their own influence rows and cannot be ablated individually. An
  agent that genuinely consulted three sources could show one. Passing
  `parallelToolCalls: false` sends `tool_choice: { type: 'auto',
disable_parallel_tool_use: true }`, which caps the model at one tool per
  reply and keeps every source separately attributable, at the cost of one
  extra round trip per tool. `auto` is deliberate: the model still chooses
  WHICH tool, and whether to call one at all — only the count is capped.
  Nothing goes on the wire when the option is omitted or `true` (batching is
  already Anthropic's default), and nothing goes on a request that carries no
  tools (Anthropic rejects `tool_choice` there — an agent's final answer call
  often has none). Both the `complete()` and `stream()` paths honor it, and
  the Node and browser adapters emit a byte-identical field so a BYOK page and
  its server behave the same. Prompting is not a substitute: "call one tool at
  a time" in the system prompt is a request the model may ignore; this is a
  request parameter the API enforces.

## [7.6.1] - 2026-07-25

### Fixed

- **Bedrock streaming now delivers tool calls.** `bedrock()`'s `stream()`
  ignored ConverseStream tool-use events and returned `toolCalls: []` on the
  terminal chunk — so an agent on Bedrock with streaming enabled saw
  `stopReason 'tool_use'` with zero tool calls and quietly treated it as a
  final answer. Tool calling via `bedrock()` + streaming never worked; the
  non-streaming path was always correct. The stream loop now accumulates
  tool-use blocks across `contentBlockStart` / `contentBlockDelta` /
  `contentBlockStop` (keyed by `contentBlockIndex`, so parallel tool calls
  that interleave are parsed correctly; no-arg tools yield `args: {}`), and a
  stream/non-stream parity test pins the two paths together.

### Added

- **Honesty invariant on Bedrock responses (both paths):** `stopReason
'tool_use'` with zero parsed tool calls is never returned quietly — the
  provider throws `BedrockProviderError` with `code:
'BEDROCK_STREAM_TOOLUSE_LOST'`. After this fix the contradiction cannot
  happen on today's wire format; the throw is a tripwire for future
  ConverseStream shape drift, and it lands in your `reliability` /
  `withRetry` rules instead of the agent silently answering without tools.
- **Malformed streamed tool-args JSON is a typed error**, not a silent `{}`:
  `BedrockProviderError` with `code: 'BEDROCK_MALFORMED_TOOL_ARGS'` plus
  `.toolName` / `.toolUseId`. A tool is never executed with dropped arguments.
- **Slot budget overflow is loud.** When a context slot composes over its
  `budgetCap` (e.g. tools slot: used 2447 chars of cap 2000), the slot now
  emits the previously-documented-but-never-fired
  `agentfootprint.context.budget_pressure` event with `planAction: 'none'`
  (nothing is truncated — the full content still goes to the LLM) and prints
  one structured `console.warn` per agent. `planAction` gains the `'none'`
  member. Cap defaults are unchanged.

## [7.6.0] - 2026-07-24

### Added

- **`recordedChat` — the chat session that can explain itself.** Every chat
  host that wanted per-turn transparency re-wrote the same correctness-
  critical glue; `recordedChat({ makeAgent })` absorbs it. `send(message)`
  runs one recorded turn through YOUR agent factory and freezes that turn's
  evidence immediately (snapshot, events, last LLM call) — the
  `getLastSnapshot()`-is-last-run-only trap becomes structurally impossible.
  History threading is owned by the library: the exact message string each
  turn ran with is frozen on the `ChatTurn`, and `rerunTurn(k, { ignore,
embedder, checkBaseline? })` replays those bytes verbatim through an
  `AblationRunner` derived from the SAME `makeAgent` — same recorded
  conversation up to that point, minus the ignored sources — delegating to
  `rerunWithoutSources` and returning its result UNMODIFIED (the honesty
  tiers are untouched: a causal verdict only with `checkBaseline: true`).
  `reason(k)` is memoized `localizeContextBug` over the turn's frozen
  artifacts (`atStep` defaulted to that turn's last LLM call), so
  `removableSources(report)` and the existing UI joins compose unchanged.
  `fork(k, { fromRerun? })` branches — never rewrites — the conversation: a
  NEW `recordedChat` seeded with the counterfactual (or original) reply,
  carrying the removed sources into every later turn, and `fromRerun` must
  be a result THIS session's `rerunTurn` produced for that turn (a
  fabricated fork would be a lie). Session registries, UI joins, comparators
  and persistence stay host-side by design. Exported from
  `agentfootprint/debug`. See the
  [recordedChat guide](https://footprintjs.github.io/agentfootprint/docs/debug/recorded-chat/).

## [7.5.0] - 2026-07-24

### Added

- **`checkIn` — evidence-carrying human consent for consequential tool
  actions.** "OpenWorker-class agents check in; agentfootprint checks in WITH
  THE RECEIPTS." A tool declares `checkIn: 'always'` or a
  `(args, ctx) => boolean` predicate (`defineTool` / `Tool.checkIn`). When it
  trips, the tool-dispatch loop pauses BEFORE the tool executes — AFTER the
  permission gate + arg-validation, BEFORE credential resolution — riding the
  EXISTING pause/checkpoint machinery. `agent.run()` returns a
  `RunnerPauseOutcome` whose new `checkIn` field is a typed `CheckInRequest`
  (`{ tool, args, intent?, evidence }`); `isCheckInPause(outcome)` is the clean
  discriminant from a plain `askHuman` pause. The evidence pack
  (`CheckInEvidence`) carries plain-named receipts — `willDo` (tool
  description + rendered args), `read` (context the run consumed, from the
  conversation frames + system rules), `drivers` (which context drove the
  choice, ranked — a PLUGGABLE `CheckInScorer`, default deterministic lexical,
  **zero LLM calls**), and `trail` (compact grouped run-so-far). Two built-in
  assemblers, configured on the builder via `.checkIn({ evidence: 'standard' |
'minimal' | <assembler>, scorer? })`. The human answers with
  `checkInApproved({ by, note? })` / `checkInDeclined({ by, note? })`;
  `agent.resume(checkpoint, decision)` executes the tool on approve, or lands a
  model-visible `"declined by human: <note>"` result on decline (mirroring
  permission rejections). Two new typed events — `agentfootprint.checkin.request`
  / `.decision` (registry now 67 events / 19 domains) — ride the emit channel;
  the built-in `CheckInRecorder` (compose-a-store, attach with
  `agent.attach(...)`) captures the ask + decision as a queryable audit trail
  (`getRequests()` / `getDecisions()` / `getStats()`). The whole pack survives
  `structuredClone` + JSON (checkpoint discipline), so Process A (ask) and
  Process B (decide) can be servers and days apart. **Backward compatible:** a
  tool without `checkIn` is byte-identical — no gate, no events, no pause. NOT a
  policy engine (`PermissionChecker` untouched; policy runs first) and NOT UI —
  this is consent, with the receipts. See the
  [Check-in guide](https://footprintjs.github.io/agentfootprint/docs/monitor/checkin/).
- **Named influence strategies — the ranking stage becomes a picker.** The
  pluggable `InfluenceScorer` seam gets a plain-named descriptor: an
  `InfluenceStrategy` is `{ name, description, requirements, scorer }`, and
  `listInfluenceStrategies()` enumerates the built-ins so a host UI can render a
  strategy selector and grey out what it can't run (`requirements: ['embedder']`
  vs `[]`). Two ship: **`semantic-alignment`** — the existing FDL four-signal
  embedding composite (`scoreInfluence`), the default, needs an embedder — and
  **`lexical-overlap`** — new `scoreLexicalInfluence`, deterministic word-overlap
  scoring with zero dependencies and zero model calls (same four-signal frame,
  set-cosine over tokens instead of embedding cosine, honest `InfluenceScore`
  output including Eq. 6 weight adaptation). `localizeContextBug({ scorer })` now
  accepts a strategy object as well as a bare function (non-breaking), and the
  report's new optional `rankedBy` echoes which strategy ranked. The claim ladder
  is untouched: any strategy only reorders suspects — ablation alone convicts.
  See the strategy section of the
  [localizer guide](https://footprintjs.github.io/agentfootprint/docs/debug/localize-context-bug/).
- **`rerunWithoutSources` — the counterfactual re-run as one call.** Take a
  finished run's `ContextBugReport`, name the sources to ignore (plain ids:
  injection id, tool name, or step id — `removableSources(report)` lists what a
  UI can offer as toggles), pass the same `AblationRunner` the localizer's causal
  mode uses, and get back `{ answer, answers, removed, whatChanged, runs }`:
  the re-run's answer, every seeded re-run's answer, the ablation specs that were
  applied, and an honest `whatChanged` (`answerFlipped` by majority over N≥2
  seeded re-runs, similarity stats, a plain-language summary — never a single-run
  diff). Opt into `checkBaseline: true` and the unchanged scenario is probed too,
  unlocking the causal-tier `verdict` (same `verdictFor` claims as
  `localizeContextBug`). Reuses `applyAblations`/`runAblationProbe` end to end —
  no new machinery — and works identically with mock providers ($0) and real
  ones. **Backward compatible:** additive API only; existing `scorer` functions,
  reports, and runners are untouched. See
  [Re-run without sources](https://footprintjs.github.io/agentfootprint/docs/debug/rerun-without-sources/).

## [7.4.0] - 2026-07-09

### Added

- **`run({ correlationId, traceId })` → `EventMeta` on every event** —
  `EventMeta.correlationId`/`traceId` already existed
  (`src/events/types.ts:92/96`) and `buildEventMeta` already forwarded them
  from `RunContext` (`src/bridge/eventMeta.ts:82-83`), but
  `Agent.createExecutor()` built `currentRunContext` without ever reading
  them, so event meta was permanently `undefined`. New `AgentRunOptions`
  (extends footprintjs `RunOptions`) adds optional `correlationId`/
  `traceId` to `run()`/`runTyped()`/`resume()`/`resumeOnError()`; both land
  in `currentRunContext` so every emitted event on that run carries them.
  `traceId` falls back to `options.env.traceId` (footprintjs
  `ExecutionEnv`) when not set directly; an explicit `traceId` always wins.
  Each `createExecutor()` call rebuilds `currentRunContext` from scratch,
  so an untagged run never inherits a prior run's `correlationId`/`traceId`
  (pinned by test). **Who consumes it:** vizfootprint's `why()` — joining
  agentfootprint's event stream against an external system (an upstream
  request id, an OTEL trace, a cross-tier causal join key) without
  smuggling the join key through tool args (D20/P1 spike).

## [7.3.1] - 2026-07-09

### Fixed

- **`ToolChoiceRecorderHandle` stays assignable to `.recorder()` again** —
  7.3.0's pause/resume fix (`f0a87de`) gave the handle's new `onResume` a
  minimal event slice that shared zero property names with the
  scope-channel `ResumeEvent` branch of `CombinedRecorder`'s union, so
  TypeScript's weak-type check ("no properties in common") rejected the
  whole handle regardless of method-parameter bivariance —
  `agentBuilder.recorder(toolChoiceRecorder(...))` failed to typecheck.
  Fixed by splitting `onResume` onto its own slice that also declares an
  optional `hasInput`, giving it a property name in common with the
  scope-channel variant. **Type-only — no runtime behavior change.** A
  compile-level type-regression test suite (`test/type-regressions/`, run
  via the new `npm run test:types`) is now wired into CI, closing the gap
  that let this slip past 22 passing `ToolChoiceRecorder` tests (the root
  `tsc --noEmit` only covers `src/**`; vitest never typechecks).

## [7.3.0] - 2026-07-08

### Added

- **`agentfootprint/embedders`** — ready-made `Embedder` implementations for the
  embedding-backed scorers (`toolChoiceRecorder`/`scoreMargin`, memory
  retrieval). Core still ships only `mockEmbedder`; these are optional +
  lazy — each heavy backend is an OPTIONAL PEER DEPENDENCY imported on first
  embed, so the core stays dependency-free and you install only what you
  use: `openaiEmbedder()` (hosted, fetch + `OPENAI_API_KEY`, no extra
  install), `localEmbedder()` (on-device MiniLM via
  `@huggingface/transformers`, offline), `staticEmbedder()` (pure-JS
  Model2Vec via `@yarflam/potion-base-8m`, no network).
- **`attributeChoice`** (influence-core / `/debug`) — the transpose of
  `scoreMargin`. `scoreMargin` fixes the context and ranks candidate tools
  (deliberately excluding the constant system prompt), so it is
  structurally blind to PROCEDURAL picks. `attributeChoice` fixes the
  CHOSEN tool and ranks the context units (system-prompt rules + task)
  against it, so a constant-but-load-bearing rule surfaces as a citation
  ("picked because rule-1"), with a per-channel share of positive
  similarity mass (procedural vs topical). New `AttributionUnit` /
  `UnitScore` / `ChoiceAttribution` types. Honest: a Tier-1 similarity
  PROXY (Tier-3 counterfactual ablation is the ground truth) — no causal
  claim.
- **`explainChoice` + `snippetUnits`** (influence-core / `/debug`) — a
  UI-ready verdict for one tool pick. `explainChoice` fixes the chosen tool
  and reports WHICH context channel best explains it — system rules,
  user's task, or data returned by earlier tools — as `{channels (share of
positive similarity mass, sorted desc, zero-share listed), top citation
with unit text, ranked units}`; thin composite over `attributeChoice`.
  `snippetUnits` cuts a tool result (JSON or prose) into citable
  `AttributionUnit`s at the natural grain — one unit per object array
  element as `key: value` pairs — bounded (max/maxLength), total
  (circular-safe), pure. Together they power the answer-first "What drove
  it" verdict card in agentThinkingUI.
- **`anthropic({ timeout, maxRetries })`** — the Anthropic provider now
  accepts `timeout?`/`maxRetries?`, passed straight to the Anthropic SDK
  client (omit for SDK defaults). Long non-streaming turns (slow models,
  long conversations, agent loops) could exceed the SDK's default request
  timeout with no escape hatch.

### Fixed

- **`staticEmbedder` matches potion's async batch embed API** — the
  adapter guessed a synchronous `new Cls().encode(text)` shape; potion-base-8m
  actually exports an async BATCH `embed(texts) => Promise<Float32Array[]>`,
  so the old adapter silently returned `[]`. Rewritten to await the batch
  embed and take the first row, plus `embedBatch` and a `toRows()`
  normalizer (`Float32Array[]` / `number[][]` / a single flat vector).
- **`toolChoiceRecorder` survives pause/resume** — it reset its store on
  ANY runId change in `onRunStart`, but footprintjs's `resume()` fires
  `onResume` (stamping the regenerated runId) and THEN
  `traverser.execute()` fires `onRunStart` with that same runId, wiping
  every pre-pause entry (downstream: a debugger's Meaning-match scores
  vanished after an approve/resume turn). A new `onResume` handler updates
  the tracked runId WITHOUT resetting; a genuinely fresh `run()` still
  resets. **Requires footprintjs ≥ 9.10.1** — the no-collision half
  (post-resume calls landing on new, non-overlapping runtimeStageIds)
  needs 9.10.1's checkpoint `executionCount`/`visitCounts` seeding.

### Changed

- footprintjs peer/dev floor raised to **`^9.10.1`** (required by the
  `toolChoiceRecorder` pause/resume fix above).

## [7.1.0] - 2026-07-02

### Added

- **`backtrack(variable, element?, before?)` — the 6th trace-toolpack tool**
  (reserved under `.selfExplain()` inline mode). Variable-first triage: no
  step id needed — anchors at the variable's last writer and walks the
  dependency chain (fp 9.10.0 `sliceForKey`/`formatSlice`). Element mode is
  the agent mega-key answer: `backtrack({variable: 'history', element: 7})`
  names the exact iteration that produced message 7, with honest attribution
  (`append-verb` = engine-recorded/EXACT under the agent's `commitValues:
'delta'` default). Honest absence (never-written → initial state / args /
  closure), corrective out-of-range/not-an-array answers, chained-triage
  hints with real commit indices.
- **`sliceToBacktrackTrace(sliceJSON, opts)`** (`agentfootprint/debug`) — the
  STRUCTURAL sibling of `toBacktrackTrace`: renders a footprintjs variable
  slice on agentThinkingUI's BacktrackView board. Always
  `mode: 'correlational'`, every card a path-only upper bound (hatched
  meter), score = hop proximity with the formula named in the honesty lines,
  slice honesty (reads-coverage, truncation, incomplete-sources, honest
  absence) mapped verbatim. The parity artifact: the LLM's triage and the
  human's board are the same JSON.

### Changed

- footprintjs peer/dev `^9.10.0` (the slice layer + `writeProvenance` dial).

## [7.2.0] - 2026-07-02

### Added

- **`contextLedger()`** (`agentfootprint/observe`) — which context pieces
  EARNED their tokens? Post-run bookkeeping over the run's own commit log:
  offers = the context in effect at each LLM call (net-change-filter-exact
  folding), uses = structural signals per kind (tool-called /
  skill-activated / answer-slice(slot)), outcomes = consumer labels credited
  to offered pieces. `earnRate` (used ÷ offered) is the headline number;
  export/import merges additively for cross-session accumulation. All
  counters are recorded facts — no causal claims (ablation can upgrade
  individual rows).
- **Ledger gates** (same subpath) — the rows feed three EXISTING seams, no
  new framework surface: `ledgerToolGate(ledger)` → `ToolGatePredicate` for
  `gatedTools(...)`; `ledgerEntryScorer(ledger, inner)` → wraps any
  `EntryScorer` for `skillGraph().entryBy(...)` (demotion is ranking
  pressure re-ranked through `rankEntries`, so the pick and the surfaced
  relevance always agree — never exclusion); `ledgerGated(injection,
ledger)` → rewrites `always` to a ledger-backed rule, ANDs an existing
  rule, passes demand-driven triggers untouched. One `LedgerPolicy`:
  **demote, never starve** (`minOffers` 5, `earnRateFloor` 0.05, parole
  every `refreshEvery` 10th decision so a demoted piece keeps earning data).
- **Runner-shape honesty**: `recordRun` returns `undefined` (and counts
  nothing) for runs with no LLM-call markers — `LLMCall` is refused, never
  silently mis-scored. Grouped agents (`reactMode: 'dynamic-grouped'`) are
  metered correctly: offers fold from each `sf-llm-call` subflow's inner
  commit log (in grouped mode injections earn via `skill-activated`;
  `answer-slice(slot)` needs root-log slot writes — `usedVia` shows which
  signals fired).
- **Example 32** ([examples/features/32-context-ledger.ts](examples/features/32-context-ledger.ts)) —
  the full loop on an over-stuffed fixture: run → rows (5 zero-earners,
  ~1,940 wasted tokens) → gates → 88% fewer input tokens per turn.

## [7.0.0] - 2026-06-26

**Major: API surface cleanup — main barrel is now just the core agent API, everything specialized lives in a named subpath.** A coordinated breaking release (agentfootprint + agentfootprint-lens) so consumers land on the clean surface once. Nothing is removed — every capability is still exported, just from its canonical home.

### Changed (BREAKING — import paths)

- **New `agentfootprint/events`** — the typed event system (`EventDispatcher`, `EVENT_NAMES`, `AgentfootprintEvent`/`AgentfootprintEventMap`, the `Payloads` namespace, context/composition types) moved off the main barrel to its own subpath.
- **`/locales` is the single i18n home** for all prose catalogs (commentary + thinking + status templates); **`/status`** keeps only the status _logic_ (`selectStatus` / `renderStatusLine`).
- **Main barrel trimmed 129 → 53 values** — provider/memory/injection/tool-provider/stream/security/status/locale factories plus `typedEmit` now import from their named subpath (they were convenience mirrors). **Types follow their values**: ~95 type duplicates (including the event types) moved off main to their feature subpath, so each feature's whole surface (values + types) lives in one place.
- **`decideSkill`** — the skill-graph decider, formerly exported as `decide` (now on `agentfootprint/injection-engine`), was renamed to avoid colliding with footprintjs's `decide()`.
- A handful of internal mechanism symbols (`buildMessageApiChart`, `buildDefaultInstruction`, `buildEventMeta`, `parseSubflowPath`, `EmitBridge` / `EmitBridgeOptions`) are no longer public.

### Added (non-breaking)

- **`agentfootprint/debug`** — a debug-focused subpath that re-exports the diagnosis tools (context-bug localizer, influence scoring, trace toolpack / `.selfExplain` machinery, tool-catalog lint). These **also remain available from `/observe`** for one transition version (`/observe` now reads as recorders-first; the debug re-exports are deprecated there). The deep `observability/contextError/finders` path gains a shorter alias, **`agentfootprint/debug/finders`** — the old path still works.
- **`agentfootprint/cache`** — the prompt-cache recorder (`cacheRecorder`) and custom cache-strategy registration are now importable (they were previously unreachable from any path).

### Migration

Re-point imports to the named subpath (e.g. `import { defineMemory } from 'agentfootprint/memory'`, `import { localizeContextBug } from 'agentfootprint/debug'`, `import { EventDispatcher } from 'agentfootprint/events'`). Rename `decide` → `decideSkill` for skill-graph trees. The commentary engine helpers used by viewers (`renderCommentary`, `extractAgentName`, `extractCommentaryVars`, `selectCommentaryKey`) remain on the main barrel. Most old paths that were _aliased_ (`/observe` debug tools, the long finders path) still resolve this major and warn; they're removed next major.

## [6.45.0] - 2026-06-24

### Added — Pluggable entry scorer (keyword router + strategy interface)

Picking the starting skill in a skill graph is now a **pluggable scorer strategy**.
`.entryBy(scorer)` takes any `EntryScorer`; two built-ins ship:

- **`keywordScorer()`** — rank entries by word overlap between the user's message and
  each skill's `description`. **No embedder, no model call, deterministic** — routing
  "on" with zero setup. The new zero-config alternative to wiring an embedder.
- **`embeddingScorer(embedder)`** — semantic (cosine similarity). `.entryByRelevance(embedder)`
  is now **sugar for `.entryBy(embeddingScorer(embedder))`**. Batches via `embedder.embedBatch`
  when available (was N+1 serial).
- Bring your own by implementing `EntryScorer`. New exports: `keywordScorer`,
  `embeddingScorer`, `rankEntries`, `EntryScorer`, `EntryScorerInput`, `EntryCandidate`.
- The chosen scorer's **name + ranking** now land on the snapshot (`entryScores` + the new
  `entryScorer`), so a lens / "Why this skill?" panel can show HOW the entry was chosen.
- `start.scoredBy` added to the `skillGraph({...})` config-object form.

### Changed

- **`EntryScore.cosine` → `EntryScore.score`** — a strategy-agnostic raw score (cosine for
  the embedding scorer, word-overlap for keyword). `EntryScoring` gains a `scorer` field (the
  strategy name). This is a recently-added, niche field; the only internal reader was updated.
  ⚠️ If you read `entryScores[].cosine`, rename it to `.score`.
- The surfaced relevance % and the chosen entry can **no longer disagree** — non-finite scores
  from a custom scorer are sanitized so they can never silently win the pick.

### Fixed

- **`.tree().entryBy()` / `.tree().entryByRelevance()` now throw** instead of silently ignoring
  the scorer (symmetric with the existing `.tree().entryByRead()` guard).

### Added — Context-bug localizer documented (Beta)

The contextual-bug **localizer** (`localizeContextBug`, "git bisect for context") is now
documented as a **beta** feature: trigger → causal slice → influence-weighted ranking →
counterfactual ablation. Honesty model held throughout — the ranking is a proxy; only
ablation verdicts are causal claims.

- **New guide:** _Localize a context bug_ (`debug/localize-context-bug`), debugging-framed,
  in the Debug section. Its code block is twoslash-compiled against the real types (anti-drift).
- **New tested example:** `examples/observability/17-localize-quickstart.ts` — runs `$0` and
  proves the planted fact is the confirmed causal root cause (3/3 ablation flips).
- **`@beta` markers** on the `context-bisect` module + `localizeContextBug`. The API (exported
  from `agentfootprint/observe`) is unchanged — now labeled beta.

### Removed — legacy Astro/Starlight `docs-site/`

The old `docs-site/` (Starlight) is **removed** — `docs-next/` (Next.js + Fumadocs) is the sole,
deployed docs site. All content had already migrated; the CI "docs gate" now builds docs-next
(twoslash-compiling every code block in place of the former `<CodeFile>` transclusion).

## [6.44.0] - 2026-06-22

### Added — `enable.localObservability()` + offline-replay `Trace`

Tier-3 (Debug) observability: **retain** a live run model, render it live via the
lens, and snapshot it for **offline replay** — no agent re-run.

- **`agent.enable.localObservability({ onLive?, onRecorded?, redact? })`** → a handle
  that is `<Lens>`-renderable live (`onLive` per event) and `getTrace()`-serializable.
  `onRecorded` fires once at run exit with the finalized `Trace`.
- **`Trace`** (UI-free, JSON-lossless) — the `BoundaryRecorder` domain-event log + the
  serialized static chart `structure`. The step graph is always a derived projection
  (never stored), so redaction reaches the rendered flowchart with no second content
  surface. `serializeTrace`, `traceToStepGraph`, `buildStepGraphFromEvents`, and the
  ready-made `redactContent` ship from `agentfootprint/observe`.
- **Redaction at the serialize boundary** — `getTrace({ redact })` runs once per event
  so PII never enters the `Trace`; the result is self-describing via `trace.redaction`.
  The engine's `RedactionPolicy` already propagates to subflows; the flat event log
  means one `redact` pass covers the whole tree.

Pairs with `agentfootprint-lens`'s `<Replay trace>` (offline) and `<Lens>` (live). See
`docs/design/local-observability-and-pii.md`. Also adds design notes for governance
(`docs/design/governance.md`). Library is UI-free and browser-bundleable unchanged.

## [6.43.0] - 2026-06-22

### Added — `BedrockAgentMemory` reader for legacy Bedrock Agents memory

AWS has two memory systems: the newer **AgentCore** (`AgentCoreStore` — a read/write event
store, the go-forward path) and the prior-generation **Bedrock Agents** product, whose memory
is **read-only** (the agent auto-generates `SESSION_SUMMARY` records). `BedrockAgentMemory` is a
small, honestly-scoped **reader** for the latter — `readSummaries()` / `readText()` / `forget()`
over `GetAgentMemory` / `DeleteAgentMemory` (peer-dep `@aws-sdk/client-bedrock-agent-runtime`).

It is intentionally **not a `MemoryStore`** (Bedrock owns the writes — there's no `put`); wrapping
it as `defineMemory({ store })` would be a "store that can't store." Use it to surface Bedrock's
built-in memory in an agentfootprint agent (e.g. inject `readText()` as a Fact). Exported from
`agentfootprint/memory-providers`; a command-name test pins the real SDK commands. Prefer
`AgentCoreStore` for a real read/write store — this exists mainly for teams migrating off
Bedrock Agents.

## [6.42.0] - 2026-06-22

### Fixed — `.memory()` now injects recall into the prompt on the Agent path

`.memory()` on an **Agent** read, formatted, and persisted recall but never composed it into
the prompt — the memory READ subflow wrote `memoryInjection_<id>`, while the slot composers
only read `activeInjections`, with nothing bridging the two. So turn N never saw turn N-1's
facts (and the uninformed reply then self-polluted the store). `memoryRecallInjections` now
turns each recall into a `'memory'`-flavored `ActiveInjection` (system-role content → system
slot, the rest → messages slot), folded in by the slot-fork inputMappers in `buildAgentChart`

- `buildDynamicAgentChart`. No new stage, no structure change, a no-op without memories.

This **changes runtime behavior** (an Agent with `.memory()` now sends prior-turn context to
the model) — hence a minor. Regression test asserts recall reaches turn-2's prompt across
classic / dynamic / dynamic-grouped (the coverage gap that let it ship).

### Fixed — `AgentCoreStore` targeted the wrong AWS service

`AgentCoreStore` imported `@aws-sdk/client-bedrock-agent-runtime` (the **old Bedrock Agents**
API) and dispatched `PutMemoryEventCommand` / `GetMemoryEventCommand` / … — commands that
**do not exist in any package**. It only passed because tests injected a mock, so it would
throw the moment a real `memoryId` was wired. It now targets **`@aws-sdk/client-bedrock-agentcore`**
(`BedrockAgentCoreClient` + `CreateEvent` / `ListEvents` / `DeleteEvent`).

AgentCore Memory is an **append-only event log**, so the adapter is mapped accordingly: `put`
appends (the entry is stored as a `blob` document; `actorId` from the identity tuple,
`sessionId` from the conversation); `list` is `ListEvents` (window / episodic memory is the
natural fit); `get`/`delete` by id are list-then-find (server-assigned event ids); `forget`
lists + deletes each event (AgentCore has no `DeleteSession`). New optional peer
`@aws-sdk/client-bedrock-agentcore` (the old `…-agent-runtime` peer is retained for future
adapters). A new test pins the **real command names** so the wrong-service bug cannot recur.

## [6.41.0] - 2026-06-19

### Fixed — OpenAI/Azure adapters use the current Chat Completions params

The `openai` / `azureOpenai` / `browserOpenai` / `browserAzureOpenai` adapters sent
deprecated params that break on current models:

- **`max_tokens` → `max_completion_tokens`** on OpenAI/Azure. `max_tokens` is deprecated
  and **rejected by o-series reasoning models** (o1/o3/o4-mini, Azure reasoning
  deployments); the new param is accepted by all current chat models (incl. gpt-4o).
  Custom OpenAI-compatible endpoints (Ollama/vLLM/Together/Groq — detected via `baseURL`
  / non-`api-key` auth) keep `max_tokens`.
- **Streaming token usage was always 0** — the adapters never sent
  `stream_options: { include_usage: true }`, so OpenAI/Azure emitted no usage chunk.
  Now requested on OpenAI/Azure streams (`usage.input/output` are populated).

### Added — `reasoning` option for o-series models

`openai({ reasoning })` / `azureOpenai({ reasoning })` (and the browser variants). When
set — or auto-detected from a standard o-series model id (`o1`/`o3`/`o4-…`) — the adapter
omits the explicit `temperature` (rejected by reasoning models) and sends the
**`developer`** role in place of `system`. Set it explicitly for Azure deployments whose
name does not reveal the underlying model.

The Tools API format was already current (modern `tools` / `tool_calls`) and is unchanged.

## [6.40.0] - 2026-06-18

### Added — `toolContractCheckup`: diff agent tool schemas vs a tool-server catalog

The server-boundary extension of proposal 009. When an agent's tools call a remote
tool-server (an MCP-ish sidecar, a function gateway), the agent's `inputSchema` and the
server's real contract can drift — the model then calls a tool that 404s, or omits an
arg the server REQUIRES and gets a "doesn't work." Servers usually publish a catalog
(`GET /tools` → `[{ name, inputSchema }]`), so the drift is **checkable at build/CI
time** instead of surfacing as a runtime error.

**`toolContractCheckup(agentTools, serverCatalog)`** — a PURE diff (no I/O; you fetch
the catalog and pass it). Accepts `Tool[]` or `{name, inputSchema}[]` on either side.
Flags:

- **`required-divergence`** (error) — server REQUIRES an arg the agent marks optional/omits → the model omits it → server rejects.
- **`optional-drift`** (warning) — server accepts an arg the agent never surfaces → the model can't use that filter ("tool ignores my narrowing").
- **`arg-divergence`** (warning) — the agent declares an arg the server doesn't know (a rename/typo).
- **`missing-on-server`** (error) — an agent tool not in the catalog → would 404.
- **`dead-endpoint`** (warning) — a server tool no agent tool calls.

New exports (main barrel): `toolContractCheckup`, `formatToolContractCheckup`, types
`ToolContractCheckup` / `ToolContractProblem` / `ToolContractCode` / `ServerToolEntry`.
New example `30-tool-contract-checkup.ts`.

Motivation: a real adopter (Neo) hit "tools don't work" against an MCP-ish Python
sidecar. Running this against the sidecar's `/tools` catalog proves the schemas are
**already aligned** (the failures were a deployment `MOCK`/env config issue, not a
contract drift) — exactly the kind of fast, honest verdict a static check gives.

## [6.39.0] - 2026-06-18

### Added — skill-body ↔ tool-contract check (proposal 009, Tier 1)

A skill's `body` (prose injected into the system prompt) can quietly contradict the tools
it actually unlocks — and the model then **refuses a tool that is right there**, or is told
about one it can't call. This was the core of a real adopter (Neo) tool-visibility report.
The library already knows each skill's real tool set, so it now flags the mismatch at
authoring time.

`graph.checkup()` runs a new **deterministic, no-LLM** pass over each skill's body vs its
`tools[]` and adds two WARNING codes (never errors — they never fail `.build()`):

- **`body-foreign-tool`** — the body names a tool that belongs to **another** skill (not
  callable on this turn). Usually an intentional `read_skill` handoff — confirm it, add the
  tool, or reword.
- **`body-unknown-tool`** — the body has a `tool_name(` call to a tool that exists **nowhere**
  (a typo, or a renamed/removed tool).

New exports (main barrel): `checkSkillContract(skill, knownToolNames?)` (check ONE skill
standalone), `checkSkillContracts(skills)`, `skillToolNames(skill)`. New example
`29-skill-contract-check.ts`; guide section 8.

Honest scope: Tier 1 is deterministic and catches the _adjacent_ class. The **semantic**
contradiction it can't see — a body calling an OPTIONAL tool arg "required" (the exact
shape of the originating bug) — is **Tier 2 (LLM-advisory)**, designed in proposal 009 but
not yet built.

## [6.38.1] - 2026-06-18

### Added — tool-name guardrail (catch the silent "my tool vanished" 400)

OpenAI, Azure OpenAI, and Anthropic all require tool names to match
`^[a-zA-Z0-9_-]{1,64}$`. A name with a dot, space, slash, colon, or >64 chars makes
the provider **400-reject the whole request — so EVERY tool disappears**, not just the
bad one, which reads as "my tool isn't visible." The library never validated this.

- **`warnIfInvalidToolName(name)`** — the new default guard. `defineTool` and the
  agent's tool-registry build call it for every tool name (including `autoActivate`
  skill-scoped tools and raw `{schema,execute}` literals). **Dev-mode warning only**
  (`enableDevMode()`), never a throw — so mock providers, name-sanitizing custom
  providers, and namespaced names (`server.tool`) keep working. Production pays nothing.
- **`assertValidToolName(name)`** — strict variant that **throws** a clear, actionable
  error (names the tool, the rule, and the fix). For consumers who want a hard failure
  in a build step or test.

Both exported from the main barrel. No behavior change at run time unless dev mode is
on (then you get a heads-up instead of an opaque provider 400 later).

## [6.38.0] - 2026-06-18

### Added — `.entryByRead()`: the LLM picks the skill-graph entry, no embedder

A flat `skillGraph()` with multiple `.entry()` skills needs to choose where a turn
starts. Until now that was either an embedder (`.entryByRelevance(embedder)`) or the
silent fallback of loading **every** entry's body each turn. For setups with no
embedder — or where embeddings route poorly for the domain's language — neither fits.

**`.entryByRead()`** makes the agent's **own LLM** pick the entry by reading the menu:

```ts
skillGraph().entry(billing).entry(incident).entryByRead().build();
```

- **Exclusive, like `entryByRelevance`** — only the chosen entry's body loads
  (token-efficient); the others stay dormant.
- **No embedder, no extra model call** — on the first turn **no** entry body is
  injected; the agent is offered the entries through the existing `read_skill` gate
  (`reachableSkills(undefined)` already returns the entries), and its pick becomes the
  cursor. The chosen entry's exclusive trigger (`nextSkill(ctx) === id`) then fires and
  the normal `from`-gated routing takes over — reuses the `read_skill → activatedInjectionIds`
  path, so no engine/runtime change was needed.
- **Object form:** `{ start: { entries: [...] } }` now defaults to `entryByRead`; add
  `byRelevance: embedder` to opt into the embedder ranking instead.
- Guardrails: mutually exclusive with `.entryByRelevance()` (throws); flat-graph only,
  not `.tree()` (throws).

Existing graphs are unchanged — `.entry()` without `.entryByRead()`/`.entryByRelevance()`
keeps its v1 always-on semantics. New: example `28-skill-graph-entry-read.ts`, guide
section 4 (now five entry strategies).

## [6.37.1] - 2026-06-18

### Fixed — a `decide()` rule no longer trips the `no-llm-call-ids` warning

`toBacktrackTrace` emitted the `no-llm-call-ids` honesty flag — _"pass llmCallIds or captured
events … the ranking is structure-only"_ — whenever a slice had zero LLM-call ids. For a
deterministic `decide()` rule that wording is misleading: a rule makes **no** LLM calls, so
structure-only ranking is the _correct, expected_ mode, not a missing input. The localizer can't
tell that case from "an LLM chart whose `llmCallIds` weren't passed" (both have
`llmCallIdCount === 0`) — only the consumer's `decidedAtKind` disambiguates. So when
`decidedAtKind: 'rule'`, that one flag is reframed into a neutral, non-`⚠` note:

> _"this decision is a deterministic rule — it makes no LLM calls, so scores rank recorded operands
> by structure (no influence weighting applies)."_

LLM decisions are unchanged — a genuine "forgot to pass `llmCallIds`" still surfaces as a `⚠`
warning. Visible on the backtrack-board demo's `decide()` rule pill, where the warning previously
read as alarming in exactly the spot the categorization calls "the clean, fully-recorded kind".

## [6.37.0] - 2026-06-18

### Added — pluggable influence scorer for `localizeContextBug` (the RANK extension point)

**`scorer?:` slot + `InfluenceScorer` type.** The context-bug localizer's suspect-ranking step is now
a swappable slot: `localizeContextBug({ scorer })` accepts any `InfluenceScorer` —
`(ScoreInfluenceArgs) => Promise<InfluenceScore[]>` — and defaults to the shipped FDL four-signal
composite (`scoreInfluence`). Bring your own to change the ranking ORDER: `scoreContrastiveInfluence`
(wrap it to remap `answerText` ← `finalAnswerText` and supply a `referenceText`), or a scorer of your
own. New `InfluenceScorer` type exported from `agentfootprint/observe`.

**Claim-ladder guarantee.** A scorer only reorders suspects (how _fast_ ablation reaches a culprit),
never _whether_ a claim is causal — ablation alone convicts. Its output flows into `semanticScore` /
ranking only, never into any verdict path. So any scorer is safe to swap; the worst a bad one does is
make confirmation slower, never wrong.

**Additive, fully back-compat.** Omitting `scorer` reproduces the exact prior behaviour (default ===
`scoreInfluence`, identical args). No existing caller is affected. Runnable + tested example
`examples/observability/16-pluggable-scorer.ts`; new guide section in `docs/guides/contrastive-influence.md`.

### Fixed

- `loop-recall.ts`: corrected a stale field comment — `LoopCandidate.eligibility` documented a
  "forward-eligibility sum" but the shipped mechanism is the BACKWARD recency-weighted sum
  (`Σ_N recencyDecay^(lastLoop−N)·perLoop_N`). Comment-only; the field name is kept for back-compat.

## [6.36.0] - 2026-06-17

### Added — skill-graph v2 remainder: check-up, object form, route recorder, governors, relevance hint

**Build-time check-up.** `graph.checkup()` → `{ ok, problems }` inspects the declared graph
for wiring mistakes — an unreachable skill, an edge/entry to an unknown id, two un-prioritized
edges from one skill, no entry, a self-loop — _before_ you run. `.build({ check: 'throw' | 'warn'
| 'off' })` runs it at build (default `'warn'`: dev-mode console, silent in prod). Pure, no engine
change. New `skillGraphCheckup.ts`.

**Object-literal form.** `skillGraph({ skills, start, steps, tree?, check? })` — an alternative to
the fluent builder that lists `skills` INDEPENDENTLY of the wiring, so the check-up can catch a
listed-but-unwired skill (the fluent form only sees skills that appear in an edge). `start` is
`'id'` / `{ use }` / `{ rules }` / `{ entries, byRelevance }`. Translate-then-delegate; defaults
`check` to `'throw'`. New public types `SkillGraphConfig`, `BuildOptions`, `GraphCheckMode`,
`GraphCheckup`, `GraphProblem`, `GraphProblemCode`.

**`routeRecorder()`** (`agentfootprint/observe`) — records the skill path a run took, hop by hop
with a human-readable reason, by COMPOSING the shipped `context.evaluated` + `skill.rejected`
events (no engine change). `getPath()` / `getHops()` / `getRejections()`. New types `RouteHop`,
`RouteOutcome`, `RouteTrip`; `formatRouteHop`. Powers the lens / "Why this skill?" panel / route figures.

**Grey-area governors** (folded into the route recorder) — `getTrips()` reports oscillation
(`A→B→A→B` within `pingPongWindow`, default 4) and a run of consecutive rejected `read_skill`
jumps (`maxRejectedRetries`, default 3). Observability (`onTrip:'stay'`); a runtime force-stop is
a deferred follow-on (the iteration cap remains the hard stop).

**`defineRelevanceHint()`** — an advisory, anti-anchoring system-prompt note that fires at turn
start ONLY when `entryByRelevance`'s top entries are a near-tie ("a keyword scorer ranked these
close; it can't see the conversation — use your judgment"). Reads `ctx.entryScores` (now threaded
onto `InjectionContext`); rides `context.evaluated` (no new event). Add it explicitly via
`.instruction(defineRelevanceHint())`.

Designed + adversarially reviewed (panel), built tests-first (25 new); full suite 3123 green.

### Still proposed (NOT in this release)

- The agentThinkingUI **Description Doctor** (the red/green description-diff view) and two minor
  enrichments (a runtime governor force-stop; a `cursorBefore`/`cursorAfter` field on
  `context.evaluated`). See `docs/design/skill-graph.md`.

## [6.35.0] - 2026-06-17

### Added — skill-graph follow-ons: scoped `read_skill` + relevance entry routing (proposal 002 v2)

**Scoped `read_skill` (stay on the trail).** `read_skill('id')` is now rejected when `id` is not
reachable from the current cursor — closing the hole where the model could silently jump out of the
`from`-gated graph.

- New `graph.reachableSkills(currentSkillId)` (sibling to `nextSkill`): cold start → entry skills;
  otherwise → the cursor's direct successors ∪ entries \ {cursor}; a decision `tree()` returns all
  leaves (`read_skill` stays a full escape hatch there).
- Runtime gate at `toolCalls.ts`: an out-of-set `read_skill` is rejected with a re-prompt naming the
  allowed skills, the cursor + activations stay unchanged (the model re-picks), and an
  `agentfootprint.skill.rejected` event fires. **Plain `read_skill` agents (no skillGraph) are
  byte-for-byte unaffected** (the gate is off when no graph is mounted).

**`entryByRelevance(embedder)` — pick the starting skill by meaning.** Route the _entry_ by
embedding-similarity to the user's message instead of regex.

- Embeds the message + each `when`-passing entry's `description`, cosine-scores, softmaxes into a
  `relevance` share → starts at the best match. **LLM-free** (an embedder, no extra model call),
  reproducible given the embedder. Reuses the existing `Embedder` + `cosineSimilarity` + `mockEmbedder`.
- The ranking lands on `scope.entryScores` (snapshot / commit-log — the "Why this skill?" relevance %).
  New public types `EntryScore` / `EntryScoring`.
- Under `entryByRelevance` the entries are **exclusive** — only the picked one loads (token-efficient).
- The async embedder runs in a once-per-turn **`PickEntry`** stage mounted _off_ the ReAct loop (before
  the Injection Engine), so `nextSkill` and the route triggers stay synchronous (no async leak into the
  hot loop). Wired in both the flat and grouped charts.

Both features ride **optional** plumbing — agents without the relevant `.skillGraph()` calls are
unchanged. Designed + adversarially reviewed (panel), then built tests-first: `reachableSkills` unit +
property, the gate's real-loop functional/integration + back-compat regression, `softmax` + `scoreEntries`
units, and `entryByRelevance` real-loop tests across reactModes; examples 23 + 24. Full suite 3099 green.

### Still proposed (NOT in this release)

- Grey-area governors (oscillation / fallback-retry caps), the `RouteDecisionRecorder`, build-time graph
  validation, and the object-literal façade. See `docs/design/skill-graph.md`.

## [6.34.0] - 2026-06-17

### Added — skillGraph() `from`-gating keystone: a sticky-cursor skill state machine (proposal 002 v2)

- **`skillGraph()` route edges are now `from`-gated.** A skill graph is a state machine over skills; the
  engine tracks which node it is in via a persisted cursor `InjectionContext.currentSkillId`. An edge
  `A → B on tool X` now fires **only while the cursor is on A** — killing the v1 cross-skill "edge bleed"
  where the same edge also fired while in an unrelated skill D that produced X. (v1 documented `from` as
  informational and NOT enforced; this enforces it.)
- **One pure resolver is the single source of truth: `graph.nextSkill(ctx)`.** Cold start → the first
  matching `entry`; a `from`-gated route whose predicate matches `lastToolResult` → its target; else the
  current cursor (sticky stay). Each route target compiles to the trigger `nextSkill(ctx) === id`, which
  delivers `from`-gating + stickiness (you stay in a skill until an edge leaves it) + a clean handoff
  (the leaving skill deactivates the same iteration the next one activates). Each candidate predicate runs
  in its own try/catch (a throw = no-match, dev-warned) so one bad edge can't crash the loop.
- **The cursor advances inside the Injection Engine** with the SAME ctx the route triggers gate on, so the
  active set and the persisted cursor can never disagree (no off-by-one). Threaded across the flat
  (`buildAgentChart`) and grouped (`buildDynamicAgentChart` + `sf-llm-call`) mount mappers; reset per turn
  at seed (each turn re-enters via the entry router). Plumbed via `Agent` + `AgentBuilder.skillGraph()`.
- **Decision `tree()` graphs are unaffected** (they route per-iteration by stable `ctx` predicates, no
  cursor). Agents without a `skillGraph()` are unchanged (`currentSkillId` stays undefined).
- Reviewed by a 3-lens panel (verdict: SHIP WITH NITS — both high-risk properties confirmed against
  source); covered by per-reactMode (`dynamic` / `classic` / `dynamic-grouped`) real-loop e2e tests
  asserting route-firing AND edge-bleed prevention via the `agentfootprint.context.evaluated` emit.

### Changed

- **`AgentBuilder.skillGraph(graph)` now requires `graph.nextSkill`** (every `skillGraph().build()` supplies
  it). Pass the full `build()` result; for a bare skill list use `.skills({ list })`. Removes a stale-resolver
  footgun when two graphs are mounted.
- Route targets now compile to a cursor-gated `rule` trigger; the drawn edge kind (`on-tool-return` /
  `predicate`) is preserved for `toMermaid()`.

### Still proposed (NOT in this release)

- The runtime activation gate at `toolCalls.ts` (no `allowedSet` enforcement yet), the scoped `read_skill`
  enum, the `'score-match'` entry strategy, grey-area governors, the `RouteDecisionRecorder`, build-time
  validation, and the object-literal façade. See `docs/design/skill-graph.md`.

## [6.33.0] - 2026-06-16

### Added — tool-output provenance unblocks L4's cross-loop descent (proposal 008)

- **`assembleTrajectory` now surfaces each loop's proximate tool result** on a new WALK-ONLY
  `LoopFrame.proximateToolSource` field — the most recent `lastToolResult` committed before that
  loop's `call-llm`, with the PRODUCING loop's tool-calls stage as its `writerId` (the cross-loop
  provenance edge `walkToRoot` descends along). FLAT charts only; `proximate: true` marks it as an
  INFERRED edge (the call-llm read `history`, not this key — honest two-tier claim preserved).
- **`walkToRoot`'s descent now fires on a real flat agent.** Previously the trajectory surfaced only
  injection suspects, so the multi-hop cross-loop descent never fired; now it hops along the
  proximate tool edge from the symptom to the loop that produced it. Component-validated (the real
  trajectory carries the edge AND the algorithm descends on it; +2 tests). The end-to-end
  model-based misdirect gate (realistic embeddings) remains the final promotion measurement.
- **L3 is provably untouched.** The source is WALK-ONLY (NOT in `contextSources`), so
  `shortlistEarlyCulprits`'s narrow + its measured top-3 10/10 recall are unchanged.

## [6.32.0] - 2026-06-16

Per-loop context-bug localization: the trajectory now segments the GROUPED agent too, plus
two new pluggable localizer stages — L3 (recall shortlist) and L4 (the backtracking walk).
Requires **footprintjs ≥ 9.9.0** (the per-loop subflow commit retention).

### Added

- **`shortlistEarlyCulprits(trajectory, { embedder, recencyDecay })` (L3 — proposal 006)** — a
  per-loop RECALL shortlist that surfaces culprits the final answer buries (each source scored
  against the loop it fed, recency-weighted), to NARROW before ablation. It is a recall booster,
  **NOT a #1 ranker** (H2 measured the ranker as a loss). **Gate-validated**: the real scorer
  reproduces top-3 recall 10/10 vs plain 9/10 on the CTXBUG benchmark at the default
  `recencyDecay` 0.5. Joins 1:1 with a localizer `Suspect`; feeds `localizeContextBug({ shortlist })`
  as a REORDER-only narrowing hook. `localizeContextBug` gains the optional `shortlist` option.
- **`assembleTrajectory` now handles the GROUPED chart** (`reactMode: 'dynamic-grouped'`): each
  loop is projected PER-SCOPE over its own `sf-llm-call` inner commit log (retained per-iteration
  by footprintjs 9.9.0). Grouped frames carry `subflowScope`.
- **`walkToRoot(artifacts, { embedder, rerun })` (L4 — proposal 007)** — an influence-guided
  backtracking debugger: narrow (per-loop influence) → hop along `writerId` provenance → isolate
  with run-wide ablation, walking symptom → root for decision bugs. **Honest scope:** the walk
  ALGORITHM is validated synthetically (the deterministic decision-bug gate passes), but the
  multi-hop cross-loop DESCENT does NOT yet fire on a real agent — today's trajectory surfaces only
  injection suspects (the `call-llm` reads `history`, not `lastToolResult`), so on a real run it
  convicts the injection root at the symptom. Promoting the real-agent descent is gated on enriching
  the trajectory's tool-output provenance (follow-up). Exported with this limitation documented.

## [6.31.0] - 2026-06-15

### Added — contrastive influence: cancel the topical-innocent confound

- **`scoreContrastiveInfluence({ evidence, answerText, referenceText, embedder })`**
  — a SEPARATE, opt-in second stage over the four-signal scorer. Identical to
  `scoreInfluence` except the FA term contrasts answer-similarity against a
  reference output: `FA(e) = sim(e, answer) − sim(e, reference)`. A
  topically-central innocent (the policy a refund decision quotes) resembles BOTH
  the wrong and the right output, so it cancels (~0); the true culprit resembles
  the wrong output specifically, so it surfaces. Same `InfluenceScore[]` shape, so
  `rankingConfidence` + ablation compose unchanged.
- Honest scope: still an embedding-geometry PROXY, never causal — the contrast
  removes a confound, it does not prove causation (ablation is the causal tier).
  Opt-in: it needs a reference output, so it is for regression / eval debugging,
  not cold localization. Helps the CONTENT-class confound only — absence/crowding
  bugs stay `rankingConfidence` + `findDroppedContext` + ablation territory.
- New surface from `agentfootprint/observe`: `scoreContrastiveInfluence`,
  `ScoreContrastiveInfluenceArgs`. `assertValidWeights` is now shared by both
  scorers (caller-attributed errors) so contrastive enforces the same
  weight-validation contract as `scoreInfluence`.
- Benchmark result (CTXBUG, bge-small embedder): top-1 culprit accuracy on the
  content-bug classes (B1–B5) **87% → 100%**, recovering 2 topical-innocent cases
  with 0 regressions; full set incl. B6 absence bugs 76% → 88%.

13 tests (7-type coverage), example 11, `docs/guides/contrastive-influence.md`.
Focused review + fix; full suite 2997 green.

## [6.30.0] - 2026-06-11

### Added — restoration: the causal tier for the missing-context finder (interface #3)

- **`localizeContextBug({ missingContext })`** — pass what was `available` for the
  turn and what was `sent`; the report's new `dropped` lists units that never
  reached the model. Add a restoration `rerun` and each candidate gets a
  **restoration verdict** — the mirror of ablation: add the unit back, seeded
  re-runs, majority-flip on a stable baseline = `confirmed` (causal). Report gains
  `dropped` + `restorationBaseline`; an unstable un-restored baseline raises a
  `baseline-unstable` honesty flag. `formatContextBugReport` renders a MISSING
  CONTEXT section symmetric with SUSPECTS.
- New surface from `agentfootprint/observe`: `runRestorationProbe`,
  `RestorationRunner`, `RestorationRerun`, `RestorationProbeConfig`,
  `RestoredCandidate`. `verdictFor` gained an `action` param ('ablating' default,
  'restoring') — ablation claim strings unchanged.

The localizer now identifies a context error whether the culprit is PRESENT
(rank → ablate) or ABSENT (find → restore), each ship-a-default + bring-your-own.
Cost note: restoration confirmation calls your model `samples × (K+1)` times.
Example 10 extended; missing-context guide updated. 7-lens panel review; full
suite 2985 green.

## [6.29.0] - 2026-06-11

### Added — three interfaces for identifying a context error (`agentfootprint/observe`)

- **`rankingConfidence`** — honesty marker for an influence ranking. When no
  source clearly dominates (the signature of an absence/crowding bug
  output-similarity is blind to), it returns `clearWinner: false` with a
  `shortlist` to confirm by ablation, instead of a confident-but-wrong rank-1.
  Guarantees the lead — and, when there's no clear winner, the runner-up — are
  in the shortlist; robust to malformed scores.
- **Pluggable `ConfidenceStrategy`** — the decisiveness rule is swappable:
  `marginStrategy` (default, absolute gap) and `ratioStrategy` (scale-invariant,
  transfers across embedders), plus bring-your-own. Framework invariants hold
  under any strategy.
- **`findDroppedContext`** — missing-context finder (interface #3). Finds context
  that was available but never reached the model (`available − sent`, an exact
  O(n) id diff — no embeddings, no LLM); confirm a candidate by RESTORATION (the
  mirror of ablation). Closes the gap influence-ranking + ablation are blind to
  (a key unit truncated out of the window has nothing to ablate).

Each interface is ship-a-default + bring-your-own. New guides:
`docs/guides/ranking-confidence.md`, `docs/guides/missing-context.md`; examples
09–10. New tests across all 7 Convention-3 types; full suite 2967 green.

## [6.28.1] - 2026-06-11

### Docs

- README: the BacktrackView board and the conversational-doors section now
  link to the dedicated live playground page
  (`agentThinkingUI/demo/backtrack.html`) instead of the generic homepage,
  which only redirected to the runtime player. The doors are code-only (no
  UI page); their link is labeled as the _same evidence the board
  visualizes_, alongside the runnable `06`/`07`/`08` examples.

## [6.28.0] - 2026-06-11

### Added

- **The conversational doors over the trace toolpack** — ask the trace
  instead of reading it (`agentfootprint/observe` + the Agent builder):
  - **`traceDebugAgent({ artifacts, provider, model })`** — one call returns
    a ready dedicated debugger: toolpack mounted, the proven methodology
    (overview → drill by id → cite evidence → respect ⚠) as its system
    prompt. A separate session over a completed run — the B13 security
    posture, packaged — and the cheap-model story made real (a Haiku-priced
    session debugging a Sonnet run, paying only for what it opens, by id —
    ~9% of the trace in the example-01 fixture, widening with run size).
  - **`.selfExplain()`** on the Agent builder — the agent answers follow-up
    why-questions from its OWN previous completed run. Mounts one skill
    (methodology body only) + a `skillScopedTools` provider composed with
    the consumer's own, so the production catalog carries only the
    activation row until the LLM activates the skill — then the next
    iteration gains the trace tools. Evidence binds LATE at each run's
    terminal flush (a fresh per-run control-dep recorder is rotated so
    captured control edges survive Convention-4 resets) and can never see
    the in-flight run; failed runs capture too ("why did you fail?" works).
    `delegate: { provider, model }` switches the model at the why-point:
    one `explain_run` tool runs a nested `traceDebugAgent` at the
    delegate's price.
  - **`lazyTraceToolpack(resolve)`** — the toolpack with late-bound
    artifacts (template schemas pre-run, real index memoized per snapshot,
    honest model-visible answer before the first completed run).
  - **Tool-boundary honesty**: `trace_node` on the tool-execution step now
    marks the consumer-system boundary explicitly (args in / results out;
    internals not traced unless the tool returns its own diagnostic refs).
  - 16 tests across Convention-3 tiers; examples
    `07-trace-debug-agent.ts` + `08-self-explain.ts` (offline, with the
    per-call catalog transcript as the on-demand proof); guide section
    "The conversational doors".

## [6.27.0] - 2026-06-11

### Added

- **`toBacktrackTrace(report, opts)`** (`agentfootprint/observe`,
  `src/lib/context-bisect/toBacktrackTrace.ts`) — serialize a
  `localizeContextBug` report into the `BacktrackTrace` contract that
  agentThinkingUI's `<BacktrackView>`/`<BacktrackOverlay>` render (the
  "why?" board: suspects → influence meters → ablation stamps →
  chain-of-custody rewind). Pure mapping, no UI dependency; the
  interfaces mirror agentthinkingui's `types/index.d.ts` and both sides
  stay framework-agnostic JSON. Honesty preserved, never added: cards
  carry TRUE report ranks even as a subset (default selection prefers
  content-evidence suspects; the rest fold into one fully-disclosed
  line), path-only scores carry `upperBound` (hatched meter + starred
  value), `inconclusive` ablations map to NO verdict, honesty flags ride
  verbatim with the claims-discipline lines. The caller supplies the two
  things the report can't know: the decision's `answer` text (required)
  and optional `custody` panes of recorded state for the rewind player.
  Example: `examples/observability/06-backtrack-trace.ts`. README: "One
  contextual error, walked end to end".

## [6.26.1] - 2026-06-11

### Fixed

- **`agentfootprint/observe` was browser-broken since 6.25.0.** The tool-lint
  CLI (`src/lib/tool-lint/cli.ts`) had a top-level `import { readFile } from
'node:fs/promises'` and is re-exported by the `/observe` barrel, so any
  browser bundle importing `agentfootprint/observe` crashed at load. The
  import is now a lazy `await import('node:fs/promises')` at the one use
  site (file-reading only happens in the Node CLI path) — same pattern the
  audit exporter uses for `node:crypto`. Found by loading the playground in
  a browser; example 21's module-top `process.env` reads got the same guard.

## [6.26.0] - 2026-06-11

RFC-003 Part B (blocks D7–D9): the contextual-bug LOCALIZER — "git bisect
for context" (`agentfootprint/observe`, `src/lib/context-bisect/`). Pure
ASSEMBLY over shipped pieces: footprintjs 9.8.0's complete causal DAG
(control edges D3, honesty markers A2, `EdgeWeigher` hook A3, truncation
flags A4) × influence-core scoring (D6) × consumer-run counterfactual
ablation. No new typed events; no engine changes.

- **D7 — `llmEdgeWeigher({ embedder, llmCallIds, commitLog })`.** Turns an
  LLM call's parent hairball into a RANKED shortlist: each DATA edge whose
  child is an LLM call is weighted by influence-core's composite of the
  parent's WRITTEN content (the edge key's committed value) vs the child's
  OUTPUT (everything it committed). Two-pass adapter over footprintjs's
  synchronous `EdgeWeigher` hook: `prime(dag)` embeds in deduplicated
  batches and memoizes; `weigh` then answers synchronously — re-run
  `causalChain({ weigh })` to stamp the weights. Control edges and non-LLM
  children stay at the engine default 1.0 (a routing decision is not a
  content question). Deterministic: same artifacts + deterministic
  embedder → same ranking, ties keep slice (BFS) order; texts come ONLY
  from the commit log, so policy-redacted values reach the embedder as
  placeholders, never secrets. Acceptance pinned: a 12-parent hairball
  ranks verbatim-reuse first, digit noise last, identically across fresh
  handles.
- **D8 — `localizeContextBug({ artifacts, embedder, atStep?, trigger?,
rerun? })`.** The five-stage pipeline: (1) TRIGGER — explicit `atStep`,
  a custom strategy, or the `QualityRecorder`'s lowest-scoring step;
  (2) SLICE — `causalChain` with `controlDepRecorder` lookups (labeled
  `[control: rule]` hops) + A2/A4 honesty markers; (3) WEIGH — D7;
  (4) RANK — slice nodes classified into ablatable context sources
  (default classifier reads the agent chart's committed shapes:
  `*Injections` records → per-`Injection.id` suspects, `lastToolResult` →
  tool suspect, A2 `args` marker → arg suspect; pluggable `classify` for
  other charts) and scored `structural × semantic` (max-product path
  weight × per-item influence composite vs the trigger's output);
  (5) ABLATE — optional: a consumer-supplied `AblationRunner` re-runs the
  scenario without each top suspect, N seeded times each, against a
  baseline probe. **WITHOUT `rerun` the report stops at the ranking,
  marked `mode: 'correlational'`** — no causal claim anywhere (§B2).
  Report shape: `{ step, suspects: [{ source, kind, score, edgePath,
ablation?, verdict?, runs? }], sliceStats, honestyFlags, baseline? }` —
  every id is a plain runtimeStageId, drillable with the Part C
  trace-toolpack over the same artifacts bag. Honesty flags surface ⚠
  truncated slices, untracked sources, missing control-deps/read-tracking/
  llm-call-ids, and unstable baselines. `formatContextBugReport` prints
  the claim tiers in the output itself.
- **THE ABLATION SEAM (documented because it did not exist):**
  `AgentOptions` has no `ignoredTools` runtime kill-switch — tools,
  injections, and memory entries enter an agent at CONSTRUCTION. The
  counterfactual therefore rebuilds the agent from
  `applyAblations(specs, { tools, injections, memoryEntries })`-filtered
  inputs inside the consumer's runner. Per-kind adapters
  (`ablationForSuspect`): tool → `ignoredTools`, injection/fact/skill →
  `excludeInjectionIds`, memory → `excludeMemoryIds`, arg → consumer
  override note (run input cannot be filtered by the library).
- **D9 — `bisectCulprits({ suspects, rerun, embedder })`.** Multi-culprit
  bisection over the ranked set (two-way ddmin with interference
  handling + an independent-culprit loop): finds MINIMAL suspect sets
  whose joint ablation flips the outcome. Every probe = N seeded reruns
  (`samples` clamps to ≥ 2 — never single-run verdicts) with similarity
  mean ± stdev ALWAYS reported; probes are cached by spec-set and
  budgeted (`maxProbes`); honest exits: `'not-reproducible'` (the full
  ranked set never flips), `'inconclusive'` (unstable baseline or budget
  exhausted). Verdicts/culprits are the ONLY causal claims — the ranking
  merely chooses the search order.
- **Honest-claims discipline (§B2), spelled out on every type:** weights
  and scores are deterministic embedding-geometry PROXIES (semantic
  alignment, never model internals, never causal attribution); slice
  completeness is bounded by tracking and the report SAYS so; ablation
  verdicts are the only causal tier. Falsifiable validation pinned in
  `test/lib/context-bisect/validation.test.ts`: across three planted-bug
  scenarios, ablating the top-ranked suspect flips the outcome strictly
  more often than the bottom-ranked (3 vs 0 with the fixtures).
  Calibration note: `mockEmbedder` compresses prose to ~0.85–0.97 cosine —
  use a DOMAIN `outcomeChanged` comparator with it; absolute similarity
  thresholds only with real embedders.
- Example: `examples/observability/05-context-bisect.ts` — a planted
  misleading FACT injection ('VIP tier override') makes a refunds agent
  approve a 47-day-old refund; the localizer finds it as the top ablatable
  suspect and CONFIRMS it via ablation (3/3 seeded reruns flip
  APPROVED → DECLINED; benign fact + lookup tool come back
  not-confirmed). Part 2: the credit fixture — labeled control edges on a
  plain decide() chart in honest correlational mode. 47 new tests across
  unit/functional/integration/property/security/performance/load +
  falsifiable validation.

## [6.25.0] - 2026-06-11

RFC-002 tiers 1–2 (blocks C1–C6): the tool-catalog confusability LINT
(build-time, CI-gateable, framework-agnostic — the adoption front door) +
the runtime tool-choice MARGIN RECORDER. Both are policy layers over the
6.24.0 `influence-core` engine (`pairwiseSimilarity` / `scoreMargin` /
`EmbeddingCache`); tier 3 (choice-entropy proxy validation) remains
specified-only.

- **C1 — `analyzeToolCatalog(tools, opts)`** (`agentfootprint/observe`,
  `src/lib/tool-lint/`). Input is a plain
  `{ name, description?, inputSchema? }[]` — ZERO stack buy-in
  (`coerceCatalog` normalizes OpenAI / Anthropic / MCP `tools/list` /
  plain shapes; `catalogFromTools` adapts the library's own `Tool[]`).
  Pairwise cosine over `confusabilityText` (tokenized name + description —
  what the model actually reads) via influence-core: pairs ≥
  `confusabilityThreshold` → `confusable` (fail the gate), pairs within
  `watchBand` below → `watch` (advisory). Each flagged pair carries a
  heuristic `hint` naming the differentiating axis (twin-name qualifier,
  or each side's distinct description terms). The report carries the FULL
  ranked pair list — the relative-ordering view that stays meaningful
  under any embedder. Duplicate tool names are a built-in structural
  error (deduped before similarity).
  **Calibration honesty (RFC-002 §3):** `DEFAULT_CONFUSABILITY_THRESHOLD`
  (0.85) is a real-embedder starting point; the test/demo `mockEmbedder`
  compresses prose into ~0.85–0.97, so `MOCK_EMBEDDER_CALIBRATION`
  (0.94/0.02) applies with the mock and only RELATIVE ordering is
  trustworthy — acceptance fixtures assert ordering, never absolute
  scores (the Neo fcns twins are each other's mutual top-1 partner and
  flag confusable; a known-false-positive pin documents the mock's floor).
- **C2 — pluggable structural rule pack** (`defaultStructuralRules` +
  factories `descriptionRule` / `saysWhatNotWhenRule` / `enumInProseRule`
  / `optionalParamRule`; rules are plain `{ id, check }` objects —
  add/remove/re-tune freely). Four field findings from the Neo catalog:
  missing/short description (<40 chars; missing = the only default
  `error`), says-WHAT-not-WHEN (no `for/when/after/first/fallback/only`
  cue), enum-in-prose (`"avg_iops | peak_iops | mbps"` → suggests the
  JSON-Schema `enum`; comma lists only behind an explicit "one of"-style
  marker so `e.g. 1h, 24h` examples don't flag), and
  optional-param-undocumented (omission has meaning, nothing says so).
  One fixture catalog per rule in `test/lib/tool-lint/rules.test.ts`.
- **C3 — CI gate.** `report.ok` (no confusable pairs + no structural
  findings at/above `failOn`, default `'error'`) + new bin
  **`agentfootprint-lint-tools`** (`bin/agentfootprint-lint-tools.mjs`,
  humble shell over the unit-tested `runToolLintCli`): reads ONE JSON
  file of tools, prints the report, exits 0/1/2 (ok / gate failed /
  usage error). Flags: `--threshold` (similarity gating is OPT-IN — the
  CLI's built-in mock embedder is rank-trustworthy, not
  verdict-trustworthy; without the flag similarity is report-only),
  `--watch-band`, `--strict`, `--no-similarity`, `--top`, `--json`.
  Embedding cost on re-lints: wrap your embedder in `embeddingCache`
  (content-hash keyed, from influence-core). FRONT DOOR:
  `docs/guides/tool-catalog-lint.md` (written for non-footprintjs users
  — 5 minutes from a `tools.json` to a gated CI check) + a README
  section.
- **C4 — choice-context construction** (`buildChoiceContext`, exported).
  The margin scorer embeds exactly the two slots the model's
  tool-selection reasoning ran on: the current turn's user message
  (head-capped) + the latest assistant reasoning text of the turn
  (tail-capped; absent on iteration 1). Deliberately EXCLUDED and
  documented: system prompt (constant per run — dilutes without
  discriminating), older history turns (recency dominates choice), raw
  tool results (their distilled effect is the included assistant text),
  tool schemas (those are the candidates, not the context). Candidate
  text per offered tool = the SAME `confusabilityText` the lint embeds,
  so build-time confusability and runtime margins measure one geometry.
- **C5 — `toolChoiceRecorder({ embedder })`** (`agentfootprint/observe`,
  `src/recorders/observability/ToolChoiceRecorder.ts`). A normal
  CombinedRecorder (Convention 1: owns a `KeyedStore<ToolChoiceEntry>`
  keyed by the LLM call's `runtimeStageId`) — attach via
  `Agent.create(...).recorder(...)`; deferred-tier friendly
  (`{ delivery: 'deferred' }` works unchanged). Per LLM call that
  OFFERED tools: menu from `stream.llm_start.tools`, chosen from the
  `stream.tool_start` events of that call (parallel + repeat calls
  visible via `toolCallIds`; `chosen` dedupes by name), context from
  `agent.turn_start` + the previous `stream.llm_end`. **Embeds LAZILY on
  first read** (`getCalls()` / `getFlagged()` / `getSummary()`) — the
  hot path only records strings; scores memoize; open entries (mid-run)
  stay unscored until they close. Convention-4 runId reset via
  `onRunStart`; same-executor `resume()` preserves pre-pause entries by
  design. Unscorable entries carry `skipped`:
  `'nothing-chosen'` (final-answer calls) or `'chosen-not-offered'`
  (wiring anomaly, surfaced not massaged).
- **C6 — `getFlagged()` + run summary.** Flagged = `narrow`
  (margin < `marginThreshold`, default 0.05) OR `proxyDisagreement`
  (top-scored candidate not among the chosen — ALWAYS flagged).
  `getSummary()` → `{ llmCallsWithTools, choices, scored, flagged,
narrow, proxyDisagreement, skipped }`.
- **Examples (Convention 2)** — all offline/deterministic, pinned by
  `test/lib/tool-lint/examples.test.ts`:
  `examples/observability/02-lint-confusable-catalog.ts` (the real
  16-tool Neo fixture in `examples/helpers/neoToolCatalog.ts`; the fcns
  twin pair flagged with its hint + the metric/optional structural
  findings), `03-lint-fix-and-pass.ts` (fail → rewrite descriptions to
  lead with WHEN + real enum + documented optionals → `ok` under the
  SAME thresholds in strict mode), `04-tool-choice-margins.ts` (scripted
  agent walks into the twin trap; margins/flags printed; counting
  embedder proves 0 embeds during `run()`).
- **Tests:** 85 new (rule fixtures, verdict policy via exact-geometry
  planted embedders, Neo relative-ordering acceptance, CLI exit codes +
  shape coercion, property fuzz over report invariants, security
  (hostile/giant descriptions; tool results never reach the embedder),
  performance (100-tool lint < 2 s; record-only hot path), load
  (300 tools / 44 850 pairs), recorder unit tests on REAL engine event
  shapes, lazy-embed + memoization proof, runId reset, parallel tool
  calls, real-Agent functional run).
- NO new typed events (anti-drift): the recorder consumes the existing
  emit stream; the lint is build-time only.

## [6.24.0] - 2026-06-11

footprintjs floor raised to ^9.8.0 (the toolpack consumes RFC-003 Part A: controlDepRecorder, control edges, honesty markers, commitValueAt).

- **`influence-core` — the ONE embedding-based scoring engine (RFC-002/003
  block D6).** New leaf module `src/lib/influence-core/`, exported from
  `agentfootprint/observe`. Extracts the Visible Reasoning paper's FDL
  influence scoring (Eq. 1–6) into named, individually-exported scorers so
  three consumers share one engine and one embedding cache: RFC-002's
  tool-catalog lint (C1) + margin recorder (C4/C5), RFC-003 Part B's
  LLM-edge weigher (D7), and the FDL paper pipeline itself. Extraction +
  module design only — no RFC-002/003 features ship yet.
  - **Four signal scorers + composite** (`finalAnswerSimilarity`,
    `averageRelevancy`, `persistence`, `structuralProximity`,
    `compositeScore`, `adaptWeights`) — the paper's FA/AVG/PERSIST/DEPTH
    with default priors 0.40/0.30/0.20/0.10 and per-item Eq. 6 adaptive
    redistribution (no-ancestor items → α′=0.80/δ′=0.20). `scoreInfluence`
    orchestrates embed → score → rank in one deduplicated pass.
  - **`EmbeddingCache`** — content-hash-keyed (`contentHash`, FNV-1a)
    transparent `Embedder` decorator: bounded LRU (`maxEntries`, default 1024) with VISIBLE eviction/hit/miss counts via `stats()` (the
    bounded-honesty convention), single-flight coalescing for concurrent
    embeds, batch-aware partial misses; rejections never cached. One cache
    instance threads through lint + margins + influence weights (RFC-002
    §3).
  - **`pairwiseSimilarity`** (RFC-002 C1's core) — text set → symmetric
    cosine matrix (diagonal exactly 1 by definition) + ranked pairs.
  - **`scoreMargin`** (RFC-002 C4's core) — (candidates, contextText,
    chosen) → ranked scores, `margin` = score(best chosen) − score(best
    non-chosen), `narrow` / `proxyDisagreement` flags (default threshold
    0.05). Pure function; recorder wiring is C5, later.
  - **Honest-claim discipline (RFC-002 §2)** documented on every scorer:
    all scores are deterministic embedding-geometry PROXIES — semantic
    alignment, never model internals, never causal attribution, not
    additive across items.
  - **One embedder contract:** re-exports the existing
    `Embedder`/`mockEmbedder` adapter interface from `memory/embedding` —
    no second embedder type. Leaf module: zero agent/runtime imports.
  - **D6 parity acceptance:** goldens created FROM an independent
    transcription of paper Eq. 1–6 (the pipeline previously existed as the
    published equations + the embedder machinery; no executable goldens);
    the module reproduces them to 1e-12 and matches a live in-test
    reference recomputation, including fractional-PERSIST and
    adaptive-weight cases. Example: `examples/features/22-influence-core.ts`.
- **Introspection toolpack — `traceToolpack(artifacts)` (RFC-003 Part C,
  `agentfootprint/observe`).** footprintjs trace evidence exposed as TOOLS an
  LLM calls: a debugging model (cheap, in a SEPARATE session) navigates a
  COMPLETED run's evidence by `runtimeStageId`s instead of reading dumps —
  feed the slice, not the trace. Factory over frozen artifacts
  (`executor.getSnapshot()` + optional `controlDepRecorder().asLookup()` +
  optional narrative lines); returns plain `Tool[]` — mount on any Agent or
  drive scripted via the new `callTraceTool` (the offline auditor pattern,
  mirroring the #9 validation boundary).
  - **The tools:** `run_overview` (the entry point: stages with id + name +
    description, loops, error locations, honesty notes) · `trace_node`
    (one step: writes with verb + bounded preview + true size, reads,
    parents with the routing decision's rule label) · `trace_slice` (the
    backward causal chain with `[control: rule]` edges, as an indented tree
    of drillable ids) · `who_wrote` (last writer of a key, optionally
    before a step) · `get_value` (the explicit full fetch, capped +
    truncation-marked) · `read_narrative` (paginated story; only when
    narrative provided).
  - **Bounded by default, honest always:** every output capped; per-call
    params clamp to `TOOLPACK_HARD_CAPS`; truncated slices, untracked
    sources (args/env/silent), missing read tracking / controlDeps, and
    values outside the commit log are ⚠-marked, never silent. Redacted
    payloads pass through verbatim and are flagged `(redacted by policy)`.
  - **#9 synergy:** small runs embed an `enum` of every real step id in the
    schemas (garbage ids rejected before dispatch, model self-corrects);
    key params deliberately carry NO enum so "key outside the commit log"
    gets its honest answer. Bad ids that get through return corrective
    messages naming the real executions.
  - **The demo** (`examples/observability/01-trace-debug-session.ts`): a
    planted wrong value (DTI computed against annual income) flows through
    a decide() decision; a scripted debugger session finds the culprit in
    8 tool calls — ~2.7K chars served vs a ~29K-char full dump (~9%).
  - **Security posture (B13):** serve to a separate debug session over a
    completed run, not the production agent mid-run — trace content can
    carry adversarial text; see docs/guides/trace-debugging.md.

## [6.23.0] - 2026-06-11

- **Deferred observer delivery — `AgentOptions.observerDelivery: 'inline' |
'deferred'` (RFC-001 Block 10; closes RFC-001).** Opting in routes the
  Agent's internal bridge recorders (Context, stream, agent, error, cost,
  permission, eval/memory/skill/tools, validation, reliability) AND consumer
  `.recorder()` / `agent.attach()` recorders through footprintjs 9.6.0's
  bounded capture queue: capture inline (≈ µs/event, `'clone'` payloads —
  the same event shape as inline), delivery one beat behind at the next
  microtask checkpoint, synchronous terminal drain at run resolve / reject /
  pause. Compatibility bar tested hard: `agent.on()` listeners receive
  deep-equal typed events (type + payload + stage anchor, same order) vs
  inline; crash `RunCheckpointError` history and `error.fatal` stay complete;
  pause returns with the pre-pause record delivered. Default `'inline'` is
  byte-identical to 6.22.0 — no queue allocated, `observerStats` absent.
  - **Kept inline for correctness:** the causal-evidence recorder (CAUSAL
    memories) — the memory write stage consumes `collect()` MID-run, so it
    never rides the queue (pinned by test: snapshots persist real evidence
    under `'deferred'`).
  - **Per-recorder override:** a consumer recorder declaring its own
    `delivery` field keeps it; the agent option is the default tier for
    recorders that don't declare one.
  - **`AgentOptions.observerDeliveryOptions`** (`capture` / `maxQueue` /
    `overflow` / `sampleEvery` / `flushBudgetMs`) forwards the queue dials;
    setting it without `observerDelivery: 'deferred'` throws at build time
    (no silently-ignored combinations).
  - **`agent.drainObservers({ timeoutMs })`** — settle async listener
    continuations before serverless freeze / shutdown (RFC-001 §11); zeros
    before the first run. `ObserverDeliveryOptions`, `ObserverDrainResult`,
    `ObserverStats` exported from the main barrel.
  - **The bench number** (50-iteration full-feature agent, 3 747 events,
    deliberately slow 5 ms-per-event wildcard listener, 100 ms mock LLM
    latency; `examples/features/21-deferred-observers.ts`): no-listener
    floor 5.6 s · inline 24.5 s · deferred 24.0 s at back-to-back streaming
    (`chunkDelayMs: 0`, worst case for overlap); at a realistic 20 ms
    streaming cadence inline 34.8 s · deferred **32.1 s (−2.7 s wall, −8%;
    p95/iter 926 → 868 ms)** with `drops: 0`, `terminalStranded: 0`. Honest
    mechanism documented: single-thread work is conserved; deferral recovers
    the wait-ADJACENT share (llm_start/tool_start/token events), and the
    guarantees that don't depend on shape are the bounded queue, error
    isolation, per-listener stats, and terminal completeness.
- **Fixed: typed event payloads carried live scope proxies.**
  `agentfootprint.agent.iteration_end` embedded `scope.history` (a TypedScope
  deep-Proxy view) and the tool-calls handler embedded the
  `scope.llmLatestToolCalls` proxy into history messages and `tool_start`
  args. Live proxies are not structured-clone-safe — under `'deferred'` the
  'clone' capture degraded to a summary and the EmitBridge dropped the typed
  event; inline consumers were silently handed a mutable view of engine
  state. Payloads now use the plain local arrays (value-identical), and
  `typedEmit` gained a dev-mode (`enableDevMode()`) guard that warns once per
  event type when a payload is not structured-clone-safe.

## [6.22.0] - 2026-06-11

- **`agentCoreIdentity` forwards per-request identity (workload identity
  scoping)** — closes the deferred 6.11.0 gap where the AgentCore adapter
  ignored `req.identity` (the `runIdentity` that toolCalls already threads
  into every `getCredential`). Verified against the AWS API reference:
  `GetResourceOauth2Token` carries NO user/tenant field — AgentCore binds the
  user at workload-token acquisition — so the adapter now exchanges
  `(workloadName, userId)` via `GetWorkloadAccessTokenForUserId` for a
  USER-SCOPED workload access token and vends `mode: 'user'` requests with it
  (AgentCore keys its token vault + 3LO grants per workload+user). Opt-in via
  the new `workloadName` option; default `userId` = `identity.principal`,
  overridable via `userIdFor` (e.g. tenant-qualified `${tenant}:${principal}`
  — `tenant` has no native AgentCore field and is not forwarded by default).
  Fail-closed: configuring `workloadName` with a client lacking
  `getWorkloadAccessTokenForUserId`, or an exchange returning no token,
  throws — never silently degrades to workload-level tokens. Without
  `workloadName` the static `workloadIdentityToken` flows byte-identically to
  before (no behavior change for existing configs). `_client` test seam
  extended with the optional second method; 8 tests including an end-to-end
  `agent.run({ identity })` → declare-and-push → AgentCore-receives-userId
  chain.

- **`withCredentialRetry` — transient credential failures retry before
  failing closed** (`agentfootprint/identity`): a `CredentialProvider`
  decorator mirroring the LLM-provider `withRetry` — same option vocabulary
  (`maxAttempts`/`initialDelayMs`/`backoffFactor`/`maxDelayMs`/`shouldRetry`/
  `onRetry`) and the same shared default transience policy (retry 5xx/429/
  network/unknown; never AbortError or other 4xx — AgentCore's documented
  retryable errors, `InternalServerException` 500 + `ThrottlingException`
  429, retry out of the box). Only THROWN errors retry: `issued` and
  `authorization-required` (3LO consent is a human flow, not a fault) return
  immediately. After retries exhaust, the last error is rethrown — fail-closed
  behavior at the tool-dispatch site is byte-identical to an unwrapped
  provider (`credential.failed` + error tool result; the tool never runs).
  Per-attempt visibility is consumer-wired via `onRetry` (the established
  decorator contract; the `error.*` event family stays reserved for
  decorators — NO new event types). Design note: the rules-based reliability
  subsystem is LLM-call-scoped (`ReliabilityScope.request: LLMRequest`; gate
  chart around CallLLM) — extending its rule vocabulary to credential
  resolution is the deferred `sf-credential` gate node, an M+ change; the
  decorator is the honest transport-level home for retry until then.
  13 tests (Convention 3); `examples/features/17-identity.ts` now simulates a
  vault blip and shows `credentialRetries: 1`.

## [6.21.0] - 2026-06-11

- **B16 — circuit-breaker scope design choice documented (feature
  deferred)**: `withCircuitBreaker` is provider-level, not per-tool, by
  design — a provider outage is every run's heartbeat at full QPS, while
  tool throws are caught and fed back to the model as tool results, so the
  ReAct loop absorbs and adapts within the iteration budget. The
  `withCircuitBreaker` module header and orchestration guide now state the
  rationale plus today's escape hatches (wrap a tool's `execute` yourself;
  hide a failing tool mid-run via a `gatedTools` predicate). First-class
  per-tool breakers remain a possible future enhancement. Docs-only.
- **B15 — `Loop.until` string contract documented (feature deferred)**:
  the guard's `latestOutput: string` is by design — the core-flow layer
  composes `Runner<{message: string}, string>` and the Loop chart coerces
  non-string body output to `''`. JSDoc on `UntilGuard` / `.until()` now
  documents the contract, the structured-exit workaround (body emits JSON,
  guard parses), and why a typed `Loop<T>` guard is deferred (it requires
  genericizing the Runner output contract shared by Sequence / Parallel /
  Conditional — an M+ design change, out of scope for a minor).
- **B13 — prompt-injection security guide**
  ([docs/guides/prompt-injection.md](docs/guides/prompt-injection.md)):
  documents the honest posture — core does NOT detect prompt injection;
  `PermissionPolicy` gates _which_ tools, not _why_ the model called them.
  Maps the untrusted-text entry points (user message, tool results,
  persisted causal-memory replay, `on-tool-return` trigger predicates,
  `read_skill`), the containment layers that exist in source (visibility
  gating, fail-closed execute-time checker with sequence awareness, #9
  args validation's never-echo-values property, declare-and-push
  credential scoping, evidence events + audit export), what core
  deliberately does not do (no classifier/scanner; redaction is opt-in),
  and recommended external guards. Cross-linked from security.md and the
  guides index. Every claim grounded in source at write time.
- **B14 — `humanizeLLMError` per-provider-SDK fallthrough tests** (+2 regex
  gaps fixed): pinned the real error formats of `@anthropic-ai/sdk` /
  `openai` v4-v5 (Stainless) / `@aws-sdk/client-bedrock-runtime` v3 / the
  in-repo browser adapter `wrapStatus` shape — 16 new tests so an SDK-major
  message-format drift surfaces as a test diff, not a silent UX regression.
  Two real misses fixed while pinning: Stainless `APIConnectionError`
  ("Connection error.", no status) now maps to the network bucket, and
  Bedrock `AccessDeniedException` ("You don't have access to the model…",
  status only under `$metadata`) now maps to the auth bucket — both
  previously fell through to the generic message. Deliberate fallthroughs
  (Bedrock `ValidationException`/`ServiceUnavailableException`, user abort)
  are pinned as raw-preserving generic.
- **B12 — resume idempotency documented (accuracy-vs-source pass)**:
  `agent.resumeOnError()` docs now state the replay semantics prominently —
  resume restores ONLY conversation history; the failed iteration's tool
  calls may be re-issued by the model and re-execute (mutating tools must
  be idempotent, keyed on stable call content). Fixed three doc-lies found
  against source: there is NO "v2.10.3+ toolCallId dedup"
  (runCheckpoint.ts header), `checkpoint.runId` is NOT reused on resume
  (fresh `runId` per resumed run), and the resumed run re-seeds
  `iteration = 1` with a full budget (`lastCompletedIteration` is
  diagnostic-only). Docs-only; no behavior change.
- **B11 — `skillGraph.tree()` dev-mode "exactly one leaf fires" monitor**:
  in dev mode (footprintjs `enableDevMode()`), compiled tree-leaf triggers
  tally fires per evaluation pass and `console.warn` when 0 or ≥2 leaves
  fire — the symptom of an impure/non-deterministic `decide()` predicate
  (the tree is exhaustive by construction; predicates are re-evaluated per
  leaf). Production behavior unchanged (one `isDevMode()` check per
  evaluation; the monitor observes, never alters activation).

## [6.20.0] - 2026-06-10

**#18/#14: `AgentOptions.readTracking` — the snapshot observability-cost
lever, exposed from the Agent** (+ long-run memory re-measured against
footprintjs 9.3.0's #13b staging-release).

- **`AgentOptions.readTracking: 'full' | 'summary' | 'off'`** — forwarded
  to the Agent's internal `FlowChartExecutor` (which previously received
  no options, leaving footprintjs's #14 lever unreachable from the
  Agent). Controls how `getSnapshot()` records per-stage reads in
  `StageSnapshot.stageReads`: cloned values (`'full'`), cheap
  `ReadSummaryMarker`s (`'summary'`), or nothing (`'off'`).
  `ReadTrackingMode` + `ReadSummaryMarker` re-exported from the main
  barrel so consumers don't need a direct footprintjs import.
- **⚠ Behavior change (default `'summary'`)**: the Agent's executor now
  defaults to `'summary'` — NOT footprintjs's own `'full'` default. In
  `agent.getSnapshot()`, `stageReads` entries are now
  `{ __readSummary: true, type, size?, preview? }` markers instead of
  cloned values. Measurement-gated decision (#18): `stageReads` values
  have ZERO consumers across agentfootprint, agentfootprint-lens, and
  explainable-ui (re-verified by grep at change time), while `'full'`
  retains ~28MB@200 / ~170MB@500 iterations of unread clones. Narrative,
  recorder events (`onRead` payloads), commit log, and shared state are
  IDENTICAL in every mode — only the snapshot's `stageReads` payload
  changes shape. If you inspect read VALUES from snapshots, opt back in:
  `Agent.create({ ..., readTracking: 'full' })`.
- **footprintjs floor: `^9.0.0` → `^9.1.0`** (peer + dev) —
  `readTracking` ships in footprintjs 9.1.0.
- **Long-run memory re-measured** (#18 replication, full-feature agent —
  steering + fact + skill + tool, mock provider `chunkDelayMs: 0`,
  heapUsed after `global.gc()`, 2GB heap cap, footprintjs 9.3.0):

  | Config                                | N=200   | N=500    | N=1000   |
  | ------------------------------------- | ------- | -------- | -------- |
  | #18 baseline (fp 9.0.0-era, `'full'`) | 563.8MB | OOM @2GB | —        |
  | fp 9.3.0 + `'full'`                   | 159.7MB | 917.7MB  | —        |
  | fp 9.3.0 + `'summary'` (new default)  | 132.2MB | 747.6MB  | OOM @2GB |
  | fp 9.3.0 + `'off'`                    | 131.9MB | 747.1MB  | —        |

  The #13b staging-release is the dominant win (563.8 → 159.7MB @200,
  3.5×; N=500 now completes instead of OOM). `readTracking: 'summary'`
  saves a further ~17% @200 / ~19% @500 on top. `'off'` ≈ `'summary'`
  (markers are near-free). Growth remains quadratic (#13c residual:
  commitLog + `_stageWrites` clones) — N=1000 still exceeds a 2GB heap
  (~3GB projected), and per-iteration latency still climbs (≈4ms
  first-10 → ≈110ms last-10 @500). Wall @200 ≈ 4.8s.

- **`iterations-unlocked` RSS budget tightened 1500 → 350MB** — worst
  observed RSS delta over 5 local runs post-#13b + `'summary'` default
  is 210MB; budget = 1.6× worst ≈ 335MB, rounded up for CI variance.
  The budget is now a real regression tripwire instead of a 7× ceiling.

### Fixed

- **Causal snapshots no longer overwrite across turns of one
  conversation.** Previously, every `agent.run()` re-seeded
  `turnNumber = 1` and `writeSnapshot` used the id `snap-{turnNumber}`
  verbatim, so turn 2 of the SAME conversation silently replaced turn 1's
  snapshot — the earlier turn's decision evidence was destroyed. This
  affected every multi-turn causal-memory conversation (including the
  canonical loan-officer example and `examples/memory/06`); the #21
  lighthouse example worked around it by exporting per turn.
  `writeSnapshot` now derives the effective turn from the store:
  `max(scope.turnNumber, maxStoredSnapshotTurn + 1)`, where
  `maxStoredSnapshotTurn` is the highest live `snap-{n}` in the
  conversation's namespace. Consequences:
  - consecutive turns persist distinct, ordered snapshots
    (`snap-1`, `snap-2`, …) — within one Agent instance, across Agent
    instances sharing a store, and across processes (the store namespace
    `identityNamespace(identity)` is the durable conversation anchor;
    an in-process counter could not have fixed the cross-instance /
    cross-process cases);
  - hosts that track `turnNumber` correctly keep their numbering
    (`turnNumber: 5` → `snap-5`, gaps preserved) — the derivation only
    overrides a counter that is provably stale against the store;
  - single-turn consumers are unchanged (`snap-1`, byte-identical ids);
    no existing test needed changes;
  - trade-off, on purpose: a host that deliberately re-runs an
    already-snapshotted turn N with `turnNumber = N` now appends
    `snap-{N+1}` instead of overwriting `snap-{N}`. Causal snapshots are
    decision evidence (audit/replay data) — when a stale counter and a
    deliberate rewrite are indistinguishable, never destroying a prior
    turn's evidence wins. (`writeSnapshot` never documented overwrite
    semantics; `writeMessages`' idempotent-id contract is untouched.)
  - note: the underlying `turnNumber` re-seed in the Agent's seed stage
    still exists and remains a follow-up for the other write-side stages
    (`msg-{turn}-{index}` ids share the same latent class).

### Added

- **`flowchartAsTool` `recorders` option** — closes the #21-lighthouse
  observability gap: the tool builds its `FlowChartExecutor` internally,
  so decide()/select() evidence inside a tool-mounted flowchart could not
  reach the agent's evidence recorders (the #5 causal
  `causalEvidenceRecorder()` bridge, the #19
  `otel.decisionEvidenceRecorder()`). New
  `recorders?: ReadonlyArray<CombinedRecorder>` attaches each entry to
  the internal executor via `attachCombinedRecorder` before every run —
  routed by method-shape detection, so one array covers all three
  observer channels (scope data-flow, control-flow, emit).
  Per-invocation semantics: the tool constructs a FRESH executor per
  call and attaches the SAME recorder instances to each one — a shared
  stateful recorder accumulates events from every invocation; each
  invocation carries a fresh `runId` (Convention 4) for per-invocation
  bookkeeping. The `20-regulated-decisioning` example keeps its
  hand-mounted chart (it predates the hook and demonstrates the manual
  wiring); new code should pass `recorders` instead.

## [6.19.0] - 2026-06-10

**#21: the compliance-wedge lighthouse example** — completes the wedge
(#19 GenAI spans, #20 tamper-evident audit export, #5 causal memory).
Example-only; no library code changes.

- **`examples/features/20-regulated-decisioning.ts` (+ paired `.md`)** —
  a regulated loan-decisioning agent that answers _"why was applicant
  A-1043 declined three weeks ago?"_ from stored evidence, offline. One
  run, three compliance artifacts from the same typed event stream:
  - the agent declines an application under **labeled footprintjs
    `decide()` rules** (the lending policy is a flowchart mounted inside
    the `adjudicate_application` tool; per-rule evidence —
    `dti gt 0.43 → 0.52 (true)` — is captured during traversal), with a
    **permission denial** (data-minimization policy) and a **#9
    validation rejection + model self-correction** in the same chain;
  - `auditExport` + `otelObservability` attach in parallel (multi-
    strategy), the audit bundle is **drained per turn** and persisted
    with an **external anchor of BOTH chain ends** (finalHash + genesis
    identity) per the documented threat model;
  - an exported AUDITOR function loads ONLY the persisted JSON (no
    agent, no provider, no LLM), re-verifies the chain, cross-checks the
    anchor, and reconstructs the decision story as a human-readable
    audit narrative — then a flipped byte in the stored permission
    denial is caught and **named by record seq**.
- **Fix**: `examples/features/18-otel-genai.ts` no longer fails
  `tsc -p examples/tsconfig.json` (the `decide(scope as never, …)` cast
  inferred `WhereFilter<never>` and rejected the filter literal; now
  casts to the chart's state shape).
- **Library follow-ups found (reported, not hacked around in src/)**:
  (1) causal snapshots overwrite across turns — the agent seeds
  `turnNumber = 1` every `run()` and `writeSnapshot` ids are
  `snap-{turn}`, so a conversation's later turn replaces the earlier
  snapshot (the example exports the snapshot per turn as a workaround);
  (2) `flowchartAsTool` has no recorder hook, so decide() evidence
  inside a tool-mounted flowchart can't reach the agent's causal
  evidence recorder or the OTel decision-evidence bridge — the example
  mounts the policy chart by hand and ships an example-level evidence
  ledger file instead.

## [6.18.0] - 2026-06-10

Minor — **#20: tamper-evident audit export** (second item of the
compliance wedge; consumes the same typed event stream #19's spans are
built from — EU AI Act Art. 12 record-keeping is the target shape).

- **`auditExport()`** (`agentfootprint/observability-providers`) — an
  ObservabilityStrategy that hash-chains every typed event into an
  append-only audit log: one `AuditRecord` per event
  (`{ seq, timestamp, eventType, payload, meta, prevHash, hash }`,
  `hash` = SHA-256 over the canonical serialization of the record minus
  `hash`), plus a per-run GENESIS record (`audit.genesis`) carrying
  runId + agent identity + library/app versions. Runs chain
  back-to-back in one log, so silently dropping a whole run breaks the
  chain. Attach via
  `agent.enable.observability({ strategy: auditExport() })`.
- **`verifyAuditBundle()`** — pure OFFLINE verification (no agent, no
  strategy): recomputes the chain and names the exact record any tamper
  broke (`{ valid, brokenAt, reason }`). Accepts one bundle or an array
  of consecutive drained segments.
- **`bundle()` / `drain()`** — the export surface. Bundles are plain
  JSON (persistence is the consumer's job); `drain()` returns the
  records since the last drain while keeping the chain intact across
  segments (`header.chainHead` = previous segment's `finalHash`, so
  concatenated segments re-verify end-to-end).
- **`canonicalJson()` (`afp-cjson/1`)** — the documented byte contract
  under the hashes (sorted keys by UTF-16 code unit, no whitespace,
  JSON.stringify number/string semantics, toJSON honored, bigint/cycles
  throw). Exported so independent verifiers can re-implement.
- **PII discipline mirrors #19** (`payloadMode: 'bounded'`, default):
  tool args → key NAMES, results → TYPE, prompts / LLM content /
  thinking / history / content previews (`contentSummary`,
  `rawContent`, `droppedSummaries`, `resultSummary`) → `[N chars]`
  markers, error MESSAGE strings → `[N chars]`, free-form Records
  (pause payloads, risk/eval evidence) → `[keys: …]`. `contentHash`
  stays verbatim (links identical content without echoing it).
  `payloadMode: 'verbatim'` embeds full payloads for access-controlled
  stores (documented disclosure). `stream.token` /
  `stream.thinking_delta` excluded by default (`includeTokenEvents`).
- **Zero new dependencies** — SHA-256 via `node:crypto`, lazily
  imported with the same gating as the optional vendor SDKs (importing
  the module stays browser-safe; capture/verify need a runtime with
  `node:crypto`: Node ≥ 20, Bun, Deno, edge Node-compat).
- **Honesty note:** the chain is tamper-EVIDENT, not tamper-PROOF — an
  adversary holding the only copy can recompute the whole suffix.
  Anchor `finalHash` externally (write-once store, signed log,
  timestamping service) for non-repudiation; documented on the API.
- `package.json` now exports `"./package.json"` (standard
  self-reference; lets the genesis record carry the real library
  version).
- Example: `examples/features/19-audit-export.ts` — agent run with
  route decisions + a tool call + a #9 validation rejection → export →
  verify OK → flip one byte → verification names the record → drained
  segments re-verify concatenated. Zero new typed events.

### Fixed

- **`xrayObservability` produced no segments on real runs — same
  dead-field bug class as the 6.17.0 otel fix** (masked by fabricated
  test event shapes; no new features, pure correctness):
  - read `payload.runId` — real dispatcher envelopes carry the run
    anchor on `meta.runId` (built by `bridge/eventMeta.ts`), so EVERY
    event was skipped and ZERO segments shipped. Now anchored on
    `meta.runId` with the `payload.runId` fallback kept for hand-fed
    events.
  - `agent.iteration_start` read `payload.iteration` — the real field
    is `iterIndex`, so every iteration segment was named `iteration:?`.
  - `stream.tool_end` read `payload.toolName` — `ToolEndPayload`
    carries only `toolCallId`, so the close fell back to "pop topmost"
    and parallel tool calls closed the WRONG segment. Tool segments are
    now correlated by `toolCallId` (parallel-safe); the toolName-based
    match remains as the legacy fallback. An explicit `error: false`
    on `tool_end` no longer marks the segment as errored.
  - `cost.tick` read `payload.cumulativeCostUsd` — the real shape is
    `cumulative.estimatedUsd` (`CostTickPayload`), so cost annotations
    never appeared. Legacy field kept as fallback; the annotation key
    stays `cumulativeCostUsd` for existing X-Ray Insights queries.
  - `error.fatal` now closes the segment tree (`fault` on the root,
    PII-safe `errorStage`/`errorScope` annotations only) instead of
    leaking the turn in `activeTurns`, where its segments never
    graduated to the outbox.
  - New integration test drives a REAL Agent run (MockProvider +
    scripted tool call) through `xrayObservability` with an injected
    client and asserts segments are actually produced — the test style
    whose absence masked the bug class.

## [6.17.0] - 2026-06-10

Minor — **#19: `otelObservability` speaks OTel GenAI semantic conventions +
explainability span events** (first item of the compliance wedge; #20
tamper-evident export builds on this span/event stream).

- **GenAI semconv attributes (`gen_ai.*`) — ON by default** (purely
  additive attribute names; current spec, `gen_ai.provider.name` era):
  - turn span → `gen_ai.operation.name: 'invoke_agent'`,
    `gen_ai.agent.name`, turn-total `gen_ai.usage.input_tokens` /
    `output_tokens`, `agentfootprint.run.id`, `agentfootprint.turn.index`;
    provider + model back-filled from the first LLM call.
  - llm span → `gen_ai.operation.name: 'chat'`, `gen_ai.provider.name`,
    `gen_ai.request.model`, `gen_ai.request.temperature`,
    `gen_ai.usage.input_tokens`/`output_tokens`/`cache_read.input_tokens`/
    `cache_creation.input_tokens`, `gen_ai.response.finish_reasons`,
    `gen_ai.response.id`.
  - tool span → `gen_ai.operation.name: 'execute_tool'`,
    `gen_ai.tool.name`, `gen_ai.tool.call.id`,
    `agentfootprint.tool.protocol`, `agentfootprint.tool.args.keys`
    (key NAMES only), `agentfootprint.tool.result.type` (type only),
    `error.type` on failure.
- **Explainability span events — ON by default** (`explainability: false`
  to opt out): route decisions (`agent.route_decided` /
  `composition.route_decided` incl. decide()-shaped `evidence`), skill
  routing provenance (`agentfootprint.skill.routing` per routed
  injection: decision path, route edge, unlocked tools),
  `skill.activated`, validation rejections (#9, type-level issues),
  permission checks/halts, credential lifecycle. Span EVENTS (not
  attributes) because decisions have per-span multiplicity + ordering;
  attribute fallback when the injected tracer lacks `addEvent`.
- **`decisionEvidenceRecorder()`** on the returned strategy — a
  footprintjs CombinedRecorder bridging decide()/select() operator-level
  evidence (rule label + `key op threshold → actual (result)` conditions)
  from the FlowRecorder channel into span events
  (`agentfootprint.decision.evidence`). Attach via
  `Agent.create(...).recorder(...)` or `executor.attachCombinedRecorder`.
  Skips evidence-less decisions (already on the typed channel) and
  sf-cache / slot-fork plumbing.
- **`genAiSpanNames: true` (opt-in)** — spec span names
  (`invoke_agent {service}`, `chat {model}`, `execute_tool {tool}`).
  Off by default: existing dashboards key on the legacy span names
  (`{service}` / `llm` / `tool:{name}`); all `gen_ai.*` ATTRIBUTES are
  emitted regardless, so semconv-aware backends work without the rename.
- **Fixed (latent — masked by fabricated test event shapes):** the
  adapter read `payload.runId` / `payload.cumulativeCostUsd` /
  `tool_end.toolName`, none of which exist on real dispatcher envelopes —
  on a REAL agent run it produced ZERO spans. Now anchors on
  `meta.runId` (payload fallback kept for hand-fed events), reads
  `cumulative.estimatedUsd`, and correlates tool spans by `toolCallId`
  (parallel tool calls close the right span). `error.fatal` now closes
  the span tree (ERROR status on root) instead of leaking until `stop()`.
- **PII discipline (mirrors the #9 contract):** prompts, LLM content,
  tool arg/result VALUES, and error messages are never emitted as
  attributes; evidence value summaries are redaction-aware upstream and
  re-capped here (256 chars / 20 list items).
- New: `examples/features/18-otel-genai.ts`,
  `test/observability-providers/otel-genai.test.ts` (24 tests — unit /
  functional / integration on a REAL Agent run + REAL decide() chart /
  security). Exported types: `OtelObservabilityStrategy`,
  `OtelDecisionEvidenceRecorder`, `OtelAttributeValue`.

## [6.16.0] - 2026-06-10

Minor — **#9: tool-args validation with model-visible retry.**

LLM-produced tool args were dispatched to `tool.execute` unvalidated; a
malformed call surfaced as a deep tool stack trace (or silent misbehavior).
Now they are validated against the tool's declared `inputSchema` BEFORE
dispatch.

- **`AgentOptions.toolArgValidation: 'enforce' | 'warn' | 'off'`** — default
  `'enforce'`: a mismatch rejects the call (the tool never runs), the model
  receives a structured retry message as the tool result, and corrects its
  args on the next ReAct iteration. `'warn'` emits the event but executes
  anyway; `'off'` disables. Exported `ToolArgValidationMode`.
- **Honest JSON-Schema subset** — enforces `type` (incl. unions),
  `required`, nested `properties`/`items`, primitive `enum`, and
  `additionalProperties: false` only when explicitly set. Everything else
  (`pattern`, `oneOf`, `$ref`, …) is IGNORED — a schema using them still
  validates the supported core, never false-rejects. Total function: a
  malformed schema can only under-validate, never throw or block.
- **Security: issues name paths, expectations, and received TYPES — never
  the supplied values** (they flow to history/LLM/trace and can carry PII
  or injection payloads). Enum expectations echo schema values only
  (already LLM-visible). Issues capped at 10 per call.
- **Ordering** — the permission gate still sees every attempted call
  (deny/halt precede validation); a rejected call never resolves
  credentials and never activates `read_skill`.
- **⚠ Behavior change (default-on):** two patterns some models emit, which
  previously reached `tool.execute` unchecked, are now rejected in
  `'enforce'` mode — the model gets the retry message and usually
  self-corrects in one extra iteration, but schemas can also opt them in:
  - `null` sent for a `{type: 'string'}` field → declare
    `{type: ['string', 'null']}` to allow it.
  - stringified numbers (`"3"` for `{type: 'integer'}`) → declare
    `{type: ['integer', 'string']}` if your tool coerces.
    Escape hatch: `toolArgValidation: 'warn'` (observe first) or `'off'`.
- **New event** `agentfootprint.validation.args_invalid`
  (`Payloads.ValidationArgsInvalidPayload`: toolName, toolCallId,
  iteration, issues, enforced) — 64 typed events / 18 domains. Bridged to
  `agent.on(...)` via the new always-on `validationRecorder`.
- Example: `examples/features/06-tool-args-validation.ts` — deterministic
  bad-args → rejection → self-correction → success, no API key needed.
- `read_skill` benefits automatically: a hallucinated skill id now fails
  the schema's `enum` with the valid ids in the retry message, before
  execute.

## [6.15.0]

Minor — **#16: footprintjs 9 adoption + iterations unlocked.**

### Changed

- **Peer/dev dependency: footprintjs `^9.0.0`** (was `^7 || ^8`). The 9.0.0
  trampoline runs linear chains and loops on a flat stack — the depth wall that
  capped agents around iteration 71 is gone. Full suite (2462) green against 9.
- **`clampIterations` no longer silently caps at 50.** The cap existed only to
  stay under the old engine wall. `maxIterations` is now an honest COST budget
  (each iteration = one LLM call): lower bound 1 kept; dev mode warns above 100.
  A 200-iteration agent run is tested end-to-end.
- **Engine loop-limit headroom:** `agent.run()` passes
  `maxIterations: agentBudget × 2 + 10` to the executor so footprintjs's own
  loop-iteration limit (default 1000) can never fire below the agent's budget —
  the two libraries' limits are now co-engineered. Consumer-provided run options
  still win.
- **#17 — cross-repo limits test in CI:** the 200-iteration run is a FULL-FEATURE
  agent (steering + fact + skill-with-tools, all three context slots) with an RSS
  budget assertion, running against the pinned footprintjs on every build — the
  boundary between the two libraries is pinned, not assumed.

## [6.14.1]

Patch — **`skillGraph.tree()` merges repeated leaves.** Using the SAME skill as
the leaf of more than one branch ("ESXi questions" and "io questions" both
route to the io-profile bundle) compiled into two same-id injections and
exploded in `Agent.injection()`'s duplicate-id guard at build time. The
compiler now merges repeated leaves into ONE injection whose trigger ORs the
path predicates; routing provenance gains `paths` (all root→leaf paths — the
existing `path` stays as the first); the graph keeps one node with an edge per
converging branch (the drawing shows both diamonds reaching the shared leaf).

## [6.14.0]

### Added

- **Listener/recorder lifecycle** (backlog #11a): long-lived runners
  (servers reusing one Agent across requests) get a complete,
  bounded-leak subscription lifecycle.
  - `removeAllListeners()` on `EventDispatcher` and every `Runner` —
    bulk escape hatch that drops all typed, domain-wildcard, and `'*'`
    listeners in one call (listeners ONLY; recorders added via
    `attach()` keep their own Unsubscribe). Safe mid-dispatch.
  - `listenerCount(type?)` diagnostic on `EventDispatcher` and every
    `Runner` — no-arg returns the total retained listener count (the
    number servers watch for leaks); with a subscription key returns
    that exact bucket.
  - `once(type, listener, { signal })` — one-shot subscriptions now
    accept AbortSignal auto-cleanup, same as `on()`.
  - Bounded-leak guarantee: EVERY removal path (manual unsubscribe,
    `off()`, signal abort, once-fire, `removeAllListeners()`) now
    prunes emptied internal listener buckets from the dispatcher's maps
    AND detaches the abort handler from the consumer's AbortSignal
    (previously a manual unsubscribe left the abort handler on the
    signal — long-lived, never-aborted server signals accumulated one
    handler per subscription cycle). Dispatcher storage is bounded by
    LIVE subscriptions, never subscription history. Enforced by
    property tests (randomized op interleavings vs a reference model)
    and a load test: 1,000 sequential `agent.run()` calls with per-run
    `{ signal }` subscriptions hold `listenerCount()` at the pre-loop
    baseline after every run.
  - Documented the lifecycle contract (who owns cleanup, what
    auto-expires): `Runner.attach()`/`on()` JSDoc, `CLAUDE.md`
    observability section, `src/events/README.md` Decision 6. Summary:
    listeners and recorders live for the RUNNER's lifetime — nothing
    auto-expires per-run; `once()` is the only self-expiring
    subscription; the caller owns cleanup via Unsubscribe handles,
    AbortSignals, or `removeAllListeners()`.

## [6.13.0]

### Added

- **Required parallel branches** (backlog #10):
  `Parallel.create().branch(id, runner, { required: true })` marks a branch
  whose failure must reject the WHOLE run — even under a tolerant
  `.mergeOutcomesWithFn()` merge — with an error naming the branch
  (`Parallel 'x': required branch 'y' failed: <reason>`). When EVERY branch
  is required, footprintjs's fork-level `failFast` is engaged
  (`Promise.all`): the first failure aborts the fan-out immediately —
  no waiting on slow siblings, no merge — and a synthetic
  `composition.exit` (`status: 'err'`) preserves enter/exit pairing for
  dashboards, carrying the same real `runId` as the paired
  `composition.enter` (Convention 4 run-scoping). Fail-fast
  re-attribution correlates by error IDENTITY: the branch-error recorder
  stores the ORIGINAL error object (footprintjs
  `FlowErrorEvent.structuredError.raw`) per branch and matches the raw
  rejection by reference first, bare message second — so attribution
  works for ANY error class (`TypeError`, provider-SDK subclasses like
  `RateLimitError`), not just bare `new Error(...)`. The per-branch error
  map is epoch-scoped per run: late failures from a rejected run's
  abandoned fail-fast siblings are dropped instead of contaminating the
  next run's attribution. With a MIXED required/optional set the fan-out
  stays best-effort (fork-level `failFast` is all-or-nothing, so engaging
  it would wrongly abort when an _optional_ sibling throws); required
  failures are enforced at the Merge join instead. Default behavior
  (no `required` flags) is unchanged. Documented limitations (README
  Decision 8 + `ParallelBranchOptions.required` JSDoc): under all-required
  fail-fast the first PAUSE pre-empts siblings, and a Parallel chart
  MOUNTED into an outer composition (e.g. a Sequence step) rejects raw —
  attribution + the synthetic exit only engage on the `run()`/`resume()`
  path (behavior pinned by test). `ParallelBranchOptions` is exported
  from the package barrel.

### Fixed

- **Parallel `outputMapper` failures are now attributed to their branch**
  instead of surfacing as `unknown error`. footprintjs swallows mapper
  throws without firing `FlowRecorder.onError` (they route to
  `addError('outputMapperError', ...)`), so Parallel's branch-error
  recorder never saw them. Every branch mount now wraps its
  `outputMapper` (`wrapBranchOutputMapper`) to record the throw against
  the branch id — first error per branch wins — before rethrowing along
  footprintjs's existing path. Strict aggregates and tolerant
  `BranchOutcome.error` strings now carry the real message.

## [6.12.0]

Minor — **the evidence bridge (backlog #5)**: causal-memory snapshots now persist
REAL run evidence, and the causal READ works inside an Agent for the first time.
The flagship "agent reads its own trace" claim is now true end-to-end.

### Added

- **`causalEvidenceRecorder`** (`agentfootprint/memory` causal) — harvests during
  the run: tool calls (name / bounded args / result preview / errored), token
  usage + iterations, duration, skill-graph routing provenance, and footprintjs
  `decide()`/`select()` operator-level evidence (`onDecision`/`onSelected`;
  internal cache-gate deciders filtered, incl. the double-prefixed
  `dynamic-grouped` shape). Auto-attached by the Agent when a CAUSAL memory is
  mounted; `mountMemoryWrite` gains `evidenceSource` and `writeSnapshot`
  populates the previously-TODO fields (zeros when absent — back-compat).
- The DECISIONS projection now includes **tool evidence** + the final answer —
  in LLM-decided flows the operator facts (creditScore=580) arrive as tool
  results.

### Fixed

- **Causal READ inside an Agent never fired** (pre-existing): the read mount
  looked for `parentState.messages`, but agents carry `history` — retrieval
  silently injected nothing. Fallback added.
- Panel-found bridge bugs fixed before release: `DecisionRecord.stageId` now
  uses the real `FlowDecisionEvent` fields (`traversalContext.stageId` /
  `decider` — was always the literal `'decider'`); the mid-run runId reset that
  wiped iteration-1 skill decisions and pause/resume evidence is removed
  (per-turn reset anchors on `turn_start`); skill-routing reads `injectionId`.

### Security notes

- Tool args + decision evidence persist into snapshots **bounded**
  (`maxFieldChars`, default 2000; result previews 200 chars). The Agent does not
  configure a RedactionPolicy by default — treat the snapshot store as
  PII-bearing. The DECISIONS projection replays stored tool output into future
  prompts — a persisted prompt-injection surface if tools ingest untrusted
  content (documented at the projection).

### Docs

- Causal claims across CLAUDE.md / AGENTS.md / SKILL.md / README / MENTAL_MODEL /
  docs-site / examples restored to the now-true state (decisions + tool evidence
  real; commitLog/narrative capture still on the roadmap).

## [6.11.1]

Patch — **truth-in-docs sweep** (backlog Phase-0 #4, panel-reviewed). No runtime
behavior change.

### Fixed (docs/claims that a reader could falsify against source)

- **Causal-memory claims calibrated to what ships today.** The "answers from
  EXACT past facts (zero hallucination)" claim assumed operator-level decision
  evidence in snapshots — today `decisions[]`/`toolCalls[]` persist **empty**
  (only query + final outcome are wired; the evidence bridge is backlog
  Phase-1 #5). Softened with an honest status note — keeping the vision, not
  overstating the present — in: CLAUDE.md, AGENTS.md, README,
  `ai-instructions/claude-code/SKILL.md` (what AI IDEs load), 5 docs-site pages
  (causal-deep-dive now explicitly narrates the _target design_),
  MENTAL_MODEL.md, the public `defineMemory`/`SnapshotEntry` JSDoc (ships in IDE
  tooltips) + generated api-reference, and the causal examples.
- **Event/domain counts mechanized.** Docs said "59 typed events × 16 domains"
  (SKILL.md said 47×13; the registry's own header said 45×13) — the registry
  has **63 events / 17 domains**. All stated counts fixed; hard numbers
  **stripped** from non-doc locations; a new **anti-drift test** derives both
  counts from `EVENT_NAMES` and asserts them across **9 docs** (CLAUDE.md,
  AGENTS.md, MENTAL_MODEL.md, SKILL.md, 5 docs-site pages) so this can't
  silently drift again.
- Removed the stale `MIGRATION_PLAN.md` (completed historical work plan;
  preserved in git history).

## [6.11.0]

Minor — **declare-and-push credentials**: a tool DECLARES the credential it needs;
the framework RESOLVES it before invoking and INJECTS `ctx.credential`. This adds
the consumption half that `agentfootprint/identity` (6.10.0) was missing, and
reshapes the credential as a generic, extensible protocol.

> **⚠️ Breaking to the `agentfootprint/identity` subpath only** (shipped 6.10.0,
> one week ago, pre-adoption). Versioned as a minor since that surface has no
> released consumers yet; the rename is small and mechanical — see Migrate below.

### BREAKING (vs the 6.10.0 `agentfootprint/identity` subpath)

- A credential is now a **`Credential` protocol** (`{ kind, toHeaders() }`), not a
  bare token. `CredentialResult`'s success branch is `{ status: 'issued', credential }`
  (was `{ status: 'token', token }`); `isCredentialToken` → **`isCredentialIssued`**;
  `CredentialToken` type → **`CredentialIssued`**.
  - **Migrate:** `if (isCredentialIssued(r)) useHeaders(r.credential.toHeaders())`
    instead of `if (isCredentialToken(r)) useToken(r.token)`.
  - `staticTokens({ svc })` now accepts a `string` (→ `bearer`) **or** a `Credential`;
    `agentCoreIdentity` issues `bearer(accessToken)`.

### Added

- **`defineTool({ needs: { credential, scopes?, mode? } })`** — declare a credential.
  Resolved before `execute`, injected as `ctx.credential`; it is NOT in `inputSchema`,
  so the LLM never sees it.
- **`Agent.create({ credentials })`** — attach a `CredentialProvider` (swap
  `staticTokens` ↔ `agentCoreIdentity` in one line; tool code unchanged).
- **`ToolExecutionContext`** gains `credentials` (fail-closed pull escape — an
  unconfigured provider THROWS, never `undefined`), `hasCredentials`, and the pushed
  `credential`.
- **Built-in credential kinds** — `bearer` / `apiKey` / `basic` / `headers` (the
  universal escape) from `agentfootprint/identity`; custom kinds plug in via the
  protocol with no library change.
- **4 typed events** — `agentfootprint.credential.{requested,acquired,authorization_required,failed}`
  (carry kind/service/reason only — **never the token**).
- **Failure ladder:** issued → inject; `authorization-required` → surface the URL to
  the LLM + skip the tool; throw → surface the reason + skip (a denial is **not**
  retried). Resolution happens before `execute` (fail-closed — never half-authed).

### Security

- The vended token lives only in the tool-call closure + `ctx`, never in tracked
  scope / commit log / recorders / emit payloads / `inputSchema`. Tested. Provider
  implementers MUST NOT echo secrets in thrown error messages (documented on the
  port). `agentCoreIdentity` does not yet forward `req.identity` (tenant isolation
  derives from the workload token — documented).
- **Secret fields are non-enumerable** on the built-in kinds: accidentally
  serializing a credential (returning `ctx.credential` from a tool, logging it)
  emits only the non-secret fields — `JSON.stringify(bearer(t))` is
  `{"kind":"bearer"}`, never the token. Direct reads (`cred.token`) still work.

### Migration note for test fixtures

- `ToolExecutionContext` gained two non-optional fields. If you construct ctx
  literals in your own tests, add them:
  `{ ..., credentials: unconfiguredCredentialProvider(), hasCredentials: false }`
  — both `unconfiguredCredentialProvider` and `CredentialNeed` are exported from
  `agentfootprint/identity`.

### Deferred (documented follow-ups)

- **Transient-retry via `reliability`** — the failure ladder currently surfaces
  transient failures (not auto-retried); wiring `withRetry` is a follow-up.
- **A dedicated `sf-credential` subflow node** — resolution is inline-with-emit for
  v1 (observable via the `credential.*` events; no hidden control flow since it
  returns a value); promote to a node when auto-pause-on-3LO is wanted.

### Tests / Examples

- `test/identity/declare-and-push` (inject / fail-closed / no-leak incl. emit /
  LLM-never-sees / denial-not-retried) + updated `identity` (kinds + 7 types);
  `examples/features/17-identity` rewritten to declare-and-push.

## [6.10.0]

Minor — **AWS Bedrock AgentCore integration**: make agentfootprint easy to run on
AgentCore. A deploy template, a complete integration guide, and a new `./identity`
port for downstream OAuth. Additive.

### Added

- **`agentfootprint/identity`** — the `CredentialProvider` port for OUTBOUND auth
  (vend a token so a tool can call GitHub/Slack/Google on the user's behalf;
  distinct from `agentfootprint/security` authorization). Two flows mirroring
  AgentCore Identity: `mode:'machine'` (2LO, token inline) and `mode:'user'`
  (3LO — may return `authorization-required` with a consent URL). Adapters:
  - **`agentCoreIdentity({ region })`** — wraps AgentCore Identity's
    `GetResourceOauth2Token` (lazy `@aws-sdk/client-bedrock-agentcore`; `_client`
    test seam). Maps `mode`→`M2M`/`USER_FEDERATION`, `service`→credential-provider
    name, response→`token`/`authorization-required`.
  - **`staticTokens({ service: token })`** — dev/test, no network.
  - Exports: `CredentialProvider`, `CredentialRequest`, `CredentialResult`,
    `CredentialToken`, `CredentialAuthorizationRequired`, `isCredentialToken`.
  - **Security invariant:** vended tokens are used locally inside a tool's
    `execute` and MUST NOT be written to tracked scope — so they never reach the
    commit log / recorders / observability export. Enforced by convention +
    proven by a test (token never appears in the snapshot/narrative).
- **AgentCore Runtime deploy template** — `examples/deploy/`: the HTTP contract
  handler (`POST /invocations` + `GET /ping` on `:8080`), ARM64 Dockerfile, and
  README. The example self-tests the contract then exits; `AGENTCORE_SERVE=1`
  listens forever (the container's mode).

### Docs

- **`docs/guides/agentcore.md`** — coverage matrix + verified setup for the
  primitives agentfootprint supports: Runtime (template), Memory (`AgentCoreStore`),
  Observability (`agentcoreObservability`/`otel`), Gateway (MCP via `mcpClient` +
  `toolProvider`), Bedrock model, Identity, and code-interpreter/browser as
  `defineTool` examples. Linked from the guides index.

### Tests / Examples

- `test/identity` — the 7 test types (unit/functional/integration/property/
  **security**/performance/load); the security test proves a vended token used
  locally never reaches the snapshot or narrative.
- `test/deploy` — Runtime handler contract + error paths (bad JSON / throw → 500,
  no stack-trace leak).
- `examples/features/17-identity` (CredentialProvider; asserts `tokenInSnapshot:
false`) + `examples/deploy/agentcore-runtime` (the contract, self-testing).

## [6.9.0]

Minor — **`skillGraph().tree()` scopes tools to the routed leaf by default** —
turning the library's on-demand-tools promise into the default, not a trick you
have to know.

### Changed

- **`.tree()` now stamps `autoActivate: 'currentSkill'` on every leaf.** A decision
  tree routes to exactly ONE skill per turn, so each leaf's `inject.tools` now reach
  the LLM **only when the tree routes there** — instead of every skill's tools
  landing in the always-on static registry on every call. `read_skill` remains the
  escape hatch to reach another skill mid-run. This is what makes a routed skill
  graph token-efficient out of the box (and sharpens tool selection — fewer choices
  per call).
  - **Opt out:** `.tree(root, { scopeTools: false })` restores the legacy additive
    behavior (all leaves' tools always visible).
  - A leaf that sets its **own** `autoActivate` in `defineSkill(...)` is always
    respected — the tree only fills the default.
  - Flat `.entry()` / `.route()` graphs are **unchanged** (not auto-scoped, since
    several skills may be active at once) — set `autoActivate` on those yourself.
  - **Migration:** if you relied on a `.tree()` exposing every leaf's tools every
    call, add `{ scopeTools: false }`. New export: `TreeOptions`.

### Tests / Examples

- `test/skillGraph` — default leaf scoping, `scopeTools: false` opt-out, explicit
  per-leaf `autoActivate` preserved, flat graphs not auto-scoped.
- `examples/features/15-skill-graph` — surfaces `treeToolScoping` (every leaf →
  `'currentSkill'`).

## [6.8.0]

Minor — **Azure OpenAI in the browser** (`browserAzureOpenai()`) + **env-driven
provider resolver** (`providerFromEnv()`), so "a company shows up with an API key"
is a `.env` edit, not a code change. Additive.

### Added

- **`browserAzureOpenai({ endpoint, apiKey, apiVersion, deployment })`** (main
  barrel) — drives an **Azure OpenAI** endpoint from the browser/edge over
  `fetch`, no Node SDK. Builds the deployment-scoped URL
  (`{endpoint}/openai/deployments/{deployment}/chat/completions?api-version=…`)
  and authenticates with the **`api-key` header** (not `Authorization: Bearer`).
  Reuses `browserOpenai`'s request/response/streaming logic. The request's
  `model` is the deployment; the shorthand `'azure'` resolves to the configured
  `deployment`. New exports: `BrowserAzureOpenAIProvider`,
  `BrowserAzureOpenAIProviderOptions`. **CORS:** point `endpoint` at a
  same-origin proxy when the browser blocks the direct call.
- **`browserOpenai({ authScheme })`** — new `authScheme?: 'bearer' | 'api-key'`
  option (default `'bearer'`); `'api-key'` sends the `api-key` header (the Azure
  shape). Backward-compatible — existing callers default to Bearer.
- **`providerFromEnv({ fallbackToMock? })`** (main barrel, **Node-only**) — reads
  `process.env`, detects which provider is configured, and returns
  `{ provider, model, kind }` with no branching in your code. Detection order:
  **Azure** (`AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`|`OPENAI_BASE_URL`)
  → **Anthropic** (`ANTHROPIC_API_KEY`) → **OpenAI** (`OPENAI_API_KEY`) →
  throws, or the mock with `{ fallbackToMock: true }`. Lazy-loads only the
  detected provider's SDK. New export: `ProviderFromEnv`.

### Docs

- `docs/guides/adapters.md` — `browserAzureOpenai` in the providers table + an
  **Env-driven: `providerFromEnv()`** section (detection table + a typical
  company `.env`) + a browser-providers CORS note. CLAUDE.md providers section
  updated (env-driven snippet + barrel exports).

### Tests / Examples

- `test/adapters/unit/AzureBrowserAndEnv` — `browserAzureOpenai` URL / `api-key`
  header / deployment routing / validation via a recording fake `fetch`;
  `providerFromEnv` detection order + mock fallback + the no-creds error.
- `examples/features/16-providers` — now uses `providerFromEnv({ fallbackToMock })`
  (dogfoods the resolver); runs offline on the mock.

## [6.7.0]

Minor — **Azure OpenAI provider** (`azureOpenai()`), for the common "company with
an Azure resource + API key" case. Additive.

### Added

- **`azureOpenai({ endpoint, apiKey, apiVersion, deployment })`** (from
  `agentfootprint/llm-providers`) — drives an **Azure OpenAI** endpoint. Azure is
  NOT OpenAI-compatible (deployment-scoped path, `api-key` header auth, an
  `api-version` param, deployment-as-model), so `openai({ baseURL })` can't reach
  it. This wraps the `openai` SDK's `AzureOpenAI` client and **reuses all of the
  OpenAI provider's** completion / streaming / tool-call logic. The request's
  `model` is the Azure **deployment**; the shorthands `'azure'` / `'azure-openai'`
  resolve to the configured `deployment` (pass a concrete deployment id to target
  another). Env fallbacks: `AZURE_OPENAI_ENDPOINT`/`OPENAI_BASE_URL`,
  `AZURE_OPENAI_API_KEY`/`OPENAI_API_KEY`, `AZURE_OPENAI_API_VERSION`,
  `AZURE_OPENAI_DEPLOYMENT`/`MODEL_NAME`. New export: `AzureOpenAIProviderOptions`.

### Docs

- `docs/guides/adapters.md` — a **supported-providers table** + the "connect a
  company endpoint" 3-bucket guide (OpenAI-compatible → `openai({ baseURL })`;
  Azure → `azureOpenai()`; anything else → the `LLMProvider` interface). CLAUDE.md
  providers section updated.

### Tests / Examples

- `test/adapters/unit/AzureOpenAIProvider` — deployment routing (shorthands → the
  configured deployment; concrete ids pass through), streaming delegation, the
  `deployment`-required guard (fake `_client`, no network).
- `examples/features/16-providers` — one agent, provider picked from the env
  (Azure / Anthropic / OpenAI / mock); runs offline on the mock.

## [6.6.0]

Minor — capture **"what the model saw"**: the tool catalog (name + description)
available to the model at each LLM call, for debugging tool selection. Additive;
behavior-safe.

### Added

- **`stream.llm_start.tools`** — the tool CATALOG the model saw for the call:
  `{ name, description }` per tool sent to the provider, in request order (absent
  when the call had no tools). The structured "what was at the model's disposal
  when it chose" payload — pair it with the iteration's reasoning to debug WHY a
  tool was (or wasn't) picked. (Skills already have the equivalent `skillCatalog`
  on `context.evaluated`.)
- **`AttToolSeen` + `toolsSeen` on `agentThinkingTrace` beats.** The recorder now
  attaches the tool menu to the iteration's first `ask` (and the terminal
  `answer`) — `toolsSeen: { name, description }[]` — so AgentThinkingUI ≥ 0.10 can
  render an expandable "Tools the model saw (N)" next to the reasoning. New export
  `AttToolSeen` from `agentfootprint/observe`.

### Fixed

- **`stream.llm_start.toolsCount` now reflects the DYNAMIC tool set** actually sent
  (registry + skill-unlocked `inject.tools`), not the static startup schemas. The
  count is computed from `activeToolSchemas` (the same list the request uses), so
  it matches `tools.length` and what the model truly saw. Previously it reported
  the startup `deps.toolSchemas.length`, which undercounted once a Skill unlocked
  tools mid-run.

### Tests / Examples

- `test/recorders/AgentThinkingTraceRecorder` — `toolsSeen` on the answer beat
  (name + description), on a tool-calling iteration's first ask, and absent when
  the agent has no tools.

## [6.5.0]

Minor — skill-graph **routing provenance**: capture _why_ a skill was reached
(the decision path / edge) as structured JSON, narrate it richly. Additive; zero
engine change; behavior-safe (routing logic is unchanged — we only record it).

### Added

- **Routing receipt on every compiled skill.** `skillGraph().build()` now stamps
  each skill's `metadata.skillGraph` with a `SkillRouting`: how it's reached
  (`via: 'tree' | 'entry' | 'route' | 'model'`), and for a decision tree the full
  root→leaf **decision path** (each predicate's caption + the `yes`/`no` branch
  taken). The compiler is the only thing that knows the routing semantics, so it's
  the right place to record them. New exports: `SkillRouting`, `SkillRoutingStep`,
  `SKILL_GRAPH_METADATA_KEY`.
- **`context.evaluated` carries `routing`.** The Injection Engine's per-iteration
  `agentfootprint.context.evaluated` event now includes a `routing` array — one
  entry per active skill-graph injection (`injectionId`, `via`, `path`, `label`,
  `from`, `triggerKind`, unlocked `tools`). The structured "what routed this turn,
  and why" payload for the lens. Absent when no active injection came from a
  `skillGraph()` (non-skill-graph runs are unchanged).
- **`context.routed` commentary.** A new template narrates the routing in prose —
  _"Neo routed to the `powermax-performance` skill (matched “array latency /
  cache?”) — 4 tools now available."_ Names the skill, the matched predicate (the
  deciding `yes`; an all-`no` path reads "no specific intent — default"), and the
  tool count. Silent when no skill-graph routing happened (no regression to other
  runs). The full path + every route ride the event payload; the prose stays
  concise (the COMMENTARY/DETAILS split).
- **`agentThinkingTrace` leads the iteration with the routing.** The Notepad's
  first beat each iteration now opens with the routing decision (then the LLM's
  reasoning), so AgentThinkingUI shows _why this skill_ before _what it did_.

### Tests / Examples

- `test/skillGraph` — routing provenance: tree leaf path (root→leaf with
  sibling-negated branches), existing metadata preserved (surfaceMode/cache),
  flat entry/route/model `via`.
- `test/recorders/observability/commentary/routing` — the three commentary fns
  over a `context.evaluated` payload: silent without routing, named skill + matched
  predicate + tool grammar (singular/plural), default-leaf clause, route-edge label.
- `test/recorders/AgentThinkingTraceRecorder` — the iteration's first beat leads
  with the routing line (and stays plain when no skill-graph routed).
- `examples/features/15-skill-graph` — captures the live run's `context.evaluated`
  `routing` off the emit stream (`runtimeRouting`), showing the per-turn provenance
  end-to-end (entry turn 1; the `CRC > 0` route edge turn 2).

## [6.4.0]

Minor — `skillGraph()`: a declarative, visualizable, token-efficient skill graph
(proposal 002, v1 + v3 decision tree). Additive; zero engine change.

### Added

- **`skillGraph()`** — declare an **entry** skill + routing **edges**; each edge
  compiles to the target skill's injection **trigger**, so a skill (its body +
  tools) loads **just-in-time**, only when its edge fires — fewer tokens, sharper
  reasoning, and the topology **draws itself**.

  - `.entry(skill, { when? })` → `always` (persistent base) or `rule` (intent-
    conditional).
  - `.route(from, to, { onToolReturn })` → `on-tool-return` trigger on `to`.
  - `.route(from, to, { when })` → `rule` trigger over `ctx.lastToolResult` on `to`
    (the deterministic "predicate on tool result → next skill" edge).
  - a bare `.route(from, to)` keeps `to`'s default `llm-activated` trigger (still
    reachable via `read_skill`; drawn as a dashed "model" edge).
  - **`graph.toMermaid()`** renders the declared graph (declared === drawn).
  - **`Agent.create().skillGraph(graph)`** mounts it (sugar over `.injection()`).

  v1 is pure sugar over the existing trigger model — the generic evaluator already
  activates a `'skill'`-flavor Injection by any trigger kind, so **no engine
  change**. Scoped `read_skill` (gating the model-reachable set by graph position)
  is deferred to v2. See `docs/proposals/002-skill-graph.md`.

- **`skillGraph().tree(...)` + `decide(...)`** (v3) — a **decision tree** whose
  **predicate nodes actually route**. `decide(predicate, whenTrue, whenFalse,
label?)` builds a branching node; leaves are skills. The compiler walks the tree
  and gives each leaf a `rule` trigger equal to the **conjunction of the predicates
  on its root→leaf path** (with earlier-sibling negation, so exactly one `if/else`
  leaf fires) — evaluated per iteration by the same generic evaluator, so still
  **zero engine change**. `toMermaid()` draws predicate **diamonds** → skill
  **boxes** with `yes`/`no` branch captions, and `graph.nodes` exposes the drawn
  shape (`{ id, kind: 'predicate' | 'skill', label? }`) for richer renderers.

  New exports: `skillGraph`, `decide`, `SkillGraph`, `SkillGraphBuilder`,
  `SkillRouteOptions`, `SkillEntryOptions`, `SkillEdge`, `SkillEdgeKind`,
  `SkillNode`, `DecisionNode`.

### Tests / Examples

- `test/skillGraph` — edge→trigger compilation, activation through the REAL
  evaluator (entry active at start; routed skill activates only when its predicate
  fires; body lands in the slot; dormant otherwise), `toMermaid`, guardrails; **v3
  decision tree** — each leaf compiles to a path-conjunction `rule`, exactly one
  leaf fires per question through the real evaluator, single-skill tree, non-skill
  leaf guard, and the diamond/box/`yes`-`no` Mermaid.
- `examples/features/15-skill-graph` — triage entry + sfp-diagnostics routed on
  `CRC > 0`, with the Mermaid + just-in-time load shown; **plus a `decide(...)`
  intent tree** routing `iops`/`sfp`/default to one leaf each.

## [6.3.0]

Minor — `agentThinkingTrace` now surfaces the model's extended-thinking
chain-of-thought. Additive.

### Added

- **Extended thinking on the beat.** `agentThinkingTrace` consumes
  `stream.thinking_end` and attaches the iteration's reasoning (the joined
  `blocks[].content`) to that iteration's first `ask` beat — or back-fills it
  onto a terminal `answer` beat (`thinking_end` fires just after `llm_end`, which
  already pushed the answer). Exposed as a new optional **`thinking`** field on
  the `AttStep` `ask`/`answer` shapes, so AgentThinkingUI ≥ 0.9 can render
  Claude's chain-of-thought in its callout. Empty/absent when the provider
  produced no reasoning (thinking disabled, or a mock). Enable it on the agent
  with `.thinking({ budget })`.

### Tests

- `test/recorders/AgentThinkingTraceRecorder` — synthetic-emit coverage: reasoning
  rides the first ask; terminal answers are back-filled; `thinking` stays
  undefined with no blocks.

## [6.2.0]

Minor — two new observability recorders (tool→tool data-flow graph; the
"watch it think" Trace, now narrated through one commentary engine). Additive.

### Added

- **`toolLineageRecorder()`** (from `agentfootprint/observe`) — reconstructs the
  tool→tool **data-flow graph** of a run that footprintjs `causalChain` cannot
  see. In a ReAct loop a tool's output goes back to the LLM as text and the LLM
  picks the next tool's args, so the dependency never touches the shared scope.
  This recorder rebuilds it by **value provenance**: when a distinctive value an
  earlier iteration's tool RESULT produced reappears in a later tool's ARGS, it
  records an edge (producer → consumer). Attach via
  `.recorder(toolLineageRecorder())` and read `getLineage()`.

  Conservative by design: short/common values are ignored (`minValueLength`,
  default 4), numbers are off by default (`matchNumbers`), and same-iteration
  (parallel) tool calls never link to each other. Run-scoped (resets per run).

  New exports: `toolLineageRecorder`, `ToolLineageRecorderHandle`,
  `ToolLineageOptions`, `ToolLineageGraph`, `ToolLineageEdge`, `ToolCallRef`.

- **`agentThinkingTrace()`** (from `agentfootprint/observe`) — builds an
  [AgentThinkingUI](https://github.com/footprintjs/agentThinkingUI) `Trace`
  (`prompt → ask → return → answer` beats) from the emit stream as the run
  traverses, so any agentfootprint agent drives the "watch it think" player for
  free. The `Trace` contract is kept inline, so agentfootprint does NOT depend on
  AgentThinkingUI. Attach via `.recorder(agentThinkingTrace({ agent, model }))`
  and read `getTrace({ task })` — it returns the run **so far**, so consumers can
  tail it live.

  **Commentary through one engine.** Each beat's `brain` (what AgentThinkingUI's
  Notepad / bottom caption render) is filled from agentfootprint's OWN commentary
  engine — the same `selectCommentaryKey` / `extractCommentaryVars` /
  `renderCommentary` the Lens uses — so the "watch it think" view and the Lens
  commentary panel read identically, from one source, consumer-overridable via a
  new **`commentaryTemplates`** option (same shape as the Lens's prop). The LLM's
  own reasoning still wins on the first ask of each iteration; the engine fills
  every other beat so no Notepad line is ever blank (previously tool-result and
  follow-up-ask beats had an empty `brain`).

  New exports: `agentThinkingTrace`, `AgentThinkingTraceHandle`,
  `AgentThinkingTraceOptions`, `AttTrace`, `AttStep`, `AttCost`, `AttAnswer`.

### Tests / Examples

- `test/recorders/ToolLineageRecorder` — unit (synthetic emits: cross-iteration
  edge, same-iteration gating, short-value/number filtering, run-scope reset)
  plus a functional real-agent `lookup → fetch` recovery.
- `test/recorders/AgentThinkingTraceRecorder` — functional (real skill-then-tool
  run → beat classification) plus the commentary engine (return beats carry
  engine prose, `commentaryTemplates` override, LLM reasoning wins on first ask).
- `examples/features/14-tool-lineage` — the FLOGI→FCID→io_profile chain, with
  the derived lineage printed.

## [6.1.0]

Minor — a self-explaining injection engine and clearer commentary. Additive; the
`activeInjections` result is byte-identical to 6.0.0.

### Changed

- **The injection engine runs as a readable `Gather → Evaluate → Route → Delta`
  subflow.** `buildInjectionEngineSubflow` is decomposed into four named stages so
  a run narrates _how_ context was assembled (which injections were gathered,
  evaluated, routed to a slot, and what changed) instead of one opaque step. The
  computed `activeInjections` is unchanged.
- **`Evaluate` emits `agentfootprint.context.evaluated`** carrying the offered
  skill catalog and a typed payload, so observers can narrate the decision.

### Fixed

- **Commentary for instruction injections now shows the rule content** (the
  injected text) instead of an empty `": ."`. The raw `context.evaluated` emit is
  no longer surfaced as prose — it is structured signal, not human narration.

### Tests

- `InjectionEngineSubflow` (the four-stage decomposition + emit) and commentary
  cases.

## [6.0.0]

Major — one breaking API simplification. No runtime behavior change.

### Changed (BREAKING)

- **`reactMode` + `reactStructure` merged into a single `reactMode`.** The two
  knobs interacted with a silently-ignored combination (`reactMode: 'classic'`
  ignored `reactStructure: 'subflow'`). They are now one setting with three
  honest, valid choices:

  - `'dynamic'` (default) — re-engineer all slots each turn; flat chart.
  - `'classic'` — engineer context once, loop only Messages; flat chart.
  - `'dynamic-grouped'` — `'dynamic'` semantics **+** the LLM turn wrapped in an
    `sf-llm-call` subflow for richer Lens grouping (was `reactStructure:'subflow'`).

  Migration: `reactStructure: 'subflow'` → `reactMode: 'dynamic-grouped'`;
  `reactStructure: 'flat'` → drop it (the default). `reactMode: 'classic' |
'dynamic'` are unchanged.

### Internal

- Renamed the internal `recorders/observability/thinking/` directory to
  `status/` (its symbols became `Status*` in 5.0.0) — no public API change.

## [5.0.0]

Major — API surface simplification (round 2). Three breaking refactors + one
additive factory. No runtime behavior change. The lens was updated in lockstep.

### Changed (BREAKING)

- **Observability recorders are `agentfootprint/observe`-only.** The ~15 recorder
  factories/classes (`ContextRecorder`, `streamRecorder`, `agentRecorder`,
  `compositionRecorder`, `costRecorder`, `evalRecorder`, `memoryRecorder`,
  `permissionRecorder`, `skillRecorder`, `toolsRecorder`,
  `contextEvaluatedRecorder`, `boundaryRecorder`/`BoundaryRecorder`,
  `liveStateRecorder` + `Live*` trackers, the `RunStep*` family,
  `attachFlowchart`/`attachLogging`/`attachStatus`, and their option/`Domain*Event`
  types) were removed from the main barrel — import them from
  `agentfootprint/observe`. `enable.flowchart()` stays on the runner, so its
  public types (`FlowchartHandle`/`FlowchartOptions`) remain on the main barrel.

- **The agent status-line concept renamed `Thinking*` → `Status*`** to
  disambiguate from the MODEL's extended-thinking reasoning (which keeps the
  "thinking" name — `ThinkingHandler`, `thinkingBudget`, `thinking_delta`):
  `ThinkingState`→`StatusState`, `ThinkingStateKind`→`StatusKind`,
  `ThinkingContext`→`StatusContext`, `ThinkingTemplates`→`StatusTemplates`,
  `selectThinkingState`→`selectStatus`, `renderThinkingLine`→`renderStatusLine`,
  `defaultThinkingTemplates`→`defaultStatusTemplates`,
  `ThinkingRecorder`→`StatusRecorder`, `attachThinking`→`attachStatus`,
  `ThinkingEvent`→`StatusEvent`, `ThinkingOptions`→`StatusOptions`.

- **Event payload shapes are namespaced under `Payloads`.** The ~60 `*Payload`
  types no longer flood the top-level barrel. Reach a shape by name as
  `Payloads.AgentRouteDecidedPayload`; `event.payload` is still typed via
  `AgentfootprintEventMap`.

### Added

- **`defineInjection({ type })`** — a unified injection factory. Pass
  `type: 'instruction' | 'skill' | 'steering' | 'fact'` and it routes to the
  matching named factory, returning the same `Injection`. For programmatic /
  config-driven flavor selection; the named factories remain the recommended
  self-documenting form. Purely additive.

## [4.0.0]

Major — API surface cleanup. Two breaking removals + one additive subpath. No
runtime behavior change.

### Removed (BREAKING)

- **Three redundant subpath aliases collapsed:**

  - `agentfootprint/providers` → use `agentfootprint/llm-providers`
  - `agentfootprint/memory-redis` → use `agentfootprint/memory-providers`
  - `agentfootprint/memory-agentcore` → use `agentfootprint/memory-providers`

  These were back-compat aliases of the canonical subpaths (same exports). 18
  subpaths → 15 real ones (16 with the new `./strategies` below).

- **`enable.thinking()` and `enable.logging()` removed** (deprecated since v2.8;
  the docs had promised removal). Use the uniform strategy enablers instead:

  ```ts
  import { chatBubbleLiveStatus, consoleObservability } from 'agentfootprint/strategies';

  agent.enable.liveStatus({ strategy: chatBubbleLiveStatus({ onLine: onStatus }) }); // was enable.thinking({ onStatus })
  agent.enable.observability({ strategy: consoleObservability() }); // was enable.logging()
  ```

  `enable.flowchart` is **kept** — it is a real, non-deprecated composition-graph
  feature. The low-level `attachThinking` / `attachLogging` helpers remain
  available from `agentfootprint/observe`.

### Added

- **`agentfootprint/strategies` subpath** — the public home for the strategy
  system (`chatBubbleLiveStatus`, `consoleObservability`, the `compose*`
  combinators, typed strategy interfaces, and the `attach*` helpers). Previously
  these defaults had no public import path, which made the strategy enablers
  unusable from outside the package; that gap is now closed.

## [3.1.2]

Patch — fixes a browser-load regression introduced in 3.1.1.

### Fixed

- **ESM build crashed on load in browser bundlers (Vite).** 3.1.1's ESM
  `lazyRequire` imported `createRequire` from `node:module` with a _named_ import,
  which Vite's CJS interop compiles to a top-level property read on the
  browser-externalized `node:module` stub — throwing `Cannot access
"node:module.createRequire" in client code` at import, even though
  `lazyRequire` is never called in a browser. Switched to a namespace import with
  the `createRequire` access deferred inside the function (call-time, never
  reached in a browser bundle). Verified end-to-end in a Vite playground (agent
  runs clean, zero console errors) and guarded by an ESM-packaging test. Node ESM
  - CJS behavior unchanged.

## [3.1.1]

Patch — packaging only. No API or behavior change.

### Changed

- **The ESM build now loads as true ESM.** Every relative import carries an
  explicit `.js` extension and `dist/esm` is marked `type:module`, so Node,
  Deno, and Bun load it as real ECMAScript Modules (not just bundlers). The ESM
  `lazyRequire` now uses `createRequire(import.meta.url)` instead of a bare
  `require()`, so optional peer-dep adapters (Anthropic, OpenAI, Bedrock,
  ioredis, AgentCore, MCP, OTEL/CloudWatch/X-Ray) work for ESM consumers instead
  of throwing `ReferenceError: require is not defined`. CJS consumers unaffected.

### Fixed

- **`sideEffects` now declares the cache-strategy registration files.** Those
  files self-register prompt-caching strategies as a side effect, so listing them
  keeps aggressive bundlers from dropping the registrations (defensive — not
  observed dropping under esbuild).

### Added

- **Tree-shaking guard test + badges.** A CI smoke test bundles a minimal
  `import { defineTool }` and asserts the Agent runtime / injection engine /
  memory stores / providers are pruned, plus true-ESM load of the main barrel and
  all 18 subpaths and an ESM `lazyRequire` check. README gains minzipped-size and
  tree-shakeable badges.

## [3.1.0]

Agent runtime + observability. Additive — `^3.0.0` consumers upgrade safely
(tracks `footprintjs@^6.0.0`).

### Added

- **`reactMode: 'classic' | 'dynamic'`** (default `dynamic`, back-compat): classic
  caches the static system-prompt/tools slots after turn 1 and re-selects only
  Messages; dynamic recomposes every slot each iteration. Same chart + loop shape.
- Runtime restructured to the merge-tree the Lens renders: context slots are a
  parallel selector fork (`failFast`), cache grouped into one `sf-cache` subflow,
  branch-sourced `loopTo` from ToolCalls — in both flat + dynamic agent builders.
- **`milestoneFor(id)`** classifier (iteration / slot / llm-turn / tool-call /
  decision) exported from conventions — drives the Lens time-travel scrub stops.
- `ErrorBridge` (`onRunFailed` → typed `error.fatal`), `ReliabilityRecorder`,
  `ContextEvaluatedRecorder`, `humanizeLLMError`.
- README coverage badge from generated v8 coverage.

### Fixed

- `cacheRecorder` slot resolution (matched by `splitStageId` local id);
  `buildMessages` / `buildTools` slot fixes.

## [3.0.0]

Major release. Tracks `footprintjs@^6.0.0` and propagates the build-time
observer rename.

### Breaking changes

- **API rename**: `buildTimeExtractor?: BuildTimeExtractor` removed from
  the config of every composition primitive. Replaced by
  `structureRecorders?: readonly StructureRecorder[]` — matching
  footprintjs v6's options-bag shape on `flowChart()`.
  Affected factories:
  - `Agent.create({ buildTimeExtractor })` → `Agent.create({ structureRecorders: [...] })`
  - `LLMCall.create()` — same rename
  - `Sequence.create()` / `Parallel.create()` / `Loop.create()` /
    `Conditional.create()` — same rename
  - **Migration**: pass a `StructureRecorder` (event-handler interface)
    instead of the v5 `BuildTimeExtractor` (per-node spec mutator).
    See footprintjs `MIGRATION-6.md` Recipe 1 for the recorder shape.
- **Peer dep**: bumped `footprintjs` from `>=4.17.2` to `^6.0.0`.

### Added

- **`agentfootprint.context.evaluated` event** — the Injection Engine now
  emits a per-iteration summary of trigger evaluation (active count, skipped
  count + reasons, trigger-kind breakdown, active ids). It is the upstream
  counterpart to `context.slot_composed` ("what was considered / active /
  skipped and why" vs "what landed in each slot"). Subscribe via
  `agent.on('agentfootprint.context.evaluated', …)`. Brought to the dispatcher
  by a new `contextEvaluatedRecorder` (an `EmitBridge` scoped to the exact
  event name — not the whole `context.` domain — so it never double-dispatches
  `context.slot_composed`, which is also `typedEmit`'d in the viz chart).
  Replaces a **dead** `injectionEvaluation` scope write that nothing read and
  that never left the Injection Engine subflow (the documented emit had never
  actually been wired). Event count 58 → 59.

### Changed

- **Setup stage renamed `Seed` → `Initialize` (display only)** — the root
  setup stage of every primitive (Agent, LLMCall, Parallel, Loop, Conditional)
  now displays as **`Initialize`** in the Lens/flowchart. "Seed" read like
  planting/growing to non-experts; "Initialize" says what it does (sets up the
  starting state). DISPLAY-ONLY: the internal id stays `'seed'`, so
  `runtimeStageId` `seed#0` is unchanged — no break to recorders / Lens / trace
  / tests that key on the id. (Per the stage-naming audit; the inner dynamic
  per-iteration `TurnSeed` is left as-is.)
- All internal `flowChart()` call sites migrated from the legacy
  5-positional signature `(name, fn, id, undefined, 'desc')` to v6's
  options-bag form `(name, fn, id, { description: '...' })`. Affected
  internal builders: every composition primitive, every slot builder
  (system-prompt / messages / tools / thinking), every memory pipeline,
  injection-engine, cache decision, reliability gate.
- `test/core-flow/unit/buildTimeExtractor.test.ts` — 14 tests rewritten
  to validate the v6 observer contract (events fire with expected
  payloads, throw isolation) instead of v5 spec-tree mutation (v6
  explicitly removed that capability per Recipe 2). All 7 test-type
  sections preserved.
- Adopted `splitStageId` (new in footprintjs v6) at `conventions.ts:96`
  and `RunStepRecorder.ts:428`. Left `eventMeta.ts:74` as
  `parseRuntimeStageId` since it consumes full runtimeStageId strings.
- Docstring sweeps: `translator.ts` + `RunnerBase.ts` reference v6
  `StructureRecorder` semantics, not v5 `BuildTimeExtractor`.
- **Agent chart restructure** — the context slots (system-prompt /
  messages / tools) now run as a **parallel selector fork** and the cache
  machinery is a single `sf-cache` subflow, so the execution tree the agent
  runs IS the merge-tree the Lens draws. Applies to both `buildAgentChart`
  (classic) and `buildDynamicAgentChart` (dynamic). `ContextRecorder` and
  `cacheRecorder` made parallel- and nesting-safe (resolve slot/gate from
  each event's own `runtimeStageId`/`stageId`).
- **`iterationStart` stage folded into `callLLM`** — the former dedicated
  `stages/iterationStart.ts` stage existed only to fire the per-iteration
  `agentfootprint.agent.iteration_start` emit. Emits are passive (no business
  logic, no scope writes), so the marker now fires from the top of `callLLM`
  and the standalone stage is removed. No consumer depended on the stage's
  own `runtimeStageId`; all read the `iterIndex` payload. Removed from
  `AgentChartDeps` and both chart builders. `stages/iterationStart.ts` deleted.
- **`UpdateSkillHistory` is now conditionally mounted** — the stage (and thus
  the skill-churn rule) is added ONLY when ≥1 Skill is registered
  (`injections.some(i => i.flavor === 'skill')`, the same gate that
  auto-attaches `read_skill`). With no skills the window could never show
  churn, so the stage was dead weight + a misleading box. Applies to both
  builders; the dynamic chart's fan-out `convergeAt` retargets to `sf-cache`
  when the stage is absent so the merge target always exists. Mirrors the
  existing `NormalizeThinking` conditional-mount pattern.

### Fixed

- **Skill-churn detection was dead** — `updateSkillHistory` sampled the
  **head** of `activatedInjectionIds` (`[0]`). Since `read_skill` appends new
  ids to the END and the list is cumulative + deduped per turn, the head was
  frozen at the first-activated skill and never changed mid-turn — so the
  rolling window recorded one constant value and `detectSkillChurn` could
  never fire. The cache's skill-churn rule was effectively inert. Fixed to
  sample the **tail** (most-recently activated skill), so churn is detected
  across A→B→C activations and the gate correctly skips caching when skills
  thrash. Known limitation (documented): multiple skills activated in the
  same iteration record only the tail.

### CI / publish workflow

- Migrated to npm **trusted publishing** (OIDC) — no `NPM_TOKEN`
  secret required. `id-token: write` permission already present.
  Workflow upgrades `npm@latest` after tests but before publish
  (trusted publishing requires npm >= 11.5.1; Node 22 ships npm 10.x).

### Internals (accumulated 2.14.5+ work)

- New `RunStepRecorder` with structural design notes
  (`RunStepRecorder.STRUCTURE.md`).
- New observability internals (`observability/internal/`),
  `observeRunId` helper.
- Multi-run aliasing test coverage, parallel-events test fixture,
  agent-toolprovider test coverage, snapshot/getLastSnapshot test
  shape, BoundaryRecorder-ranges coverage.
- `docs/design/` notes added.

### Verification

- Suite: 2177 / 2177 passing (175 files).
- Build: CJS + ESM clean.

## [2.14.5]

### Added — `name` field on `CompositionExitPayload`

Mirror of the `name` field already on `CompositionEnterPayload`, so consumers narrating the exit moment can reference the same human-readable identity used at entry — no name-cache required across the start/stop pair.

```ts
// Before (v2.14.4 and earlier):
interface CompositionExitPayload {
  kind: CompositionKind;
  id: string;
  status: 'ok' | 'err' | 'break' | 'budget_exhausted';
  durationMs: number;
}

// After (v2.14.5):
interface CompositionExitPayload {
  kind: CompositionKind;
  id: string;
  name?: string;          // ← NEW (optional for back-compat)
  status: ...;
  durationMs: number;
}
```

**Why:** the v2.14.4 `composition.exit` commentary template (`'`{{name}}` finished — {{status}} in {{durationMs}}ms.'`) had to fall back to `id` because the exit payload didn't carry `name`. For runs like `Sequence.create({ name: 'IntakePipeline' })`, the closing line read `'`sequence` finished'` (lowercase id) instead of `'`IntakePipeline` finished'`. Now reads correctly.

### Changed — Sequence / Parallel / Conditional / Loop emit `name` on exit

All four core-flow primitives now pass their build-time `name` through to `composition.exit`. Pre-existing consumers reading only `id` are unaffected. Consumers using the `composition.exit` template automatically get the right name.

`extractCommentaryVars` for `composition.exit` reads `p.name ?? p.id` so v2.14.4-emitter events still render (using id as a fallback).

### Tests

2071/2071 unchanged — the field is optional for back-compat, and existing test fixtures that asserted on the exit payload didn't reference `name`.

Pure addition. No breaking changes.

## [2.14.4]

### Added — `{{agentName}}` template variable + multi-agent commentary templates

Commentary templates can now surface the **active agent's identity** for multi-agent / multi-LLM runs (Sequence-of-LLMCalls, Swarm, Debate, etc.) via a new `{{agentName}}` template variable:

```ts
'stream.llm_start.iter1': '{{agentName}} sent the question to the LLM.',
```

For single-Agent runs, `{{agentName}}` falls back to `{{appName}}` — so existing copy reads identically (no breaking change). For Sequence-of-LLMCalls or Swarm, the active agent's name surfaces (e.g., `'classify sent the question…'` then `'respond sent the question…'`).

**`extractAgentName(event, ctx)`** — new exported helper that walks `event.meta.subflowPath` right-to-left, skipping library-internal segments (slot subflows `sf-*`, agent-routing subflows, thinking-handler subflows, the `final` route-branch), and returns the first meaningful segment with the optional `step-` Sequence prefix stripped. Falls back to `appName` when no meaningful segment is found.

```ts
import { extractAgentName } from 'agentfootprint';

extractAgentName(event, { appName: 'Chatbot' });
//   path: []                                   → 'Chatbot' (single-Agent runner)
//   path: ['step-classify']                    → 'classify' (Sequence stage)
//   path: ['agent-A', 'agent-B']               → 'agent-B' (Swarm: latest hand-off)
//   path: ['agent-Triage', 'sf-system-prompt'] → 'agent-Triage' (skips slot subflow)
//   path: ['sf-injection-engine']              → 'Chatbot' (all internal → fallback)
```

### Added — Composition templates (Sequence / Parallel / Loop / Conditional)

Each composition primitive gets its own `composition.enter.<Kind>` template, plus a `composition.exit` template:

```ts
'composition.enter.Sequence':    'Started pipeline `{{name}}` — {{childCount}} stages chained.',
'composition.enter.Parallel':    'Forked `{{name}}` into {{childCount}} parallel branches.',
'composition.enter.Loop':        'Started loop `{{name}}` — repeat until done.',
'composition.enter.Conditional': 'Entering router `{{name}}` — picking a branch.',
'composition.enter.Generic':     'Entered composition `{{name}}` ({{kind}}) with {{childCount}} children.',
'composition.exit':              '`{{name}}` finished — {{status}} in {{durationMs}}ms.',
'composition.handoff':           'Handed off `{{fromAgent}}` → `{{toAgent}}`.',
```

`selectCommentaryKey` routes `agentfootprint.composition.enter` to `composition.enter.${kind}` and `composition.exit` to `composition.exit`. Single-Agent runs never fire these, so they're additive only — no existing behavior changes. Override per-key for locale or brand voice via the existing `commentaryTemplates` consumer override mechanism.

### Updated — default templates use `{{agentName}}` where actor identity matters

Updated keys (semantically equivalent for single-Agent runs):

- `stream.llm_start.iter1`
- `stream.llm_start.iterN`
- `stream.llm_end.tools`
- `stream.llm_end.terminal`
- `stream.tool_start`
- `stream.tool_end`

Backward-compat verified — all 2053 pre-existing tests continue passing because `agentName === appName` when no inner-agent context exists.

### Tests

18 new tests covering 5 edge cases (single-Agent, Sequence-of-LLMCalls, Swarm, slot-subflow walk-past, pause/resume) + composition.enter/exit rendering + variable bag includes agentName for every event type. Total suite 2071/2071.

### Public exports

- `extractAgentName(event, ctx): string` — re-exported from `'agentfootprint'` (used by Lens, custom dashboards, tests).

Pure addition. No breaking changes. No new public API beyond the new template variable + helper.

## [2.14.3]

### Added — `BoundaryRecorder.aggregateForBoundary` + `aggregateAllBoundaries`

Per-boundary rollups for multi-agent / multi-LLM UIs. Two new methods on the existing `BoundaryRecorder` (no new class) plus a new `BoundaryAggregate` type.

**Why this exists:** Lens, CLI live monitors, Sentry breadcrumbs, OTel exporters, and custom dashboards all need the same per-Agent rollup (tokens, llmCalls, toolCalls, iterations, duration). Re-implementing the prefix-match-by-`subflowPath` fold in each consumer is exactly what the recorder pattern is meant to prevent. Domain math (what counts as an "iteration"? does a cache hit count separately?) lives in the library; consumers hook up.

**API:**

```ts
import { boundaryRecorder, type BoundaryAggregate } from 'agentfootprint';

const boundary = boundaryRecorder();
// ... attach + run ...

// One boundary's rollup
const triage: BoundaryAggregate | undefined = boundary.aggregateForBoundary('agent-triage#0');

// Every primitive boundary's rollup, in entry order
const all = boundary.aggregateAllBoundaries();
all.forEach((r) => {
  console.log(
    `${r.label}: ${r.tokens.input}+${r.tokens.output} tokens, ` +
      `${r.llmCalls} llm calls, ${r.toolCalls} tool calls, ` +
      `${r.durationMs ?? '(in flight)'}ms`,
  );
});
```

**Shape:**

```ts
export interface BoundaryAggregate {
  readonly runtimeStageId: string;
  readonly subflowId: string;
  readonly subflowPath: readonly string[];
  readonly primitiveKind?: string; // 'Agent' | 'LLMCall' | 'Sequence' | ...
  readonly label: string; // subflow display name
  readonly tokens: { readonly input: number; readonly output: number };
  readonly llmCalls: number; // count of llm.start
  readonly toolCalls: number; // count of tool.start
  readonly iterations: number; // count of loop.iteration
  readonly startedAtMs: number;
  readonly endedAtMs?: number; // undefined while in flight
  readonly durationMs?: number; // undefined while in flight
}
```

**Semantics:**

- Events count toward a boundary's rollup when their `subflowPath` is a **prefix-match** of the boundary's path. Nested boundaries (e.g., `LLMCall` inside an `Agent`) contribute to BOTH rollups — caller decides which level to render.
- `aggregateAllBoundaries` filters to `primitiveKind`-tagged subflows ONLY (Agent / LLMCall / Sequence / Parallel / Conditional / Loop). Slot subflows (`sf-system-prompt` / `sf-messages` / `sf-tools`) are NOT included — they're context-engineering machinery, not user-facing rollup units.
- Works **mid-run** — in-flight boundaries get partial values (`endedAtMs` / `durationMs` undefined). Lens uses this for per-agent live chips that update as the run progresses.
- Works **post-run** — same call, terminal state.

**Performance:** O(N events × M boundaries) for `aggregateAllBoundaries`. Pure projection over the existing flat event stream — no parallel state, no drift risk vs. `getEvents()`. For typical agent runs (<1000 events, <10 boundaries) this is sub-millisecond.

**Tests:** 9 new tests covering single-boundary rollup, in-flight partial, prefix-match isolation, nested rollup contribution, ordering, primitive-kind filter. Total suite 2053/2053.

**Public exports:** `BoundaryAggregate` type from `'agentfootprint'` main barrel + `'agentfootprint/observe'` subpath.

Pure addition. No breaking changes.

## [2.14.2]

### Added — `LiveStateRecorder` — O(1) "what's happening RIGHT NOW" reads

A live-state recorder built on the new footprintjs **`BoundaryStateTracker<TState>`** storage primitive (v4.17.2). Three bracket-scoped trackers + one façade answer "is something in flight, and what's the partial?" without folding the event log.

**The three trackers:**

| Tracker                | Boundary                  | Key                 | Tracks                                                                                       |
| ---------------------- | ------------------------- | ------------------- | -------------------------------------------------------------------------------------------- |
| `LiveLLMTracker`       | `llm_start` ↔ `llm_end`   | `runtimeStageId`    | partial content (token-stream accumulation), tokens, iteration, provider, model, startedAtMs |
| `LiveToolTracker`      | `tool_start` ↔ `tool_end` | `toolCallId`        | toolName, args, toolCallId, startedAtMs                                                      |
| `LiveAgentTurnTracker` | `turn_start` ↔ `turn_end` | `String(turnIndex)` | turnIndex, userPrompt, startedAtMs                                                           |

**The façade — `LiveStateRecorder`:** bundles all three with one subscribe call, exposes O(1) convenience reads:

```ts
import { liveStateRecorder } from 'agentfootprint';

const live = liveStateRecorder();
live.subscribe(agent); // wires all 3 trackers to the agent's dispatcher

await agent.run({ message: input });

// Read live, O(1), at any moment during the run:
live.isLLMInFlight(); // true between llm_start ↔ llm_end
live.getPartialLLM(); // accumulated tokens of latest active call
live.isToolExecuting(); // true between tool_start ↔ tool_end
live.getExecutingToolNames(); // names of currently-executing tools
live.isAgentInTurn(); // true between turn_start ↔ turn_end
live.getCurrentTurnIndex(); // most-recent active turn (-1 if none)

live.unsubscribe();
```

Each tracker is also independently usable when a consumer only needs one slice (e.g., a CLI status line that only cares about LLM streaming):

```ts
import { LiveLLMTracker } from 'agentfootprint';

const llm = new LiveLLMTracker();
llm.subscribe(agent);
llm.isInFlight();
llm.getLatestPartial();
```

**Mental model:**

> Existing recorder _interfaces_ (`Recorder` / `FlowRecorder` / `EmitRecorder` / `CombinedRecorder`) are **observers**. Storage primitives (`SequenceRecorder<T>` / `KeyedRecorder<T>` / **`BoundaryStateTracker<TState>` 🆕**) are **bookkeeping shelves**. A real recorder picks ONE observer interface AND ONE storage shelf via `extends + implements`. `LiveLLMTracker` extends the new `BoundaryStateTracker` shelf and subscribes to typed events from the agentfootprint dispatcher.

**Subscribe semantics:** `live.subscribe(runner)` is idempotent — calling twice unsubscribes the prior subscription before re-attaching, so consumers don't have to track state. `live.clear()` resets transient state across all three trackers without unsubscribing.

**Tier 1 (live) only.** Past states are not stored — when a boundary closes, its transient state clears. For time-travel queries ("what was the LLM partial at slider step N?"), snapshot to a `SequenceRecorder<TState>`. See the `BoundaryStateTracker` JSDoc on the footprintjs side for the rationale.

**Multi-consumer story:**

- Lens / UI live commentary (the "Chatbot is responding: …" line)
- CLI live monitor (stdout status line)
- Sentry breadcrumb capture ("agent in flight at exception time")
- Test harness (`await waitForLLMIdle()`)

Each consumer reads `live.*` getters in O(1) — no per-render fold over the event log.

**Tests:** 27 new tests across 7 tiers (unit / scenario / integration / property / perf / security / ROI). Total suite 2044/2044.

**Example:** [examples/features/13-live-state.ts](examples/features/13-live-state.ts) — full ReAct turn with mid-stream peeks demonstrating the transient state evolving and clearing.

**Public exports:** main barrel `'agentfootprint'` + `'agentfootprint/observe'` subpath:

- `LiveStateRecorder` / `liveStateRecorder()` factory
- `LiveLLMTracker` / `LiveToolTracker` / `LiveAgentTurnTracker`
- `LLMLiveState` / `ToolLiveState` / `AgentTurnLiveState` (state shape types)
- `LiveStateRunnerLike` (minimal Runner shape required by `subscribe`)

### Bumped — peer dependency on footprintjs to `>=4.17.2`

`LiveStateRecorder` extends `BoundaryStateTracker<TState>` which lands in footprintjs v4.17.2. Existing v4.17.1 consumers will see a peer-dep warning until they bump. No breaking changes in either library.

## [2.14.1]

### Added — `StepNode` payload fields for ReAct steps

`StepNode` now carries the actual data crossing each ReAct boundary, not just metadata. Three new optional fields populated during `buildStepGraph`:

- `assistantText` — LLM's text content. Set on `llm->tool` (the reasoning emitted alongside `tool_use` blocks) and on `llm->user` (the terminal answer).
- `toolArgs` — tool input arguments the LLM produced. Set on `llm->tool` from the matching `tool.start` event payload.
- `toolResult` — tool result returned to the LLM. Set on `tool->llm` from the preceding `tool.end` event payload.

Lets renderers (e.g. agentfootprint-lens NodeDetailPanel) surface "what arrived / what was produced" per ReAct step without consumer-side correlation.

### Fixed — `SUBFLOW_IDS.FINAL` now matches the route-branch key

`SUBFLOW_IDS.FINAL` was `'sf-final'` but the Agent mounts the final-answer composition via `addSubFlowChartBranch('final', ...)` — the branch key IS the subflow id, no `sf-` prefix. The mismatch leaked the final subflow into the user-facing StepGraph as a phantom "step". Now `SUBFLOW_IDS.FINAL = 'final'`, and `BoundaryRecorder`'s `AGENT_INTERNAL_LOCAL_IDS` correctly skips it.

### Added — `SUBFLOW_IDS.THINKING` registered + filtered

The v2.14 thinking-normalize subflow (`sf-thinking`) and its inner handler subflows (`thinking-anthropic`, `thinking-openai`) are now declared in `SUBFLOW_IDS` and filtered from the StepGraph via `AGENT_INTERNAL_LOCAL_IDS` plus a new `thinking-` prefix matcher in `isAgentInternalId()`. The wrapping LLM step's `assistantText`/`toolArgs`/`toolResult` already carry the relevant info, so the inner subflows don't surface as separate user-facing steps.

## [2.14.0]

### Added — Extended-thinking subsystem (Anthropic + OpenAI o1/o3)

When the LLM emits reasoning blocks (Anthropic extended thinking, OpenAI o1/o3 `reasoning_summary`), v2.14 normalizes them into a provider-agnostic `ThinkingBlock[]`, persists the assistant message with byte-exact signature for the round-trip the next turn requires, and surfaces them on the typed-event stream so live UIs can render reasoning per iteration without post-walking `scope.history`.

**Two-layer architecture:**

- **CONSUMER-FACING:** `ThinkingHandler` — a small function-pair `{id, providerNames, normalize, parseChunk?}`. Provider authors and custom-LLM consumers implement this shape. Auto-wired by `provider.name` via the registry.
- **FRAMEWORK-INTERNAL:** each handler is auto-wrapped in a real footprintjs subflow at chart build time. The subflow gets its own `runtimeStageId`, narrative entry, and InOutRecorder boundary — full trace observability for free without consumers writing flowchart code.

Same pattern as v2.6 caching, v2.11.5 reliability, v2.11.6 tool-providers: a small typed surface for the consumer, a real subflow for the framework.

**Pre-implementation 7-panel review** (Anthropic + OpenAI + Architect + footprintjs + SRE + Security + QA, each with architect + coder dual lens) ran before EVERY phase. **Post-implementation 7-panel review** at the end of every phase, with must-fixes folded in before the next phase opened. Each phase shipped its own 7-pattern test matrix (unit · scenario · integration · property · security · performance · ROI).

#### Builder surface

```ts
// Request-side: ASK the model to think.
//   Anthropic: sets thinking: { type: 'enabled', budget_tokens } on the wire.
//   OpenAI:    no-op (o1/o3 reasoning is selected at the model id level).
Agent.create({ provider: anthropic({...}), model: 'claude-sonnet-4-5' })
  .thinking({ budget: 5000 })
  .build();

// Response-side: NORMALIZE the response (auto-wired by provider.name).
// Override per-agent when you need custom normalization or opt out:
agent.thinkingHandler(myCustomHandler);  // override
agent.thinkingHandler(null);             // opt out
```

`max_tokens` is auto-bumped to `budget + 1024` when the resolved value would violate Anthropic's `max_tokens > thinking.budget_tokens` invariant. Consumers who explicitly set `maxTokens` keep their choice.

#### Round-trip integrity (Anthropic)

Anthropic's signed thinking blocks must echo back BYTE-EXACT in subsequent assistant turns or the API rejects with HTTP 400. `LLMMessage.thinkingBlocks` (PERSISTED — different from `ephemeral`) carries the signature through `scope.history`; `AnthropicProvider.toAnthropicMessages` serializes them first in the assistant content array (Anthropic's wire-format ordering rule). Tested with tricky base64 + padding + trailing-whitespace signatures across the full pipeline.

#### Live event stream — collect during traversal

Per-iteration thinking content lands on `agentfootprint.stream.thinking_end.payload.blocks`. Live UIs subscribe once, accumulate as iterations complete — no post-walking `scope.history`:

```ts
agent.on('agentfootprint.stream.thinking_end', (e) => {
  // e.payload.blocks: readonly ThinkingBlock[]
  // e.payload.iteration: which agent loop iteration produced these
  // e.payload.totalChars / blockCount / tokens: metadata
});
```

Same data the framework persists to `LLMMessage.thinkingBlocks` (post-`providerMeta` strip). Privacy: wildcard (`*`) recorders piping to external sinks (Datadog, CloudWatch, OTel) will see reasoning content — same risk profile as `stream.token`.

#### Three new typed events (count 52 → 55)

- `agentfootprint.stream.thinking_delta` — per-chunk streaming reasoning fragments (Anthropic streams these; OpenAI doesn't, as of early 2026)
- `agentfootprint.stream.thinking_end` — per-call summary with full blocks (use this for live per-iteration UIs)
- `agentfootprint.agent.thinking_parse_failed` — graceful-failure signal when a handler's `normalize()` throws; framework drops the blocks and continues, same pattern as v2.11.6 `tools.discovery_failed`

#### Three shipped handlers

- `anthropicThinkingHandler` (`'anthropic'` + `'browser-anthropic'`) — Anthropic + browser direct-fetch, byte-exact signature
- `openAIThinkingHandler` (`'openai'`) — o1 string + o3+ structured `reasoning_summary` array; all blocks marked `summary: true`
- `mockThinkingHandler` (`'mock'`) — canonical reference implementation; defensive `isMockRaw` guard against malformed shapes

Future provider authors implement `ThinkingHandler` and append to `SHIPPED_THINKING_HANDLERS`; the cross-cutting contract test (`test/thinking/cross-cutting.test.ts`) iterates the registry and pins invariants for every handler.

#### `providerMeta` strip — defense in depth

`ThinkingBlock.providerMeta` is documented as "escape hatch for fields the normalized shape doesn't model." The framework strips it from blocks before persisting to `scope.thinkingBlocks` (which feeds `LLMMessage.thinkingBlocks` → audit logs and the event payload). Type doc declared this; Phase 6 enforced it via test + source fix.

#### Phase summary

- **Phase 1** — types foundation (`ThinkingBlock`, `ThinkingHandler`, registry, mock)
- **Phase 2** — three typed events (`thinking_delta`, `thinking_end`, `thinking_parse_failed`)
- **Phase 3** — framework wiring: `buildThinkingSubflow` + auto-wire by `provider.name` + build-time conditional mount (zero overhead for non-thinking agents)
- **Phase 4a** — `AnthropicThinkingHandler` (response normalization, byte-exact signature)
- **Phase 4b** — `AnthropicProvider` serialization (request → response → round-trip on second turn)
- **Phase 5** — `OpenAIThinkingHandler` (string + structured array shapes)
- **Phase 6** — cross-cutting: registry-iterating contract test + E2E 2-turn signature round-trip + `providerMeta` non-leak. Source fixes for `MockThinkingHandler` defensive guard and `providerMeta` strip in `buildThinkingSubflow`
- **Phase 6.5** — request-side activation: `LLMRequest.thinking?: { budget }`, `AgentBuilder.thinking({budget})`, plumbed through `callLLM`. `AnthropicProvider` translates to wire format; OpenAI ignores
- **Phase 6.5b** — `BrowserAnthropicProvider` reaches v2.14 parity (request body + response + streaming `thinking_delta` + `signature_delta` accumulation). `max_tokens` auto-bump in both providers
- **Phase 6.6** — `StreamThinkingEndPayload.blocks` for live per-iteration consumers; closes the "post-walk scope.history" anti-pattern

Test suite: 2017/2017 (was 1862 before v2.14). Build clean (CJS + ESM). Lint clean. Format clean.

## [2.13.0]

### Added — Instructor-style schema retry on the reliability gate

When the LLM emits valid JSON that fails your `outputSchema` (e.g. `amount` came back as `"USD 50"` instead of `50`), v2.13 re-prompts the same model with the validation error — within the SAME turn — for up to N retries. Each retry's feedback is an ephemeral message: visible to the model, never persisted to memory or audit logs. Composes on top of the existing v2.11.5 reliability gate; no new factory.

**Pattern parallels v2.11.6 `discoveryProvider` + v2.12 `sequencePolicy`:** the library extends primitives, ships a recipe; consumers build the convenience layer in user-land. Avoids API lock-in before real usage shapes the right factory.

**Pre-implementation 7-panel review** (Anthropic + OpenAI + tool-dispatch + architect + footprintjs + SRE + security + QA) surfaced 7 must-fix items + 10 doc notes; all folded in before code landed. **Post-implementation 7-panel review** in CHANGELOG section below.

#### `ReliabilityScope` extension

```ts
interface ReliabilityScope {
  // existing
  attempt, providerIdx, response?, error?, errorKind, latencyMs, ...

  // NEW in v2.13
  validationError?: { message: string; path?: string; rawOutput?: string };
  validationErrorHistory: readonly string[];   // accumulates across retries
}
```

Rules read these to drive `retry`/`fail-fast` on schema-fail outcomes.

#### `ReliabilityRule.feedbackForLLM`

```ts
interface ReliabilityRule {
  // existing
  when;
  then;
  kind;
  label?;

  // NEW in v2.13
  feedbackForLLM?: string | ((s: ReliabilityScope) => string | Promise<string>);
}
```

When a rule fires with `then: 'retry'` (or `'retry-other'`) AND `feedbackForLLM` is set, the gate appends an ephemeral user message to the next request. Sync OR async (callback may return Promise). Throwing callbacks are caught and fall back to a generic message — never abort the run.

#### `LLMMessage.ephemeral` (persistence flag)

```ts
interface LLMMessage {
  // existing
  role;
  content;
  toolCallId?;
  toolName?;
  toolCalls?;

  // NEW in v2.13 — persistence flag (NOT a visibility flag)
  ephemeral?: boolean;
}
```

Critical clarification (v2.13 7-panel security reviewer's concern): `ephemeral` is a PERSISTENCE flag, not a VISIBILITY flag. Ephemeral messages:

- ✅ ARE sent to the LLM in the next request (visible to the model, count toward context window)
- ✅ ARE observable via narrative / recorders / typed events (visible to humans for debugging + forensics)
- ❌ NOT persisted to `scope.history` (so memory writes / `getNarrative()` snapshots don't include them)

An attacker cannot use the ephemeral marker to construct audit-invisible prompts.

#### `ValidationFailure` sentinel + `OutputSchemaValidator` hook

```ts
class ValidationFailure extends Error {
  readonly stage: 'json-parse' | 'schema-validate';
  readonly path?: string;
  readonly rawOutput?: string;
}

type OutputSchemaValidator = (response: LLMResponse) => void;
```

Caller-supplied validators throw `ValidationFailure` to signal schema-fail to the reliability loop. The framework auto-builds a validator from `outputSchemaParser` when both `outputSchema()` AND `reliability()` are configured on the same agent — consumers don't need to write their own validator for the common case.

#### `defaultStuckLoopRule` + `lastNValidationErrorsMatch` helpers

```ts
import { defaultStuckLoopRule, lastNValidationErrorsMatch } from 'agentfootprint/reliability';

// Drop in BEFORE retry rules:
.reliability({
  postDecide: [
    defaultStuckLoopRule,                // ← fail-fast on 2 identical errors
    { when: ..., then: 'retry', feedbackForLLM: ..., ... },
    { when: ..., then: 'fail-fast', ... },
  ],
})
```

Stuck-loop detection is a built-in rule (must-fix #4 from 7-panel review). `kind: 'schema-stuck-loop'` surfaces on `ReliabilityFailFastError.kind` for caller branching. Custom n: `lastNValidationErrorsMatch(scope, 3)`.

#### `agentfootprint.agent.output_schema_validation_failed` event

```ts
interface AgentOutputSchemaValidationFailedPayload {
  message: string;
  stage: 'json-parse' | 'schema-validate';
  path?: string;
  rawOutput?: string;
  attempt: number;
  cumulativeRetries: number; // leading indicator for model drift
}
```

**Naming clarification** (security reviewer's concern): the event lives in the `agent.*` domain (parallel to `agent.turn_end`), NOT `eval.*` — because "schema" is overloaded in agentfootprint and `output_schema` makes the scope unambiguous. Tool-input schema validation is a different concern handled at the provider layer.

Fires BEFORE PostDecide rules evaluate, so observability sees every validation failure even if a buggy rule routes to fail-fast or swallows it (must-fix #2). Payload includes `attempt` + `cumulativeRetries` for SRE dashboards (must-fix #3).

Total event count: 51 → 52.

#### Validation only fires on terminal turns (must-fix #1)

When the LLM returns `toolCalls.length > 0` (a tool-using turn, not a final answer), validation is skipped. Tool-call turns aren't terminal output; validating them would be premature and break the agent loop. This guard is enforced in `callLLM.ts`; consumers writing custom validators should mirror it.

#### Implementation

- **`src/adapters/types.ts`** — `LLMMessage.ephemeral` field; widened `PermissionChecker.check()` (was already widened in v2.12).
- **`src/reliability/types.ts`** — `ReliabilityScope.validationError` + `validationErrorHistory`; `ReliabilityRule.feedbackForLLM`.
- **`src/core/agent/stages/reliabilityExecution.ts`** — validation hook in retry loop; ephemeral feedback append via `applyFeedback` helper; `lastNValidationErrorsMatch` + `defaultStuckLoopRule` exports.
- **`src/core/agent/stages/callLLM.ts`** — `outputSchemaParser` dep; auto-builds `postValidate` hook from parser; passes through to `executeWithReliability`. Guards on `toolCalls.length === 0` (must-fix #1). Extracts `path` from Zod-style `.issues` when present.
- **`src/core/Agent.ts`** — passes `outputSchemaParser` through to `callLLM` deps when both reliability + outputSchema are configured.
- **`src/events/payloads.ts`** + **`src/events/registry.ts`** — `AgentOutputSchemaValidationFailedPayload`; new entry in `ALL_EVENT_TYPES` (count 51 → 52).
- **`src/reliability/index.ts`** — export `ValidationFailure`, `lastNValidationErrorsMatch`, `defaultStuckLoopRule`, `OutputSchemaValidator`.

#### Tests (16 new in `test/reliability/strict-output.test.ts` — full 7-pattern matrix)

| Pattern        | Coverage                                                                                                                                                                                           |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. Unit        | `lastNValidationErrorsMatch` (4 tests); `defaultStuckLoopRule` (2 tests)                                                                                                                           |
| 2. Scenario    | Model fails once → retry with feedback → succeeds (1 test)                                                                                                                                         |
| 3. Integration | `runTyped()` returns parsed value after retry (1 test); throws `ReliabilityFailFastError` when exhausted (1 test)                                                                                  |
| 4. Property    | Random fail counts 0..3 preserve dispatch invariant (1 test)                                                                                                                                       |
| 5. Security    | Throwing `feedbackForLLM` falls back to generic + run continues (1 test); ephemeral messages never leak to `scope.history` (1 test)                                                                |
| 6. Performance | 50 successful runs without validation fail under 5s (overhead bound, 1 test)                                                                                                                       |
| 7. ROI         | RefundBot stuck-loop guard fires before retry exhaustion (1 test); event payload carries the right fields (1 test); validation does NOT fire on tool-call turns (1 test, must-fix #1 verification) |

Running total: 1862/1862 tests across the suite.

#### Recipe + example

- **`examples/features/12-strict-output.ts`** — `strictOutputRules({maxRetries})` factory in user-land (~30 LOC); 3 scenarios (happy, retry-with-feedback, stuck-loop fail-fast).
- **`docs-site/src/content/docs/guides/strict-output.mdx`** — full recipe page using CodeFile region markers; explains why no library factory ships; composition order with reliability + outputFallback; streaming trade-off; anti-patterns including security concerns from the 7-panel review.

#### Backward compatibility

None broken. Existing v2.11.5 reliability rules work unchanged — the new `feedbackForLLM` field is optional and ignored when absent. Existing `outputSchema` consumers (parseOutput / runTyped) work unchanged — validation INSIDE the loop only happens when `reliability` is ALSO configured on the same agent.

#### Pattern locked in across 3 features

| Feature                                          | Library effort | Recipe                       |
| ------------------------------------------------ | -------------- | ---------------------------- |
| v2.11.6 `discoveryProvider` (async ToolProvider) | ~5 days        | docs/tool-discovery.mdx      |
| v2.12 `sequencePolicy` (sequence governance)     | ~2 days        | docs/sequence-governance.mdx |
| v2.13 `strictOutput` (Instructor-style retry)    | ~3 days        | docs/strict-output.mdx       |

Library extends primitives; consumers ship convenience layers; recipes in docs. Avoids API lock-in before real consumer patterns shape the right factory. If 5+ consumers ship the same factory shape over the next quarter, we promote to first-class library export in v3.

## [2.12.1]

### Fixed — 7-pattern test coverage backfill

Project rule: every release ships tests covering all 7 patterns of the matrix (unit · scenario · integration · property · security · performance · ROI). Pre-release reviews of v2.11.6 (`async-provider`) and v2.12 (`policy-halt`) found gaps in the property + performance + ROI columns. This patch release backfills them retroactively. **No source code changes; tests only.**

#### v2.11.6 backfill — `test/tool-providers/async-provider.test.ts` (+6 tests)

- **PROPERTY** — random sync/async/throw provider compositions hold dispatch-shape invariants (sync → non-Promise; async → Promise; sync-throw → throws; async-reject → rejects, drained safely)
- **PROPERTY** — random forbidden-pattern + random sequence runs never silently dispatch a denied tool
- **PERF (sync)** — `staticTools.list()` × 1000 < 250ms (zero-overhead claim, ~50µs/call)
- **PERF (sync)** — `gatedTools(staticTools, pred).list()` × 1000 < 300ms (decorator overhead bound)
- **PERF (async)** — 50 turns × 2 iterations dispatch never doubles `list()` calls (cache contract holds under load)
- **ROI** — Rube-style hub adapter end-to-end: TTL cache + AbortSignal + start/completed events + dispatch all wired together

#### v2.12 backfill — `test/security/policy-halt.test.ts` (+5 tests)

- **PROPERTY** — random safe-name sequences vs random dangerous-name patterns: no false-positive matches
- **PROPERTY** — random-prefix + dangerous-suffix sequences ALWAYS match their dangerous pattern
- **PERF** — `extractSequence(history)` over 1000-message history < 50ms
- **PERF** — `extractSequence` skipping synthetic denies in 1000-message history < 50ms
- **PERF** — sync `permissionChecker.check()` × 1000 < 300ms (overhead bound)

#### Process change

Going forward, every new feature release MUST hit all 7 patterns from the start. The pre-release 7-panel review now includes a Test/QA reviewer who audits the matrix and blocks release if any column is missing.

#### Tests

1846/1846 (1835 pre-backfill + 11 new). No source changes.

## [2.12.0]

### Added — sequence-aware PermissionChecker (the recipe primitive)

Single-call permission (v2.4) answers _"is this tool allowed?"_ in isolation. v2.12 enriches the check ctx so consumers can build sequence-aware governance — security (exfil chains), cost (wasteful patterns), correctness (idempotency caps) — over the SAME `PermissionChecker` interface, no new factory required.

**Pattern parallels v2.11.6 `discoveryProvider`:** the library extends a primitive, ships a recipe; consumers build the convenience layer in user-land. Avoids API lock-in before real usage shapes the right factory.

#### `PermissionRequest` enrichment (5 new fields)

```ts
interface PermissionRequest {
  // existing
  capability;
  actor;
  target?;
  context?;

  // NEW in v2.12
  sequence?: readonly ToolCallEntry[]; // dispatched calls so far this run
  history?: readonly LLMMessage[]; // full conversation
  iteration?: number; // current ReAct iteration
  identity?: { tenant?; principal?; conversationId };
  signal?: AbortSignal;
}
```

The framework derives `sequence` on demand from `scope.history` via `extractSequence()` — single source of truth, survives `agent.resumeOnError(checkpoint)` correctly. No parallel state in scope.

#### `PermissionDecision` extension — `'halt'` + `tellLLM` + `reason`

```ts
interface PermissionDecision {
  // existing
  result: 'allow' | 'deny' | 'gate_open';
  rationale?, policyRuleId?, gateId?

  // NEW in v2.12
  result: ... | 'halt';                   // terminates run via PolicyHaltError
  reason?: string;                        // telemetry tag (machine-readable)
  tellLLM?: ToolResultContent;            // LLM-facing synthetic tool_result
}
```

`'halt'` writes a synthetic tool_result (using `tellLLM`) to history BEFORE throwing — Anthropic / OpenAI tool_use ↔ tool_result protocol stays satisfied; conversation history is consistent for resume.

#### Default `tellLLM` is deliberately generic

Omitted `tellLLM` defaults to `"Tool '${name}' is not available in this context."` — NEVER falls back to `reason` (which is telemetry, e.g. `'security:exfiltration'`). Leaking the reason tag to the LLM teaches it the rule space; consumers who want a richer message provide `tellLLM` explicitly.

#### `PolicyHaltError` typed error

```ts
class PolicyHaltError extends Error {
  reason: string; // telemetry tag from rule
  tellLLM?: ToolResultContent;
  sequence: readonly ToolCallEntry[];
  iteration: number;
  history: readonly LLMMessage[];
  proposed: { name: string; args: unknown };
  checkerId?: string;
}
```

Parallel to `ReliabilityFailFastError`. Caller branches on `e.reason.startsWith('security:')` etc. for alert routing (PagerDuty / Slack / dashboard).

#### Strict halt ordering (audit-trail completeness)

When `{ result: 'halt' }` fires:

1. Synthetic tool_result appended to `scope.history`
2. `agentfootprint.permission.halt` event emitted
3. Stage commits (commitLog has the entry, runtimeStageId complete)
4. `scope.$break` propagates
5. `Agent.run()` catches at the API boundary, throws `PolicyHaltError`

If anything in the halt path throws, the audit trail is still committed before the run terminates. `agent.run()` exempts `PolicyHaltError` (and `ReliabilityFailFastError`, `PauseSignal`) from the auto-checkpoint wrapping so callers can `instanceof` the typed error directly.

#### One new typed event — `agentfootprint.permission.halt`

```ts
interface PermissionHaltPayload {
  checkerId?: string;
  target: string;
  reason: string;
  tellLLM?: string;
  iteration: number;
  sequenceLength: number;
}
```

Routes via existing `PermissionRecorder` bridge (no new bridge). `PermissionCheckPayload.result` widened to include `'halt'`; `PermissionCheckPayload.reason` field added for telemetry routing on the existing event. Event count: 50 → 51.

#### `extractSequence(history, iteration, options?)` exported helper

Pure function: walks history, returns `readonly ToolCallEntry[]` of dispatched calls in order. Filters out:

- Calls without a matching `tool` message (in-flight from current turn)
- Calls whose tool_result starts with `[permission denied:` (synthetic denies — never executed)

Optional `resolveProviderId(toolName) => string | undefined` for cross-hub policy matching (`'local'` for static tools; provider's `id` for `discoveryProvider` tools).

#### Implementation

- **`src/adapters/types.ts`** — `PermissionRequest` enriched; `PermissionDecision` widened with `'halt'` + `tellLLM` + `reason`; `PermissionChecker.check()` may return `Promise<Decision>` OR `Decision` (sync zero-overhead path); `ToolCallEntry` + `ToolResultContent` types added.
- **`src/security/PolicyHaltError.ts`** (new) — typed error class, `PolicyHaltContext` shape.
- **`src/security/extractSequence.ts`** (new) — pure helper, `SYNTHETIC_DENY_PREFIX` exported for consumer policies that want to filter their own.
- **`src/core/agent/stages/toolCalls.ts`** — pass enriched ctx to `permissionChecker.check()`; handle `'halt'` result with strict ordering (synthetic → event → commit → $break).
- **`src/core/agent/types.ts`** — `AgentState.policyHalt*` fields added.
- **`src/core/Agent.ts`** — halt translation in `finalizeResult()`; `PolicyHaltError` exempted from `RunCheckpointError` wrapping (parallel to `PauseSignal` / `ReliabilityFailFastError`).
- **`src/events/payloads.ts`** + **`src/events/registry.ts`** — `PermissionHaltPayload` + entry; `PermissionCheckPayload.result` widened; `PermissionCheckPayload.reason` field added; `ALL_EVENT_TYPES` count 50 → 51.

#### Tests (14 new in `test/security/policy-halt.test.ts`)

Enriched ctx (sequence + history + iteration + identity) / halt → PolicyHaltError with full context / halt without `tellLLM` defaults to safe generic (NEVER leaks `reason`) / `permission.halt` event / strict ordering (synthetic before throw) / `extractSequence` helper (skips synthetic denies, skips in-flight, custom providerId resolver) / async checker (Promise return) / no regression on `'allow'` / `'deny'` / sequence-aware user-land policies (forbidden-suffix + frequency-limit).

#### Recipe + example

- **`examples/features/11-sequence-policy.ts`** — `sequencePolicy({ forbidden, limits })` factory in user-land (~80 LOC); three scenarios (happy path, cost rule denies + LLM recovers, security rule halts via `PolicyHaltError`).
- **`docs-site/src/content/docs/guides/sequence-governance.mdx`** — full recipe page using CodeFile region markers; explains why no library factory ships (lock-in risk + cost-benefit); composition with `gatedTools`; anti-patterns; deny vs halt decision matrix.

#### Backward compatibility

None broken. Existing v2.4 `PermissionChecker` consumers work unchanged — the new fields are optional reads, the new result is opt-in. The `PermissionDecision.result` widening is a strict superset; existing return types still satisfy the new union.

## [2.11.6]

### Added — async ToolProvider for runtime tool discovery

`ToolProvider.list(ctx)` may now return EITHER `readonly Tool[]` (sync, the 99% case — `staticTools`, `gatedTools`, `skillScopedTools`) OR `Promise<readonly Tool[]>` (async, discovery-style providers backed by tool hubs / MCP registries / per-tenant catalogs). The agent runtime checks `result instanceof Promise` before awaiting, so sync providers pay zero microtask overhead.

```ts
const provider: ToolProvider = {
  id: 'rube',
  async list(ctx) {
    const response = await fetch('/api/tools', { signal: ctx.signal });
    return parseTools(await response.json());
  },
};

const agent = Agent.create({ provider: llm, model: 'claude-sonnet-4-5-20250929' })
  .toolProvider(provider)
  .build();
```

This is what unlocks Rube / Composio / Arcade / custom-hub adapters as user code over the existing `ToolProvider` abstraction — no library API additions required.

#### Type widening

`ToolProvider.list(ctx): readonly Tool[]` → `readonly Tool[] | Promise<readonly Tool[]>`. No code changes needed for existing sync providers; the sync return type is a strict subset of the new union.

#### `ToolDispatchContext.signal`

`ctx` carries the agent's `AbortSignal` (propagated from `agent.run({ env: { signal } })`). Async providers MUST honor it — when the agent run is cancelled, an in-flight catalog fetch should abort instead of holding the run open. Sync providers can ignore.

#### `agentfootprint.tools.discovery_failed` event

A throwing or rejecting provider emits the typed event with `{ providerId, error, errorName, iteration }` and re-throws. Discovery failure is loud by design — silently dropping tools mid-conversation produces non-deterministic agent behavior harder to debug than a crash. For graceful degradation, configure `.reliability(...)` to route discovery failures via retry / fallback / fail-fast.

#### One `list()` call per iteration

The Tools slot caches the resolved `Tool[]` in a closure shared with the toolCalls handler. When the LLM dispatches a tool from your provider, the handler reads from the cache instead of re-invoking `list()` — async providers pay the discovery cost once per turn, not twice. Fresh chart per `agent.run()` ensures concurrent runs don't share cache state.

#### Tools subflow split: Discover → Compose

The Tools slot subflow now exposes two stages instead of one, so async discovery is first-class observable in every recorder/trace surface:

```
sf-tools subflow:
  ├── Discover  ← own runtimeStageId, own InOutRecorder boundary,
  │              own narrative entry. Calls provider.list(ctx).
  │              Emits discovery_started → discovery_completed (or
  │              discovery_failed). When no toolProvider is set,
  │              early-returns in microseconds (no-op fast path).
  └── Compose   ← merges static + provider + per-skill schemas into
                  the slot. Reads providerToolCache.current populated
                  by Discover.
```

Why: with discovery + compose merged into one stage (the v2.5–v2.11.5 shape), async-discovery latency was indistinguishable from compose latency in the trace, the discovery had no dedicated `runtimeStageId` for KeyedRecorder lookups, and InOutRecorder showed one boundary instead of two. The split fixes all three. Sync providers pay zero extra cost — Discover early-returns when no provider is set, and the dynamic `instanceof Promise` check still skips await for sync provider returns.

Two new typed events round it out:

- **`agentfootprint.tools.discovery_started`** — `{ providerId, iteration }`. Fires before `provider.list(ctx)`.
- **`agentfootprint.tools.discovery_completed`** — `{ providerId, iteration, durationMs, toolCount }`. Fires after a successful `list()` resolution. Use the started→completed pair for per-iteration discovery latency.

`tools.discovery_failed` payload now also carries `durationMs` so timeouts are distinguishable from immediate rejections.

Event count: 48 → 50.

#### Implementation

- **`src/tool-providers/types.ts`** — widened `list()` return type; added `signal?: AbortSignal` to `ToolDispatchContext`.
- **`src/core/slots/buildToolsSlot.ts`** — split into Discover + Compose stages; dynamic `instanceof Promise` check (sync fast-path); typed `discovery_started` / `discovery_completed` / `discovery_failed` emits; `ProviderToolCache` written by Discover, read by Compose AND the toolCalls handler.
- **`src/core/agent/stages/toolCalls.ts`** — dispatch reads from `providerToolCache.current`, eliminating the second `provider.list(ctx)` call per iteration.
- **`src/tool-providers/gatedTools.ts`** — propagates async return through the decorator chain via `result instanceof Promise ? result.then(filter) : filter(result)`. A sync inner stays sync; an async inner stays async.
- **`src/recorders/core/ToolsRecorder.ts`** (new) — EmitBridge for `agentfootprint.tools.*`, parallel to `streamRecorder` / `skillRecorder`. Auto-attached in `Agent.run()`.
- **`src/events/payloads.ts`** + **`src/events/registry.ts`** — `ToolsDiscoveryStartedPayload` / `ToolsDiscoveryCompletedPayload` / `ToolsDiscoveryFailedPayload` + 3 entries in `ALL_EVENT_TYPES` (now 50).

#### Tests (15 new in `test/tool-providers/async-provider.test.ts`)

Sync path / async path / sync throw / async reject / signal abort / mixed sync+async chain / no double-discovery (cache contract) / concurrent agents (reentrancy) / discovery_started→discovery_completed ordering with timing / failed discovery emits started→failed (no completed) / no-provider agents emit zero discovery events.

#### Docs + example

- **`docs-site/src/content/docs/guides/tool-discovery.mdx`** — sync vs async contract, TTL caching pattern, signal propagation, failure semantics, concurrency notes.
- **`examples/features/10-discovery-provider.ts`** — `discoveryProvider({ hub, ttlMs })` over a generic `ToolHub` interface; three scenarios (happy + cache hit, cancellation, failure path).
- **`docs-site/src/content/docs/guides/observability.mdx`** — `tools.discovery_failed` listed in event taxonomy; event count bumped to 58.

#### Backward compatibility

None broken. Sync providers (`staticTools`, `gatedTools`, `skillScopedTools` and any custom sync provider) work unchanged. The widened `list()` return type is a strict superset; the new `ctx.signal` is optional. The cache eliminates a redundant `list()` call that was already correct under the v2.11.5 contract.

## [2.11.5]

### Added — reliability gate wired into Agent

The v2.11.1 reliability foundation (`CircuitBreaker`, `classifyError`, `ReliabilityConfig`, `ReliabilityFailFastError`, `buildReliabilityGateChart`) now has a consumer-facing surface inside `Agent`:

```ts
const agent = Agent.create({ provider, model: 'mock' })
  .system('You triage support tickets.')
  .reliability({
    postDecide: [
      {
        when: (s) => s.errorKind === '5xx-transient' && s.attempt < 3,
        then: 'retry',
        kind: 'transient-retry',
      },
      { when: (s) => s.error !== undefined, then: 'fail-fast', kind: 'unrecoverable' },
    ],
    circuitBreaker: { failureThreshold: 3 },
  })
  .build();

try {
  await agent.run({ message: 'help' });
} catch (e) {
  if (e instanceof ReliabilityFailFastError) {
    console.log(e.kind, e.reason, e.payload);
  }
}
```

#### Streaming + reliability semantics (first-chunk arbitration)

Streaming and retry don't compose cleanly — a stream that errors after token 5 either replays duplicates or has to buffer the whole stream first (losing progressive UX). LLM providers don't expose resume tokens or per-stream idempotency, so the conflict can't be solved at the boundary today.

agentfootprint adopts **first-chunk arbitration** (the same pattern LangChain uses in `RunnableWithFallbacks`):

- **Pre-first-chunk failures** — full rule set fires (retry, retry-other, fallback, fail-fast).
- **Post-first-chunk failures** — only `ok` and `fail-fast` are honored. Rules wanting retry/retry-other/fallback are escalated to fail-fast with `kind: 'mid-stream-not-retryable'`.

The consumer keeps streaming on or off as their own choice; reliability adapts. See the [reliability gate guide](https://footprintjs.github.io/agentfootprint/guides/reliability-gate/) for the industry-pattern comparison and design rationale.

#### Implementation

- **`src/core/agent/stages/reliabilityExecution.ts`** (new) — JS retry-loop helper invoked by `callLLM` when reliability is configured. Pure function over the LLMCallFn callback; reuses `CircuitBreaker.ts` admit/recordSuccess/recordFailure pure functions; reuses `classifyError` for `errorKind` taxonomy. Closure-local state (attempt, providerIdx, breakerStates, attemptsPerProvider) — closure not scope, because this loop runs WITHIN one footprintjs stage execution.
- **`src/core/agent/stages/callLLM.ts`** — refactored: extracted `singleProviderCall` so the SAME call function feeds both the unconfigured path (single-shot) and the reliability path (retry loop). Streaming chunk emission unchanged; added `onFirstChunk` hook for the arbitration boundary.
- **`src/core/agent/AgentBuilder.ts`** — new `.reliability(config)` method (throws on double-call).
- **`src/core/Agent.ts`** — new constructor parameter + private field; threaded through `buildCallLLMStage`. `finalizeResult` translates fail-fast scope state into typed `ReliabilityFailFastError` at the API boundary.
- **`package.json`** — `./reliability` subpath added to exports map (alongside existing `./security`, `./locales` etc.).

#### Tests + example

- **`test/core/agent-reliability.test.ts`** (new) — 5 integration tests via the public surface: happy path, retry success, post-decide fail-fast, pre-check fail-fast, double-builder rejection.
- **`examples/features/09-reliability-gate.ts`** (new) — three runnable scenarios (happy / retry / fail-fast) with `process.exit(1)` regression guards.
- **`test/core/reliability-gate-example.test.ts`** (new) — integration test wrapping the example so docs-page consumers stay aligned.
- Suite: **1806 / 1806 passing** (was 1805 before this release).

#### Documentation

- **`docs-site/src/content/docs/guides/reliability-gate.mdx`** (new) — design memo covering decision verbs, streaming semantics, industry comparison (Anthropic SDK / OpenAI SDK / LangChain `RunnableRetry` & `RunnableWithFallbacks` / LangGraph Pregel / Strands / LlamaIndex / Llama Stack), and composition with the v2.10.x reliability primitives.

#### Why this design

- **Loop-internal retry** (rather than chart-level loopTo subflow) preserves streaming, cost tracking, and the existing CallLLM event surface unchanged. Retry attempts are one stage execution; richer "every retry as a separate stage" tracing is available today via `buildReliabilityGateChart` for consumers composing raw `LLMCall + gate` patterns directly.
- **Closure state, not scope state** — the retry loop runs inside one footprintjs stage execution. Putting attempt/breakerStates into scope would commit them across iterations of the agent's outer ReAct loop, which is not the intent.
- **Reconstruct cause at the API boundary** — Error instances don't `structuredClone` cleanly through scope; we capture message+name as strings and rebuild the Error in `finalizeResult`. Consumers' `instanceof Error` checks still pass.

## [2.11.4]

### Fixed — actually fix non-null-assertion warnings in src (don't just disable)

v2.11.3 cleaned the CI by turning off `@typescript-eslint/no-non-null-assertion` globally. v2.11.4 walks back the global disable: re-enables the rule for `src/`, fixes each of the 30 source-side warnings either with a proper guard or with a targeted `eslint-disable-next-line` carrying a one-line "why this is safe" reason. Tests stay permissive (`!` is idiomatic in test assertions where the framework guarantees the value).

#### Refactored to proper guards (no `!` retained)

- **`src/recorders/observability/FlowchartRecorder.ts`** — 7 sites: `boundary.onRunStart!(e)` etc. → `boundary.onRunStart?.(e)`. Optional chaining is actually MORE correct because BoundaryRecorder methods are optional on the FlowRecorder interface; the previous `!` would have crashed if a wrapped recorder didn't implement every hook.
- **`src/patterns/SelfConsistency.ts`** — 4 sites in the merge function: `extract(results[id]!)`, `order[0]!`, `tallies.get(best)!`, `tallies.get(vote)!` → guarded by `if (value === undefined) continue`, explicit empty-results throw, and `?? 0` fallbacks.
- **`src/resilience/fallbackProvider.ts`** — 3 sites: `providers[0]!`, `providers[providers.length-1]!`, `providers[i]!` → explicit `head`/`tail` consts with throw-on-unreachable + `if (!cur) continue` loop guard.

#### Suppressed with `eslint-disable-next-line` + intent comment (legitimate post-conditions)

- **`src/adapters/llm/MockProvider.ts`** (2) — cursor bounds-checked above; signal-defined invariant inside onAbort.
- **`src/adapters/observability/otel.ts`** (2) + **`src/adapters/observability/xray.ts`** (2) — `idx >= 0` guard above + 1-element splice result.
- **`src/cache/strategyRegistry.ts`** (1) — `'*'` wildcard set at module load by registerDefaults.
- **`src/core/agent/buildToolRegistry.ts`** (1) — `skills.length > 0` guard left of ternary; assertion only fires on the truthy branch.
- **`src/lib/rag/indexDocuments.ts`** (1) — bounded by `i >= texts.length` early-return.
- **`src/memory/causal/loadSnapshot.ts`** (1) + **`src/memory/embedding/loadRelevant.ts`** (1) — `store.search` required when an embedder is configured (validated upstream by `defineMemory`).
- **`src/recorders/observability/commentary/commentaryTemplates.ts`** (1) — `hasDesc` boolean guarantees `desc` is a non-empty string.
- **`src/resilience/withCircuitBreaker.ts`** (1) — stream method conditionally defined only when `inner.stream` exists.
- **`src/resilience/withRetry.ts`** (1) — guarded by `if (provider.stream)`.
- **`src/strategies/attach.ts`** (1) — caller validates `onHandle` is set when `mode !== 'forget'`.
- **`src/stream.ts`** (1) — `queue.length > 0` guards the shift.

#### `.eslintrc.js`

- `@typescript-eslint/no-non-null-assertion`: `'warn'` (was `'off'` in v2.11.3) for src.
- Test file override now explicitly turns `no-non-null-assertion` off (idiomatic in test assertions).

#### Verification

- `npm run lint` — **0 problems** (was 365 in v2.11.2 → 0 in v2.11.3 via global disable → 0 in v2.11.4 via actual fixes).
- `tsc --noEmit` clean.
- Full suite: **1800 / 1800 passing**, no regressions.
- Release pipeline (8 gates) passes.

## [2.11.3]

### Fixed — CI lint pipeline cleaned to zero warnings

Per-commit CI lint job now passes cleanly (0 warnings) instead of surfacing 365 noisy GitHub Actions annotations on every push. The release script's gate was always tighter (`--max-warnings=99999` tolerated, fixed manually before tagging) — this release brings the per-commit CI in line so PRs and merges stay actionable.

#### Changes

- **`.eslintrc.js`** — turn off `@typescript-eslint/no-non-null-assertion`. 359 of the 365 warnings were this rule firing on idiomatic `!` usage in tests (asserting on values known to exist after a check) and source (post-condition guarantees inside well-typed maps, e.g., `registryByName.get(name)!` after we just put it in). The rule was being routinely ignored — same effective safety from `tsc` + tests; less GitHub annotation noise.
- **`src/events/dispatcher.ts`** — extracted `noopUnsubscribe` const for the already-aborted-signal path; lifts the inline `() => {}` to a named, JSDoc'd intent.
- **`src/memory/define.types.ts`** — `_T` phantom-type-parameter on `ReadonlyMemoryFlowChart<_T>` is intentional (lets consumers write `ReadonlyMemoryFlowChart<MyShape>` for documentation even though the brand erases at runtime); suppressed `no-unused-vars` with explanatory comment.
- **`src/reliability/buildReliabilityGateChart.ts`** — extracted `preContinueNoop` const for the PreCheck `'continue'` branch; lifts the inline `() => {}` to a named, JSDoc'd no-op (matches the rest of the file's pattern of named branch handlers).
- **`src/strategies/attach.ts`** — extracted `noopHostStage` for the detach-executor's host chart; updated `NOOP_UNSUBSCRIBE` to explicit `(): void => undefined`.
- **`src/strategies/compose.ts`** — added intent comment + lint-suppress on the `flush().catch(() => {})` swallow (passive-recorder discipline: flush errors don't propagate to consumer; recorder's own onError is the right channel).

#### Verification

- `npm run lint` — 0 problems (was 365 warnings).
- Full suite: **1800 / 1800 passing**, no regressions.
- `tsc --noEmit` clean.
- Release pipeline (8 gates) passes all gates.

#### What this is NOT

- **No public API changes.** All 7 modified files are either configuration or no-op extractions.
- **No behavior changes.** Lifting an inline `() => {}` to a named const, or swapping `() => {}` for `(): void => undefined`, produces identical runtime behavior.
- **No reliability wiring yet** — that lands in v2.11.4+ (the `buildAgentChart.ts` wiring + agent-builder `.withCircuitBreaker()`/`.withRetry()`/`.withFallback()` methods + `Agent.run()` error translation).

## [2.11.2]

### Refactored — Agent.ts decomposition complete

`core/Agent.ts` reduced from **2249 LOC → 710 LOC (−68%)** by extracting 11 focused files under `src/core/agent/`. **Public API surface is unchanged** — every external import site (28 of them) continues to work via re-exports from `Agent.ts`. Behavior is identical; this is a pure code organization release.

#### Files extracted to `src/core/agent/`

- **`types.ts`** — `AgentOptions`, `AgentInput`, `AgentOutput` (PUBLIC, re-exported from `Agent.ts`) + internal `AgentState`.
- **`validators.ts`** — `validateMemoryIdUniqueness`, `validateToolNameUniqueness`, `clampIterations`, `safeStringify`. Pure helpers, no class state.
- **`AgentBuilder.ts`** — full fluent builder class (547 LOC). Re-exported from `Agent.ts`.
- **`buildToolRegistry.ts`** — pure function composing the 3-source tool registry (static `.tool()` + auto-attached `read_skill` + skill-supplied tools). Handles autoActivate skill scoping + cross-source name uniqueness + same-Tool-reference dedupe across skills.
- **`buildAgentChart.ts`** — the FlowChart composition that wires every stage + slot subflow + memory subflow together. Takes a comprehensive `AgentChartDeps` interface enumerating all dependencies. The reliability gate chart (v2.11.1 foundation) wires into this file in v2.11.3+.
- **`stages/breakFinal.ts`** — terminates the ReAct loop ($break + return finalContent).
- **`stages/iterationStart.ts`** — emits per-iteration marker event.
- **`stages/route.ts`** — decider routing to 'tool-calls' or 'final'.
- **`stages/seed.ts`** — initial scope state. Factory takes `consumePendingResumeHistory` + `getCurrentRunId` accessors so the resume side-channel and current run id remain dynamic.
- **`stages/callLLM.ts`** — the LLM invocation. Factory takes provider/model/cache strategy/pricing. Streaming-first; falls back to `complete()` for the authoritative response.
- **`stages/toolCalls.ts`** — pausable tool-execution handler. Factory takes `registryByName` + optional `externalToolProvider` + optional `permissionChecker`.
- **`stages/prepareFinal.ts`** — captures turn payload for the final-branch subflow.

#### Pattern: factory functions take explicit deps

Every extracted stage that previously closed over `this.X` becomes a `build*(deps)` factory taking explicit dependencies as args. No `this` references in the extracted code; everything is testable in isolation. Per-run mutable accessors (e.g., `consumePendingResumeHistory` for the resumeOnError side-channel) are passed as closure functions so the dynamic behavior survives the move.

#### What's left in `Agent.ts` (710 LOC)

- Agent class declaration + 18 readonly fields (~150 LOC)
- Constructor (validates uniqueness, defaults cache strategy)
- Public methods (toFlowChart, getSpec, run, runOnce, resumeOnError, resume, parseOutputAsync, runTyped, getLastSnapshot, getLastNarrativeEntries)
- Private helpers (createExecutor + recorder attachment, finalizeResult, installCheckpointTracker, detectPause)
- `buildChart()` — now an ~80-line wire-up that captures `this.X` deps as locals, builds 4 slot subflows, builds 6 stage handlers via factories, calls `buildAgentChart()` and returns

#### Why this lands as its own release

1. **Atomic checkpoint.** The decomposition is a clean, behavior-preserving refactor that reviews independently of the v2.11.1 reliability foundation and the upcoming v2.11.3 wiring.
2. **De-risks the next step.** The reliability gate wiring (v2.11.3) touches `buildAgentChart.ts` (250 LOC) instead of a 2249-line monolith. Smaller blast radius, easier review, easier rollback.
3. **Sets the pattern for future subsystems.** Cache layer (v2.6) followed the same shape; reliability (v2.11.x), governance (planned), and any future cross-cutting concern should compose into `buildAgentChart.ts` rather than fight a giant `Agent.ts`.

#### Verification

- Full suite: **1800 / 1800 passing** (no regressions; same count as v2.11.1).
- `tsc --noEmit` clean.
- All 28 external import sites for `Agent`, `AgentBuilder`, `AgentInput`, `AgentOptions`, `AgentOutput` continue to work unchanged via `Agent.ts` re-exports.

#### Coming next (v2.11.3+)

- Wire the v2.11.1 reliability gate chart into `buildAgentChart.ts` via `addSubFlowChartNext('sf-reliability', gateChart)` between `IterationStart` and `CallLLM` when reliability is configured.
- Add agent-builder methods `.withRetry()` / `.withCircuitBreaker()` / `.withFallback()` to `AgentBuilder.ts`; each populates a unified internal `ReliabilityConfig`.
- Wire `Agent.run()` error translation: read `scope.reliabilityFailKind` from snapshot, throw `ReliabilityFailFastError` at the API boundary.
- Integration test exercising all three reliability modes through a real agent run.

## [2.11.1]

### Added — Reliability v2.11 internal foundation + Agent.ts decomposition (step 1)

Internal infrastructure for the rules-based reliability refactor flagged in v2.11.0's "Coming next" section. **Public API surface is unchanged** in this release — the foundation lands first as its own atomic checkpoint; wiring it into the Agent's chart lands in a follow-up patch once the Agent.ts decomposition is complete.

#### Reliability foundation (`src/reliability/`)

- **Multi-stage gate chart** built using footprintjs's native `decide()` DSL via `addDeciderFunction`. Shape: `Init → PreCheck (decider) → CallProvider → PostDecide (decider) → loopTo('pre-check')`. Branches that don't `$break()` fall through to the loopTo target → retry semantics; branches that `$break()` escape the loop with the appropriate scope state (success/failure).
- **`CircuitBreaker` as a pure state machine.** Refactored from a class with instance state to PURE FUNCTIONS (`admitCall`, `recordSuccess`, `recordFailure`, `initialBreakerState`) that take + return a serializable `BreakerState` record. State now lives in scope (round-trippable across gate invocations via inputMapper/outputMapper) instead of closure. Visible in commitLog; ready for v2.12 distributed-state via a future `BreakerStateStore` adapter.
- **`classifyError`** — pure function mapping any thrown error to a coarse `errorKind` taxonomy (`'5xx-transient'`, `'rate-limit'`, `'circuit-open'`, `'schema-fail'`, `'unknown'`) so rules match on a structured field rather than regexing on `error.message`.
- **`ReliabilityRule` / `ReliabilityScope` / `ReliabilityFailFastError` types** with full JSDoc on the three-channel discipline: scope state for runtime data (read by `Agent.run()` at the API boundary), `$emit` for passive observability (CloudWatch/X-Ray/OTel), `$break(reason)` for control flow + human narrative reason.
- **17 7-pattern tests** drive the gate chart end-to-end via real `FlowChartExecutor`, verifying retry, retry-other, fallback, and fail-fast semantics through the decider DSL. Tests pass in isolation; foundation is ready for wiring into the Agent chart in v2.11.2.

#### Agent.ts decomposition (step 1 of N)

Begin breaking up the 2249-LOC `core/Agent.ts`. Step 1 extracts the safe, dependency-free pieces using the **index-file pattern**: extracted modules live under `src/core/agent/`, and `Agent.ts` re-exports them so the 28+ existing import sites stay valid.

- **`src/core/agent/validators.ts`** — 4 pure helpers (`validateMemoryIdUniqueness`, `validateToolNameUniqueness`, `clampIterations`, `safeStringify`).
- **`src/core/agent/types.ts`** — both PUBLIC types (`AgentOptions`, `AgentInput`, `AgentOutput`) and INTERNAL `AgentState`. `Agent.ts` re-exports the public ones for back-compat.
- **`Agent.ts`: 2249 → 2006 LOC** (−243). Behavior unchanged.

Steps 2-N will extract the inline stage functions (seed, iterationStart, callLLM, route, toolCalls, breakFinal, updateSkillHistory, cacheGate) to `src/core/agent/stages/*.ts` and the chart composition to `src/core/agent/buildAgentChart.ts`. Each becomes a `build*(deps)` factory taking explicit dependencies — no `this` references in extracted code. Lands progressively in subsequent v2.11.x patches.

#### Verification

- Full suite: **1800 / 1800 passing** (1783 from v2.11.0 + 17 new reliability foundation tests).
- `tsc --noEmit` clean.
- Three-channel discipline locked into JSDoc as the canonical pattern for downstream subsystems.

#### Coming next (v2.11.2+)

- Complete the Agent.ts decomposition (extract 8 inline stages + chart composition).
- Wire the reliability gate chart into `buildAgentChart.ts` via `addSubFlowChartNext('sf-reliability', gateChart)` + a TranslateFailFast agent-level stage that translates the gate's `$break(reason)` into a typed `ReliabilityFailFastError` at the `Agent.run()` API boundary.
- Update existing builder methods (`.outputFallback()`, plus new `.withRetry()` / `.withCircuitBreaker()` / `.withFallback()` agent-builder methods) to populate the unified internal `ReliabilityConfig`. The existing standalone `withCircuitBreaker(provider, opts)` etc. functions in `agentfootprint/resilience` continue to work unchanged.

## [2.11.0]

### Added — Reliability subsystem documentation

Closes the docs/example gap noted during the v2.10.0 retrospective. v2.10.0 → v2.10.2 shipped the 3 reliability primitives; this minor release ships the unified docs + runnable example + integration test that the patch releases skipped.

- **`examples/features/08-reliability.ts`** — single runnable example covering all 3 reliability primitives end-to-end: `withCircuitBreaker` (vendor outage detection), `outputFallback` (3-tier degradation on schema failure), `resumeOnError` (mid-run failure recovery from JSON-serializable checkpoint). Three demo functions, isolated and copy-pasteable. With regression guards (`process.exit(1)` on any invariant violation).
- **`examples/features/08-reliability.md`** — companion explainer with the consumer-facing "what to copy" table.
- **`test/core/reliability-example.test.ts`** — integration test that imports `run()` from the example, asserts each of the 3 primitives engaged correctly, and pins the checkpoint shape via snapshot bounds. Catches silent example breakage so the docs page never lies.
- **`docs-site/src/content/docs/guides/reliability.mdx`** — new docs site page under Production Concerns sidebar group. Live-imports the example file via `<CodeFile path="..." />` so the docs snippet stays in sync with the runnable file. Covers all 3 primitives with state-machine diagrams, the per-instance vs distributed tradeoff for CircuitBreaker, the fail-open vs fail-closed tradeoff for outputFallback, and the tools-re-execute caveat for resumeOnError.
- **`docs-site/src/content/docs/index.mdx` updates** — "What ships today" list now mentions the Reliability subsystem with link to guide. "Roadmap" table updated through v2.11.0 with checkmarks for completed releases.
- **Sidebar entry** — "Reliability subsystem (v2.10)" added under Production Concerns.

Total project tests: **1783 / 1783 passing** (1781 from v2.10.2 + 2 new integration tests). Docs site builds clean (51 pages).

### Coming next

- **v2.11.1+** — Rules-based reliability refactor. Today's `withCircuitBreaker.shouldCount`, `withRetry.shouldRetry`, `withFallback.shouldFallback`, `outputFallback.fallback` are opaque predicate functions — invisible to the trace. v2.11.1 may refactor these to use footprintjs's `decide()` evidence-capture mechanism so every reliability decision lands in the narrative + commit log automatically (same pattern as the v2.6 cache layer's `CacheDecisionSubflow`). Design memo to follow.

## [2.10.2]

### Added — Reliability subsystem (part 3 of 3 — COMPLETE)

The Reliability subsystem ships its third and final piece. v2.10.0 was CircuitBreaker; v2.10.1 was outputFallback; v2.10.2 closes the trio with **fault-tolerant resume on error**.

- **`agent.resumeOnError(checkpoint)` + `RunCheckpointError` + auto-checkpoint at iteration boundaries.** Today's `agent.run()` throws on mid-run errors (LLM 500, vendor outage, tool throw, container restart) and the consumer must restart from scratch — losing every prior iteration's work. With this release, recoverable errors come wrapped in `RunCheckpointError` carrying a JSON-serializable checkpoint of the conversation history at the last completed iteration:

  ```ts
  import { Agent, RunCheckpointError } from 'agentfootprint';

  try {
    const result = await agent.run({ message: 'long task' });
  } catch (err) {
    if (err instanceof RunCheckpointError) {
      // Persist anywhere — Redis, Postgres, S3, queue, file.
      await checkpointStore.put(sessionId, err.checkpoint);

      // hours / restart / new process / next deploy later:
      const checkpoint = await checkpointStore.get(sessionId);
      const result = await agent.resumeOnError(checkpoint);
    } else {
      throw err; // non-recoverable — propagate
    }
  }
  ```

  **Three new exports** from the main barrel: `RunCheckpointError`, `AgentRunCheckpoint`, and `agent.resumeOnError(checkpoint, options?)`.

  **Auto-checkpoint at iteration boundaries** — the agent listens to its own `agentfootprint.agent.iteration_end` events and snapshots the conversation history into a per-run tracker. On error, the tracker's last snapshot is wrapped in `RunCheckpointError`.

  **Failure-phase classifier** — `RunCheckpointError.checkpoint.failurePoint.phase` is one of `'llm' | 'tool' | 'iteration' | 'unknown'`. Recognizes `CircuitOpenError` from v2.10.0, `AnthropicError` / `OpenAIError` / `BedrockError`. Goes straight into oncall postmortem queries.

  **Conversation-history checkpoint shape** — JSON-serializable, tiny payload, survives process restart. Tradeoff: tools inside the failed iteration **re-execute on resume**. For idempotent tools (read-only DB queries) this is fine; **for non-idempotent tools (charge card, send email) consumers MUST add their own idempotency keys**. Documented prominently. v2.10.3+ may add `toolCallId`-based dedup.

  **`AgentIterationEndPayload.history` field added** (optional, for back-compat).

  13 7-pattern tests covering happy path, error → checkpoint, end-to-end resume cycle, JSON round-trip, forward-compat version guard, missing-field validation, and failure-phase classifier. Total suite: **1781 / 1781 passing, 0 regressions.**

### Reliability subsystem complete

| Piece                    | Release | What it solves                                 |
| ------------------------ | ------- | ---------------------------------------------- |
| **`withCircuitBreaker`** | v2.10.0 | Vendor outage detection; fail-fast in <5µs     |
| **`outputFallback`**     | v2.10.1 | Schema-validation failure; 3-tier degradation  |
| **`resumeOnError`**      | v2.10.2 | Mid-run failure recovery; checkpoint + restart |

### Coming next

- **v2.11.0** — Reliability guide on docs site + runnable example covering all 3 primitives end-to-end + integration test with snapshots. Closes the docs/example gap noted in the v2.10.0 retrospective.

## [2.10.1]

### Added — Reliability subsystem (part 2 of 3)

- **`.outputFallback({ fallback, canned })` — 3-tier degradation for output-schema validation failures.** Pairs with `.outputSchema(parser)`. When the LLM's final answer fails schema validation, instead of throwing `OutputSchemaError` to the caller, the agent falls through:

  1. **Primary** — LLM emitted schema-valid JSON. Caller gets the parsed value.
  2. **Fallback** — async `fallback(error, raw)` runs; its return value is re-validated against the schema.
  3. **Canned** — static safety-net value (validated against the schema at builder time so it's _guaranteed_ to satisfy). When `canned` is set, the agent **NEVER throws** on output-schema failure — fail-open by construction.

  ```ts
  import { z } from 'zod';
  const Refund = z.object({ amount: z.number().nonnegative(), reason: z.string().min(1) });

  const agent = Agent.create({...})
    .system('You decide refund amounts.')
    .outputSchema(Refund)
    .outputFallback({
      fallback: async (err, raw) => ({
        amount: 0,
        reason: `manual review (LLM output: ${raw.slice(0, 200)})`,
      }),
      canned: { amount: 0, reason: 'unable to process — please retry' },
    })
    .build();

  // Caller never sees OutputSchemaError; gets a typed Refund either way.
  const refund = await agent.runTyped({ message: '...' });
  ```

  **Two typed events** fire on tier transitions for observability:

  - `agentfootprint.resilience.output_fallback_triggered` (tier 2 fired)
  - `agentfootprint.resilience.output_canned_used` (tier 3 fired — fallback also failed)

  **Builder-time `canned` validation** — the canned value is parsed against the schema at `.outputFallback({...})` time. Throws `TypeError` immediately if it doesn't satisfy. Misconfig surfaces in CI / dev, not at 3am when the fallback engages.

  **New method: `agent.parseOutputAsync<T>(raw)`** — async sister of `parseOutput`. Engages the fallback chain. The sync `parseOutput` stays back-compat — always throws on validation failure regardless of fallback config.

  **Fail-open vs fail-closed** is consumer choice:

  - With `canned` → agent NEVER throws on output failure (fail-open)
  - Without `canned` → if `fallback` throws or returns invalid value, the error propagates (fail-closed)

  13 7-pattern tests in `test/core/outputFallback.test.ts` covering all 3 tiers, builder-time validation, double-set guard, and event emission. Total suite: 1768 / 1768 passing, 0 regressions.

### Changed — `withCircuitBreaker` documentation

- **JSDoc note: per-instance scope, NOT distributed.** Each `withCircuitBreaker(...)` call holds its own breaker state in process memory. If you run 100 server replicas, each has its own independent breaker (matches Hystrix default). For cluster-wide coordination, layer your own Redis-backed counter via the `onStateChange` hook + `shouldCount` predicate. Surfaced after the 7-panel review on v2.10.0 — pure docs change, no API change.

### Coming next

- **v2.10.2** — `agent.resumeOnError(checkpoint)` + auto-checkpoint at iteration boundaries + `RunCheckpointError`. Reliability subsystem complete.
- **v2.11.0** — unified Reliability guide page on the docs site + runnable example covering all 3 reliability primitives + integration test.

## [2.10.0]

### Added — Reliability subsystem (part 1 of 3)

The Reliability subsystem was deferred from v2.5 → v2.6 → v2.7 → v2.8 → v2.9. It ships in three pieces — this release is the first.

- **`withCircuitBreaker(provider, options)` — Nygard-style circuit breaker decorator** under `agentfootprint/resilience`. Wraps any `LLMProvider`, tracks consecutive failures, and OPENS after `failureThreshold` failures. Once OPEN, calls fail-fast with `CircuitOpenError` (no network round-trip) until `cooldownMs` elapses. Then enters HALF-OPEN: probe calls run; `halfOpenSuccessThreshold` successes close the breaker; one failure re-opens it.

  ```ts
  import { anthropic, openai } from 'agentfootprint/llm-providers';
  import { withCircuitBreaker, withFallback } from 'agentfootprint/resilience';

  const provider = withFallback(
    withCircuitBreaker(anthropic({ apiKey }), {
      failureThreshold: 5, // open after 5 consecutive failures
      cooldownMs: 30_000, // stay open for 30s before probing
      halfOpenSuccessThreshold: 2, // need 2 probe successes to close
    }),
    withCircuitBreaker(openai({ apiKey })),
  );
  ```

  **Why this matters more than `withRetry`** — `withRetry` keeps hammering one provider with backoff during a multi-minute vendor outage. Each request burns 3 retries + backoff = ~3 sec of wasted latency before giving up to the fallback. Multiplied by your QPS, that's a lot of wasted time + tokens. The circuit breaker says "we just saw 5 failures in a row; stop calling for 30 seconds." Subsequent requests fail in <5µs, `withFallback` routes to OpenAI immediately.

  **Three states with explicit transitions:**

  ```
  CLOSED ──[ N consecutive failures ]──► OPEN
     ▲                                    │
     │                                    │ [cooldownMs elapsed]
     │                                    ▼
     └──[ M probe successes ]──── HALF-OPEN
  ```

  - **`shouldCount` predicate** — by default everything except `AbortError` counts toward the threshold. Override to ignore client errors (e.g., 4xx) so a malformed request doesn't trip the breaker for everyone.
  - **`onStateChange(state, reason)` hook** — fires on every transition. Wire to your observability stack (e.g., emit `agentfootprint.resilience.circuit_state_changed`).
  - **Streaming-aware** — `stream()` is decorated identically. A mid-stream error doesn't count toward the threshold (could be a content-filter trip on a single request); only stream failures BEFORE any chunk yields count.
  - **Composable** — wrap inside `withRetry` (per-attempt circuit check) or compose under `fallbackProvider` (which we recommend).

  **Performance:** OPEN-state rejection is sub-µs (10k rejections under 200ms in CI; <5µs/op on a hot core). The wrapped provider isn't called at all when OPEN — that's the whole point.

  12 7-pattern tests in `test/resilience/unit/withCircuitBreaker.test.ts` covering all state transitions (CLOSED → OPEN → HALF-OPEN → CLOSED, HALF-OPEN → OPEN), the `shouldCount` predicate, and composition with `withFallback`. Total suite: 1755 / 1755 passing, 0 regressions.

### Coming next — completing the Reliability subsystem

- **v2.10.1** — 3-tier `outputFallback(primary, fallback, canned)` for structured-output validation: when validation fails after maxIterations, fall through to a fallback output, then to a canned response. Different from provider fallback — this is about the SHAPE of the agent's final answer, not which LLM gets called.
- **v2.10.2** — `agent.resumeOnError(checkpoint)` + auto-checkpoint at iteration boundaries + `RunCheckpointError`. Today's pause/resume only handles intentional pauses (`askHuman`). With this, an LLM 500 mid-iteration throws `RunCheckpointError` carrying the last-known-good checkpoint, which the consumer can persist to Redis/queue/DB and resume hours/days later from a different process. Reliability subsystem complete.

## [2.9.0]

### Added

- **`otelObservability(opts)`** — OpenTelemetry distributed-tracing adapter under `agentfootprint/observability-providers`. The strategically biggest unlock since OTel-compat backends include the entire industry: **Honeycomb**, **Grafana Cloud / Tempo / Mimir**, **AWS Distro for OTel** (alternative to `xrayObservability`), **Datadog APM** via OTLP, **Splunk Observability Cloud**, **New Relic**, **Lightstep / ServiceNow Cloud Observability**, and any custom OTel collector pipeline.

  ```ts
  import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
  import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
  import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
  import { otelObservability } from 'agentfootprint/observability-providers';
  import { microtaskBatchDriver } from 'footprintjs/detach';

  // Set up OTel ONCE at app startup (BYO SDK + exporter).
  const provider = new NodeTracerProvider();
  provider.addSpanProcessor(
    new BatchSpanProcessor(
      new OTLPTraceExporter({
        url: 'https://api.honeycomb.io/v1/traces',
        headers: { 'x-honeycomb-team': process.env.HONEYCOMB_KEY },
      }),
    ),
  );
  provider.register();

  agent.enable.observability({
    strategy: otelObservability({ serviceName: 'my-agent' }),
    detach: { driver: microtaskBatchDriver, mode: 'forget' },
  });
  ```

  **BYO SDK contract** — this adapter only takes `@opentelemetry/api` (the small typed API surface) as an OPTIONAL peer dep. The consumer brings the OTel SDK + exporter package(s) for their backend. That's what makes the adapter portable across every OTel-compat destination — we never lock in a particular exporter.

  - **Hierarchical span mapping** — same shape as `xrayObservability`: `agent.turn_start` → root span; `iteration_start` → child; `llm_start` / `tool_start` → leaf children. OTel parent-context propagation via `trace.setSpan(context.active(), parent)`.
  - **OTel GenAI + Tool semantic conventions** — `gen_ai.request.model`, `tool.name`, `iteration.number`, `cost.cumulative_usd` attributes follow OTel semconv where applicable.
  - **Sampling** — `sampleRate` option for per-strategy span dropping (separate from OTel SDK Samplers).
  - **`tool_end` with error sets ERROR span status** (per OTel `SpanStatusCode.ERROR` convention).
  - **`stop()` is leak-safe** — defensively ends any in-flight spans on teardown.
  - **`flush()` is a no-op by design** — OTel SDKs handle their own flushing via `provider.forceFlush()`. Documented in JSDoc; consumer's responsibility on shutdown.

  15 7-pattern tests in `test/observability-providers/otel.test.ts` against a mock tracer. Total suite: 1743 / 1743 passing, 0 regressions.

### Changed

- **Datadog adapter deferred** — `datadogObservability` was on the v2.9 roadmap. Datadog APM accepts OTLP, so consumers can point their OTel SDK at Datadog's OTLP endpoint and `otelObservability` covers the Datadog use case end-to-end. We'll ship a dedicated `dd-trace`-based adapter only if real-world feedback demands the native Datadog APM client.

### Coming next

- **v2.10.0** — first `cost-providers` adapter (`stripeCost`).
- **v2.11.x** — Reliability subsystem (CircuitBreaker / 3-tier fallback / `resumeOnError`) — deferred since v2.5.
- **v2.12.x** — `lens-browser` / `lens-cli` (visual debugger backends).

## [2.8.3]

### Added

- **`xrayObservability(opts)`** — AWS X-Ray distributed-tracing observability adapter under `agentfootprint/observability-providers`. Maps agentfootprint's event taxonomy onto hierarchical X-Ray segment trees:

  ```
  agent.turn_start          ↦  root segment (one trace per turn)
  agent.iteration_start     ↦  push subsegment under root
  stream.llm_start          ↦  push leaf subsegment (model call)
  stream.tool_start         ↦  push leaf subsegment (tool call)
  ```

  Result in the X-Ray Trace Map: a hierarchical timeline of every agent run — turn → iteration → llm-call/tool-call — queryable in X-Ray Insights, joinable with the rest of your AWS distributed trace via `AWSTraceHeader` propagation.

  ```ts
  import { xrayObservability } from 'agentfootprint/observability-providers';
  import { microtaskBatchDriver } from 'footprintjs/detach';

  agent.enable.observability({
    strategy: xrayObservability({
      region: 'us-east-1',
      serviceName: 'my-agent-prod',
      sampleRate: 0.1, // 10% sampling — decisions made at turn_start
    }),
    detach: { driver: microtaskBatchDriver, mode: 'forget' },
  });
  ```

  - **Hierarchical segment management**: per-turn stack tracks active segments by `runId` (events for multiple in-flight turns interleave correctly). Defensive `popSegment` matches by name to survive out-of-order `_end` events (e.g., pause/resume mid-turn).
  - **Sampling**: decisions made at `turn_start` and persist for the whole turn — partial traces never reach X-Ray.
  - **Standard X-Ray segment shape**: `name`, `id` (16 hex), `trace_id` (`1-{8hex}-{24hex}` per spec), `parent_id`, `start_time` / `end_time` (unix seconds), `annotations` (queryable in X-Ray Insights), `metadata` (visible but not queryable).
  - **Annotations on segments**: `model` on llm segments, `toolName` on tool segments, `cumulativeCostUsd` from `cost.tick` events lands on the topmost active segment.
  - **Batching**: up to 25 segments per `PutTraceSegments` call (X-Ray hard caps at 50). Default 1s flush window for low-traffic agents.
  - **`flush()` is shutdown-safe**: force-closes any in-flight turn segments so partial traces ship on graceful shutdown.

  Peer dep `@aws-sdk/client-xray` declared as **optional** in `peerDependenciesMeta` — consumers who never call `xrayObservability(...)` don't need the AWS SDK in their lockfile. Lazy-required via `lib/lazyRequire.ts`.

  Unlike `cloudwatchObservability` and `agentcoreObservability` (both share the `_buildCloudWatchObservability` base), X-Ray is a fundamentally different shape (spans + parent/child + sampling) so it doesn't share that base.

  12 7-pattern tests in `test/observability-providers/xray.test.ts`. Total suite: 1728 / 1728 passing, 0 regressions.

### Coming next

- **v2.9.0** — `otelObservability` (industry-standard OpenTelemetry) + `datadogObservability` (most-requested commercial vendor).
- **v2.10.0** — first `cost-providers` adapter (`stripeCost`).

## [2.8.2]

### Added

- **`cloudwatchObservability(opts)`** — generic AWS CloudWatch Logs observability adapter under `agentfootprint/observability-providers`. Same SDK as `agentcoreObservability` but **without** AgentCore-specific defaults. Use when you're shipping agent telemetry to CloudWatch and not running inside Bedrock AgentCore (most common case).

  ```ts
  import { cloudwatchObservability } from 'agentfootprint/observability-providers';
  import { microtaskBatchDriver } from 'footprintjs/detach';

  agent.enable.observability({
    strategy: cloudwatchObservability({
      region: 'us-east-1',
      logGroupName: '/myapp/agent-prod',
      logStreamName: `${process.env.HOSTNAME}/${Date.now()}`,
    }),
    detach: { driver: microtaskBatchDriver, mode: 'forget' },
  });
  ```

  Same peer dep + lazy-require contract as `agentcoreObservability`: `@aws-sdk/client-cloudwatch-logs` is declared **optional** in `peerDependenciesMeta`. Consumers who never call this factory don't need the AWS SDK in their lockfile. Bundlers don't pull the SDK into builds that never use the adapter.

  9 7-pattern tests in `test/observability-providers/cloudwatch.test.ts`. Total suite: 1716 / 1716 passing, 0 regressions.

### Changed

- **`agentcoreObservability` refactored to thin-wrap `cloudwatchObservability`'s shared base.** Both adapters now share one CloudWatch Logs hot-path — improvements (retry, sequence-token handling, metric emission) flow to every CloudWatch-shaped adapter automatically. Behavior-preserving: all 11 existing `agentcoreObservability` tests pass unchanged. The only observable difference between the two adapters is `strategy.name` (`'agentcore'` vs `'cloudwatch'`) — used for registry-lookup and diagnostics.

  Public API for `agentcoreObservability` is unchanged. `AgentcoreObservabilityOptions` is now a type alias for `CloudwatchObservabilityOptions` — kept as a separate type so future AgentCore-specific options (e.g., `agentcoreSessionId` propagation) can be added without a breaking change.

### Coming next

- **v2.8.3** — `xrayObservability` (AWS distributed tracing). Different SDK (`@aws-sdk/client-xray`), different shape (spans not log events), so won't share the CloudWatch base.
- **v2.9.x** — `otelObservability` + `datadogObservability`.

## [2.8.1]

### Added

- **`agentfootprint/observability-providers` — new grouped subpath for vendor observability strategies.** Follows the parallel-providers pattern v2.5 established for `llm-providers` / `tool-providers` / `memory-providers`. Future vendor adapters add an export here, NOT a new subpath — keeps `package.json#exports` from sprawling.

  Ships with one adapter:

  - **`agentcoreObservability(opts)`** — AWS Bedrock AgentCore observability adapter. Ships every `AgentfootprintEvent` to **CloudWatch Logs** in a structured-JSON shape AgentCore's hosted-agent telemetry layer understands. Buffers in `exportEvent` (sync + non-throwing); drains in `flush()` (async batch). Default flush window: 1s OR 10 KB, whichever first.

  ```ts
  import { agentcoreObservability } from 'agentfootprint/observability-providers';
  import { microtaskBatchDriver } from 'footprintjs/detach';

  agent.enable.observability({
    strategy: agentcoreObservability({
      region: 'us-east-1',
      logGroupName: '/agentfootprint/my-agent',
      logStreamName: `${process.env.HOSTNAME}/${Date.now()}`,
    }),
    detach: { driver: microtaskBatchDriver, mode: 'forget' },
  });
  ```

  Peer dep: `@aws-sdk/client-cloudwatch-logs` (declared as **optional** via `peerDependenciesMeta.{name}.optional = true` — only consumers who actually call `agentcoreObservability(...)` need to install it). Lazy-required via `lib/lazyRequire.ts` so bundlers don't pull the AWS SDK into builds that never use the adapter.

  `_client` test injection escape hatch lets tests skip the SDK require entirely. 11 7-pattern tests in `test/observability-providers/agentcore.test.ts`.

### Fixed

- **Roadmap JSDoc in `src/strategies/index.ts` corrected.** v2.8.0 ship notes mistakenly listed the per-vendor subpath naming (`agentfootprint/observability-agentcore`, `observability-cloudwatch`, etc.) — same anti-pattern v2.5 fixed for memory adapters when collapsing 6+ per-vendor subpaths into `memory-providers`. Now lists the correct grouped subpaths: `observability-providers`, `cost-providers`, `lens-providers`. Pure docs change; no code surface affected.

### Coming next

- **v2.8.2** — `cloudwatchObservability` (the same SDK without AgentCore-specific log-group conventions).
- **v2.8.3** — `xrayObservability` (AWS distributed tracing).
- **v2.9.x** — `otelObservability` + `datadogObservability` (industry-standard backends).

All future vendor adapters land under the existing `agentfootprint/observability-providers` subpath — no new subpaths.

## [2.8.0]

### Added

- **Detached observability via `footprintjs/detach` — `enable.observability(...)` and `enable.cost(...)` now accept an opt-in `detach` option** that schedules the strategy's hot-path call (`exportEvent` / `recordCost`) onto a [footprintjs detach driver](https://footprintjs.github.io/footPrint/guides/patterns/detach/) instead of running it inline. The agent loop returns immediately; exports flush on the driver's schedule. Sync inline behavior is unchanged when the option is omitted — full back-compat for every existing consumer.

  Three semantics:

  - `detach: { driver, mode: 'forget' }` — discard the handle. Pure fire-and-forget telemetry. (Default when `mode` omitted.)
  - `detach: { driver, mode: 'join-later', onHandle: (h) => ... }` — driver returns a `DetachHandle`; we deliver it to your callback so you can `await` later (graceful shutdown, tests, backpressure).
  - omitted (default) — sync inline, same as v2.7.x and earlier.

  ```ts
  import { microtaskBatchDriver, flushAllDetached } from 'footprintjs/detach';

  agent.enable.observability({
    strategy: datadogExporter(...),
    detach: { driver: microtaskBatchDriver, mode: 'forget' },
  });

  // Graceful shutdown:
  process.on('SIGTERM', async () => {
    const stats = await flushAllDetached({ timeoutMs: 10_000 });
    process.exit(stats.pending === 0 ? 0 : 1);
  });
  ```

  Pick a driver by environment: `microtaskBatchDriver` (default cross-runtime), `setImmediateDriver` (Node), `setTimeoutDriver` (cross-runtime, configurable delay), `sendBeaconDriver` (browser, survives page-unload), `workerThreadDriver` (CPU-isolated). All from `footprintjs/detach`.

  `enable.thinking` and `enable.lens` deliberately **stay sync** — UI/debugger render must feel responsive and can't be deferred to next microtask.

  9 new 7-pattern tests in `test/strategies/detach-integration.test.ts` (Unit / Boundary / Scenario / Property / Security / ROI). Total suite now 1696 passing, 0 regressions. New runnable example: `examples/features/06-detached-observability.ts`.

### Changed

- **footprintjs peer-dep bumped to `>=4.17.1`** (was `>=4.14.0`). The `detach` option requires the `footprintjs/detach` subpath shipped in 4.17.0 and the publish-pipeline fix shipped in 4.17.1.

## [2.7.3]

**Design memo: `strategy-everywhere.md` — AWS-first vendor adapter
roadmap for v2.8+.**

The v2.6 cache layer proved out a pattern: one DSL, N vendor
strategies, side-effect-import auto-registration, wildcard fallback.
Sonnet Dynamic ReAct dropped 36,322 → 6,535 input tokens (−82%) end
to end. v2.8+ generalizes this as the universal architectural pattern.

This release adds the design memo only — no code changes, no API
surface changes. Implementation lands in v2.8.0+ across separate
minors per vendor adapter.

### What the memo covers

- **Pattern lineage**: Strategy Pattern (GoF) + Bridge + Hexagonal +
  Provider model (.NET) + Algebraic effects (Plotkin/Pretnar). Same
  architectural shape, 5 names.
- **4 groups in scope for v2.8**: `enable.observability`, `enable.cost`,
  `enable.liveStatus`, `enable.lens` — each gets a strategy slot.
- **AWS-first adapter priority**: builds on the existing
  `memory-agentcore` peer-dep precedent. v2.8.1 ships
  `observability-agentcore` (AWS Bedrock AgentCore Observability —
  same SDK consumers already imported for memory). v2.8.2 ships
  `observability-cloudwatch`. v2.8.3 ships `observability-xray`.
  Non-AWS adapters (OTel, Datadog, Pino) follow in v2.9.x.
- **Locked-in design decisions** from a 7-expert panel review (AWS
  IAM, Datadog, OTel, Stripe, Vercel, React, Anthropic): discriminated
  union options, idempotent stop, tier knob with cost-of-on docs,
  sample-rate, dry-run mode for audit, zero-arg defaults, dev/prod
  auto-detect, `compose([...])` combinator.
- **Migration plan**: v2.8.0 additive; v3.0 removes deprecated flat
  `enable.thinking` / `enable.logging` / `enable.flowchart`.
- **Approval gates** before v2.8.0 implementation: strategy interface
  signatures locked, 1 vendor adapter prototyped end-to-end (suggest
  AgentCore as the first), mock-strategy contract test,
  performance baseline (`compose([...])` of 5 children must add ≤ 5%
  overhead).

### Files

- `docs/inspiration/strategy-everywhere.md` (canonical)
- `docs-site/src/content/docs/inspiration/strategy-everywhere.mdx` (mirrored)
- `docs-site/astro.config.mjs` (sidebar entry)
- `docs/inspiration/README.md` (index updated — third pillar after
  Palantir/Liskov: "the scaling spine")

No code change. 1630/1630 tests pass.

## [2.7.2]

**Docs + example for the `agentfootprint/status` subpath.**

The v2.7.0 subpath shipped without a runnable example. v2.7.2 adds:

- **`examples/features/06-status-subpath.ts`** — runnable end-to-end
  example. Subscribes to `'*'` (the global wildcard), feeds events to
  `selectThinkingState`, renders via `renderThinkingLine` with
  per-tool template overrides. Same path Neo's chat-bubble feed uses.
  Now part of the CI sweep — future regressions in the subpath get
  caught before release.
- **`examples/features/06-status-subpath.md`** — companion guide.
  Explains the state machine, the renderer, built-in template vars
  (`{{appName}}` / `{{toolName}}` / `{{toolCallId}}` / `{{partial}}` /
  `{{question}}`), and where consumers need to walk events directly
  for arg-aware templates.
- **README — "Chat-bubble status surface" bullet** in "What ships
  today", linking the high-level `enable.thinking` and low-level
  `agentfootprint/status` paths so consumers see both.

No code change. Tests still 1630/1630.

## [2.7.1]

**Docs fix: `'agentfootprint.*'` is NOT a valid wildcard pattern.**

Four docs incorrectly told consumers to subscribe via
`agent.on('agentfootprint.*', listener)`:

- `CLAUDE.md` line 429
- `AGENTS.md` line 429
- `docs-site/.../debug.mdx` line 12
- `ai-instructions/claude-code/SKILL.md` line 371

The `EventDispatcher` only accepts:

| Pattern                       | Match                                                                                                                             |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `'*'`                         | every event                                                                                                                       |
| `'agentfootprint.<domain>.*'` | every event in one domain (15 domains: `agent`, `stream`, `context`, `tools`, `memory`, `cost`, `error`, `pause`, `embedding`, …) |
| Specific type                 | one event                                                                                                                         |

`'agentfootprint.*'` (just the namespace, no domain) silently matches
nothing — the dispatcher's wildcard table doesn't include it. TypeScript
catches it via `WildcardSubscription`, but consumers using `as never`
casts (or following these docs verbatim) hit silent zero-match: agent
runs, no events fire on the listener, chat UIs stay frozen on initial
state.

This bit a real consumer (Neo's chat-feed status bubble) — the
listener subscribed via the broken pattern, no events arrived, the
bubble stayed stuck on "Getting started…" through the entire run even
though the agent completed successfully and Lens received its events
through a different (correct) path.

### Fix

All 4 docs updated to:

- Recommend `'*'` for global subscription
- Document `'agentfootprint.<domain>.*'` for per-domain
- Explicitly call out that `'agentfootprint.*'` is invalid

No code change. No behavior change. Tests still 1630/1630.

## [2.7.0]

**New `agentfootprint/status` subpath** — chat-bubble status surface.

Tiny addition (one re-export file + one `package.json` exports entry)
that brings the thinking-state primitives in line with the rest of the
library's subpath organization:

| Subpath                           | What's in it                                                             |
| --------------------------------- | ------------------------------------------------------------------------ |
| `agentfootprint/observe`          | BoundaryRecorder, StepGraph, FlowchartRecorder                           |
| `agentfootprint/locales`          | composeMessages, validateMessages, defaultThinkingMessages               |
| `agentfootprint/status` ← **new** | selectThinkingState, renderThinkingLine, defaultThinkingTemplates, types |
| `agentfootprint/tool-providers`   | staticTools, gatedTools, …                                               |

### Why

Consumers building chat UIs / status indicators / Lens-style live
panels can now opt-in explicitly:

```typescript
// Before (still works — back-compat preserved)
import { selectThinkingState, renderThinkingLine } from 'agentfootprint';

// After (preferred for new code)
import { selectThinkingState, renderThinkingLine } from 'agentfootprint/status';
```

The import line is self-documenting (matches `agentfootprint/observe`
and `agentfootprint/locales` naming). Bundler tree-shaking is more
explicit. Future extended-thinking primitives (Anthropic
`thinking_delta` / `redacted_thinking`) will land here too without
inflating the main entry.

### What's exported

- `selectThinkingState(events)` — derive current state (idle / tool /
  streaming / paused / null) from the typed event log
- `renderThinkingLine(state, templates, ctx)` — resolve template +
  substitute vars to a final string
- `defaultThinkingTemplates` — bundled English defaults
- `type ThinkingTemplates` / `ThinkingState` / `ThinkingStateKind` /
  `ThinkingContext`

### Migration

Zero breaking changes. Main `agentfootprint` exports unchanged. New
code uses the subpath; old code keeps working indefinitely.

## [2.6.4]

**Fix: v2.6 cache-layer subflows leaked as fake user-visible steps in
the StepGraph.** When v2.6 introduced `CacheDecisionSubflow` (with
local id `sf-cache-decision`) and the `CacheGate` decider (stage id
`cache-gate`), neither was registered in `BoundaryRecorder`'s
`AGENT_INTERNAL_LOCAL_IDS` set. Result: every iteration of an agent
emitted `subflow.entry` / `subflow.exit` / `decision.branch` events
that weren't tagged `isAgentInternal: true`, so `FlowchartRecorder`
projected them as user-facing `StepNode`s. A 5-iteration run showed
~30 nodes instead of ~14 — every iter contributed 3 fake steps the
user had to scrub past. Same issue (pre-existing) for
`SUBFLOW_IDS.INJECTION_ENGINE`.

### Fix

Three ids added to `AGENT_INTERNAL_LOCAL_IDS` in
`src/recorders/observability/BoundaryRecorder.ts`:

```ts
SUBFLOW_IDS.INJECTION_ENGINE,   // pre-existing oversight
SUBFLOW_IDS.CACHE_DECISION,     // v2.6
STAGE_IDS.CACHE_GATE,           // v2.6 (decider stage id)
```

Plus a comment block warning future contributors: when adding a new
subflow to the Agent's internal flowchart, decide whether it's a
context-engineering moment (leave OUT — it should be a user-visible
step) or pure plumbing (add HERE — it's wiring, not a step).

### Regression guard

New test `test/recorders/observability/internal-ids-coverage.test.ts`
enumerates `SUBFLOW_IDS` and asserts every entry is categorized as
either a slot subflow OR an agent-internal id. The next time someone
adds a new entry to `SUBFLOW_IDS` without categorizing it, the test
fails by NAME so the bug is caught before it leaks into Lens.

### Verified

In the Neo MDS triage browser app (1630/1630 tests passing, lens dist
unchanged):

- 5-iteration run, before fix: 30+ visible step-graph nodes
- 5-iteration run, after fix: 14 nodes (1 Run + per-iter LLM/tool steps + final llm→user)

## [2.6.3]

**README rewrite + new `Inspiration` section in docs/site.** Three docs
moves bundled together:

1. **README rewrite** — leads with the abstraction-lineage framing
   (PyTorch autograd / Express / Prisma / Kubernetes / React → agentfootprint
   for context engineering). Same kind-of-move applied to a new domain.
   The hand-rolled vs declarative code comparison is now the visual hook;
   the differentiator section ("the trace is a cache of the agent's
   thinking") names the unique IP claim.

2. **New "Why it's shaped this way — two pillars" section** in the README.

   - **THE WHY (user-visible win):** Palantir's 2003 thesis applied to
     agent runtime — connect the four classes of agent data (state,
     decisions, execution, memory) so the next token compounds the
     connection instead of paying for it again.
   - **THE HOW (engineering discipline):** Liskov's ADT + LSP work, applied
     to flowcharts. Every framework boundary is LSP-substitutable.
     Subflows are CLU clusters. Locality of reasoning enforced as a
     runtime invariant.

3. **New `docs/inspiration/` section + matching `docs-site/inspiration/`**:
   - `README.md` (index) explaining the two-pillar structure
   - `connected-data-palantir.md` — full Palantir thesis → agentfootprint
     mapping; the four classes of agent data; where we go beyond Palantir
     (emergent vs pre-built ontology)
   - `modularity-liskov.md` — CLU clusters → subflows; LSP examples
     (CacheStrategy / LLMProvider / ToolProvider); locality of reasoning
     → operationalized; where we extend beyond classical Liskov
   - New "💡 Inspiration" sidebar section in the docs site between
     Architecture and Reference

Plus accuracy fixes uncovered during README verification:

- Provider count: 6 → **7** (Anthropic, OpenAI, Bedrock, Ollama,
  Browser-Anthropic, Browser-OpenAI, Mock)
- "47 typed events" → **48+ typed events** (recounted via grep)
- Strengthened the "frameworks that compose state per-node can't recompute
  cache markers in lockstep" claim about other frameworks (less
  combative phrasing, same defensible point)

No code change. 1627/1627 tests pass.

## [2.6.2]

**Docs: tool-dependency framing for Dynamic ReAct + remove application-specific
references.** Two unrelated docs cleanups bundled together:

1. **README — sharper rule for when to use Dynamic ReAct.** The previous
   benchmark-heavy section (4 sub-sections, multi-model token tables,
   parallelization caveats) led with the wrong heuristic ("30+ tools across
   8+ skills"). Replaced with the clearer rule: **use Dynamic ReAct when
   your tools have dependencies — when one tool's output implies which tool
   to call next.** Skills encode that workflow. If tools are independent
   and order doesn't matter, Classic is fine. The side-by-side example +
   "what Dynamic gives you that Classic doesn't" list is preserved; the
   noisy benchmark tables are gone.

2. **Removed all application-specific references.** Earlier docs referred
   to "Neo" (a Cisco MDS Fibre Channel triage agent used internally for
   benchmarking) by name. Generic phrasing now: "production-shaped Skills
   agent (10 skills, 18 tools after dedup)." Affected: README.md,
   CHANGELOG.md (2.6.0 + 2.5.0 entries), docs/guides/caching.md,
   examples/dynamic-react/README.md.

No code change. 1627/1627 tests still pass.

## [2.6.1]

**Lint cleanup + release-pipeline hardening.** v2.6.0 shipped with three
trivial eslint errors (`prefer-const`, `no-inferrable-types`) in cache
files and pre-existing test files. The release script's 8 gates didn't
include lint — only docs / format / build / tests / examples — so the
errors slipped through. Two-part fix:

1. **Source fix** — auto-applied via `eslint --fix`. Three lines changed
   across `src/core/Agent.ts`, `test/core/agent-toolprovider.test.ts`,
   and `test/recorders/contextEngineering.test.ts`. No behavior change.
2. **Process fix** — added Gate 2.85 to `scripts/release.sh`:
   `npm run lint --max-warnings=99999`. Errors fail the gate; warnings
   tolerated for now (334 pre-existing non-null-assertion warnings need
   a separate cleanup pass).

Net: all 1627 tests still pass; CI is green; future releases can't
ship with eslint errors.

## [2.6.0]

**Provider-agnostic prompt caching.** Dynamic ReAct repeats the same
stable prefix (system prompt + tool schemas + active skill body) on
every iteration. Without caching, every iter pays full price for that
duplicated context. v2.6 introduces a unified DSL — `cache:` policy on
each injection flavor — over per-provider strategies, so the right
cache hints land on the wire automatically.

### What's new

- **CacheDecision subflow** walks `activeInjections` each iteration,
  evaluates each injection's `cache:` directive, and emits a
  provider-agnostic `CacheMarker[]`.
- **CacheGate decider** uses footprintjs `decide()` with three rules —
  kill switch (`cachingDisabled`), hit-rate floor (skip when recent
  hit-rate < 0.3), and skill-churn (skip when ≥3 unique skills in the
  last 5 iters). Decision evidence captured for free.
- **5 cache strategies** (auto-registered via side-effect imports):
  - `AnthropicCacheStrategy` — manual `cache_control` on system blocks
    (4-marker clamp; surfaces `cache_creation_input_tokens` +
    `cache_read_input_tokens`)
  - `OpenAICacheStrategy` — pass-through (auto-cache); extracts
    `prompt_tokens_details.cached_tokens` for metrics
  - `BedrockCacheStrategy` — model-aware: Anthropic-style hints when
    modelId matches `^anthropic\.claude`, pass-through otherwise
  - `NoOpCacheStrategy` — wildcard fallback for unknown providers
  - Future: `GeminiCacheStrategy`
- **Per-flavor defaults** (overridable on each `defineX(...)`):
  - `defineSteering` → `'always'`
  - `defineFact` → `'always'`
  - `defineSkill` → `'while-active'`
  - `defineInstruction` → `'never'`
  - `defineMemory` → `'while-active'`
- **`cacheRecorder()`** — high-level observability; dump after a run
  for gate decisions + total markers emitted.
- **`Agent.create({ caching: 'on' | 'off' })`** — top-level kill switch
  (defaults to `'on'`).

### Validated on a production-shaped Skills agent

Same task, same scenario, against the live Anthropic API on a
10-skill / 18-tool agent:

| Mode (Sonnet 4.5)                    | cache=off  | cache=on   | Δ        |
| ------------------------------------ | ---------- | ---------- | -------- |
| Classic (no skill markdown)          | 40,563     | (untested) | —        |
| Static (all skill markdowns stuffed) | ~140,000   | 7,640      | **−95%** |
| **Dynamic (smart gating)**           | **28,404** | **6,535**  | **−77%** |

Cross-model Dynamic cache=on results:

| Model      | cache=off | cache=on   | Δ    |
| ---------- | --------- | ---------- | ---- |
| Sonnet 4.5 | 36,322    | **6,535**  | −82% |
| Haiku 4.5  | 36,309    | **13,637** | −62% |
| Opus 4.5   | 28,477    | **10,745** | −62% |

### Strategic implication

Pre-v2.6 the only economically sane Dynamic ReAct shape was smart
gating — bind tools and skill markdowns conditionally per iter.
Post-v2.6 you have a real second option: **stuff-and-cache** (put every
skill markdown into the system prompt always, let the cache layer carry
the cost). Both patterns are now first-class. Pick based on your team's
preferences, not on token cost alone.

### Migration

Zero breaking changes. Existing agents get caching for free if they use
Anthropic, Bedrock-Claude, or OpenAI providers. Disable explicitly with
`Agent.create({ caching: 'off' })`.

### Tests / Docs

- +66 tests in `test/cache/` (1627/1627 pass)
- New guide: [docs/guides/caching.md](docs/guides/caching.md) — Caching
  in 60 seconds + per-strategy reference + custom-strategy authoring
  template

## [2.5.1]

**Bug fix release.** v2.5.0 shipped with a single-line bug in the
`Agent.buildChart` InjectionEngine subflow mount: the `outputMapper`
was missing `arrayMerge: ArrayMergeMode.Replace`. Default footprintjs
behavior CONCATENATES arrays from child to parent, so each iteration's
`activeInjections` accumulated instead of replacing. Effect:
8 → 16 → 24 → 32 → 40 → 48 cumulative injections per turn instead
of the intended ~8-per-iter. The 8 always-on injection bodies were
duplicated 5× into the system prompt at iter 5, ballooning Dynamic
ReAct's input-token cost.

### The fix

One line added to the InjectionEngine subflow mount in `Agent.ts`:

```ts
arrayMerge: ArrayMergeMode.Replace,
```

Same fix that was already present on the SystemPrompt / Messages /
Tools subflow mounts. The InjectionEngine mount was missed in v2.5.0.

### Empirical impact (real Anthropic benchmark, 3 models × 2 modes)

| Model      | Dynamic in (v2.5.0) | Dynamic in (v2.5.1) |        Δ |
| ---------- | ------------------: | ------------------: | -------: |
| Haiku 4.5  |              62,571 |              36,341 | **−42%** |
| Sonnet 4.5 |              44,621 |              28,486 | **−36%** |
| Opus 4.5   |              44,590 |              28,401 | **−36%** |

Same scenario, same scripted answers, same iteration count. The
~36–42% drop is purely the system prompt no longer being duplicated.

### Regression tests

Three new tests in `test/core/dynamic-react-loop.test.ts` assert
bounded per-iteration injection counts:

- `activeInjections` ≤ 4 across 5 iterations
- `systemPromptInjections` ≤ 5 across 5 iterations
- `messagesInjections` ≤ 1.5× history length

These would have caught the v2.5.0 bug. Suite: 1490 → 1493.

### v1 marketing claim correction

v2.5.0's README claimed "Dynamic ReAct cuts input tokens 30–70%."
The real-world benchmark above shows this is **not universal** at sub-30-tool
scale. The corrected README now shows the real 3-model comparison
and explains:

- Dynamic provides **predictable cost** (varies <5% across models)
- Classic provides **lowest absolute cost** when the model parallelizes
- Dynamic wins clearly above ~30 tools across 8+ skills
- Dynamic ALWAYS wins on per-call payload size + deterministic routing

### Suite

1490 → 1493 (+3 regression tests).

## [2.5.0]

**Dynamic ReAct primacy + skill-driven tool gating.** This release
makes the Dynamic ReAct loop the load-bearing story: tools and
system-prompt content recompose every iteration, so an agent with
N skills × M tools no longer pays the full tool-list token cost on
every LLM call. Plus eight new builder/runtime features for
production agent surfaces.

### Block A — eight runtime + builder additions

- **A1 `.toolProvider()`** — first-class builder method for dynamic
  tool sources (registry-backed, MCP-mediated, runtime-decided).
- **A2 `PermissionPolicy`** — declarative role/capability allowlists
  on `agent.run({ identity })`. Tool-call recorder consults the
  policy; deny → tool throws `PermissionDeniedError`.
- **A3 `SkillRegistry.toTools()`** — explicit conversion API so
  consumers can opt skill-supplied tools into the static registry
  (gated by autoActivate mode).
- **A4 Builder ergonomics** — `.maxIterations()`, `.recorder()`,
  `.instructions()` on AgentBuilder.
- **A5 `autoActivate: 'currentSkill'`** — runtime tool gating: a
  skill's tools become visible to the LLM only when that skill is
  the most-recently-activated one. Cuts tool-list bloat for agents
  with N skills × M tools.
- **A6 `outputSchema(parser)`** — terminal-contract validation via
  `agent.runTyped()`. Uses footprintjs's schema abstraction
  (Zod-optional, duck-typed). On parse/validation failure throws
  `OutputSchemaError` with `.rawOutput` preserved.
- **A7 `flowchartAsTool(chart)`** — wraps a footprintjs FlowChart
  as an LLM-callable Tool. Inner pause throws with
  `error.checkpoint` attached (full nested-pause integration is on
  the v2.6 backlog).
- **A8 Richer `Skill`** — first-class `metadata`, `inject` shape,
  per-skill activation hooks. Subsumes v2.4 ad-hoc skill factories.

### Block B — `agentfootprint/{llm,tool,memory}-providers` + `/security`

Subpath restructure so consumers don't pay tree-shake costs for
adapters they don't use. v2.4's main barrel pulled every provider;
v2.5 splits them. The genuinely-clean per-adapter subpath
(Drizzle/Lucia pattern) is on the v2.6 backlog.

### Block C — Skills runtime per-mode routing

Closes the v2.4 Phase 4 commitment: `autoActivate` now actually
narrows the tool slot at runtime (was previously a static-only
hint). The Tools slot subflow consults `activatedInjectionIds`
each iteration.

### Block D — Message Catalog Pattern (`agentfootprint/locales`)

i18n-ready prose templates for Lens commentary and chat-bubble
thinking messages. `defaultThinkingMessages`, `composeMessages`,
`validateMessages` exports.

### Block E — examples README auto-generator

`scripts/generate-examples-readme.mjs` walks `examples/`, extracts
title + summary from each file's leading JSDoc, emits a
table-of-contents README. Runs as a release gate.

### Post-run trace accessors

`agent.getLastSnapshot()`, `agent.getLastNarrativeEntries()`,
`agent.getSpec()` — three accessors for post-run UIs (Lens Trace
tab, ExplainableShell, custom dashboards) to pull execution state
without intercepting the run() call site. `enableNarrative()` is
called inside `createExecutor()` so the entries array is populated
for any consumer that asks.

### BrowserAnthropicProvider — streaming-spec fixes

The v1→v2 rewrite regressed the SSE parser. v2.5 restores both:
**tool args via `input_json_delta`** (per-block accumulation, parsed
on `content_block_stop` — was always landing as `{}`) and
**cumulative usage tracking** from `message_start.usage` +
`message_delta.usage` (was always 0).

### Tool dedupe in Tools slot

Three sources can register the same tool name (static registry +
toolProvider + skill injection); LLMs reject duplicates. Tools
slot now dedupes by name + uses `ArrayMergeMode.Replace` on the
subflow output mapping (the documented fix to the documented
anti-pattern).

### Suite

1408 → 1490 (+82).

## [2.4.0]

**We made it impossible for our docs to lie.**

The headline of this release is structural: every code block on the
docs site is now imported from a real, runnable file in `examples/`.
A docs build fails if a referenced example doesn't exist or if a
named region marker is missing. Drift between docs and code becomes
impossible by construction — you can't ship a docs page that
documents an API that isn't there.

Suite: 1229 → 1253 (+24 from new Skills features). Pages: 67% drift
→ ~0%.

### The structural drift fix

- New `<CodeFile path="..." region="..." />` Astro component imports
  code from any file in the repo at docs-build time. Region markers
  in source files (`// #region NAME` / `// #endregion NAME`) let you
  show only the relevant slice.
- New CI job `docs` (`.github/workflows/ci.yml`) runs the docs-site
  build. A missing file → ENOENT. A missing region →
  `RegionNotFoundError`. Either kills CI.
- 35 of 42 docs pages converted to `<CodeFile>` imports. ~25 region
  markers added across `examples/`. Inline code blocks in the docs
  surface now exist only for illustrative anti-examples (the
  "without agentfootprint" 80-line block in the README).

### Skills features — the essay becomes truth

The `skills-explained.mdx` essay was the strongest piece of writing
in the docs and the most aspirational. Three features it described
now ship:

- `defineSkill({ surfaceMode })` — typed `'auto' | 'system-prompt' |
'tool-only' | 'both'`. Default `'auto'` resolves per provider via
  `resolveSurfaceMode`.
- `defineSkill({ refreshPolicy })` — typed
  `{ afterTokens, via: 'tool-result' }` for re-injecting skill bodies
  past a token threshold. API surface ships today; runtime hook lands
  in v2.5 (long-context attention work) — non-breaking.
- `resolveSurfaceMode(provider, model)` — pure function, exported.
  Per-provider attention-profile defaults match the essay:
  Claude ≥ 3.5 → `'both'`; everywhere else → `'tool-only'`.
- `SkillRegistry` class — centralized governance for shared skill
  catalogs across multiple agents. Methods: `register / replace /
unregister / get / has / list / size / clear`. Throws on duplicate
  register. Throws on non-Skill flavor inputs.
- `agent.skills(registry)` builder method — bulk-register every skill
  in a registry on an agent. Companion to existing `.skill(t)`.

Today's runtime treats every `surfaceMode` the same (the cross-
provider-correct activation + next-iteration injection pattern the
essay calls right). Full per-mode runtime routing diversity lands in
v2.5 — non-breaking; consumer code written today continues to work.

24 new tests cover the new API surface end-to-end.

### New navigation + 4 new pages

The docs site sidebar restructured around how readers actually
navigate (persona-aware grouping, max 7 items per group):

Get Started → Mental model → Primitives & compositions →
Context engineering → Memory → Observability → Production →
Providers → Memory stores → Architecture → Reference → Resources

Four new pages address the gaps the multi-persona review surfaced:

- `manifesto.mdx` — "How agentfootprint thinks". First-person
  opinionated essay naming what we are, what we're not, what we
  believe, what we ask of you. The framework's perspective made
  tangible. Storyteller's voice.
- `causal-deep-dive.mdx` — researcher-grade snapshot deep-dive.
  Annotated JSON shape of a `RunSnapshot` byte-for-byte. Four
  projection modes documented. Worked Monday→Friday replay with
  cheap-model triage economic argument (Sonnet→Haiku follow-up
  at ~10× lower cost).
- `research/citations.mdx` — bibliography for every shipped pattern
  (ReAct, Reflexion, ToT, Self-Consistency, Debate, Map-Reduce,
  Swarm, Skills) with proper paper references + how the recipe in
  `examples/patterns/` relates to + deviates from each paper. Plus
  the augmented-LM survey as the conceptual root of our Injection
  primitive. Plus a BibTeX entry for citing agentfootprint.
- `architecture/dependency-graph.mdx` — 8-layer DAG diagram for
  senior engineers. Substrate (footprintjs) → events → adapters →
  memory → context engineering → primitives → compositions → public
  barrel. Documents the Hexagonal isolation property + per-layer
  subpath exports + anti-cycle CI enforcement.

### API reference — auto-generated via TypeDoc

- New devDeps: `typedoc` + `typedoc-plugin-markdown`.
- New script: `npm run docs:api`. Reads `src/index.ts`, follows the
  public exports, emits markdown to `docs/api-reference/`.
- Generated tree committed so consumers browsing GitHub can follow
  links to it directly. Five sections: classes/ + functions/ +
  interfaces/ + type-aliases/ + variables/.
- The 7 hand-written API ref pages (which were drifted) consolidated
  to a single `api/agent.mdx` placeholder that points at the
  generated tree.

### Coverage badge

- New devDep: `@vitest/coverage-v8`.
- New script: `npm run test:coverage`.
- New CI job `coverage` (`.github/workflows/ci.yml`) uploads
  `coverage/lcov.info` to Codecov via `codecov-action@v5`. No
  threshold enforcement — badge surfaces the number; consumers
  ratchet up over time.
- README badge added. Initial baseline: 85.75% lines, 83.77%
  statements, 90.30% functions, 73.20% branches across 3962
  statements.

### README rewrite

- Tagline changed: "Context engineering, abstracted."
- New autograd / Express / Prisma / Kubernetes / React framing places
  agentfootprint in the category of credible abstractions — not
  "another agent framework."
- Side-by-side "without (~80 LOC, drifts) vs with agentfootprint
  (~8 LOC, stable)" code blocks.
- "The trace is a cache of the agent's thinking" reframing of
  causal memory with three downstream consumers: audit, cheap-
  model triage, training data.
- "Why exactly four triggers? Because _who decides activation_ is
  a closed axis: nobody / dev / system / LLM" — defensibly stable
  surface argument.
- Evergreen sections — no version-specific facts in the README. The
  npm version badge auto-updates from the registry; CHANGELOG carries
  per-release truth. **From now on the README never needs touching
  for a release.**

### Process

- Six 6-persona reviews (one per phase: 1, 2, 3, 4, 6 + Phase 7 final).
  Every review's adjustments folded into the next phase.
- Design memo signed off BEFORE code, per the v2.3 process change.
  No internal panel verdicts in JSDoc — design lives in
  `memory/agentfootprint_v24_design.md`.

### What's next (v2.5)

- Reliability subsystem — `CircuitBreaker`, 3-tier output fallback,
  `agent.resumeOnError(checkpoint, input)`. Deferred from v2.4.
- Skills runtime per-mode routing diversity — suppressing system-
  prompt slot for `'tool-only'`, synthesizing fresh tool-result for
  `refreshPolicy`. The API surface is shipped today; the runtime
  tightening lands in v2.5 non-breaking.

## [2.3.0]

Mock-first development is now a first-class workflow with two new
public surfaces, the first two production memory-store adapters
arrive as peer-deps via subpath imports, and `package.json` declares
every optional SDK in `peerDependenciesMeta`. Suite: 1229 / 1229.

### Added — `mock({ replies })` for scripted multi-turn agents

```typescript
import { Agent, mock, defineTool } from 'agentfootprint';

const provider = mock({
  replies: [
    // Iteration 1: LLM decides to call a tool
    { toolCalls: [{ id: '1', name: 'lookup', args: { topic: 'refunds' } }] },
    // Iteration 2: LLM produces final answer
    { content: 'Refunds take 3 business days.' },
  ],
});
```

Each `complete()` / `stream()` consumes one reply in order. Exhaustion
throws a clear error so a misnumbered script fails the test instead
of silently looping. `provider.resetReplies()` rewinds the cursor for
cross-scenario reuse.

### Added — `mockMcpClient({ tools })` (in-memory MCP server)

Drop-in replacement for `mcpClient(opts)` — same `McpClient` shape,
zero subprocess / network / SDK install. Build the entire MCP
integration offline, swap to real `mcpClient` when ready.

```typescript
import { Agent, mock, mockMcpClient } from 'agentfootprint';

const slack = mockMcpClient({
  name: 'slack',
  tools: [
    {
      name: 'send_message',
      description: 'Post a message to a channel',
      inputSchema: { type: 'object' },
      handler: async ({ text }) => `Posted: ${text}`,
    },
  ],
});

const agent = Agent.create({ provider: mock({ reply: 'ok' }) })
  .tools(await slack.tools())
  .build();
```

The `_client` injection on `mcpClient` is `@internal` because the SDK
shape isn't a stable public surface. `mockMcpClient` is the public,
documented mock entry point.

### Added — `RedisStore` (subpath: `agentfootprint/memory-redis`)

Persistent `MemoryStore` implementation backed by Redis. Lazy-requires
`ioredis`; no runtime cost when another adapter is in use.

```typescript
import { RedisStore } from 'agentfootprint/memory-redis';

const store = new RedisStore({ url: 'redis://localhost:6379' });
const memory = defineMemory({
  id: 'redis-window',
  type: MEMORY_TYPES.EPISODIC,
  strategy: { kind: MEMORY_STRATEGIES.WINDOW, size: 10 },
  store,
});
```

Implements every `MemoryStore` method except `search()`. `putIfVersion`
is atomic via a small Lua script (real CAS, not emulated). RedisSearch
(vector retrieval) lands as a separate adapter in a future release.

### Added — `AgentCoreStore` (subpath: `agentfootprint/memory-agentcore`)

AWS Bedrock AgentCore Memory adapter. Lazy-requires
`@aws-sdk/client-bedrock-agent-runtime`.

```typescript
import { AgentCoreStore } from 'agentfootprint/memory-agentcore';

const store = new AgentCoreStore({
  memoryId: 'arn:aws:bedrock:us-east-1:...:memory/my-mem',
  region: 'us-east-1',
});
```

Maps the `MemoryStore` interface onto AgentCore's session/event model.
Caveats called out in the JSDoc:

- `putIfVersion` is emulated client-side (read+write) — fine for
  single-writer-per-session deployments.
- `seen` / `feedback` use in-process shadow state (don't survive
  process restart). Use `RedisStore` for durable recognition.
- `search()` is NOT exposed in v2.3 — AgentCore's native retrieve API
  will land as a separate `agentcoreRetrieve()` helper in a future release.

### Changed — `package.json` peer-dep declarations

Every lazy-required SDK is now declared in `peerDependenciesMeta` with
`optional: true` so npm advertises the relationship without auto-installing
or warning:

- `@anthropic-ai/sdk` (was undeclared — silent peer-dep)
- `openai` (was undeclared)
- `@aws-sdk/client-bedrock-runtime` (was undeclared)
- `@aws-sdk/client-bedrock-agent-runtime` (new — AgentCore)
- `@modelcontextprotocol/sdk` (was undeclared)
- `ioredis` (new — Redis)
- `zod` (already declared)

Friendly install hints fire at first call when an SDK is missing — same
pattern as `AnthropicProvider` since v1.

### Examples

- `examples/features/07-mock-multi-turn-replies.ts` — scripted ReAct loop
- `examples/memory/08-redis-store.ts` — RedisStore with mock-injected client
- `examples/memory/09-agentcore-store.ts` — AgentCoreStore with mock-injected client

All run end-to-end via `npm run example <path>`.

### Tests

+66 new tests (1163 → 1229):

- +6 MockProvider replies (consumption order, toolCalls partial, exhaustion, reset, precedence, stream)
- +15 mockMcpClient (lifecycle, handler dispatch, arg coercion, error context, Agent integration, schema fidelity)
- +23 RedisStore (CAS Lua, TTL, multi-tenant isolation, GDPR forget, signatures, feedback)
- +22 AgentCoreStore (emulated CAS, session-keyed isolation, shadow state, GDPR forget)

### Process change — design memo BEFORE release

v2.3 ships with a 9-panel design memo signed off ahead of code, per the
process-change committed in v2.2.x: panel verdicts live in
`memory/agentfootprint_v23_design.md`, not in JSDoc.

## [2.2.0]

Adds MCP (Model Context Protocol) client integration. Connect to any
MCP server, pull its tools as agentfootprint `Tool[]`, register them
on your agent in one builder call. Validates the v2.0 thesis again:
new tool sources slot in via the existing `Tool` interface — no
engine code, no new event types.

### Added — `mcpClient` (Model Context Protocol client)

```typescript
import { Agent, mcpClient } from 'agentfootprint';

const slack = await mcpClient({
  name: 'slack',
  transport: { transport: 'stdio', command: 'npx', args: ['@example/slack-mcp'] },
});

const agent = Agent.create({ provider })
  .tools(await slack.tools()) // bulk-register every tool the server exposes
  .build();

await agent.run({ message: 'Send "deploy succeeded" to #alerts' });
await slack.close();
```

- Transports: `stdio` (local subprocess) and `http` (Streamable HTTP)
- Lazy-required `@modelcontextprotocol/sdk` peer-dep — zero runtime
  cost when MCP isn't used; friendly install hint if missing
- `_client` injection point for testing without the SDK
- Each MCP tool wraps as one agentfootprint `Tool` — `inputSchema`
  preserved verbatim; `callTool()` becomes the wrapped `execute()`
- MCP error responses (`isError: true`) throw with the server's
  message; non-text content blocks (image / resource) summarized as
  `[type]` placeholders (full multi-modal mapping is a future release)

### Added — `Agent.tools(toolArray)` builder method

Bulk-register companion to `.tool(t)`. Pair with
`await mcpClient(...).tools()` for the canonical MCP flow:

```typescript
agent
  .tools(await slack.tools())
  .tools(await github.tools())
  .tools(await db.tools())
  .build();
```

Tool-name uniqueness still validated per-entry across all sources
(MCP servers + manual `.tool()` calls). Duplicates throw at build
time.

### Added — `examples/context-engineering/08-mcp.ts` + `.md`

End-to-end runnable example using an injected mock MCP client. Same
code path as production; only the SDK construction is mocked. Pairs
with the existing 7 context-engineering examples.

### Internal

- 1157 tests (was 1141 — 16 new MCP tests across 7 patterns)
- 35 examples (was 34 — added 08-mcp.ts)
- AI tooling instructions (CLAUDE.md, AGENTS.md, all `ai-instructions/`)
  updated to cover MCP

## [2.1.0]

The first new context-engineering flavor since the v2.0 InjectionEngine
shipped. Validates the v2.0 thesis: "adding the next flavor is one new
factory file." defineRAG is exactly that — composes over the existing
memory subsystem (semantic + top-K + strict threshold), zero engine
changes, zero new event types.

### Added — RAG (`defineRAG` + `indexDocuments`)

Two-function public surface:

- `defineRAG({ id, store, embedder, topK?, threshold?, asRole? })` —
  the read-side factory. Returns a `MemoryDefinition` with RAG-friendly
  defaults (asRole='user', topK=3, threshold=0.7).
- `indexDocuments(store, embedder, documents, options?)` — the seeding
  helper. Embeds each doc, batches into `store.putMany()`. Used at
  application startup to populate the corpus before the first agent run.

Plus `Agent.rag(definition)` builder method — alias for `.memory()` so
consumer intent reads clearly:

```typescript
import { defineRAG, indexDocuments, InMemoryStore, mockEmbedder } from 'agentfootprint';

const embedder = mockEmbedder();
const store = new InMemoryStore();

await indexDocuments(store, embedder, [
  { id: 'doc1', content: 'Refunds processed in 3 business days.' },
  { id: 'doc2', content: 'Pro plan: $20/month.' },
]);

const docs = defineRAG({ id: 'product-docs', store, embedder, topK: 3, threshold: 0.7 });

agent.rag(docs); // alias for .memory(docs); same plumbing
```

Strict threshold semantics: when no chunk meets the threshold, no
injection happens (no fallback to top-K-anyway). Same panel-decision
rule as defineMemory({strategy: TOP_K}).

Multi-tenant corpora supported via `IndexDocumentsOptions.identity`.

### Added — `examples/context-engineering/07-rag.ts` + `.md`

End-to-end runnable example demonstrating the full RAG flow (seed →
define → query → retrieved-context-injected). Pairs with the existing
6 context-engineering examples.

### Added — AI tooling instructions cover RAG

`CLAUDE.md`, `AGENTS.md`, and every file under `ai-instructions/`
updated to include the RAG section so AI coding tools generate v2.1
code by default.

### Internal

- 1141 tests (was 1121 — 20 new RAG tests)
- 34 examples (was 33 — added 07-rag.ts)
- Public exports: `defineRAG`, `DefineRAGOptions`, `indexDocuments`,
  `IndexDocumentsOptions`, `RagDocument` from top-level barrel

## [2.0.1]

The first npm-published v2 build. v2.0.0 was tagged on GitHub but the
publish workflow failed before reaching `npm publish` because of a
case-sensitive Linux CI failure (`mapReduce.ts` vs `MapReduce.ts`).
2.0.1 carries every v2.0 feature plus the post-tag fixes:

### Fixed

- `src/patterns/mapReduce.ts` → `MapReduce.ts` so case-sensitive Linux
  CI resolves `import '../../../src/patterns/MapReduce.js'`. macOS dev
  hid the issue.
- ESLint `require-yield` violation in
  `test/resilience/unit/withFallback.test.ts` (intentionally-empty
  generator that throws before yielding — suppression added locally).

### Changed

- Release script Gate 5: now runs the in-repo `examples/` sweep
  (`npm run test:examples` → typecheck + tsx end-to-end run) instead
  of the external `../agent-samples` repo. Examples are now the source
  of truth for the consumer surface.
- Root README: tagline reframed to "Building Generative AI applications
  is mostly context engineering" (was "Building agents..."). Quick Start
  leads with `anthropic({...})` not `mock({reply})`. Roadmap split
  into "What v2.0 ships (today)" + "What's next" so v2.0 reads as a
  complete release. "Why a context-engineering framework" comparison
  table moved up — right after the patterns recipes — where the
  contrast lands hardest.
- Root README: 3-line code teaser between install + the pedagogy
  sections so fluent readers see the builder API in 5 seconds.

### AI tooling overhaul

- `CLAUDE.md`, `AGENTS.md`, and every file under `ai-instructions/`
  rewritten for the v2.0 surface. The old contents were stale (copy
  of footprintjs's instructions or v1 agentfootprint patterns), so
  AI coding tools using bundled instructions would generate code
  against APIs that no longer exist. New surface covers:
  - 6-layer mental model
  - All four `define*` factories (Skill / Steering / Instruction / Fact)
  - `defineMemory({ type, strategy, store })` with 4 types × 7 strategies
  - Multi-agent via control flow (no `MultiAgentSystem` class)
  - Anti-patterns naming the v1 vocabulary so tools don't regress
    consumers to old APIs

## [2.0.0]

The release that lands the **6-layer mental model** end-to-end:
2 primitives + 3 compositions + N patterns + Context Engineering +
**Memory** + Production Features. Every layer is pure composition over
the layers below — no hidden primitives.

### Added — InjectionEngine (unified context-engineering primitive)

One `Injection` primitive evaluated by one engine subflow each
iteration, with N typed sugar factories that all reduce to the same
shape:

- `defineSkill(...)` — LLM-activated body + tools (auto-attaches `read_skill`)
- `defineSteering(...)` — always-on system-prompt rule
- `defineInstruction(...)` — predicate-gated, supports `on-tool-return` for Dynamic ReAct
- `defineFact(...)` — developer-supplied data injection

Consumer wires them via `Agent.create(...).skill(...)`, `.steering(...)`,
`.instruction(...)`, `.fact(...)`, or the generic `.injection(...)`. Every
flavor emits `agentfootprint.context.injected` with `source` discriminating
the flavor — Lens / observability surfaces show one chip per active
injection without per-feature special casing.

### Added — Memory subsystem (`defineMemory` factory)

Single factory dispatches `type × strategy.kind` onto the right
pipeline. The 2D mental model:

```
                MEMORY = TYPE × STRATEGY × STORE

  TYPE                       STRATEGY                    STORE
  ──────────────────         ──────────────────          ─────────
  EPISODIC   messages        WINDOW    last N            InMemoryStore
  SEMANTIC   facts        ×  BUDGET    fit-to-tokens  ×  Redis · Dynamo
  NARRATIVE  beats           SUMMARIZE LLM compress      Postgres · …
  CAUSAL ⭐  snapshots       TOP_K     score-threshold   (peer-deps in v2.1+)
                              EXTRACT   distill on write
                              DECAY     recency × access
                              HYBRID    composed
```

- `Agent.memory(definition)` builder method — multiple memories layer
  cleanly via per-id scope keys (`memoryInjection_${id}`)
- `agent.run({ message, identity })` — multi-tenant scope through the
  full `MemoryIdentity` tuple (tenant / principal / conversationId)
- READ subflow runs at `MEMORY_TIMING.TURN_START` (default; `EVERY_ITERATION`
  opt-in for tool-result-sensitive memory)
- WRITE subflow mounts in the Final route branch with `propagateBreak`
  so writes happen reliably before the loop terminates
- Strict TopK threshold semantics — no fallback when nothing matches
  (garbage past context worse than no context)

**Causal memory ⭐ — the differentiator no other library has.**
footprintjs's `decide()` / `select()` capture decision evidence as
first-class events during traversal. Causal memory persists those
snapshots tagged with the original user query; new questions match
against past queries via cosine similarity, injecting decision evidence
into the next turn's context. Cross-run "why did you reject X?"
follow-ups answer from EXACT past facts — zero hallucination. Same data
shape supports SFT/DPO/process-RL training-data export in v2.1+.

### Added — examples folder (33 examples, all runnable end-to-end)

- `examples/core/` — 2 primitives (LLMCall, Agent + tools)
- `examples/core-flow/` — 4 compositions (Sequence, Parallel, Conditional, Loop)
- `examples/patterns/` — 6 canonical patterns (ReAct, Reflexion, ToT, MapReduce, Debate, Swarm)
- `examples/context-engineering/` — 6 InjectionEngine flavors
  (Instruction / Skill / Steering / Fact / Dynamic-ReAct / mixed)
- `examples/memory/` — 7 strategy-organized memory examples
- `examples/features/` — pause-resume, cost, permissions, observability, events

Every example is a runnable end-to-end test (CI runs `npm run test:examples`
which now does both typecheck + sweep). New `npm run example <path>`
wraps tsx with the right runtime tsconfig so consumers don't need
`TSX_TSCONFIG_PATH` env-var gymnastics.

### Added — top-level public exports

```ts
import {
  // Memory
  defineMemory,
  MEMORY_TYPES,
  MEMORY_STRATEGIES,
  MEMORY_TIMING,
  SNAPSHOT_PROJECTIONS,
  InMemoryStore,
  mockEmbedder,
  identityNamespace,
  // InjectionEngine
  defineSkill,
  defineSteering,
  defineInstruction,
  defineFact,
  evaluateInjections,
  buildInjectionEngineSubflow,
  // … (existing core surface unchanged)
} from 'agentfootprint';
```

### Changed — Agent flowchart shape (internal — no consumer impact)

The Agent's main flowchart now has memory READ subflows mounted
between Seed and InjectionEngine, and the `Route → 'final'` branch is
now a sub-chart (`PrepareFinal → memory-write subflows → BreakFinal`)
so memory writes happen reliably before the loop terminates. This is
visible in narrative + Lens but doesn't change the consumer API.

### Changed — top-level scrub

- All `v2` marketing prefixes scrubbed from `src/` JSDoc / READMEs.
  The library is now just "agentfootprint", not "agentfootprint v2".
- Removed redundant `Execution stopped... due to break condition`
  console.info from footprintjs (3 sites — break is already recorded
  via `narrativeGenerator.onBreak`).

### Fixed — example runtime

- `examples/core/02-agent-with-tools.ts` — custom respond extracts
  city from user message instead of returning empty args
- All 33 examples now run end-to-end in CI; previously only typecheck
  was verified

### Internal — test counts

- agentfootprint: **1121 tests** (was 1044 in 1.23.0; +77 new memory tests)
- footprintjs (peer dep): 2436 tests pass after the leaked-log fix

### Roadmap (next minor releases)

| Release | Focus                                                                                                            |
| ------- | ---------------------------------------------------------------------------------------------------------------- |
| v2.1    | Reliability subsystem (3-tier fallback, CircuitBreaker, auto-retry, fault-tolerant resume) + Redis store adapter |
| v2.2    | Governance subsystem (Policy, BudgetTracker, access levels) + DynamoDB adapter                                   |
| v2.3    | Causal training-data exports (`exportForTraining({format})`) + RLPolicyRecorder                                  |
| v2.4+   | MCP integration, Deep Agents, A2A                                                                                |

## [1.23.0]

### BREAKING — but no users yet, shipped as minor

`AgentTimelineRecorder` redesigned around an event stream + selectors + pluggable humanizer. `getTimeline()` method + the `AgentTimeline` bundle interface are removed. Consumers compose typed selectors directly (or use a thin helper like Lens's `timelineFromRecorder`). Three-layer architecture:

```
EVENT STREAM              (structured, canonical — single source of truth)
    ↓
SELECTORS                 (typed, memoized, lazy, composable — THE API)
    ↓
VIEWS                     (renderer plugs in: React / Vue / Angular / CLI / Grafana)
```

### Added — new selector API on `AgentTimelineRecorder`

- `getEvents(): readonly AgentEvent[]` — raw structured event stream
- `selectAgent()`, `selectTurns()`, `selectMessages()`, `selectTools()`, `selectSubAgents()`, `selectFinalDecision()` — classic slices
- `selectTopology()` — composition graph for flowchart renderers (engineer view)
- `selectCommentary(cursor?)` — humanized narrative, one line per event (analyst view)
- `selectActivities(cursor?)` + `selectStatus(cursor?)` — breadcrumb + typing-bubble (end-user view)
- `selectRunSummary()` — tokens, tool counts, duration, skills activated
- `selectIterationRanges()` — iter ↔ event-index map for scrubbers
- `selectContextBySource(cursor?)` — per-slot injection ledger grouped by source (rag / skill / memory / instructions / ...) — powers slot-row badges in Lens and the "teach context engineering" pedagogical surface
- `setHumanizer(Humanizer)` — pluggable domain phrasings. Library defaults ("Thinking", "Running ${toolName}", "Got result") override per-tool for domain-friendly text ("Checking port status on switch-3"). Translation, localization, UX tone = humanizer swap, NOT data change.

### Added — new exported types

`AgentEvent` (discriminated union — the canonical contract), `Activity`, `StatusLine`, `CommentaryLine`, `RunSummary`, `IterationRange`, `IterationRangeIndex`, `ContextBySource`, `ContextSlotSummary`, `ContextSourceSummary`, `Humanizer`.

### Changed — `selectSubAgents()` heuristic

A topology subflow classifies as a sub-agent only if its descendants include one of the API-slot subflows (`sf-system-prompt` / `sf-messages` / `sf-tools`). This correctly distinguishes:

- **Single-agent runs** — the API-slot subflows are top-level, nothing wraps them → no sub-agents
- **Multi-agent runs** (Pipeline/Parallel/Swarm/Conditional) — each Agent wraps its own slots → each qualifies

Robust against future internal-agent subflow additions (auto-classifies as "internal").

### Composed primitive

`AgentTimelineRecorder` now composes footprintjs's `TopologyRecorder` (new in footprintjs 4.15.0) internally. Runner-side `setComposition()` handshake — DELETED. Composition shape discovered at runtime from the executor's traversal (subflow / fork / decision / loop events).

### Memoized selectors

Every selector is memoized by `(name, version, cursor)`. `version` increments on every `emit()` / `setHumanizer()` / `clear()` — long runs don't recompute unchanged views. Same selector call returns the same reference until new events arrive (referential equality for React).

### 10+ new pattern tests

selectActivities state machine + cursor, selectStatus idle/at-cursor, selectCommentary, selectRunSummary totals, humanizer override + fall-through + swap invalidation, selectIterationRanges, memoization reference equality, clear() invalidation, selectContextBySource grouping + cursor.

### Migration

```diff
- const t = agentTimeline();
- const timeline = t.getTimeline();
- timeline.turns;
- timeline.messages;
- timeline.subAgents;
+ const t = agentTimeline();
+ const turns = t.selectTurns();
+ const messages = t.selectMessages();
+ const subAgents = t.selectSubAgents();
```

UI libraries that want a bundled shape define their own helper (Lens ships `timelineFromRecorder(recorder)`).

## [1.22.0]

### attachRecorder() on every runner — multi-agent flows end-to-end

- **FlowChartRunner / ConditionalRunner / ParallelRunner / SwarmRunner**
  all gain `attachRecorder(recorder)` matching the AgentRunner contract.
  Returns detach function; idempotent on recorder id.
- Without this, `<Lens for={runner} />` for these multi-agent
  composition runners fell back to `runner.observe()` + flat
  AgentStreamEvent translation — losing `subflowPath`, which
  broke multi-agent grouping in Lens (subAgents always empty).
- New shared helper `attachRecorderToList()` so the four
  composition runners + AgentRunner stay in sync; future \*Runner
  classes get the same behavior with one line of glue.
- 1960 / 1960 tests pass.

End-to-end multi-agent now works in `<Lens for={runner} />`:

- FlowChart pipeline (classify → analyze → respond) renders 3
  stacked sub-agent boxes
- Conditional / Parallel / Swarm samples render the right number
  of sub-agent boxes for their composition pattern

## [1.21.0]

### Multi-agent foundations

- **`runner.attachRecorder(rec)`** — new method on AgentRunner. Attach
  a recorder POST-BUILD; it participates in every subsequent `.run()`
  with the standard recorder lifecycle (clear() + emit-channel hookup
  via forwardEmitRecorders). Returns a detach function; idempotent on
  recorder id (matching the rest of the recorder-attachment contract).
  Lets `<Lens for={runner} />` consume EmitEvents directly (real
  runtimeStageId + subflowPath), unblocking multi-agent grouping.
- **`AgentTimeline.subAgents`** — new field on the timeline shape.
  Per-sub-agent slices for multi-agent runs (Pipeline / Swarm /
  Routing). Empty array for single-agent runs. Each entry is its own
  SubAgentTimeline with `id`, `name`, own `turns`, own `tools` —
  derived by grouping TimelineEntries by `subflowPath[0]`.
- **`SubAgentTimeline`** — new exported type. Self-contained sub-
  agent timeline shape that UIs iterate over for multi-agent
  rendering.
- **TimelineEntry now carries `subflowPath`** internally — preserved
  verbatim from the EmitEvent so the folder can derive sub-agents
  without re-reading source events.
- 7th pattern test added covering multi-agent grouping (Pipeline-style
  classify→analyze→respond) + single-agent's empty subAgents.

The data shape is the contract every UI library reads. `agentfootprint-
lens` 0.11+ uses it to render N agent containers (one per sub-agent)
for Pipeline / Swarm / Routing samples.

## [1.20.0]

### Agent identity surfaces on `AgentTimeline`

- **`agentTimeline({ name })`** — new option on the recorder factory.
  Set the display name once at recorder construction; surfaces on
  `timeline.agent.name`. Match this to `Agent.create({ name })` for
  end-to-end identity consistency.
- **`AgentTimeline.agent`** — new required field of shape
  `{ id, name }`. UI libraries read this directly instead of fishing
  the agent name out of `runtimeSnapshot.agentName / .name` or asking
  the consumer to thread a separate prop. Single source of truth.
- **New exported type `AgentInfo`** —
  `{ id: string; name: string }`. Shape of the new field.
- **Defaults**: `id` falls back to `agentfootprint-agent-timeline`,
  `name` falls back to `Agent`. UIs that get the fallback render
  "Agent · Agent" rather than crashing on undefined.
- **Multi-agent foundation**: each sub-agent recorder
  (`agentTimeline({ id: 'classify', name: 'Classify Bot' })`) carries
  its own identity → multi-agent shells render N labeled containers
  pulling each name from `timeline.agent.name` directly.
- 6th pattern test added, full suite green (1959 tests).

This is the data-layer counterpart to lens 0.9.0's "Agent container +
LLM rename" UI work. Lens reads `timeline.agent.name` to label the
dotted Agent boundary that wraps the LLM / Tool / Skill / satellites.

## [1.19.0]

### New recorder — `agentTimeline()` (the canonical agent narrative)

Parallels footprintjs's `CombinedNarrativeRecorder`. One place every UI
/ observability consumer translates the agentfootprint emit stream into
the agent-shaped narrative they render against — turns → iterations →
tool calls + per-iteration context injections + folded ledger. UI
libraries (`agentfootprint-lens`, `agentfootprint-grafana`, custom
dashboards) consume the same shape instead of each re-implementing
their own translation.

- **`agentTimeline(options?)`** factory, exported from both
  `agentfootprint` and `agentfootprint/observe`. Returns an
  `AgentTimelineRecorder` that extends footprintjs
  `SequenceRecorder<TimelineEntry>` and implements `EmitRecorder`.
  Gets storage, keyed index, range index, progressive `accumulate()`,
  and the `clear()` lifecycle hook for free — no reinvented
  bookkeeping.
- Attach via the standard `.recorder(t)` on AgentBuilder;
  `forwardEmitRecorders` routes to `executor.attachEmitRecorder(t)`.
- **Public types**: `AgentTimeline`, `AgentTurn`, `AgentIteration`,
  `AgentToolInvocation`, `AgentToolCallStub`, `AgentMessage`,
  `AgentContextInjection`, `AgentContextLedger`. These are the data
  contract every UI library consumes.
- **Context-injection routing** preserves semantics: events during the
  LLM phase shape THIS iter's prompt; events between phases shape the
  NEXT iter (skill activation post-`read_skill`).
- **Multi-agent**: `agentTimeline({ id: 'classify' })` — each sub-agent
  in a Pipeline/Swarm gets its own named recorder, its own snapshot
  slot.
- 5 pattern tests (`test/unit/agent-timeline-recorder.test.ts`):
  basic shape, ReAct loop ordering (tool_start after llm_end),
  context-injection routing, multi-turn, clear() lifecycle.
- Docs update in `src/recorders/README.md`.

## [1.18.0]

### Context engineering — first-class teaching surface

- **New `contextEngineering()` recorder** (`src/recorders/ContextEngineeringRecorder.ts`).
  Public consumer-facing recorder that subscribes to the emit channel and
  exposes a structured query API: `injections()`, `ledger()`,
  `ledgerByIteration()`, `bySource()`, `bySlot()`, `clear()`. Lets any
  UI layer (Lens, Datadog, custom panels) observe **who** injected
  **what** into **which** Agent slot, on every iteration. Mirrors
  `agentObservability()` in shape — same factory, same emit-channel
  substrate, different domain focus.

- **Context-injection emits land at the source of truth.**

  - `agentfootprint.context.rag.chunks` fires from
    `src/stages/augmentPrompt.ts` with role + targetIndex + chunkCount +
    topScore (was previously emitted before role/index were known).
  - `agentfootprint.context.skill.activated` fires from
    `src/lib/call/toolExecutionSubflow.ts` whenever
    `decision.currentSkill` flips post-`read_skill`. Carries `skillId`,
    `previousSkillId`, `deltaCount: { systemPromptChars, toolsFromSkill }`.
  - `agentfootprint.context.instructions.fired` fires when
    AgentInstructions fire on a turn — counted, with delta info.
  - `agentfootprint.context.memory.injected` fires from memory subsystem
    when prior-turn memory writes flow back into the prompt.

- **`forwardEmitRecorders()` helper**
  (`src/recorders/forwardEmitRecorders.ts`). Detects whether a
  user-supplied recorder implements `onEmit` and routes it to
  `executor.attachEmitRecorder()`. Wired into all 7 runners (Agent,
  LLMCall, RAG, FlowChart, Parallel, Swarm, Conditional) so
  `.recorder(contextEngineering())` Just Works without consumers having
  to know about footprintjs's three-channel observer architecture.

- **`StreamEventRecorder` forwards `agentfootprint.context.*`** events to
  the `AgentStreamEventHandler`, so consumers using `<Lens for={runner} />`
  see context events alongside stream events without a separate
  subscription.

### Multi-agent + EventDispatcher

- **`EventDispatcher`** — per-runner observer list pattern in
  `src/streaming/EventDispatcher.ts`. Foundation for the
  `runner.observe()` contract Lens consumes.
- Multi-agent type updates in `src/types/multiAgent.ts` + tests.

### Examples + tests

- Snapshot tests updated for the new emit events in execution traces.
- New test scaffolding for context-engineering recorder e2e
  (`test/integration/ce-recorder-e2e.test.ts`,
  `test/unit/context-engineering-recorder.test.ts`,
  `test/unit/context-injection-emits.test.ts`,
  `test/unit/runner-observe-contract.test.ts`).

### Docs

- New / updated guides: `dynamic-react.mdx`, `rag.mdx`, `swarm.mdx`,
  `key-concepts.mdx`, `quick-start.mdx`, `why.mdx`, `vs.mdx`,
  `debug.mdx`.
- README + index.mdx refreshed for the new context-engineering surface.

## [1.17.6]

### Examples — full footprintjs-style parity

- **Wrote 19 missing `.md` explainer files** so every `.ts` example now has
  a paired `.md` (31 / 31 — full 1:1 coverage matching the
  footprintjs/examples/ pattern). New explainers cover: `providers/` (3),
  `runtime-features/{streaming,instructions,parallel-tools,custom-route,memory}/`
  (6), `observability/` (4), `security/` (1), `resilience/` (2),
  `advanced/` (1), `integrations/` (2). Same frontmatter format
  (`name`, `group`, `guide`, `defaultInput`) and same section structure
  (When to use / What you'll see in the trace / Key API / Failure modes /
  Related) as the `concepts/` and `patterns/` explainers shipped in
  v1.17.5.

### Tests — snapshot regression detection

- **`test/examples-smoke.test.ts` now asserts `toMatchSnapshot()`** on
  every example's `run()` output. The previous version only verified
  "does it run without throwing?" — too weak to catch silent behavior
  drift. Now if a library change alters tool counts, iteration counts,
  branch selection, content shape, or any other observable result, the
  snapshot diff fails loudly and forces the author to either fix the
  example or update the golden with `npm test -- -u`.
- 31 baseline snapshots committed to `test/__snapshots__/`. Stable across
  re-runs (verified) — non-determinism (timestamps, latencies, generated
  trace IDs, JSON byte sizes) is scrubbed by a small `sanitize()` helper
  before comparison.
- Brings the in-repo gate to parity with footprintjs's
  `footprint-samples/test/integration` snapshot suite — but inside the
  main repo, no external sibling required.

## [1.17.5]

### Examples

- **Restructured `examples/` from feature-buckets into a lifecycle-based
  ladder** that mirrors the footprintjs/examples/ pattern. New folders:
  `concepts/` (the 7-concept ladder, in order), `patterns/` (Regular vs
  Dynamic + the 4 composition patterns each in their own file),
  `providers/`, `runtime-features/{streaming,instructions,memory,parallel-tools,custom-route}`,
  `observability/`, `security/`, `resilience/`, `advanced/`,
  `integrations/`. The old folders (`basics/`, `orchestration/`,
  `memory/`, `integration/`) are gone — files renumbered sequentially
  within their new home so `01,02,03,...` reflects learning order.
- **Added `examples/DESIGN.md`** explaining the categorization rationale,
  the file contract, and the playground-injection pattern. Added
  `examples/README.md` as the reader's entry point.
- **Every example now follows a single contract**: exports
  `run(input, provider?)` (factory pattern) + `meta: ExampleMeta`
  (catalog metadata for the playground) + a CLI fallback so
  `npx tsx examples/...` still works. The optional `provider` parameter
  lets the playground inject any LLMProvider at runtime — the example
  source stays clean and copy-pastable. Multi-provider examples
  (`planExecute`, `reflexion`, `treeOfThoughts`, `mapReduce`) accept an
  object with named slots declared in `meta.providerSlots`.
- **Split `orchestration/28-patterns.ts`** into four separate files
  under `patterns/` — one per pattern — so each is independently
  citable and runnable.
- **Added `concepts/05-parallel.ts`** — the Parallel concept previously
  had no standalone example.
- **Added paired `.md` files** for `concepts/` (7) and `patterns/` (5)
  with frontmatter (`name`, `group`, `guide`, `defaultInput`),
  "When to use", "What you'll see in the trace", "Key API",
  "Failure modes", and "Related concepts" sections — same shape as
  footprintjs/examples/building-blocks/\*.md. Other folders' .md files
  will be added in follow-up patches.
- **New `examples/helpers/cli.ts`** centralizes the
  `isCliEntry(import.meta.url)` guard, the `printResult()` formatter,
  and the `ExampleMeta` type.

### Tests

- **New `test/examples-smoke.test.ts`** auto-discovers every example
  under `examples/`, verifies the file contract (`run` + `meta`
  exports with the right shape), and invokes each `run()` with the
  example's own scripted mock provider. 32 examples covered. This
  replaces the previous gate-5 dependency on
  `agent-samples/npm-run-all` — examples are now self-validating
  inside the agentfootprint repo.

### `agent-samples` (separate repo)

- **Updated `agent-samples/package.json`** to point at the new example
  paths so the cross-repo `npm run all` keeps working through the
  transition. Marked the package as DEPRECATED in its description —
  the in-repo smoke test supersedes it; the directory will be removed
  once the playground migration is complete.

## [1.17.4]

### Documentation

- **New `docs/guides/patterns.md`** covering both loop patterns
  (`AgentPattern.Regular` vs `Dynamic`) and the four composition pattern
  factories (`planExecute`, `reflexion`, `treeOfThoughts`, `mapReduce`)
  that ship from `agentfootprint/patterns` but were previously
  undocumented. Each pattern section includes an everyday analogy, the
  canonical research citation (Yao et al. 2023, Shinn et al. 2023, Wang
  et al. 2023, Madaan et al. 2023, Dean & Ghemawat 2004), an
  "honesty box" naming the simplification (e.g. shipped `reflexion`
  factory is closer to Self-Refine than full Reflexion), per-pattern
  observability + failure-mode notes, and a
  "Picking a quality pattern" decision table.
- **`docs/guides/concepts.md` updated to reflect the seven shipped
  concepts** (was documenting five — `Parallel` and `Conditional` were
  missing). Added builder + runner sections for both, plus
  per-concept analogies, ReAct/RAG/Swarm citations, and failure-mode
  notes for every concept.
- **`docs/guides/recorders.md` adds the missing `ExplainRecorder`
  section** — the per-iteration grounding evidence recorder that the
  README pitches as the differentiator. Also adds the LLM-as-judge
  caveat (Zheng et al. 2023) on `QualityRecorder`, the recorder-id
  idempotency rule, and updates the summary table with `ExplainRecorder`,
  `PermissionRecorder`, and `agentObservability()`.
- **All other guides (`quick-start`, `providers`, `adapters`,
  `orchestration`, `security`, `instructions`, `streaming`) reviewed
  through a four-persona lens** (student / professor / senior engineer
  / researcher) and updated with: opening analogies, prior-art
  citations where applicable, "Failure modes" / "Cost note" /
  "What's novel" subsections at production-relevant spots, and honest
  positioning language separating shipped behavior from prior art.
- Quick-start example tool replaced (deterministic `add` instead of a
  fake `web_search` returning a hallucinated answer); a new
  "Before You Ship" production checklist links the security /
  orchestration / observability primitives readers should add before
  deploying with a real provider.
- No source code changes — documentation-only release.

## [1.17.3]

### Fixed

- **`agentfootprint.stream.llm_end` now forwards token usage and stop
  reason.** The typed `AgentStreamEvent` schema carried
  `{iteration, toolCallCount, content, model, latencyMs}` but omitted
  `usage` and `stopReason` — so stream consumers (Lens, cost meters,
  any dashboard subscribing to the stream) got `0→0` tokens and no
  finish reason, even though the same data was already present on the
  sibling `agentfootprint.llm.response` event. Three emit sites
  (`callLLMStage.ts` + both paths in `streamingCallLLMStage.ts`) now
  include `usage: response.usage` and
  `stopReason: response.finishReason`. Schema additions are optional
  fields → backwards-compatible for consumers that ignore them.

## [1.17.2]

### Fixed

- **InstructionsToLLM subflow was concatenating arrays across Dynamic
  ReAct iterations.** `buildAgentLoop` mounted `sf-instructions-to-llm`
  without `arrayMerge: ArrayMergeMode.Replace`, so each loop iteration
  appended its `promptInjections` / `toolInjections` to the parent
  scope — the effective system prompt grew 7→14→21→28 lines, and the
  tool list doubled on every turn, triggering Anthropic's
  `"tools: Tool names must be unique"` rejection on iter 4+. Matches the
  existing Replace flag on `sf-messages` / `sf-tools`.
- **`.skills(registry)` did not register per-skill tools for dispatch.**
  Skill tools were declared to the LLM via `AgentInstruction.tools`
  injections, but the dispatch registry only had `list_skills` +
  `read_skill`. When an LLM called a skill-gated tool,
  `staticTools.execute()` returned `{error: true, content: "Unknown
tool: ..."}` and the turn wedged. `.skills()` now iterates each
  skill's `tools: []` and registers them into the agent's `ToolRegistry`
  so dispatch is always reachable.
- **ToolProvider dispatch now falls back to the registry on "Unknown
  tool" errors.** Callers who use a narrow resolve-time provider
  (`staticTools([listSkills, readSkill])` + injection-based visibility)
  need dispatch to reach the registered skill tools. Both the
  sequential and parallel dispatch paths in `lib/call/helpers.ts` now
  check: if the primary provider reports the tool as unknown AND the
  registry has it, fall through to the registry handler.
- **Decision scope now persists across `.run()` calls.** Previously
  `scope.decision = { ...initialDecision }` reset the decision on every
  turn, so follow-up messages would silently lose the `currentSkill`
  written by the prior turn's `read_skill` — causing `autoActivate` to
  stop surfacing the skill's tools on iter 1 of turn 2+. The runner now
  captures `state.decision` after each run and re-seeds from it next
  time. Cleared by `resetConversation()` for clean new dialogues.
  Unblocks multi-turn chat where the skill context should feel
  continuous.
- **`buildToolsSubflow` now defensively dedupes on three axes.** Base
  tools vs. base tools (in case the provider returned duplicates),
  base vs. injections (pre-existing check), and within injections
  themselves. First-wins on every axis. Belt-and-braces safety net
  against the Anthropic "tool names must be unique" rejection even if
  a future bug reintroduces an injection collision.
- Added 2 new tests to `test/lib/slots/tools.test.ts` pinning the dedup
  behaviors — 15/15 slot tests pass, 1874/1874 full suite still green.

## [1.17.1]

### Fixed

- `SkillRegistry.toTools()` aliased `this` via `const registry = this` which
  tripped the `@typescript-eslint/no-this-alias` rule post-release CI.
  Replaced with explicit `.bind(this)` method captures + a direct reference
  to `this.options.autoActivate` — cleaner closure pattern, no behavioral
  change, 1872/1872 tests still pass.

## [1.17.0]

### Added

- **`SkillRegistry.autoActivate`** — one-line skill-gated tool visibility
  (`agentfootprint/skills`). Unlocks the 25+-tool regime without
  customers hand-wiring a ~30-LOC bridge for every adopter.

  When configured, the auto-generated `read_skill(id)` tool writes the
  loaded skill's id into agent decision scope. Downstream
  `AgentInstruction.activeWhen: (d) => d[stateField] === 'my-skill'`
  predicates fire naturally — so each skill's `tools: [...]` only reach
  the LLM when that skill is active. Smaller tool menus per turn, no
  token-budget drift on long tool lists.

  ```ts
  const registry = new SkillRegistry<TriageDecision>({
    surfaceMode: 'auto',
    autoActivate: { stateField: 'currentSkill' },
  });
  ```

  - `SkillRegistryOptions.autoActivate?: AutoActivateOptions` — new
    config shape: `{ stateField: string, onUnknownSkill?: 'leave'|'clear' }`
  - `read_skill` now returns `{ content, decisionUpdate: { [stateField]: id } }`
    when configured; decisionUpdate is merged into agent decision scope
    by the tool-execution stage.
  - `toInstructions()` auto-fills `activeWhen: (d) => d[stateField] === skill.id`
    on any skill that doesn't declare its own — so consumers set
    `autoActivate` once and every skill gates its own tools by id.
  - `AgentBuilder.skills(registry)` auto-switches agent pattern to
    `Dynamic` when registry has autoActivate, because Regular pattern
    assembles instructions once per turn and wouldn't re-materialize
    tools on the next iteration. Explicit `.pattern(AgentPattern.Regular)`
    after `.skills()` overrides.
  - `SkillRegistry.hasAutoActivate` / `.autoActivate` getters for
    consumers writing custom builders.

- **`ToolResult.decisionUpdate` + `ToolExecutionResult.decisionUpdate`**
  — new optional field any tool (not just auto-generated skill tools)
  can use to write a partial update into the agent's decision scope.
  The tool-execution stage applies shallow `Object.assign(decision, update)`
  after the tool runs. Built-in ToolProviders (`staticTools`,
  `gatedTools`, `compositeTools`, `agentAsTool`) pass it through from
  the inner handler.

### Changed

- Tool-execution subflow: `decisionRef` is now always allocated as `{}`
  when the inbound decision scope is undefined (previously tri-state).
  Simpler invariant + fixes a latent bug where the first turn's
  decision writes from any tool (decide() or decisionUpdate) could be
  dropped if no initial decision scope was configured.

### Tests

- 13 new 5-pattern tests for `autoActivate` (unit / boundary / scenario
  / property / security). Library total: **1872 tests passing**
  (was 1859).

## [1.16.0]

### Added

- **Skills** (`agentfootprint/skills`) — typed, versioned agent skills
  with cross-provider correct delivery. The Claude Agent SDK pattern,
  packaged at `agentfootprint`'s framework layer.
  - `defineSkill<TDecision>(skill)` factory — typed, inference-friendly.
  - `SkillRegistry<TDecision>` — compile skills into `AgentInstruction[]`
    - auto-generated `list_skills` / `read_skill` tools + optional
      system-prompt fragment.
  - `Skill extends AgentInstruction` — every `activeWhen` / `prompt` /
    `tools` / `onToolResult` field inherited, skills add `id`,
    `version`, `title`, `description`, optional `scope[]`, `steps[]`,
    and `body` (string or async loader for disk/blob/Notion).
  - Four surface modes: `'tool-only'` (portable default, works on every
    provider), `'system-prompt'`, `'both'`, `'auto'` (library picks per
    provider — Claude ≥ 3.5 → `'both'`, everyone else → `'tool-only'`).
  - `AgentBuilder.skills(registry)` — one-line wiring. Idempotent
    replace (call twice, latest wins).
  - Tag-escape defense in rendered skill bodies: `</memory>`,
    `</tool_use>`, `</skill>` escaped in author-controlled fields.
  - Error paths (unknown id, lazy-loader throws, path-traversal
    attempts) return `isError: true` in the tool result — agent
    recovers, no crash.
  - Full documentation: `/guides/skills`.
  - `ToolRegistry.unregister(id)` — small focused API for builder-layer
    idempotent replace flows.

### Tests

- 41 new tests across 2 files (32 unit + 9 acceptance).
- Library total: 1859 tests passing.

## [1.15.0]

### Added

- **`autoPipeline()`** — the opinionated default memory preset
  (`agentfootprint/memory`). Composes facts (dedup-on-key) + beats
  (append-only narrative) on a single store, emitting ONE combined
  system message on read.
  - Zero-LLM-cost defaults (`patternFactExtractor` + `heuristicExtractor`).
  - Single `provider` config knob upgrades BOTH extractors to
    LLM-backed in one line.
  - Explicit `factExtractor` / `beatExtractor` escape hatches for
    mixed-quality configurations.
  - READ subflow: `LoadAll` (one `store.list`, split by payload shape
    via `isFactId` + `isNarrativeBeat`) → `FormatAuto` (facts block +
    narrative paragraph in one system msg).
  - WRITE subflow: `LoadFacts` (update-awareness) → `ExtractFacts` →
    `WriteFacts` → `ExtractBeats` → `WriteBeats`.
  - `AutoPipelineState` extends both `FactPipelineState` +
    `ExtractBeatsState` for typed scope.
  - Full documentation: `/guides/auto-memory`.

### Tests

- 16 new tests across 2 files (5-pattern coverage + acceptance).
- Library total: 1818 tests passing.

## [1.14.0]

### Added

- **Fact extraction** (`agentfootprint/memory`). Stable key/value
  fact memory with dedup-on-write — "what's currently true" as a
  complement to beats ("what happened").
  - `Fact<V>` type with `key` / `value` / optional `confidence` /
    `category` / `refs[]` (source-message provenance, like beats).
  - `factId(key)` helper → stable `fact:${key}` MemoryStore ids.
    Last-write-wins: the same key written twice REPLACES the prior
    entry (unlike beats/messages which are append-only).
  - `FactExtractor` interface + two implementations:
    - `patternFactExtractor()` — zero-dep regex heuristics for
      identity / contact / location / preference. Free.
    - `llmFactExtractor({ provider })` — LLM-backed extraction with
      `existing`-facts prompt injection so the model can update
      rather than duplicate. One call per turn. Malformed JSON falls
      back to `[]` with `onParseError` callback.
  - Stages: `extractFacts`, `writeFacts`, `loadFacts`, `formatFacts`.
    `formatFacts` renders a compact `Known facts:` key/value block
    (not `<memory>` tags, not a paragraph) — the shape LLMs parse
    most efficiently.
  - `factPipeline({ store, extractor? })` preset. Read subflow:
    LoadFacts → FormatFacts. Write subflow: LoadFacts → ExtractFacts
    → WriteFacts (LoadFacts-on-write surfaces existing facts to the
    extractor for update-awareness).
  - Full documentation: `/guides/fact-extraction`.

### Tests

- 104 new tests across 6 files (5-pattern coverage per layer).
- Library total: 1802 tests passing.

## [1.13.0]

### Added

- **Semantic retrieval** (`agentfootprint/memory`). Vector-based
  recall via cosine similarity over entry embeddings.
  - `Embedder` interface with `embed()` / optional `embedBatch()` —
    pluggable (OpenAI / Voyage / Cohere / custom). Ships
    `mockEmbedder()` (deterministic character-frequency hash) for tests.
  - `MemoryEntry.embedding?` + `embeddingModel?` fields for indexing.
  - `MemoryStore.search?(identity, query, options)` optional method;
    `InMemoryStore` implements O(n) cosine scan. Options: `k`,
    `minScore`, `tiers`, `embedderId` (cross-model safety).
  - `cosineSimilarity(a, b)` helper; length-mismatch throws,
    zero-magnitude returns 0 (never NaN).
  - Stages: `embedMessages` (write-side) + `loadRelevant` (read-side,
    pulls query from last user message by default).
  - `semanticPipeline({ store, embedder, embedderId? })` preset —
    drop-in replacement for `defaultPipeline` with vector recall.
  - Write-side: `writeMessages` attaches per-message embeddings
    from `scope.newMessageEmbeddings` when present.
  - Read-side: `mountMemoryRead` passes `scope.messages` into the
    subflow so `loadRelevant` derives the query from the user turn.
  - 85 new 5-pattern tests + 4-scenario acceptance test.
  - `/guides/semantic-retrieval` docs.

### Changed

- `test/lib/concepts/Agent.parallelTools.test.ts` — perf threshold
  relaxed from 2× to 2.5×DELAY to tolerate dev-machine jitter while
  still discriminating parallel (≤2.5×) from sequential (3×).

## [1.12.0] — BREAKING

### Added

- **Narrative memory** (`agentfootprint/memory`). A new memory strategy
  that compresses each turn into `NarrativeBeat`s on write and recalls
  them as a single cohesive paragraph on read — instead of storing
  raw messages.
  - `NarrativeBeat` type: `{ summary, importance, refs, category? }`
    — every beat carries `refs[]` traceable back to source messages
    for explainability / audit.
  - `BeatExtractor` interface with two built-in implementations:
    - `heuristicExtractor()` — zero-dep, zero-cost baseline.
    - `llmExtractor({ provider, systemPrompt?, onParseError? })` —
      one LLM call per turn, produces semantically rich beats. Robust
      JSON parsing; malformed responses skipped without crashing turns.
  - `extractBeats(config)` + `writeBeats(config)` write-side stages.
  - `formatAsNarrative(config)` read-side stage — composes selected
    beats into a single paragraph (vs `formatDefault`'s per-entry blocks).
  - `narrativePipeline({ store, extractor?, ... })` preset — drop-in
    replacement for `defaultPipeline` with beat-based memory.
  - **Differentiator**: no other open-source agent framework provides
    beat-level traceability for recalled memory.
  - 77 new 5-pattern tests + 4-scenario acceptance test.
  - `/guides/narrative-memory` docs.

### Removed (hard break — pre-GA, no deprecation cycle)

- **`Agent.memory(config: MemoryConfig)`** builder method.
  Superseded by `.memoryPipeline(pipeline)` which landed in 1.11.0.
- **`MemoryConfig` / `ConversationStore`** interfaces and the legacy
  `InMemoryStore` adapter from `src/adapters/memory/`. The canonical
  store interface is now `MemoryStore` in `agentfootprint/memory`.
- **`createCommitMemoryStage` / `CommitMemoryConfig`** —
  `CommitMemory` stage retired; the memory pipeline's write subflow
  lives inside the `final` branch subflow and is composed via
  `mountMemoryWrite`.
- **`createPrepareMemorySubflow` / `PrepareMemoryConfig`** —
  absorbed into the memory pipeline's read subflow.
- **`persistentHistory()` message strategy + its bundled `InMemoryStore`** —
  message strategies now focus on in-context reshaping (sliding
  window, char budget, summary). Durable persistence lives in the
  memory pipeline.
- **`MessagesSlotConfig.store` / `.conversationId`** fields — the
  Messages slot is now strategy-only. Durable persistence is owned by
  the memory pipeline.
- **`AgentLoopConfig.commitMemory` / `.useCommitFlag` / `.onStreamEvent`**.
  Memory wiring flows via `memoryPipeline`. Stream events route
  through the emit channel — attach an onEvent callback via
  `agent.run(msg, { onEvent })`.
- **`memory_storedHistory` scope field + `MEMORY_PATHS.STORED_HISTORY`** —
  dead after `CommitMemory` removal.
- **Legacy store adapters** `redisStore`, `dynamoStore`, `postgresStore`
  — real backends land in Phase 3 against the new `MemoryStore` interface.

### Changed

- **Conditional concept** (`Agent.route()` extensions) now mounts
  branches as subflows when the runner exposes `toFlowChart()`,
  matching the `FlowChart.ts` / `Swarm` patterns. UI consumers get
  drill-down into routed-to agents for free.
- **Stream events now flow through the emit channel.**
  `agentfootprint.stream.llm_start` / `llm_end` / `token` / `thinking`
  / `tool_start` / `tool_end` events are emitted with the full
  `AgentStreamEvent` as the payload. `AgentRunner` attaches a
  `StreamEventRecorder` (public API in `agentfootprint/stream`) that
  forwards emits to the consumer's `{ onEvent }` callback — zero
  closure capture of handlers inside stage code.
- **Agent chart is now CACHED** — built once per agent, reused across
  all `.run()` and `.toFlowChart()` calls. Per-run data (stream handler,
  memory identity, seed messages) flows via args / attached recorders.
- **`pickByBudget`** restructured as a proper decider stage with three
  branches (`skip-empty`, `skip-no-budget`, `pick`) — decision evidence
  now lands on `FlowRecorder.onDecision` with structured `rules[]`.
- **`MemoryStore.putMany`** added for batched writes. `writeMessages`
  now persists a turn's messages in one round-trip instead of N.
- **`RouteResponse` decider** uses the filter-form `decide()` DSL with
  structured evidence (`{ key: 'hasToolCalls', op: 'eq', threshold: true, … }`).
  `ParseResponse` lifts `parsedResponse.hasToolCalls` to the flat
  `scope.hasToolCalls` so the filter form can reach it.
- **`buildSwarmRouting` + `Conditional`** deciders return full
  `DecisionResult` objects so `FlowRecorder.onDecision` captures
  evidence (no more silent `.branch`-only returns).

### Migration

Replace:

```ts
const store = new InMemoryStore();
const agent = Agent.create({ provider }).memory({ store, conversationId: 'user-123' }).build();
```

With:

```ts
import { defaultPipeline, InMemoryStore } from 'agentfootprint/memory';

const pipeline = defaultPipeline({ store: new InMemoryStore() });
const agent = Agent.create({ provider }).memoryPipeline(pipeline).build();

await agent.run(message, {
  identity: { conversationId: 'user-123' },
});
```

## [1.11.0]

### Added

- **`agentfootprint/memory` subpath — full memory pipeline system.** Built bottom-up in 9 reviewed layers, 190 tests, composing into a flowchart-first architecture consistent with the rest of the library.
  - **Identity + entries** — `MemoryIdentity { tenant?, principal?, conversationId }`, `MemoryEntry<T>` with decay/tier/source/version, pure `computeDecayFactor()` with exponential time decay + access boost.
  - **`MemoryStore` interface** — 9-method CRUD boundary with pagination cursor, `putIfVersion` optimistic concurrency, `seen()` recognition, `feedback()` usefulness aggregation, `forget()` GDPR delete. `InMemoryStore` reference implementation (zero deps, TTL-aware, tenant-isolated).
  - **Reusable stages** — `loadRecent`, `writeMessages`, `pickByBudget` (decider — budget-aware selection with `decide()` evidence), `formatDefault` (source-cited `<memory>` blocks + prompt-injection escape), `summarize` (deterministic contract for prompt caching).
  - **Pipeline presets** — `defaultPipeline()` (load → pick → format for read; persist for write), `ephemeralPipeline()` (read-only, compliance-grade no-write guarantee).
  - **Wire helpers** — `mountMemoryRead`, `mountMemoryWrite`, `mountMemoryPipeline` for composing pipelines into custom flowcharts.
- **`Agent.memoryPipeline(pipeline)` builder method** — first-class integration wiring the pipeline's read subflow before `AssemblePrompt` and write subflow after `Finalize`. Prior-turn memory is injected as citation-tagged `system` messages that AssemblePrompt prepends to the LLM prompt.
- **Per-run identity via `agent.run(msg, { identity, turnNumber?, contextTokensRemaining? })`** — same agent instance can serve many tenants / sessions with hardware-enforced isolation. Identity defaults to `{ conversationId: 'default' }` when omitted.
- **Example** `examples/memory/30-remember-across-turns.ts` — Alice/Bob session isolation demo using `mock` adapter.
- **5 integration tests** in `test/integration/memoryPipeline.test.ts` covering turn-1 persistence, turn-2 retrieval, per-run identity scoping, tenant isolation, and `.memory()` vs `.memoryPipeline()` mutual exclusivity.

### Process

- Every one of the 9 layers cleared an 8-person review gate (performance, DS/algorithms, security, research/RAG, platform, Anthropic, abstract/modular, 5-pattern tests) — iterating until no actionable findings remained. All 7 industry + 3 research reviewer asks from the design phase landed (hierarchical identity, pagination, `putIfVersion`, source-tagged recall, budget-aware picker, `seen()` + `feedback()`, decay math, ephemeral mode, deterministic summarizer, prompt-injection escape in formatter).

### Compatibility

- Existing `Agent.memory(MemoryConfig)` legacy API is unchanged. New consumers should prefer `.memoryPipeline()`. The two cannot be combined on the same builder — builder throws if both are set.
- Internals: `AgentLoopConfig` gains optional `memoryPipeline?: MemoryPipeline` alongside the existing `commitMemory?`. Legacy `commitMemory` path takes precedence when both somehow reach the loop (guards exist at the builder level).

## [1.10.0]

### Added

- **`exportTrace(runner, { redact?: boolean })`** — capture an agent run's full state as a portable JSON trace for external sharing. Bundles `snapshot`, `narrativeEntries`, `narrative`, and `spec` into a `AgentfootprintTrace` shape with `schemaVersion: 1`. Default `redact: true` requests `getSnapshot({ redact: true })` from the runner so footprintjs's [4.14.0 redacted-mirror](https://github.com/footprintjs/footPrint/blob/main/docs/internals/adr-002-redacted-mirror.md) feature scrubs `sharedState`. Use this to ship traces to a viewer, support engineer, or audit log without leaking PII.
- **`AgentfootprintTrace` + `ExportTraceOptions` types** exported from the main entry. Pin consumers to `schemaVersion: 1`; future shape changes will bump the version.
- **Example** `examples/observability/29-export-trace.ts` — captures and prints a trace using the `mock` adapter.
- **10 new tests** (5 patterns) covering schema version, snapshot pass-through, missing-method graceful degradation, JSON round-trip, and the safe-by-default `redact: true` choice.

### Changed

- **`footprintjs` peer dep + devDep bumped to `^4.14.0`** — required for the redacted-mirror `getSnapshot({ redact })` API. `exportTrace` falls back to a 0-arg `getSnapshot()` if the runner predates 4.14, so older deployments still produce a (raw) trace.

## [1.9.0]

### Added

- **`agentfootprint/patterns` — canonical composition patterns as thin factories.** Each pattern composes existing concepts (FlowChart / Parallel / Conditional / Agent / LLMCall) and returns a standard Runner — no new primitives, no new classes. Source files are short and teach the composition pattern.
  - `planExecute({ planner, executor })` — sequential planning → execution (FlowChart of 2).
  - `mapReduce({ provider, mappers, reduce })` — N pre-bound mappers fanned out, then reduced via LLM or pure fn (Parallel with named merge).
  - `treeOfThoughts({ provider, branches, thinker, judge })` — N parallel thinkers, judge picks the best (FlowChart of Parallel → judge).
  - `reflexion({ solver, critic, improver })` — single-pass Solve → Critique → Improve (FlowChart of 3). Multi-iteration variants compose with `Conditional`.
- **Example**: `examples/orchestration/28-patterns.ts` — all four patterns + a composed `Conditional` routing between them, all using the `mock` adapter.
- **10 new tests** covering wiring, input propagation, argument validation, and patterns-inside-patterns composition.

## [1.8.0]

### Added

- **`Conditional` concept — the DAG branch primitive.** Thin wrapper over footprintjs `addDeciderFunction` + `addFunctionBranch` that routes between runners based on synchronous predicates. First-match-wins; failing predicate fail-opens to the next branch; `.otherwise(runner)` is required. Exposes the same Runner surface as other concepts (`run`, `getNarrative`, `getSnapshot`, `getSpec`, `toFlowChart`) and composes inside `FlowChart` / `Parallel` / `Agent.route()` / another `Conditional`.

  ```ts
  const triage = Conditional.create({ name: 'triage' })
    .when((input) => /refund/i.test(input), refundAgent, { id: 'refund' })
    .when((input) => input.length > 500, ragRunner)
    .otherwise(generalAgent)
    .build();

  await triage.run('I want a refund');
  // narrative: "[triage] Chose refund — predicate 0 matched"
  ```

  Completes the DAG primitive set: **leaf** (LLMCall/RAG), **cycle** (Agent), **sequence** (FlowChart), **fan-out** (Parallel), **branch** (Conditional), **dispatch** (Swarm). Users can now build any composition from existing concepts without dropping to raw footprintjs.

- **Guards on `Conditional.when()`** — rejects non-function predicates, non-runner values, reserved `'default'` id, branch IDs with `/` or whitespace (would break `runtimeStageId`), and duplicate IDs. Fail-open on throwing predicates (never blocks a valid branch). Frozen state snapshot passed to predicate — mutation attempts silently no-op.
- **Example**: `examples/orchestration/27-conditional-triage.ts` — deterministic triage demo using the `mock` adapter.
- **25 new tests** across 5 patterns (unit/boundary/scenario/property/security), including real Agent composition and nested Conditionals.

## [1.7.1]

### Fixed

- **CI + npm publish** — `devDependencies.footprintjs` was pinned to `file:../footPrint`, which doesn't resolve in CI. Switched to `^4.13.0` so CI installs from the registry. `footprintjs` is also now declared as a `peerDependency` (`>=4.13.0`) to make the install-time contract explicit. This is why v1.7.0 failed to publish.

## [1.7.0]

### Added

- **Emit-channel LLM diagnostics.** `CallLLM` stage (both streaming and non-streaming) now fires `scope.$emit('agentfootprint.llm.request', {...})` before the provider call and `scope.$emit('agentfootprint.llm.response', {...})` after, surfacing the exact shape being sent/received. Payloads include iteration, message roles, tool names + required fields, usage, stop reason, and tool-call signatures.
- **`agentRenderer.renderEmit`** — custom narrative rendering for `agentfootprint.llm.request`/`response` events. Output like `LLM request (iter 2): 5 msgs [system,user,assistant,tool,tool], 4 tools — calculator required:[expression]` appears inline under each `CallLLM` stage in combined narratives.
- **`AgentBuilder.maxIdenticalFailures(n)`** — threshold for repeated-identical-failure escalation. When a tool call with the exact same `(name, args)` has failed `n` times in a row, a one-shot `escalation` field is injected into that tool result content urging the LLM to change arguments, switch tools, or finalize. Fires exactly once per `(name, args)` key per conversation. Defaults to `3`. Pass `0` to disable. Uses strict JSON parsing (not substring sniffing) so legitimate prose containing `"error":true` is not misclassified; stable key-sorted stringify so equivalent arg objects match regardless of insertion order.
- **`scope.maxIterationsReached` signal** — when the agent loop hits `maxIterations`, the structural guard now sets this flag AND force-routes to the default branch. Any terminal stage (default `Finalize`, `Swarm.RouteSpecialist` fallback, user-supplied terminals) can detect forced termination and synthesize an appropriate final message. Finalize now emits a user-facing fallback when the flag is set.
- **Tool-call signatures in narrative.** `ParseResponse` now renders `responseType` as `tool_calls: [calculator({"expression":"4+5"}), web_search({"query":"weather"})]` — names plus JSON-stringified args (tight cap) so debuggers see at a glance whether the LLM passed required fields. Names alone hid the common failure mode of retrying with empty / wrong args.

### Fixed

- **Anthropic streaming adapters dropped tool arguments.** `BrowserAnthropicAdapter.chatStream()` and `AnthropicAdapter.chatStream()` yielded `tool_call` chunks with `arguments: {}` at `content_block_start`, then accumulated `input_json_delta` chunks into a buffer that was never consumed. The streaming stage pushed the empty-args version, causing LLMs to re-attempt calls with `{}` until `maxIterations` exhausted. Fixed by deferring the `tool_call` yield until args are complete — emit at `content_block_stop` with parsed JSON (browser) / after `stream.finalMessage()` (Node SDK). Combined with the new emit-channel diagnostics, this bug was diagnosable for the first time.

### Changed

- **Requires `footprintjs` >= 4.13.0** for emit-channel features. Install explicitly: `npm install footprintjs@^4.13.0 agentfootprint@^1.7.0`.

## [1.6.1]

### Fixed

- **CI + publish workflows** — `npm install` instead of `npm ci`, no npm cache (lockfile not committed due to platform-specific native deps). This is why v1.5.0 and v1.6.0 failed to publish to npm.
- **footprintjs devDep** bumped to `^4.12.2` (resume continuation fix).

## [1.6.0]

### Added

- **`examples/` directory** — 22 type-checked examples as single source of truth (was in separate agent-samples repo). 8 categories: basics, providers, orchestration, observability, security, resilience, memory, integration.
- **`test:examples` npm script** — type-checks all examples against library source.
- **Barrel exports** — `agentLoop`, `AgentLoopConfig`, `defineInstruction`, `AgentPattern`, `quickBind`, `AgentInstruction`, `InstructedToolDefinition`, `TokenRecorder`, `ToolUsageRecorder`, `TurnRecorder`, `CostRecorder` from main entry. `staticTools`, `noTools` from `/providers`. `ExplainRecorder` from `/observe`.
- **3 new examples** — agent-loop (low-level engine), instructions (conditional context injection), explain-recorder (grounding evidence).

### Changed

- **`ToolHandler` type** — `(input: any)` instead of `(input: Record<string, unknown>)`. Allows typed destructured params in tool handlers: `({ query }: { query: string }) =>`. Non-breaking.
- **`footprintjs` peer dep** — bumped to `>=4.12.0` (backtracking, quality trace, staged optimization).

### Fixed

- **4 pre-existing type errors** in examples (API drift from agent-samples): resilience callbacks, ToolDefinition.name→id, message strategy args, instruction type casts.

## [1.5.0] - 2026-04-09

### Added

- **`runtimeStageId`** — mandatory on `LLMCallEvent` and `ToolCallEvent`. The universal key linking recorder data to execution tree nodes and commit log entries. Format: `[subflowPath/]stageId#executionIndex`.
- **Map-based recorders** — `TokenRecorder`, `ToolUsageRecorder`, `CostRecorder` extend `KeyedRecorder<T>` from `footprintjs/trace`. O(1) lookup via `getByKey(runtimeStageId)`, `getMap()`. Zero fallback keys.
- **`EvalIteration.runtimeStageId`** — each iteration links to its execution step
- **`createLLMCaptureRecorder()`** — shared factory for run() and resume() LLM capture. Both paths now track `runtimeStageId` for stream bridge tool events.
- **`RecorderBridge.setToolRuntimeStageId()`** — encapsulated state tracking (was public mutable field)
- 5 new tests for runtimeStageId on all recorder types

### Changed

- **footprintjs >=4.7.0 required** — added to `dependencies` (was only in devDependencies)
- **`agentLoop.ts`** — uses `buildRuntimeStageId` + `createExecutionCounter` from `footprintjs/trace`
- **`LLMCallRunner` + `RAGRunner`** — use `findCommit` from `footprintjs/trace` (zero `(b: any)` casts)
- CLAUDE.md + AGENTS.md — documented `runtimeStageId`, `KeyedRecorder`, `getByKey()` pattern

### Removed

- All `__auto_` fallback keys — runtimeStageId is always provided
- Duplicate LLM capture code in resume() path — replaced by shared factory

## [1.4.2] - 2026-04-07

### Fixed

- **README rewrite** — Architecture moved to 3rd section, headers renamed to relatable terms (Conditional Behavior, Observability, Human-in-the-Loop), 4 broken import paths fixed, redundant sections folded, 380→280 lines
- **5 folder READMEs** — concepts, adapters, providers, memory, tools with relatable naming and code examples
- **recorders/README.md** — 5 categories, event→recorder mapping, design principles
- **What's Different section** — 10 unique features grouped by concern (Quality/Safety/UX/Debugging)

## [1.4.1] - 2026-04-07

### Fixed

- **`RecorderBridge.loopIteration`** — now increments after each `dispatchLLMCall` (was always 0)
- **Per-iteration context** — each LLM call gets its own context snapshot (was sharing last state for all)
- **`resume()` path** — captures context same as `run()` (was empty)
- **`ExplainRecorder`** — guards `iteration: -1` when `onTurnComplete` fires without `onLLMCall`
- **Format gate** — release script fails on unformatted files instead of silently fixing

### Added

- **5 folder READMEs** — concepts, adapters, providers, memory, tools — with relatable naming (Single LLM / Multi-Agent), code examples, and cross-references
- **Main README** — 5-layer architecture diagram (Build → Compose → Evaluate → Monitor → Infrastructure), updated Recorders section with 5 categories
- **recorders/README.md** — event → recorder mapping, design principles
- **5 tests** for `EvalIteration`, per-iteration context, flat/iteration consistency
- **Flattened `recorders/v2/`** → `recorders/` — removed unnecessary indirection

### Changed

- `CLAUDE.md` + `AGENTS.md` — updated directory tree descriptions

## [1.4.0] - 2026-04-07

### Added

- **`explain().iterations`** — per-iteration evaluation units with connected data. Each iteration captures context (what the LLM had), decisions (tools chosen), sources (results), and claim (LLM output). Evaluators walk iterations to check faithfulness, relevance, and hallucination.
- **`EvalIteration` type** — self-contained evaluation unit for each loop iteration

## [1.3.0] - 2026-04-07

### Added

- **`explain().context`** — ExplainRecorder captures evaluation context during traversal: input, systemPrompt, availableTools, messages, model
- **`LLMContext` type** — what the LLM had when making decisions
- **`LLMCallEvent.systemPrompt`/`toolDescriptions`/`messages`** — context fields on events (optional, backward-compatible)

## [1.2.0] - 2026-04-07

### Added

- **`obs.explain()`** — ExplainRecorder bundled into `agentObservability()` preset. Grounding analysis (sources vs claims) out of the box — the differentiator.
- **8-gate release script** — mirrors footprintjs: doc check, dup type check, build, tests, sample projects, CHANGELOG validation
- **`scripts/check-docs.sh`** — blocks release if docs reference removed APIs
- **`scripts/check-dup-types.mjs`** — blocks release if duplicate type definitions found across src/

### Fixed

- **ModelPricing duplicate** — CostRecorder now imports from `models/types` instead of redefining

## [1.1.0] - 2026-04-07

### Added

- **Message strategies in providers barrel** — `slidingWindow`, `charBudget`, `fullHistory`, `withToolPairSafety`, `summaryStrategy`, `compositeMessages`, `persistentHistory` now exported from `agentfootprint/providers`
- **Error utilities in resilience barrel** — `classifyStatusCode`, `wrapSDKError` now exported from `agentfootprint/resilience`

### Removed

- **`getGroundingSources`, `getLLMClaims`, `getFullLLMContext`** from `agentfootprint/explain` — post-processed narrative entries (anti-pattern). Use `ExplainRecorder` instead, which collects during traversal.
- **`slidingWindow`, `truncateToCharBudget`** from internal `memory/conversationHelpers` — dead code duplicating the public `MessageStrategy` API in `providers/messages/`

## [1.0.0] - 2026-04-06

### Added

- **Capability-based subpath exports** — 7 focused import paths, tree-shakeable:
  - `agentfootprint/providers` — LLM providers, adapters, prompt/tool strategies
  - `agentfootprint/instructions` — defineInstruction, AgentPattern, InstructionRecorder
  - `agentfootprint/observe` — all 9 recorders + agentObservability preset
  - `agentfootprint/resilience` — withRetry, withFallback, resilientProvider
  - `agentfootprint/security` — gatedTools, PermissionPolicy
  - `agentfootprint/explain` — grounding helpers, narrative renderer
  - `agentfootprint/stream` — AgentStreamEvent, SSEFormatter
- **Full backward compatibility** — `import { everything } from 'agentfootprint'` still works
- **`typesVersions`** in package.json for older TypeScript resolution

### Changed

- `index.ts` reorganized with comments pointing to capability subpaths
- PermissionRecorder canonical home is `agentfootprint/observe` (removed from security barrel)

## [0.6.2] - 2026-04-05

### Added

- **Instructions guide** — `docs/guides/instructions.md` (Decision Scope, 3-position injection, decide())
- **Streaming guide** — `docs/guides/streaming.md` (AgentStreamEvent, onEvent, SSE, event timeline)
- **Sample 17** — Instructions (defineInstruction, decide, conditional activation, tool injection)
- **Sample 18** — Streaming events (lifecycle, tool events, ordering, backward compat, SSE)
- **Module READMEs** — `src/lib/instructions/`, `src/streaming/`, `src/lib/narrative/`
- **CLAUDE.md + AGENTS.md** — Instructions, Streaming, Grounding sections + anti-patterns
- **README.md** — Instructions, Streaming, Grounding Analysis sections
- **JSDoc** — `@example` on `getGroundingSources()`, `getLLMClaims()`

## [0.6.1] - 2026-04-05

### Added

- **AgentStreamEvent** — 9-event discriminated union for real-time agent lifecycle
  - `turn_start`, `llm_start`, `thinking`, `token`, `llm_end`, `tool_start`, `tool_end`, `turn_end`, `error`
  - `onEvent` callback on `agent.run()` — full lifecycle visibility for CLI/web/mobile consumers
  - Works in both streaming and non-streaming mode (only `token` requires `.streaming(true)`)
  - `turn_end` emits `paused: true` on ask_human pause
- **Backward compat** — `onToken` still works (deprecated, sugar for `onEvent` token filter)
- **Collision guard** — `onEvent` + `onToken` together: `onToken` ignored + dev-mode warn
- **Error isolation** — `onEvent` handler errors swallowed (never crash agent pipeline)

### Fixed

- `streamingCallLLMStage` fallback path now passes `signal` for cancellation
- `tool_end.latencyMs` excludes instruction processing overhead

## [0.6.0] - 2026-04-05

### Added

- **Instruction Architecture** — `AgentInstruction`, `defineInstruction()`, `InstructionsToLLM` subflow
  - 3-position injection: system prompt, tools, tool-result recency window
  - `activeWhen(decision)` — state-driven conditional instruction activation
  - `decide()` field on `LLMInstruction` — tool results update Decision Scope
  - `AgentScopeKey` enum — type-safe scope key references
- **Agent builder API** — `.instruction()`, `.instructions()`, `.decision()`, `.verbose()`
- **Grounding helpers** — `getGroundingSources()`, `getLLMClaims()`, `getFullLLMContext()`
- **Verbose narrative** — `createAgentRenderer({ verbose: true })` shows full values
- **Dynamic ReAct + Instructions** — `AgentPattern.Dynamic` loops back to `InstructionsToLLM`

### Fixed

- Tool names duplication in Dynamic mode (uses `ArrayMergeMode.Replace`)
- `toolProvider` wired through `buildConfig` for execution
- AssemblePrompt replaces system message in Dynamic mode
- Browser compat (`process.env` guarded)
- Registry mutation moved to constructor (runs once)
- Pausable root stage (no post-build graph mutation)
- Streaming stage typed as `TypedScope<AgentLoopState>`

### Changed

- Peer dependency: `footprintjs >= 4.4.1` (was `>= 4.0.0`)
- Eliminated `ApplyPreparedMessages` and `ApplyResolvedTools` copy stages

## [0.3.0] - 2026-03-29

### Fixed

- `setEnableNarrative()` removed from FlowChartBuilder chain — call `executor.enableNarrative()` instead (footprintjs v3.x API)
- Stage functions in LLMCall, Agent, RAG, FlowChart now receive a plain `ScopeFacade` via `agentScopeFactory`, bypassing TypedScope proxy (required for `getValue`/`setValue` access)

### Changed

- Peer dependency: `footprintjs >= 3.0.0` (was `>= 0.10.0`)

## [0.2.0] - 2026-03-17

### Added

- **Browser LLM adapters**: `BrowserAnthropicAdapter` and `BrowserOpenAIAdapter` — fetch-based, zero peer dependencies
  - Direct browser-to-API calls using user's own API key
  - Full chat() + chatStream() with SSE streaming via ReadableStream
  - Tool call support, AbortSignal, custom baseURL for compatible APIs
  - Anthropic CORS via `anthropic-dangerous-direct-browser-access` header
  - OpenAI `stream_options.include_usage` for streaming token counts
- 18 browser adapter tests

### Removed

- Legacy v1 recorders: LLMRecorder, CostRecorder, RAGRecorder, MultiAgentRecorder (no users yet, replaced by v2 AgentRecorder interface)

## [0.1.0] - 2026-03-15

### Added

- **Concept ladder**: LLMCall, Agent, RAG, FlowChart, Swarm — each builds on the previous
- **LLM Adapters**: AnthropicAdapter, OpenAIAdapter, BedrockAdapter with full chat + streaming
- **Provider bridge**: `createProvider()` connects config factories (`anthropic()`, `openai()`, `ollama()`, `bedrock()`) to adapter instances
- **Mock adapter**: `mock()` for $0 deterministic testing — same code path as production
- **Multi-modal content**: Base64 and URL image support across all adapters
- **Error normalization**: `LLMError` with 9 error codes, `retryable` flag, `wrapSDKError()` auto-classifier
- **Compositions**: `withRetry()`, `withFallback()`, `CircuitBreaker` for resilient agent execution
- **V2 Recorders**: TokenRecorder, TurnRecorder, ToolUsageRecorder, QualityRecorder, GuardrailRecorder, CostRecorderV2, CompositeRecorder
- **V1 Recorders**: LLMRecorder, CostRecorder, RAGRecorder, MultiAgentRecorder _(removed in 0.2.0)_
- **Protocol adapters**: `mcpToolProvider()` for MCP, `a2aRunner()` for A2A
- **Prompt providers**: staticPrompt, templatePrompt, skillBasedPrompt, compositePrompt
- **Tool providers**: agentAsTool, compositeTools, ToolRegistry, defineTool
- **Memory management**: slidingWindow, truncateToCharBudget, appendMessage
- **Streaming**: StreamEmitter, SSEFormatter
- **Agent loop**: Low-level `agentLoop()` for custom control flow
- **16 sample tests** covering every feature
- **608 tests** across 63 test files
