Skip to content

V8: Upstream contract watch - #17

Merged
afogel merged 25 commits into
slice/v7from
slice/v8
Sep 6, 2026
Merged

afogel merged 25 commits into
slice/v7from
slice/v8

Conversation

@afogel

@afogel afogel commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Closes #9

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

What this slice is for

Point the same harness at AGT’s main branch. If an enum or schema field moved, the report names the surface and the field.

Without it, V7 is a snapshot that silently rots the first time AGT ships a breaking change.

The finished MVP is the demo you can run, the matrix you can publish, and the watch that tells you when the matrix is lying. This slice is that watch.

What a reviewer is looking at

This PR does not yet fetch or diff those surfaces. It freezes the names so the first implementation file cannot inherit the wrong noun: “drift” is already AGT’s behaviour-drift gate (drift.rego, policy/manifest.drift.yaml). This slice watches the upstream contract.

PinnedSurfaces is the eight watched surfaces at the locked ref; UpstreamSurfaces is the same set at main. diffSurfaces is told both snapshots and reads neither store itself. A changed enum is a SurfaceDiff, not a cell of V7’s 8×5 — V7 already split renderers so this output cannot arrive as a column of “does ACS v0.1.0 express AGT.”

Surfaces to watch, once implemented

manifest.schema.json, policy-input.schema.json, verdict.schema.json, snapshot.schema.json, the intervention-point enum, the verdict enum, reserved-reasons.json, and the stock bundle’s data.agt.defaults.config keys.

Slice notes: slices/v8/README.md.

@afogel afogel changed the title V8: Conformance matrix V8: Upstream drift watch Aug 9, 2026
@afogel
afogel force-pushed the slice/v8 branch 2 times, most recently from 4bebe5c to 8d69dfc Compare August 10, 2026 06:17
@afogel
afogel force-pushed the slice/v8 branch 2 times, most recently from f8616c5 to 48d586a Compare August 10, 2026 06:28
@afogel
afogel force-pushed the slice/v8 branch 3 times, most recently from b18ba1f to 35f0222 Compare August 10, 2026 14:32
@afogel
afogel force-pushed the slice/v8 branch 2 times, most recently from 15c7957 to b8917ae Compare August 11, 2026 10:51

@afogel afogel left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sandi Metz review — V8 scaffold

9-line README. Keep the Surface stem already in N45/N46; do not reuse V3’s drift or V7’s cell.

Comment thread slices/v8/README.md Outdated
Comment thread slices/v8/README.md Outdated
… gate

`drift` is not a free word here. `policy/lib/drift.rego` is AGT's stock
gate, `input.annotations.drift_score` is the input it reads,
`drift.warn_threshold` under `data.agt.defaults.config` is the number it
compares against, `drift_detected` is the reason it emits, and V3 shipped
`policy/manifest.drift.yaml` so that gate could fire at all. All of that
is about a model's behaviour moving. V8 is about the upstream contract
moving -- AGT's schemas and enums at `main` against the ref `agt.lock`
pins -- which is a different subject wearing the same English word, and
the collision lands before any code exists to inherit it: the first file
would be called `drift.ts` and read as the gate.

The slice is now **Upstream contract watch** in its README H1, in the
master doc's SS V8 heading and index row, and in the shaping doc's C6
part. U31 is the **surface-diff detail** in both affordance tables and in
the breadboard's own node label, named for what it renders rather than
for the phenomenon. P5's part list says "upstream contract check", and
the root README's planned-claim row says an upstream contract change.

C6 also stopped saying a red cell names the changed field. A cell is
V7's (intervention point x AGT verdict); what this part produces is a
surface diff, and the demo sentences follow in the next commit.

No V3 use moved. `policy/manifest.drift.yaml` is a tracked file, the
Rego reads `input.annotations.drift_score`, and requirements R2.5/R2.6
say "drift" because that is the phenomenon they were written about --
none of them names this slice or anything it builds.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
A schema or enum change is not a V7 coverage cell. A cell pairs an
intervention point with an AGT verdict; N41-N44 measure those and N47
`renderCoverageMatrix()` renders them. What V8 produces is a named
surface, a named field, and what that field was against what it is now
-- a `SurfaceDiff`, rendered by N53 `renderUpstreamDiff()` into U31. The
demo sentence said "turns a cell red" in three places (the index row, SS
V8, this README) and the SS V5 residual that files a hookmap/registry
check at V8 said it in a fourth. All four now say what actually happens,
and SS V8 records the restatement rather than leaving it to a diff.

