Skip to content

V6: Session state and provenance carriage - #15

Merged
afogel merged 25 commits into
slice/v5from
slice/v6
Sep 6, 2026
Merged

V6: Session state and provenance carriage#15
afogel merged 25 commits into
slice/v5from
slice/v6

Conversation

@afogel

@afogel afogel commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #7

Stacked on slice/v5. Review the diff against that base, not main. Slice 6 of 8.

What this slice is for

The Guardian remembers the session: each step is chained, and labels AGT emits on one step come back as input on the next.

Without it, AGT’s own spec says the engine stores nothing — and nobody here is doing that job either. IFC / session claims are empty, and V7 has nothing honest to measure there.

V5 proved two hosts share one runtime. This slice is the Guardian doing the host job AGT explicitly delegates. It is not a signed audit chain, and it is not the handshake cache from V3 (SessionConfig vs SessionContext are different objects).

What a reviewer is looking at

On every arriving step — before a snapshot is assembled, so a later deny is still recorded — the Guardian appends a SHA-256-linked entry (method, request_id, tool_name; never a decision). Labels from AGT’s result_labels persist on a session provenance record and return as input.ifc.source_labels.

The store is in-memory. Restarting the Guardian drops it. An optional JSONL projection is what the Inspector tails. Responses do not carry ACS chain_hash. The Inspector checks that prev_hash matches the previous line for that session; it does not recompute the digest, so this is not tamper-evidence.

Neither host changes. The demo talks to the Guardian over HTTP. The labels never appear on the ACS wire.

What ships

  • Hash-chained SessionContext in the Guardian
  • IFC label round-trip (persistIfcLabels / supplySourceLabels)
  • Inspector session-chain view
  • config.ifc.sink_clearance: "confidential" in data.json (no authored Rego)

Intent exists as an immutable store and is not wired from the ACS intent field yet.

Captured walkthrough: docs/demos/v6-runbook.md.

@afogel afogel changed the title V6: Second host, zero AGT changes V6: Session state and provenance carriage Aug 9, 2026
@afogel
afogel force-pushed the slice/v6 branch 2 times, most recently from 068cd3b to c9821d8 Compare August 10, 2026 06:17
@afogel
afogel force-pushed the slice/v6 branch 2 times, most recently from 75f1019 to e849829 Compare August 10, 2026 06:28
@afogel
afogel force-pushed the slice/v6 branch 2 times, most recently from cab302a to 2456279 Compare August 11, 2026 03:45
@afogel
afogel force-pushed the slice/v6 branch 2 times, most recently from 4f766b0 to bdea8ef Compare August 11, 2026 11:07
@afogel
afogel force-pushed the slice/v6 branch 2 times, most recently from 7e5b7d5 to fd49db5 Compare August 13, 2026 18:09
V6 has no code yet, so the affordance tables are still the only place
these two functions are named -- and both names were set to mislead the
implementer who writes them first.

N22 `appendSessionEntry()` -> `appendContextEntry()`. Its store is
SessionContext (S3), so any reader of that store is `loadSessionContext`,
and the pair `loadSessionContext` / `appendSessionEntry` names one store
with two different nouns.

N25 `persistResultLabels()` -> `persistIfcLabels()`. This Guardian has
had a real result gate since V4 (`post_tool_call`), so "result labels"
now reads as that gate's labels rather than as AGT's IFC tags, which is
what they are.

S5 and the demo sentence stop making ACS `Provenance` the label store.
`provenance.json` defines `provenance_id` / `origin` / `source_id` /
`derived_from` and is already a field on every tool argument and result
output item in the v0.1.0 hook schemas; the labels ride it as a named
`IfcLabels` field, so the docs say field rather than store.

N23's row and node pick up the names its two assemblers actually ship
under -- `assemblePreToolCallSnapshot` / `assemblePostToolCallSnapshot`,
per packages/guardian/src/assemble-snapshot.ts, whose header cites these
tables as the authority for its own naming.
…ists

`@acs/host-adapter` already exports SessionConfig, SessionConfigStore,
ResolvedSessionConfig, SessionConfigNotStoredError,
SessionConfigStoreFailedError and SessionFailureKind, every one of them
about the handshake. V6 adds a session-shaped noun that is about
something else, and with no V6 file written yet that name is still
free to be chosen deliberately rather than by whoever types it first.

