diff --git a/vinci/test/lib/worker-fixture.mjs b/vinci/test/lib/worker-fixture.mjs index ec749fe9..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" }); @@ -480,11 +495,33 @@ process.exit(r.status ?? 1); }); request.on("end", () => { const evidence = JSON.parse(body); - const invalidRefs = (evidence.refs ?? []).filter((ref) => !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. + // + // 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. 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"] + .filter((field) => typeof evidence[field] !== "string" || !evidence[field].trim()); + const invalidRefs = missing.includes("job_ref") + ? [] + : [evidence.job_ref, ...(evidence.refs ?? [])] + .filter((ref) => !this.acceptsEvidenceRef(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) { diff --git a/vinci/test/run.sh b/vinci/test/run.sh index a78a554a..d31ffe25 100644 --- a/vinci/test/run.sh +++ b/vinci/test/run.sh @@ -304,6 +304,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..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,6 +964,214 @@ try { assert.equal(f.getVinciCalls().length, vinciRuns, "neither invalid handoff spawns"); } + // --- CCM-v0: governed evidence and its terminal share the validated WorkOrder identity --- + // + // 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; + + // 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( + 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, + "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: 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( + 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; + + 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, "wo-ccm7", "the bundle is filed under the WorkOrder, never a backlog surrogate"); + assert.equal(post.kind, "bundle"); + + // 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}$/); + + 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(woFail, specFor(woFail, { 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.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"); } finally { await f.cleanup(); 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 3dd8131e..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,6 +57,43 @@ function command(commandName, args) { }); } +// 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. +// +// 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. +// +// 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 — +// 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, + // and this runs on the terminal path where a throw loses the whole evidence bundle. + 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({ sessionJsonl, gitDiff, @@ -114,10 +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. - if (busUrl && busToken && isLedgerRef(ref)) { + // 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 && 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 new file mode 100644 index 00000000..c20b15b7 --- /dev/null +++ b/vinci/worker/test/evidence-ref.test.mjs @@ -0,0 +1,96 @@ +// Canonical evidence identity for CCM-v0 (#53 / #54, after the #295 option-1 ruling). +// +// 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"; + +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("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", contractValidated: true }), + "bk_contract", + ); +}); + +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 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("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("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)); + } +}); diff --git a/vinci/worker/worker.mjs b/vinci/worker/worker.mjs index 3edee32c..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"; @@ -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"; @@ -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,7 +1476,8 @@ async function processHandoff( taskId, busUrl: bus.serverUrl, busToken: bus.token, - ref: envelopeToUse.ref, + // 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, @@ -1507,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. @@ -1531,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; }