**`PinnedSurfaces` and `UpstreamSurfaces` are frozen as a twin pair.**
Same eight surfaces, two sources: the ref `agt.lock` records (S11) and
`main` (S12). A single `Surfaces` type serving both is the shape in
which a run that read the pinned side twice still reports a clean diff.

**`diffSurfaces(pinned, upstream)` is told both snapshots.** It does not
open `agt.lock`, resolve a ref, or reach the network. Both affordance
tables now say so at the row, so an implementer meets the constraint
where the function is defined. The precedent is in this stack: a review
round ended with `governStep` being handed the tool its caller had
already checked (`GovernStepInput.scopedTool`) instead of deriving it.

**S11 was missing from SS V8's table, and it is the pinned half of that
pair.** Detail C's shared-store row has named N46 a reader of `agt.lock`
since the initial shaping commit, and the breadboard has carried
`S11 -.-> N46` just as long -- only the slice table was silent, the same
asymmetry SS V7 just closed for S10. The row lands in V7's form, and the
README's affordance line carries all six with the store halves named:
S12 declared here, S11 shared and read.

Also: SS V8's note now states the README's affordance line as a fact,
because as of this commit it is one.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The rename reached four statements of the demo claim and missed a fifth
in the V5 runbook, where the hookmap-tools residual is filed at V8 and
described it as turning a cell red -- the conflation with V7's coverage
cell that this slice's own commitment exists to prevent. The Fit Check
note and the requirements-to-affordances row cite C6 and V8's chain, so
they move with the slice; R2.5 and R2.6 themselves keep the word, which
they use as ordinary English for divergence rather than as a name.
The requirement table still described this slice's subject with the noun
the slice was renamed off. R2.5 and R2.6 are the two requirements the
upstream contract watch exists to satisfy, and they read "drift surfaces
as a failing case" and "a drift failure names what changed" -- the word
that already belongs to AGT's behaviour gate, three files of which ship.
They now say what moves: a contract surface. Every use that really is
about the behaviour gate is untouched.

The freeze gains a fifth commitment, because the conformance harness
just spent the stem: it clones the PINNED ref for its policy-input
schema check, so both names now live in one package. That clone is
PINNED_AGT_CLONE, leaving upstream free for the store this slice
fetches into -- a differ told "upstream" twice is the exact failure
commitment 2 describes.
The plan projects §V8's six affordances into eight tasks. Planning measured
AGT at the pinned ref rather than reading it, and that produced three
corrections, all amended into the slices doc here rather than left for the
implementer:

Eight surfaces are not eight files. Two of them are enums living inside
documents already fetched, and one has no document at all -- it is the set of
cfg.<key> reads in agt_default.rego. A fetcher written from the prose alone
cannot express three of the eight.

The wire schemas exist in two copies at the pinned ref, byte-identical there.
The watch reads the spec/ copy and a divergence between the copies is a
recorded blind spot.

The tools-registry cross-check cannot close the failure it was filed for. The
manifest registers run_shell, Bash and bash -- one per host, deliberately --
so OpenCode's tools: [bash] recased to [Bash] passes it. The open question
inverts: the manifest names more than any one host dispatches.

Separately, R2.5 asks for a failing case, not a diff, and the chain §V8
describes delivers only the diff. The reachable half is re-asking V7's schema
question of main; the matrix-against-main half needs the Node SDK built from
source and is a slice of its own. The Fit Check now says so.

Slice: #9

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
…unbook's own voice

The prose stitching together the negative-control captures in
docs/demos/v8-runbook.md carried an internal task reference and role name
that had nothing for a published reader to resolve against. The genuine
captured output -- the byte comparison lines, the watch's own run lines and
tables, the exit codes -- is untouched and re-verified byte-for-byte against
its source; only the narration around it is rewritten, restructured to match
the prose-then-fenced-block pattern the rest of the file already uses.

Slice: #9
…nt the compared refs

readSurfaces' readJson moved JSON.parse outside its try/catch, so a
malformed watched document surfaced as an anonymous JSON.parse error
naming none of the five JSON surfaces it could have been. Both the
read and the parse now share one catch that always names the surface,
matching the fix already made for a malformed hookmap.