Five commitments, each a sentence an implementer can be held to rather
than a bullet of vocabulary: SessionContext stays distinct from
SessionConfig and stays Guardian-side (R6.2, A3); the IFC label store is
`IfcLabels` / `source_labels` and ACS `Provenance` is not it; N25 is
`persistIfcLabels` twinned with `supplySourceLabels`; N22 is
`appendContextEntry` twinned with `loadSessionContext`; and session state
is injected into both shipped assemblers rather than collapsing N23 back
into one function that asks which intervention point it is on.

Nothing here asserts what an unwritten function will do -- each claim
about a shipped object names the file it is shipped in. The demo
sentence picks up the same field-not-store correction as the master doc.
Renaming N23's Detail C row left two live-voice sites still naming the
retired assemblers, both of which now contradict that row.

`acs-reference-impl-slices.md:173` (§V4 body prose) described the system
as it is and said `N23 assembleSnapshot` / `assembleResultSnapshot`; it
now says `assemblePreToolCallSnapshot` / `assemblePostToolCallSnapshot`.
`slices/v4/README.md:88-89` had the same defect in the same voice, in its
list of what V4 delivered, and moves the same way. The sibling structure
those sentences describe is unchanged and still true -- only the names
were stale.

**Why this commit reaches into a V4 file from slice/v6.** It puts one
V4 README hunk in V6's PR diff, which is not where a reviewer expects it.
The trade is deliberate: the alternative is a master doc whose Detail C
row and whose V4 README disagree about what two shipped functions are
called, and a reader trusting the live-voice prose would cite a name that
exists nowhere in the tree. A contradiction in the docs costs more than a
slightly wider diff.

Two sites keep the old names because they are dated records rather than
live description, and both now say so rather than leaving a reader to
infer it: §V1's affordance row (a new note dates it and points at Detail
C for current names) and §V4's correction paragraph (which quotes the
sentence it corrects, so changing the names would break the quotation).

Also: V6 commitment 5 moves from "is injected" to "must be injected", so
it reads as the obligation it is rather than as a behavioural claim about
code that does not exist yet.
Fix round 1 parked six of these hits behind "needs frozen-path access".
That was wrong. `scripts/verify-zero-diff.sh` freezes `policy/lib/`, not
`policy/`, and `packages/guardian/src/`, not `packages/guardian/test/` --
and the script's own comment says why the manifest is deliberately
outside the fence: it is the deployment's tool registry, not its policy.
Only `packages/guardian/src/handshake.ts:54-55` was ever out of reach.

policy/manifest.yaml was the sharpest instance and is now fixed: it
pointed a reader at packages/guardian/src/assemble-snapshot.ts by a name
absent from that file, which is the exact failure the sweep exists to
prevent. Two guardian test comments move for the same reason.

`assembleResultSnapshot` was never a function. `git log --all -S"export
function assembleResultSnapshot"` returns nothing, and V4's own 32dec42
shipped that assembler as `assemblePostToolCallSnapshot` directly -- the
old name lived only in prose. So there is no shipped history for it to be
a record of, which is why §V4's unquoted clauses were renamed rather than
dated. `assembleSnapshot` is different: it really did ship, from ccebb52
until ed274c7 renamed it, so the one quotation that carries it stays.

§V4's correction paragraph now leads with the naming note instead of
closing with it, so a reader meets the disclaimer before the stale name
rather than after -- the previous placement left the whole paragraph
reading as current fact until its last sentence.
The reviewed default used origin: "acs.guardian", which is not one of
the seven values spec/acs/specification/v0.1.0/provenance.json defines
for Provenance.origin. Add ProvenanceOrigin as a named union of those
seven values and type origin with it, so a value outside the enum is a
compile error here rather than a schema failure downstream. The
Guardian's synthesized session provenance now reads origin: "system"
with source_id: "acs.guardian" -- the spec's field for "identifier
within the origin" is where that name belongs.

