diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bc7c962..dc971ff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -933,3 +933,79 @@ jobs:
echo "stale, re-run \`pnpm run package:build\`; if a capture is stale, re-run"
echo "\`pnpm run package:capture\` against the merged cockpit."
} >> "$GITHUB_STEP_SUMMARY"
+
+ # The final cut. This job does not encode anything: `ffmpeg` is not on the
+ # runner and adding it would make the gate depend on a tool that can be absent,
+ # which is how a gate becomes a comment. `verify-film.mjs` reads the committed
+ # MP4's own boxes instead.
+ final-media-gate:
+ name: Final media gate
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22.19.0'
+ cache: pnpm
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ - name: Verify the assembled cut
+ run: pnpm run check:film
+
+ # The boards, the captions and the two manifests are pure functions of the
+ # cut and the frozen evidence. The frames are not — resvg's text
+ # rasterisation is not byte-identical across hosts — so they are bound by
+ # source digest rather than by rebuild, exactly as HAC-334 established.
+ - name: The boards, captions and manifests are derived, not hand-edited
+ run: |
+ set -euo pipefail
+ pnpm run film:derive
+ if ! git diff --exit-code -- \
+ media/hac-336/masters \
+ media/hac-336/captions \
+ media/hac-336/evidence/input-manifest.json \
+ media/hac-336/evidence/scene-map.json; then
+ echo "::error::a derived film artifact differs from its rebuild"
+ exit 1
+ fi
+
+ - name: Every film gate is proven to fail on its own defect
+ run: pnpm vitest run test/hac-336-film-gates.test.mjs
+
+ - name: Explain the failure
+ if: failure()
+ run: |
+ {
+ echo "## Final media gate failed"
+ echo
+ echo "**Invariant.** The judge-facing cut runs under four minutes, shows the"
+ echo "controlled local experiment (HAC-330) and Google Cloud participation"
+ echo "(HAC-324 filmed run) as two proof classes separated by an explicit reset,"
+ echo "and never lets one borrow the other's evidence. Every filmed frame hashes"
+ echo "to a digest the HAC-324 capture manifest promoted. Every factual line maps"
+ echo "to a claim row in the HAC-335 ledger or the HAC-336 filmed-run rows. The"
+ echo "HAC-343 four-arm figure never appears without its evidence-ablation control"
+ echo "on the same board. The opening thirty seconds are legible with the sound"
+ echo "off, and the cut carries no audio track at all."
+ echo
+ echo "**Why it matters.** The video is the one artifact a judge watches before"
+ echo "reading anything. It is also the easiest place for two runs to look like"
+ echo "one, for a crop to quietly exclude an inconvenient line, or for a bounded"
+ echo "result to lose the bound that makes it honest."
+ echo
+ echo "**Authority.** HAC-336 issue contract; HAC-333 owns story, timing and the"
+ echo "muted contract; HAC-324 owns the filmed capture package; HAC-343's media"
+ echo "guardrail owns the four-arm scene; HAC-335 owns the claim ledger."
+ echo
+ echo "**Evidence required.** \`pnpm run check:film\` passing, the derived"
+ echo "artifacts matching their rebuild, and the negative-proof suite showing each"
+ echo "invariant still fails when violated."
+ echo
+ echo "**Do not weaken.** Do not raise the ceiling, do not drop a proof element to"
+ echo "fit the time, do not remove a non-claim to quiet a phrase check, and do not"
+ echo "substitute a frozen HAC-340 reference artifact for filmed footage. If a"
+ echo "derived artifact is reported stale, re-run \`pnpm run film:build\`; if the"
+ echo "video is stale, re-run \`pnpm run film:render\` locally, which needs ffmpeg."
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/media/hac-336/README.md b/media/hac-336/README.md
new file mode 100644
index 0000000..29d2c69
--- /dev/null
+++ b/media/hac-336/README.md
@@ -0,0 +1,173 @@
+# HAC-336 — final media assembly
+
+The judge-facing cut, as a repository-native contract. This issue owns the
+**edit**: which frozen artifact is on screen, for how long, under which proof
+class, saying what. It owns no facts. Every number in the video was frozen by
+HAC-330, HAC-343 or HAC-324 before this package existed, and every board here
+reads those files rather than restating them.
+
+**Runtime 3:49.5 against a 4:00 ceiling. 1920 × 1080, H.264, no audio track.**
+
+## Files
+
+| Path | Role |
+| --- | --- |
+| `evidence/cut.json` | **Authored.** The edit decision list: beats, holds, sources, proof classes, narration, claim citations. The only file here a human writes. |
+| `evidence/filmed-run-claims.json` | **Authored.** Claim rows for the HAC-324 filmed run, each bound to JSON pointers into the frozen record. |
+| `evidence/input-manifest.json` | Derived. Every artifact the cut consumes, with its content digest, plus every declared revision and whether it could be checked. |
+| `evidence/scene-map.json` | Derived. Timestamp → scene → proof class → source artifact → claim. |
+| `evidence/frame-manifest.json` | Derived. One row per beat: the frame, its digest, and the artifact it came from. |
+| `evidence/render-manifest.json` | Derived. What the encoder actually wrote, read back out of the file. |
+| `masters/*.svg` | The six film boards, in the HAC-334 display-list grammar. |
+| `frames/*.png` | The composed holds. Also the reduced-motion equivalent — see below. |
+| `captions/*.vtt`, `*.srt` | The narration track. |
+| `exports/IL-MOT-020-…mp4` | The cut. |
+| `bin/verify-film.mjs` | The gate. Node builtins only. |
+
+## Build
+
+```sh
+pnpm run film:build # boards -> frames -> captions -> manifests
+pnpm run film:render # the encode; needs ffmpeg, and is not run in CI
+pnpm run check:film # the gate
+```
+
+`film:render` resolves `ffmpeg` to an absolute path rather than through `PATH`;
+set `FFMPEG` to an absolute path if your host keeps it somewhere else. The
+resolved binary is recorded in `evidence/render-manifest.json`, so the encoder
+that produced the submission video is a fact rather than whatever the shell found
+that afternoon.
+
+`film:derive` is the subset CI re-runs and diffs: boards, captions and the two
+manifests are pure functions of the cut and the frozen evidence. Frames are not
+— resvg's text rasterisation is not byte-identical across hosts — so they are
+bound by source digest instead, exactly as HAC-334 established for its PNGs.
+
+## Two proof classes, one reset, nothing crossing
+
+The cut is built around a hard divide at **B09**, HAC-333's SB-06.
+
+**Before it — controlled local.** HAC-330's counterfactual (`140 > 130`,
+`WITHHOLD_SERIALIZE` → `120 <= 130`, perturbed evidence → `ALLOW_PARALLEL` →
+`140 > 130`, checks 24/24) and HAC-343's bounded four-arm evaluation. Paper
+field. Deterministic, local, no cloud runtime.
+
+**After it — Google Cloud participation.** The HAC-324 **authoritative filmed
+run**, correlation `ilk-hac340-cloud-1787536029323`: five frames captured while a
+real `gemini-3.5-flash` agent traversed Google ADK 1.35.1 on Cloud Run through
+the Interlock MCP proxy, producing `ALLOW` + receipt, an `EXECUTED` protected
+mutation at `105 <= 130`, an independently authenticated `OBSERVED` read-back at
+`alpha=45`, and Cloud Logging correlation. Ink field.
+
+The gate refuses class-B material before the reset, class-A material after it, a
+filmed capture presented under any class but B, and the frozen HAC-340 reference
+run's correlation id anywhere in judge-facing copy.
+
+### Why the architecture board is new
+
+`IL-DIAG-011` and `IL-DIAG-012` already explain this topology, and they are
+excellent. They also name `interlock-hac340-proxy-00002-wzf` and correlation
+`…1786730369123`, which belong to the **frozen reference run**. Putting either
+beside filmed footage would place two run identities in one act — the precise
+collapse HAC-324 exists to prevent. `IL-DIAG-020` is bound to the filmed run and
+to nothing else.
+
+## What the cut does to filmed evidence
+
+Exactly one thing: a **crop**. A rectangle of the original pixels, scaled
+uniformly into a stage, with board chrome drawn around it. No recolouring, no
+redaction, no compositing of two captures, no text inside the stage.
+
+A 1920 × 1080 terminal capture with its content in the top third is unreadable at
+video bitrates. Cropping to that content changes what a judge can read, not what
+the evidence says. Every crop rectangle is declared in the cut, recorded on the
+frame, and compared by the gate — so a crop that quietly excluded an
+inconvenient line is visible as a rectangle that moved.
+
+All five promoted scenes appear. The gate fails if one is dropped.
+
+## Audio, captions, and reduced motion
+
+**There is no audio track**, and the gate fails if one appears. HAC-333 froze
+this cut as muted: every claim has to survive with the sound off. The captions
+carry the narration text — the words a voice-over would speak, written so that
+recording one later is a matter of reading them aloud rather than re-authoring
+them. Synthetic speech was not generated: a channel no gate can check and no
+human spoke is not an accessibility feature.
+
+**Reduced motion.** The only motion in 3:49 is a 0.4 s crossfade between two
+still holds. It carries a state change and nothing else — every proposition is
+legible on the frame at either end. `frames/` is therefore a complete static
+equivalent of the cut, not an approximation of one: the frame set and the video
+say the same thing, and the scene map orders them.
+
+Nothing on screen implies a frozen result is being recomputed while the viewer
+watches. There are no counters, no typewriter effects, no particles, and no
+agent-thinking cues.
+
+## The first thirty seconds
+
+`B01`–`B05` establish, with the sound off: two intents each valid alone, one
+shared environment carrying one joint bound, and `140 > 130` at display size
+marked `INVALID JOINT STATE`. The gate requires every beat that starts before
+0:30 either to be a frozen board that carries its own copy or to record what a
+muted reader takes from it, so the thesis can never come to depend on the caption
+track.
+
+## The HAC-343 scene
+
+The media guardrail requires Panel 1 and Panel 2 to be one contiguous visual
+unit, so the `0/2` Interlock figure never stands alone as a broader safety claim.
+`IL-PROOF-021` satisfies that literally: the `ablation` state is the `comparison`
+state with the control panel added, on the same board. The per-target lock
+credibility strip (`2/2` serialized, `4/4` parallelized, `2/2` missed) stays
+visible on both, so A3 never reads as a straw man.
+
+The gate checks all four arms, both ablation rows and the credibility strip are
+present on the ablation board, and that the ablation beat immediately follows the
+comparison beat.
+
+## What the cut does not claim
+
+Carried by `IL-PROOF-014` at B19 and `IL-PROOF-022` at B18, and enforced by the
+gate's phrase list: Interlock is not universally safe and not safer than locking;
+no `0%`/`100%` headline over a heterogeneous corpus; no interval or statistical
+significance; no exactly-once, restart safety, target-side atomicity or
+production readiness; Agent Runtime, Agent Gateway, Memory Bank and
+`CONTENT_AUTHZ` did not participate; `ALLOW` is a decision, not a verification or
+an authorization; Cloud Run IAM establishes transport provenance only; the two
+runs are two runs.
+
+Several of those phrases are ones the cut is *required* to say, in the negative —
+a limitations board that could not print "exactly-once" could not disclaim it
+either. The phrase check therefore flags a hit only when nothing nearby negates
+it, and `test/hac-336-film-gates.test.mjs` separately proves the bare assertive
+form of each still fails.
+
+## Cold read
+
+**Not run. No result is claimed.**
+
+`media/hac-341/cold-read/` is the moderator kit and it is the protocol for this
+cut too: three technically competent readers unfamiliar with the build, tested
+one at a time, no pre-explanation. Until three real readers have completed it,
+this package stays `READY_FOR_HUMAN_TEST` and nothing here may describe the media
+as frozen. LLM judges, agent review and automated browser tests are not
+substitutes.
+
+## Verify
+
+```sh
+pnpm run check:film
+pnpm vitest run test/hac-336-film-gates.test.mjs
+```
+
+The gate fails on: a cut over four minutes; an encoded duration that disagrees
+with the derived one; a frame that changed after the encode; a filmed frame whose
+bytes do not hash to the digest the capture manifest promoted; a promoted scene
+dropped without record; a crop that moved; a claim citation with no ledger row; a
+filmed-run row whose pointer stopped resolving; a forbidden phrasing asserted
+without negation; a missing boundary statement; the ablation control separated
+from its comparison; a beat in the opening thirty seconds with no muted reading;
+a caption that drifted from its narration; an audio track; a declared input whose
+bytes moved; and a declared revision the evidence no longer carries.
diff --git a/media/hac-336/bin/build-boards.mjs b/media/hac-336/bin/build-boards.mjs
new file mode 100644
index 0000000..3188f7b
--- /dev/null
+++ b/media/hac-336/bin/build-boards.mjs
@@ -0,0 +1,485 @@
+#!/usr/bin/env node
+/**
+ * Renders the six masters the edit needs and the static suite does not have.
+ *
+ * HAC-334 froze the still boards a README and a Devpost page need. Four things a
+ * cut needs are not among them: a problem that *builds* rather than arriving
+ * complete, the HAC-343 four-arm comparison with its ablation control attached,
+ * an architecture board bound to the **filmed** run rather than the frozen
+ * reference run, and the two typographic cards that open and close an act.
+ *
+ * The binding rule is the same one HAC-334 works under: nothing here types a
+ * number. Every count, status and identifier arrives by reading a frozen
+ * evidence file, so a board can be wrong about layout but cannot be wrong about
+ * a fact unless the evidence is wrong first.
+ *
+ * That rule is enforced downstream rather than asserted here.
+ * `media/hac-336/bin/verify-film.mjs` re-runs this script into a scratch
+ * directory and diffs the result against the committed masters, so a master
+ * edited by hand — or a value that stopped matching the evidence it came from —
+ * fails the gate. It is the same "derived, not hand-edited" check CI already
+ * applies to the HAC-334 masters and the HAC-343 judge export.
+ *
+ * The architecture board is deliberately bound to the filmed run and to nothing
+ * else. IL-DIAG-011 and IL-DIAG-012 already explain this topology, but they name
+ * `interlock-hac340-proxy-00002-wzf` and correlation `...1786730369123`, which
+ * belong to the frozen reference run. Putting either on screen next to filmed
+ * footage would place two run identities in one act and invite exactly the
+ * collapse HAC-324 exists to prevent.
+ */
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { buildExportName, validateExportName } from '../../../scripts/export-naming.mjs';
+import {
+ text, paragraph, rect, line, arrow, chip, stateColor,
+} from '../../hac-334/bin/lib/draw.mjs';
+import { composeBoard, panel, W, M, RAIL_Y } from './lib/film-board.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const read = (p) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
+
+/* -- frozen inputs -------------------------------------------------------- */
+
+const arms = read('experiments/hac-330/evidence/arms.json');
+const results = read('experiments/hac-330/evidence/results.json');
+const judge = read('experiments/hac-343/evidence/judge-export.json');
+const filmed = read('experiments/hac-324/evidence/filmed-run.json');
+const capture = read('experiments/hac-324/evidence/capture-package.json');
+
+const checks = { passed: results.checks.filter((c) => c.passed).length, total: results.checks.length };
+const bound = arms.baseline.invariant.report.totalReservable;
+const baselineTotal = arms.baseline.invariant.report.total;
+const treatmentTotal = arms.treatment.invariant.report.total;
+const perturbedTotal = arms.perturbedControl.invariant.report.total;
+const constraint = `sum(services[].reserved) <= ${bound}`;
+const expr = (total) => `${total} ${total <= bound ? '<=' : '>'} ${bound}`;
+
+/* -- boards --------------------------------------------------------------- */
+
+const boards = [];
+
+/*
+ * IL-PROOF-020 -- the problem, built in three holds.
+ *
+ * The first twenty seconds have to work muted, so the build is the argument:
+ * two valid things, then one shared thing, then the joint failure. Arriving at
+ * `140 > 130` complete would show the answer before the reader has the question.
+ */
+{
+ const CHIP_Y = 300;
+ const laneW = 560;
+ const envX = M + laneW + 120;
+ const envW = W - M - envX;
+
+ const setup = (t, coupled) => {
+ const o = [];
+ ['Intent A', 'Intent B'].forEach((label, i) => {
+ const y = CHIP_Y + i * 190;
+ o.push(rect(M, y, laneW, 132, { stroke: t.hair, width: 1 }));
+ o.push(text(M + 28, y + 52, label, { size: 27, fill: t.fg, weight: 500 }));
+ o.push(chip(M + 28, y + 74, 'LOCALLY VALID', 'local', { dark: t.dark, size: 18 }).nodes);
+ if (coupled) {
+ o.push(arrow(M + laneW, y + 66, envX - 8, CHIP_Y + 161, 'coupling', stateColor('coupled', t.dark)));
+ }
+ });
+ if (coupled) {
+ o.push(rect(envX, CHIP_Y, envW, 322, { stroke: stateColor('coupled', t.dark), width: 2 }));
+ o.push(text(envX + 28, CHIP_Y + 56, 'Shared environment', { size: 27, fill: t.fg, weight: 500 }));
+ o.push(chip(envX + 28, CHIP_Y + 82, 'COUPLED', 'coupled', { dark: t.dark, size: 18 }).nodes);
+ o.push(text(envX + 28, CHIP_Y + 200, 'JOINT BOUND', { size: 16, mono: true, fill: t.muted, tracking: 2.2 }));
+ o.push(text(envX + 28, CHIP_Y + 244, constraint, { size: 25, mono: true, fill: t.fg }));
+ o.push(paragraph(envX + 28, CHIP_Y + 290, 'Revision-bound, and derived from the commit history rather than declared.', envW - 56, { size: 17, fill: t.muted }).nodes);
+ }
+ return o;
+ };
+
+ const caption = (t, s) => paragraph(M, 706, s, W - M * 2, { size: 30, fill: t.muted, lineHeight: 1.35 }).nodes;
+
+ const rail = (nonClaim) => [
+ `Frozen evidence: ${constraint} checks ${checks.passed}/${checks.total}`,
+ `Non-claim: ${nonClaim}`,
+ ];
+ const NC = 'this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it';
+
+ boards.push({
+ id: 'IL-PROOF-020',
+ slug: 'composition-hazard',
+ state: 'intents',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EXPERIMENT',
+ title: 'Two changes, each valid on its own',
+ rail: rail(NC),
+ render: (t) => [
+ setup(t, false),
+ caption(t, 'Each intent is checked in its own scope and passes. Nothing is coordinated yet.'),
+ ],
+ });
+
+ boards.push({
+ id: 'IL-PROOF-020',
+ slug: 'composition-hazard',
+ state: 'coupled',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EXPERIMENT',
+ title: 'One shared environment, one joint bound',
+ rail: rail(NC),
+ render: (t) => [
+ setup(t, true),
+ caption(t, 'They write into the same environment, so the constraint applies to their joint outcome, not to either change alone.'),
+ ],
+ });
+
+ boards.push({
+ id: 'IL-PROOF-020',
+ slug: 'composition-hazard',
+ state: 'baseline',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EXPERIMENT',
+ title: 'Applied together, with no coordination',
+ rail: [
+ `Frozen evidence: baseline ${expr(baselineTotal)} ${constraint}`,
+ `Non-claim: ${NC}`,
+ ],
+ render: (t) => {
+ const col = stateColor('failed', t.dark);
+ return [
+ text(M, 300, 'BASELINE ARM - INTERLOCK DISABLED', { size: 17, mono: true, fill: t.muted, tracking: 2.2 }),
+ text(M, 460, expr(baselineTotal), { size: 150, mono: true, fill: col, weight: 600, tracking: -4 }),
+ chip(M, 508, 'INVALID JOINT STATE', 'failed', { dark: t.dark, size: 20 }).nodes,
+ text(W - M, 300, 'JOINT BOUND', { size: 17, mono: true, fill: t.muted, tracking: 2.2, anchor: 'end' }),
+ text(W - M, 344, constraint, { size: 25, mono: true, fill: t.fg, anchor: 'end' }),
+ paragraph(M, 706, 'Neither change is wrong. The composition is.', W - M * 2, { size: 40, fill: t.fg, weight: 600 }).nodes,
+ paragraph(M, 766, 'Both intents passed their own precondition at the base revision. The failure only exists jointly.', W - M * 2, { size: 26, fill: t.muted }).nodes,
+ ];
+ },
+ });
+}
+
+/*
+ * IL-PROOF-021 -- the bounded four-arm comparison, and its ablation control.
+ *
+ * The HAC-343 media guardrail requires Panel 1 and Panel 2 to be one contiguous
+ * visual unit, so the `0/2` cannot stand alone as a broader safety claim. The
+ * two states satisfy that literally: `ablation` is `comparison` with the control
+ * panel added, on the same board, so the flattering figure is never on screen
+ * without the finding that bounds it.
+ */
+{
+ const rows = judge.panel1.rows;
+ const cred = judge.panel1.perTargetLockCredibility;
+ const abl = judge.panel2.rows;
+
+ const colX = [M, 900, 1420];
+ const headY = 300;
+ const rowH = 62;
+
+ const comparison = (t) => {
+ const o = [
+ text(colX[0], headY, 'STRATEGY', { size: 16, mono: true, fill: t.muted, tracking: 2.2 }),
+ paragraph(colX[1], headY, 'Cross-target hazards unsafe', 420, { size: 16, mono: true, fill: t.muted, tracking: 1.4 }).nodes,
+ paragraph(colX[2], headY, 'Independent opportunities kept parallel', 420, { size: 16, mono: true, fill: t.muted, tracking: 1.4 }).nodes,
+ line(M, headY + 44, W - M, headY + 44, { stroke: t.hair, width: 1 }),
+ ];
+ rows.forEach((r, i) => {
+ const y = headY + 96 + i * rowH;
+ const isIL = r.arm === 'A4_interlock';
+ const unsafeCol = stateColor(r.coupledUnsafe.numerator === 0 ? 'observed' : 'failed', t.dark);
+ const parCol = stateColor(r.safeParallelism.numerator > 0 ? 'observed' : 'blocked', t.dark);
+ if (isIL) o.push(rect(M - 16, y - 40, W - M * 2 + 32, rowH, { stroke: t.fg, width: 2 }));
+ o.push(text(colX[0], y, r.label, { size: 28, fill: t.fg, weight: isIL ? 600 : 400 }));
+ o.push(text(colX[1], y, r.coupledUnsafe.display, { size: 34, mono: true, fill: unsafeCol, weight: 600 }));
+ o.push(text(colX[2], y, r.safeParallelism.display, { size: 34, mono: true, fill: parCol, weight: 600 }));
+ });
+ return o;
+ };
+
+ const credibility = (t, y) => [
+ panel(M, y, W - M * 2, 138, 'IS THE PER-TARGET LOCK A REAL LOCK?', t, { stroke: t.hair }),
+ [
+ [`same-target contention serialized ${cred.serializedSameTargetContention.display}`, M + 24],
+ [`cross-target pairs parallelized ${cred.parallelisedCrossTarget.display}`, M + 620],
+ [`cross-target hazards missed ${cred.missedCrossTargetHazards.display}`, M + 1180],
+ ].map(([s, x]) => text(x, y + 82, s, { size: 21, mono: true, fill: t.fg })),
+ paragraph(M + 24, y + 118, cred.note, W - M * 2 - 48, { size: 19, fill: t.muted }).nodes,
+ ];
+
+ const railFor = (nonClaim) => [
+ 'Frozen evidence: '
+ + rows.map((r) => `${r.label.replace(/ /g, '-')} ${r.coupledUnsafe.display}/${r.safeParallelism.display}`).join(' '),
+ `Non-claim: ${nonClaim}`,
+ ];
+ const NC1 = 'bounded to this sixteen-scenario corpus; Interlock is not universally safe and not safer than locking; no interval or significance is claimed';
+
+ boards.push({
+ id: 'IL-PROOF-021',
+ slug: 'bounded-four-arm-comparison',
+ state: 'comparison',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EVALUATION',
+ title: 'Four coordination strategies, one frozen corpus',
+ rail: railFor(NC1),
+ render: (t) => [
+ comparison(t),
+ credibility(t, 636),
+ paragraph(M, 828, judge.panel1.reading, W - M * 2, { size: 24, fill: t.muted, lineHeight: 1.4 }).nodes,
+ ],
+ });
+
+ boards.push({
+ id: 'IL-PROOF-021',
+ slug: 'bounded-four-arm-comparison',
+ state: 'ablation',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EVALUATION',
+ title: 'The same result, with its evidence removed',
+ rail: [
+ `Frozen evidence: interlock ${rows[3].coupledUnsafe.display}/${rows[3].safeParallelism.display} `
+ + `evidence present ${abl[0].invalidOutcomes.display} invalid evidence removed ${abl[1].invalidOutcomes.display} invalid`,
+ `Non-claim: ${NC1}`,
+ ],
+ render: (t) => {
+ const o = [comparison(t)];
+ // The A3 credibility strip stays on screen with the table it qualifies.
+ // The guardrail asks for it beside Panel 1, and Panel 1 is on this state
+ // too: dropping it here would leave the per-target lock's 2/2 looking
+ // like a straw man for the eighteen seconds this board holds.
+ o.push(text(M, 626,
+ `per-target lock: same-target serialized ${cred.serializedSameTargetContention.display}`
+ + ` cross-target parallelized ${cred.parallelisedCrossTarget.display}`
+ + ` cross-target hazards missed ${cred.missedCrossTargetHazards.display}`,
+ { size: 19, mono: true, fill: t.muted }));
+ const y = 660;
+ o.push(panel(M, y, W - M * 2, 196, 'EVIDENCE ABLATION - SAME INTENTS, SAME DECISION CORE', t,
+ { stroke: t.fg, width: 2 }));
+ abl.forEach((r, i) => {
+ const ry = y + 88 + i * 62;
+ const col = stateColor(r.invalidOutcomes.numerator === 0 ? 'observed' : 'failed', t.dark);
+ o.push(text(M + 24, ry, r.condition, { size: 25, fill: t.fg }));
+ o.push(text(M + 900, ry, `${r.invalidOutcomes.display} invalid`, { size: 30, mono: true, fill: col, weight: 600 }));
+ o.push(text(M + 1240, ry, r.decision.join(' / '), { size: 21, mono: true, fill: t.muted }));
+ });
+ o.push(paragraph(M, 896, judge.panel2.reading, W - M * 2, { size: 24, fill: t.fg, lineHeight: 1.35 }).nodes);
+ return o;
+ },
+ });
+}
+
+/*
+ * IL-SCAF-020 -- the proof-class reset.
+ *
+ * HAC-333's SB-06. The field inverts completely and the board carries no
+ * caption, because the split itself is the proposition. It is the only frame in
+ * the cut that draws both classes at once, and it draws them as two halves that
+ * do not touch.
+ */
+boards.push({
+ id: 'IL-SCAF-020',
+ slug: 'proof-class-reset',
+ state: 'reset',
+ proofClass: 'A',
+ classLabel: 'PROOF-CLASS RESET',
+ title: 'Different run. Different evidence.',
+ rail: [
+ 'Frozen evidence: two proof classes, recorded separately',
+ 'Non-claim: no claim crosses between the two runs; the Google Cloud run does not reproduce the controlled local counterfactual',
+ ],
+ render: (t) => {
+ const half = (W - M * 2 - 64) / 2;
+ const top = 300;
+ const h = 540;
+ const right = M + half + 64;
+ return [
+ rect(right, top, half, h, { fill: '#0b0d0e' }),
+ text(M, top + 48, 'WHAT YOU HAVE SEEN', { size: 17, mono: true, fill: t.muted, tracking: 2.2 }),
+ text(M, top + 120, 'Controlled local', { size: 46, fill: t.fg, weight: 600 }),
+ text(M, top + 176, 'experiment', { size: 46, fill: t.fg, weight: 600 }),
+ paragraph(M, top + 250, 'Deterministic and local. No cloud runtime, no receipt, no protected target, no independent observer.', half - 24, { size: 24, fill: t.muted, lineHeight: 1.4 }).nodes,
+ text(right + 32, top + 48, 'WHAT COMES NEXT', { size: 17, mono: true, fill: '#9ba2a2', tracking: 2.2 }),
+ text(right + 32, top + 120, 'Google Cloud', { size: 46, fill: '#f2f3f2', weight: 600 }),
+ text(right + 32, top + 176, 'participation', { size: 46, fill: '#f2f3f2', weight: 600 }),
+ paragraph(right + 32, top + 250, 'One recorded traversal on real Google Cloud infrastructure, filmed while it ran. A different run, with its own evidence.', half - 64, { size: 24, fill: '#9ba2a2', lineHeight: 1.4 }).nodes,
+ text(M, top + h + 80, 'Nothing crosses.', { size: 44, fill: t.fg, weight: 600 }),
+ ];
+ },
+});
+
+/*
+ * IL-DIAG-020 -- the architecture, bound to the filmed run.
+ *
+ * Two states. `path` answers where Interlock sits and what it gates; `boundary`
+ * adds what the deployment does and does not establish. Splitting them keeps one
+ * diagram on screen at a time, which is the whole point of preferring one clear
+ * diagram over two dense ones.
+ */
+{
+ const proxyRevision = filmed.runtimeProof.proxyLogEntries[0].resource.labels.revision_name;
+ const nodes = [
+ ['01', 'Gemini', filmed.model],
+ ['02', 'Agent framework', filmed.adkPath],
+ ['03', 'Cloud Run agent', `${capture.region} / ${capture.revisions.agent}`],
+ ['04', 'Interlock MCP proxy', capture.revisions.proxy],
+ ['05', 'Decision + receipt', `${filmed.decision} / ${filmed.receiptId.slice(0, 18)}...`],
+ ['06', 'Protected target', capture.revisions.target],
+ ['07', 'Independent observer', 'keyless observer service account'],
+ ['08', 'Cloud Logging', `correlated by ${filmed.correlationId}`],
+ ];
+
+ const grid = (t) => {
+ const o = [];
+ const cols = 4;
+ const gw = (W - M * 2 - 3 * 28) / cols;
+ const gh = 150;
+ nodes.forEach(([n, label, value], i) => {
+ const x = M + (i % cols) * (gw + 28);
+ const y = 300 + Math.floor(i / cols) * (gh + 40);
+ const gated = i === 3 || i === 4;
+ o.push(rect(x, y, gw, gh, { stroke: gated ? t.fg : t.hair, width: gated ? 2 : 1 }));
+ o.push(text(x + 20, y + 34, n, { size: 15, mono: true, fill: t.muted, tracking: 2 }));
+ o.push(text(x + 20, y + 74, label, { size: 23, fill: t.fg, weight: gated ? 600 : 500 }));
+ o.push(paragraph(x + 20, y + 106, value, gw - 40, { size: 16, mono: true, fill: t.muted }).nodes);
+ if (i % cols !== cols - 1) {
+ o.push(arrow(x + gw + 2, y + gh / 2, x + gw + 24, y + gh / 2, 'mutation', t.muted));
+ }
+ });
+ return o;
+ };
+
+ const rail = [
+ `Frozen evidence: ${filmed.correlationId} ${proxyRevision} runtime source ${filmed.commitSha.slice(0, 12)}`,
+ 'Non-claim: Cloud Run IAM establishes transport provenance only; internal Interlock roles are not Google-managed identities',
+ ];
+
+ boards.push({
+ id: 'IL-DIAG-020',
+ slug: 'filmed-run-path',
+ state: 'path',
+ proofClass: 'B',
+ classLabel: 'GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN',
+ title: 'Where Interlock sits on the recorded path',
+ rail,
+ render: (t) => [
+ grid(t),
+ paragraph(M, 706, 'Interlock reads revision-bound composition evidence before the mutation and gates it with a decision and a receipt. The protected target refused a direct call that carried no receipt.', W - M * 2, { size: 27, fill: t.fg, lineHeight: 1.4 }).nodes,
+ text(M, 806, `A direct target call with no receipt returned ${filmed.controls.directBypassStatus}.`, { size: 23, mono: true, fill: t.muted }),
+ ],
+ });
+
+ boards.push({
+ id: 'IL-DIAG-020',
+ slug: 'filmed-run-path',
+ state: 'boundary',
+ proofClass: 'B',
+ classLabel: 'GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN',
+ title: 'What the deployment does and does not establish',
+ rail,
+ render: (t) => {
+ const half = (W - M * 2 - 48) / 2;
+ const top = 300;
+ const idSrc = filmed.runtimeProof.proxyLogEntries[0].jsonPayload.identitySource;
+ return [
+ panel(M, top, half, 250, 'TRANSPORT PROVENANCE', t, { dash: '2 4' }),
+ text(M + 24, top + 96, idSrc, { size: 24, mono: true, fill: t.fg }),
+ paragraph(M + 24, top + 140, 'Cloud Run IAM establishes which platform-verified identity made the call.', half - 48, { size: 21, fill: t.muted }).nodes,
+ panel(M + half + 48, top, half, 250, 'APPLICATION / RECEIPT PROVENANCE', t, { stroke: t.fg, width: 2 }),
+ text(M + half + 72, top + 96, `${filmed.receiptDigest.slice(0, 30)}...`, { size: 22, mono: true, fill: t.fg }),
+ paragraph(M + half + 72, top + 140, 'The Interlock decision and its receipt. Distinct from transport provenance, and not derived from it.', half - 48, { size: 21, fill: t.muted }).nodes,
+ text(M, top + 330, 'THESE DO NOT COLLAPSE', { size: 17, mono: true, fill: t.muted, tracking: 2.2 }),
+ paragraph(M, top + 380, 'Cloud Run IAM does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock.', W - M * 2, { size: 27, fill: t.fg, lineHeight: 1.4 }).nodes,
+ panel(M, top + 430, W - M * 2, 128, 'ABSENT FROM THIS DEPLOYMENT', t, { dash: '2 4' }),
+ text(M + 24, top + 512, 'Agent Runtime Agent Gateway CONTENT_AUTHZ', { size: 26, mono: true, fill: t.fg }),
+ ];
+ },
+ });
+}
+
+/*
+ * IL-PROOF-022 -- what the evaluation is bounded by.
+ *
+ * Including the negative finding. Two of eight refusal reasons disagreed with
+ * the frozen corpus; the corpus is frozen and stays wrong on the record. A
+ * limitations board that only lists comfortable limitations is marketing.
+ */
+{
+ const lim = judge.limitations;
+ const inad = lim.inadmissibleEvidence;
+ boards.push({
+ id: 'IL-PROOF-022',
+ slug: 'evaluation-bounds',
+ state: 'bounds',
+ proofClass: 'A',
+ classLabel: 'CONTROLLED LOCAL EVALUATION - BOUNDS',
+ title: 'What this evaluation is bounded by',
+ rail: [
+ `Frozen evidence: matrix ${judge.provenance.matrix.display} families ${judge.provenance.families.join('+')} `
+ + `failed closed ${inad.failedClosed.display} reason agreement ${inad.exactReasonAgreement.display}`,
+ 'Non-claim: no population estimate, no interval, no significance; exactly-once, restart safety, target-side atomicity and production readiness were not tested',
+ ],
+ render: (t) => {
+ const half = (W - M * 2 - 48) / 2;
+ const top = 290;
+ const outside = lim.outsideScope;
+ return [
+ panel(M, top, half, 300, 'THE CORPUS', t),
+ paragraph(M + 24, top + 84, lim.corpusBound, half - 48, { size: 22, fill: t.fg, lineHeight: 1.45 }).nodes,
+ panel(M + half + 48, top, half, 300, 'NOT TESTED, AND NOT CLAIMED', t),
+ // Each item carries its own negator rather than relying on the panel
+ // label five lines above it. A reader who scans one line has to get the
+ // same answer as one who reads the heading.
+ outside.map((s, i) => text(M + half + 72, top + 90 + i * 40, `no ${s}`, { size: 21, fill: t.fg })),
+ panel(M, top + 348, W - M * 2, 260, 'NEGATIVE FINDING, RETAINED', t, { stroke: t.fg, width: 2 }),
+ text(M + 24, top + 428, `inadmissible evidence failed closed ${inad.failedClosed.display}`,
+ { size: 26, mono: true, fill: stateColor('observed', t.dark), weight: 600 }),
+ text(M + 24, top + 476, `exact refusal-reason agreement ${inad.exactReasonAgreement.display}`,
+ { size: 26, mono: true, fill: stateColor('blocked', t.dark), weight: 600 }),
+ paragraph(M + 24, top + 522, inad.statement, W - M * 2 - 48, { size: 21, fill: t.muted, lineHeight: 1.4 }).nodes,
+ ];
+ },
+ });
+}
+
+/*
+ * IL-SCAF-021 -- the close.
+ *
+ * One proposition and the mechanism that earns it. No new claim arrives in the
+ * last ten seconds of a submission video.
+ */
+boards.push({
+ id: 'IL-SCAF-021',
+ slug: 'closing-thesis',
+ state: 'thesis',
+ proofClass: 'A',
+ classLabel: 'CLOSE',
+ title: 'Valid alone does not mean safe together.',
+ rail: [
+ `Frozen evidence: ${expr(baselineTotal)} ${expr(treatmentTotal)} ${expr(perturbedTotal)}`,
+ 'Non-claim: no safety, security, verification or production-readiness guarantee is claimed',
+ ],
+ render: (t) => [
+ paragraph(M, 360, 'Interlock reads revision-bound evidence about the composition before the mutation.', W - M * 2, { size: 54, fill: t.fg, weight: 600, lineHeight: 1.25 }).nodes,
+ line(M, 540, W - M, 540, { stroke: t.hair, width: 1 }),
+ text(M, 620, 'Parallel', { size: 40, fill: stateColor('observed', t.dark), weight: 600 }),
+ paragraph(M, 668, 'when the evidence supports the composition.', 700, { size: 26, fill: t.muted }).nodes,
+ text(M + 940, 620, 'Serialized', { size: 40, fill: stateColor('blocked', t.dark), weight: 600 }),
+ paragraph(M + 940, 668, 'when it does not.', 700, { size: 26, fill: t.muted }).nodes,
+ text(M, 830, 'The evidence is load-bearing: remove it and the decision reverses.', { size: 27, fill: t.fg }),
+ ],
+});
+
+/* -- emit ----------------------------------------------------------------- */
+
+const mastersDir = join(repoRoot, 'media', 'hac-336', 'masters');
+mkdirSync(mastersDir, { recursive: true });
+for (const f of readdirSync(mastersDir)) if (f.endsWith('.svg')) rmSync(join(mastersDir, f));
+
+let n = 0;
+for (const b of boards) {
+ const name = buildExportName({ id: b.id, slug: `${b.slug}-${b.state}`, ext: 'svg' });
+ const check = validateExportName(name);
+ if (!check.valid) throw new Error(`built an unparseable export name ${name}: ${check.error}`);
+ writeFileSync(join(mastersDir, name), composeBoard(b));
+ n += 1;
+}
+
+process.stdout.write(`HAC-336 film boards rendered\n ${n} SVG masters in media/hac-336/masters\n`);
diff --git a/media/hac-336/bin/build-captions.mjs b/media/hac-336/bin/build-captions.mjs
new file mode 100644
index 0000000..eea6a4d
--- /dev/null
+++ b/media/hac-336/bin/build-captions.mjs
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+/**
+ * Writes the caption tracks from the cut.
+ *
+ * Separate from `build-video.mjs` on purpose: the captions are pure text derived
+ * from `cut.json`, so CI can regenerate them and diff, while the encode — the
+ * one step that needs ffmpeg — stays out of CI entirely. A caption that drifted
+ * from the narration it transcribes would otherwise be invisible until someone
+ * turned subtitles on.
+ *
+ * The cut carries no audio track. These cues are the narration text a voice-over
+ * would speak, and the text a muted viewer reads beside the frame; they are
+ * written now so that recording a voice-over later is a matter of reading them
+ * aloud rather than re-authoring them.
+ */
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { timeline, vttTime, srtTime } from './lib/timeline.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const captionsDir = join(repoRoot, 'media', 'hac-336', 'captions');
+
+const cut = JSON.parse(readFileSync(join(repoRoot, 'media/hac-336/evidence/cut.json'), 'utf8'));
+const tl = timeline(cut);
+
+export const cues = tl.beats.map((b) => ({
+ index: b.index,
+ start: b.captionStart,
+ end: b.captionEnd,
+ text: cut.beats[b.index].narration,
+}));
+
+export const vtt = 'WEBVTT\n\nNOTE\n'
+ + 'Interlock final cut - narration and caption track.\n'
+ + 'The cut has no audio. These cues are the spoken text a voice-over would carry,\n'
+ + 'and the text a muted viewer can read alongside the frame.\n\n'
+ + `${cues.map((c, i) => `${i + 1}\n${vttTime(c.start)} --> ${vttTime(c.end)}\n${c.text}\n`).join('\n')}`;
+
+export const srt = cues
+ .map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}\n`)
+ .join('\n');
+
+export const VTT_PATH = 'media/hac-336/captions/interlock-final-cut.en.vtt';
+export const SRT_PATH = 'media/hac-336/captions/interlock-final-cut.en.srt';
+
+/* Importable for the gate; executable for the pipeline. */
+if (process.argv[1] && process.argv[1].endsWith('build-captions.mjs')) {
+ mkdirSync(captionsDir, { recursive: true });
+ writeFileSync(join(repoRoot, VTT_PATH), vtt);
+ writeFileSync(join(repoRoot, SRT_PATH), srt);
+ process.stdout.write(`HAC-336 captions written\n ${cues.length} cues, WebVTT + SubRip\n`);
+}
diff --git a/media/hac-336/bin/build-frames.mjs b/media/hac-336/bin/build-frames.mjs
new file mode 100644
index 0000000..35898d0
--- /dev/null
+++ b/media/hac-336/bin/build-frames.mjs
@@ -0,0 +1,330 @@
+#!/usr/bin/env node
+/**
+ * Turns the cut into frames: one 1920x1080 PNG per beat that needs composing.
+ *
+ * Three source kinds, and only two of them produce a file here.
+ *
+ * `asset` an already-frozen HAC-334 / HAC-335 export. Used byte-for-byte at
+ * its committed path. Nothing is recomposed, recoloured or
+ * re-exported, because a judge-facing board that differs between the
+ * README and the video is the drift this whole package exists to
+ * prevent.
+ * `board` a HAC-336 master from `build-boards.mjs`, rasterised here.
+ * `capture` a frame from the HAC-324 authoritative filmed run, cropped and
+ * scaled into a stage with the board chrome around it.
+ *
+ * The crop is the only editorial operation applied to filmed evidence, and it is
+ * bounded deliberately: a rectangle of the original pixels, scaled uniformly.
+ * No recolouring, no redaction, no compositing of two captures, no text added
+ * inside the stage. A 1920x1080 terminal capture with its content in the top
+ * third is unreadable at video bitrates; cropping to that content changes what a
+ * judge can read, not what the evidence says. Each crop rectangle is recorded in
+ * the cut and re-derived by the gate, so an "editorial" crop that quietly
+ * excluded an inconvenient line would be visible as a rectangle that moved.
+ *
+ * Rasterisation is not claimed to be byte-identical across hosts — resvg's text
+ * rasterisation is not — so correspondence runs through the source digest and
+ * the declared geometry, exactly as HAC-334's `export-png.mjs` established.
+ */
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, existsSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { Resvg } from '@resvg/resvg-js';
+import { text, line, paragraph, rect } from '../../hac-334/bin/lib/draw.mjs';
+import { toSvg } from '../../hac-334/bin/lib/svg.mjs';
+import { themeFor, flat, W, H, M, RAIL_Y } from './lib/film-board.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const filmDir = join(repoRoot, 'media', 'hac-336');
+const framesDir = join(filmDir, 'frames');
+
+const readJson = (p) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
+const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
+
+const cut = readJson('media/hac-336/evidence/cut.json');
+const capturePackage = readJson('experiments/hac-324/evidence/capture-package.json');
+const filmedRun = readJson('experiments/hac-324/evidence/filmed-run.json');
+
+const FONT = {
+ loadSystemFonts: true,
+ defaultFontFamily: 'Helvetica',
+ sansSerifFamily: 'Helvetica',
+ monospaceFamily: 'Courier',
+};
+
+/** Width and height out of the PNG header, not out of what was asked for. */
+function pngSize(buf) {
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ if (!buf.subarray(0, 8).equals(sig)) throw new Error('not a PNG: bad signature');
+ if (buf.subarray(12, 16).toString('latin1') !== 'IHDR') throw new Error('not a PNG: first chunk is not IHDR');
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
+}
+
+/**
+ * Proves the rasteriser can draw text at all before anything is written.
+ *
+ * Without this a host with no usable face emits valid PNGs with every label
+ * missing, and the first person to notice is a judge.
+ */
+function assertFontsUsable() {
+ const box = (body) => ''
+ + ` ${body} `;
+ const ink = (svg) => sha256(new Resvg(svg, { fitTo: { mode: 'width', value: 200 }, font: FONT }).render().asPng());
+ const blank = ink(box(''));
+ for (const [family, stack] of [['sans', 'Helvetica, sans-serif'], ['mono', 'Courier, monospace']]) {
+ const drawn = ink(box(`140 > 130 `));
+ if (drawn === blank) {
+ throw new Error(
+ `the rasteriser produced no ink for ${family} text (${stack}). Frames rendered now would drop every `
+ + 'label while still being valid images. Install a base-14 compatible face and re-run.',
+ );
+ }
+ }
+}
+
+/** Cheap non-degeneracy check: a blank or single-colour frame must not pass. */
+function distinctBytes(buf) {
+ const seen = new Set();
+ for (let i = 0; i < buf.length; i += 61) seen.add(buf[i]);
+ return seen.size;
+}
+
+const rasterise = (svg) => Buffer.from(
+ new Resvg(svg, { fitTo: { mode: 'width', value: W }, font: FONT }).render().asPng(),
+);
+
+/* -- the capture frame ---------------------------------------------------- */
+
+/**
+ * The box a capture is fitted into. Content ends above the rail rule, as on
+ * every board.
+ *
+ * The drawn stage is not this box: it is the crop, scaled uniformly to fit
+ * inside it and centred, so the stage border hugs the capture instead of
+ * leaving a letterbox band that reads as part of the terminal.
+ */
+const STAGE_BOX = { x: M, y: 228, w: W - M * 2, h: 544 };
+
+/** Uniform fit of a crop rectangle into the stage box. */
+function stageFor(crop) {
+ const scale = Math.min(STAGE_BOX.w / crop.w, STAGE_BOX.h / crop.h);
+ const w = Math.round(crop.w * scale);
+ const h = Math.round(crop.h * scale);
+ return {
+ x: Math.round(STAGE_BOX.x + (STAGE_BOX.w - w) / 2),
+ y: Math.round(STAGE_BOX.y + (STAGE_BOX.h - h) / 2),
+ w,
+ h,
+ scale: Number(scale.toFixed(4)),
+ };
+}
+const CAPTION_Y = 824;
+const CAPTION_SIZE = 26;
+const CAPTION_MAX_LINES = 3;
+
+const CAPTURE_CLASS_LABEL = 'GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN';
+
+/**
+ * One filmed capture, cropped into the stage with board chrome around it.
+ *
+ * The chrome is drawn through the shared display list and serialised by the
+ * shared `toSvg`, then nested inside an outer SVG that carries the image. The
+ * nesting is what keeps the capture underneath the chrome without a second
+ * serialiser existing anywhere in this repository.
+ */
+function captureFrame(beat, scene, sourcePng) {
+ const t = themeFor('B');
+ const { crop } = beat.source;
+ const stage = stageFor(crop);
+ const cap = paragraph(M, CAPTION_Y, beat.narration, W - M * 2, {
+ size: CAPTION_SIZE, fill: t.fg, lineHeight: 1.4,
+ });
+ if (cap.lines > CAPTION_MAX_LINES) {
+ throw new Error(
+ `${beat.beatId}: caption wraps to ${cap.lines} lines, over the ${CAPTION_MAX_LINES}-line limit. `
+ + 'Shorten the narration in cut.json rather than shrinking the type below the caption-safe minimum.',
+ );
+ }
+
+ const rail = [
+ `Frozen evidence: scene ${scene.sceneId} sha256 ${scene.sha256.slice(0, 16)} run ${filmedRun.correlationId}`,
+ 'Non-claim: one recorded traversal; it does not reproduce the controlled local counterfactual on Google Cloud',
+ ];
+
+ const chrome = toSvg(flat([
+ text(M, 90, CAPTURE_CLASS_LABEL, { size: 18, mono: true, fill: t.muted, weight: 500, tracking: 3 }),
+ text(W - M, 90, scene.sceneId, { size: 18, mono: true, fill: t.muted, weight: 500, tracking: 2, anchor: 'end' }),
+ line(M, 116, W - M, 116, { stroke: t.hair, width: 1 }),
+ text(M, 184, beat.title, { size: 44, weight: 600, fill: t.fg, tracking: -0.8 }),
+ rect(stage.x, stage.y, stage.w, stage.h, { stroke: t.hair, width: 1 }),
+ cap.nodes,
+ line(M, RAIL_Y, W - M, RAIL_Y, { stroke: t.hair, width: 1 }),
+ rail.map((r, i) => text(M, RAIL_Y + 34 + i * 26, r, { size: 16, mono: true, fill: t.muted })),
+ ]), {
+ width: W,
+ height: H,
+ background: 'none',
+ title: `${scene.sceneId} - ${beat.title}`,
+ desc: `${CAPTURE_CLASS_LABEL}. ${beat.narration} ${rail.join(' ')}`,
+ });
+
+ const b64 = sourcePng.toString('base64');
+ return '`
+ + ` `
+ + ``
+ + ` `
+ + ' '
+ + chrome.replace(/^\n';
+}
+
+/* -- run ------------------------------------------------------------------ */
+
+assertFontsUsable();
+
+mkdirSync(framesDir, { recursive: true });
+for (const f of readdirSync(framesDir)) if (f.endsWith('.png')) rmSync(join(framesDir, f));
+
+const mastersDir = join(filmDir, 'masters');
+
+/**
+ * The master file for a `(board, state)` pair.
+ *
+ * Resolved by looking at what `build-boards.mjs` actually wrote rather than by
+ * rebuilding the filename from the naming grammar: if the two ever disagree, the
+ * disagreement should surface as a missing master, not as a frame silently
+ * rendered from the wrong state.
+ */
+function masterFileFor(source) {
+ const suffix = `-${source.state}.svg`;
+ const hits = readdirSync(mastersDir)
+ .filter((f) => f.startsWith(`${source.master}-`) && f.endsWith(suffix));
+ if (hits.length !== 1) {
+ throw new Error(
+ `expected exactly one master for ${source.master} state ${source.state}, found ${hits.length}. `
+ + 'Run media/hac-336/bin/build-boards.mjs first.',
+ );
+ }
+ return hits[0];
+}
+
+const frames = [];
+for (const beat of cut.beats) {
+ const { source } = beat;
+
+ if (source.kind === 'asset') {
+ const abs = join(repoRoot, source.path);
+ if (!existsSync(abs)) throw new Error(`${beat.beatId}: frozen export missing: ${source.path}`);
+ const buf = readFileSync(abs);
+ const size = pngSize(buf);
+ if (size.width !== W || size.height !== H) {
+ throw new Error(`${beat.beatId}: ${source.path} is ${size.width}x${size.height}, not ${W}x${H}`);
+ }
+ frames.push({
+ beatId: beat.beatId,
+ kind: 'asset',
+ assetId: source.assetId,
+ path: source.path,
+ composed: false,
+ width: size.width,
+ height: size.height,
+ sha256: sha256(buf),
+ sourcePath: source.path,
+ sourceSha256: sha256(buf),
+ });
+ continue;
+ }
+
+ if (source.kind === 'board') {
+ const name = masterFileFor(source);
+ const svgPath = join('media', 'hac-336', 'masters', name);
+ const svg = readFileSync(join(repoRoot, svgPath), 'utf8');
+ const png = rasterise(svg);
+ const size = pngSize(png);
+ if (size.width !== W || size.height !== H) {
+ throw new Error(`${beat.beatId}: rasterised ${size.width}x${size.height}, not ${W}x${H}`);
+ }
+ if (distinctBytes(png) < 32) throw new Error(`${beat.beatId}: rasterised frame is degenerate`);
+ const out = `${beat.beatId}-${source.master}-${source.state}.png`;
+ writeFileSync(join(framesDir, out), png);
+ frames.push({
+ beatId: beat.beatId,
+ kind: 'board',
+ assetId: source.master,
+ state: source.state,
+ path: `media/hac-336/frames/${out}`,
+ composed: true,
+ width: size.width,
+ height: size.height,
+ sha256: sha256(png),
+ sourcePath: svgPath,
+ sourceSha256: sha256(Buffer.from(svg, 'utf8')),
+ });
+ continue;
+ }
+
+ if (source.kind === 'capture') {
+ const scene = capturePackage.frames.find((f) => f.sceneId === source.sceneId);
+ if (!scene) throw new Error(`${beat.beatId}: no promoted scene ${source.sceneId} in the capture package`);
+ const scenePath = `experiments/hac-324/frames/scene-${source.sceneId}.png`;
+ const src = readFileSync(join(repoRoot, scenePath));
+ if (sha256(src) !== scene.sha256) {
+ throw new Error(
+ `${beat.beatId}: ${scenePath} hashes to ${sha256(src).slice(0, 16)} but the capture manifest `
+ + `promoted ${scene.sha256.slice(0, 16)}. The committed frame is not the frame that was filmed.`,
+ );
+ }
+ const srcSize = pngSize(src);
+ const { crop } = source;
+ if (crop.x < 0 || crop.y < 0 || crop.x + crop.w > srcSize.width || crop.y + crop.h > srcSize.height) {
+ throw new Error(`${beat.beatId}: crop ${JSON.stringify(crop)} leaves the ${srcSize.width}x${srcSize.height} capture`);
+ }
+ const stage = stageFor(crop);
+ const png = rasterise(captureFrame(beat, scene, src));
+ if (distinctBytes(png) < 32) throw new Error(`${beat.beatId}: composed capture frame is degenerate`);
+ const out = `${beat.beatId}-scene-${source.sceneId}${source.cropId ? `-${source.cropId}` : ''}.png`;
+ writeFileSync(join(framesDir, out), png);
+ frames.push({
+ beatId: beat.beatId,
+ kind: 'capture',
+ sceneId: source.sceneId,
+ cropId: source.cropId ?? null,
+ crop,
+ stage,
+ path: `media/hac-336/frames/${out}`,
+ composed: true,
+ width: W,
+ height: H,
+ sha256: sha256(png),
+ sourcePath: scenePath,
+ sourceSha256: scene.sha256,
+ });
+ continue;
+ }
+
+ throw new Error(`${beat.beatId}: unknown source kind ${source.kind}`);
+}
+
+writeFileSync(
+ join(filmDir, 'evidence', 'frame-manifest.json'),
+ `${JSON.stringify({
+ manifestId: 'HAC-336-frame-manifest',
+ revision: cut.revision,
+ issue: 'HAC-336',
+ note: 'Derived by media/hac-336/bin/build-frames.mjs. Do not hand-edit. `sourceSha256` binds each frame to the artifact it came from: an SVG master for a board, the promoted capture bytes for a filmed scene, the frozen export itself for an asset.',
+ generator: 'media/hac-336/bin/build-frames.mjs',
+ rasteriser: `@resvg/resvg-js@${JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).devDependencies['@resvg/resvg-js']}`,
+ geometry: { width: W, height: H },
+ stageBox: STAGE_BOX,
+ frames,
+ }, null, 2)}\n`,
+);
+
+process.stdout.write(
+ `HAC-336 frames built\n ${frames.filter((f) => f.composed).length} composed in media/hac-336/frames\n`
+ + ` ${frames.filter((f) => !f.composed).length} frozen exports used in place\n`,
+);
diff --git a/media/hac-336/bin/build-input-manifest.mjs b/media/hac-336/bin/build-input-manifest.mjs
new file mode 100644
index 0000000..e69f677
--- /dev/null
+++ b/media/hac-336/bin/build-input-manifest.mjs
@@ -0,0 +1,228 @@
+#!/usr/bin/env node
+/**
+ * Derives the assembly input manifest and the scene map.
+ *
+ * The editor should not depend on memory. Every artifact the cut consumes is
+ * listed here with its content digest, every declared revision that can be
+ * cross-checked against frozen evidence is checked, and every beat resolves to
+ * the exact claim rows that license what it says.
+ *
+ * Content digests are the load-bearing binding, not the revision strings. A
+ * commit SHA says which revision was checked out; it cannot answer *are these
+ * bytes still the bytes the cut was built from*, and HAC-335 already paid for
+ * that distinction once when an asset registry kept a superseded capture SHA
+ * while the capture itself had moved. Two of the six declared revisions cannot
+ * be checked at all — a commit cannot name itself — and the manifest says so
+ * rather than implying a verification that never happens.
+ *
+ * The scene map is the same data ordered for a human: what is on screen, when,
+ * under which proof class, from which artifact, saying what.
+ */
+import { readFileSync, writeFileSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { timeline, clock } from './lib/timeline.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const readBytes = (p) => readFileSync(join(repoRoot, p));
+const readJson = (p) => JSON.parse(readBytes(p).toString('utf8'));
+const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
+
+const cut = readJson('media/hac-336/evidence/cut.json');
+const frameManifest = readJson('media/hac-336/evidence/frame-manifest.json');
+const filmedClaims = readJson('media/hac-336/evidence/filmed-run-claims.json');
+const ledger = readJson('media/hac-335/evidence/claim-ledger.json');
+const capturePackage = readJson('experiments/hac-324/evidence/capture-package.json');
+
+const tl = timeline(cut);
+
+/** RFC 6901 JSON pointer, enough of it for a `/a/b/0/c` path. */
+function resolvePointer(doc, pointer) {
+ if (pointer === '') return doc;
+ let node = doc;
+ for (const raw of pointer.slice(1).split('/')) {
+ const key = raw.replace(/~1/g, '/').replace(/~0/g, '~');
+ if (node === null || node === undefined) return undefined;
+ node = Array.isArray(node) ? node[Number(key)] : node[key];
+ }
+ return node;
+}
+
+/* -- declared revisions, cross-checked where the evidence can check them --- */
+
+const revisions = [];
+for (const [name, decl] of Object.entries(cut.sourceRevisions)) {
+ if (name === 'note') continue;
+ if (!decl.checkable) {
+ revisions.push({ name, value: decl.value, checkable: false, meaning: decl.meaning });
+ continue;
+ }
+ const found = resolvePointer(readJson(decl.source), decl.pointer);
+ if (found !== decl.value) {
+ throw new Error(
+ `declared ${name} is ${decl.value} but ${decl.source}${decl.pointer} holds `
+ + `${JSON.stringify(found)}. The cut is bound to a revision the evidence does not carry.`,
+ );
+ }
+ revisions.push({
+ name, value: decl.value, checkable: true, source: decl.source, pointer: decl.pointer, agrees: true,
+ });
+}
+
+/* -- inputs --------------------------------------------------------------- */
+
+const inputPaths = new Map();
+const addInput = (role, path) => {
+ const existing = inputPaths.get(path);
+ if (existing) {
+ if (!existing.roles.includes(role)) existing.roles.push(role);
+ return;
+ }
+ const bytes = readBytes(path);
+ inputPaths.set(path, { path, roles: [role], bytes: bytes.length, sha256: sha256(bytes) });
+};
+
+addInput('edit decision list', 'media/hac-336/evidence/cut.json');
+addInput('filmed-run claim rows', 'media/hac-336/evidence/filmed-run-claims.json');
+
+addInput('controlled local experiment - arms', 'experiments/hac-330/evidence/arms.json');
+addInput('controlled local experiment - results', 'experiments/hac-330/evidence/results.json');
+addInput('bounded evaluation - judge export', 'experiments/hac-343/evidence/judge-export.json');
+addInput('authoritative filmed run - record', 'experiments/hac-324/evidence/filmed-run.json');
+addInput('authoritative filmed run - emitted bytes', 'experiments/hac-324/evidence/filmed-run.raw.json');
+addInput('authoritative filmed run - capture manifest', 'experiments/hac-324/evidence/capture-package.json');
+addInput('frozen HAC-340 reference packet', 'experiments/hac-342/evidence/cloud-run.public.json');
+addInput('judge-package claim ledger', 'media/hac-335/evidence/claim-ledger.json');
+addInput('storyboard contract', 'media/hac-333/scene-manifest.json');
+
+for (const frame of frameManifest.frames) {
+ if (frame.kind === 'asset') addInput(`frozen export used at ${frame.beatId}`, frame.path);
+ if (frame.kind === 'board') addInput(`film board master used at ${frame.beatId}`, frame.sourcePath);
+ if (frame.kind === 'capture') addInput(`promoted capture scene used at ${frame.beatId}`, frame.sourcePath);
+}
+
+/* -- promoted scenes ------------------------------------------------------ */
+
+const usedScenes = new Set(
+ cut.beats.filter((b) => b.source.kind === 'capture').map((b) => b.source.sceneId),
+);
+const promotedScenes = capturePackage.frames.map((f) => ({
+ sceneId: f.sceneId,
+ sha256: f.sha256,
+ bytes: f.bytes,
+ source: f.source,
+ qualityPass: f.qualityPass,
+ usedInCut: usedScenes.has(f.sceneId),
+ beats: cut.beats.filter((b) => b.source.sceneId === f.sceneId).map((b) => b.beatId),
+}));
+
+const unusedScenes = promotedScenes.filter((s) => !s.usedInCut).map((s) => s.sceneId);
+
+/* -- claims --------------------------------------------------------------- */
+
+const claimText = new Map();
+for (const c of ledger.claims) {
+ claimText.set(c.id, { id: c.id, text: c.text, classification: c.classification, source: 'HAC-335 claim ledger', proofSource: c.proofSource });
+}
+for (const c of filmedClaims.claims) {
+ const resolved = c.pointers.map((ptr) => {
+ const doc = readJson(filmedClaims.sources[ptr.source]);
+ const value = resolvePointer(doc, ptr.pointer);
+ if (value === undefined) {
+ throw new Error(`${c.id}: pointer ${ptr.pointer} does not resolve in ${filmedClaims.sources[ptr.source]}`);
+ }
+ return { source: filmedClaims.sources[ptr.source], pointer: ptr.pointer, value };
+ });
+ claimText.set(c.id, {
+ id: c.id, text: c.text, classification: c.classification, source: 'HAC-336 filmed-run claim rows', resolved,
+ });
+}
+
+const referenced = new Set(cut.beats.flatMap((b) => b.claims));
+for (const id of referenced) {
+ if (!claimText.has(id)) throw new Error(`the cut cites ${id}, which is in no claim ledger`);
+}
+
+/* -- emit ----------------------------------------------------------------- */
+
+const write = (name, body) => writeFileSync(
+ join(repoRoot, 'media', 'hac-336', 'evidence', name),
+ `${JSON.stringify(body, null, 2)}\n`,
+);
+
+write('input-manifest.json', {
+ manifestId: 'HAC-336-input-manifest',
+ revision: cut.revision,
+ issue: 'HAC-336',
+ note: 'Derived by media/hac-336/bin/build-input-manifest.mjs. Do not hand-edit. Every artifact the final cut consumes, with its content digest; every declared revision, with whether it could be checked against frozen evidence and whether it agreed.',
+ generator: 'media/hac-336/bin/build-input-manifest.mjs',
+ declaredRevisions: revisions,
+ capturePackage: {
+ packageId: capturePackage.packageId,
+ filmedRunId: capturePackage.filmedRunId,
+ correlationId: capturePackage.correlationId,
+ receiptId: capturePackage.receiptId,
+ runtimeSourceSha: capturePackage.runtimeSourceSha,
+ productRevision: capturePackage.productRevision,
+ model: capturePackage.model,
+ adk: capturePackage.adk,
+ region: capturePackage.region,
+ allFramesPassQuality: capturePackage.allFramesPassQuality,
+ teardown: capturePackage.teardown,
+ note: 'productRevision is the Director revision that performed the capture. It is deliberately not the same value as the declared directorSha, which is Director main at assembly time.',
+ },
+ promotedScenes,
+ unusedPromotedScenes: {
+ sceneIds: unusedScenes,
+ note: unusedScenes.length
+ ? 'Promoted by the capture manifest and not placed in the cut. Recorded so an omission is visible rather than silent.'
+ : 'None. Every promoted scene appears in the cut.',
+ },
+ inputs: [...inputPaths.values()].sort((a, b) => (a.path < b.path ? -1 : 1)),
+ claims: [...claimText.values()].filter((c) => referenced.has(c.id)),
+ narrationSource: {
+ path: 'media/hac-336/evidence/cut.json',
+ sha256: inputPaths.get('media/hac-336/evidence/cut.json').sha256,
+ note: 'The narration and caption text is authored in the cut and nowhere else. The caption files are generated from it, so a caption cannot drift from the scene map.',
+ },
+ timeline: { totalSeconds: tl.totalSeconds, holdSum: tl.holdSum, transitions: tl.transitions, beats: tl.beats.length },
+});
+
+write('scene-map.json', {
+ manifestId: 'HAC-336-scene-map',
+ revision: cut.revision,
+ issue: 'HAC-336',
+ note: 'Derived by media/hac-336/bin/build-input-manifest.mjs. Do not hand-edit.',
+ generator: 'media/hac-336/bin/build-input-manifest.mjs',
+ totalSeconds: tl.totalSeconds,
+ scenes: tl.beats.map((b) => {
+ const beat = cut.beats[b.index];
+ const frame = frameManifest.frames.find((f) => f.beatId === beat.beatId);
+ return {
+ beatId: beat.beatId,
+ timestamp: `${clock(b.startSeconds)}-${clock(b.endSeconds)}`,
+ startSeconds: b.startSeconds,
+ endSeconds: b.endSeconds,
+ act: beat.act,
+ proofClass: beat.proofClass,
+ proofClassMeaning: cut.proofClasses[beat.proofClass],
+ scene: frame.kind === 'capture'
+ ? `filmed capture ${frame.sceneId}${frame.cropId ? ` (${frame.cropId})` : ''}`
+ : `${frame.assetId}${frame.state ? ` (${frame.state})` : ''}`,
+ sourceArtifact: frame.sourcePath,
+ sourceSha256: frame.sourceSha256,
+ frame: frame.path,
+ claimCommunicated: beat.narration,
+ claims: beat.claims,
+ };
+ }),
+});
+
+process.stdout.write(
+ 'HAC-336 input manifest and scene map derived\n'
+ + ` ${inputPaths.size} inputs, ${revisions.filter((r) => r.checkable).length}/${revisions.length} declared revisions cross-checked\n`
+ + ` ${promotedScenes.filter((s) => s.usedInCut).length}/${promotedScenes.length} promoted capture scenes used\n`
+ + ` ${referenced.size} claim rows cited across ${cut.beats.length} beats\n`,
+);
diff --git a/media/hac-336/bin/build-video.mjs b/media/hac-336/bin/build-video.mjs
new file mode 100644
index 0000000..eeab7ba
--- /dev/null
+++ b/media/hac-336/bin/build-video.mjs
@@ -0,0 +1,249 @@
+#!/usr/bin/env node
+/**
+ * Assembles the frames into the final cut.
+ *
+ * The only step in this package that needs a tool outside Node, and the only one
+ * CI does not run. That split is deliberate: `verify-film.mjs` reads the
+ * finished MP4's own boxes rather than re-encoding it, so the gate has no
+ * ffmpeg dependency and cannot pass by being skipped on a runner that lacks one.
+ *
+ * There is no audio track. HAC-333 froze this cut as muted — every claim has to
+ * survive with the sound off — so the caption files carry the narration as text
+ * and double as a voice-over script. Generating synthetic speech to fill the
+ * silence would add a channel that no gate can check and no human spoke.
+ *
+ * The transition is a crossfade and nothing else. Each beat is a still hold, so
+ * the only thing that moves in four minutes is the change between two frozen
+ * states. Nothing counts up, nothing types itself out, and nothing on screen
+ * suggests a frozen result is being recomputed while the viewer watches.
+ */
+import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { dirname, join, isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { buildExportName, validateExportName } from '../../../scripts/export-naming.mjs';
+import { timeline } from './lib/timeline.mjs';
+import { cues, vtt, srt, VTT_PATH, SRT_PATH } from './build-captions.mjs';
+import { inspectMp4, trackHandlers } from './lib/mp4.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const filmDir = join(repoRoot, 'media', 'hac-336');
+
+const readJson = (p) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
+const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
+
+const cut = readJson('media/hac-336/evidence/cut.json');
+const frameManifest = readJson('media/hac-336/evidence/frame-manifest.json');
+const tl = timeline(cut);
+
+/** The submission ceiling, and the reason the cut is planned under it. */
+const HARD_CEILING_SECONDS = 240;
+if (tl.totalSeconds > HARD_CEILING_SECONDS) {
+ throw new Error(
+ `the cut assembles to ${tl.totalSeconds}s, over the ${HARD_CEILING_SECONDS}s submission ceiling. `
+ + 'Shorten holds in cut.json; do not drop a required proof element to fit.',
+ );
+}
+
+const frameFor = (beatId) => {
+ const f = frameManifest.frames.find((x) => x.beatId === beatId);
+ if (!f) throw new Error(`no frame for ${beatId}. Run media/hac-336/bin/build-frames.mjs first.`);
+ const abs = join(repoRoot, f.path);
+ if (!existsSync(abs)) throw new Error(`${beatId}: frame missing on disk: ${f.path}`);
+ if (sha256(readFileSync(abs)) !== f.sha256) {
+ throw new Error(`${beatId}: ${f.path} no longer matches the frame manifest. Rebuild frames.`);
+ }
+ return { ...f, abs };
+};
+
+/* -- the encoder ---------------------------------------------------------- */
+
+/**
+ * Resolve ffmpeg to an absolute path, never through `PATH`.
+ *
+ * Invoking a bare `ffmpeg` runs whatever the first writable directory on `PATH`
+ * happens to offer, which is both a real substitution risk and a reproducibility
+ * one: the binary that encoded the submission video should be a fact the render
+ * manifest can record, not whatever the shell found that afternoon.
+ *
+ * `FFMPEG` overrides for a host that keeps it elsewhere, and must itself be
+ * absolute — an override that reintroduced a relative lookup would defeat the
+ * point of having one.
+ */
+const FFMPEG_CANDIDATES = [
+ '/opt/homebrew/bin/ffmpeg',
+ '/usr/local/bin/ffmpeg',
+ '/usr/bin/ffmpeg',
+ '/snap/bin/ffmpeg',
+];
+
+function resolveFfmpeg() {
+ const override = process.env.FFMPEG;
+ if (override) {
+ if (!isAbsolute(override)) {
+ throw new Error(`FFMPEG must be an absolute path, not ${JSON.stringify(override)}`);
+ }
+ if (!existsSync(override) || !statSync(override).isFile()) {
+ throw new Error(`FFMPEG points at ${override}, which is not a file`);
+ }
+ return override;
+ }
+ const found = FFMPEG_CANDIDATES.find((c) => existsSync(c) && statSync(c).isFile());
+ if (!found) {
+ throw new Error(
+ `no ffmpeg found at any of ${FFMPEG_CANDIDATES.join(', ')}. Install it, or set FFMPEG to an `
+ + 'absolute path. This step is deliberately not run in CI; the gate reads the finished file instead.',
+ );
+ }
+ return found;
+}
+
+const FFMPEG = resolveFfmpeg();
+
+/* -- captions ------------------------------------------------------------- */
+
+/*
+ * Not written here. `build-captions.mjs` owns them so CI can regenerate and diff
+ * without ffmpeg; this step only proves the committed files are the ones the
+ * current cut derives, so the manifest cannot record a digest for a caption file
+ * that no longer matches the narration.
+ */
+for (const [path, expected] of [[VTT_PATH, vtt], [SRT_PATH, srt]]) {
+ if (!existsSync(join(repoRoot, path))) {
+ throw new Error(`${path} is missing. Run media/hac-336/bin/build-captions.mjs first.`);
+ }
+ if (readFileSync(join(repoRoot, path), 'utf8') !== expected) {
+ throw new Error(`${path} does not match the current cut. Re-run media/hac-336/bin/build-captions.mjs.`);
+ }
+}
+
+/* -- video ---------------------------------------------------------------- */
+
+const exportName = buildExportName({
+ id: 'IL-MOT-020',
+ slug: 'interlock-final-cut',
+ width: cut.geometry.width,
+ height: cut.geometry.height,
+ ext: 'mp4',
+});
+const nameCheck = validateExportName(exportName);
+if (!nameCheck.valid) throw new Error(`built an unparseable export name ${exportName}: ${nameCheck.error}`);
+
+const exportsDir = join(filmDir, 'exports');
+mkdirSync(exportsDir, { recursive: true });
+const outPath = join(exportsDir, exportName);
+
+const frames = tl.beats.map((b) => frameFor(b.beatId));
+const { fps } = cut.geometry;
+const x = cut.transitionSeconds;
+
+/*
+ * One ffmpeg invocation, one xfade chain.
+ *
+ * Each input is a still looped for its hold. `offset` is where the crossfade
+ * into the next beat begins, measured on the *accumulated* chain rather than on
+ * the input, which is why it tracks the same cursor `timeline()` uses. Getting
+ * that wrong does not fail loudly — it produces a video that is subtly the wrong
+ * length — so the assembled file is measured afterwards and compared.
+ */
+const inputs = frames.flatMap((f, i) => [
+ '-loop', '1', '-t', String(cut.beats[i].holdSeconds), '-i', f.abs,
+]);
+
+let filter = '';
+let label = '[0:v]';
+let chainEnd = cut.beats[0].holdSeconds;
+for (let i = 1; i < frames.length; i += 1) {
+ const next = `[x${i}]`;
+ const offset = Math.round((chainEnd - x) * 1000) / 1000;
+ filter += `${label}[${i}:v]xfade=transition=fade:duration=${x}:offset=${offset}${next};`;
+ label = next;
+ chainEnd = chainEnd - x + cut.beats[i].holdSeconds;
+}
+filter += `${label}format=yuv420p[v]`;
+
+const args = [
+ '-hide_banner', '-loglevel', 'error', '-y',
+ ...inputs,
+ '-filter_complex', filter,
+ '-map', '[v]',
+ '-r', String(fps),
+ '-c:v', 'libx264',
+ '-preset', 'veryslow',
+ '-crf', '18',
+ // Still frames with hard state changes: a keyframe on every beat keeps a
+ // scrubbing judge from landing between two boards.
+ '-g', String(fps * 2),
+ '-pix_fmt', 'yuv420p',
+ '-movflags', '+faststart',
+ '-an',
+ outPath,
+];
+
+process.stdout.write(`ffmpeg: ${frames.length} stills, ${tl.transitions} crossfades, target ${tl.totalSeconds}s\n`);
+execFileSync(FFMPEG, args, { stdio: ['ignore', 'inherit', 'inherit'] });
+
+/* -- measure what was actually written ------------------------------------ */
+
+const bytes = readFileSync(outPath);
+const probe = inspectMp4(bytes);
+const handlers = trackHandlers(bytes);
+
+const drift = Math.abs(probe.durationSeconds - tl.totalSeconds);
+if (drift > 0.2) {
+ throw new Error(
+ `assembled duration ${probe.durationSeconds.toFixed(3)}s differs from the derived `
+ + `${tl.totalSeconds}s by ${drift.toFixed(3)}s. The xfade offsets and the timeline disagree.`,
+ );
+}
+if (probe.width !== cut.geometry.width || probe.height !== cut.geometry.height) {
+ throw new Error(`assembled ${probe.width}x${probe.height}, not ${cut.geometry.width}x${cut.geometry.height}`);
+}
+if (handlers.includes('soun')) {
+ throw new Error('the assembled cut carries an audio track; this cut is muted by contract');
+}
+
+writeFileSync(
+ join(filmDir, 'evidence', 'render-manifest.json'),
+ `${JSON.stringify({
+ manifestId: 'HAC-336-render-manifest',
+ revision: cut.revision,
+ issue: 'HAC-336',
+ note: 'Derived by media/hac-336/bin/build-video.mjs. Do not hand-edit. Duration, geometry and codec are read back out of the finished file rather than copied from the encode request.',
+ generator: 'media/hac-336/bin/build-video.mjs',
+ encoder: execFileSync(FFMPEG, ['-version'], { encoding: 'utf8' }).split('\n')[0],
+ encoderPath: FFMPEG,
+ video: {
+ path: `media/hac-336/exports/${exportName}`,
+ sha256: sha256(bytes),
+ bytes: bytes.length,
+ width: probe.width,
+ height: probe.height,
+ codec: probe.codec,
+ brand: probe.brand,
+ fps,
+ measuredDurationSeconds: Number(probe.durationSeconds.toFixed(3)),
+ derivedDurationSeconds: tl.totalSeconds,
+ audioTracks: handlers.filter((h) => h === 'soun').length,
+ trackHandlers: handlers,
+ },
+ captions: [
+ { path: VTT_PATH, format: 'WebVTT', cues: cues.length, sha256: sha256(readFileSync(join(repoRoot, VTT_PATH))) },
+ { path: SRT_PATH, format: 'SubRip', cues: cues.length, sha256: sha256(readFileSync(join(repoRoot, SRT_PATH))) },
+ ],
+ // Which frames this encode actually contains. Without it, editing a board
+ // after the encode leaves a video that no longer shows what the frame
+ // manifest describes, and every digest in sight still matches.
+ frames: frames.map((f) => ({ beatId: f.beatId, path: f.path, sha256: f.sha256 })),
+ timeline: tl,
+ }, null, 2)}\n`,
+);
+
+process.stdout.write(
+ `HAC-336 cut assembled\n media/hac-336/exports/${exportName}\n`
+ + ` ${probe.width}x${probe.height} ${probe.codec} ${probe.durationSeconds.toFixed(2)}s `
+ + `(${(bytes.length / 1e6).toFixed(1)} MB, no audio track)\n`
+ + ` captions: ${cues.length} cues, WebVTT + SubRip\n`,
+);
diff --git a/media/hac-336/bin/lib/film-board.mjs b/media/hac-336/bin/lib/film-board.mjs
new file mode 100644
index 0000000..8afca89
--- /dev/null
+++ b/media/hac-336/bin/lib/film-board.mjs
@@ -0,0 +1,129 @@
+/**
+ * The film board frame: the HAC-334 board grammar, held at video geometry.
+ *
+ * HAC-336 does not own a visual system. It owns an edit. Every board it renders
+ * therefore composes with the same header rail, title, safe area and evidence
+ * rail that `media/hac-334/bin/render-masters.mjs` established, drawn with the
+ * same display-list vocabulary from `media/hac-334/bin/lib/draw.mjs`. What is
+ * new here is only what the edit needs and the static suite does not have: a
+ * board that resolves to more than one state, so a crossfade between two holds
+ * can carry a state change instead of decoration.
+ *
+ * Why a shared composer rather than copying HAC-334's `compose()`: the safe-area
+ * and rail-collision assertions are the reason a label cannot silently run off a
+ * 1080p frame. A second copy of them would drift, and the copy that drifted
+ * would be the one that stopped catching anything.
+ *
+ * Reusing HAC-334's `lib/` across issue boundaries is deliberate and narrow. The
+ * import is one direction only — HAC-336 reads HAC-334, never the reverse — so
+ * regenerating the static suite cannot be broken by the film.
+ */
+import { join } from 'node:path';
+import {
+ N, INK, PAPER, text, line, measure,
+} from '../../../hac-334/bin/lib/draw.mjs';
+import { toSvg } from '../../../hac-334/bin/lib/svg.mjs';
+
+export const W = 1920;
+export const H = 1080;
+/** The HAC-334 margin, unchanged: a film frame and a still share one safe area. */
+export const M = 88;
+/** Where the evidence rail rule sits. Content must end above it. */
+export const RAIL_Y = 962;
+
+export const flat = (a) => a.flat(Infinity).filter(Boolean);
+
+/**
+ * Paper for the controlled local classes, ink for Google Cloud participation.
+ * HAC-333's split, transcribed rather than reinvented, so the proof-class reset
+ * at B09 reads as the same inversion the storyboard froze.
+ */
+export function themeFor(proofClass) {
+ return proofClass === 'B'
+ ? { bg: INK, fg: '#f2f3f2', muted: N[40], hair: N[70], dark: true }
+ : { bg: PAPER, fg: INK, muted: N[50], hair: N[30], dark: false };
+}
+
+/** A titled panel, the workhorse. Same shape as HAC-334's. */
+export const panel = (x, y, w, h, label, t, o = {}) => [
+ {
+ t: 'rect', x, y, w, h,
+ fill: o.fill ?? null, stroke: o.stroke ?? t.hair, width: o.width ?? 1,
+ dash: o.dash ?? null, opacity: o.opacity ?? 1,
+ },
+ label
+ ? text(x + 24, y + 36, label, { size: 17, mono: true, fill: o.labelFill ?? t.muted, weight: 500, tracking: 2.2 })
+ : null,
+];
+
+/**
+ * Refuse a frame whose text leaves the safe area or crosses the rail rule.
+ *
+ * Both failures look fine as data and wrong as a picture, and a video makes them
+ * worse than a still does: nobody scrubs a 4-minute cut frame by frame looking
+ * for a clipped label.
+ */
+function assertInside(nodes, content, where) {
+ for (const node of nodes) {
+ if (node.t !== 'text') continue;
+ const w = measure(node.s, node.size, node);
+ const left = node.anchor === 'end' ? node.x - w : node.anchor === 'middle' ? node.x - w / 2 : node.x;
+ if (left < M - 2 || left + w > W - M + 2) {
+ throw new Error(
+ `${where}: text overflows the safe area [${Math.round(left)}..${Math.round(left + w)}] `
+ + `outside [${M}..${W - M}]: ${JSON.stringify(node.s.slice(0, 60))}`,
+ );
+ }
+ }
+ for (const node of content) {
+ const bottom = node.t === 'text' ? node.y + node.size * 0.24
+ : node.t === 'rect' ? node.y + node.h
+ : node.t === 'line' ? Math.max(node.y1, node.y2)
+ : node.t === 'circle' ? node.cy + node.r : 0;
+ if (bottom > RAIL_Y - 8) {
+ throw new Error(
+ `${where}: content reaches y=${Math.round(bottom)}, past the rail rule at ${RAIL_Y}`
+ + `${node.t === 'text' ? `: ${JSON.stringify(node.s.slice(0, 60))}` : ` (${node.t})`}`,
+ );
+ }
+ }
+}
+
+/**
+ * Compose one board state into an SVG master.
+ *
+ * @param {object} spec
+ * @param {string} spec.id registry id, `IL-{FAMILY}-{NNN}`
+ * @param {string} spec.state state name; part of the master's slug
+ * @param {string} spec.proofClass keys `themeFor`
+ * @param {string} spec.classLabel the header rail's left-hand label
+ * @param {string} spec.title the board title
+ * @param {string[]} spec.rail evidence rail lines, already `Frozen evidence:` / `Non-claim:` shaped
+ * @param {(t:object)=>object[]} spec.render body, given the resolved theme
+ */
+export function composeBoard(spec) {
+ const t = themeFor(spec.proofClass);
+ const content = flat([spec.render(t)]);
+ const nodes = flat([
+ text(M, 90, spec.classLabel, { size: 18, mono: true, fill: t.muted, weight: 500, tracking: 3 }),
+ text(W - M, 90, spec.id, { size: 18, mono: true, fill: t.muted, weight: 500, tracking: 2, anchor: 'end' }),
+ line(M, 116, W - M, 116, { stroke: t.hair, width: 1 }),
+ text(M, 184, spec.title, { size: 50, weight: 600, fill: t.fg, tracking: -0.8 }),
+ content,
+ line(M, RAIL_Y, W - M, RAIL_Y, { stroke: t.hair, width: 1 }),
+ spec.rail.map((r, i) => text(M, RAIL_Y + 34 + i * 26, r, { size: 16, mono: true, fill: t.muted })),
+ ]);
+
+ assertInside(nodes, content, `${spec.id}:${spec.state}`);
+
+ return toSvg(nodes, {
+ width: W,
+ height: H,
+ background: t.bg,
+ title: `${spec.id} ${spec.title} - ${spec.state}`,
+ desc: `${spec.classLabel}. ${spec.rail.join(' ')}`,
+ });
+}
+
+/** Repository root, resolved from this module rather than from the caller's cwd. */
+export const repoRootFrom = (here) => join(here, '..', '..', '..', '..');
diff --git a/media/hac-336/bin/lib/mp4.mjs b/media/hac-336/bin/lib/mp4.mjs
new file mode 100644
index 0000000..43a21dd
--- /dev/null
+++ b/media/hac-336/bin/lib/mp4.mjs
@@ -0,0 +1,104 @@
+/**
+ * Enough of the ISO base media file format to interrogate a finished cut.
+ *
+ * Why not `ffprobe`: the gate that reads these values has to run in CI, and CI
+ * has no ffmpeg. A gate that silently skips when a tool is absent is not a gate
+ * — it is a comment. Duration, geometry and codec all live in fixed offsets
+ * inside `moov`, so reading them needs a box walker and nothing else.
+ *
+ * Deliberately narrow. This parses the boxes needed to answer four questions
+ * about a file this repository produced, and is not a general MP4 reader: it
+ * assumes a well-formed file and reports what it finds rather than trying to
+ * recover from damage.
+ */
+
+/** Walk the box tree at `buf[start..end)`, calling `visit(type, payloadRange)`. */
+function walk(buf, start, end, visit) {
+ let p = start;
+ while (p + 8 <= end) {
+ let size = buf.readUInt32BE(p);
+ const type = buf.toString('latin1', p + 4, p + 8);
+ let header = 8;
+ if (size === 1) {
+ // 64-bit size. Node has no readUInt64BE that is safe past 2^53, but a box
+ // that large cannot occur in a file this repository writes.
+ size = Number(buf.readBigUInt64BE(p + 8));
+ header = 16;
+ } else if (size === 0) {
+ size = end - p;
+ }
+ if (size < header || p + size > end) return;
+ visit(type, p + header, p + size);
+ p += size;
+ }
+}
+
+/** Depth-first search for the first box of `type` under `path`. */
+function find(buf, start, end, path) {
+ let hit = null;
+ walk(buf, start, end, (type, s, e) => {
+ if (hit) return;
+ if (type !== path[0]) return;
+ hit = path.length === 1 ? { start: s, end: e } : find(buf, s, e, path.slice(1));
+ });
+ return hit;
+}
+
+/**
+ * Duration in seconds, geometry in pixels, and the sample-description format of
+ * the first video track.
+ *
+ * @param {Buffer} buf whole file
+ * @returns {{durationSeconds:number,timescale:number,width:number,height:number,codec:string,brand:string}}
+ */
+export function inspectMp4(buf) {
+ if (buf.length < 16) throw new Error('not an MP4: file is too short to hold a box header');
+ const ftyp = find(buf, 0, buf.length, ['ftyp']);
+ if (!ftyp) throw new Error('not an MP4: no ftyp box');
+ const brand = buf.toString('latin1', ftyp.start, ftyp.start + 4);
+
+ const mvhd = find(buf, 0, buf.length, ['moov', 'mvhd']);
+ if (!mvhd) throw new Error('not an MP4: no moov/mvhd box');
+ const mvhdVersion = buf[mvhd.start];
+ const timescale = mvhdVersion === 1 ? buf.readUInt32BE(mvhd.start + 20) : buf.readUInt32BE(mvhd.start + 12);
+ const duration = mvhdVersion === 1
+ ? Number(buf.readBigUInt64BE(mvhd.start + 24))
+ : buf.readUInt32BE(mvhd.start + 16);
+ if (!timescale) throw new Error('MP4 declares a zero movie timescale');
+
+ const tkhd = find(buf, 0, buf.length, ['moov', 'trak', 'tkhd']);
+ if (!tkhd) throw new Error('not an MP4: no moov/trak/tkhd box');
+ const tkhdVersion = buf[tkhd.start];
+ const geomAt = tkhdVersion === 1 ? tkhd.start + 88 : tkhd.start + 76;
+ // Track width and height are 16.16 fixed point.
+ const width = buf.readUInt32BE(geomAt) / 65536;
+ const height = buf.readUInt32BE(geomAt + 4) / 65536;
+
+ const stsd = find(buf, 0, buf.length, ['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd']);
+ // stsd payload: version/flags(4), entry_count(4), then the first entry, whose
+ // own header is size(4) + format(4).
+ const codec = stsd ? buf.toString('latin1', stsd.start + 12, stsd.start + 16) : 'unknown';
+
+ return {
+ durationSeconds: duration / timescale,
+ timescale,
+ width: Math.round(width),
+ height: Math.round(height),
+ codec,
+ brand,
+ };
+}
+
+/** Every top-level `trak` handler type, so an unexpected audio track is visible. */
+export function trackHandlers(buf) {
+ const moov = find(buf, 0, buf.length, ['moov']);
+ if (!moov) return [];
+ const handlers = [];
+ walk(buf, moov.start, moov.end, (type, s, e) => {
+ if (type !== 'trak') return;
+ const hdlr = find(buf, s, e, ['mdia', 'hdlr']);
+ // hdlr payload: version/flags(4), pre_defined(4), handler_type(4).
+ if (hdlr) handlers.push(buf.toString('latin1', hdlr.start + 8, hdlr.start + 12));
+ });
+ return handlers;
+}
diff --git a/media/hac-336/bin/lib/timeline.mjs b/media/hac-336/bin/lib/timeline.mjs
new file mode 100644
index 0000000..bb5a277
--- /dev/null
+++ b/media/hac-336/bin/lib/timeline.mjs
@@ -0,0 +1,68 @@
+/**
+ * The one place the cut's arithmetic lives.
+ *
+ * Builder, caption writer and gate all derive timings here rather than each
+ * doing the subtraction. HAC-333 learned the same lesson on a 30-second
+ * storyboard: a duration read from a `total` field is a duration nobody
+ * recomputed, and the first edit that changes a hold leaves it stale and green.
+ *
+ * Crossfades overlap, so the assembled length is not the sum of the holds. Each
+ * transition consumes `transitionSeconds` of wall clock shared between the two
+ * beats it joins, which is why the subtraction is `beats - 1` and not `beats`.
+ */
+
+/** Round to the millisecond, so floating point cannot make two derivations differ. */
+const ms = (n) => Math.round(n * 1000) / 1000;
+
+/**
+ * @param {{beats:{beatId:string,holdSeconds:number}[],transitionSeconds:number}} cut
+ * @returns {{beats:object[],totalSeconds:number,holdSum:number,transitions:number}}
+ */
+export function timeline(cut) {
+ const x = cut.transitionSeconds;
+ const beats = [];
+ let cursor = 0;
+ for (const [i, beat] of cut.beats.entries()) {
+ const start = ms(cursor);
+ const end = ms(start + beat.holdSeconds);
+ beats.push({
+ beatId: beat.beatId,
+ index: i,
+ startSeconds: start,
+ endSeconds: end,
+ holdSeconds: beat.holdSeconds,
+ // The window in which this beat is the only thing on screen. A caption cue
+ // that started inside a crossfade would be legible over the wrong frame.
+ captionStart: ms(i === 0 ? start : start + x),
+ captionEnd: ms(i === cut.beats.length - 1 ? end : end - x),
+ });
+ cursor = end - x;
+ }
+ const holdSum = ms(cut.beats.reduce((a, b) => a + b.holdSeconds, 0));
+ return {
+ beats,
+ holdSum,
+ transitions: cut.beats.length - 1,
+ totalSeconds: ms(holdSum - x * (cut.beats.length - 1)),
+ };
+}
+
+/** `H:MM:SS.mmm`, the WebVTT timestamp shape. */
+export function vttTime(seconds) {
+ const t = Math.max(0, seconds);
+ const h = Math.floor(t / 3600);
+ const m = Math.floor((t % 3600) / 60);
+ const s = Math.floor(t % 60);
+ const milli = Math.round((t - Math.floor(t)) * 1000);
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(milli).padStart(3, '0')}`;
+}
+
+/** `HH:MM:SS,mmm`, the SubRip shape. Same instant, different punctuation. */
+export const srtTime = (seconds) => vttTime(seconds).replace('.', ',');
+
+/** `M:SS`, for a human-readable scene map. */
+export function clock(seconds) {
+ const m = Math.floor(seconds / 60);
+ const s = Math.floor(seconds % 60);
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
diff --git a/media/hac-336/bin/verify-film.mjs b/media/hac-336/bin/verify-film.mjs
new file mode 100644
index 0000000..e770938
--- /dev/null
+++ b/media/hac-336/bin/verify-film.mjs
@@ -0,0 +1,462 @@
+#!/usr/bin/env node
+/**
+ * The HAC-336 gate. Node builtins only: no ffmpeg, no git, no browser.
+ *
+ * A gate that needs a tool CI does not have is a gate that gets skipped, and a
+ * skipped gate reads as a pass. So nothing here re-encodes or re-renders. It
+ * interrogates what is committed: the MP4's own boxes, the frame digests, the
+ * capture manifest's promoted digests, the claim rows, and the text on every
+ * board.
+ *
+ * What it is actually protecting, in order of how much a failure would cost:
+ *
+ * 1. **Two runs staying two runs.** The controlled local experiment and the
+ * Google Cloud traversal are separate proof classes. The cut must not put
+ * class-B material before the reset, class-A material after it, or the
+ * frozen reference run's identifiers anywhere near filmed footage.
+ * 2. **Filmed evidence being the filmed evidence.** Every capture frame's
+ * source must hash to the digest the HAC-324 capture manifest promoted.
+ * 3. **Claims staying inside the ledger.** Every factual line maps to a row;
+ * every forbidden phrasing is refused; the HAC-343 figure never appears
+ * without its ablation control on the same board.
+ * 4. **The ceiling.** Four minutes, derived from the beats and cross-checked
+ * against what the encoder actually wrote.
+ *
+ * Failures print the defect and what to do about it, then exit 1.
+ */
+import { readFileSync, existsSync, readdirSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { timeline } from './lib/timeline.mjs';
+import { inspectMp4, trackHandlers } from './lib/mp4.mjs';
+import { cues, vtt, srt, VTT_PATH, SRT_PATH } from './build-captions.mjs';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = join(here, '..', '..', '..');
+const readBytes = (p) => readFileSync(join(repoRoot, p));
+const readJson = (p) => JSON.parse(readBytes(p).toString('utf8'));
+const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
+
+const failures = [];
+const check = (id, ok, detail) => {
+ if (!ok) failures.push(`${id}: ${detail}`);
+ return ok;
+};
+
+const cut = readJson('media/hac-336/evidence/cut.json');
+const frameManifest = readJson('media/hac-336/evidence/frame-manifest.json');
+const renderManifest = readJson('media/hac-336/evidence/render-manifest.json');
+const inputManifest = readJson('media/hac-336/evidence/input-manifest.json');
+const sceneMap = readJson('media/hac-336/evidence/scene-map.json');
+const filmedClaims = readJson('media/hac-336/evidence/filmed-run-claims.json');
+const ledger = readJson('media/hac-335/evidence/claim-ledger.json');
+const capturePackage = readJson('experiments/hac-324/evidence/capture-package.json');
+const filmedRun = readJson('experiments/hac-324/evidence/filmed-run.json');
+const judge = readJson('experiments/hac-343/evidence/judge-export.json');
+const storyboard = readJson('media/hac-333/scene-manifest.json');
+
+const tl = timeline(cut);
+const beatById = new Map(cut.beats.map((b) => [b.beatId, b]));
+const frameById = new Map(frameManifest.frames.map((f) => [f.beatId, f]));
+
+/** Every glyph the cut puts in front of a judge: narration plus board text. */
+const svgText = (svg) => [...svg.matchAll(/>([^<]+) m[1]).join(' ');
+const boardCopy = readdirSync(join(repoRoot, 'media/hac-336/masters'))
+ .filter((f) => f.endsWith('.svg'))
+ .map((f) => svgText(readFileSync(join(repoRoot, 'media/hac-336/masters', f), 'utf8')))
+ .join('\n');
+const narrationCopy = cut.beats.map((b) => `${b.narration} ${b.title ?? ''}`).join('\n');
+const allCopy = `${narrationCopy}\n${boardCopy}`;
+
+/* -- 1. structure --------------------------------------------------------- */
+
+check('CUT-IDS', new Set(cut.beats.map((b) => b.beatId)).size === cut.beats.length,
+ 'beat ids are not unique');
+check('CUT-CLASSES',
+ cut.beats.every((b) => Object.hasOwn(cut.proofClasses, b.proofClass)),
+ `a beat declares a proof class the cut does not define: ${
+ cut.beats.filter((b) => !Object.hasOwn(cut.proofClasses, b.proofClass)).map((b) => b.beatId).join(', ')}`);
+check('CUT-NARRATION', cut.beats.every((b) => typeof b.narration === 'string' && b.narration.length > 12),
+ 'a beat has no narration; every beat must state what it is claiming');
+check('CUT-CLAIMS', cut.beats.every((b) => Array.isArray(b.claims) && b.claims.length > 0),
+ 'a beat cites no claim row');
+check('CUT-HOLDS', cut.beats.every((b) => b.holdSeconds > cut.transitionSeconds * 2),
+ 'a hold is not longer than the two crossfades that consume it; the beat would never be fully on screen');
+
+/* -- 2. duration ---------------------------------------------------------- */
+
+const CEILING = 240;
+check('DUR-CEILING', tl.totalSeconds <= CEILING,
+ `the cut derives to ${tl.totalSeconds}s, over the ${CEILING}s submission ceiling`);
+check('DUR-DERIVED', renderManifest.video.derivedDurationSeconds === tl.totalSeconds,
+ `render manifest records a derived duration of ${renderManifest.video.derivedDurationSeconds}s `
+ + `but the cut derives ${tl.totalSeconds}s; the manifest is stale`);
+
+const videoPath = renderManifest.video.path;
+if (check('VID-PRESENT', existsSync(join(repoRoot, videoPath)), `${videoPath} is missing`)) {
+ const bytes = readBytes(videoPath);
+ check('VID-DIGEST', sha256(bytes) === renderManifest.video.sha256,
+ `${videoPath} does not match the digest in the render manifest; re-run build-video.mjs`);
+ const probe = inspectMp4(bytes);
+ const handlers = trackHandlers(bytes);
+ check('VID-DURATION', Math.abs(probe.durationSeconds - tl.totalSeconds) <= 0.2,
+ `the encoded file runs ${probe.durationSeconds.toFixed(3)}s but the cut derives ${tl.totalSeconds}s`);
+ check('VID-CEILING', probe.durationSeconds <= CEILING,
+ `the encoded file runs ${probe.durationSeconds.toFixed(3)}s, over the ${CEILING}s ceiling`);
+ check('VID-GEOMETRY', probe.width === cut.geometry.width && probe.height === cut.geometry.height,
+ `the encoded file is ${probe.width}x${probe.height}, not ${cut.geometry.width}x${cut.geometry.height}`);
+ check('VID-CODEC', probe.codec === 'avc1',
+ `the encoded file's video sample description is ${probe.codec}, not avc1 (H.264)`);
+ check('VID-MUTED', !handlers.includes('soun'),
+ 'the cut carries an audio track; HAC-333 froze this cut as muted');
+}
+
+/*
+ * The encode has to contain the frames that are committed now, not the ones
+ * that were committed when it ran. Every digest in this package can match while
+ * the video still shows a board that has since been corrected, and that failure
+ * is invisible without this comparison.
+ */
+check('VID-FRAMES-COUNT', renderManifest.frames.length === cut.beats.length,
+ `the encode records ${renderManifest.frames.length} frames for ${cut.beats.length} beats`);
+for (const encoded of renderManifest.frames) {
+ const current = frameById.get(encoded.beatId);
+ check(`VID-FRAME-${encoded.beatId}`, current && current.sha256 === encoded.sha256,
+ `the committed frame for ${encoded.beatId} has changed since the cut was encoded; `
+ + 're-run build-video.mjs so the video shows what the frame manifest describes');
+}
+
+/* -- 3. frames ------------------------------------------------------------ */
+
+for (const beat of cut.beats) {
+ const f = frameById.get(beat.beatId);
+ if (!check(`FRM-${beat.beatId}`, Boolean(f), 'no frame recorded for this beat')) continue;
+ if (!check(`FRM-${beat.beatId}-EXISTS`, existsSync(join(repoRoot, f.path)), `${f.path} is missing`)) continue;
+ check(`FRM-${beat.beatId}-DIGEST`, sha256(readBytes(f.path)) === f.sha256,
+ `${f.path} does not match the frame manifest; re-run build-frames.mjs`);
+ check(`FRM-${beat.beatId}-GEOMETRY`, f.width === cut.geometry.width && f.height === cut.geometry.height,
+ `${f.path} is ${f.width}x${f.height}, not ${cut.geometry.width}x${cut.geometry.height}`);
+ check(`FRM-${beat.beatId}-SOURCE`, existsSync(join(repoRoot, f.sourcePath)),
+ `the artifact this frame came from is missing: ${f.sourcePath}`);
+}
+
+/* -- 4. filmed evidence is the filmed evidence ---------------------------- */
+
+const promoted = new Map(capturePackage.frames.map((f) => [f.sceneId, f]));
+for (const beat of cut.beats.filter((b) => b.source.kind === 'capture')) {
+ const scene = promoted.get(beat.source.sceneId);
+ const f = frameById.get(beat.beatId);
+ if (!check(`CAP-${beat.beatId}-PROMOTED`, Boolean(scene),
+ `${beat.source.sceneId} is not a scene the HAC-324 capture manifest promoted`)) continue;
+ check(`CAP-${beat.beatId}-MANIFEST`, f.sourceSha256 === scene.sha256,
+ `the frame cites source digest ${f.sourceSha256.slice(0, 16)} but the capture manifest promoted `
+ + `${scene.sha256.slice(0, 16)} for ${scene.sceneId}`);
+ check(`CAP-${beat.beatId}-BYTES`, sha256(readBytes(f.sourcePath)) === scene.sha256,
+ `${f.sourcePath} does not hash to the digest the capture manifest promoted; the committed frame is `
+ + 'not the frame that was filmed');
+ check(`CAP-${beat.beatId}-QUALITY`, scene.qualityPass === true,
+ `${scene.sceneId} did not pass the capture-quality floor and must not appear in the cut`);
+ check(`CAP-${beat.beatId}-CROP`,
+ JSON.stringify(f.crop) === JSON.stringify(beat.source.crop),
+ 'the crop recorded on the frame differs from the crop declared in the cut');
+}
+
+const usedScenes = new Set(cut.beats.filter((b) => b.source.kind === 'capture').map((b) => b.source.sceneId));
+check('CAP-COVERAGE', [...promoted.keys()].every((s) => usedScenes.has(s)),
+ `promoted capture scenes are absent from the cut without being recorded as dropped: ${
+ [...promoted.keys()].filter((s) => !usedScenes.has(s)).join(', ')}`);
+
+/* -- 5. two runs stay two runs -------------------------------------------- */
+
+const resetIndexes = cut.beats
+ .map((b, i) => (b.proofClass === 'transition' ? i : -1))
+ .filter((i) => i >= 0);
+check('CLS-RESET-ONE', resetIndexes.length === 1,
+ `expected exactly one proof-class reset beat, found ${resetIndexes.length}`);
+const reset = resetIndexes[0] ?? -1;
+
+check('CLS-B-AFTER', cut.beats.every((b, i) => b.proofClass !== 'B' || i > reset),
+ 'Google Cloud material appears before the proof-class reset');
+check('CLS-A-BEFORE',
+ cut.beats.every((b, i) => !['A', 'EVAL'].includes(b.proofClass) || i < reset),
+ 'controlled local material appears after the proof-class reset');
+check('CLS-CAPTURE-B', cut.beats.every((b) => b.source.kind !== 'capture' || b.proofClass === 'B'),
+ 'a filmed Google Cloud capture is presented under a proof class other than B');
+check('CLS-LOCAL-SOURCE',
+ cut.beats.filter((b) => ['A', 'EVAL'].includes(b.proofClass)).every((b) => b.source.kind !== 'capture'),
+ 'a controlled local beat sources a Google Cloud capture');
+
+/*
+ * The frozen HAC-340 reference run and the filmed run share a claim, not an
+ * identity. Their correlation ids differ by one digit group, which is exactly
+ * why they must never share a frame: a judge who reads both as one run has been
+ * told something the evidence does not support.
+ */
+const FROZEN_REFERENCE_RUN = 'ilk-hac340-cloud-1786730369123';
+const FILMED_RUN = filmedRun.correlationId;
+check('RUN-DISTINCT', FROZEN_REFERENCE_RUN !== FILMED_RUN,
+ 'the frozen reference run and the filmed run resolve to the same correlation id');
+
+for (const beat of cut.beats.filter((b) => b.proofClass === 'B')) {
+ const f = frameById.get(beat.beatId);
+ const copy = `${beat.narration} ${beat.title ?? ''} ${f?.sourcePath ?? ''} ${f?.path ?? ''}`;
+ check(`RUN-${beat.beatId}`, !copy.includes(FROZEN_REFERENCE_RUN),
+ 'a Google Cloud beat names the frozen reference run; class-B beats carry the filmed run only');
+}
+for (const beat of cut.beats.filter((b) => ['A', 'EVAL'].includes(b.proofClass))) {
+ check(`RUN-LOCAL-${beat.beatId}`, !beat.narration.includes(FILMED_RUN),
+ 'a controlled local beat names the filmed Google Cloud run');
+}
+
+/* -- 6. claims ------------------------------------------------------------ */
+
+const knownClaims = new Set([
+ ...ledger.claims.map((c) => c.id),
+ ...filmedClaims.claims.map((c) => c.id),
+]);
+for (const beat of cut.beats) {
+ for (const id of beat.claims) {
+ check(`CLM-${beat.beatId}-${id}`, knownClaims.has(id),
+ `cites ${id}, which is in neither the HAC-335 ledger nor the HAC-336 filmed-run rows`);
+ }
+}
+
+/** Every filmed-run row still resolves to the value its pointers name. */
+function resolvePointer(doc, pointer) {
+ let node = doc;
+ for (const raw of pointer.slice(1).split('/')) {
+ if (node === null || node === undefined) return undefined;
+ const key = raw.replace(/~1/g, '/').replace(/~0/g, '~');
+ node = Array.isArray(node) ? node[Number(key)] : node[key];
+ }
+ return node;
+}
+for (const row of filmedClaims.claims) {
+ for (const ptr of row.pointers) {
+ const path = filmedClaims.sources[ptr.source];
+ check(`CLM-${row.id}-PTR`, resolvePointer(readJson(path), ptr.pointer) !== undefined,
+ `pointer ${ptr.pointer} no longer resolves in ${path}; the row has lost its evidence`);
+ }
+}
+
+/* -- 7. forbidden phrasing ------------------------------------------------- */
+
+/*
+ * Transcribed from HAC-343's `mustNotClaim`, HAC-333's `globalNonClaims` and the
+ * HAC-336 issue's claim boundary. Patterns, not sentences, because the failure
+ * mode is a paraphrase: "100% of hazards" is the same overclaim as "0% unsafe"
+ * with the arithmetic run the other way.
+ */
+const FORBIDDEN = [
+ [/\b0\s*%\s*unsafe/i, 'HAC-343 forbids a 0% unsafe headline; use exact counts'],
+ [/\b100\s*%\s*(safe|of\s+hazards|of\s+the\s+hazards)/i, 'a 100% headline collapses a heterogeneous corpus into one denominator'],
+ [/statistical(ly)?\s+significan/i, 'the corpus is an exhaustive deterministic enumeration, not a sample'],
+ [/confidence\s+interval/i, 'no interval is claimed'],
+ [/\bproduction[- ]ready\b/i, 'production readiness was not tested'],
+ [/\bexactly[- ]once\b/i, 'exactly-once is not claimed'],
+ [/\brestart[- ]saf(e|ety)\b/i, 'restart safety is not claimed'],
+ [/\bfleet[- ]scale\b/i, 'fleet-scale operation is not claimed'],
+ [/\bprevents\s+(all\s+)?(composition\s+)?hazards?\b/i, 'Interlock withheld the hazards in this corpus; it does not prevent hazards'],
+ [/\bsafer\s+than\s+locking\b/i, 'per-target locking is correct for the hazard it addresses'],
+ [/\btarget[- ]side\s+atomic/i, 'target-side atomicity was not tested'],
+ [/\bagent\s+runtime\b/i, 'Agent Runtime did not participate'],
+ [/\bagent\s+gateway\b/i, 'Agent Gateway did not participate'],
+ [/\bmemory\s+bank\b/i, 'Memory Bank did not participate'],
+ [/\bCONTENT_AUTHZ\b/, 'CONTENT_AUTHZ is not on the recorded path'],
+ [/ALLOW\s+(means|is)\s+(VERIFIED|AUTHORIZED|SAFE)/i, 'ALLOW is a decision, not a verification or an authorization'],
+ [/\bvalid[- ]wrong[- ]audience\b/i, 'no valid-token wrong-audience cloud negative test was run'],
+ [/history\s+(was\s+)?rewritten/i, 'the HAC-343 guardrail asks for "deliberately removed the coupling signal"'],
+];
+
+/*
+ * Several of these phrases are ones the cut is *required* to say, in the
+ * negative: a limitations board that could not print the words "exactly-once"
+ * could not disclaim exactly-once either. So a hit is a finding only when
+ * nothing nearby negates it.
+ *
+ * The window is a heuristic and is treated as one. It can be fooled by an
+ * unrelated negation in the same breath, which is why the boundary list in
+ * section 8 separately requires the disclaimers to be present, and why
+ * test/hac-336-film-gates.test.mjs asserts the bare assertive form of each
+ * phrase still fails. Neither check trusts the other.
+ */
+const NEGATORS = /\b(no|not|never|without|neither|nor|absent|outside|excluded|refus\w+|forbid\w+)\b/i;
+const NEGATION_WINDOW = 110;
+
+/** Matches of `pattern` in `copy` that nothing nearby negates. */
+function assertedWithoutNegation(pattern, copy) {
+ const global = new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`);
+ const hits = [];
+ for (const m of copy.matchAll(global)) {
+ const from = Math.max(0, m.index - NEGATION_WINDOW);
+ const to = Math.min(copy.length, m.index + m[0].length + NEGATION_WINDOW);
+ const context = copy.slice(from, to);
+ if (!NEGATORS.test(context)) hits.push({ text: m[0], context: context.replace(/\s+/g, ' ').trim() });
+ }
+ return hits;
+}
+
+for (const [pattern, why] of FORBIDDEN) {
+ const hits = assertedWithoutNegation(pattern, allCopy);
+ check(`PHR-${pattern.source.slice(0, 24)}`, hits.length === 0,
+ `judge-facing copy asserts ${JSON.stringify(hits[0]?.text)} with nothing negating it: ${why}\n`
+ + ` context: ...${hits[0]?.context ?? ''}...`);
+}
+
+/* -- 8. required boundaries ------------------------------------------------ */
+
+/*
+ * A limitations act that can be quietly deleted is not a limitations act. Each
+ * of these is a proposition the cut has to carry somewhere, checked against the
+ * text actually on screen rather than against a beat id that could be renamed.
+ */
+const REQUIRED = [
+ [/two runs, two proof classes/i, 'the two-runs boundary'],
+ [/neither is evidence for the other/i, 'the no-crossing statement'],
+ [/not\s+a\s+population\s+estimate|no\s+confidence\s+intervals/i, 'the bounded-corpus statement'],
+ [/transport\s+provenance/i, 'the transport-versus-application provenance distinction'],
+ [/Agent Runtime\s+Agent Gateway\s+CONTENT_AUTHZ|absent from this deployment/i,
+ 'the absent-services statement'],
+ [/exactly-once/i, 'the exactly-once non-claim'],
+ [/evidence[- ]derived|evidence is load-bearing/i, 'the evidence-ablation finding'],
+];
+for (const [pattern, what] of REQUIRED) {
+ check(`REQ-${pattern.source.slice(0, 24)}`, pattern.test(allCopy),
+ `the cut no longer states ${what}`);
+}
+
+check('REQ-BOUNDARY-CARD',
+ cut.beats.some((b) => b.source.assetId === 'IL-PROOF-014'),
+ 'the claim-boundary card is not in the cut');
+
+/* -- 9. the HAC-343 adjacency guardrail ----------------------------------- */
+
+const comparisonBeat = cut.beats.findIndex((b) => b.source.state === 'comparison');
+const ablationBeat = cut.beats.findIndex((b) => b.source.state === 'ablation');
+check('EVAL-PRESENT', comparisonBeat >= 0 && ablationBeat >= 0,
+ 'the bounded four-arm comparison or its ablation control is missing from the cut');
+check('EVAL-ADJACENT', ablationBeat === comparisonBeat + 1,
+ 'the evidence-ablation control does not immediately follow the four-arm comparison; the HAC-343 '
+ + 'guardrail requires them to be one contiguous visual unit');
+
+const ablationMaster = readdirSync(join(repoRoot, 'media/hac-336/masters'))
+ .find((f) => f.endsWith('-ablation.svg'));
+if (check('EVAL-MASTER', Boolean(ablationMaster), 'no ablation master was rendered')) {
+ const copy = svgText(readFileSync(join(repoRoot, 'media/hac-336/masters', ablationMaster), 'utf8'));
+ for (const row of judge.panel1.rows) {
+ check(`EVAL-ARM-${row.arm}`, copy.includes(row.label),
+ `the ablation board omits the ${row.label} arm; the Interlock figure would stand without its comparison`);
+ }
+ for (const row of judge.panel2.rows) {
+ check(`EVAL-ABL-${row.invalidOutcomes.display}`, copy.includes(row.invalidOutcomes.display),
+ 'the ablation board omits an ablation outcome');
+ }
+ check('EVAL-CREDIBILITY',
+ copy.includes(judge.panel1.perTargetLockCredibility.parallelisedCrossTarget.display),
+ 'the ablation board omits the per-target lock credibility strip, leaving A3 looking like a straw man');
+}
+
+/* -- 10. figures still match the evidence they came from ------------------ */
+
+for (const row of judge.panel1.rows) {
+ check(`FIG-${row.arm}`,
+ boardCopy.includes(row.coupledUnsafe.display) && boardCopy.includes(row.safeParallelism.display),
+ `the boards no longer carry the frozen ${row.label} figures`);
+}
+check('FIG-CONTROLS',
+ [filmedRun.controls.forgedHeaderStatus, filmedRun.controls.wrongAudienceStatus, filmedRun.controls.directBypassStatus]
+ .every((s) => narrationCopy.includes(String(s))),
+ 'the narration no longer states all three recorded fail-closed statuses');
+check('FIG-RECEIPT', boardCopy.includes(filmedRun.receiptId.slice(0, 18)),
+ 'the architecture board no longer carries the filmed run receipt id');
+
+/* -- 11. the first thirty seconds work muted ------------------------------ */
+
+/*
+ * A judge who never unmutes must still reach the thesis. Every beat inside the
+ * opening thirty seconds therefore has to be legible from the frame alone:
+ * either it is a frozen board that carries its own copy, or the cut records
+ * what a muted reader takes from it.
+ */
+for (const b of tl.beats.filter((x) => x.startSeconds < 30)) {
+ const beat = beatById.get(b.beatId);
+ check(`MUTE-${b.beatId}`,
+ beat.source.kind === 'asset' || typeof beat.mutedRead === 'string',
+ 'a beat in the first thirty seconds records no muted reading and is not a frozen board that '
+ + 'carries its own copy; the thesis would depend on the caption track');
+}
+
+/* -- 12. captions --------------------------------------------------------- */
+
+if (check('CAP-VTT-EXISTS', existsSync(join(repoRoot, VTT_PATH)), `${VTT_PATH} is missing`)) {
+ check('CAP-VTT-FRESH', readBytes(VTT_PATH).toString('utf8') === vtt,
+ 'the WebVTT track does not match the narration in the cut; re-run build-captions.mjs');
+}
+if (check('CAP-SRT-EXISTS', existsSync(join(repoRoot, SRT_PATH)), `${SRT_PATH} is missing`)) {
+ check('CAP-SRT-FRESH', readBytes(SRT_PATH).toString('utf8') === srt,
+ 'the SubRip track does not match the narration in the cut; re-run build-captions.mjs');
+}
+check('CAP-COUNT', cues.length === cut.beats.length,
+ `${cues.length} caption cues for ${cut.beats.length} beats`);
+check('CAP-ORDER', cues.every((c, i) => c.start < c.end && (i === 0 || c.start >= cues[i - 1].end)),
+ 'caption cues overlap or run backwards');
+for (const m of renderManifest.captions) {
+ check(`CAP-DIGEST-${m.format}`, sha256(readBytes(m.path)) === m.sha256,
+ `${m.path} does not match the digest in the render manifest`);
+}
+
+/* -- 13. inputs are still the inputs -------------------------------------- */
+
+for (const input of inputManifest.inputs) {
+ if (!check(`INP-${input.path}`, existsSync(join(repoRoot, input.path)), 'a declared input is missing')) continue;
+ check(`INP-${input.path}-DIGEST`, sha256(readBytes(input.path)) === input.sha256,
+ 'a declared input has changed since the manifest was derived; re-run build-input-manifest.mjs');
+}
+for (const rev of inputManifest.declaredRevisions.filter((r) => r.checkable)) {
+ check(`REV-${rev.name}`, resolvePointer(readJson(rev.source), rev.pointer) === rev.value,
+ `${rev.name} is declared as ${rev.value} but ${rev.source}${rev.pointer} no longer holds it`);
+}
+check('MAP-COVERAGE', sceneMap.scenes.length === cut.beats.length,
+ `the scene map describes ${sceneMap.scenes.length} scenes for ${cut.beats.length} beats`);
+check('MAP-TOTAL', sceneMap.totalSeconds === tl.totalSeconds,
+ 'the scene map records a total the cut no longer derives');
+
+/* -- 14. the storyboard's non-claims survive ------------------------------ */
+
+/*
+ * HAC-333 hands HAC-336 a `mustNotAppear` list. It is a list of propositions,
+ * not of strings, so this checks the handful whose contradiction would be
+ * visible as literal text rather than trying to pattern-match twelve English
+ * sentences and quietly matching none of them.
+ */
+check('SB-HANDOFF', Array.isArray(storyboard.handoffs['HAC-336'].mustNotAppear),
+ 'the storyboard no longer hands HAC-336 a must-not-appear list');
+check('SB-REFERENCE-RUN', !allCopy.includes(FROZEN_REFERENCE_RUN),
+ 'the frozen reference run identifier appears in judge-facing copy; the cut shows the filmed run');
+
+/* -- report --------------------------------------------------------------- */
+
+if (failures.length) {
+ process.stderr.write(`HAC-336 film gate FAILED - ${failures.length} finding(s)\n\n`);
+ for (const f of failures) process.stderr.write(` ${f}\n`);
+ process.stderr.write(
+ '\nNothing here is fixed by loosening the gate. Re-run the pipeline in order:\n'
+ + ' node media/hac-336/bin/build-boards.mjs\n'
+ + ' node media/hac-336/bin/build-frames.mjs\n'
+ + ' node media/hac-336/bin/build-captions.mjs\n'
+ + ' node media/hac-336/bin/build-input-manifest.mjs\n'
+ + ' node media/hac-336/bin/build-video.mjs # needs ffmpeg; not run in CI\n',
+ );
+ process.exit(1);
+}
+
+process.stdout.write(
+ 'HAC-336 film gate PASS\n'
+ + ` ${cut.beats.length} beats, ${tl.totalSeconds}s derived and ${renderManifest.video.measuredDurationSeconds}s encoded `
+ + `(ceiling ${CEILING}s)\n`
+ + ` ${cut.beats.filter((b) => b.source.kind === 'capture').length} filmed capture frames bound to `
+ + `${capturePackage.packageId}\n`
+ + ` ${new Set(cut.beats.flatMap((b) => b.claims)).size} claim rows, ${FORBIDDEN.length} forbidden phrasings refused, `
+ + `${REQUIRED.length} boundaries required\n`,
+);
diff --git a/media/hac-336/captions/interlock-final-cut.en.srt b/media/hac-336/captions/interlock-final-cut.en.srt
new file mode 100644
index 0000000..69e2c51
--- /dev/null
+++ b/media/hac-336/captions/interlock-final-cut.en.srt
@@ -0,0 +1,83 @@
+1
+00:00:00,000 --> 00:00:03,600
+Interlock. Evidence-bound coordination before shared-state mutation.
+
+2
+00:00:04,000 --> 00:00:08,200
+Two agents propose two changes. Each one is valid when you check it on its own.
+
+3
+00:00:08,600 --> 00:00:13,300
+They write into one shared environment, so one bound applies to their joint outcome: sum of reserved must stay at or under 130.
+
+4
+00:00:13,700 --> 00:00:19,400
+Applied together with no coordination, the joint state is 140 against a bound of 130. Neither change is wrong. The composition is.
+
+5
+00:00:19,800 --> 00:00:33,000
+Same intents, same environment. Interlock reads revision-bound co-change evidence before the mutation and decides WITHHOLD_SERIALIZE. The joint outcome is 120 against the same bound of 130, on 24 of 24 checks.
+
+6
+00:00:33,400 --> 00:00:46,600
+Then the coupling signal is deliberately removed from the frozen evidence. Same decision core, same intents: the decision becomes ALLOW_PARALLEL and 140 against 130 returns. The evidence is load-bearing. Both arms are recorded results; nothing is re-run to produce this comparison.
+
+7
+00:00:47,000 --> 00:01:00,200
+On a frozen sixteen-scenario corpus, four coordination strategies. Uncoordinated keeps both independent opportunities parallel and leaves both cross-target hazards unsafe. A global lock blocks both hazards by eliminating concurrency: none of the two parallel opportunities survive. A credible per-target lock keeps both opportunities parallel and still misses both hazards, because a hazard spanning two lock keys is not visible to any per-key discipline.
+
+8
+00:01:00,600 --> 00:01:17,800
+Interlock preserved both known-safe parallel opportunities while withholding both evidence-supported cross-target hazards. Then the coupling evidence was deliberately removed from the frozen fixtures, with the intents unchanged. The same core reversed to ALLOW_PARALLEL and both invariants failed. Bounded to this corpus.
+
+9
+00:01:18,200 --> 00:01:21,900
+Different run. Different evidence. Nothing crosses.
+
+10
+00:01:22,300 --> 00:01:33,500
+This is the deployed Cloud Run agent reading back its own configuration: gemini-3.5-flash, reached through Google ADK 1.35.1 with Vertex AI access, from a pinned image built at the runtime source commit.
+
+11
+00:01:33,900 --> 00:01:43,100
+Three Cloud Run services carried the run in us-central1: the ADK agent, the Interlock MCP proxy, and the protected target.
+
+12
+00:01:43,500 --> 00:01:57,700
+The agent traversal, executed while this frame was being captured. Interlock returned ALLOW with a receipt digest, under one correlation id. Three fail-closed controls were recorded on the same run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct call to the target with no receipt returned 403.
+
+13
+00:01:58,100 --> 00:02:09,300
+The protected mutation is EXECUTED against that receipt. The target moved from one revision to the next, and the resulting invariant is a total of 105 against the bound of 130.
+
+14
+00:02:09,700 --> 00:02:22,900
+EXECUTED is not OBSERVED. A separately authenticated keyless observer service account read the target back at the post-mutation revision and observed alpha at 45. Two records, not one: the model's own output declares nothing.
+
+15
+00:02:23,300 --> 00:02:33,500
+Cloud Logging, filtered to that same correlation id: the proxy request, the agent identity as the platform verified it, and the Cloud Run revision that served it.
+
+16
+00:02:33,900 --> 00:02:47,100
+Where Interlock sits. The agent calls a tool through the Interlock MCP proxy. Interlock reads revision-bound composition evidence before the mutation and returns a decision with a receipt. The protected target refused a direct call that carried no receipt.
+
+17
+00:02:47,500 --> 00:02:59,700
+Cloud Run IAM establishes transport provenance. It does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock. Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on this path.
+
+18
+00:03:00,100 --> 00:03:12,300
+What the evaluation is bounded by. Sixteen frozen scenarios across two hazard families, enumerated exhaustively: exact counts, not estimates, and no interval or significance is claimed. Every inadmissible-evidence scenario failed closed, and on two of eight the refusal reason disagreed with the frozen corpus; the corpus is frozen and stays wrong on the record.
+
+19
+00:03:12,700 --> 00:03:29,900
+And what is not claimed. Two runs, two proof classes; neither is evidence for the other. No exactly-once execution, no restart safety, no target-side atomicity, no production readiness. ALLOW is a decision, not a verification or an authorization, and WITHHOLD_SERIALIZE is not human approval.
+
+20
+00:03:30,300 --> 00:03:38,500
+Valid alone does not mean safe together. Interlock reads revision-bound evidence about the composition before the mutation: parallel when the evidence supports it, serialized when it does not.
+
+21
+00:03:38,900 --> 00:03:49,500
+Every number in this video is committed. The Google Cloud packet is readable without an account, pinned at one commit, and its bytes hash to a digest any reader can recompute.
diff --git a/media/hac-336/captions/interlock-final-cut.en.vtt b/media/hac-336/captions/interlock-final-cut.en.vtt
new file mode 100644
index 0000000..ee5ef63
--- /dev/null
+++ b/media/hac-336/captions/interlock-final-cut.en.vtt
@@ -0,0 +1,90 @@
+WEBVTT
+
+NOTE
+Interlock final cut - narration and caption track.
+The cut has no audio. These cues are the spoken text a voice-over would carry,
+and the text a muted viewer can read alongside the frame.
+
+1
+00:00:00.000 --> 00:00:03.600
+Interlock. Evidence-bound coordination before shared-state mutation.
+
+2
+00:00:04.000 --> 00:00:08.200
+Two agents propose two changes. Each one is valid when you check it on its own.
+
+3
+00:00:08.600 --> 00:00:13.300
+They write into one shared environment, so one bound applies to their joint outcome: sum of reserved must stay at or under 130.
+
+4
+00:00:13.700 --> 00:00:19.400
+Applied together with no coordination, the joint state is 140 against a bound of 130. Neither change is wrong. The composition is.
+
+5
+00:00:19.800 --> 00:00:33.000
+Same intents, same environment. Interlock reads revision-bound co-change evidence before the mutation and decides WITHHOLD_SERIALIZE. The joint outcome is 120 against the same bound of 130, on 24 of 24 checks.
+
+6
+00:00:33.400 --> 00:00:46.600
+Then the coupling signal is deliberately removed from the frozen evidence. Same decision core, same intents: the decision becomes ALLOW_PARALLEL and 140 against 130 returns. The evidence is load-bearing. Both arms are recorded results; nothing is re-run to produce this comparison.
+
+7
+00:00:47.000 --> 00:01:00.200
+On a frozen sixteen-scenario corpus, four coordination strategies. Uncoordinated keeps both independent opportunities parallel and leaves both cross-target hazards unsafe. A global lock blocks both hazards by eliminating concurrency: none of the two parallel opportunities survive. A credible per-target lock keeps both opportunities parallel and still misses both hazards, because a hazard spanning two lock keys is not visible to any per-key discipline.
+
+8
+00:01:00.600 --> 00:01:17.800
+Interlock preserved both known-safe parallel opportunities while withholding both evidence-supported cross-target hazards. Then the coupling evidence was deliberately removed from the frozen fixtures, with the intents unchanged. The same core reversed to ALLOW_PARALLEL and both invariants failed. Bounded to this corpus.
+
+9
+00:01:18.200 --> 00:01:21.900
+Different run. Different evidence. Nothing crosses.
+
+10
+00:01:22.300 --> 00:01:33.500
+This is the deployed Cloud Run agent reading back its own configuration: gemini-3.5-flash, reached through Google ADK 1.35.1 with Vertex AI access, from a pinned image built at the runtime source commit.
+
+11
+00:01:33.900 --> 00:01:43.100
+Three Cloud Run services carried the run in us-central1: the ADK agent, the Interlock MCP proxy, and the protected target.
+
+12
+00:01:43.500 --> 00:01:57.700
+The agent traversal, executed while this frame was being captured. Interlock returned ALLOW with a receipt digest, under one correlation id. Three fail-closed controls were recorded on the same run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct call to the target with no receipt returned 403.
+
+13
+00:01:58.100 --> 00:02:09.300
+The protected mutation is EXECUTED against that receipt. The target moved from one revision to the next, and the resulting invariant is a total of 105 against the bound of 130.
+
+14
+00:02:09.700 --> 00:02:22.900
+EXECUTED is not OBSERVED. A separately authenticated keyless observer service account read the target back at the post-mutation revision and observed alpha at 45. Two records, not one: the model's own output declares nothing.
+
+15
+00:02:23.300 --> 00:02:33.500
+Cloud Logging, filtered to that same correlation id: the proxy request, the agent identity as the platform verified it, and the Cloud Run revision that served it.
+
+16
+00:02:33.900 --> 00:02:47.100
+Where Interlock sits. The agent calls a tool through the Interlock MCP proxy. Interlock reads revision-bound composition evidence before the mutation and returns a decision with a receipt. The protected target refused a direct call that carried no receipt.
+
+17
+00:02:47.500 --> 00:02:59.700
+Cloud Run IAM establishes transport provenance. It does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock. Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on this path.
+
+18
+00:03:00.100 --> 00:03:12.300
+What the evaluation is bounded by. Sixteen frozen scenarios across two hazard families, enumerated exhaustively: exact counts, not estimates, and no interval or significance is claimed. Every inadmissible-evidence scenario failed closed, and on two of eight the refusal reason disagreed with the frozen corpus; the corpus is frozen and stays wrong on the record.
+
+19
+00:03:12.700 --> 00:03:29.900
+And what is not claimed. Two runs, two proof classes; neither is evidence for the other. No exactly-once execution, no restart safety, no target-side atomicity, no production readiness. ALLOW is a decision, not a verification or an authorization, and WITHHOLD_SERIALIZE is not human approval.
+
+20
+00:03:30.300 --> 00:03:38.500
+Valid alone does not mean safe together. Interlock reads revision-bound evidence about the composition before the mutation: parallel when the evidence supports it, serialized when it does not.
+
+21
+00:03:38.900 --> 00:03:49.500
+Every number in this video is committed. The Google Cloud packet is readable without an account, pinned at one commit, and its bytes hash to a digest any reader can recompute.
diff --git a/media/hac-336/evidence/cut.json b/media/hac-336/evidence/cut.json
new file mode 100644
index 0000000..8facf6b
--- /dev/null
+++ b/media/hac-336/evidence/cut.json
@@ -0,0 +1,266 @@
+{
+ "manifestId": "HAC-336-cut",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "The edit decision list. Authored: this is the one file in HAC-336 a human writes. Everything else under media/hac-336 is derived from it plus frozen evidence. Durations are holds; the assembled total is derived as sum(holdSeconds) - transitionSeconds * (beats - 1) and is never read from a total field.",
+ "sourceRevisions": {
+ "note": "Declared provenance for the assembly. The four values that also appear inside frozen evidence files are cross-checked mechanically by media/hac-336/bin/verify-film.mjs. The two repository revisions cannot be checked from content — a commit cannot name itself — so they are recorded as declarations and the mechanical binding for every input is its content digest in input-manifest.json, which is the lesson HAC-335 recorded when a registry kept a superseded capture SHA.",
+ "interlockSha": {
+ "value": "6da9884bc7922a199003ea9babcdf47dbba104df",
+ "checkable": false,
+ "meaning": "Marcelle-Labs/interlock main at the revision this cut was assembled from. The assembly commit is its descendant."
+ },
+ "directorSha": {
+ "value": "81a0535acac9a3b58b2b948ed9e600a9c2c59af9",
+ "checkable": false,
+ "meaning": "Marcelle-Labs/director (Studio v3) main at assembly time. Studio produced the HAC-324 capture package; it is not a build input to this repository, and the revision that actually performed the capture is recorded separately as capturePackage.productRevision."
+ },
+ "hac343CanonicalResult": {
+ "value": "7ede0f97e55685c16e5bb762b5e7fbe471a6e8b0",
+ "checkable": true,
+ "source": "experiments/hac-343/evidence/judge-export.json",
+ "pointer": "/provenance/canonicalResultCommit"
+ },
+ "filmedRunId": {
+ "value": "ilk-hac340-cloud-1787536029323",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/correlationId"
+ },
+ "receiptId": {
+ "value": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/receiptId"
+ },
+ "runtimeSourceSha": {
+ "value": "ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/commitSha"
+ }
+ },
+ "geometry": { "width": 1920, "height": 1080, "aspect": "16:9", "fps": 30 },
+ "transitionSeconds": 0.4,
+ "transition": {
+ "kind": "crossfade",
+ "semanticJob": "A state change between two frozen holds. The transition carries no value of its own: every proposition is legible on the still frame at either end, so removing motion removes nothing.",
+ "forbidden": ["particles", "typewriter", "counters", "perpetual motion", "recomputation of a frozen result"]
+ },
+ "audio": {
+ "track": "none",
+ "why": "HAC-333 froze the cut as muted: every claim must survive with the sound off. The narration lines below are the caption track and double as a voice-over script if one is ever recorded. No synthetic narration is generated, because narration that no human spoke is not evidence of anything.",
+ "captions": "media/hac-336/captions"
+ },
+ "proofClasses": {
+ "brand": "Identity. No evidence is asserted.",
+ "A": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "EVAL": "Controlled local evaluation (HAC-343). Bounded sixteen-scenario corpus, exhaustively enumerated.",
+ "transition": "Proof-class reset. Asserts only that nothing crosses.",
+ "B": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "bounds": "Claim boundary. States what is not claimed.",
+ "close": "Close. Restates the thesis and where to verify it."
+ },
+ "beats": [
+ {
+ "beatId": "B01",
+ "act": "PROBLEM",
+ "proofClass": "brand",
+ "holdSeconds": 4.0,
+ "source": { "kind": "asset", "path": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png", "assetId": "IL-SCAF-010" },
+ "narration": "Interlock. Evidence-bound coordination before shared-state mutation.",
+ "claims": ["CL-002"]
+ },
+ {
+ "beatId": "B02",
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "holdSeconds": 5.0,
+ "source": { "kind": "board", "master": "IL-PROOF-020", "state": "intents" },
+ "narration": "Two agents propose two changes. Each one is valid when you check it on its own.",
+ "claims": ["CL-001"],
+ "mutedRead": "Two intent panels, both marked LOCALLY VALID. No coupling is drawn yet."
+ },
+ {
+ "beatId": "B03",
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "holdSeconds": 5.5,
+ "source": { "kind": "board", "master": "IL-PROOF-020", "state": "coupled" },
+ "narration": "They write into one shared environment, so one bound applies to their joint outcome: sum of reserved must stay at or under 130.",
+ "claims": ["CL-001", "CL-002"],
+ "mutedRead": "Both intents now terminate in the shared environment band, marked COUPLED, with the joint bound shown."
+ },
+ {
+ "beatId": "B04",
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "holdSeconds": 6.5,
+ "source": { "kind": "board", "master": "IL-PROOF-020", "state": "baseline" },
+ "narration": "Applied together with no coordination, the joint state is 140 against a bound of 130. Neither change is wrong. The composition is.",
+ "claims": ["CL-003"],
+ "mutedRead": "140 > 130 at display size, marked INVALID JOINT STATE."
+ },
+ {
+ "beatId": "B05",
+ "act": "CAUSAL",
+ "proofClass": "A",
+ "holdSeconds": 14.0,
+ "source": { "kind": "asset", "path": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png", "assetId": "IL-PROOF-010" },
+ "narration": "Same intents, same environment. Interlock reads revision-bound co-change evidence before the mutation and decides WITHHOLD_SERIALIZE. The joint outcome is 120 against the same bound of 130, on 24 of 24 checks.",
+ "claims": ["CL-002", "CL-003", "CL-004", "CL-005", "CL-027"]
+ },
+ {
+ "beatId": "B06",
+ "act": "CAUSAL",
+ "proofClass": "A",
+ "holdSeconds": 14.0,
+ "source": { "kind": "asset", "path": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png", "assetId": "IL-PROOF-011" },
+ "narration": "Then the coupling signal is deliberately removed from the frozen evidence. Same decision core, same intents: the decision becomes ALLOW_PARALLEL and 140 against 130 returns. The evidence is load-bearing. Both arms are recorded results; nothing is re-run to produce this comparison.",
+ "claims": ["CL-006", "CL-007"]
+ },
+ {
+ "beatId": "B07",
+ "act": "UTILITY",
+ "proofClass": "EVAL",
+ "holdSeconds": 14.0,
+ "source": { "kind": "board", "master": "IL-PROOF-021", "state": "comparison" },
+ "narration": "On a frozen sixteen-scenario corpus, four coordination strategies. Uncoordinated keeps both independent opportunities parallel and leaves both cross-target hazards unsafe. A global lock blocks both hazards by eliminating concurrency: none of the two parallel opportunities survive. A credible per-target lock keeps both opportunities parallel and still misses both hazards, because a hazard spanning two lock keys is not visible to any per-key discipline.",
+ "claims": ["CL-028", "CL-029"]
+ },
+ {
+ "beatId": "B08",
+ "act": "UTILITY",
+ "proofClass": "EVAL",
+ "holdSeconds": 18.0,
+ "source": { "kind": "board", "master": "IL-PROOF-021", "state": "ablation" },
+ "narration": "Interlock preserved both known-safe parallel opportunities while withholding both evidence-supported cross-target hazards. Then the coupling evidence was deliberately removed from the frozen fixtures, with the intents unchanged. The same core reversed to ALLOW_PARALLEL and both invariants failed. Bounded to this corpus.",
+ "claims": ["CL-028", "CL-030", "CL-031"],
+ "guardrail": "HAC-343 media narration guardrail: Panel 1 and Panel 2 must appear as one contiguous visual unit. This state renders both on one board, so the 0/2 figure is never on screen without its ablation control."
+ },
+ {
+ "beatId": "B09",
+ "act": "RESET",
+ "proofClass": "transition",
+ "holdSeconds": 4.5,
+ "source": { "kind": "board", "master": "IL-SCAF-020", "state": "reset" },
+ "narration": "Different run. Different evidence. Nothing crosses.",
+ "claims": ["CL-009", "CL-020", "CL-027"],
+ "mutedRead": "The field inverts from paper to ink. The board states that the controlled local experiment and the Google Cloud run are two runs, and that neither is evidence for the other."
+ },
+ {
+ "beatId": "B10",
+ "title": "Gemini 3.5 Flash through Google ADK, on Cloud Run",
+ "act": "CLOUD",
+ "proofClass": "B",
+ "holdSeconds": 12.0,
+ "source": { "kind": "capture", "sceneId": "gemini-adk-attribution", "crop": { "x": 52, "y": 44, "w": 1560, "h": 690 } },
+ "narration": "This is the deployed Cloud Run agent reading back its own configuration: gemini-3.5-flash, reached through Google ADK 1.35.1 with Vertex AI access, from a pinned image built at the runtime source commit.",
+ "claims": ["FR-001", "FR-002"]
+ },
+ {
+ "beatId": "B11",
+ "title": "The three Cloud Run services that carried the run",
+ "act": "CLOUD",
+ "proofClass": "B",
+ "holdSeconds": 10.0,
+ "source": { "kind": "capture", "sceneId": "cloud-run-topology", "crop": { "x": 52, "y": 44, "w": 1180, "h": 300 } },
+ "narration": "Three Cloud Run services carried the run in us-central1: the ADK agent, the Interlock MCP proxy, and the protected target.",
+ "claims": ["FR-003"]
+ },
+ {
+ "beatId": "B12",
+ "title": "The agent traversal, executed while it was filmed",
+ "act": "CLOUD",
+ "proofClass": "B",
+ "holdSeconds": 15.0,
+ "source": { "kind": "capture", "sceneId": "agent-traversal", "crop": { "x": 52, "y": 44, "w": 1160, "h": 440 } },
+ "narration": "The agent traversal, executed while this frame was being captured. Interlock returned ALLOW with a receipt digest, under one correlation id. Three fail-closed controls were recorded on the same run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct call to the target with no receipt returned 403.",
+ "claims": ["FR-004", "FR-005", "FR-009"]
+ },
+ {
+ "beatId": "B13",
+ "title": "EXECUTED: the protected mutation, bound to the receipt",
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "holdSeconds": 12.0,
+ "source": { "kind": "capture", "sceneId": "receipt-mutation-observation", "crop": { "x": 52, "y": 236, "w": 1180, "h": 400 }, "cropId": "executed" },
+ "narration": "The protected mutation is EXECUTED against that receipt. The target moved from one revision to the next, and the resulting invariant is a total of 105 against the bound of 130.",
+ "claims": ["FR-005", "FR-006"]
+ },
+ {
+ "beatId": "B14",
+ "title": "OBSERVED: an independent read-back, separately authenticated",
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "holdSeconds": 14.0,
+ "source": { "kind": "capture", "sceneId": "receipt-mutation-observation", "crop": { "x": 52, "y": 672, "w": 1180, "h": 300 }, "cropId": "observed" },
+ "narration": "EXECUTED is not OBSERVED. A separately authenticated keyless observer service account read the target back at the post-mutation revision and observed alpha at 45. Two records, not one: the model's own output declares nothing.",
+ "claims": ["FR-007", "FR-008"]
+ },
+ {
+ "beatId": "B15",
+ "title": "Cloud Logging, correlated to the same run",
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "holdSeconds": 11.0,
+ "source": { "kind": "capture", "sceneId": "cloud-logging-correlation", "crop": { "x": 52, "y": 44, "w": 1700, "h": 560 } },
+ "narration": "Cloud Logging, filtered to that same correlation id: the proxy request, the agent identity as the platform verified it, and the Cloud Run revision that served it.",
+ "claims": ["FR-010"]
+ },
+ {
+ "beatId": "B16",
+ "act": "ARCHITECTURE",
+ "proofClass": "B",
+ "holdSeconds": 14.0,
+ "source": { "kind": "board", "master": "IL-DIAG-020", "state": "path" },
+ "narration": "Where Interlock sits. The agent calls a tool through the Interlock MCP proxy. Interlock reads revision-bound composition evidence before the mutation and returns a decision with a receipt. The protected target refused a direct call that carried no receipt.",
+ "claims": ["FR-002", "FR-003", "FR-004", "FR-009"]
+ },
+ {
+ "beatId": "B17",
+ "act": "ARCHITECTURE",
+ "proofClass": "B",
+ "holdSeconds": 13.0,
+ "source": { "kind": "board", "master": "IL-DIAG-020", "state": "boundary" },
+ "narration": "Cloud Run IAM establishes transport provenance. It does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock. Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on this path.",
+ "claims": ["CL-015", "CL-021", "FR-011"]
+ },
+ {
+ "beatId": "B18",
+ "act": "BOUNDS",
+ "proofClass": "bounds",
+ "holdSeconds": 13.0,
+ "source": { "kind": "board", "master": "IL-PROOF-022", "state": "bounds" },
+ "narration": "What the evaluation is bounded by. Sixteen frozen scenarios across two hazard families, enumerated exhaustively: exact counts, not estimates, and no interval or significance is claimed. Every inadmissible-evidence scenario failed closed, and on two of eight the refusal reason disagreed with the frozen corpus; the corpus is frozen and stays wrong on the record.",
+ "claims": ["CL-031"]
+ },
+ {
+ "beatId": "B19",
+ "act": "BOUNDS",
+ "proofClass": "bounds",
+ "holdSeconds": 18.0,
+ "source": { "kind": "asset", "path": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png", "assetId": "IL-PROOF-014" },
+ "narration": "And what is not claimed. Two runs, two proof classes; neither is evidence for the other. No exactly-once execution, no restart safety, no target-side atomicity, no production readiness. ALLOW is a decision, not a verification or an authorization, and WITHHOLD_SERIALIZE is not human approval.",
+ "claims": ["CL-009", "CL-014", "CL-020", "CL-021", "CL-022", "CL-023", "CL-024", "CL-027"]
+ },
+ {
+ "beatId": "B20",
+ "act": "CLOSE",
+ "proofClass": "close",
+ "holdSeconds": 9.0,
+ "source": { "kind": "board", "master": "IL-SCAF-021", "state": "thesis" },
+ "narration": "Valid alone does not mean safe together. Interlock reads revision-bound evidence about the composition before the mutation: parallel when the evidence supports it, serialized when it does not.",
+ "claims": ["CL-001", "CL-002"]
+ },
+ {
+ "beatId": "B21",
+ "act": "CLOSE",
+ "proofClass": "close",
+ "holdSeconds": 11.0,
+ "source": { "kind": "asset", "path": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png", "assetId": "IL-SCAF-011" },
+ "narration": "Every number in this video is committed. The Google Cloud packet is readable without an account, pinned at one commit, and its bytes hash to a digest any reader can recompute.",
+ "claims": ["CL-017", "CL-009"]
+ }
+ ]
+}
diff --git a/media/hac-336/evidence/filmed-run-claims.json b/media/hac-336/evidence/filmed-run-claims.json
new file mode 100644
index 0000000..f662a10
--- /dev/null
+++ b/media/hac-336/evidence/filmed-run-claims.json
@@ -0,0 +1,134 @@
+{
+ "manifestId": "HAC-336-filmed-run-claims",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "The HAC-335 claim ledger binds the frozen HAC-340 reference run (correlation ilk-hac340-cloud-1786730369123). The video shows the HAC-324 authoritative filmed run (correlation ilk-hac340-cloud-1787536029323), which is a different run instance. Its claims need their own rows so no sentence about the filmed run borrows a ledger row that describes the frozen one. Every row names JSON pointers into the frozen filmed-run record; the builder resolves them and the gate re-derives them, so a text that drifts from its evidence is a mechanical finding.",
+ "runIdentity": {
+ "filmedRunId": "ilk-hac340-cloud-1787536029323",
+ "distinctFrom": "ilk-hac340-cloud-1786730369123",
+ "relationship": "Claim-equivalent to the frozen HAC-340 Google Cloud participation claim on 16 of 16 material parity fields. It is a new run, not the frozen reference run, and is never substituted into the frozen packet."
+ },
+ "sources": {
+ "filmedRun": "experiments/hac-324/evidence/filmed-run.json",
+ "capturePackage": "experiments/hac-324/evidence/capture-package.json",
+ "nonClaims": "media/hac-333/scene-manifest.json"
+ },
+ "claims": [
+ {
+ "id": "FR-001",
+ "text": "The recorded traversal ran gemini-3.5-flash.",
+ "classification": "EVIDENCED",
+ "pointers": [{ "source": "filmedRun", "pointer": "/model" }]
+ },
+ {
+ "id": "FR-002",
+ "text": "The agent reached the model through Google ADK 1.35.1 with Vertex AI access, from an image pinned to the runtime source commit.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/adkPath" },
+ { "source": "capturePackage", "pointer": "/adk" },
+ { "source": "filmedRun", "pointer": "/resources/vertexLocation" },
+ { "source": "filmedRun", "pointer": "/resources/agentImage" },
+ { "source": "filmedRun", "pointer": "/commitSha" }
+ ]
+ },
+ {
+ "id": "FR-003",
+ "text": "Three Cloud Run services in us-central1 carried the run: the ADK agent, the Interlock MCP proxy and the protected target.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "capturePackage", "pointer": "/region" },
+ { "source": "capturePackage", "pointer": "/revisions/agent" },
+ { "source": "capturePackage", "pointer": "/revisions/proxy" },
+ { "source": "capturePackage", "pointer": "/revisions/target" }
+ ]
+ },
+ {
+ "id": "FR-004",
+ "text": "Interlock returned ALLOW with an authorization receipt, under one correlation id.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/decision" },
+ { "source": "filmedRun", "pointer": "/receiptId" },
+ { "source": "filmedRun", "pointer": "/receiptDigest" },
+ { "source": "filmedRun", "pointer": "/correlationId" }
+ ]
+ },
+ {
+ "id": "FR-005",
+ "text": "The protected mutation is EXECUTED against that receipt, moving the target from one revision to the next.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/protectedMutation/status" },
+ { "source": "filmedRun", "pointer": "/protectedMutation/receiptId" },
+ { "source": "filmedRun", "pointer": "/protectedMutation/revisionBefore" },
+ { "source": "filmedRun", "pointer": "/protectedMutation/revisionAfter" }
+ ]
+ },
+ {
+ "id": "FR-006",
+ "text": "The resulting invariant on the protected target is total 105 <= 130.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/protectedMutation/invariant/detail" },
+ { "source": "filmedRun", "pointer": "/protectedMutation/invariant/holds" }
+ ]
+ },
+ {
+ "id": "FR-007",
+ "text": "The read-back was performed by a dedicated keyless observer service account, independently authenticated and distinct from the provisioning operator.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/resources/observerPrincipal" },
+ { "source": "filmedRun", "pointer": "/resources/operatorPrincipal" },
+ { "source": "filmedRun", "pointer": "/principalProjection/correction/classification" },
+ { "source": "capturePackage", "pointer": "/externalCallerPrincipal" }
+ ]
+ },
+ {
+ "id": "FR-008",
+ "text": "That observation read alpha=45 at the post-mutation revision.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/observation/revision" },
+ { "source": "filmedRun", "pointer": "/observation/state/services/alpha" }
+ ]
+ },
+ {
+ "id": "FR-009",
+ "text": "Three fail-closed controls were recorded on this run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct target call carrying no receipt returned 403.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/controls/forgedHeaderStatus" },
+ { "source": "filmedRun", "pointer": "/controls/wrongAudienceStatus" },
+ { "source": "filmedRun", "pointer": "/controls/directBypassStatus" }
+ ],
+ "renderingRule": "The emitted field is named wrongAudienceStatus, which is the implementation label the frozen HAC-340 packet also uses. Judge-facing copy says invalid bearer token -> 401, matching HAC-333 and HAC-335. No valid-token wrong-audience cloud negative test is claimed; wrong-audience rejection remains controlled local parity evidence."
+ },
+ {
+ "id": "FR-010",
+ "text": "Cloud Logging carries a proxy request for this run's correlation id, with the agent identity as the platform verified it and the Cloud Run revision that served it.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "filmedRun", "pointer": "/runtimeProof/proxyLogEntries/0/jsonPayload/correlationId" },
+ { "source": "filmedRun", "pointer": "/runtimeProof/proxyLogEntries/0/jsonPayload/identitySource" },
+ { "source": "filmedRun", "pointer": "/runtimeProof/proxyLogEntries/0/resource/labels/revision_name" }
+ ]
+ },
+ {
+ "id": "FR-011",
+ "text": "Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on the recorded path, and Cloud Run IAM establishes transport provenance only.",
+ "classification": "NOT CLAIMED",
+ "pointers": [{ "source": "nonClaims", "pointer": "/globalNonClaims" }]
+ },
+ {
+ "id": "FR-012",
+ "text": "The filming environment was torn down after capture; the Google Cloud project is deleted.",
+ "classification": "EVIDENCED",
+ "pointers": [
+ { "source": "capturePackage", "pointer": "/teardown/status" },
+ { "source": "capturePackage", "pointer": "/teardown/projectLifecycleState" }
+ ]
+ }
+ ]
+}
diff --git a/media/hac-336/evidence/frame-manifest.json b/media/hac-336/evidence/frame-manifest.json
new file mode 100644
index 0000000..66c1847
--- /dev/null
+++ b/media/hac-336/evidence/frame-manifest.json
@@ -0,0 +1,366 @@
+{
+ "manifestId": "HAC-336-frame-manifest",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "Derived by media/hac-336/bin/build-frames.mjs. Do not hand-edit. `sourceSha256` binds each frame to the artifact it came from: an SVG master for a board, the promoted capture bytes for a filmed scene, the frozen export itself for an asset.",
+ "generator": "media/hac-336/bin/build-frames.mjs",
+ "rasteriser": "@resvg/resvg-js@2.6.2",
+ "geometry": {
+ "width": 1920,
+ "height": 1080
+ },
+ "stageBox": {
+ "x": 88,
+ "y": 228,
+ "w": 1744,
+ "h": 544
+ },
+ "frames": [
+ {
+ "beatId": "B01",
+ "kind": "asset",
+ "assetId": "IL-SCAF-010",
+ "path": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "composed": false,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "0d238422b53d960ceb2b75f7871f042799bd8578969fd20d2944c7d84e080fa2",
+ "sourcePath": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "sourceSha256": "0d238422b53d960ceb2b75f7871f042799bd8578969fd20d2944c7d84e080fa2"
+ },
+ {
+ "beatId": "B02",
+ "kind": "board",
+ "assetId": "IL-PROOF-020",
+ "state": "intents",
+ "path": "media/hac-336/frames/B02-IL-PROOF-020-intents.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "0d12bebadeb29d6b4ee0f4df96cac92c34517b073c66fbac21044d0f3d3bf15b",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg",
+ "sourceSha256": "567da3a0bbb76eace11b4829fd3c1935c6224acf409c2bcb6be4803f5efe09ac"
+ },
+ {
+ "beatId": "B03",
+ "kind": "board",
+ "assetId": "IL-PROOF-020",
+ "state": "coupled",
+ "path": "media/hac-336/frames/B03-IL-PROOF-020-coupled.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "780d1eb2a59482a156b9ae145dbd9eea0682bcaee1bd9d995d519b8bb3dfa754",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg",
+ "sourceSha256": "4c512891c728aa84cf698d5f29b07a1be81781db326b7a316b2c332b969c6925"
+ },
+ {
+ "beatId": "B04",
+ "kind": "board",
+ "assetId": "IL-PROOF-020",
+ "state": "baseline",
+ "path": "media/hac-336/frames/B04-IL-PROOF-020-baseline.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "2fb939d1ce99a406128538fb2b4e421fbb0a431f8028d0cdb9fd9dc13294ea86",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg",
+ "sourceSha256": "9d62da859c43c74e6623f8267f0a542905168a1f1234d9542a9a7b1b89864ca3"
+ },
+ {
+ "beatId": "B05",
+ "kind": "asset",
+ "assetId": "IL-PROOF-010",
+ "path": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "composed": false,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "cf9055667978d78a1a5f65816ac376f56722f5594eaab6abef229e8b3a4ab53a",
+ "sourcePath": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "sourceSha256": "cf9055667978d78a1a5f65816ac376f56722f5594eaab6abef229e8b3a4ab53a"
+ },
+ {
+ "beatId": "B06",
+ "kind": "asset",
+ "assetId": "IL-PROOF-011",
+ "path": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "composed": false,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "26ba70a6c1cda6f1645ae213e2149bced9541f92753968bc67b1cdd470a7c639",
+ "sourcePath": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "sourceSha256": "26ba70a6c1cda6f1645ae213e2149bced9541f92753968bc67b1cdd470a7c639"
+ },
+ {
+ "beatId": "B07",
+ "kind": "board",
+ "assetId": "IL-PROOF-021",
+ "state": "comparison",
+ "path": "media/hac-336/frames/B07-IL-PROOF-021-comparison.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "6543146ac6f33bce3ecb98423d0d4e576c068c907d58246121b10e39e88f76dc",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg",
+ "sourceSha256": "b1c7e284dcca5a1a2e5f6bf557a8e03aabb6fcec3a6c2ca39501ffda447c8b05"
+ },
+ {
+ "beatId": "B08",
+ "kind": "board",
+ "assetId": "IL-PROOF-021",
+ "state": "ablation",
+ "path": "media/hac-336/frames/B08-IL-PROOF-021-ablation.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "c7af286394411cfb7326b0479deeab9618a7582e83915c1cb2ce40dd67a08db1",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg",
+ "sourceSha256": "40cbb5f977cbebe24663a6a39f3452a33f342befc17d8b8e840b9abf8d63aa81"
+ },
+ {
+ "beatId": "B09",
+ "kind": "board",
+ "assetId": "IL-SCAF-020",
+ "state": "reset",
+ "path": "media/hac-336/frames/B09-IL-SCAF-020-reset.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "a14bd9217fa0351b904c3313c497ce672e6a0050d49589a2dd46df663ffa08c7",
+ "sourcePath": "media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg",
+ "sourceSha256": "71c40a0df15799ffb85abd3017c9017e9b9336eb2fe2151a886b00e94dfdc7b3"
+ },
+ {
+ "beatId": "B10",
+ "kind": "capture",
+ "sceneId": "gemini-adk-attribution",
+ "cropId": null,
+ "crop": {
+ "x": 52,
+ "y": 44,
+ "w": 1560,
+ "h": 690
+ },
+ "stage": {
+ "x": 345,
+ "y": 228,
+ "w": 1230,
+ "h": 544,
+ "scale": 0.7884
+ },
+ "path": "media/hac-336/frames/B10-scene-gemini-adk-attribution.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "8f3becec942bf62452d5db5c7338353c321c07dcd02bf36e3c38a91d5678e8dd",
+ "sourcePath": "experiments/hac-324/frames/scene-gemini-adk-attribution.png",
+ "sourceSha256": "ee007e9c29c5fea3719fcec6d63d68c1ead5af33fceef4d90a8d1775f24f85c0"
+ },
+ {
+ "beatId": "B11",
+ "kind": "capture",
+ "sceneId": "cloud-run-topology",
+ "cropId": null,
+ "crop": {
+ "x": 52,
+ "y": 44,
+ "w": 1180,
+ "h": 300
+ },
+ "stage": {
+ "x": 88,
+ "y": 279,
+ "w": 1744,
+ "h": 443,
+ "scale": 1.478
+ },
+ "path": "media/hac-336/frames/B11-scene-cloud-run-topology.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "dbbce721f073c8f28f21bf35b2a5c169800e29867e3d0a47d6909cc480b5501b",
+ "sourcePath": "experiments/hac-324/frames/scene-cloud-run-topology.png",
+ "sourceSha256": "ae6bd02c7f4ebf0dc2b55ad76eb5b91d03ec32eba672672433480dd75b80e779"
+ },
+ {
+ "beatId": "B12",
+ "kind": "capture",
+ "sceneId": "agent-traversal",
+ "cropId": null,
+ "crop": {
+ "x": 52,
+ "y": 44,
+ "w": 1160,
+ "h": 440
+ },
+ "stage": {
+ "x": 243,
+ "y": 228,
+ "w": 1434,
+ "h": 544,
+ "scale": 1.2364
+ },
+ "path": "media/hac-336/frames/B12-scene-agent-traversal.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "7e63f8c2d33ff144e8c1e214400b3ffac7117748092f52b481d8b727f3821521",
+ "sourcePath": "experiments/hac-324/frames/scene-agent-traversal.png",
+ "sourceSha256": "102cafb35c0c02f9df1d9518e3be9fd9f361480f12d6f7bb37b3f1e640a1a490"
+ },
+ {
+ "beatId": "B13",
+ "kind": "capture",
+ "sceneId": "receipt-mutation-observation",
+ "cropId": "executed",
+ "crop": {
+ "x": 52,
+ "y": 236,
+ "w": 1180,
+ "h": 400
+ },
+ "stage": {
+ "x": 158,
+ "y": 228,
+ "w": 1605,
+ "h": 544,
+ "scale": 1.36
+ },
+ "path": "media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "daf6725048babae0d06229b3e51efb190cd24e03e00b5c6e5600faec00327b47",
+ "sourcePath": "experiments/hac-324/frames/scene-receipt-mutation-observation.png",
+ "sourceSha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c"
+ },
+ {
+ "beatId": "B14",
+ "kind": "capture",
+ "sceneId": "receipt-mutation-observation",
+ "cropId": "observed",
+ "crop": {
+ "x": 52,
+ "y": 672,
+ "w": 1180,
+ "h": 300
+ },
+ "stage": {
+ "x": 88,
+ "y": 279,
+ "w": 1744,
+ "h": 443,
+ "scale": 1.478
+ },
+ "path": "media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "cc6a3851c7dfa7e1c73b5b551ea034c92a8fa8870e91249a27c5821734df82a9",
+ "sourcePath": "experiments/hac-324/frames/scene-receipt-mutation-observation.png",
+ "sourceSha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c"
+ },
+ {
+ "beatId": "B15",
+ "kind": "capture",
+ "sceneId": "cloud-logging-correlation",
+ "cropId": null,
+ "crop": {
+ "x": 52,
+ "y": 44,
+ "w": 1700,
+ "h": 560
+ },
+ "stage": {
+ "x": 135,
+ "y": 228,
+ "w": 1651,
+ "h": 544,
+ "scale": 0.9714
+ },
+ "path": "media/hac-336/frames/B15-scene-cloud-logging-correlation.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "7bcb53f93c469793abbee2d3fd346f3c87a6a370b2aef0d24da13bdcf78e5b19",
+ "sourcePath": "experiments/hac-324/frames/scene-cloud-logging-correlation.png",
+ "sourceSha256": "ccd916391f6a8f38dde906477c6e16423db8a509bde55f64b59945517087fee8"
+ },
+ {
+ "beatId": "B16",
+ "kind": "board",
+ "assetId": "IL-DIAG-020",
+ "state": "path",
+ "path": "media/hac-336/frames/B16-IL-DIAG-020-path.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "960c2d9bd8e13946c5332e040907553a7d751858e179694002e9c27725732dad",
+ "sourcePath": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg",
+ "sourceSha256": "b67d8ccf796ff761d5f74491e99302689cb23049e1b4310dea14b6880b837e40"
+ },
+ {
+ "beatId": "B17",
+ "kind": "board",
+ "assetId": "IL-DIAG-020",
+ "state": "boundary",
+ "path": "media/hac-336/frames/B17-IL-DIAG-020-boundary.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "13ca784d8447cd49226f90ff3f026bbf9a35271b60add2b687d65b5063e99074",
+ "sourcePath": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg",
+ "sourceSha256": "222bc44997e9732b28c047e493a29aeae9e3f56e99bf7e627562bed5489c01ef"
+ },
+ {
+ "beatId": "B18",
+ "kind": "board",
+ "assetId": "IL-PROOF-022",
+ "state": "bounds",
+ "path": "media/hac-336/frames/B18-IL-PROOF-022-bounds.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "5c5a0fc0268eddb8d64017c043408d2f86aa94040d6b346c125eadb0db889e29",
+ "sourcePath": "media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg",
+ "sourceSha256": "b6e89f22fea263976acc24a696adbfc3c71d63abb3ed78c6bbf7fe296b9e5ed1"
+ },
+ {
+ "beatId": "B19",
+ "kind": "asset",
+ "assetId": "IL-PROOF-014",
+ "path": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "composed": false,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "11f1d61ee8445d1f0800d7895bf7a54335613c3d462a4eab8e517843f1d0b1a9",
+ "sourcePath": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "sourceSha256": "11f1d61ee8445d1f0800d7895bf7a54335613c3d462a4eab8e517843f1d0b1a9"
+ },
+ {
+ "beatId": "B20",
+ "kind": "board",
+ "assetId": "IL-SCAF-021",
+ "state": "thesis",
+ "path": "media/hac-336/frames/B20-IL-SCAF-021-thesis.png",
+ "composed": true,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "9983457402a8f77dfc26fc4699c1ab9ee9b9a1c850c3bc34d7cf1161e5caf2bb",
+ "sourcePath": "media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg",
+ "sourceSha256": "6274f887c139dfc4f9151d8af6f766efe230707b481671bdd4015e147def7175"
+ },
+ {
+ "beatId": "B21",
+ "kind": "asset",
+ "assetId": "IL-SCAF-011",
+ "path": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "composed": false,
+ "width": 1920,
+ "height": 1080,
+ "sha256": "0fd186af922178a972e049f9d70351ceae62e63494270b182dbc8a9ec5ae1946",
+ "sourcePath": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "sourceSha256": "0fd186af922178a972e049f9d70351ceae62e63494270b182dbc8a9ec5ae1946"
+ }
+ ]
+}
diff --git a/media/hac-336/evidence/input-manifest.json b/media/hac-336/evidence/input-manifest.json
new file mode 100644
index 0000000..f75d42b
--- /dev/null
+++ b/media/hac-336/evidence/input-manifest.json
@@ -0,0 +1,810 @@
+{
+ "manifestId": "HAC-336-input-manifest",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "Derived by media/hac-336/bin/build-input-manifest.mjs. Do not hand-edit. Every artifact the final cut consumes, with its content digest; every declared revision, with whether it could be checked against frozen evidence and whether it agreed.",
+ "generator": "media/hac-336/bin/build-input-manifest.mjs",
+ "declaredRevisions": [
+ {
+ "name": "interlockSha",
+ "value": "6da9884bc7922a199003ea9babcdf47dbba104df",
+ "checkable": false,
+ "meaning": "Marcelle-Labs/interlock main at the revision this cut was assembled from. The assembly commit is its descendant."
+ },
+ {
+ "name": "directorSha",
+ "value": "81a0535acac9a3b58b2b948ed9e600a9c2c59af9",
+ "checkable": false,
+ "meaning": "Marcelle-Labs/director (Studio v3) main at assembly time. Studio produced the HAC-324 capture package; it is not a build input to this repository, and the revision that actually performed the capture is recorded separately as capturePackage.productRevision."
+ },
+ {
+ "name": "hac343CanonicalResult",
+ "value": "7ede0f97e55685c16e5bb762b5e7fbe471a6e8b0",
+ "checkable": true,
+ "source": "experiments/hac-343/evidence/judge-export.json",
+ "pointer": "/provenance/canonicalResultCommit",
+ "agrees": true
+ },
+ {
+ "name": "filmedRunId",
+ "value": "ilk-hac340-cloud-1787536029323",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/correlationId",
+ "agrees": true
+ },
+ {
+ "name": "receiptId",
+ "value": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/receiptId",
+ "agrees": true
+ },
+ {
+ "name": "runtimeSourceSha",
+ "value": "ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a",
+ "checkable": true,
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/commitSha",
+ "agrees": true
+ }
+ ],
+ "capturePackage": {
+ "packageId": "HAC-324-authoritative-filmed-run",
+ "filmedRunId": "ilk-hac340-cloud-1787536029323",
+ "correlationId": "ilk-hac340-cloud-1787536029323",
+ "receiptId": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d",
+ "runtimeSourceSha": "ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a",
+ "productRevision": "director 0beeb4a40283687ccfc518f16115e9e3a3277664",
+ "model": "gemini-3.5-flash",
+ "adk": "Google ADK 1.35.1 / Vertex AI",
+ "region": "us-central1",
+ "allFramesPassQuality": true,
+ "teardown": {
+ "status": "completed",
+ "deletedAt": "2026-08-24T01:50:43.559Z",
+ "projectLifecycleState": "DELETE_REQUESTED"
+ },
+ "note": "productRevision is the Director revision that performed the capture. It is deliberately not the same value as the declared directorSha, which is Director main at assembly time."
+ },
+ "promotedScenes": [
+ {
+ "sceneId": "agent-traversal",
+ "sha256": "102cafb35c0c02f9df1d9518e3be9fd9f361480f12d6f7bb37b3f1e640a1a490",
+ "bytes": 93288,
+ "source": "live agent traversal executed during capture",
+ "qualityPass": true,
+ "usedInCut": true,
+ "beats": [
+ "B12"
+ ]
+ },
+ {
+ "sceneId": "gemini-adk-attribution",
+ "sha256": "ee007e9c29c5fea3719fcec6d63d68c1ead5af33fceef4d90a8d1775f24f85c0",
+ "bytes": 146792,
+ "source": "live Cloud Run config: model + pinned ADK image",
+ "qualityPass": true,
+ "usedInCut": true,
+ "beats": [
+ "B10"
+ ]
+ },
+ {
+ "sceneId": "cloud-run-topology",
+ "sha256": "ae6bd02c7f4ebf0dc2b55ad76eb5b91d03ec32eba672672433480dd75b80e779",
+ "bytes": 99108,
+ "source": "live Cloud Run services and revisions",
+ "qualityPass": true,
+ "usedInCut": true,
+ "beats": [
+ "B11"
+ ]
+ },
+ {
+ "sceneId": "cloud-logging-correlation",
+ "sha256": "ccd916391f6a8f38dde906477c6e16423db8a509bde55f64b59945517087fee8",
+ "bytes": 133422,
+ "source": "live Cloud Logging filtered to this run",
+ "qualityPass": true,
+ "usedInCut": true,
+ "beats": [
+ "B15"
+ ]
+ },
+ {
+ "sceneId": "receipt-mutation-observation",
+ "sha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c",
+ "bytes": 233212,
+ "source": "evidence packet emitted by scene 1's traversal",
+ "qualityPass": true,
+ "usedInCut": true,
+ "beats": [
+ "B13",
+ "B14"
+ ]
+ }
+ ],
+ "unusedPromotedScenes": {
+ "sceneIds": [],
+ "note": "None. Every promoted scene appears in the cut."
+ },
+ "inputs": [
+ {
+ "path": "experiments/hac-324/evidence/capture-package.json",
+ "roles": [
+ "authoritative filmed run - capture manifest"
+ ],
+ "bytes": 5221,
+ "sha256": "58dde0cc6ba94c60f4bdc555a7175f0e3a28676490add3c3c07d1f5189363fa5"
+ },
+ {
+ "path": "experiments/hac-324/evidence/filmed-run.json",
+ "roles": [
+ "authoritative filmed run - record"
+ ],
+ "bytes": 6113,
+ "sha256": "bf31c4ef79806c3590bc08de2f9b977a4b249f315414ca887c8602e800d21a11"
+ },
+ {
+ "path": "experiments/hac-324/evidence/filmed-run.raw.json",
+ "roles": [
+ "authoritative filmed run - emitted bytes"
+ ],
+ "bytes": 4876,
+ "sha256": "297e89303834e3a24ea41eb3731d2f6dffb251aee3ae8421665dfe5b66c61f07"
+ },
+ {
+ "path": "experiments/hac-324/frames/scene-agent-traversal.png",
+ "roles": [
+ "promoted capture scene used at B12"
+ ],
+ "bytes": 93288,
+ "sha256": "102cafb35c0c02f9df1d9518e3be9fd9f361480f12d6f7bb37b3f1e640a1a490"
+ },
+ {
+ "path": "experiments/hac-324/frames/scene-cloud-logging-correlation.png",
+ "roles": [
+ "promoted capture scene used at B15"
+ ],
+ "bytes": 133422,
+ "sha256": "ccd916391f6a8f38dde906477c6e16423db8a509bde55f64b59945517087fee8"
+ },
+ {
+ "path": "experiments/hac-324/frames/scene-cloud-run-topology.png",
+ "roles": [
+ "promoted capture scene used at B11"
+ ],
+ "bytes": 99108,
+ "sha256": "ae6bd02c7f4ebf0dc2b55ad76eb5b91d03ec32eba672672433480dd75b80e779"
+ },
+ {
+ "path": "experiments/hac-324/frames/scene-gemini-adk-attribution.png",
+ "roles": [
+ "promoted capture scene used at B10"
+ ],
+ "bytes": 146792,
+ "sha256": "ee007e9c29c5fea3719fcec6d63d68c1ead5af33fceef4d90a8d1775f24f85c0"
+ },
+ {
+ "path": "experiments/hac-324/frames/scene-receipt-mutation-observation.png",
+ "roles": [
+ "promoted capture scene used at B13",
+ "promoted capture scene used at B14"
+ ],
+ "bytes": 233212,
+ "sha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c"
+ },
+ {
+ "path": "experiments/hac-330/evidence/arms.json",
+ "roles": [
+ "controlled local experiment - arms"
+ ],
+ "bytes": 5117,
+ "sha256": "dcadc10eb4b963427bdf38916bb9a4a758c035902037bad116325815293795c9"
+ },
+ {
+ "path": "experiments/hac-330/evidence/results.json",
+ "roles": [
+ "controlled local experiment - results"
+ ],
+ "bytes": 12583,
+ "sha256": "acf6ed87d67647f06f7da38467323b3fc8000edd8acda5e8a7d70301e98006aa"
+ },
+ {
+ "path": "experiments/hac-342/evidence/cloud-run.public.json",
+ "roles": [
+ "frozen HAC-340 reference packet"
+ ],
+ "bytes": 4698,
+ "sha256": "ea1d6993ca937bb5ae14ad43954e48bd1a91ceb5e959719f8a99492b0b0dbf0d"
+ },
+ {
+ "path": "experiments/hac-343/evidence/judge-export.json",
+ "roles": [
+ "bounded evaluation - judge export"
+ ],
+ "bytes": 12191,
+ "sha256": "e3688e86cf5d35b15869bcf212960031c05878b6d5f342b9741f5fa5f8e99dae"
+ },
+ {
+ "path": "media/hac-333/scene-manifest.json",
+ "roles": [
+ "storyboard contract"
+ ],
+ "bytes": 23329,
+ "sha256": "b5afcef606618f89e7d3c63eab8ab2ab5fb9e60d7c24209d3fd7752198816a9c"
+ },
+ {
+ "path": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "roles": [
+ "frozen export used at B05"
+ ],
+ "bytes": 116084,
+ "sha256": "cf9055667978d78a1a5f65816ac376f56722f5594eaab6abef229e8b3a4ab53a"
+ },
+ {
+ "path": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "roles": [
+ "frozen export used at B06"
+ ],
+ "bytes": 112823,
+ "sha256": "26ba70a6c1cda6f1645ae213e2149bced9541f92753968bc67b1cdd470a7c639"
+ },
+ {
+ "path": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "roles": [
+ "frozen export used at B19"
+ ],
+ "bytes": 171199,
+ "sha256": "11f1d61ee8445d1f0800d7895bf7a54335613c3d462a4eab8e517843f1d0b1a9"
+ },
+ {
+ "path": "media/hac-335/evidence/claim-ledger.json",
+ "roles": [
+ "judge-package claim ledger"
+ ],
+ "bytes": 16945,
+ "sha256": "3dd3a1cfb5e6cf0720d2988fa80d545bed445eca4f1f910c6125f4e5c890df4d"
+ },
+ {
+ "path": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "roles": [
+ "frozen export used at B01"
+ ],
+ "bytes": 48536,
+ "sha256": "0d238422b53d960ceb2b75f7871f042799bd8578969fd20d2944c7d84e080fa2"
+ },
+ {
+ "path": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "roles": [
+ "frozen export used at B21"
+ ],
+ "bytes": 87427,
+ "sha256": "0fd186af922178a972e049f9d70351ceae62e63494270b182dbc8a9ec5ae1946"
+ },
+ {
+ "path": "media/hac-336/evidence/cut.json",
+ "roles": [
+ "edit decision list"
+ ],
+ "bytes": 15625,
+ "sha256": "f3c954113494606d8a63cf3b4930c6bc26fc8762f8d0858ccfabc313a7c6f7d8"
+ },
+ {
+ "path": "media/hac-336/evidence/filmed-run-claims.json",
+ "roles": [
+ "filmed-run claim rows"
+ ],
+ "bytes": 6960,
+ "sha256": "ef4253a3bd3d28cc4bc9bcc9ac31cea304f69b37714f7686c3c232c878990885"
+ },
+ {
+ "path": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg",
+ "roles": [
+ "film board master used at B17"
+ ],
+ "bytes": 4792,
+ "sha256": "222bc44997e9732b28c047e493a29aeae9e3f56e99bf7e627562bed5489c01ef"
+ },
+ {
+ "path": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg",
+ "roles": [
+ "film board master used at B16"
+ ],
+ "bytes": 10139,
+ "sha256": "b67d8ccf796ff761d5f74491e99302689cb23049e1b4310dea14b6880b837e40"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg",
+ "roles": [
+ "film board master used at B04"
+ ],
+ "bytes": 3830,
+ "sha256": "9d62da859c43c74e6623f8267f0a542905168a1f1234d9542a9a7b1b89864ca3"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg",
+ "roles": [
+ "film board master used at B03"
+ ],
+ "bytes": 5492,
+ "sha256": "4c512891c728aa84cf698d5f29b07a1be81781db326b7a316b2c332b969c6925"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg",
+ "roles": [
+ "film board master used at B02"
+ ],
+ "bytes": 3443,
+ "sha256": "567da3a0bbb76eace11b4829fd3c1935c6224acf409c2bcb6be4803f5efe09ac"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg",
+ "roles": [
+ "film board master used at B08"
+ ],
+ "bytes": 7479,
+ "sha256": "40cbb5f977cbebe24663a6a39f3452a33f342befc17d8b8e840b9abf8d63aa81"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg",
+ "roles": [
+ "film board master used at B07"
+ ],
+ "bytes": 6917,
+ "sha256": "b1c7e284dcca5a1a2e5f6bf557a8e03aabb6fcec3a6c2ca39501ffda447c8b05"
+ },
+ {
+ "path": "media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg",
+ "roles": [
+ "film board master used at B18"
+ ],
+ "bytes": 6187,
+ "sha256": "b6e89f22fea263976acc24a696adbfc3c71d63abb3ed78c6bbf7fe296b9e5ed1"
+ },
+ {
+ "path": "media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg",
+ "roles": [
+ "film board master used at B09"
+ ],
+ "bytes": 4066,
+ "sha256": "71c40a0df15799ffb85abd3017c9017e9b9336eb2fe2151a886b00e94dfdc7b3"
+ },
+ {
+ "path": "media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg",
+ "roles": [
+ "film board master used at B20"
+ ],
+ "bytes": 3199,
+ "sha256": "6274f887c139dfc4f9151d8af6f766efe230707b481671bdd4015e147def7175"
+ }
+ ],
+ "claims": [
+ {
+ "id": "CL-001",
+ "text": "Two changes can each be locally valid yet jointly violate a shared constraint.",
+ "classification": "EDITORIAL",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "Framing for the HAC-330 experiment; the concrete instance is CL-003."
+ },
+ {
+ "id": "CL-002",
+ "text": "Interlock reads revision-bound environment evidence before shared-state mutation and selects a deterministic coordination decision.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-330/evidence/arms.json; media/hac-341/evidence/view-model.json (runs.local.environmentEvidence, runs.local.arms)"
+ },
+ {
+ "id": "CL-003",
+ "text": "Without Interlock the two intents produce 140 > 130, an invalid joint state against the bound sum(services[].reserved) <= 130.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-330/evidence/arms.json (baseline arm); experiments/hac-330/evidence/results.json"
+ },
+ {
+ "id": "CL-004",
+ "text": "With Interlock enabled and the original evidence, the decision is WITHHOLD_SERIALIZE and the joint outcome is 120 <= 130.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-330/evidence/arms.json (treatment arm)"
+ },
+ {
+ "id": "CL-005",
+ "text": "The controlled local experiment reports 24/24 checks.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-330/evidence/results.json"
+ },
+ {
+ "id": "CL-006",
+ "text": "Changing the environment evidence changes the decision to ALLOW_PARALLEL and returns the joint outcome to 140 > 130.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-330/evidence/perturbed.evidence.json; experiments/hac-330/evidence/perturbed.selection.json; experiments/hac-330/evidence/arms.json (perturbed arm)"
+ },
+ {
+ "id": "CL-007",
+ "text": "Each arm is a recorded result; the perturbation is a frozen arm, not a live re-run.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-334/masters/IL-PROOF-011-evidence-load-bearing-runhac330local.svg; media/hac-341/evidence/view-model.json (runs.local.arms)"
+ },
+ {
+ "id": "CL-009",
+ "text": "HAC-330 and HAC-340 are two separate runs with separate evidence. Neither is evidence for the other.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-334/masters/IL-PROOF-014-claim-boundary.svg; media/hac-333/scene-manifest.json (SB-06); media/hac-341/evidence/view-model.json (intentionallyAbsent on both runs)"
+ },
+ {
+ "id": "CL-014",
+ "text": "Three recorded refusals are three controls, not comprehensive attack coverage.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-333/scene-manifest.json (globalNonClaims); media/hac-334 IL-PROOF-014"
+ },
+ {
+ "id": "CL-015",
+ "text": "Cloud Run IAM establishes transport provenance. It does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-342/evidence/cloud-run.public.json (runtimeProof); media/hac-334/masters/IL-DIAG-012-deployment-trust-boundaries-runilkhac340cloud1786730369123.svg"
+ },
+ {
+ "id": "CL-017",
+ "text": "The public cloud packet is published immutably at commit 75253e38791e69f7e2a4bb3a041044a9114c32f0 and its bytes hash to publicPacketSha256 ea1d6993ca937bb5ae14ad43954e48bd1a91ceb5e959719f8a99492b0b0dbf0d, which any reader can recompute.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-342/evidence/publication-bindings.json; experiments/hac-342/bin/verify-public-packet.mjs"
+ },
+ {
+ "id": "CL-020",
+ "text": "HAC-340 does not reproduce the HAC-330 counterfactual in Google Cloud.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-333/scene-manifest.json (globalNonClaims); media/hac-334 IL-PROOF-014"
+ },
+ {
+ "id": "CL-027",
+ "text": "HAC-330 did not run on Google Cloud. It is a controlled experiment executed locally.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-333/scene-manifest.json (globalNonClaims); media/hac-334 IL-PROOF-010 non-claim strip; media/hac-341/evidence/view-model.json (runs.local.intentionallyAbsent)"
+ },
+ {
+ "id": "CL-021",
+ "text": "Agent Runtime, Agent Gateway and CONTENT_AUTHZ are not on the recorded HAC-340 path.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-342/evidence/cloud-run.public.json; media/hac-334 IL-DIAG-012 (ABSENT FROM THIS DEPLOYMENT)"
+ },
+ {
+ "id": "CL-022",
+ "text": "Wrong-audience token rejection is controlled local parity evidence, not a HAC-340 cloud result.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-341/evidence/view-model.json (negativeControlsNote); media/hac-333/scene-manifest.json (globalNonClaims)"
+ },
+ {
+ "id": "CL-023",
+ "text": "No safety, security, verification, certification or production-readiness guarantee is claimed. ALLOW is not VERIFIED, OBSERVED is not SAFE, and WITHHOLD_SERIALIZE is not human approval, joint authorization or an AUTHORIZED lifecycle state.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-333/scene-manifest.json (globalNonClaims); media/hac-334 IL-PROOF-014; DISCLOSURE.md"
+ },
+ {
+ "id": "CL-024",
+ "text": "No exactly-once execution, restart-safety, recovery guarantee, fleet-scale readiness or universal collision prevention is claimed.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "media/hac-333/scene-manifest.json (globalNonClaims); media/hac-334 IL-PROOF-014"
+ },
+ {
+ "id": "CL-028",
+ "text": "On a frozen sixteen-scenario corpus, four coordination strategies compare as: Uncoordinated 2/2 hazards unsafe and 2/2 independent opportunities parallel; Global lock 0/2 and 0/2; Per-target lock 2/2 and 2/2; Interlock 0/2 and 2/2.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-343/evidence/judge-export.json#panel1.rows"
+ },
+ {
+ "id": "CL-029",
+ "text": "The per-target lock baseline is credible rather than a straw man: it serialized same-target contention 2/2, parallelised cross-target pairs 4/4, and missed cross-target hazards 2/2. A composition hazard spanning two lock keys is not visible to any per-key discipline.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-343/evidence/judge-export.json#panel1.perTargetLockCredibility"
+ },
+ {
+ "id": "CL-030",
+ "text": "Interlock's safety in this corpus is evidence-derived: with coupling evidence present it produced 0/2 invalid outcomes, and with that evidence removed the same core produced 2/2 invalid outcomes.",
+ "classification": "EVIDENCED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-343/evidence/judge-export.json#panel2.rows"
+ },
+ {
+ "id": "CL-031",
+ "text": "Interlock is not 0% unsafe, is not safer than locking, and the sixteen-scenario corpus is not collapsed into one denominator. No interval or statistical significance is claimed, and no exactly-once, restart-safety or production-readiness result was tested.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-335 claim ledger",
+ "proofSource": "experiments/hac-343/evidence/judge-export.json#mustNotClaim"
+ },
+ {
+ "id": "FR-001",
+ "text": "The recorded traversal ran gemini-3.5-flash.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/model",
+ "value": "gemini-3.5-flash"
+ }
+ ]
+ },
+ {
+ "id": "FR-002",
+ "text": "The agent reached the model through Google ADK 1.35.1 with Vertex AI access, from an image pinned to the runtime source commit.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/adkPath",
+ "value": "Google ADK 1.35.1 / Vertex AI"
+ },
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/adk",
+ "value": "Google ADK 1.35.1 / Vertex AI"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/resources/vertexLocation",
+ "value": "global"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/resources/agentImage",
+ "value": "us-central1-docker.pkg.dev/interlock-film-260823/interlock-hac340/interlock-adk:ae6d0d3"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/commitSha",
+ "value": "ae6d0d3c405b6169d5f0495c22aaf05d8fc1de4a"
+ }
+ ]
+ },
+ {
+ "id": "FR-003",
+ "text": "Three Cloud Run services in us-central1 carried the run: the ADK agent, the Interlock MCP proxy and the protected target.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/region",
+ "value": "us-central1"
+ },
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/revisions/agent",
+ "value": "interlock-hac340-agent-00001-hbk"
+ },
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/revisions/proxy",
+ "value": "interlock-hac340-proxy-00001-s76"
+ },
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/revisions/target",
+ "value": "interlock-hac340-target-00001-sng"
+ }
+ ]
+ },
+ {
+ "id": "FR-004",
+ "text": "Interlock returned ALLOW with an authorization receipt, under one correlation id.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/decision",
+ "value": "ALLOW"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/receiptId",
+ "value": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/receiptDigest",
+ "value": "sha256:7fb65efe30d89bab241d1f5b8ea00ca58fb732a30b9fb87288a09deab411743f"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/correlationId",
+ "value": "ilk-hac340-cloud-1787536029323"
+ }
+ ]
+ },
+ {
+ "id": "FR-005",
+ "text": "The protected mutation is EXECUTED against that receipt, moving the target from one revision to the next.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/status",
+ "value": "EXECUTED"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/receiptId",
+ "value": "rcpt-e742d4f3-85d4-46b8-a1e0-320fa429358d"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/revisionBefore",
+ "value": "sha256:c78fe996446426339788c38a6a6edaf6b1b8ab72f984c9c72786637148615a10"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/revisionAfter",
+ "value": "sha256:6a8acad8a0f7f4df96548ba9b60714b4a7e350cc480c1ef86f4d677db62b8a27"
+ }
+ ]
+ },
+ {
+ "id": "FR-006",
+ "text": "The resulting invariant on the protected target is total 105 <= 130.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/invariant/detail",
+ "value": "total 105 <= 130"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/protectedMutation/invariant/holds",
+ "value": true
+ }
+ ]
+ },
+ {
+ "id": "FR-007",
+ "text": "The read-back was performed by a dedicated keyless observer service account, independently authenticated and distinct from the provisioning operator.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/resources/observerPrincipal",
+ "value": "serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/resources/operatorPrincipal",
+ "value": "user:qwynn@marcellelabs.io"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/principalProjection/correction/classification",
+ "value": "NON_MATERIAL"
+ },
+ {
+ "source": "experiments/hac-324/evidence/capture-package.json",
+ "pointer": "/externalCallerPrincipal",
+ "value": "serviceAccount:interlock-hac340-observer@interlock-film-260823.iam.gserviceaccount.com"
+ }
+ ]
+ },
+ {
+ "id": "FR-008",
+ "text": "That observation read alpha=45 at the post-mutation revision.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/observation/revision",
+ "value": "sha256:6a8acad8a0f7f4df96548ba9b60714b4a7e350cc480c1ef86f4d677db62b8a27"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/observation/state/services/alpha",
+ "value": 45
+ }
+ ]
+ },
+ {
+ "id": "FR-009",
+ "text": "Three fail-closed controls were recorded on this run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct target call carrying no receipt returned 403.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/controls/forgedHeaderStatus",
+ "value": 403
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/controls/wrongAudienceStatus",
+ "value": 401
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/controls/directBypassStatus",
+ "value": 403
+ }
+ ]
+ },
+ {
+ "id": "FR-010",
+ "text": "Cloud Logging carries a proxy request for this run's correlation id, with the agent identity as the platform verified it and the Cloud Run revision that served it.",
+ "classification": "EVIDENCED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/runtimeProof/proxyLogEntries/0/jsonPayload/correlationId",
+ "value": "ilk-hac340-cloud-1787536029323"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/runtimeProof/proxyLogEntries/0/jsonPayload/identitySource",
+ "value": "oidc-id-token/platform-verified:email"
+ },
+ {
+ "source": "experiments/hac-324/evidence/filmed-run.json",
+ "pointer": "/runtimeProof/proxyLogEntries/0/resource/labels/revision_name",
+ "value": "interlock-hac340-proxy-00001-s76"
+ }
+ ]
+ },
+ {
+ "id": "FR-011",
+ "text": "Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on the recorded path, and Cloud Run IAM establishes transport provenance only.",
+ "classification": "NOT CLAIMED",
+ "source": "HAC-336 filmed-run claim rows",
+ "resolved": [
+ {
+ "source": "media/hac-333/scene-manifest.json",
+ "pointer": "/globalNonClaims",
+ "value": [
+ "HAC-330 did not run on Google Cloud",
+ "HAC-340 does not reproduce the 140/120 counterfactual",
+ "Agent Runtime did not participate",
+ "Agent Gateway did not participate",
+ "CONTENT_AUTHZ is not on the HAC-340 path",
+ "wrong-audience-token rejection is local parity evidence, not a HAC-340 cloud result",
+ "HAC-316 is failed/pivoted provenance and is not a passing proof packet",
+ "WITHHOLD_SERIALIZE is not human approval, joint authorization or certification",
+ "ALLOW plus receipt is not an AUTHORIZED lifecycle state; no HAC-317 lifecycle vocabulary is imported",
+ "no exactly-once, restart-safety or recovery guarantee is claimed (HAC-327 out of scope)",
+ "no safety, security, verification or production-readiness guarantee is claimed",
+ "three cloud negative controls are three controls, not comprehensive attack coverage"
+ ]
+ }
+ ]
+ }
+ ],
+ "narrationSource": {
+ "path": "media/hac-336/evidence/cut.json",
+ "sha256": "f3c954113494606d8a63cf3b4930c6bc26fc8762f8d0858ccfabc313a7c6f7d8",
+ "note": "The narration and caption text is authored in the cut and nowhere else. The caption files are generated from it, so a caption cannot drift from the scene map."
+ },
+ "timeline": {
+ "totalSeconds": 229.5,
+ "holdSum": 237.5,
+ "transitions": 20,
+ "beats": 21
+ }
+}
diff --git a/media/hac-336/evidence/render-manifest.json b/media/hac-336/evidence/render-manifest.json
new file mode 100644
index 0000000..09df723
--- /dev/null
+++ b/media/hac-336/evidence/render-manifest.json
@@ -0,0 +1,342 @@
+{
+ "manifestId": "HAC-336-render-manifest",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "Derived by media/hac-336/bin/build-video.mjs. Do not hand-edit. Duration, geometry and codec are read back out of the finished file rather than copied from the encode request.",
+ "generator": "media/hac-336/bin/build-video.mjs",
+ "encoder": "ffmpeg version 8.0.1 Copyright (c) 2000-2025 the FFmpeg developers",
+ "encoderPath": "/opt/homebrew/bin/ffmpeg",
+ "video": {
+ "path": "media/hac-336/exports/IL-MOT-020-interlock-final-cut-1920x1080.mp4",
+ "sha256": "344d5954c4abc038ae6c741388466c7a19551b4b3897a2cb292c0d6b9a193af0",
+ "bytes": 15974400,
+ "width": 1920,
+ "height": 1080,
+ "codec": "avc1",
+ "brand": "isom",
+ "fps": 30,
+ "measuredDurationSeconds": 229.534,
+ "derivedDurationSeconds": 229.5,
+ "audioTracks": 0,
+ "trackHandlers": [
+ "vide"
+ ]
+ },
+ "captions": [
+ {
+ "path": "media/hac-336/captions/interlock-final-cut.en.vtt",
+ "format": "WebVTT",
+ "cues": 21,
+ "sha256": "ce9d562e33eff0dffd47c9723077ce3f28e385bfcdd1237e2403e8f24a16dd44"
+ },
+ {
+ "path": "media/hac-336/captions/interlock-final-cut.en.srt",
+ "format": "SubRip",
+ "cues": 21,
+ "sha256": "9304950e76ebb36b8f43694a038feea2e9d01be47b708450575019b2b8098aa1"
+ }
+ ],
+ "frames": [
+ {
+ "beatId": "B01",
+ "path": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "sha256": "0d238422b53d960ceb2b75f7871f042799bd8578969fd20d2944c7d84e080fa2"
+ },
+ {
+ "beatId": "B02",
+ "path": "media/hac-336/frames/B02-IL-PROOF-020-intents.png",
+ "sha256": "0d12bebadeb29d6b4ee0f4df96cac92c34517b073c66fbac21044d0f3d3bf15b"
+ },
+ {
+ "beatId": "B03",
+ "path": "media/hac-336/frames/B03-IL-PROOF-020-coupled.png",
+ "sha256": "780d1eb2a59482a156b9ae145dbd9eea0682bcaee1bd9d995d519b8bb3dfa754"
+ },
+ {
+ "beatId": "B04",
+ "path": "media/hac-336/frames/B04-IL-PROOF-020-baseline.png",
+ "sha256": "2fb939d1ce99a406128538fb2b4e421fbb0a431f8028d0cdb9fd9dc13294ea86"
+ },
+ {
+ "beatId": "B05",
+ "path": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "sha256": "cf9055667978d78a1a5f65816ac376f56722f5594eaab6abef229e8b3a4ab53a"
+ },
+ {
+ "beatId": "B06",
+ "path": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "sha256": "26ba70a6c1cda6f1645ae213e2149bced9541f92753968bc67b1cdd470a7c639"
+ },
+ {
+ "beatId": "B07",
+ "path": "media/hac-336/frames/B07-IL-PROOF-021-comparison.png",
+ "sha256": "6543146ac6f33bce3ecb98423d0d4e576c068c907d58246121b10e39e88f76dc"
+ },
+ {
+ "beatId": "B08",
+ "path": "media/hac-336/frames/B08-IL-PROOF-021-ablation.png",
+ "sha256": "c7af286394411cfb7326b0479deeab9618a7582e83915c1cb2ce40dd67a08db1"
+ },
+ {
+ "beatId": "B09",
+ "path": "media/hac-336/frames/B09-IL-SCAF-020-reset.png",
+ "sha256": "a14bd9217fa0351b904c3313c497ce672e6a0050d49589a2dd46df663ffa08c7"
+ },
+ {
+ "beatId": "B10",
+ "path": "media/hac-336/frames/B10-scene-gemini-adk-attribution.png",
+ "sha256": "8f3becec942bf62452d5db5c7338353c321c07dcd02bf36e3c38a91d5678e8dd"
+ },
+ {
+ "beatId": "B11",
+ "path": "media/hac-336/frames/B11-scene-cloud-run-topology.png",
+ "sha256": "dbbce721f073c8f28f21bf35b2a5c169800e29867e3d0a47d6909cc480b5501b"
+ },
+ {
+ "beatId": "B12",
+ "path": "media/hac-336/frames/B12-scene-agent-traversal.png",
+ "sha256": "7e63f8c2d33ff144e8c1e214400b3ffac7117748092f52b481d8b727f3821521"
+ },
+ {
+ "beatId": "B13",
+ "path": "media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png",
+ "sha256": "daf6725048babae0d06229b3e51efb190cd24e03e00b5c6e5600faec00327b47"
+ },
+ {
+ "beatId": "B14",
+ "path": "media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png",
+ "sha256": "cc6a3851c7dfa7e1c73b5b551ea034c92a8fa8870e91249a27c5821734df82a9"
+ },
+ {
+ "beatId": "B15",
+ "path": "media/hac-336/frames/B15-scene-cloud-logging-correlation.png",
+ "sha256": "7bcb53f93c469793abbee2d3fd346f3c87a6a370b2aef0d24da13bdcf78e5b19"
+ },
+ {
+ "beatId": "B16",
+ "path": "media/hac-336/frames/B16-IL-DIAG-020-path.png",
+ "sha256": "960c2d9bd8e13946c5332e040907553a7d751858e179694002e9c27725732dad"
+ },
+ {
+ "beatId": "B17",
+ "path": "media/hac-336/frames/B17-IL-DIAG-020-boundary.png",
+ "sha256": "13ca784d8447cd49226f90ff3f026bbf9a35271b60add2b687d65b5063e99074"
+ },
+ {
+ "beatId": "B18",
+ "path": "media/hac-336/frames/B18-IL-PROOF-022-bounds.png",
+ "sha256": "5c5a0fc0268eddb8d64017c043408d2f86aa94040d6b346c125eadb0db889e29"
+ },
+ {
+ "beatId": "B19",
+ "path": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "sha256": "11f1d61ee8445d1f0800d7895bf7a54335613c3d462a4eab8e517843f1d0b1a9"
+ },
+ {
+ "beatId": "B20",
+ "path": "media/hac-336/frames/B20-IL-SCAF-021-thesis.png",
+ "sha256": "9983457402a8f77dfc26fc4699c1ab9ee9b9a1c850c3bc34d7cf1161e5caf2bb"
+ },
+ {
+ "beatId": "B21",
+ "path": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "sha256": "0fd186af922178a972e049f9d70351ceae62e63494270b182dbc8a9ec5ae1946"
+ }
+ ],
+ "timeline": {
+ "beats": [
+ {
+ "beatId": "B01",
+ "index": 0,
+ "startSeconds": 0,
+ "endSeconds": 4,
+ "holdSeconds": 4,
+ "captionStart": 0,
+ "captionEnd": 3.6
+ },
+ {
+ "beatId": "B02",
+ "index": 1,
+ "startSeconds": 3.6,
+ "endSeconds": 8.6,
+ "holdSeconds": 5,
+ "captionStart": 4,
+ "captionEnd": 8.2
+ },
+ {
+ "beatId": "B03",
+ "index": 2,
+ "startSeconds": 8.2,
+ "endSeconds": 13.7,
+ "holdSeconds": 5.5,
+ "captionStart": 8.6,
+ "captionEnd": 13.3
+ },
+ {
+ "beatId": "B04",
+ "index": 3,
+ "startSeconds": 13.3,
+ "endSeconds": 19.8,
+ "holdSeconds": 6.5,
+ "captionStart": 13.7,
+ "captionEnd": 19.4
+ },
+ {
+ "beatId": "B05",
+ "index": 4,
+ "startSeconds": 19.4,
+ "endSeconds": 33.4,
+ "holdSeconds": 14,
+ "captionStart": 19.8,
+ "captionEnd": 33
+ },
+ {
+ "beatId": "B06",
+ "index": 5,
+ "startSeconds": 33,
+ "endSeconds": 47,
+ "holdSeconds": 14,
+ "captionStart": 33.4,
+ "captionEnd": 46.6
+ },
+ {
+ "beatId": "B07",
+ "index": 6,
+ "startSeconds": 46.6,
+ "endSeconds": 60.6,
+ "holdSeconds": 14,
+ "captionStart": 47,
+ "captionEnd": 60.2
+ },
+ {
+ "beatId": "B08",
+ "index": 7,
+ "startSeconds": 60.2,
+ "endSeconds": 78.2,
+ "holdSeconds": 18,
+ "captionStart": 60.6,
+ "captionEnd": 77.8
+ },
+ {
+ "beatId": "B09",
+ "index": 8,
+ "startSeconds": 77.8,
+ "endSeconds": 82.3,
+ "holdSeconds": 4.5,
+ "captionStart": 78.2,
+ "captionEnd": 81.9
+ },
+ {
+ "beatId": "B10",
+ "index": 9,
+ "startSeconds": 81.9,
+ "endSeconds": 93.9,
+ "holdSeconds": 12,
+ "captionStart": 82.3,
+ "captionEnd": 93.5
+ },
+ {
+ "beatId": "B11",
+ "index": 10,
+ "startSeconds": 93.5,
+ "endSeconds": 103.5,
+ "holdSeconds": 10,
+ "captionStart": 93.9,
+ "captionEnd": 103.1
+ },
+ {
+ "beatId": "B12",
+ "index": 11,
+ "startSeconds": 103.1,
+ "endSeconds": 118.1,
+ "holdSeconds": 15,
+ "captionStart": 103.5,
+ "captionEnd": 117.7
+ },
+ {
+ "beatId": "B13",
+ "index": 12,
+ "startSeconds": 117.7,
+ "endSeconds": 129.7,
+ "holdSeconds": 12,
+ "captionStart": 118.1,
+ "captionEnd": 129.3
+ },
+ {
+ "beatId": "B14",
+ "index": 13,
+ "startSeconds": 129.3,
+ "endSeconds": 143.3,
+ "holdSeconds": 14,
+ "captionStart": 129.7,
+ "captionEnd": 142.9
+ },
+ {
+ "beatId": "B15",
+ "index": 14,
+ "startSeconds": 142.9,
+ "endSeconds": 153.9,
+ "holdSeconds": 11,
+ "captionStart": 143.3,
+ "captionEnd": 153.5
+ },
+ {
+ "beatId": "B16",
+ "index": 15,
+ "startSeconds": 153.5,
+ "endSeconds": 167.5,
+ "holdSeconds": 14,
+ "captionStart": 153.9,
+ "captionEnd": 167.1
+ },
+ {
+ "beatId": "B17",
+ "index": 16,
+ "startSeconds": 167.1,
+ "endSeconds": 180.1,
+ "holdSeconds": 13,
+ "captionStart": 167.5,
+ "captionEnd": 179.7
+ },
+ {
+ "beatId": "B18",
+ "index": 17,
+ "startSeconds": 179.7,
+ "endSeconds": 192.7,
+ "holdSeconds": 13,
+ "captionStart": 180.1,
+ "captionEnd": 192.3
+ },
+ {
+ "beatId": "B19",
+ "index": 18,
+ "startSeconds": 192.3,
+ "endSeconds": 210.3,
+ "holdSeconds": 18,
+ "captionStart": 192.7,
+ "captionEnd": 209.9
+ },
+ {
+ "beatId": "B20",
+ "index": 19,
+ "startSeconds": 209.9,
+ "endSeconds": 218.9,
+ "holdSeconds": 9,
+ "captionStart": 210.3,
+ "captionEnd": 218.5
+ },
+ {
+ "beatId": "B21",
+ "index": 20,
+ "startSeconds": 218.5,
+ "endSeconds": 229.5,
+ "holdSeconds": 11,
+ "captionStart": 218.9,
+ "captionEnd": 229.5
+ }
+ ],
+ "holdSum": 237.5,
+ "transitions": 20,
+ "totalSeconds": 229.5
+ }
+}
diff --git a/media/hac-336/evidence/scene-map.json b/media/hac-336/evidence/scene-map.json
new file mode 100644
index 0000000..13b4357
--- /dev/null
+++ b/media/hac-336/evidence/scene-map.json
@@ -0,0 +1,397 @@
+{
+ "manifestId": "HAC-336-scene-map",
+ "revision": "r01",
+ "issue": "HAC-336",
+ "note": "Derived by media/hac-336/bin/build-input-manifest.mjs. Do not hand-edit.",
+ "generator": "media/hac-336/bin/build-input-manifest.mjs",
+ "totalSeconds": 229.5,
+ "scenes": [
+ {
+ "beatId": "B01",
+ "timestamp": "0:00-0:04",
+ "startSeconds": 0,
+ "endSeconds": 4,
+ "act": "PROBLEM",
+ "proofClass": "brand",
+ "proofClassMeaning": "Identity. No evidence is asserted.",
+ "scene": "IL-SCAF-010",
+ "sourceArtifact": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "sourceSha256": "0d238422b53d960ceb2b75f7871f042799bd8578969fd20d2944c7d84e080fa2",
+ "frame": "media/hac-335/exports/IL-SCAF-010-video-title-card-1920x1080.png",
+ "claimCommunicated": "Interlock. Evidence-bound coordination before shared-state mutation.",
+ "claims": [
+ "CL-002"
+ ]
+ },
+ {
+ "beatId": "B02",
+ "timestamp": "0:03-0:08",
+ "startSeconds": 3.6,
+ "endSeconds": 8.6,
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "proofClassMeaning": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "scene": "IL-PROOF-020 (intents)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg",
+ "sourceSha256": "567da3a0bbb76eace11b4829fd3c1935c6224acf409c2bcb6be4803f5efe09ac",
+ "frame": "media/hac-336/frames/B02-IL-PROOF-020-intents.png",
+ "claimCommunicated": "Two agents propose two changes. Each one is valid when you check it on its own.",
+ "claims": [
+ "CL-001"
+ ]
+ },
+ {
+ "beatId": "B03",
+ "timestamp": "0:08-0:13",
+ "startSeconds": 8.2,
+ "endSeconds": 13.7,
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "proofClassMeaning": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "scene": "IL-PROOF-020 (coupled)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg",
+ "sourceSha256": "4c512891c728aa84cf698d5f29b07a1be81781db326b7a316b2c332b969c6925",
+ "frame": "media/hac-336/frames/B03-IL-PROOF-020-coupled.png",
+ "claimCommunicated": "They write into one shared environment, so one bound applies to their joint outcome: sum of reserved must stay at or under 130.",
+ "claims": [
+ "CL-001",
+ "CL-002"
+ ]
+ },
+ {
+ "beatId": "B04",
+ "timestamp": "0:13-0:19",
+ "startSeconds": 13.3,
+ "endSeconds": 19.8,
+ "act": "PROBLEM",
+ "proofClass": "A",
+ "proofClassMeaning": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "scene": "IL-PROOF-020 (baseline)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg",
+ "sourceSha256": "9d62da859c43c74e6623f8267f0a542905168a1f1234d9542a9a7b1b89864ca3",
+ "frame": "media/hac-336/frames/B04-IL-PROOF-020-baseline.png",
+ "claimCommunicated": "Applied together with no coordination, the joint state is 140 against a bound of 130. Neither change is wrong. The composition is.",
+ "claims": [
+ "CL-003"
+ ]
+ },
+ {
+ "beatId": "B05",
+ "timestamp": "0:19-0:33",
+ "startSeconds": 19.4,
+ "endSeconds": 33.4,
+ "act": "CAUSAL",
+ "proofClass": "A",
+ "proofClassMeaning": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "scene": "IL-PROOF-010",
+ "sourceArtifact": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "sourceSha256": "cf9055667978d78a1a5f65816ac376f56722f5594eaab6abef229e8b3a4ab53a",
+ "frame": "media/hac-334/exports/IL-PROOF-010-causal-counterfactual-1920x1080-runhac330local.png",
+ "claimCommunicated": "Same intents, same environment. Interlock reads revision-bound co-change evidence before the mutation and decides WITHHOLD_SERIALIZE. The joint outcome is 120 against the same bound of 130, on 24 of 24 checks.",
+ "claims": [
+ "CL-002",
+ "CL-003",
+ "CL-004",
+ "CL-005",
+ "CL-027"
+ ]
+ },
+ {
+ "beatId": "B06",
+ "timestamp": "0:33-0:47",
+ "startSeconds": 33,
+ "endSeconds": 47,
+ "act": "CAUSAL",
+ "proofClass": "A",
+ "proofClassMeaning": "Controlled local experiment (HAC-330). Deterministic, local. No cloud runtime, no receipt, no protected target, no observer.",
+ "scene": "IL-PROOF-011",
+ "sourceArtifact": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "sourceSha256": "26ba70a6c1cda6f1645ae213e2149bced9541f92753968bc67b1cdd470a7c639",
+ "frame": "media/hac-334/exports/IL-PROOF-011-evidence-load-bearing-1920x1080-runhac330local.png",
+ "claimCommunicated": "Then the coupling signal is deliberately removed from the frozen evidence. Same decision core, same intents: the decision becomes ALLOW_PARALLEL and 140 against 130 returns. The evidence is load-bearing. Both arms are recorded results; nothing is re-run to produce this comparison.",
+ "claims": [
+ "CL-006",
+ "CL-007"
+ ]
+ },
+ {
+ "beatId": "B07",
+ "timestamp": "0:46-1:00",
+ "startSeconds": 46.6,
+ "endSeconds": 60.6,
+ "act": "UTILITY",
+ "proofClass": "EVAL",
+ "proofClassMeaning": "Controlled local evaluation (HAC-343). Bounded sixteen-scenario corpus, exhaustively enumerated.",
+ "scene": "IL-PROOF-021 (comparison)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg",
+ "sourceSha256": "b1c7e284dcca5a1a2e5f6bf557a8e03aabb6fcec3a6c2ca39501ffda447c8b05",
+ "frame": "media/hac-336/frames/B07-IL-PROOF-021-comparison.png",
+ "claimCommunicated": "On a frozen sixteen-scenario corpus, four coordination strategies. Uncoordinated keeps both independent opportunities parallel and leaves both cross-target hazards unsafe. A global lock blocks both hazards by eliminating concurrency: none of the two parallel opportunities survive. A credible per-target lock keeps both opportunities parallel and still misses both hazards, because a hazard spanning two lock keys is not visible to any per-key discipline.",
+ "claims": [
+ "CL-028",
+ "CL-029"
+ ]
+ },
+ {
+ "beatId": "B08",
+ "timestamp": "1:00-1:18",
+ "startSeconds": 60.2,
+ "endSeconds": 78.2,
+ "act": "UTILITY",
+ "proofClass": "EVAL",
+ "proofClassMeaning": "Controlled local evaluation (HAC-343). Bounded sixteen-scenario corpus, exhaustively enumerated.",
+ "scene": "IL-PROOF-021 (ablation)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg",
+ "sourceSha256": "40cbb5f977cbebe24663a6a39f3452a33f342befc17d8b8e840b9abf8d63aa81",
+ "frame": "media/hac-336/frames/B08-IL-PROOF-021-ablation.png",
+ "claimCommunicated": "Interlock preserved both known-safe parallel opportunities while withholding both evidence-supported cross-target hazards. Then the coupling evidence was deliberately removed from the frozen fixtures, with the intents unchanged. The same core reversed to ALLOW_PARALLEL and both invariants failed. Bounded to this corpus.",
+ "claims": [
+ "CL-028",
+ "CL-030",
+ "CL-031"
+ ]
+ },
+ {
+ "beatId": "B09",
+ "timestamp": "1:17-1:22",
+ "startSeconds": 77.8,
+ "endSeconds": 82.3,
+ "act": "RESET",
+ "proofClass": "transition",
+ "proofClassMeaning": "Proof-class reset. Asserts only that nothing crosses.",
+ "scene": "IL-SCAF-020 (reset)",
+ "sourceArtifact": "media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg",
+ "sourceSha256": "71c40a0df15799ffb85abd3017c9017e9b9336eb2fe2151a886b00e94dfdc7b3",
+ "frame": "media/hac-336/frames/B09-IL-SCAF-020-reset.png",
+ "claimCommunicated": "Different run. Different evidence. Nothing crosses.",
+ "claims": [
+ "CL-009",
+ "CL-020",
+ "CL-027"
+ ]
+ },
+ {
+ "beatId": "B10",
+ "timestamp": "1:21-1:33",
+ "startSeconds": 81.9,
+ "endSeconds": 93.9,
+ "act": "CLOUD",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture gemini-adk-attribution",
+ "sourceArtifact": "experiments/hac-324/frames/scene-gemini-adk-attribution.png",
+ "sourceSha256": "ee007e9c29c5fea3719fcec6d63d68c1ead5af33fceef4d90a8d1775f24f85c0",
+ "frame": "media/hac-336/frames/B10-scene-gemini-adk-attribution.png",
+ "claimCommunicated": "This is the deployed Cloud Run agent reading back its own configuration: gemini-3.5-flash, reached through Google ADK 1.35.1 with Vertex AI access, from a pinned image built at the runtime source commit.",
+ "claims": [
+ "FR-001",
+ "FR-002"
+ ]
+ },
+ {
+ "beatId": "B11",
+ "timestamp": "1:33-1:43",
+ "startSeconds": 93.5,
+ "endSeconds": 103.5,
+ "act": "CLOUD",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture cloud-run-topology",
+ "sourceArtifact": "experiments/hac-324/frames/scene-cloud-run-topology.png",
+ "sourceSha256": "ae6bd02c7f4ebf0dc2b55ad76eb5b91d03ec32eba672672433480dd75b80e779",
+ "frame": "media/hac-336/frames/B11-scene-cloud-run-topology.png",
+ "claimCommunicated": "Three Cloud Run services carried the run in us-central1: the ADK agent, the Interlock MCP proxy, and the protected target.",
+ "claims": [
+ "FR-003"
+ ]
+ },
+ {
+ "beatId": "B12",
+ "timestamp": "1:43-1:58",
+ "startSeconds": 103.1,
+ "endSeconds": 118.1,
+ "act": "CLOUD",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture agent-traversal",
+ "sourceArtifact": "experiments/hac-324/frames/scene-agent-traversal.png",
+ "sourceSha256": "102cafb35c0c02f9df1d9518e3be9fd9f361480f12d6f7bb37b3f1e640a1a490",
+ "frame": "media/hac-336/frames/B12-scene-agent-traversal.png",
+ "claimCommunicated": "The agent traversal, executed while this frame was being captured. Interlock returned ALLOW with a receipt digest, under one correlation id. Three fail-closed controls were recorded on the same run: a forged identity header returned 403, an invalid bearer token returned 401, and a direct call to the target with no receipt returned 403.",
+ "claims": [
+ "FR-004",
+ "FR-005",
+ "FR-009"
+ ]
+ },
+ {
+ "beatId": "B13",
+ "timestamp": "1:57-2:09",
+ "startSeconds": 117.7,
+ "endSeconds": 129.7,
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture receipt-mutation-observation (executed)",
+ "sourceArtifact": "experiments/hac-324/frames/scene-receipt-mutation-observation.png",
+ "sourceSha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c",
+ "frame": "media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png",
+ "claimCommunicated": "The protected mutation is EXECUTED against that receipt. The target moved from one revision to the next, and the resulting invariant is a total of 105 against the bound of 130.",
+ "claims": [
+ "FR-005",
+ "FR-006"
+ ]
+ },
+ {
+ "beatId": "B14",
+ "timestamp": "2:09-2:23",
+ "startSeconds": 129.3,
+ "endSeconds": 143.3,
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture receipt-mutation-observation (observed)",
+ "sourceArtifact": "experiments/hac-324/frames/scene-receipt-mutation-observation.png",
+ "sourceSha256": "381c629fa64bbafec37b07ad4b22c8d074cb2f67a24b2f25e1b2fddd54860d8c",
+ "frame": "media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png",
+ "claimCommunicated": "EXECUTED is not OBSERVED. A separately authenticated keyless observer service account read the target back at the post-mutation revision and observed alpha at 45. Two records, not one: the model's own output declares nothing.",
+ "claims": [
+ "FR-007",
+ "FR-008"
+ ]
+ },
+ {
+ "beatId": "B15",
+ "timestamp": "2:22-2:33",
+ "startSeconds": 142.9,
+ "endSeconds": 153.9,
+ "act": "EXECUTED_OBSERVED",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "filmed capture cloud-logging-correlation",
+ "sourceArtifact": "experiments/hac-324/frames/scene-cloud-logging-correlation.png",
+ "sourceSha256": "ccd916391f6a8f38dde906477c6e16423db8a509bde55f64b59945517087fee8",
+ "frame": "media/hac-336/frames/B15-scene-cloud-logging-correlation.png",
+ "claimCommunicated": "Cloud Logging, filtered to that same correlation id: the proxy request, the agent identity as the platform verified it, and the Cloud Run revision that served it.",
+ "claims": [
+ "FR-010"
+ ]
+ },
+ {
+ "beatId": "B16",
+ "timestamp": "2:33-2:47",
+ "startSeconds": 153.5,
+ "endSeconds": 167.5,
+ "act": "ARCHITECTURE",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "IL-DIAG-020 (path)",
+ "sourceArtifact": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg",
+ "sourceSha256": "b67d8ccf796ff761d5f74491e99302689cb23049e1b4310dea14b6880b837e40",
+ "frame": "media/hac-336/frames/B16-IL-DIAG-020-path.png",
+ "claimCommunicated": "Where Interlock sits. The agent calls a tool through the Interlock MCP proxy. Interlock reads revision-bound composition evidence before the mutation and returns a decision with a receipt. The protected target refused a direct call that carried no receipt.",
+ "claims": [
+ "FR-002",
+ "FR-003",
+ "FR-004",
+ "FR-009"
+ ]
+ },
+ {
+ "beatId": "B17",
+ "timestamp": "2:47-3:00",
+ "startSeconds": 167.1,
+ "endSeconds": 180.1,
+ "act": "ARCHITECTURE",
+ "proofClass": "B",
+ "proofClassMeaning": "Google Cloud participation (HAC-324 authoritative filmed run, claim-equivalent to the frozen HAC-340 reference).",
+ "scene": "IL-DIAG-020 (boundary)",
+ "sourceArtifact": "media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg",
+ "sourceSha256": "222bc44997e9732b28c047e493a29aeae9e3f56e99bf7e627562bed5489c01ef",
+ "frame": "media/hac-336/frames/B17-IL-DIAG-020-boundary.png",
+ "claimCommunicated": "Cloud Run IAM establishes transport provenance. It does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock. Agent Runtime, Agent Gateway and CONTENT_AUTHZ were not on this path.",
+ "claims": [
+ "CL-015",
+ "CL-021",
+ "FR-011"
+ ]
+ },
+ {
+ "beatId": "B18",
+ "timestamp": "2:59-3:12",
+ "startSeconds": 179.7,
+ "endSeconds": 192.7,
+ "act": "BOUNDS",
+ "proofClass": "bounds",
+ "proofClassMeaning": "Claim boundary. States what is not claimed.",
+ "scene": "IL-PROOF-022 (bounds)",
+ "sourceArtifact": "media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg",
+ "sourceSha256": "b6e89f22fea263976acc24a696adbfc3c71d63abb3ed78c6bbf7fe296b9e5ed1",
+ "frame": "media/hac-336/frames/B18-IL-PROOF-022-bounds.png",
+ "claimCommunicated": "What the evaluation is bounded by. Sixteen frozen scenarios across two hazard families, enumerated exhaustively: exact counts, not estimates, and no interval or significance is claimed. Every inadmissible-evidence scenario failed closed, and on two of eight the refusal reason disagreed with the frozen corpus; the corpus is frozen and stays wrong on the record.",
+ "claims": [
+ "CL-031"
+ ]
+ },
+ {
+ "beatId": "B19",
+ "timestamp": "3:12-3:30",
+ "startSeconds": 192.3,
+ "endSeconds": 210.3,
+ "act": "BOUNDS",
+ "proofClass": "bounds",
+ "proofClassMeaning": "Claim boundary. States what is not claimed.",
+ "scene": "IL-PROOF-014",
+ "sourceArtifact": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "sourceSha256": "11f1d61ee8445d1f0800d7895bf7a54335613c3d462a4eab8e517843f1d0b1a9",
+ "frame": "media/hac-334/exports/IL-PROOF-014-claim-boundary-1920x1080.png",
+ "claimCommunicated": "And what is not claimed. Two runs, two proof classes; neither is evidence for the other. No exactly-once execution, no restart safety, no target-side atomicity, no production readiness. ALLOW is a decision, not a verification or an authorization, and WITHHOLD_SERIALIZE is not human approval.",
+ "claims": [
+ "CL-009",
+ "CL-014",
+ "CL-020",
+ "CL-021",
+ "CL-022",
+ "CL-023",
+ "CL-024",
+ "CL-027"
+ ]
+ },
+ {
+ "beatId": "B20",
+ "timestamp": "3:29-3:38",
+ "startSeconds": 209.9,
+ "endSeconds": 218.9,
+ "act": "CLOSE",
+ "proofClass": "close",
+ "proofClassMeaning": "Close. Restates the thesis and where to verify it.",
+ "scene": "IL-SCAF-021 (thesis)",
+ "sourceArtifact": "media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg",
+ "sourceSha256": "6274f887c139dfc4f9151d8af6f766efe230707b481671bdd4015e147def7175",
+ "frame": "media/hac-336/frames/B20-IL-SCAF-021-thesis.png",
+ "claimCommunicated": "Valid alone does not mean safe together. Interlock reads revision-bound evidence about the composition before the mutation: parallel when the evidence supports it, serialized when it does not.",
+ "claims": [
+ "CL-001",
+ "CL-002"
+ ]
+ },
+ {
+ "beatId": "B21",
+ "timestamp": "3:38-3:49",
+ "startSeconds": 218.5,
+ "endSeconds": 229.5,
+ "act": "CLOSE",
+ "proofClass": "close",
+ "proofClassMeaning": "Close. Restates the thesis and where to verify it.",
+ "scene": "IL-SCAF-011",
+ "sourceArtifact": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "sourceSha256": "0fd186af922178a972e049f9d70351ceae62e63494270b182dbc8a9ec5ae1946",
+ "frame": "media/hac-335/exports/IL-SCAF-011-video-end-card-1920x1080.png",
+ "claimCommunicated": "Every number in this video is committed. The Google Cloud packet is readable without an account, pinned at one commit, and its bytes hash to a digest any reader can recompute.",
+ "claims": [
+ "CL-017",
+ "CL-009"
+ ]
+ }
+ ]
+}
diff --git a/media/hac-336/exports/IL-MOT-020-interlock-final-cut-1920x1080.mp4 b/media/hac-336/exports/IL-MOT-020-interlock-final-cut-1920x1080.mp4
new file mode 100644
index 0000000..2685fa3
Binary files /dev/null and b/media/hac-336/exports/IL-MOT-020-interlock-final-cut-1920x1080.mp4 differ
diff --git a/media/hac-336/frames/B02-IL-PROOF-020-intents.png b/media/hac-336/frames/B02-IL-PROOF-020-intents.png
new file mode 100644
index 0000000..24defd0
Binary files /dev/null and b/media/hac-336/frames/B02-IL-PROOF-020-intents.png differ
diff --git a/media/hac-336/frames/B03-IL-PROOF-020-coupled.png b/media/hac-336/frames/B03-IL-PROOF-020-coupled.png
new file mode 100644
index 0000000..961504c
Binary files /dev/null and b/media/hac-336/frames/B03-IL-PROOF-020-coupled.png differ
diff --git a/media/hac-336/frames/B04-IL-PROOF-020-baseline.png b/media/hac-336/frames/B04-IL-PROOF-020-baseline.png
new file mode 100644
index 0000000..1d269ba
Binary files /dev/null and b/media/hac-336/frames/B04-IL-PROOF-020-baseline.png differ
diff --git a/media/hac-336/frames/B07-IL-PROOF-021-comparison.png b/media/hac-336/frames/B07-IL-PROOF-021-comparison.png
new file mode 100644
index 0000000..5f2ed6d
Binary files /dev/null and b/media/hac-336/frames/B07-IL-PROOF-021-comparison.png differ
diff --git a/media/hac-336/frames/B08-IL-PROOF-021-ablation.png b/media/hac-336/frames/B08-IL-PROOF-021-ablation.png
new file mode 100644
index 0000000..c1f13f1
Binary files /dev/null and b/media/hac-336/frames/B08-IL-PROOF-021-ablation.png differ
diff --git a/media/hac-336/frames/B09-IL-SCAF-020-reset.png b/media/hac-336/frames/B09-IL-SCAF-020-reset.png
new file mode 100644
index 0000000..c77a6a8
Binary files /dev/null and b/media/hac-336/frames/B09-IL-SCAF-020-reset.png differ
diff --git a/media/hac-336/frames/B10-scene-gemini-adk-attribution.png b/media/hac-336/frames/B10-scene-gemini-adk-attribution.png
new file mode 100644
index 0000000..03a34c7
Binary files /dev/null and b/media/hac-336/frames/B10-scene-gemini-adk-attribution.png differ
diff --git a/media/hac-336/frames/B11-scene-cloud-run-topology.png b/media/hac-336/frames/B11-scene-cloud-run-topology.png
new file mode 100644
index 0000000..f87b6f1
Binary files /dev/null and b/media/hac-336/frames/B11-scene-cloud-run-topology.png differ
diff --git a/media/hac-336/frames/B12-scene-agent-traversal.png b/media/hac-336/frames/B12-scene-agent-traversal.png
new file mode 100644
index 0000000..2352fcf
Binary files /dev/null and b/media/hac-336/frames/B12-scene-agent-traversal.png differ
diff --git a/media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png b/media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png
new file mode 100644
index 0000000..5a61f85
Binary files /dev/null and b/media/hac-336/frames/B13-scene-receipt-mutation-observation-executed.png differ
diff --git a/media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png b/media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png
new file mode 100644
index 0000000..fedf053
Binary files /dev/null and b/media/hac-336/frames/B14-scene-receipt-mutation-observation-observed.png differ
diff --git a/media/hac-336/frames/B15-scene-cloud-logging-correlation.png b/media/hac-336/frames/B15-scene-cloud-logging-correlation.png
new file mode 100644
index 0000000..b4c5917
Binary files /dev/null and b/media/hac-336/frames/B15-scene-cloud-logging-correlation.png differ
diff --git a/media/hac-336/frames/B16-IL-DIAG-020-path.png b/media/hac-336/frames/B16-IL-DIAG-020-path.png
new file mode 100644
index 0000000..9b07ff1
Binary files /dev/null and b/media/hac-336/frames/B16-IL-DIAG-020-path.png differ
diff --git a/media/hac-336/frames/B17-IL-DIAG-020-boundary.png b/media/hac-336/frames/B17-IL-DIAG-020-boundary.png
new file mode 100644
index 0000000..259523f
Binary files /dev/null and b/media/hac-336/frames/B17-IL-DIAG-020-boundary.png differ
diff --git a/media/hac-336/frames/B18-IL-PROOF-022-bounds.png b/media/hac-336/frames/B18-IL-PROOF-022-bounds.png
new file mode 100644
index 0000000..52edf40
Binary files /dev/null and b/media/hac-336/frames/B18-IL-PROOF-022-bounds.png differ
diff --git a/media/hac-336/frames/B20-IL-SCAF-021-thesis.png b/media/hac-336/frames/B20-IL-SCAF-021-thesis.png
new file mode 100644
index 0000000..84c38c1
Binary files /dev/null and b/media/hac-336/frames/B20-IL-SCAF-021-thesis.png differ
diff --git a/media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg b/media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg
new file mode 100644
index 0000000..0fa7bc2
--- /dev/null
+++ b/media/hac-336/masters/IL-DIAG-020-filmed-run-path-boundary.svg
@@ -0,0 +1 @@
+IL-DIAG-020 What the deployment does and does not establish - boundary GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN. Frozen evidence: ilk-hac340-cloud-1787536029323 interlock-hac340-proxy-00001-s76 runtime source ae6d0d3c405b Non-claim: Cloud Run IAM establishes transport provenance only; internal Interlock roles are not Google-managed identities GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN IL-DIAG-020 What the deployment does and does not establish TRANSPORT PROVENANCE oidc-id-token/platform-verified:email Cloud Run IAM establishes which platform-verified identity made the call. APPLICATION / RECEIPT PROVENANCE sha256:7fb65efe30d89bab241d1f5... The Interlock decision and its receipt. Distinct from transport provenance, and not derived from it. THESE DO NOT COLLAPSE Cloud Run IAM does not establish Google-managed proposer, reviewer or authorizer roles inside Interlock. ABSENT FROM THIS DEPLOYMENT Agent Runtime Agent Gateway CONTENT_AUTHZ Frozen evidence: ilk-hac340-cloud-1787536029323 interlock-hac340-proxy-00001-s76 runtime source ae6d0d3c405b Non-claim: Cloud Run IAM establishes transport provenance only; internal Interlock roles are not Google-managed identities
diff --git a/media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg b/media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg
new file mode 100644
index 0000000..2071a7b
--- /dev/null
+++ b/media/hac-336/masters/IL-DIAG-020-filmed-run-path-path.svg
@@ -0,0 +1 @@
+IL-DIAG-020 Where Interlock sits on the recorded path - path GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN. Frozen evidence: ilk-hac340-cloud-1787536029323 interlock-hac340-proxy-00001-s76 runtime source ae6d0d3c405b Non-claim: Cloud Run IAM establishes transport provenance only; internal Interlock roles are not Google-managed identities GOOGLE CLOUD PARTICIPATION - AUTHORITATIVE FILMED RUN IL-DIAG-020 Where Interlock sits on the recorded path 01 Gemini gemini-3.5-flash 02 Agent framework Google ADK 1.35.1 / Vertex AI 03 Cloud Run agent us-central1 / interlock-hac340-agent-00001-hbk 04 Interlock MCP proxy interlock-hac340-proxy-00001-s76 05 Decision + receipt ALLOW / rcpt-e742d4f3-85d4... 06 Protected target interlock-hac340-target-00001-sng 07 Independent observer keyless observer service account 08 Cloud Logging correlated by ilk-hac340-cloud-1787536029323 Interlock reads revision-bound composition evidence before the mutation and gates it with a decision and a receipt. The protected target refused a direct call that carried no receipt. A direct target call with no receipt returned 403. Frozen evidence: ilk-hac340-cloud-1787536029323 interlock-hac340-proxy-00001-s76 runtime source ae6d0d3c405b Non-claim: Cloud Run IAM establishes transport provenance only; internal Interlock roles are not Google-managed identities
diff --git a/media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg b/media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg
new file mode 100644
index 0000000..17429db
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-020-composition-hazard-baseline.svg
@@ -0,0 +1 @@
+IL-PROOF-020 Applied together, with no coordination - baseline CONTROLLED LOCAL EXPERIMENT. Frozen evidence: baseline 140 > 130 sum(services[].reserved) <= 130 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it CONTROLLED LOCAL EXPERIMENT IL-PROOF-020 Applied together, with no coordination BASELINE ARM - INTERLOCK DISABLED 140 > 130 INVALID JOINT STATE JOINT BOUND sum(services[].reserved) <= 130 Neither change is wrong. The composition is. Both intents passed their own precondition at the base revision. The failure only exists jointly. Frozen evidence: baseline 140 > 130 sum(services[].reserved) <= 130 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it
diff --git a/media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg b/media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg
new file mode 100644
index 0000000..a50a4e6
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-020-composition-hazard-coupled.svg
@@ -0,0 +1 @@
+IL-PROOF-020 One shared environment, one joint bound - coupled CONTROLLED LOCAL EXPERIMENT. Frozen evidence: sum(services[].reserved) <= 130 checks 24/24 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it CONTROLLED LOCAL EXPERIMENT IL-PROOF-020 One shared environment, one joint bound Intent A LOCALLY VALID Intent B LOCALLY VALID Shared environment COUPLED JOINT BOUND sum(services[].reserved) <= 130 Revision-bound, and derived from the commit history rather than declared. They write into the same environment, so the constraint applies to their joint outcome, not to either change alone. Frozen evidence: sum(services[].reserved) <= 130 checks 24/24 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it
diff --git a/media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg b/media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg
new file mode 100644
index 0000000..1859b44
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-020-composition-hazard-intents.svg
@@ -0,0 +1 @@
+IL-PROOF-020 Two changes, each valid on its own - intents CONTROLLED LOCAL EXPERIMENT. Frozen evidence: sum(services[].reserved) <= 130 checks 24/24 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it CONTROLLED LOCAL EXPERIMENT IL-PROOF-020 Two changes, each valid on its own Intent A LOCALLY VALID Intent B LOCALLY VALID Each intent is checked in its own scope and passes. Nothing is coordinated yet. Frozen evidence: sum(services[].reserved) <= 130 checks 24/24 Non-claim: this experiment ran locally, not on Google Cloud; no cloud runtime, receipt, protected target or observer exists in it
diff --git a/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg b/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg
new file mode 100644
index 0000000..303d26a
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg
@@ -0,0 +1 @@
+IL-PROOF-021 The same result, with its evidence removed - ablation CONTROLLED LOCAL EVALUATION. Frozen evidence: interlock 0/2/2/2 evidence present 0/2 invalid evidence removed 2/2 invalid Non-claim: bounded to this sixteen-scenario corpus; Interlock is not universally safe and not safer than locking; no interval or significance is claimed CONTROLLED LOCAL EVALUATION IL-PROOF-021 The same result, with its evidence removed STRATEGY Cross-target hazards unsafe Independent opportunities kept parallel Uncoordinated 2/2 2/2 Global lock 0/2 0/2 Per-target lock 2/2 2/2 Interlock 0/2 2/2 per-target lock: same-target serialized 2/2 cross-target parallelized 4/4 cross-target hazards missed 2/2 EVIDENCE ABLATION - SAME INTENTS, SAME DECISION CORE Interlock + coupling evidence present 0/2 invalid ALLOW_SERIALIZED / WITHHOLD_SERIALIZE Interlock + coupling evidence removed 2/2 invalid ALLOW_PARALLEL Interlock's safety is evidence-derived. With revision-bound composition evidence present it withheld both hazardous compositions while retaining both safe parallel opportunities. When that evidence was deliberately removed, the decision reversed and both invariants failed. Frozen evidence: interlock 0/2/2/2 evidence present 0/2 invalid evidence removed 2/2 invalid Non-claim: bounded to this sixteen-scenario corpus; Interlock is not universally safe and not safer than locking; no interval or significance is claimed
diff --git a/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg b/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg
new file mode 100644
index 0000000..acd495f
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-comparison.svg
@@ -0,0 +1 @@
+IL-PROOF-021 Four coordination strategies, one frozen corpus - comparison CONTROLLED LOCAL EVALUATION. Frozen evidence: Uncoordinated 2/2/2/2 Global-lock 0/2/0/2 Per-target-lock 2/2/2/2 Interlock 0/2/2/2 Non-claim: bounded to this sixteen-scenario corpus; Interlock is not universally safe and not safer than locking; no interval or significance is claimed CONTROLLED LOCAL EVALUATION IL-PROOF-021 Four coordination strategies, one frozen corpus STRATEGY Cross-target hazards unsafe Independent opportunities kept parallel Uncoordinated 2/2 2/2 Global lock 0/2 0/2 Per-target lock 2/2 2/2 Interlock 0/2 2/2 IS THE PER-TARGET LOCK A REAL LOCK? same-target contention serialized 2/2 cross-target pairs parallelized 4/4 cross-target hazards missed 2/2 It locked exactly what a lock can see. A composition hazard spanning two lock keys is not visible to any per-key discipline. Global locking preserved safety by eliminating concurrency. Per-target locking preserved concurrency but missed every cross-target composition hazard. Interlock is the only arm in both left-hand columns at once on this corpus. Frozen evidence: Uncoordinated 2/2/2/2 Global-lock 0/2/0/2 Per-target-lock 2/2/2/2 Interlock 0/2/2/2 Non-claim: bounded to this sixteen-scenario corpus; Interlock is not universally safe and not safer than locking; no interval or significance is claimed
diff --git a/media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg b/media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg
new file mode 100644
index 0000000..2a59b8a
--- /dev/null
+++ b/media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg
@@ -0,0 +1 @@
+IL-PROOF-022 What this evaluation is bounded by - bounds CONTROLLED LOCAL EVALUATION - BOUNDS. Frozen evidence: matrix 128/128 families budget+registry failed closed 8/8 reason agreement 6/8 Non-claim: no population estimate, no interval, no significance; exactly-once, restart safety, target-side atomicity and production readiness were not tested CONTROLLED LOCAL EVALUATION - BOUNDS IL-PROOF-022 What this evaluation is bounded by THE CORPUS Sixteen scenarios across two hazard families. Every rate is a property of this corpus and is not a population estimate. No confidence intervals: the decision core is deterministic and the corpus is enumerated exhaustively, so there is no sampling process. NOT TESTED, AND NOT CLAIMED no same-target atomicity and target-side compare-and-set no exactly-once execution no restart and recovery behaviour no joint human authorization no Agent Runtime and Agent Gateway participation NEGATIVE FINDING, RETAINED inadmissible evidence failed closed 8/8 exact refusal-reason agreement 6/8 Every inadmissible-evidence scenario failed closed and applied no mutation. Exact refusal-reason agreement with the frozen corpus was lower: two scenarios refused with HISTORY_NOT_MINED where the corpus predicted HISTORY_EVIDENCE_UNAVAILABLE. The envelope's completeness state is NOT_MINED, so the decision core took the correct branch and the corpus expectation was mistaken. Neither mismatch permitted a mutation. The corpus is frozen and stays wrong on the record. Frozen evidence: matrix 128/128 families budget+registry failed closed 8/8 reason agreement 6/8 Non-claim: no population estimate, no interval, no significance; exactly-once, restart safety, target-side atomicity and production readiness were not tested
diff --git a/media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg b/media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg
new file mode 100644
index 0000000..94470d2
--- /dev/null
+++ b/media/hac-336/masters/IL-SCAF-020-proof-class-reset-reset.svg
@@ -0,0 +1 @@
+IL-SCAF-020 Different run. Different evidence. - reset PROOF-CLASS RESET. Frozen evidence: two proof classes, recorded separately Non-claim: no claim crosses between the two runs; the Google Cloud run does not reproduce the controlled local counterfactual PROOF-CLASS RESET IL-SCAF-020 Different run. Different evidence. WHAT YOU HAVE SEEN Controlled local experiment Deterministic and local. No cloud runtime, no receipt, no protected target, no independent observer. WHAT COMES NEXT Google Cloud participation One recorded traversal on real Google Cloud infrastructure, filmed while it ran. A different run, with its own evidence. Nothing crosses. Frozen evidence: two proof classes, recorded separately Non-claim: no claim crosses between the two runs; the Google Cloud run does not reproduce the controlled local counterfactual
diff --git a/media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg b/media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg
new file mode 100644
index 0000000..a4565fe
--- /dev/null
+++ b/media/hac-336/masters/IL-SCAF-021-closing-thesis-thesis.svg
@@ -0,0 +1 @@
+IL-SCAF-021 Valid alone does not mean safe together. - thesis CLOSE. Frozen evidence: 140 > 130 120 <= 130 140 > 130 Non-claim: no safety, security, verification or production-readiness guarantee is claimed CLOSE IL-SCAF-021 Valid alone does not mean safe together. Interlock reads revision-bound evidence about the composition before the mutation. Parallel when the evidence supports the composition. Serialized when it does not. The evidence is load-bearing: remove it and the decision reverses. Frozen evidence: 140 > 130 120 <= 130 140 > 130 Non-claim: no safety, security, verification or production-readiness guarantee is claimed
diff --git a/package.json b/package.json
index 1bd53a1..ed04ca5 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"check:packet:s2": "node experiments/hac-326/bin/verify-packet.mjs",
"check:packet:eval": "pnpm run build && node experiments/hac-343/bin/verify-packet.mjs",
"check:filmed-run": "node experiments/hac-324/bin/verify-filmed-run.mjs",
- "check": "pnpm run check:provenance && pnpm run check:packet && pnpm run check:packet:s2 && pnpm run check:packet:public && pnpm run check:packet:eval && pnpm run check:filmed-run && pnpm run check:storyboard && pnpm run check:cockpit && pnpm run check:visuals && pnpm run check:identity && pnpm run check:package",
+ "check": "pnpm run check:provenance && pnpm run check:packet && pnpm run check:packet:s2 && pnpm run check:packet:public && pnpm run check:packet:eval && pnpm run check:filmed-run && pnpm run check:storyboard && pnpm run check:cockpit && pnpm run check:visuals && pnpm run check:identity && pnpm run check:package && pnpm run check:film",
"check:cockpit": "node media/hac-341/bin/verify-cockpit.mjs",
"check:cockpit:visual": "node media/hac-341/bin/verify-cockpit-visual.mjs",
"check:package": "node media/hac-335/bin/verify-package.mjs",
@@ -33,7 +33,11 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"hac330": "node experiments/hac-330/bin/run-experiment.mjs",
- "hac326": "pnpm run build && node experiments/hac-326/bin/run-experiment.mjs"
+ "hac326": "pnpm run build && node experiments/hac-326/bin/run-experiment.mjs",
+ "check:film": "node media/hac-336/bin/verify-film.mjs",
+ "film:build": "node media/hac-336/bin/build-boards.mjs && node media/hac-336/bin/build-frames.mjs && node media/hac-336/bin/build-captions.mjs && node media/hac-336/bin/build-input-manifest.mjs",
+ "film:derive": "node media/hac-336/bin/build-boards.mjs && node media/hac-336/bin/build-captions.mjs && node media/hac-336/bin/build-input-manifest.mjs",
+ "film:render": "node media/hac-336/bin/build-video.mjs"
},
"devDependencies": {
"@resvg/resvg-js": "2.6.2",
diff --git a/test/hac-336-film-gates.test.mjs b/test/hac-336-film-gates.test.mjs
new file mode 100644
index 0000000..5a657c9
--- /dev/null
+++ b/test/hac-336-film-gates.test.mjs
@@ -0,0 +1,367 @@
+/**
+ * Proves the HAC-336 film gate fails on the defects it exists to catch.
+ *
+ * A media gate is unusually easy to write and unusually hard to trust. Almost
+ * everything it inspects — a digest, a duration, a run id — is correct on the
+ * day it is written, so a check that silently stopped working would go on
+ * printing PASS indefinitely. Each case below breaks exactly one property in a
+ * copy of the repository and expects the gate to name it.
+ *
+ * The cases are chosen by consequence, not by coverage. The ones that matter
+ * most are the four a judge could be misled by: the two proof classes bleeding
+ * into each other, filmed evidence that is not the filmed evidence, a claim
+ * escaping its ledger, and a cut that quietly runs past four minutes.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { cpSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/*
+ * The gate reads across four issues' evidence, so the scratch copy carries all
+ * of them. `scripts/` comes too: the naming grammar is a repository-wide
+ * contract the builders import.
+ */
+const NEEDED = [
+ 'media/hac-336', 'media/hac-335', 'media/hac-334', 'media/hac-333',
+ 'experiments/hac-324', 'experiments/hac-330', 'experiments/hac-342', 'experiments/hac-343',
+ 'scripts', 'package.json',
+];
+const GATE = 'media/hac-336/bin/verify-film.mjs';
+const CUT = 'media/hac-336/evidence/cut.json';
+const FRAMES = 'media/hac-336/evidence/frame-manifest.json';
+const RENDER = 'media/hac-336/evidence/render-manifest.json';
+const INPUTS = 'media/hac-336/evidence/input-manifest.json';
+
+let pristine;
+const scratch = [];
+
+beforeAll(() => {
+ pristine = mkdtempSync(join(tmpdir(), 'hac336-pristine-'));
+ for (const rel of NEEDED) cpSync(join(repoRoot, rel), join(pristine, rel), { recursive: true });
+});
+afterAll(() => {
+ for (const d of [pristine, ...scratch]) rmSync(d, { recursive: true, force: true });
+});
+
+const run = (dir) => {
+ const r = spawnSync(process.execPath, [join(dir, GATE)], { encoding: 'utf8' });
+ return { code: r.status, out: `${r.stdout}${r.stderr}` };
+};
+
+function perturbed(mutate) {
+ const dir = mkdtempSync(join(tmpdir(), 'hac336-case-'));
+ scratch.push(dir);
+ cpSync(pristine, dir, { recursive: true });
+ const api = {
+ dir,
+ json: (f) => JSON.parse(readFileSync(join(dir, f), 'utf8')),
+ writeJson: (f, o) => writeFileSync(join(dir, f), `${JSON.stringify(o, null, 2)}\n`),
+ text: (f) => readFileSync(join(dir, f), 'utf8'),
+ write: (f, s) => writeFileSync(join(dir, f), s),
+ };
+ mutate(api);
+ return run(dir);
+}
+
+describe('the gate accepts the cut as assembled', () => {
+ it('passes unmodified', () => {
+ const r = run(pristine);
+ expect(r.out).toContain('HAC-336 film gate PASS');
+ expect(r.code).toBe(0);
+ });
+});
+
+describe('the two proof classes cannot bleed into each other', () => {
+ it('fails when Google Cloud material moves ahead of the proof-class reset', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ const cloud = cut.beats.findIndex((b) => b.proofClass === 'B');
+ const reset = cut.beats.findIndex((b) => b.proofClass === 'transition');
+ const [beat] = cut.beats.splice(cloud, 1);
+ cut.beats.splice(reset, 0, beat);
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/Google Cloud material appears before the proof-class reset/);
+ });
+
+ it('fails when the proof-class reset is removed entirely', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats = cut.beats.filter((b) => b.proofClass !== 'transition');
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/expected exactly one proof-class reset beat/);
+ });
+
+ it('fails when a filmed capture is relabelled as controlled local evidence', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats.find((b) => b.source.kind === 'capture').proofClass = 'A';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/presented under a proof class other than B/);
+ });
+
+ it('fails when the frozen reference run is named beside filmed footage', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ const beat = cut.beats.find((b) => b.proofClass === 'B');
+ beat.narration += ' Recorded under run ilk-hac340-cloud-1786730369123.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/frozen reference run/);
+ });
+});
+
+describe('filmed evidence has to be the filmed evidence', () => {
+ it('fails when a capture frame cites a digest the capture manifest never promoted', () => {
+ const r = perturbed((p) => {
+ const frames = p.json(FRAMES);
+ frames.frames.find((f) => f.kind === 'capture').sourceSha256 = 'a'.repeat(64);
+ p.writeJson(FRAMES, frames);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/the capture manifest promoted/);
+ });
+
+ it('fails when the committed capture bytes no longer hash to the promoted digest', () => {
+ const r = perturbed((p) => {
+ p.write('experiments/hac-324/frames/scene-agent-traversal.png', 'not a capture');
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/not the frame that was filmed/);
+ });
+
+ it('fails when a promoted scene is dropped from the cut without being recorded', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats = cut.beats.filter((b) => b.source.sceneId !== 'cloud-logging-correlation');
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/promoted capture scenes are absent from the cut/);
+ });
+
+ it('fails when a crop is widened in the cut but not in the rendered frame', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats.find((b) => b.source.kind === 'capture').source.crop.h += 40;
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/differs from the crop declared in the cut/);
+ });
+});
+
+describe('claims cannot escape their ledger', () => {
+ it('fails when a beat cites a claim row that does not exist', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].claims = ['CL-999'];
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/CL-999/);
+ });
+
+ it('fails when narration asserts a forbidden headline', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].narration = 'Interlock is 0% unsafe and prevents composition hazards.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/0% unsafe headline/);
+ });
+
+ it('fails when narration claims production readiness', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].narration = 'Interlock is production-ready today.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/production readiness was not tested/);
+ });
+
+ it('fails when narration implies Agent Runtime participated', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].narration = 'The traversal ran through Agent Runtime on Vertex AI.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/Agent Runtime did not participate/);
+ });
+
+ it('fails when narration claims exactly-once execution', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].narration = 'The receipt gives exactly-once execution across restarts.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/exactly-once is not claimed/);
+ });
+
+ it('fails when a filmed-run claim row loses the evidence it points at', () => {
+ const r = perturbed((p) => {
+ const rec = p.json('experiments/hac-324/evidence/filmed-run.json');
+ delete rec.protectedMutation.invariant.detail;
+ p.writeJson('experiments/hac-324/evidence/filmed-run.json', rec);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/no longer resolves/);
+ });
+});
+
+describe('the bounded evaluation keeps its ablation control', () => {
+ it('fails when the ablation beat is moved away from the comparison', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ const i = cut.beats.findIndex((b) => b.source.state === 'ablation');
+ const [beat] = cut.beats.splice(i, 1);
+ cut.beats.splice(cut.beats.length - 1, 0, beat);
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/one contiguous visual unit/);
+ });
+
+ it('fails when the ablation board loses an arm from the comparison', () => {
+ const r = perturbed((p) => {
+ const f = 'media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg';
+ p.write(f, p.text(f).replace(/Per-target lock/g, 'Redacted'));
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/omits the Per-target lock arm/);
+ });
+
+ it('fails when the per-target lock credibility strip is removed', () => {
+ const r = perturbed((p) => {
+ const f = 'media/hac-336/masters/IL-PROOF-021-bounded-four-arm-comparison-ablation.svg';
+ p.write(f, p.text(f).replace(/cross-target parallelized 4\/4/g, ''));
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/credibility strip/);
+ });
+});
+
+describe('the four-minute ceiling is real', () => {
+ it('fails when the holds add up past the ceiling', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].holdSeconds += 30;
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/over the 240s submission ceiling/);
+ });
+
+ it('fails when the encoded file no longer matches the derived timeline', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].holdSeconds += 5;
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/but the cut derives/);
+ });
+});
+
+describe('the committed artifacts stay in step', () => {
+ it('fails when the video is edited after the manifest was written', () => {
+ const r = perturbed((p) => {
+ const render = p.json(RENDER);
+ render.video.sha256 = 'b'.repeat(64);
+ p.writeJson(RENDER, render);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/does not match the digest in the render manifest/);
+ });
+
+ it('fails when a frame is rebuilt after the encode', () => {
+ const r = perturbed((p) => {
+ const frames = p.json(FRAMES);
+ const board = frames.frames.find((f) => f.kind === 'board');
+ board.sha256 = 'c'.repeat(64);
+ p.writeJson(FRAMES, frames);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/has changed since the cut was encoded|does not match the frame manifest/);
+ });
+
+ it('fails when a declared input has moved under the manifest', () => {
+ const r = perturbed((p) => {
+ const inputs = p.json(INPUTS);
+ inputs.inputs[0].sha256 = 'd'.repeat(64);
+ p.writeJson(INPUTS, inputs);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/has changed since the manifest was derived/);
+ });
+
+ it('fails when a declared revision no longer matches the evidence that carries it', () => {
+ const r = perturbed((p) => {
+ const rec = p.json('experiments/hac-324/evidence/filmed-run.json');
+ rec.correlationId = 'ilk-hac340-cloud-0000000000000';
+ p.writeJson('experiments/hac-324/evidence/filmed-run.json', rec);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/no longer holds it|has changed since the manifest was derived/);
+ });
+
+ it('fails when the captions drift from the narration', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats[0].narration = 'Interlock. A different sentence entirely, at the same length.';
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/does not match the narration in the cut/);
+ });
+});
+
+describe('the opening still works muted', () => {
+ it('fails when a beat in the first thirty seconds loses its muted reading', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ delete cut.beats.find((b) => b.mutedRead).mutedRead;
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/records no muted reading/);
+ });
+});
+
+describe('the limitations act cannot be quietly deleted', () => {
+ it('fails when the claim-boundary card is dropped from the cut', () => {
+ const r = perturbed((p) => {
+ const cut = p.json(CUT);
+ cut.beats = cut.beats.filter((b) => b.source.assetId !== 'IL-PROOF-014');
+ p.writeJson(CUT, cut);
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/claim-boundary card is not in the cut/);
+ });
+
+ it('fails when the bounded-corpus statement is removed from the boards', () => {
+ const r = perturbed((p) => {
+ const f = 'media/hac-336/masters/IL-PROOF-022-evaluation-bounds-bounds.svg';
+ p.write(f, p.text(f)
+ .replace(/not a population estimate/g, 'a general result')
+ .replace(/No confidence intervals/g, 'Broadly applicable'));
+ });
+ expect(r.code).toBe(1);
+ expect(r.out).toMatch(/bounded-corpus statement/);
+ });
+});