The scheduled workflow piped watch:upstream into
tee "$GITHUB_STEP_SUMMARY" with no shell key, so bash ran without
pipefail and any shell-side failure (git fetch, a garbage-collected
pinned ref, AGT renaming main, jq missing) left the job green with an
empty summary and the real error stranded on stderr. Redirecting
stderr into the pipe publishes that failure in the summary without
adding pipefail or making the job fail -- this workflow reports and
never refuses.

A clean diff and a run that compared the wrong ref (or the same ref
twice) used to read identically. scripts/run-upstream-watch.sh now
resolves each clone's commit with git rev-parse HEAD and hands both
SHAs to the runner by environment variable, the same way it hands in
the clone paths; the run prints both, or a fixed line saying they were
not supplied when they aren't -- never an empty or fabricated commit.

The five test files this slice added never cleaned up their fixture
directories, leaving hundreds of stale temp dirs behind. Every test
file that creates one now trashes it in afterEach, adopting the
trashDir convention policy-input-schema.test.ts already used.

policy-input-schema.ts's own header claimed PinnedSurfaces and
UpstreamSurfaces "will live in this package once its drift watch
exists" -- both already live in this package, and this package's
watch is about the upstream contract moving, not about drift. The
sentence now says what is true, in the present tense.

Slice: #9
The published captures were taken while the run still rendered one
section, then two; the shipped run now always renders four when it
completes, and a reader reproducing the enum edit got a hookmap line
that appeared in none of the published blocks. Line 71's "nothing
about the watch itself changed between any of the runs below" was
false for the same reason.

Every captured block below is pasted from a real run against the
shipped build: the two clones unmodified, the upstream clone with one
value added to the verdict enum, and the upstream clone with
verdict.schema.json renamed -- each verified byte-for-byte against its
raw command output before being pasted in. The live-run capture at the
top of the file is also refreshed, since publishing the compared-refs
line and fixing the workflow's pipe both changed what it prints.

Slice: #9
The renamed-schema scenario's closing sentence claimed the run "never
gets far enough to know which two commits it was comparing" -- false,
since both SHAs arrive as environment variables before either clone
is read. The run returns as soon as a side cannot be read, which is
the real reason a read failure prints no compared-refs, schema, or
hookmap line; the sentence now says that instead.

The closing summary undercounted its own captures: the file pastes
four run blocks verbatim, not three -- the live top-of-file capture
and the unmodified re-run against the scratch clones are two separate
clean captures, not one, alongside the enum-edit and renamed captures.

Slice: #9
…he rule

AGT writes verdict.message for an operator reading a log -- patterns.rego
sprintf's "matched pattern <regex> at offset <n>" -- and policy/lib is
vendored byte-identical from upstream. Rewording it there would fork the
bundle this deployment's whole claim rests on, so the sentence a human
reads is composed on this side of the boundary instead.

mapping.yaml declares the wording, the same way it already declares every
other field synthesis: a template, a summary per AGT rule, and how AGT's
own message renders into the last clause. The pattern form's regex is the
one part a reader gains nothing from, so the offset is kept and the regex
dropped; every other message is passed through whole and attributed, so a
shape this does not recognise is never lost. A rule with no summary takes
the default rather than throwing -- an incomplete wording table is a gap
in prose, and the Guardian's evaluation catch would turn a throw into a
deny of a step AGT allowed.

A modify now carries an explanation where it carried none. AGT supplies no
message for a transform, so there was nothing to copy, and composing from
the rule that decided closes the gap docs/demos/v4-runbook.md records as
"the redaction reaches the model unexplained". Six tests pinned on that
absence state the new premise instead.

A mapping that declares no template still copies its source verbatim,
which is what a source-only declaration asks for and what keeps the two
fixture mappings two lines long.

policy/lib is untouched and verify:pin passes.

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

Two files a runbook previously told a reader to create by hand.
.claude/settings.json is hosts/claude-code/settings.json in the place
Claude Code actually reads it, gating Bash at both the request and the
result gate. opencode.json points OpenCode at the committed plugin and
declares nothing else, so the model and provider come from whatever the
operator already has configured.