Also cover putProvenance, which was on the brief's produced interface
but untested: assert it replaces the provenance record, ifc_labels
included, and that the replacement reads back through
loadSessionContext.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…side copy

Task 2 fix round 1: the propagated-labels citation named policy/lib/ifc.rego,
the upstream module AGT_ifc.rego's own header disclaims ("AGT users MUST
import data.agt.ifc rather than the upstream package"). Cite
policy/lib/agt_ifc.rego instead -- the module policy/lib/agt_default.rego
actually imports -- and say why it is the one that runs. Also add the
write-side defensive-copy test that was missing next to the read-side one.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…arries no labels

Turns on AGT's own IFC gate (policy/lib/data.json's agt.defaults.config.ifc.
sink_clearance: "confidential") and seeds emptySessionContext's provenance at
the lattice floor (["public"]), because policy/lib/agt_ifc.rego's
flow_allowed_with_lattice requires count(labels) > 0 -- a zero-label flow is
denied outright, not waved through, so an unseeded session could do nothing
at all.

Six pre-existing fixtures (test/redaction.test.ts, packages/agt-bridge/test/
bridge.test.ts, packages/guardian/test/assemble-snapshot.test.ts) build
snapshots by hand, bypassing the session store the seed lives in. Each now
carries the ["public"] label the Guardian would have supplied, so it
represents what this deployment actually produces. A new precedence test in
test/ifc-round-trip.test.ts pins that a labelled snapshot still reaches the
pattern gate -- IFC deny outranks every other gate in agt_default.rego's own
severity ranking, so this is what proves the six edits are fixture repairs
and not silenced tests.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…n, and strip round citations from shipped comments

Important: "denies the -fr spelling too" (packages/agt-bridge/test/
bridge.test.ts) was the only coverage of data.json's second destructive-
command regex, and it had no label -- so it was passing for IFC's reason
rather than the pattern's, and deleting that regex would have left the
whole suite green. Gave it the same public label and reason assertion as
its sibling; proved it by temporarily removing the regex from data.json
(cp backup, restored byte-identical after) and watching the test go red.

