From 2b457b8bfe64c26231e6d8ceb7383767d56e7913 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Wed, 2 Sep 2026 23:32:57 -0400 Subject: [PATCH 1/7] fix(worker): a governed attempt files its evidence under its work order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the CCM-v0 Wave-1 gap that PR #49 named in its own body: on the GOVERNED (contract-envelope) path the economics summary reached disk and never reached the ledger. task.mjs builds a contract envelope with `ref: undefined` and carries the identity in the contract triple, so uploadEvidence's `isLedgerRef(ref)` gate was false and no evidence was POSTed at all. The work order id IS the ledger row, and real ids are `bk_`-shaped, which `isLedgerRef` already admits. `resolveEvidenceRef` (evidence.mjs) resolves it: contract work order id when it is a ledger ref, else the envelope ref, else null. The GATE IS NOT WIDENED — task.mjs's WORK_ORDER_ID is broader than LEDGER_REF, so a `wo-`-shaped id still falls through and posts nothing, exactly as today. This can only turn "posted nowhere" into "posted under the work order", never "posted under the wrong row". Behaviour change to state plainly: governed attempts now create evidence rows that did not exist before. They are NEW rows, not moved ones. The evidence job_ref also drives the economics projection's known-work-order test and the worker-principal visibility filter in vinci-gpu-control #288, so those surfaces will start showing governed attempts. Also registers vinci/worker/test/*.test.mjs in vinci/test/run.sh. Nothing in CI ran that directory, so the economics unit tests shipped in #49 have never executed there — an inert guard, the same shape run.sh already records for the byok tests. Zero matches is a failure, not a pass. Controls: the resolver's mutation (drop the contract branch) fails both unit tests and the integration test at "exactly one evidence POST"; the non-ledger work order id is a negative control in the same run; the integration case asserts the POST's job_ref equals the summary's work_order_id, which is what makes #288's binding check pass by construction rather than by luck. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/run.sh | 15 ++++++++ vinci/test/worker-handoff-triple.mjs | 45 +++++++++++++++++++++++ vinci/worker/evidence.mjs | 22 ++++++++++++ vinci/worker/test/evidence-ref.test.mjs | 48 +++++++++++++++++++++++++ vinci/worker/worker.mjs | 5 +-- 5 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 vinci/worker/test/evidence-ref.test.mjs diff --git a/vinci/test/run.sh b/vinci/test/run.sh index 0be60aa8..14658a38 100644 --- a/vinci/test/run.sh +++ b/vinci/test/run.sh @@ -303,6 +303,21 @@ if [ "${containment_broker_test_count}" -eq 0 ]; then exit 1 fi run_group containment-broker node --test "${ROOT}"/vinci/containment-broker/test/*.test.mjs + +# Worker unit tests (vinci/worker/test/*.test.mjs). Registered because they were NOT: the +# economics emitter's tests shipped in PR #49, passed locally, and were never executed by any +# CI job — an inert guard, the exact shape the byok note above records. Zero matches is a +# failure, not a pass, so deleting the directory cannot read as green. +worker_unit_test_count=0 +for worker_unit_test in "${ROOT}"/vinci/worker/test/*.test.mjs; do + [ -e "${worker_unit_test}" ] || continue + worker_unit_test_count=$((worker_unit_test_count + 1)) +done +if [ "${worker_unit_test_count}" -eq 0 ]; then + echo "run.sh: no worker unit tests found under vinci/worker/test/*.test.mjs" >&2 + exit 1 +fi +run_group worker-unit node --test "${ROOT}"/vinci/worker/test/*.test.mjs # Print-mode liveness: every prompt gets a fresh idle watchdog, all session activity resets it, # tool execution suspends it, invalid timeout configuration falls back safely, and cleanup cancels it. run_group print-mode-liveness node --experimental-strip-types --input-type=module --eval ' diff --git a/vinci/test/worker-handoff-triple.mjs b/vinci/test/worker-handoff-triple.mjs index 3ebe64b5..1ce080ab 100644 --- a/vinci/test/worker-handoff-triple.mjs +++ b/vinci/test/worker-handoff-triple.mjs @@ -958,6 +958,51 @@ try { assert.equal(f.getVinciCalls().length, vinciRuns, "neither invalid handoff spawns"); } + // --- CCM-v0: a GOVERNED attempt files its evidence under its work order --- + // + // task.mjs builds a contract envelope with `ref: undefined`, so uploadEvidence's + // `isLedgerRef(ref)` gate was false and a governed attempt POSTed no evidence at all — its + // economics summary reached disk and never reached the ledger. The work order id IS the row. + // The gate is unchanged: a work order id that is not ledger-shaped still posts nothing, which + // is what every `wo-…` case above exercises. + { + const awsRecord = join(f.tempDir, "aws-ccm-calls.txt"); + const evidenceEnv = { VINCI_EVIDENCE_URI_PREFIX: "s3://evidence-bucket/worker/", FAKE_AWS_RECORD: awsRecord }; + const postsBefore = f.getEvidencePosts().length; + + // Negative control FIRST, on the same harness: a non-ledger work order id posts nothing. + const woOrder = orderFor("wo-ccm-control"); + debrisAuthority.reserveTask("m-ccm-control"); + f.busMessages.push(handoff("m-ccm-control", register(woOrder, specFor(woOrder, { targetBranch: "feat/ccm-control" })))); + let r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-control.txt", ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + assert.equal( + f.getEvidencePosts().length, postsBefore, + "control: a work order id that is not a ledger ref must still post no evidence", + ); + + // Positive: a bk_-shaped work order id is a ledger row, so the bundle is filed under it. + const bkOrder = orderFor("bk_ccm7"); + debrisAuthority.reserveTask("m-ccm-governed"); + f.busMessages.push(handoff("m-ccm-governed", register(bkOrder, specFor(bkOrder, { targetBranch: "feat/ccm-governed" })))); + r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-governed.txt", ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + + const posts = f.getEvidencePosts(); + assert.equal(posts.length, postsBefore + 1, `exactly one evidence POST, from the governed run: ${JSON.stringify(posts)}`); + const post = posts.at(-1); + assert.equal(post.job_ref, "bk_ccm7", "the bundle is filed under the work order id, not a null ref"); + assert.equal(post.kind, "bundle"); + + // The two sides of the join agree BY CONSTRUCTION: the ledger compares the POST's job_ref + // against the summary's work_order_id and refuses binding:work_order_mismatch otherwise. + assert.ok(post.economics_summary, "the POST carries the economics summary"); + assert.equal(post.economics_summary.work_order_id, post.job_ref, "summary key == evidence key"); + assert.match(post.economics_sha256 ?? "", /^[0-9a-f]{64}$/); + } + console.log("PASS worker-handoff-triple"); } finally { await f.cleanup(); diff --git a/vinci/worker/evidence.mjs b/vinci/worker/evidence.mjs index 3dd8131e..d9e33276 100644 --- a/vinci/worker/evidence.mjs +++ b/vinci/worker/evidence.mjs @@ -57,6 +57,28 @@ function command(commandName, args) { }); } +// Which ledger row an evidence bundle is filed under. +// +// A PROSE handoff names its row in `ref:`. A GOVERNED (contract) handoff does not: task.mjs +// builds its envelope with `ref: undefined` and carries the identity in the contract triple +// instead, so `isLedgerRef(envelope.ref)` was false and the bundle was never POSTed at all — +// the summary landed on disk and nothing reached the ledger. The work order id IS the row, and +// real ids are `bk_`-shaped, which `isLedgerRef` already admits. +// +// This resolves the ref; it does NOT widen the gate. A work order id that is not ledger-shaped +// (`WORK_ORDER_ID` in task.mjs is broader than `LEDGER_REF`) falls through to the envelope ref +// and, failing that, posts nothing — exactly today's behaviour. So this can only turn +// "posted nowhere" into "posted under the work order", never "posted under the wrong row". +export function resolveEvidenceRef(input) { + // A default parameter covers `undefined` only; an explicit `null` would throw on destructure, + // and this runs on the terminal path where a throw loses the whole evidence bundle. + const { contractWorkOrderId = null, envelopeRef = null } = (typeof input === "object" && input !== null) ? input : {}; + if (isLedgerRef(contractWorkOrderId)) return contractWorkOrderId; + // Only a string or null leaves here. Anything else would reach `isLedgerRef` at the gate + // (which would refuse it) and the POST body (which would not), so it is normalised once. + return typeof envelopeRef === "string" ? envelopeRef : null; +} + export async function uploadEvidence({ sessionJsonl, gitDiff, diff --git a/vinci/worker/test/evidence-ref.test.mjs b/vinci/worker/test/evidence-ref.test.mjs new file mode 100644 index 00000000..5e09f551 --- /dev/null +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -0,0 +1,48 @@ +// Which ledger row an evidence bundle is filed under (CCM-v0 follow-up to #49). +// +// Before this, a GOVERNED handoff posted no evidence at all: task.mjs sets `ref: undefined` +// on a contract envelope, so `isLedgerRef(ref)` was false and uploadEvidence skipped the bus. +// The economics summary was written to disk and never reached the ledger, which is the join +// the whole measurement depends on. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveEvidenceRef } from "../evidence.mjs"; +import { isLedgerRef } from "../bus.mjs"; + +test("a governed handoff files under its work order id", () => { + assert.equal( + resolveEvidenceRef({ contractWorkOrderId: "bk_9f2c1d", envelopeRef: undefined }), + "bk_9f2c1d", + ); +}); + +test("the contract id wins over an envelope ref, and they are told apart", () => { + // Deliberately DIFFERENT values: a fixture where both sources coincide cannot discriminate. + assert.equal( + resolveEvidenceRef({ contractWorkOrderId: "bk_contract", envelopeRef: "bk_envelope" }), + "bk_contract", + ); +}); + +test("a prose handoff is unchanged", () => { + assert.equal(resolveEvidenceRef({ contractWorkOrderId: null, envelopeRef: "job_17" }), "job_17"); +}); + +test("the gate is NOT widened: a non-ledger work order id falls through", () => { + // task.mjs's WORK_ORDER_ID admits ids LEDGER_REF does not (e.g. the golden vector's + // "wo-vec-1"). Those must behave exactly as before — fall back, and post nothing when + // there is nothing to fall back to. + assert.ok(!isLedgerRef("wo-vec-1"), "test premise: wo- ids are not ledger refs"); + assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: "job_5" }), "job_5"); + assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }), null); + assert.ok(!isLedgerRef(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }))); +}); + +test("malformed input never throws and never invents a ref", () => { + for (const input of [undefined, null, {}, { contractWorkOrderId: 7 }, { envelopeRef: [] }, + { contractWorkOrderId: "", envelopeRef: "" }, { contractWorkOrderId: "../etc", envelopeRef: undefined }]) { + const out = resolveEvidenceRef(input); + assert.ok(out === null || typeof out === "string", `${JSON.stringify(input)} -> ${JSON.stringify(out)}`); + } + assert.equal(resolveEvidenceRef(), null); +}); diff --git a/vinci/worker/worker.mjs b/vinci/worker/worker.mjs index 3edee32c..87ea5111 100644 --- a/vinci/worker/worker.mjs +++ b/vinci/worker/worker.mjs @@ -21,7 +21,7 @@ import { DECLARATION_REFRESH_DEFAULT_S, LEASE_TIMEOUT_MS, LeaseClient, buildDecl import { BranchLeaseClient, branchLeaseFence } from "./branch-lease.mjs"; import { composeFences } from "./publisher.mjs"; import { readSessionState, summarizeUnattendedPolicy } from "./session-read.mjs"; -import { uploadEvidence } from "./evidence.mjs"; +import { uploadEvidence, resolveEvidenceRef } from "./evidence.mjs"; import { buildEconomicsSummary, canonicalJson, economicsSha256 } from "./economics.mjs"; import { buildIdentity, fetchServerBuild, formatServerBuild, formatVinciBinary, formatWorkerBuild, vinciBinaryVersion } from "./build.mjs"; @@ -1468,7 +1468,8 @@ async function processHandoff( taskId, busUrl: bus.serverUrl, busToken: bus.token, - ref: envelopeToUse.ref, + // A governed handoff files its bundle under the work order; a prose one under `ref:`. + ref: resolveEvidenceRef({ contractWorkOrderId: contractFields?.work_order_id ?? null, envelopeRef: envelopeToUse.ref ?? null }), fence: lease ? fence : null, economics: { summary: economicsSummary, sha256: economicsSha }, extraFiles, From 3a72e43c9edb750a363524d77a8b7b27c556eb0e Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 3 Sep 2026 00:05:25 -0400 Subject: [PATCH 2/7] fix(worker): a non-ledger work order id refuses rather than misfiling; state the precondition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review (Opus, fresh agent) — two findings taken: F4: the resolver fell through to the envelope ref when the contract id was present but not ledger-shaped. The economics summary takes contract-first UNCONDITIONALLY, so that could file a bundle under a row the summary does not name, which the ledger refuses as binding:work_order_mismatch. Unreachable today (a contract envelope has no ref), but the failure direction must be refusal, never a plausible wrong row. A present non-ledger contract id now returns null. F1: the comment asserted "real ids are bk_-shaped" with no corroboration, and that was the load-bearing premise. Checked: work order ids are caller-supplied, not minted in one shape; every order registry in vinci-gpu-control uses wo- ids while live backlog rows are bk_. So for a wo- order this change does nothing at all. The comment now states that as a precondition instead of an assumption, and says plainly what to read the change as. F3: adds the newly-reachable failure surface. A governed attempt now POSTs where it never did, so a non-2xx downgrades COMPLETED to UNVERIFIED — the only coverage was prose. The integration block now drives a governed attempt with evidencePostStatus 500 and asserts UNVERIFIED plus evidence_error, in the terminal post rather than the claim post. Controls: removing the refusal fails both new unit tests; removing the contract branch still fails the integration positive. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/worker-handoff-triple.mjs | 23 ++++++++++++++++++++ vinci/worker/evidence.mjs | 23 +++++++++++++++----- vinci/worker/test/evidence-ref.test.mjs | 29 ++++++++++++++++++++----- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/vinci/test/worker-handoff-triple.mjs b/vinci/test/worker-handoff-triple.mjs index 1ce080ab..95dea992 100644 --- a/vinci/test/worker-handoff-triple.mjs +++ b/vinci/test/worker-handoff-triple.mjs @@ -1001,6 +1001,29 @@ try { assert.ok(post.economics_summary, "the POST carries the economics summary"); assert.equal(post.economics_summary.work_order_id, post.job_ref, "summary key == evidence key"); assert.match(post.economics_sha256 ?? "", /^[0-9a-f]{64}$/); + + // NEWLY REACHABLE FAILURE SURFACE. Before this change a governed attempt never POSTed, so a + // POST failure could not affect it. Now it can: a non-2xx downgrades COMPLETED to UNVERIFIED + // and records evidence_error. That must be true for a governed attempt too, not just a prose + // one — the only existing coverage was prose (worker-evidence-integration.mjs). + const bkFail = orderFor("bk_ccm8"); + debrisAuthority.reserveTask("m-ccm-postfail"); + f.busMessages.push(handoff("m-ccm-postfail", register(bkFail, specFor(bkFail, { targetBranch: "feat/ccm-postfail" })))); + f.evidencePostStatus = 500; + try { + r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-postfail.txt", ...evidenceEnv } }); + } finally { + f.evidencePostStatus = null; + } + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + const failState = taskState("m-ccm-postfail"); + assert.equal(failState.state, "UNVERIFIED", `a governed attempt whose evidence POST fails is not COMPLETED: ${JSON.stringify(failState)}`); + assert.ok(failState.evidence_error, "the failure is recorded, not swallowed"); + // The LAST post in the thread is the terminal; the first is `claimed`. + const failPost = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-postfail").at(-1); + assert.ok(failPost, "the governed attempt still posts a terminal"); + assert.match(failPost.body, /evidence_error=/, failPost.body); } console.log("PASS worker-handoff-triple"); diff --git a/vinci/worker/evidence.mjs b/vinci/worker/evidence.mjs index d9e33276..be7a5461 100644 --- a/vinci/worker/evidence.mjs +++ b/vinci/worker/evidence.mjs @@ -62,18 +62,29 @@ function command(commandName, args) { // A PROSE handoff names its row in `ref:`. A GOVERNED (contract) handoff does not: task.mjs // builds its envelope with `ref: undefined` and carries the identity in the contract triple // instead, so `isLedgerRef(envelope.ref)` was false and the bundle was never POSTed at all — -// the summary landed on disk and nothing reached the ledger. The work order id IS the row, and -// real ids are `bk_`-shaped, which `isLedgerRef` already admits. +// the summary landed on disk and nothing reached the ledger. // -// This resolves the ref; it does NOT widen the gate. A work order id that is not ledger-shaped -// (`WORK_ORDER_ID` in task.mjs is broader than `LEDGER_REF`) falls through to the envelope ref -// and, failing that, posts nothing — exactly today's behaviour. So this can only turn -// "posted nowhere" into "posted under the work order", never "posted under the wrong row". +// PRECONDITION, and it is not satisfied everywhere: this only files a governed bundle when the +// contract's `work_order_id` is itself a ledger ref. `WORK_ORDER_ID` (task.mjs) is broader than +// `LEDGER_REF`, ids are caller-supplied rather than minted in one shape, and the order registries +// in vinci-gpu-control today use `wo-`-shaped ids while live backlog rows are `bk_`. So for a +// `wo-` order this changes nothing at all — the governed path stays unfiled until the issuer +// mints ledger-shaped work-order ids. Do not read this as "governed attempts now reach the +// ledger"; read it as "they reach it exactly when their id names a ledger row". +// +// A non-ledger contract id returns null rather than falling through to the envelope ref. The +// summary takes contract-first UNCONDITIONALLY (economics.mjs), so falling through could file +// the bundle under a row the summary does not name, which the ledger refuses as +// `binding:work_order_mismatch`. Unreachable today (a contract envelope has no ref), but the +// failure direction must be "post nothing", never "post under a plausible wrong row". export function resolveEvidenceRef(input) { // A default parameter covers `undefined` only; an explicit `null` would throw on destructure, // and this runs on the terminal path where a throw loses the whole evidence bundle. const { contractWorkOrderId = null, envelopeRef = null } = (typeof input === "object" && input !== null) ? input : {}; if (isLedgerRef(contractWorkOrderId)) return contractWorkOrderId; + // A contract that named an id we cannot file under does not get to fall back to some other + // row: refuse the POST instead of misfiling it. + if (typeof contractWorkOrderId === "string" && contractWorkOrderId) return null; // Only a string or null leaves here. Anything else would reach `isLedgerRef` at the gate // (which would refuse it) and the POST body (which would not), so it is normalised once. return typeof envelopeRef === "string" ? envelopeRef : null; diff --git a/vinci/worker/test/evidence-ref.test.mjs b/vinci/worker/test/evidence-ref.test.mjs index 5e09f551..f042836f 100644 --- a/vinci/worker/test/evidence-ref.test.mjs +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -28,14 +28,31 @@ test("a prose handoff is unchanged", () => { assert.equal(resolveEvidenceRef({ contractWorkOrderId: null, envelopeRef: "job_17" }), "job_17"); }); -test("the gate is NOT widened: a non-ledger work order id falls through", () => { - // task.mjs's WORK_ORDER_ID admits ids LEDGER_REF does not (e.g. the golden vector's - // "wo-vec-1"). Those must behave exactly as before — fall back, and post nothing when - // there is nothing to fall back to. +test("a non-ledger work order id REFUSES rather than misfiling", () => { + // task.mjs's WORK_ORDER_ID admits ids LEDGER_REF does not (the golden vector's "wo-vec-1", + // and every order registry in vinci-gpu-control today). Such an order must post NOTHING. + // Falling back to the envelope ref would file the bundle under a row the economics summary + // does not name — the summary takes contract-first unconditionally — and the ledger refuses + // that as binding:work_order_mismatch. The failure direction is refusal, not misfiling. assert.ok(!isLedgerRef("wo-vec-1"), "test premise: wo- ids are not ledger refs"); - assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: "job_5" }), "job_5"); assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }), null); - assert.ok(!isLedgerRef(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }))); + assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: "job_5" }), null, + "a contract naming an unfilable row does not borrow another row"); + // And the gate is still not widened: nothing non-ledger ever leaves here. + for (const id of ["wo-vec-1", "wo-example-001", "../etc", "bk", "bk_", " bk_1"]) { + const out = resolveEvidenceRef({ contractWorkOrderId: id, envelopeRef: undefined }); + assert.ok(out === null || isLedgerRef(out), `${id} -> ${out}`); + } +}); + +test("a contract id that disagrees with the summary cannot be filed under a third row", () => { + // The summary uses contract-first unconditionally; if the resolver used anything else while + // the contract id was present, job_ref and summary.work_order_id would disagree. + for (const [contractId, envelopeRef] of [["bk_a", "bk_b"], ["wo-x", "bk_b"], ["bk_a", undefined]]) { + const out = resolveEvidenceRef({ contractWorkOrderId: contractId, envelopeRef }); + assert.ok(out === null || out === contractId, + `a present contract id must yield itself or nothing, got ${out} for ${contractId}/${envelopeRef}`); + } }); test("malformed input never throws and never invents a ref", () => { From b86da6899229e3f074dbfe4c5bd68d7875e8de04 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 3 Sep 2026 00:12:02 -0400 Subject: [PATCH 3/7] test: the fake bus enforces the real job_ref rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by projects-7f reviewing #53: worker-fixture.mjs filtered `evidence.refs`, a key the worker never sends to /v1/evidence — a dead branch, which made `rejectedPosts.length === 0` a check that could not fail. The fake therefore accepted ANY job_ref, including ones the real server refuses. That matters more than a dead assertion. vinci-gpu-control's POST /v1/evidence 422s a job_ref outside ("job_", "exp_", "bk_"), and WorkGraph-issued work order ids are `wo-{graph}-{node}` — so a test could file evidence under a governed work order id here and pass while production refused it. The integration test for #53 could not have caught the namespace collision now filed as vinci-gpu-control#295. The fake now applies the server's rule to `job_ref`. Control: bk_ccm7 -> 200 wo-g1-n1 -> 422 (matches the real server) All 8 worker integration files still pass. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/lib/worker-fixture.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vinci/test/lib/worker-fixture.mjs b/vinci/test/lib/worker-fixture.mjs index ec749fe9..52ac6749 100644 --- a/vinci/test/lib/worker-fixture.mjs +++ b/vinci/test/lib/worker-fixture.mjs @@ -480,7 +480,15 @@ process.exit(r.status ?? 1); }); request.on("end", () => { const evidence = JSON.parse(body); - const invalidRefs = (evidence.refs ?? []).filter((ref) => !LEDGER_REF.test(ref)); + // The worker sends `job_ref`, never `refs` — so the old `evidence.refs` filter here was + // a branch nothing could reach, and `rejectedPosts.length === 0` was a check that could + // not fail. Worse, this fake accepted ANY job_ref, including ones the real server + // refuses: vinci-gpu-control's POST /v1/evidence 422s a job_ref outside + // ("job_", "exp_", "bk_") (MESSAGE_REF_PREFIXES). A test could therefore file under a + // `wo-`-shaped ref here and pass while production refused it. Mirror the real rule. + const invalidRefs = [evidence.job_ref, ...(evidence.refs ?? [])] + .filter((ref) => ref !== undefined && ref !== null) + .filter((ref) => typeof ref !== "string" || !LEDGER_REF.test(ref)); if (invalidRefs.length > 0) { this.rejectedPosts.push(evidence); response.writeHead(422, { "content-type": "application/json" }); From 9493b55545844d5bc52e13299dd4a4f4a190adb9 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 3 Sep 2026 00:16:22 -0400 Subject: [PATCH 4/7] docs(worker): the ledger refuses the economics EVENT, not the evidence POST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precision defect in my own comments, caught by projects-7f's reviewer. I wrote that the ledger 'refuses binding:work_order_mismatch', which reads as the POST being rejected. It is not: app.py records ECONOMICS_REFUSED and still stores the evidence row, by a design rule I wrote myself — economics never blocks evidence. So a misfiled bundle would PERSIST with a refusal event beside it, which is a stronger reason to refuse at the worker than the one I gave. Also narrows the integration comment: agreement by construction keeps the pair joinable; it is not the ledger rejecting bad pairs on our behalf. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/worker-handoff-triple.mjs | 7 +++++-- vinci/worker/evidence.mjs | 6 ++++-- vinci/worker/test/evidence-ref.test.mjs | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/vinci/test/worker-handoff-triple.mjs b/vinci/test/worker-handoff-triple.mjs index 95dea992..6bb5885d 100644 --- a/vinci/test/worker-handoff-triple.mjs +++ b/vinci/test/worker-handoff-triple.mjs @@ -996,8 +996,11 @@ try { assert.equal(post.job_ref, "bk_ccm7", "the bundle is filed under the work order id, not a null ref"); assert.equal(post.kind, "bundle"); - // The two sides of the join agree BY CONSTRUCTION: the ledger compares the POST's job_ref - // against the summary's work_order_id and refuses binding:work_order_mismatch otherwise. + // The two sides of the join agree BY CONSTRUCTION. Note what that does and does not buy: + // the ledger compares the POST's job_ref against the summary's work_order_id and records + // ECONOMICS_REFUSED binding:work_order_mismatch when they differ — but it still stores the + // evidence row, because economics never blocks evidence. Agreement here is what keeps the + // pair joinable; it is not the ledger rejecting bad pairs on our behalf. assert.ok(post.economics_summary, "the POST carries the economics summary"); assert.equal(post.economics_summary.work_order_id, post.job_ref, "summary key == evidence key"); assert.match(post.economics_sha256 ?? "", /^[0-9a-f]{64}$/); diff --git a/vinci/worker/evidence.mjs b/vinci/worker/evidence.mjs index be7a5461..11de7b25 100644 --- a/vinci/worker/evidence.mjs +++ b/vinci/worker/evidence.mjs @@ -74,8 +74,10 @@ function command(commandName, args) { // // A non-ledger contract id returns null rather than falling through to the envelope ref. The // summary takes contract-first UNCONDITIONALLY (economics.mjs), so falling through could file -// the bundle under a row the summary does not name, which the ledger refuses as -// `binding:work_order_mismatch`. Unreachable today (a contract envelope has no ref), but the +// the bundle under a row the summary does not name. The ledger records that as an +// `ECONOMICS_REFUSED binding:work_order_mismatch` EVENT and still stores the evidence row — +// economics never blocks evidence — so the misfiled row would persist with a refusal beside it, +// which is worse than not posting. Unreachable today (a contract envelope has no ref), but the // failure direction must be "post nothing", never "post under a plausible wrong row". export function resolveEvidenceRef(input) { // A default parameter covers `undefined` only; an explicit `null` would throw on destructure, diff --git a/vinci/worker/test/evidence-ref.test.mjs b/vinci/worker/test/evidence-ref.test.mjs index f042836f..7e64936c 100644 --- a/vinci/worker/test/evidence-ref.test.mjs +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -32,8 +32,9 @@ test("a non-ledger work order id REFUSES rather than misfiling", () => { // task.mjs's WORK_ORDER_ID admits ids LEDGER_REF does not (the golden vector's "wo-vec-1", // and every order registry in vinci-gpu-control today). Such an order must post NOTHING. // Falling back to the envelope ref would file the bundle under a row the economics summary - // does not name — the summary takes contract-first unconditionally — and the ledger refuses - // that as binding:work_order_mismatch. The failure direction is refusal, not misfiling. + // does not name — the summary takes contract-first unconditionally. The ledger records that + // as an ECONOMICS_REFUSED binding:work_order_mismatch event but still stores the evidence row + // (economics never blocks evidence), so the misfile persists. Refusal here, not misfiling. assert.ok(!isLedgerRef("wo-vec-1"), "test premise: wo- ids are not ledger refs"); assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }), null); assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: "job_5" }), null, From 16eab172eb12ccd2ff80137df21ac7124cb141f5 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 3 Sep 2026 00:21:28 -0400 Subject: [PATCH 5/7] test: the fake bus mirrors every /v1/evidence refusal, not just the ref rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projects-7f found the residual in my own fidelity fix: the filter dropped undefined/null BEFORE testing, so a POST with job_ref absent produced an empty invalidRefs and was accepted. The real server has no such escape hatch — it requires job_ref, sha256, uri, kind and produced_at to each be a non-blank string BEFORE it checks the prefix. Absent is invalid, not nothing-to-check. Same class as the wo- hole, one level narrower. Closing the class rather than the instance: the fake now enforces both of the server's rules in the server's order. Controls: valid bk_ -> 200 wo- ref -> 422 absent job_ref -> 422 null job_ref -> 422 blank job_ref -> 422 missing sha256 -> 422 All 8 worker integration files pass. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/lib/worker-fixture.mjs | 35 +++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/vinci/test/lib/worker-fixture.mjs b/vinci/test/lib/worker-fixture.mjs index 52ac6749..fe093f3e 100644 --- a/vinci/test/lib/worker-fixture.mjs +++ b/vinci/test/lib/worker-fixture.mjs @@ -480,19 +480,32 @@ process.exit(r.status ?? 1); }); request.on("end", () => { const evidence = JSON.parse(body); - // The worker sends `job_ref`, never `refs` — so the old `evidence.refs` filter here was - // a branch nothing could reach, and `rejectedPosts.length === 0` was a check that could - // not fail. Worse, this fake accepted ANY job_ref, including ones the real server - // refuses: vinci-gpu-control's POST /v1/evidence 422s a job_ref outside - // ("job_", "exp_", "bk_") (MESSAGE_REF_PREFIXES). A test could therefore file under a - // `wo-`-shaped ref here and pass while production refused it. Mirror the real rule. - const invalidRefs = [evidence.job_ref, ...(evidence.refs ?? [])] - .filter((ref) => ref !== undefined && ref !== null) - .filter((ref) => typeof ref !== "string" || !LEDGER_REF.test(ref)); - if (invalidRefs.length > 0) { + // MIRROR THE REAL SERVER'S REFUSALS, not just its happy path. This fake previously + // filtered `evidence.refs` — a key the worker never sends — so its 422 branch was dead + // and `rejectedPosts.length === 0` could not fail. The consequence was worse than a + // vacuous assertion: it accepted ANY job_ref, so an integration test could file under a + // `wo-`-shaped ref and pass while production 422s it. That is why a green suite sat on + // top of the disjoint-namespace collision in vinci-gpu-control#295 without a murmur. + // + // vinci-gpu-control app.py POST /v1/evidence, in order: + // 1. job_ref, sha256, uri, kind, produced_at must each be a non-blank string + // 2. job_ref must start with MESSAGE_REF_PREFIXES ("job_", "exp_", "bk_") + // A MISSING field is refused by (1) — it is not "nothing to check". Both rules are + // enforced here so a test cannot pass on a request the real server would reject. + const missing = ["job_ref", "sha256", "uri", "kind", "produced_at"] + .filter((field) => typeof evidence[field] !== "string" || !evidence[field].trim()); + const invalidRefs = missing.includes("job_ref") + ? [] + : [evidence.job_ref, ...(evidence.refs ?? [])] + .filter((ref) => typeof ref !== "string" || !LEDGER_REF.test(ref)); + if (missing.length > 0 || invalidRefs.length > 0) { this.rejectedPosts.push(evidence); response.writeHead(422, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: `invalid refs: ${invalidRefs.join(", ")}` })); + response.end(JSON.stringify({ + error: missing.length > 0 + ? `missing or blank: ${missing.join(", ")}` + : `invalid refs: ${invalidRefs.join(", ")}`, + })); return; } if (this.evidencePostStatus) { From a0d627d0a6ac5d05f07690c8b086e5a9063ee6d6 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5 (1M context)" Date: Thu, 3 Sep 2026 00:31:53 -0400 Subject: [PATCH 6/7] test: record the gate-masking mutation table, and add the assertion that makes it true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch authored by projects-7f (reviewer of this PR, deliberately not pushed by them). Verified before applying rather than taken on trust — and the verification found the table had already decayed. Their measurement, taken at 2b457b8b/3a72e43c, said widening BOTH gates fails the integration control. At this head it SURVIVED. Cause: my own fixture fidelity fix. Once the fake bus enforced the server's job_ref rule, a widened pair stopped producing an extra ACCEPTED post — which the count assertion caught — and started producing a REFUSED one, which no assertion looked at. The stricter fake silently removed a control while looking like an improvement. Fixed by asserting `f.rejectedPosts` is empty in the governed block: the bus refusing anything is now a failure, which also gives the previously-dead "worker must not send refs rejected by the bus" idea a live meaning. Re-measured at this head: widen RESOLVER gate only -> integration SURVIVES; unit tests FAIL widen POST gate only -> integration SURVIVES; unit tests PASS (masked) widen BOTH gates -> integration FAILS Both notes land in the repo rather than on the bus, and the unit-test note records that the third row depends on the new assertion, so removing it cannot quietly revert the table. Co-Authored-By: Claude Opus 5 (1M context) --- vinci/test/worker-handoff-triple.mjs | 5 +++++ vinci/worker/evidence.mjs | 5 +++++ vinci/worker/test/evidence-ref.test.mjs | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/vinci/test/worker-handoff-triple.mjs b/vinci/test/worker-handoff-triple.mjs index 6bb5885d..0bdc9315 100644 --- a/vinci/test/worker-handoff-triple.mjs +++ b/vinci/test/worker-handoff-triple.mjs @@ -990,6 +990,11 @@ try { assert.equal(r.status, 0, r.stderr); vinciRuns += 1; + // The fake now enforces the server's job_ref rule, so a widened gate shows up as a REFUSED + // post rather than an extra accepted one. Assert both: nothing was refused, and exactly one + // landed. Without the first, widening both gates files a `wo-` ref, gets 422'd, and no + // assertion here notices — the count is unchanged because the post never landed. + assert.deepEqual(f.rejectedPosts, [], `the bus refused a post the worker should never have sent: ${JSON.stringify(f.rejectedPosts)}`); const posts = f.getEvidencePosts(); assert.equal(posts.length, postsBefore + 1, `exactly one evidence POST, from the governed run: ${JSON.stringify(posts)}`); const post = posts.at(-1); diff --git a/vinci/worker/evidence.mjs b/vinci/worker/evidence.mjs index 11de7b25..5da60351 100644 --- a/vinci/worker/evidence.mjs +++ b/vinci/worker/evidence.mjs @@ -152,6 +152,11 @@ export async function uploadEvidence({ // Post evidence metadata to the bus evidence endpoint. Only ledger refs // (job_/exp_/bk_) are attached as refs; any other ref (or none) skips the // bus entirely — the server would reject the post with 422. + // This gate is MASKED by the resolver's gate: widening it alone changes no observable + // behaviour, because resolveEvidenceRef has already returned null for anything that would + // fail here. It is defence in depth, not dead code — no test fails when it alone is + // removed, which is exactly the evidence that gets a real guard deleted. See the mutation + // table in worker/test/evidence-ref.test.mjs. if (busUrl && busToken && isLedgerRef(ref)) { // Wave 1B L3: the evidence POST is a consequential side effect — ask the lease fence first. // A stale generation records `fenced_out:` and never reaches the ledger. diff --git a/vinci/worker/test/evidence-ref.test.mjs b/vinci/worker/test/evidence-ref.test.mjs index 7e64936c..21720264 100644 --- a/vinci/worker/test/evidence-ref.test.mjs +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -4,6 +4,31 @@ // on a contract envelope, so `isLedgerRef(ref)` was false and uploadEvidence skipped the bus. // The economics summary was written to disk and never reached the ledger, which is the join // the whole measurement depends on. +// WHY THESE UNIT TESTS ARE NOT REDUNDANT WITH THE INTEGRATION CONTROL. +// +// There are two gates on the evidence ref: the resolver's (`isLedgerRef` inside +// resolveEvidenceRef) and the POST's (`isLedgerRef(ref)` before the fetch). They MASK EACH +// OTHER, so no single control discriminates either one alone. Measured by mutation against +// worker-handoff-triple.mjs at this head, re-run after every change below: +// +// widen the RESOLVER gate only -> integration SURVIVES; these unit tests FAIL +// widen the POST gate only -> integration SURVIVES; these unit tests PASS +// (masked: the resolver already returned null, so the +// widened gate is never reached) +// widen BOTH gates -> integration FAILS +// +// So the integration test pins the PAIR, and the resolver's own gate is pinned ONLY here. +// Deleting these as "already covered by the integration test" would silently unpin the +// resolver gate and leave a widened POST gate undetectable by any test in the repo. +// +// The both-gates row is load-bearing and was NOT free: it holds only because the integration +// test asserts `f.rejectedPosts` is empty. Once the fake bus began enforcing the server's +// job_ref rule, a widened pair stopped producing an EXTRA accepted post (which a count +// assertion caught) and started producing a REFUSED one (which no count assertion sees). +// The measured table said FAILS while the code said SURVIVES for one commit, which is how +// the missing assertion was found. If that assertion is removed, this row reverts to +// SURVIVES and nothing in the repo detects a widened pair. + import { test } from "node:test"; import assert from "node:assert/strict"; import { resolveEvidenceRef } from "../evidence.mjs"; From 9dad37e8108283cc8dbb12e54e2a85198b2c356d Mon Sep 17 00:00:00 2001 From: George Pu <19347973+thegeorgepu@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:06:51 -0400 Subject: [PATCH 7/7] fix(worker): bind governed evidence to WorkOrder Use the registry-validated WorkOrder as the single governed evidence identity, carry it through the durable evidence POST and completed terminal, and keep prose refs on the legacy namespace. Add server-boundary, retry, failure, idempotency, and mutation coverage.\n\nCloses #54 --- vinci/test/lib/worker-fixture.mjs | 24 ++- vinci/test/worker-handoff-triple.mjs | 200 ++++++++++++++++++++---- vinci/worker/bus.mjs | 16 +- vinci/worker/evidence.mjs | 45 +++--- vinci/worker/test/evidence-ref.test.mjs | 141 +++++++++-------- vinci/worker/worker.mjs | 28 ++-- 6 files changed, 317 insertions(+), 137 deletions(-) diff --git a/vinci/test/lib/worker-fixture.mjs b/vinci/test/lib/worker-fixture.mjs index fe093f3e..895b0f6d 100644 --- a/vinci/test/lib/worker-fixture.mjs +++ b/vinci/test/lib/worker-fixture.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; const LEDGER_REF = /^(?:job|exp|bk)_[A-Za-z0-9][A-Za-z0-9._-]*$/; +const WORK_ORDER_EVIDENCE_REF = /^wo-[A-Za-z0-9][A-Za-z0-9._:-]{0,124}$/; function runGit(args, cwd) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); @@ -267,6 +268,11 @@ export class WorkerTestFixture { this.busMessages = []; this.evidencePosts = []; this.getRequests = []; + // The resolved #295 contract is enforced at the SERVER boundary: `wo-` is accepted only + // for an existing contract-registry WorkOrder bound to this program. The worker never gets + // to assert that binding. Tests populate workOrderPrograms alongside registry entries. + this.evidenceProgramId = "prog-worker-test"; + this.workOrderPrograms = {}; // When set (e.g. 500), every /v1/evidence POST answers with that status instead of 200. this.evidencePostStatus = null; // When set to a RegExp, every /v1/messages POST whose subject matches answers 500 and is @@ -370,6 +376,15 @@ process.exit(r.status ?? 1); if (this.gitRecordFile) writeFileSync(this.gitRecordFile, ""); } + acceptsEvidenceRef(ref) { + if (typeof ref !== "string") return false; + if (LEDGER_REF.test(ref)) return true; + if (!WORK_ORDER_EVIDENCE_REF.test(ref)) return false; + const entry = this.contractRegistry?.[ref]; + return entry?.work_order?.id === ref + && this.workOrderPrograms?.[ref] === this.evidenceProgramId; + } + // git subcommands that transfer objects or talk to origin. static TRANSFER = new Set(["fetch", "clone", "ls-remote", "push", "pull"]); @@ -427,7 +442,7 @@ process.exit(r.status ?? 1); }); request.on("end", () => { const message = JSON.parse(body); - const invalidRefs = (message.refs ?? []).filter((ref) => !LEDGER_REF.test(ref)); + const invalidRefs = (message.refs ?? []).filter((ref) => !this.acceptsEvidenceRef(ref)); if (invalidRefs.length > 0) { this.rejectedPosts.push(message); response.writeHead(422, { "content-type": "application/json" }); @@ -487,9 +502,10 @@ process.exit(r.status ?? 1); // `wo-`-shaped ref and pass while production 422s it. That is why a green suite sat on // top of the disjoint-namespace collision in vinci-gpu-control#295 without a murmur. // - // vinci-gpu-control app.py POST /v1/evidence, in order: + // Resolved vinci-gpu-control#295 POST /v1/evidence contract, in order: // 1. job_ref, sha256, uri, kind, produced_at must each be a non-blank string - // 2. job_ref must start with MESSAGE_REF_PREFIXES ("job_", "exp_", "bk_") + // 2. a legacy ref must start job_/exp_/bk_; `wo-` must name an existing registry + // WorkOrder bound to the configured program // A MISSING field is refused by (1) — it is not "nothing to check". Both rules are // enforced here so a test cannot pass on a request the real server would reject. const missing = ["job_ref", "sha256", "uri", "kind", "produced_at"] @@ -497,7 +513,7 @@ process.exit(r.status ?? 1); const invalidRefs = missing.includes("job_ref") ? [] : [evidence.job_ref, ...(evidence.refs ?? [])] - .filter((ref) => typeof ref !== "string" || !LEDGER_REF.test(ref)); + .filter((ref) => !this.acceptsEvidenceRef(ref)); if (missing.length > 0 || invalidRefs.length > 0) { this.rejectedPosts.push(evidence); response.writeHead(422, { "content-type": "application/json" }); diff --git a/vinci/test/worker-handoff-triple.mjs b/vinci/test/worker-handoff-triple.mjs index 0bdc9315..2ce7dc49 100644 --- a/vinci/test/worker-handoff-triple.mjs +++ b/vinci/test/worker-handoff-triple.mjs @@ -154,8 +154,14 @@ try { const orderFor = (id, overrides = {}) => ({ ...workOrder, id, expiresAt: futureExpiry, ...overrides }); // Register a (order, spec) pair under the order's id; returns the triple body. `specDigest` // lets a test name a spec the validator refuses (recordDigest: the raw identity). - const register = (order, spec, { orderDigest = workOrderDigest(order), specDigest = executionSpecDigest(spec) } = {}) => { + const register = (order, spec, { + orderDigest = workOrderDigest(order), + specDigest = executionSpecDigest(spec), + programId = f.evidenceProgramId, + } = {}) => { f.contractRegistry[order.id] = { work_order: order, execution_spec: spec }; + if (programId === null) delete f.workOrderPrograms[order.id]; + else f.workOrderPrograms[order.id] = programId; return triple(order.id, orderDigest, specDigest); }; @@ -958,65 +964,109 @@ try { assert.equal(f.getVinciCalls().length, vinciRuns, "neither invalid handoff spawns"); } - // --- CCM-v0: a GOVERNED attempt files its evidence under its work order --- + // --- CCM-v0: governed evidence and its terminal share the validated WorkOrder identity --- // - // task.mjs builds a contract envelope with `ref: undefined`, so uploadEvidence's - // `isLedgerRef(ref)` gate was false and a governed attempt POSTed no evidence at all — its - // economics summary reached disk and never reached the ledger. The work order id IS the row. - // The gate is unchanged: a work order id that is not ledger-shaped still posts nothing, which - // is what every `wo-…` case above exercises. + // task.mjs builds a contract envelope with `ref: undefined`. Before #53 that meant no evidence + // POST; before #54 the new durable row still disagreed with an unreferenced terminal. The #295 + // ruling keeps WorkOrder canonical, admits validated `wo-` refs, and leaves WorkOrder existence + // plus program binding to the evidence server. Backlog identity is never substituted. { const awsRecord = join(f.tempDir, "aws-ccm-calls.txt"); const evidenceEnv = { VINCI_EVIDENCE_URI_PREFIX: "s3://evidence-bucket/worker/", FAKE_AWS_RECORD: awsRecord }; const postsBefore = f.getEvidencePosts().length; - // Negative control FIRST, on the same harness: a non-ledger work order id posts nothing. - const woOrder = orderFor("wo-ccm-control"); + // A validated contract id outside both admitted namespaces still posts nothing and cannot + // borrow a plausible bk_ row from anywhere else. + const unfilableOrder = orderFor("contract-ccm-control"); debrisAuthority.reserveTask("m-ccm-control"); - f.busMessages.push(handoff("m-ccm-control", register(woOrder, specFor(woOrder, { targetBranch: "feat/ccm-control" })))); + f.busMessages.push(handoff("m-ccm-control", register( + unfilableOrder, + specFor(unfilableOrder, { targetBranch: "feat/ccm-control" }), + ))); let r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-control.txt", ...evidenceEnv } }); assert.equal(r.status, 0, r.stderr); vinciRuns += 1; assert.equal( f.getEvidencePosts().length, postsBefore, - "control: a work order id that is not a ledger ref must still post no evidence", + "an inadmissible WorkOrder id must post no evidence", ); + const unfilableTerminal = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-control").at(-1); + assert.equal(unfilableTerminal.kind, "status"); + assert.equal(unfilableTerminal.refs, undefined); - // Positive: a bk_-shaped work order id is a ledger row, so the bundle is filed under it. - const bkOrder = orderFor("bk_ccm7"); + // Positive: the exact `wo-` id came from the registry-validated order/spec pair. The fake + // server independently sees that the WorkOrder exists and is bound to its configured program. + const woOrder = orderFor("wo-ccm7"); debrisAuthority.reserveTask("m-ccm-governed"); - f.busMessages.push(handoff("m-ccm-governed", register(bkOrder, specFor(bkOrder, { targetBranch: "feat/ccm-governed" })))); + f.busMessages.push(handoff("m-ccm-governed", register( + woOrder, + specFor(woOrder, { targetBranch: "feat/ccm-governed" }), + ))); r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-governed.txt", ...evidenceEnv } }); assert.equal(r.status, 0, r.stderr); vinciRuns += 1; - // The fake now enforces the server's job_ref rule, so a widened gate shows up as a REFUSED - // post rather than an extra accepted one. Assert both: nothing was refused, and exactly one - // landed. Without the first, widening both gates files a `wo-` ref, gets 422'd, and no - // assertion here notices — the count is unchanged because the post never landed. assert.deepEqual(f.rejectedPosts, [], `the bus refused a post the worker should never have sent: ${JSON.stringify(f.rejectedPosts)}`); const posts = f.getEvidencePosts(); assert.equal(posts.length, postsBefore + 1, `exactly one evidence POST, from the governed run: ${JSON.stringify(posts)}`); const post = posts.at(-1); - assert.equal(post.job_ref, "bk_ccm7", "the bundle is filed under the work order id, not a null ref"); + assert.equal(post.job_ref, "wo-ccm7", "the bundle is filed under the WorkOrder, never a backlog surrogate"); assert.equal(post.kind, "bundle"); - // The two sides of the join agree BY CONSTRUCTION. Note what that does and does not buy: - // the ledger compares the POST's job_ref against the summary's work_order_id and records - // ECONOMICS_REFUSED binding:work_order_mismatch when they differ — but it still stores the - // evidence row, because economics never blocks evidence. Agreement here is what keeps the - // pair joinable; it is not the ledger rejecting bad pairs on our behalf. + // WorkOrder + run + attempt are all explicit in the durable row. The terminal is a finding + // under that same ref, replying to the exact handoff message that names this run. assert.ok(post.economics_summary, "the POST carries the economics summary"); assert.equal(post.economics_summary.work_order_id, post.job_ref, "summary key == evidence key"); + assert.equal(post.economics_summary.attempt_label, "m-ccm-governed/1"); assert.match(post.economics_sha256 ?? "", /^[0-9a-f]{64}$/); - - // NEWLY REACHABLE FAILURE SURFACE. Before this change a governed attempt never POSTed, so a - // POST failure could not affect it. Now it can: a non-2xx downgrades COMPLETED to UNVERIFIED - // and records evidence_error. That must be true for a governed attempt too, not just a prose - // one — the only existing coverage was prose (worker-evidence-integration.mjs). - const bkFail = orderFor("bk_ccm8"); + + const completedPost = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-governed").at(-1); + assert.ok(completedPost, "the governed attempt posts a terminal"); + assert.equal(completedPost.kind, "finding"); + assert.equal(completedPost.outcome, "COMPLETED"); + assert.deepEqual(completedPost.refs, [post.job_ref], "terminal ref == durable evidence ref"); + const publicEvidenceRecords = JSON.stringify({ post, completedPost }); + assert.equal(publicEvidenceRecords.includes(woOrder.request), false, "bus metadata must not disclose the WorkOrder request"); + assert.equal(publicEvidenceRecords.includes(woOrder.scope), false, "bus metadata must not disclose the WorkOrder scope"); + + // Replaying the same bus page is idempotent: the cursor/lifecycle pair emits no second row or + // terminal for the already-terminal attempt. + const evidenceAfterSuccess = f.getEvidencePosts().length; + const terminalsAfterSuccess = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-governed").length; + r = await run({ env: evidenceEnv }); + assert.equal(r.status, 0, r.stderr); + assert.equal(f.getEvidencePosts().length, evidenceAfterSuccess); + assert.equal(f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-governed").length, terminalsAfterSuccess); + + // The worker cannot self-assert program binding. An existing, locally valid WorkOrder whose + // server-side binding is absent or stale reaches the one ingress, gets 422, downgrades to + // UNVERIFIED, and does not attach the refused ref to its terminal. + for (const [id, programId] of [["unbound", null], ["stale", "prog-stale"]]) { + const order = orderFor(`wo-ccm-${id}`); + const taskId = `m-ccm-${id}`; + debrisAuthority.reserveTask(taskId); + f.busMessages.push(handoff(taskId, register( + order, + specFor(order, { targetBranch: `feat/ccm-${id}` }), + { programId }, + ))); + const rejectedBefore = f.rejectedPosts.length; + r = await run({ env: { FAKE_VINCI_COMMIT_FILE: `ccm-${id}.txt`, ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + assert.equal(f.rejectedPosts.length, rejectedBefore + 1, `${id}: server must refuse the wo- evidence ref`); + assert.equal(taskState(taskId).state, "UNVERIFIED"); + const terminal = f.getPostedMessages().filter((m) => m.in_reply_to === taskId).at(-1); + assert.equal(terminal.kind, "status"); + assert.equal(terminal.refs, undefined); + assert.match(terminal.body, /evidence_error=Bus POST failed: 422/); + } + + // A transport/server failure on an otherwise valid WorkOrder follows the same truthful + // downgrade. It is not retried as a second ingress and never claims a finding ref. + const woFail = orderFor("wo-ccm8"); debrisAuthority.reserveTask("m-ccm-postfail"); - f.busMessages.push(handoff("m-ccm-postfail", register(bkFail, specFor(bkFail, { targetBranch: "feat/ccm-postfail" })))); + f.busMessages.push(handoff("m-ccm-postfail", register(woFail, specFor(woFail, { targetBranch: "feat/ccm-postfail" })))); f.evidencePostStatus = 500; try { r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-postfail.txt", ...evidenceEnv } }); @@ -1031,7 +1081,95 @@ try { // The LAST post in the thread is the terminal; the first is `claimed`. const failPost = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-postfail").at(-1); assert.ok(failPost, "the governed attempt still posts a terminal"); + assert.equal(failPost.kind, "status"); + assert.equal(failPost.refs, undefined); assert.match(failPost.body, /evidence_error=/, failPost.body); + + // Runtime failure still emits one governed bundle under the canonical WorkOrder. FAILED is + // deliberately a status terminal (the finding contract remains COMPLETED-only), so it never + // advertises a successful evidence ref even though the diagnostic row is durable. + const runtimeFailOrder = orderFor("wo-ccm-runtime-fail"); + debrisAuthority.reserveTask("m-ccm-runtime-fail"); + f.busMessages.push(handoff("m-ccm-runtime-fail", register( + runtimeFailOrder, + specFor(runtimeFailOrder, { targetBranch: "feat/ccm-runtime-fail" }), + ))); + const runtimeFailBefore = f.getEvidencePosts().length; + r = await run({ env: { FAKE_VINCI_EXIT: "3", ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + const runtimeFailPosts = f.getEvidencePosts().slice(runtimeFailBefore); + assert.equal(runtimeFailPosts.length, 1); + assert.equal(runtimeFailPosts[0].job_ref, runtimeFailOrder.id); + assert.equal(runtimeFailPosts[0].economics_summary.attempt_label, "m-ccm-runtime-fail/1"); + assert.equal(taskState("m-ccm-runtime-fail").state, "FAILED"); + const runtimeFailTerminal = f.getPostedMessages().filter((m) => m.in_reply_to === "m-ccm-runtime-fail").at(-1); + assert.equal(runtimeFailTerminal.kind, "status"); + assert.equal(runtimeFailTerminal.outcome, "FAILED"); + assert.equal(runtimeFailTerminal.refs, undefined); + + // A resumed non-terminal record keeps the same WorkOrder key while advancing the attempt + // identity. This is the retry side of the same invariant; the lifecycle table and restart + // integration suites separately exercise the real interruption/cancellation mechanics. + const retryOrder = orderFor("wo-ccm-retry"); + const retryTaskId = "m-ccm-retry"; + mkdirSync(join(f.tempDir, "tasks"), { recursive: true }); + writeFileSync( + join(f.tempDir, "tasks", `${retryTaskId}.json`), + `${JSON.stringify({ task: retryTaskId, attempt: 1, session_id: "ccm-retry-session", state: "RUNNING", terminal: false })}\n`, + ); + debrisAuthority.reserveTask(retryTaskId); + f.busMessages.push(handoff(retryTaskId, register( + retryOrder, + specFor(retryOrder, { targetBranch: "feat/ccm-retry" }), + ))); + const retryBefore = f.getEvidencePosts().length; + r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-retry.txt", ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 1; + const retryPost = f.getEvidencePosts().slice(retryBefore); + assert.equal(retryPost.length, 1); + assert.equal(retryPost[0].job_ref, retryOrder.id); + assert.equal(retryPost[0].economics_summary.attempt_label, `${retryTaskId}/2`); + assert.equal(taskState(retryTaskId).attempt, 2); + assert.deepEqual( + f.getPostedMessages().filter((m) => m.in_reply_to === retryTaskId).at(-1).refs, + [retryOrder.id], + ); + + // Two runs under one WorkOrder retain one canonical ref while preserving distinct run and + // attempt identities. Each spec is selected by its recomputed digest. + const multiOrder = orderFor("wo-ccm-multi"); + const multiSpecs = [ + specFor(multiOrder, { targetBranch: "feat/ccm-multi-a" }), + specFor(multiOrder, { targetBranch: "feat/ccm-multi-b" }), + ]; + f.contractRegistry[multiOrder.id] = { work_order: multiOrder, execution_specs: multiSpecs }; + f.workOrderPrograms[multiOrder.id] = f.evidenceProgramId; + for (const [suffix, spec] of [["a", multiSpecs[0]], ["b", multiSpecs[1]]]) { + const taskId = `m-ccm-multi-${suffix}`; + debrisAuthority.reserveTask(taskId); + f.busMessages.push(handoff( + taskId, + triple(multiOrder.id, workOrderDigest(multiOrder), executionSpecDigest(spec)), + )); + } + const multiBefore = f.getEvidencePosts().length; + r = await run({ env: { FAKE_VINCI_COMMIT_FILE: "ccm-multi.txt", ...evidenceEnv } }); + assert.equal(r.status, 0, r.stderr); + vinciRuns += 2; + const multiPosts = f.getEvidencePosts().slice(multiBefore); + assert.equal(multiPosts.length, 2); + assert.deepEqual(multiPosts.map((p) => p.job_ref), [multiOrder.id, multiOrder.id]); + assert.deepEqual( + multiPosts.map((p) => p.economics_summary.attempt_label).sort(), + ["m-ccm-multi-a/1", "m-ccm-multi-b/1"], + ); + assert.notEqual(multiPosts[0].sha256, multiPosts[1].sha256, "distinct runs retain distinct durable bundles"); + for (const taskId of ["m-ccm-multi-a", "m-ccm-multi-b"]) { + const terminal = f.getPostedMessages().filter((m) => m.in_reply_to === taskId).at(-1); + assert.deepEqual(terminal.refs, [multiOrder.id]); + } } console.log("PASS worker-handoff-triple"); diff --git a/vinci/worker/bus.mjs b/vinci/worker/bus.mjs index 23849d5d..869ae76b 100644 --- a/vinci/worker/bus.mjs +++ b/vinci/worker/bus.mjs @@ -1,6 +1,10 @@ import { DEFAULT_OUTBOX_DIR, clearPending, recordPending } from "./outbox.mjs"; const LEDGER_REF = /^(?:job|exp|bk)_[A-Za-z0-9][A-Za-z0-9._-]*$/; +// Option 1 of vinci-gpu-control#295: a WorkOrder remains the canonical identity. `wo-` is +// therefore an evidence/message ref only after the caller has resolved it from a validated +// contract. This predicate is syntax only; existence and program binding stay server-side. +const WORK_ORDER_EVIDENCE_REF = /^wo-[A-Za-z0-9][A-Za-z0-9._:-]{0,124}$/; // A terminal record says the task is OVER. The consumer keys human attention on // `outcome !== "COMPLETED"`, so this field is load-bearing: it is what lets a failure be @@ -16,6 +20,14 @@ export function isLedgerRef(value) { return typeof value === "string" && LEDGER_REF.test(value); } +export function isWorkOrderEvidenceRef(value) { + return typeof value === "string" && WORK_ORDER_EVIDENCE_REF.test(value); +} + +export function isEvidenceRef(value) { + return isLedgerRef(value) || isWorkOrderEvidenceRef(value); +} + // Production rows are not all shaped like the fixtures: rows older than the server-recorded // `posted_by` (bus PR #70) carry null there, and `body` can be null. Tolerate nulls for // optional text; reject only rows that cannot be routed (no id, no kind, no ts, or a @@ -121,8 +133,8 @@ export class BusClient { if (options.outcome !== undefined && !TERMINAL_OUTCOMES.has(options.outcome)) { throw new Error(`worker outcome must be one of ${[...TERMINAL_OUTCOMES].join(", ")} (got ${options.outcome})`); } - if (options.refs !== undefined && (!Array.isArray(options.refs) || options.refs.some((ref) => !isLedgerRef(ref)))) { - throw new Error("worker refs must be job_, exp_, or bk_ ledger refs"); + if (options.refs !== undefined && (!Array.isArray(options.refs) || options.refs.some((ref) => !isEvidenceRef(ref)))) { + throw new Error("worker refs must be job_, exp_, bk_, or validated wo- evidence refs"); } if (kind === "finding" && (!Array.isArray(options.refs) || options.refs.length === 0)) { throw new Error("finding messages require refs"); diff --git a/vinci/worker/evidence.mjs b/vinci/worker/evidence.mjs index 5da60351..1453e118 100644 --- a/vinci/worker/evidence.mjs +++ b/vinci/worker/evidence.mjs @@ -5,7 +5,7 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { delimiter, join, resolve } from "node:path"; -import { isLedgerRef } from "./bus.mjs"; +import { isEvidenceRef, isLedgerRef } from "./bus.mjs"; import { checkFence } from "./publisher.mjs"; function resolveBin(name) { @@ -57,22 +57,21 @@ function command(commandName, args) { }); } -// Which ledger row an evidence bundle is filed under. +// Which canonical row an evidence bundle is filed under. // // A PROSE handoff names its row in `ref:`. A GOVERNED (contract) handoff does not: task.mjs // builds its envelope with `ref: undefined` and carries the identity in the contract triple // instead, so `isLedgerRef(envelope.ref)` was false and the bundle was never POSTed at all — // the summary landed on disk and nothing reached the ledger. // -// PRECONDITION, and it is not satisfied everywhere: this only files a governed bundle when the -// contract's `work_order_id` is itself a ledger ref. `WORK_ORDER_ID` (task.mjs) is broader than -// `LEDGER_REF`, ids are caller-supplied rather than minted in one shape, and the order registries -// in vinci-gpu-control today use `wo-`-shaped ids while live backlog rows are `bk_`. So for a -// `wo-` order this changes nothing at all — the governed path stays unfiled until the issuer -// mints ledger-shaped work-order ids. Do not read this as "governed attempts now reach the -// ledger"; read it as "they reach it exactly when their id names a ledger row". +// The #295 ruling keeps WorkOrder as the canonical identity and admits `wo-` evidence refs. That +// does NOT make an arbitrary `wo-` string authority: this branch is enabled only for the +// `contractFields` returned after task.mjs has fetched an existing registry entry, validated both +// records, recomputed both digests, and proved their binding. The evidence server remains the +// authority for whether that WorkOrder is bound to the program; a refusal there is recorded as an +// evidence failure. No backlog id is substituted, and there is still one `/v1/evidence` ingress. // -// A non-ledger contract id returns null rather than falling through to the envelope ref. The +// An unvalidated or inadmissible contract id returns null rather than falling through to the envelope ref. The // summary takes contract-first UNCONDITIONALLY (economics.mjs), so falling through could file // the bundle under a row the summary does not name. The ledger records that as an // `ECONOMICS_REFUSED binding:work_order_mismatch` EVENT and still stores the evidence row — @@ -82,14 +81,17 @@ function command(commandName, args) { export function resolveEvidenceRef(input) { // A default parameter covers `undefined` only; an explicit `null` would throw on destructure, // and this runs on the terminal path where a throw loses the whole evidence bundle. - const { contractWorkOrderId = null, envelopeRef = null } = (typeof input === "object" && input !== null) ? input : {}; - if (isLedgerRef(contractWorkOrderId)) return contractWorkOrderId; - // A contract that named an id we cannot file under does not get to fall back to some other - // row: refuse the POST instead of misfiling it. - if (typeof contractWorkOrderId === "string" && contractWorkOrderId) return null; - // Only a string or null leaves here. Anything else would reach `isLedgerRef` at the gate - // (which would refuse it) and the POST body (which would not), so it is normalised once. - return typeof envelopeRef === "string" ? envelopeRef : null; + const { contractWorkOrderId = null, contractValidated = false, envelopeRef = null } = + (typeof input === "object" && input !== null) ? input : {}; + if (contractValidated === true) { + return isEvidenceRef(contractWorkOrderId) ? contractWorkOrderId : null; + } + // A supplied contract identity without validation never borrows a prose ref. This is the + // wrong-type/stale-call-site failure direction: post nothing, never a plausible wrong row. + if (contractWorkOrderId !== null && contractWorkOrderId !== undefined) return null; + // Prose remains on the original closed namespace. In particular, spelling `ref: wo-...` in a + // prose handoff cannot bypass the contract-registry validation above. + return isLedgerRef(envelopeRef) ? envelopeRef : null; } export async function uploadEvidence({ @@ -149,15 +151,14 @@ export async function uploadEvidence({ const bytes = statSync(tarPath).size; - // Post evidence metadata to the bus evidence endpoint. Only ledger refs - // (job_/exp_/bk_) are attached as refs; any other ref (or none) skips the - // bus entirely — the server would reject the post with 422. + // Post evidence metadata to the bus evidence endpoint. Legacy ledger refs and a validated + // WorkOrder ref are admitted; any other ref (or none) skips the bus entirely. // This gate is MASKED by the resolver's gate: widening it alone changes no observable // behaviour, because resolveEvidenceRef has already returned null for anything that would // fail here. It is defence in depth, not dead code — no test fails when it alone is // removed, which is exactly the evidence that gets a real guard deleted. See the mutation // table in worker/test/evidence-ref.test.mjs. - if (busUrl && busToken && isLedgerRef(ref)) { + if (busUrl && busToken && isEvidenceRef(ref)) { // Wave 1B L3: the evidence POST is a consequential side effect — ask the lease fence first. // A stale generation records `fenced_out:` and never reaches the ledger. if (fence) { diff --git a/vinci/worker/test/evidence-ref.test.mjs b/vinci/worker/test/evidence-ref.test.mjs index 21720264..c20b15b7 100644 --- a/vinci/worker/test/evidence-ref.test.mjs +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -1,91 +1,96 @@ -// Which ledger row an evidence bundle is filed under (CCM-v0 follow-up to #49). +// Canonical evidence identity for CCM-v0 (#53 / #54, after the #295 option-1 ruling). // -// Before this, a GOVERNED handoff posted no evidence at all: task.mjs sets `ref: undefined` -// on a contract envelope, so `isLedgerRef(ref)` was false and uploadEvidence skipped the bus. -// The economics summary was written to disk and never reached the ledger, which is the join -// the whole measurement depends on. -// WHY THESE UNIT TESTS ARE NOT REDUNDANT WITH THE INTEGRATION CONTROL. -// -// There are two gates on the evidence ref: the resolver's (`isLedgerRef` inside -// resolveEvidenceRef) and the POST's (`isLedgerRef(ref)` before the fetch). They MASK EACH -// OTHER, so no single control discriminates either one alone. Measured by mutation against -// worker-handoff-triple.mjs at this head, re-run after every change below: -// -// widen the RESOLVER gate only -> integration SURVIVES; these unit tests FAIL -// widen the POST gate only -> integration SURVIVES; these unit tests PASS -// (masked: the resolver already returned null, so the -// widened gate is never reached) -// widen BOTH gates -> integration FAILS -// -// So the integration test pins the PAIR, and the resolver's own gate is pinned ONLY here. -// Deleting these as "already covered by the integration test" would silently unpin the -// resolver gate and leave a widened POST gate undetectable by any test in the repo. -// -// The both-gates row is load-bearing and was NOT free: it holds only because the integration -// test asserts `f.rejectedPosts` is empty. Once the fake bus began enforcing the server's -// job_ref rule, a widened pair stopped producing an EXTRA accepted post (which a count -// assertion caught) and started producing a REFUSED one (which no count assertion sees). -// The measured table said FAILS while the code said SURVIVES for one commit, which is how -// the missing assertion was found. If that assertion is removed, this row reverts to -// SURVIVES and nothing in the repo detects a widened pair. +// Discriminating mutations, re-run against this file plus worker-handoff-triple.mjs: +// - admit a raw `wo-` string without validated contract provenance -> unit failure +// - restore the POST gate to job_/exp_/bk_ only -> integration failure +// - derive the terminal ref again from envelope.ref -> integration failure +// - substitute a bk_ backlog identity for the WorkOrder -> integration failure +// The integration control also requires zero server refusals on the positive path, so a client +// and fake-server widening cannot mask each other. import { test } from "node:test"; import assert from "node:assert/strict"; +import { isEvidenceRef, isLedgerRef, isWorkOrderEvidenceRef } from "../bus.mjs"; import { resolveEvidenceRef } from "../evidence.mjs"; -import { isLedgerRef } from "../bus.mjs"; -test("a governed handoff files under its work order id", () => { - assert.equal( - resolveEvidenceRef({ contractWorkOrderId: "bk_9f2c1d", envelopeRef: undefined }), - "bk_9f2c1d", - ); +test("the evidence namespace adds only a bounded wo- shape", () => { + for (const ref of ["wo-a", "wo-g1-n1", `wo-${"a".repeat(125)}`]) { + assert.equal(isWorkOrderEvidenceRef(ref), true, ref); + assert.equal(isEvidenceRef(ref), true, ref); + assert.equal(isLedgerRef(ref), false, `${ref} is not a legacy ledger ref`); + } + for (const ref of ["wo-", "wo-a/b", " wo-a", "wo-a ", `wo-${"a".repeat(126)}`, 7, null]) { + assert.equal(isWorkOrderEvidenceRef(ref), false, JSON.stringify(ref)); + } }); -test("the contract id wins over an envelope ref, and they are told apart", () => { - // Deliberately DIFFERENT values: a fixture where both sources coincide cannot discriminate. +test("a governed handoff files under its validated WorkOrder", () => { + assert.equal( + resolveEvidenceRef({ + contractWorkOrderId: "wo-g1-n1", + contractValidated: true, + envelopeRef: undefined, + }), + "wo-g1-n1", + ); + // Existing caller-supplied WorkOrder ids remain valid when the validated record really uses + // that id; this is not a backlog substitution because the value comes from the contract. assert.equal( - resolveEvidenceRef({ contractWorkOrderId: "bk_contract", envelopeRef: "bk_envelope" }), + resolveEvidenceRef({ contractWorkOrderId: "bk_contract", contractValidated: true }), "bk_contract", ); }); -test("a prose handoff is unchanged", () => { - assert.equal(resolveEvidenceRef({ contractWorkOrderId: null, envelopeRef: "job_17" }), "job_17"); +test("a raw wo- string has no authority without validated contract provenance", () => { + for (const contractValidated of [undefined, false, null, "true", 1]) { + assert.equal( + resolveEvidenceRef({ + contractWorkOrderId: "wo-g1-n1", + contractValidated, + envelopeRef: "bk_fallback", + }), + null, + String(contractValidated), + ); + } }); -test("a non-ledger work order id REFUSES rather than misfiling", () => { - // task.mjs's WORK_ORDER_ID admits ids LEDGER_REF does not (the golden vector's "wo-vec-1", - // and every order registry in vinci-gpu-control today). Such an order must post NOTHING. - // Falling back to the envelope ref would file the bundle under a row the economics summary - // does not name — the summary takes contract-first unconditionally. The ledger records that - // as an ECONOMICS_REFUSED binding:work_order_mismatch event but still stores the evidence row - // (economics never blocks evidence), so the misfile persists. Refusal here, not misfiling. - assert.ok(!isLedgerRef("wo-vec-1"), "test premise: wo- ids are not ledger refs"); - assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: undefined }), null); - assert.equal(resolveEvidenceRef({ contractWorkOrderId: "wo-vec-1", envelopeRef: "job_5" }), null, - "a contract naming an unfilable row does not borrow another row"); - // And the gate is still not widened: nothing non-ledger ever leaves here. - for (const id of ["wo-vec-1", "wo-example-001", "../etc", "bk", "bk_", " bk_1"]) { - const out = resolveEvidenceRef({ contractWorkOrderId: id, envelopeRef: undefined }); - assert.ok(out === null || isLedgerRef(out), `${id} -> ${out}`); +test("a present contract identity yields itself or nothing, never a third row", () => { + for (const [contractWorkOrderId, envelopeRef, expected] of [ + ["wo-g1-n1", "bk_backlog", "wo-g1-n1"], + ["bk_actual_work_order", "bk_backlog", "bk_actual_work_order"], + ["caller-id", "bk_backlog", null], + ["wo-", "bk_backlog", null], + [7, "bk_backlog", null], + [null, "bk_backlog", null], + ]) { + assert.equal( + resolveEvidenceRef({ contractWorkOrderId, contractValidated: true, envelopeRef }), + expected, + `${JSON.stringify(contractWorkOrderId)} / ${envelopeRef}`, + ); } }); -test("a contract id that disagrees with the summary cannot be filed under a third row", () => { - // The summary uses contract-first unconditionally; if the resolver used anything else while - // the contract id was present, job_ref and summary.work_order_id would disagree. - for (const [contractId, envelopeRef] of [["bk_a", "bk_b"], ["wo-x", "bk_b"], ["bk_a", undefined]]) { - const out = resolveEvidenceRef({ contractWorkOrderId: contractId, envelopeRef }); - assert.ok(out === null || out === contractId, - `a present contract id must yield itself or nothing, got ${out} for ${contractId}/${envelopeRef}`); +test("prose handoffs stay on the legacy namespace and cannot spell a wo- bypass", () => { + for (const ref of ["job_17", "exp_run", "bk_row"]) { + assert.equal(resolveEvidenceRef({ envelopeRef: ref }), ref); + } + for (const ref of ["wo-g1-n1", "caller-id", "bk_", " bk_1", "", [], 7]) { + assert.equal(resolveEvidenceRef({ envelopeRef: ref }), null, JSON.stringify(ref)); } }); -test("malformed input never throws and never invents a ref", () => { - for (const input of [undefined, null, {}, { contractWorkOrderId: 7 }, { envelopeRef: [] }, - { contractWorkOrderId: "", envelopeRef: "" }, { contractWorkOrderId: "../etc", envelopeRef: undefined }]) { - const out = resolveEvidenceRef(input); - assert.ok(out === null || typeof out === "string", `${JSON.stringify(input)} -> ${JSON.stringify(out)}`); +test("missing and malformed inputs never throw or invent a ref", () => { + for (const input of [ + undefined, + null, + {}, + [], + { contractWorkOrderId: undefined, envelopeRef: undefined }, + { contractWorkOrderId: "../etc", contractValidated: true }, + { contractValidated: true, envelopeRef: "job_fallback" }, + ]) { + assert.equal(resolveEvidenceRef(input), null, JSON.stringify(input)); } - assert.equal(resolveEvidenceRef(), null); }); diff --git a/vinci/worker/worker.mjs b/vinci/worker/worker.mjs index 87ea5111..d7d8ca9b 100644 --- a/vinci/worker/worker.mjs +++ b/vinci/worker/worker.mjs @@ -12,7 +12,7 @@ import { import { join, resolve } from "node:path"; import { replayPending } from "./outbox.mjs"; -import { BusClient, isLedgerRef } from "./bus.mjs"; +import { BusClient, isEvidenceRef } from "./bus.mjs"; import { command, finalState, noCommitOutcome, prepareRepository, publish, readHead, runVinci } from "./run.mjs"; import { childEnv, DEFAULT_DISK_FLOOR_MB, DEFAULT_KEEP_ATTEMPTS, markEvidenceUploaded, prepareCleanRoom, pruneAttempts, publishFromCache, sealAttemptDir } from "./cleanroom.mjs"; import { assertTaskId, contractTag, DEFAULT_ALLOWED_PROVIDERS, isDigestHandoff, loadModelClasses, materializeEnvelope, parseAllowedProviders, parseEnvelope, parseHandoffTriple, providerAllowed, TaskLifecycle, vinciBinaryRecord } from "./task.mjs"; @@ -570,7 +570,7 @@ function blockerPostBody(record, details, fallback = null) { return terminalPostBody(tag ? `${tag} ${details}` : details); } -async function postFinal(bus, message, envelope, state, evidence, economicsSha = null) { +async function postFinal(bus, message, evidenceRef, state, evidence, economicsSha = null) { const subject = `task ${message.message_id} ${state.state.toLowerCase()}`; // uri/sha256 are advertised only when the bundle actually reached S3 (`uploaded === true`, // set by uploadEvidence solely after a successful `aws s3 cp`); a failed upload also carries @@ -625,8 +625,8 @@ async function postFinal(bus, message, envelope, state, evidence, economicsSha = const options = { inReplyTo: message.message_id }; const outcome = terminalOutcome(state.state); if (outcome !== null) options.outcome = outcome; - if (state.state === "COMPLETED" && isLedgerRef(envelope.ref)) { - options.refs = [envelope.ref]; + if (state.state === "COMPLETED" && isEvidenceRef(evidenceRef)) { + options.refs = [evidenceRef]; await bus.postTerminal("finding", subject, body, options); } else if (state.state === "BLOCKED" && state.harness_stop) { // An instrument stop: the harness refused the agent's work mid-run. Say so explicitly so the @@ -837,6 +837,14 @@ async function processHandoff( } + // Resolve the canonical evidence identity ONCE and use the same value for both the durable + // evidence row and the terminal bus row. `contractFields` exists only after registry fetch, + // record validation, digest recomputation and order/spec binding all succeeded. + const evidenceRef = resolveEvidenceRef({ + contractWorkOrderId: contractFields?.work_order_id ?? null, + contractValidated: contractFields !== null, + envelopeRef: envelope.ref ?? null, + }); const attempt = lifecycle.startAttempt({ id: taskId, envelope }, version, { workerBuild, serverBuild, vinciBinary }); // Wave 1B: stamp the record with the materialized contract (work_order_id, both digests, // base_commit, promotion) so the snapshot and every terminal post can cite the handoff. @@ -1255,7 +1263,7 @@ async function processHandoff( lifecycle.transition("BLOCKED", { outcome: { reason }, publish: "skipped", pr: null, fenced_out: reason }); await releaseLease("BLOCKED"); const econBranch = await emitEconomics({ taskId, attempt: lifecycle.snapshot().attempt ?? 0, stateDir, envelopeToUse, lease, lifecycle, contractFields, sessionId: attempt?.sessionId ?? null }); - await postFinal(bus, message, envelopeToUse, lifecycle.snapshot(), null, econBranch.sha256); + await postFinal(bus, message, evidenceRef, lifecycle.snapshot(), null, econBranch.sha256); return true; } branchLease = acquired.lease; @@ -1275,7 +1283,7 @@ async function processHandoff( lifecycle.transition("BLOCKED", { outcome: { reason: authorityLost }, publish: "skipped", pr: null, fenced_out: authorityLost, lease: { ...lifecycle.snapshot().lease, ...lease } }); await releaseLease("BLOCKED"); const econLost = await emitEconomics({ taskId, attempt: lifecycle.snapshot().attempt ?? 0, stateDir, envelopeToUse, lease, lifecycle, contractFields, sessionId: attempt?.sessionId ?? null }); - await postFinal(bus, message, envelopeToUse, lifecycle.snapshot(), null, econLost.sha256); + await postFinal(bus, message, evidenceRef, lifecycle.snapshot(), null, econLost.sha256); return true; } // #18: probe the binary IMMEDIATELY before the spawn — after the Governor lease and the clone, @@ -1468,8 +1476,8 @@ async function processHandoff( taskId, busUrl: bus.serverUrl, busToken: bus.token, - // A governed handoff files its bundle under the work order; a prose one under `ref:`. - ref: resolveEvidenceRef({ contractWorkOrderId: contractFields?.work_order_id ?? null, envelopeRef: envelopeToUse.ref ?? null }), + // A governed handoff files under the validated WorkOrder; prose stays on its ledger ref. + ref: evidenceRef, fence: lease ? fence : null, economics: { summary: economicsSummary, sha256: economicsSha }, extraFiles, @@ -1508,7 +1516,7 @@ async function processHandoff( // L4: release with the committed state's outcome, BEFORE the final post so the lease is not // held across a bus retry. A release failure is logged; the state above is already final. await releaseLease(state); - await postFinal(bus, message, envelopeToUse, lifecycle.snapshot(), evidenceResult, economicsSha); + await postFinal(bus, message, evidenceRef, lifecycle.snapshot(), evidenceResult, economicsSha); } catch (error) { // A terminal state is immutable: if the failure happened after it was committed (e.g. the // final bus post), surface the error to the daemon loop instead of rewriting the record. @@ -1532,7 +1540,7 @@ async function processHandoff( await releaseLease("FAILED"); // A session may already have run and spent here (exception after runVinci): read it. const econFailed = await emitEconomics({ taskId, attempt: lifecycle.snapshot().attempt ?? 0, stateDir, envelopeToUse: envelope, lease: lease ?? null, lifecycle, contractFields, sessionId: lifecycle.snapshot().session_id ?? null }); - await postFinal(bus, message, envelope, lifecycle.snapshot(), null, econFailed.sha256); + await postFinal(bus, message, evidenceRef, lifecycle.snapshot(), null, econFailed.sha256); } return true; }