Two consequences, both deliberate. .claude/settings.json governs every
Claude Code session in this repository, not only a demo one -- a Bash
call made here now asks a Guardian. The negotiated posture is proceed, so
a Guardian that is not running audits and proceeds rather than blocking
work. And OpenCode's own permission.bash: allow is OpenCode's separate
permission system, set only so it does not stop to ask before the call
reaches the plugin hooks; every decision is still the Guardian's,
downstream of it.

The Claude Code half is verified end to end: a real headless session
denies a destructive command and the envelope log carries the handshake
and the decision. The OpenCode half is not -- a tool-calling run produces
no output and writes no envelope, and that is still being chased.

Claude-Session: https://claude.ai/code/session_019qZbQWyJHrYG7UodpyKjYr
The entry point printed and exited 0 unconditionally, so "the watcher could
not run" and "the watcher ran and found no drift" were the same answer at the
only boundary CI can see. A clone that was never made, a surface relocated out
from under readSurfaces, a fetch that never landed: all published as a clean
weekly summary.

`ran: false` now leaves the process non-zero. Drift, a schema rejection
against main and an unregistered hookmap tool all keep exit 0, because each is
a completed run with a finding for a human to weigh -- a gate that reddens on
drift is a gate nobody can leave green.

process.exitCode rather than process.exit, so stdout is flushed before the
process ends: the workflow reads this run through a pipe into the step summary
and a truncated summary is the same class of defect as a status that could
never go red.

Two header claims falsified by this and rewritten: the paragraph justifying the
read-failure catch by the absence of pipefail downstream, and the closing line
"nothing here fails a build".
GitHub's implicit shell for `run:` is `bash -e {0}`; pipefail is added only
when `shell: bash` is stated, and this file stated no shell. The pipeline's
status was tee's, which is 0 whatever happened on the left, so every hard exit
in scripts/run-upstream-watch.sh was thrown away -- missing agt.lock, missing
jq, missing trash, a fetch that never landed, the pinned ref gone from
upstream. Measured: `bash -e -c 'false | tee /dev/null'` exits 0 and
`bash -eo pipefail -c` on the identical line exits 1; with a git that fails
inside the script, the old form exits 0 and the new one exits 128.

The comment that defended the omission conflated a finding with a failure. It
now states the distinction it was fumbling: drift is this job's product and
exits 0, a watcher that could not run exits non-zero.

permissions: contents: read, rather than inheriting a repository default that
can be read-and-write. The step summary is a file named by an environment
variable, not an API call, so read is the whole requirement.

timeout-minutes: 10 on a job that does two shallow clones and a diff, in place
of the 360-minute default a hang would otherwise burn.

Both actions pinned to the commit their major tag resolved to, tag kept as a
trailing comment: checkout v4 -> 11d5960 (v4.4.0), setup-bun v2 -> 0c5077e
(v2.2.0), each read from the upstream repository's own refs. apt's trash-cli
stays unpinned and is now the only unpinned install left in the file.
.github held exactly one workflow and it ran none of the gates. bun test,
typecheck, verify:pin, verify:zero-diff and conformance were human-typed only,
so no commit in this stack had ever been verified by anything mechanical.

Two jobs, split along the network. `suite` is hermetic: typecheck first because
it is the cheaper answer, then bun test under `!cancelled()` so one push yields
both results instead of one and a re-run. `pinned-gates` runs verify:pin and
conformance, which shallow-clone AGT at agt.lock's pinned ref -- an immutable
commit, so upstream cannot move under them, but they still need the network and
still hard-exit without jq or trash. Kept separate so an outage on their side
reddens only them and leaves the hermetic answer standing.

submodules: recursive in both. validate-envelope.ts builds its Ajv registry
from a path into spec/acs relative to import.meta.url, so an absent submodule
is a hard ENOENT at request time rather than a self-skip. trash-cli in both
too: five conformance test files clean their fixtures with `Bun.$`trash ...``
because this repo does not permit rm -rf, and Bun's shell throws on a command
it cannot find.

verify:zero-diff is deliberately left out. Its base is slice/v4 and it is a
claim about that slice's own diff, not an invariant of later commits: run on
this branch it reports fourteen changed frozen paths and exits 1, correctly,
because the slices after v4 changed them. On push it would redden the whole
stack for something that is not a defect, and the branch it resolves against
is one this stack is designed to delete.

push is narrowed to main because every branch here has an open pull request,
which would otherwise pay for each commit twice.
@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
2 of 4 checks passed
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.

V8: Upstream drift watch

1 participant