Also: restored the "surfaces the policy config to Rego" test's ability to
tell the patterns config surfaced from the ifc config surfacing (label
plus a reason assertion); corrected the publicLabel comment in
bridge.test.ts, which claimed a selection rule ("checks more than
.decision") its own code contradicted; and stripped "(Task 4, fix round
N)" citations from shipped comments across six files -- an SDD workspace
reference that will be unresolvable once this slice lands, with the
behavioural half of each comment kept intact.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…ved sessions, and stop main.ts from splitting rendered text to find a row

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Seven statements in shipped text said things the code beside them does not
do. None of them changed behaviour and none is fixed by changing behaviour;
each is replaced by what was measured.

- The `bridge` option and the two `recordingBridge` comments said the stock
  IFC gate was off and that no real verdict carried `result_labels`. It is
  on (`policy/lib/data.json` sets `config.ifc.sink_clearance`) and they do
  (`test/redaction.test.ts`). The real reason the stub exists is that AGT
  propagates the labels it is handed and originates none, so a real bridge
  could only ever show `public -> public`, and it returns a verdict rather
  than the snapshot it was given.
- The session-context log fail-open test claimed a hand-verified ENOTDIR.
  Measured: EEXIST -- `mkdirSync(dirname(path), {recursive: true})` is asked
  to create a directory where a plain file already sits. ENOTDIR is the
  errno for a path beneath such a file, which this is not.
- `handshake.ts` named `assembleSnapshot` / `assembleResultSnapshot` as the
  dispatch targets. Neither exists; the branches call
  `assemblePreToolCallSnapshot` / `assemblePostToolCallSnapshot`.
- Three files said correlating a call with its result "is V6's session
  chain". The chain records both steps in one session's order, each entry
  carrying the arriving message's own `request_id`, and holds no reference
  from a result back to its call. `request_id_ref` is declared and read
  nowhere. Correlation is unimplemented.

Also, without changing what any of it does: name the hash test for what it
measures, say what the chain check is not (it compares links and never
recomputes `hashEntry`, so a self-consistent rewrite, a trailing truncation
and a deleted first entry all read as unbroken -- each measured), correct an
import alias that named the wrong package, and record that the session store
is never evicted and its projection never rotated.

V6's plan is now tracked and linked from the slice README and from the
slices doc, as V1-V5's are.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…thing

Each finding is a defect rather than a trade-off, so each is fixed rather
than recorded as a divergence.

`AgtSessionState` is gone. It was a one-field bag (`{ sourceLabels }`)
announcing session state and holding labels, bound to a parameter named
`session` in `ifcMember`, both assemblers and `server.ts`'s assembler type --
on the same step where V3 already binds `GovernStepInput.session` to
`ResolvedSessionConfig`, and under a name commitment 1 reserves for neither
object it governs. Both assemblers now take `sourceLabels: IfcLabels`, which
also drops a fourth spelling of the same tags. No parameter in the Guardian
is named `session`. The test fixture is `EMPTY_SOURCE_LABELS`, not
`NO_SESSION_STATE`: an empty label list is a value these assemblers pass
through, not the unseeded session the `["public"]` seed exists to prevent.

`IfcLabels`' doc called itself a store, which this slice's own README already
denied.

`Provenance` is `SessionProvenance`. Two provenance records live here and
never meet: the wire's `{value, provenance}` member, typed `unknown` and
stripped at C5, and this one, synthesized per session and seeded. One word
for both invited a reader of provenance.json to think they were the same.

N25 no longer asks. `persistIfcLabels` kept `undefined` vs `[]` -- a fact
about a verdict, read next to the verdict -- but loaded the session, spread
its provenance record and wrote the whole thing back through `putProvenance`,
which could replace `origin` and `source_id` while meaning to set labels.
The store is told instead: `replaceIfcLabels` and `sourceLabels`, both naming
the field, with the defensive copies moved to the owner of the array.
`supplySourceLabels` returns `IfcLabels`, so the naive mutation no longer
compiles; its test casts to keep measuring the runtime copy underneath.

S3's name is S3's alone. `SessionContext` carried entries, intent and
provenance, so `loadSessionContext` returned something wider than its name.
`SessionContext` is now the chain; `SessionState` is the aggregate the store
holds; `loadSessionContext` returns the former and `store.load` the latter.

`renderSessionChainRow` is split. It decided the chain break AND recorded the
hash, under a module header claiming every function pure -- so rendering was
a write, and rendering one entry twice reported a break the log did not
contain. `checkSessionChainLink` records and decides; the renderer is pure.
Both cases are now tests.

U22 prints `session_id=`, not `session=`. S14's row prints `audit_session=`,
qualified because that value is the host's raw id; both stream past one eye
when `main.ts` tails both logs. The Inspector barrel exports the third tailer
and the chain functions beside the other two tailers.

Detail C drew U22 as a third child of N50 (`tailEnvelopeLog`), which it never
calls -- it reads a different file through `tailSessionContextLog`. Table
first, then the mermaid: N50's row loses U22, S3's gains it, and the edge is
`S3 -.-> U22` with no minted N-number, because minting one is a shaping call.
Filed in §V6 as the ask, alongside the same gap for `supplySourceLabels` and
`loadSessionContext`.

Re-captured rather than hand-edited: both U22 blocks in the runbook, from the
same tracked log through the same commands. The label-carriage capture was
re-run too and is unchanged apart from its generated session id.

795 pass / 1 skip / 0 fail, typecheck clean, verify:pin clean.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
Comments in this slice carried review archaeology ("PR #15 review", "used to
be", a retired putProvenance/persistResultLabels narrated instead of just its
current shape), internal shaping identifiers (S3, S4, S5, N21-N25, R2.1,
R5.1, U22, C5) that resolve only against documents not in front of them, and
slice-number scheduling references (V1, V4, V6) standing in for what those
slices actually did. Test and describe/it names carried the same
identifiers and were renamed to say in words what they named.

Every comment here now stands on its own: what the code does, and the
reasoning behind it that the code cannot show itself. Where a constraint
came from an identifier, the constraint is stated instead of named.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
setIntent has no sender outside tests -- the ACS wire's intent object is
unread -- so every Guardian stand-in was made to answer a message
production never sends. It moves to SessionIntentStore, which the memory
store still implements, so S4 is off the live interface rather than
deleted.

load() went with it. The module header claimed S5 was reachable only
through replaceIfcLabels and sourceLabels while load handed back the
whole SessionState: the wide write was gone and the wide read was not.
Every read is now one record wide -- context, intent, provenance,
sourceLabels -- and the aggregate is the memory store's own business.

provenance() exists so that replaceIfcLabels leaving provenance_id,
origin and source_id as session birth wrote them stays a measurement
rather than an inspection.
policy/manifest.yaml carried the last dense pocket of archaeology:
requirement numbers, task numbers, slice names and a PR-review citation,
across the two intervention points and all three tool registrations.
Each is replaced by the fact it stood for -- the two-file agreement each
policy_target has to keep, and why an unregistered tool name fails AGT
closed before any rule runs.

One count went with it rather than being restated. The post_tool_call
comment claimed moving that leaf fails a specific number of other tests
"(measured)"; the number was not backed by a measurement anyone could
reproduce, so the sentence now says what those other tests can and
cannot tell without counting them.

The session store's own comments lose their store numbers, and the
remaining fixture's two null rows get the same self-explanatory note as
the real table.
`append` rebuilt the whole history on every step -- O(n) per step and
O(n^2) per session -- and `sessions` had no cap, no TTL and no eviction, so
a long-lived Guardian retained every session and every entry for the life
of the process. That is a bypass, not an untidiness: a slower Guardian
misses the host's negotiated `timeout_config.default_ms`, a missing
decision is a delivery failure, and the default `proceed` posture resolves
a delivery failure by running the tool ungoverned.

`append` now pushes onto the array the session already owns, so a step
costs the same at the forty-thousandth entry as at the first. Measured on
this tree, per-append cost at 40000 entries against 2000: pushing is
1.07-1.19x, rebuilding was 8.7-9.0x.

The bound is on sessions, not on entries. Truncating a session's oldest
entries would leave a chain whose first surviving entry links to something
absent, and `checkSessionChainLink` documents that it never marks a first
entry -- so a front-truncated chain reads exactly like a complete one.
Evicting whole sessions cannot produce that: a retained chain is entire and
an evicted one is absent. The eviction is reported twice over, by `onEvict`
and by the resumed session restarting at genesis, which the Inspector
renders as a chain break rather than as a fresh session.

`SessionState` now holds the chain as the array it is appended to, and
`context` copies on the way out for the same reason `sourceLabels` already
did, plus one more: the stored array grows.
README.md was byte-identical to slice/v5's. V6 gave both the Guardian and
the Inspector a session-context log and re-transcribed neither banner, so
six claims here were false: a three-line Guardian banner carrying `(S6)` /
`(D8)` labels the process does not print, a paragraph asserting as an
executed capture that it "prints all three lines", a two-line Inspector
banner where the process prints a three-path tailing line and a posture
badge, "three kinds" of `.acs/` artifact tailed "two" at a time where there
are four and three, and both env tables missing `ACS_SESSION_CONTEXT_LOG`
and the Inspector's `--session-context-log`.

All six are corrected from measured output: both processes were run and
their stdout pasted, per the convention that a fenced block in a doc is a
capture rather than a composition.

The root cause was that no test in this repo read any doc, so all six
stayed invisible behind a green suite. `test/readme-captures.test.ts`
parses the fenced captures out of README.md and asserts them against the
real stdout of both processes, so a banner line that is added, removed,
reworded or relabelled fails `bun test` instead of surviving a slice. The
port is normalised and nothing else is -- the quickstart tells the
developer to leave a Guardian on 8787, so binding it would flake on
exactly the machine that followed the instructions.
V1 added ACS_GUARDIAN_HOST and left the env table untouched, which is
the same omission this slice just spent two commits repairing for the
session-context log: a variable the process reads and the README does
not name. Recorded with the reason for the default rather than only the
default, since loopback is a security posture and an operator reaching
for 0.0.0.0 should read what it costs before setting it.
@afogel
afogel marked this pull request as ready for review September 6, 2026 10:14
@afogel
afogel merged commit 0156d78 into main Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

V6: Session state and provenance carriage

1 participant