From 991436a201be1aeeb876ec9da005157afbc1561d Mon Sep 17 00:00:00 2001 From: random block Date: Fri, 31 Jul 2026 08:26:58 +0100 Subject: [PATCH 1/2] feat(directory): show verified delivery during DACS-5 finalisation --- .../dacs-directory/app/globals.css | 4 +- .../dacs-directory/e2e/try-dacs-fixtures.ts | 51 +++++++ .../dacs-directory/e2e/try-dacs.spec.ts | 37 +++++ .../dacs-directory/src/components/TryDacs.tsx | 87 +++++++++++- .../src/components/try-dacs-contract.ts | 118 ++++++++++++++++ .../test/butler-contract.test.ts | 129 ++++++++++++++++++ 6 files changed, 424 insertions(+), 2 deletions(-) diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index 05df0a5..c055d09 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -135,6 +135,7 @@ a.card:hover { border-color: var(--border-hover); background: var(--bg-card-hove .badge.rail { background: var(--cyan-soft); color: var(--cyan-strong); border-color: var(--cyan-border); } .badge.ok { background: var(--green-soft); color: var(--green-strong); border-color: var(--green-border); } .badge.err { background: var(--red-soft); color: var(--red-strong); border-color: var(--red-border); } +.badge.pending { background: var(--accent-soft); color: var(--accent-strong); border-color: var(--accent-border); } /* ── Stats ────────────────────────────────────────────────────────────────── */ .stat-row { display: flex; flex-wrap: wrap; gap: 16px; margin: 24px 0; } @@ -549,6 +550,7 @@ textarea.form-control { min-height: 112px; resize: vertical; } @keyframes livePulse { 0% { box-shadow: 0 0 0 0 rgba(105,62,255,.35); } 70% { box-shadow: 0 0 0 7px rgba(105,62,255,0); } 100% { box-shadow: 0 0 0 0 rgba(105,62,255,0); } } .full-proc-result { background: var(--bg-subtle); }.full-proc-result > .result-title { background: var(--bg-card); border-bottom: 1px solid var(--border); }.proc-report { padding: 22px; display: grid; gap: 18px; }.proc-summary { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.proc-summary > div { min-width: 0; padding: 17px; background: var(--bg-card); }.proc-summary span,.proc-panel-head span,.tx-link small { display: block; color: var(--text-muted); font-size: .61rem; font-weight: 800; letter-spacing: .08em; }.proc-summary strong { display: block; overflow-wrap: anywhere; margin-top: 7px; color: var(--text-primary); font-size: .82rem; }.success-text { color: #10845b !important; }.error-text { color: var(--red-strong) !important; }.muted-text { color: var(--text-muted) !important; }.proc-panel { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 17px 18px; border-bottom: 1px solid var(--border); }.proc-panel-head h3 { margin-top: 4px; color: var(--text-primary); font-size: .9rem; }.payment-panel { border-color: rgba(16,132,91,.28); }.payment-panel.unverified-panel { border-color: var(--red-border); }.tx-link { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px 18px; background: rgba(16,132,91,.045); color: inherit; text-decoration: none; border-bottom: 1px solid var(--border); }.tx-link.missing-evidence { background: var(--red-soft); }.tx-link span { min-width: 0; }.tx-link code { display: block; overflow: hidden; margin-top: 6px; color: var(--text-primary); font-size: .72rem; text-overflow: ellipsis; }.tx-link b { flex: none; color: #10845b; font-size: .7rem; }.party-row { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 12px 18px; color: var(--text-muted); font-size: .65rem; }.party-row code { margin-left: 4px; color: var(--text-secondary); }.party-row i { color: var(--accent); font-style: normal; }.candidate-row { display: grid; grid-template-columns: 1.5fr .55fr .65fr 1.3fr; gap: 12px; align-items: center; padding: 13px 18px; border-bottom: 1px solid var(--border); color: var(--text-secondary); font-size: .7rem; }.candidate-row:last-child { border-bottom: 0; }.candidate-row strong,.candidate-row small { display: block; }.candidate-row small { margin-top: 3px; color: var(--text-muted); font: .59rem var(--font-scp, monospace); }.candidate-head { color: var(--text-muted); background: var(--bg-subtle); font-size: .6rem; font-weight: 800; letter-spacing: .06em; }.finding-list article { display: grid; grid-template-columns: 74px 1fr; gap: 14px; padding: 16px 18px; border-bottom: 1px solid var(--border); }.finding-list article:last-child { border-bottom: 0; }.severity { align-self: start; padding: 5px 7px; border-radius: 6px; background: rgba(180,95,0,.1); color: #9a5300; text-align: center; font-size: .6rem; font-weight: 800; text-transform: uppercase; }.severity.high,.severity.critical { color: #b72f3c; background: rgba(183,47,60,.1); }.severity.low,.severity.info { color: #2769a8; background: rgba(39,105,168,.1); }.finding-list strong { color: var(--text-primary); font-size: .76rem; }.finding-list p { margin: 5px 0; color: var(--text-secondary); font-size: .7rem; line-height: 1.5; }.finding-list code { color: var(--text-muted); font-size: .62rem; }.empty-report { padding: 18px; color: var(--text-muted); font-size: .74rem; }.verify-grid { display: grid; grid-template-columns: repeat(4,1fr); }.verify-grid > div { display: flex; gap: 10px; align-items: center; padding: 17px; border-right: 1px solid var(--border); }.verify-grid > div:last-child { border-right: 0; }.verify-grid i { display: grid; place-items: center; flex: 0 0 24px; height: 24px; border-radius: 50%; color: #10845b; background: rgba(16,132,91,.09); font-style: normal; font-size: .7rem; }.verify-grid .failed-check i { color: var(--red-strong); background: var(--red-soft); }.verify-grid .failed-check strong { color: var(--red-strong); }.verify-grid span,.verify-grid strong { display: block; }.verify-grid span { color: var(--text-muted); font-size: .62rem; }.verify-grid strong { margin-top: 3px; color: var(--text-primary); font-size: .7rem; }.receipt-row { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--border); }.receipt-index { color: var(--text-muted); font: .62rem var(--font-scp, monospace); }.receipt-row strong,.receipt-row small { display: block; }.receipt-row strong { color: var(--text-secondary); font-size: .7rem; }.receipt-row small { overflow: hidden; margin-top: 3px; color: var(--text-muted); font: .6rem var(--font-scp, monospace); text-overflow: ellipsis; }.receipt-row a { color: var(--accent); font: .63rem var(--font-scp, monospace); text-decoration: none; }.anchor-details,.raw-result { border-top: 1px solid var(--border); }.proc-report .raw-result { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-report details pre { max-height: 420px; }.anchor-details summary,.raw-result summary { padding: 13px 18px !important; } .oracle-delivery { padding: 22px 18px; }.oracle-delivery span,.oracle-delivery strong,.oracle-delivery small { display: block; }.oracle-delivery span { color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); text-transform: uppercase; }.oracle-delivery strong { margin-top: 8px; color: var(--text-primary); font-size: 2rem; letter-spacing: -.04em; }.oracle-delivery small { margin-top: 8px; overflow-wrap: anywhere; color: var(--text-muted); font-size: .66rem; } +.preview-proc-result { border-color: var(--accent-border); box-shadow: 0 16px 50px rgba(105,62,255,.08); }.proc-preview { background: var(--bg-card); }.proc-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--border); background: var(--accent-soft); }.proc-preview-head > div > span { display: block; color: var(--accent-strong); font: 800 .61rem var(--font-scp, monospace); letter-spacing: .1em; }.proc-preview-head h2 { margin-top: 5px; color: var(--text-primary); font-size: 1.05rem; }.proc-preview-state { display: grid; grid-template-columns: repeat(2,1fr); border-bottom: 1px solid var(--border); }.proc-preview-state > div { padding: 16px 22px; }.proc-preview-state > div + div { border-left: 1px solid var(--border); }.proc-preview-state strong,.proc-preview-state small { display: block; }.proc-preview-state strong { color: var(--text-primary); font-size: .76rem; }.proc-preview-state small { margin-top: 5px; color: var(--text-muted); font-size: .68rem; line-height: 1.5; }.preview-delivery { border-bottom: 1px solid var(--border); }.preview-fields { display: grid; grid-template-columns: repeat(4,1fr); border-bottom: 1px solid var(--border); }.preview-fields > div { min-width: 0; padding: 16px 18px; border-right: 1px solid var(--border); }.preview-fields > div:nth-child(4n) { border-right: 0; }.preview-fields span,.preview-fields strong { display: block; overflow-wrap: anywhere; }.preview-fields span { color: var(--text-muted); font-size: .6rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }.preview-fields strong { margin-top: 6px; color: var(--text-primary); font-size: .75rem; }.proc-preview-warning { display: flex; align-items: center; gap: 14px; padding: 14px 22px; color: var(--text-secondary); background: rgba(180,95,0,.07); border-bottom: 1px solid var(--border); }.proc-preview-warning strong { flex: none; color: #9a5300; font-size: .7rem; }.proc-preview-warning span { font-size: .68rem; line-height: 1.45; }.preview-anchor-list > div { display: grid; grid-template-columns: 130px minmax(0,1fr); gap: 12px; padding: 10px 18px; border-top: 1px solid var(--border); }.preview-anchor-list span { color: var(--text-muted); font-size: .64rem; text-transform: capitalize; }.preview-anchor-list code { overflow: hidden; color: var(--text-secondary); font-size: .64rem; text-overflow: ellipsis; } .try-agents { margin-top: 82px; }.try-section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; margin-bottom: 25px; }.try-section-head span { color: var(--accent-strong); font: 700 .63rem var(--font-scp, monospace); letter-spacing: .12em; }.try-section-head h2 { font-size: 1.8rem; letter-spacing: -.025em; margin-top: 5px; }.try-section-head > p { max-width: 420px; color: var(--text-muted); font-size: .8rem; } .try-agent-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }.try-agent-grid button { min-height: 190px; padding: 20px; text-align: left; color: var(--text-primary); background: var(--bg-card); border: 0; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); cursor: pointer; font-family: inherit; }.try-agent-grid button:nth-child(3n){border-right:0}.try-agent-grid button:nth-last-child(-n+3){border-bottom:0}.try-agent-grid button:hover { background: var(--accent-soft); }.try-agent-grid button > span { color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.try-agent-grid strong { display: block; font-size: .9rem; margin-top: 13px; }.try-agent-grid p { color: var(--text-muted); font-size: .72rem; line-height: 1.5; margin: 7px 0 20px; }.try-agent-grid i { color: var(--accent-strong); font-size: .7rem; font-style: normal; font-weight: 700; } @@ -563,7 +565,7 @@ textarea.form-control { min-height: 112px; resize: vertical; } @media (max-width: 580px) { main { padding-left: 16px; padding-right: 16px; }.nav-inner { padding-left: 16px; padding-right: 16px; }.try-hero { padding-top: 20px; }.try-hero h1 { font-size: 16vw; } .chat-body { padding: 20px 15px; }.bubble { max-width: 92%; }.ask-box,.job-box,.agent-picker { padding: 18px 15px; }.picker-grid { grid-template-columns: 1fr; }.job-head,.job-actions,.try-section-head,.rail-picker-head,.rail-disclosure,.rail-governance { align-items: stretch; flex-direction: column; }.rail-options { grid-template-columns: 1fr; }.job-actions .try-primary { width: 100%; }.try-agent-grid { grid-template-columns: 1fr; }.try-agent-grid button { border-right: 0 !important; border-bottom: 1px solid var(--border) !important; }.try-agent-grid button:last-child { border-bottom: 0 !important; } - .live-flow { padding: 18px 15px; }.live-flow-head,.proc-panel-head,.tx-link,.result-title { align-items: stretch; flex-direction: column; }.proc-report { padding: 12px; gap: 12px; }.proc-summary { grid-template-columns: 1fr; }.candidate-head { display: none; }.candidate-row { grid-template-columns: 1fr 1fr; }.candidate-row > span:last-child { grid-column: 1 / -1; }.verify-grid { grid-template-columns: 1fr; }.verify-grid > div { border-right: 0; border-bottom: 1px solid var(--border); }.verify-grid > div:last-child { border-bottom: 0; }.finding-list article { grid-template-columns: 1fr; }.receipt-row { grid-template-columns: 26px minmax(0,1fr); }.receipt-row > a,.receipt-row > .muted-text { grid-column: 2; }.party-row { align-items: flex-start; flex-direction: column; }.party-row i { transform: rotate(90deg); } + .live-flow { padding: 18px 15px; }.live-flow-head,.proc-panel-head,.tx-link,.result-title,.proc-preview-head,.proc-preview-warning { align-items: stretch; flex-direction: column; }.proc-report { padding: 12px; gap: 12px; }.proc-summary,.proc-preview-state,.preview-fields { grid-template-columns: 1fr; }.proc-preview-state > div + div { border-left: 0; border-top: 1px solid var(--border); }.preview-fields > div { border-right: 0; border-bottom: 1px solid var(--border); }.candidate-head { display: none; }.candidate-row { grid-template-columns: 1fr 1fr; }.candidate-row > span:last-child { grid-column: 1 / -1; }.verify-grid { grid-template-columns: 1fr; }.verify-grid > div { border-right: 0; border-bottom: 1px solid var(--border); }.verify-grid > div:last-child { border-bottom: 0; }.finding-list article { grid-template-columns: 1fr; }.receipt-row { grid-template-columns: 26px minmax(0,1fr); }.receipt-row > a,.receipt-row > .muted-text { grid-column: 2; }.party-row { align-items: flex-start; flex-direction: column; }.party-row i { transform: rotate(90deg); }.preview-anchor-list > div { grid-template-columns: 1fr; } } /* ── /try agent input forms ────────────────────────────────────────────── */ diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts index d56db58..582f417 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts @@ -136,6 +136,13 @@ export const completedJob = { id: "job-e2e-1", status: "complete", phase: "complete", + preview: null, + finalisation: { + status: "complete", + startedAt: at, + updatedAt: at, + attempts: 1, + }, events: [ { phase: "discovering", label: "Signed listing verified", at, txRef: "mock-listing-tx" }, { phase: "selecting", label: "Counterparty vet anchored", at, txRef: "mock-vet-tx" }, @@ -146,6 +153,50 @@ export const completedJob = { result: acceptedResult, }; +export const securityPreviewJob = { + id: completedJob.id, + status: "running", + phase: "verifying", + events: [ + ...completedJob.events.slice(0, 4), + { phase: "verifying", label: "Seller delivery and payment evidence verified", at, txRef: "mock-delivery-tx" }, + ], + preview: { + kind: "dacs-procurement-delivery-preview", + status: "report-verified-finalising-dacs5", + jobId: "web-auditor-e2e-1", + delivery: { + verified: true, + report: { + version: 1, + target: "(posted content)", + findings: [{ + id: "SEC-1", + severity: "high", + title: "Unsafe dynamic execution", + detail: "A dynamic code path requires review.", + file: "app.js", + line: 4, + }], + }, + }, + anchors: { + listing: "mock-listing-anchor", + agreement: "mock-agreement-anchor", + commitment: "mock-commitment-anchor", + paymentEvidence: "mock-payment-evidence-anchor", + delivery: "mock-delivery-anchor", + deliveryEvidence: "mock-delivery-evidence-anchor", + }, + }, + finalisation: { + status: "running", + startedAt: at, + updatedAt: at, + attempts: 1, + }, +}; + const x402PaymentTx = `0x${"ab".repeat(32)}`; export const x402CompletedJob = { diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts index e3254b0..ecb572d 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts @@ -6,6 +6,7 @@ import { completedJob, expectAcceptedEvidence, installMockGateway, + securityPreviewJob, x402CompletedJob, } from "./try-dacs-fixtures.js"; @@ -211,4 +212,40 @@ test.describe("/try procurement browser safety", () => { await expect(quote).toHaveAttribute("aria-invalid", "true"); await expect(page.getByText("Required — enter the currency to convert to.")).toBeVisible(); }); + + test("8. shows verified delivery while DACS-5 remains nonterminal, then accepts only the completed job", async ({ context, page }) => { + let releaseFinalPoll!: () => void; + const finalPollGate = new Promise((resolve) => { releaseFinalPoll = resolve; }); + let previewReturned!: () => void; + const previewSeen = new Promise((resolve) => { previewReturned = resolve; }); + + await installMockGateway(context, { + onProcurementPost: async (route) => { + await fulfillJson(route, securityPreviewJob); + previewReturned(); + }, + onProcurementGet: async (route) => { + await finalPollGate; + await fulfillJson(route, completedJob); + }, + }); + await chooseProcurementExample(page); + + await page.getByRole("button", { name: /Run the full deal/ }).click(); + await previewSeen; + + await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); + await expect(page.getByText("DACS-5 finalising", { exact: true })).toBeVisible(); + await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); + await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); + await expect(page.getByText(/DELIVERY VERIFIED · DACS-5 FINALISING/)).toBeVisible(); + await expect(page.locator(".journey-step.active").filter({ hasText: "Verify" })).toContainText("two DACS-5 copies are finalising"); + await expect(page.locator(".journey-step.active")).toHaveCount(1); + await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); + await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); + + releaseFinalPoll(); + await expectAcceptedEvidence(page); + await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toHaveCount(0); + }); }); diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 3e76580..9c2ed46 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -19,7 +19,9 @@ import { type OutputReceipt, type PaymentRail, type ProcurementEvent, + type ProcurementFinalisation, type ProcurementJob, + type ProcurementPreview, type ProcurementProfile, } from "./try-dacs-contract.js"; import { @@ -254,6 +256,65 @@ function ProcurementReport({ value, events, profile, rail }: { value: unknown; e ); } +function ProcurementPreviewReport({ + preview, + finalisation, + profile, +}: { + preview: ProcurementPreview; + finalisation: ProcurementFinalisation; + profile: ProcurementProfile; +}) { + const report = preview.delivery.report; + const hasFindingList = Array.isArray(report.findings); + const findings = hasFindingList ? (report.findings as unknown[]).map(record) : []; + const summary = record(report.summary); + const primitiveFields = Object.entries(report).filter(([, value]) => + value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean", + ).slice(0, 8); + const anchorEntries = Object.entries(preview.anchors).filter((entry): entry is [string, string] => typeof entry[1] === "string"); + const finalising = finalisation.status === "running"; + const heading = preview.status === "report-verified-finalising-dacs5" + ? "Verified security report is ready" + : `Verified ${profile.agentName} delivery is ready`; + + return ( +
+
+
VERIFIED DELIVERY · NONTERMINAL

{heading}

+ {finalising ? "DACS-5 finalising" : "DACS-5 needs recovery"} +
+
+
Result available nowThe seller-signed delivery and confirmed payment evidence have been verified.
+
{finalising ? "Two bundle copies anchoring" : "Finalisation stopped safely"}{finalising ? `Attempt ${finalisation.attempts} · the job remains running until both sides reconcile.` : finalisation.lastError ?? "The verified delivery remains inspectable while the gateway recovers DACS-5."}
+
+ {profile.id === "oracle-auto-accept" ? ( +
+ {String(report.preset ?? report.kind ?? "attested value")} + {String(report.value ?? report.extract ?? "reported")} + {String(report.url ?? "Source recorded in the signed delivery attestation")} +
+ ) : hasFindingList ? ( + findings.length ? ( +
{findings.map((finding, index) => ( +
+ {String(finding.severity ?? "info")} +
{String(finding.title ?? finding.ruleId ?? finding.id ?? "Finding")}

{String(finding.detail ?? finding.rationale ?? "Attested finding")}

{String(finding.file ?? finding.rule ?? "evidence")}{finding.line !== undefined ? `:${String(finding.line)}` : ""}
+
+ ))}
+ ) :

No findings were returned by the verified security report.

+ ) : primitiveFields.length ? ( +
{primitiveFields.map(([key, value]) =>
{key.replaceAll(/[-_]/g, " ")}{String(value ?? "null")}
)}
+ ) : ( +

{String(summary.text ?? "The signed deliverable is verified and available in the raw preview below.")}

+ )} +
Not settled-and-accepted yetDACS-5 is still mandatory. The Directory will only show final acceptance after the buyer and seller bundles verify and reconcile.
+
Verified preview anchors
{anchorEntries.map(([name, anchor]) =>
{name}{anchor}
)}
+
Raw verified delivery preview
{JSON.stringify(preview, null, 2)}
+
+ ); +} + /** * The five DACS stages, in the standard's own words (see /how-it-works: * Identify → Vet → Negotiate → Settle → Verify, "one deal · five receipts"). @@ -429,6 +490,8 @@ export default function TryDacs() { const resultFields = Object.keys(record(resultPayload)); const isProcurementSel = selectedProfileId !== null; const procEvents = procurementJob?.events ?? []; + const procurementPreview = procurementJob?.preview; + const procurementFinalisation = procurementJob?.finalisation; const procurementWaiting = phase === "running" && procurementJob?.queue?.status === "waiting"; // Fail-closed staging: a terminal "failed" (or unknown-phase) event attaches // to the stage the run had actually reached and never advances progress. @@ -442,6 +505,22 @@ export default function TryDacs() { const chain = events.filter((event) => event.txRef || event.anchorRef); const jobComplete = procurementJob?.status === "complete"; const jobFailed = procurementJob?.status === "failed" || phase === "error"; + if (stageIdx === 3 && procurementPreview) { + return { + state: "complete", + summary: "Payment evidence and seller delivery verified", + detail: "The verified result is available; two-sided DACS-5 reconciliation continues separately", + chain, + }; + } + if (stageIdx === 4 && procurementPreview && procurementFinalisation?.status === "running") { + return { + state: "active", + summary: "Verified delivery ready; two DACS-5 copies are finalising", + detail: `Attempt ${procurementFinalisation.attempts} · completion remains pending until both bundles reconcile`, + chain, + }; + } if (stageIdx === 4 && jobComplete) { return { state: procurementAccepted ? "complete" : "warning", @@ -1065,7 +1144,7 @@ export default function TryDacs() { ) : phase === "running" && isProcurementSel ? ( -
{procurementWaiting ? `QUEUED · POSITION ${procurementJob?.queue?.position ?? "…"} · ${elapsedLabel(elapsedMs)}` : `FULL DACS FLOW · ${paymentRailLabel(selectedPaymentRail)} · ${elapsedLabel(elapsedMs)}`}
{procurementWaiting ? "Your purchase is safely queued behind the active buyer-wallet deal. No payment has started yet." : "Keep this page open — settlement and chain confirmations appear here live."}
{submittedSummary.length > 0 &&
SUBMITTED
    {submittedSummary.map((line, index) =>
  • {line}
  • )}
}
{(procurementJob?.events ?? []).map((event, index) =>
{index === (procurementJob?.events.length ?? 0) - 1 ? "·" : "✓"}{event.label}{new Date(event.at).toLocaleTimeString()}{event.txRef ? ` · tx ${compact(event.txRef, 12, 6)}` : ""}
)}
+
{procurementWaiting ? `QUEUED · POSITION ${procurementJob?.queue?.position ?? "…"} · ${elapsedLabel(elapsedMs)}` : procurementPreview ? `DELIVERY VERIFIED · DACS-5 FINALISING · ${elapsedLabel(elapsedMs)}` : `FULL DACS FLOW · ${paymentRailLabel(selectedPaymentRail)} · ${elapsedLabel(elapsedMs)}`}
{procurementWaiting ? "Your purchase is safely queued behind the active buyer-wallet deal. No payment has started yet." : procurementPreview ? "The result is available below. Keep this page open while both mandatory bundle copies reconcile." : "Keep this page open — settlement and chain confirmations appear here live."}
{submittedSummary.length > 0 &&
SUBMITTED
    {submittedSummary.map((line, index) =>
  • {line}
  • )}
}
{(procurementJob?.events ?? []).map((event, index) =>
{index === (procurementJob?.events.length ?? 0) - 1 ? "·" : "✓"}{event.label}{new Date(event.at).toLocaleTimeString()}{event.txRef ? ` · tx ${compact(event.txRef, 12, 6)}` : ""}
)}
) : phase === "running" ?
Specialist is workingAgent execution · {elapsedLabel(elapsedMs)} elapsed · 2-minute deadline{submittedSummary.length > 0 &&
SUBMITTED
    {submittedSummary.map((line, index) =>
  • {line}
  • )}
}
: null} @@ -1120,6 +1199,12 @@ export default function TryDacs() { + {procurementPreview && procurementFinalisation && selectedProfile && result === undefined && ( +
+ +
+ )} + {result !== undefined && selectedProfile &&
{procurementAccepted ? "verified" : "verification incomplete"}

{selected?.label ?? "Agent"} result

{specialistDurationMs !== undefined && Specialist completed in {elapsedLabel(specialistDurationMs)} · {paymentRailLabel(selectedPaymentRail)}}
}
THREE WAYS TO PROCURE

Production agents, DEM or x402, full DACS

Each route uses the gateway’s rail-specific live schema and runs Identify → Vet → Negotiate → Settle → Verify. Sealed tender stays hidden until its DACS-3 role model is released.

{profiles.map((profile, index) => { diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts index 9c5e22b..b6ea3b2 100644 --- a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts +++ b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts @@ -82,11 +82,44 @@ export type ProcurementEvent = { anchorRef?: string; }; +export type ProcurementPreview = { + kind: "dacs-procurement-delivery-preview"; + status: "delivery-verified-finalising-dacs5" | "report-verified-finalising-dacs5"; + jobId: string; + delivery: { + verified: true; + report: Record; + }; + anchors: { + listing?: string; + agreement?: string; + commitment?: string; + paymentEvidence?: string; + delivery?: string; + deliveryEvidence?: string; + }; +}; + +export type ProcurementFinalisation = { + status: "running" | "complete" | "failed"; + startedAt: string; + updatedAt: string; + attempts: number; + lastError?: string; +}; + export type ProcurementJob = { id: string; status: "running" | "complete" | "failed"; phase: string; events: ProcurementEvent[]; + /** + * A verified delivery that is safe to inspect while DACS-5 continues. + * This is deliberately nonterminal and must never be treated as acceptance. + */ + preview?: ProcurementPreview; + /** Persisted two-sided DACS-5 finalisation state. */ + finalisation?: ProcurementFinalisation; result?: unknown; error?: string; /** @@ -443,6 +476,70 @@ export function procurementProfileCard(profile: ProcurementProfile, rail: Paymen }; } +const PREVIEW_ANCHORS = [ + "listing", + "agreement", + "commitment", + "paymentEvidence", + "delivery", + "deliveryEvidence", +] as const; + +function parseProcurementPreview(value: unknown): ProcurementPreview { + const preview = requiredRecord(value, "procurement job.preview"); + if (preview.kind !== "dacs-procurement-delivery-preview") { + throw new ButlerContractError("procurement job.preview.kind", '"dacs-procurement-delivery-preview"'); + } + if (preview.status !== "delivery-verified-finalising-dacs5" && preview.status !== "report-verified-finalising-dacs5") { + throw new ButlerContractError( + "procurement job.preview.status", + '"delivery-verified-finalising-dacs5" or "report-verified-finalising-dacs5"', + ); + } + const delivery = requiredRecord(preview.delivery, "procurement job.preview.delivery"); + if (requiredBoolean(delivery.verified, "procurement job.preview.delivery.verified") !== true) { + throw new ButlerContractError("procurement job.preview.delivery.verified", "true"); + } + const report = requiredRecord(delivery.report, "procurement job.preview.delivery.report"); + if (Object.keys(report).length === 0) { + throw new ButlerContractError("procurement job.preview.delivery.report", "a non-empty verified report object"); + } + const rawAnchors = requiredRecord(preview.anchors, "procurement job.preview.anchors"); + const anchors: ProcurementPreview["anchors"] = {}; + for (const key of PREVIEW_ANCHORS) { + const anchor = optionalString(rawAnchors[key], `procurement job.preview.anchors.${key}`); + if (anchor !== undefined) anchors[key] = anchor; + } + if (!anchors.delivery || !anchors.paymentEvidence) { + throw new ButlerContractError("procurement job.preview.anchors", "delivery and paymentEvidence references"); + } + return { + kind: preview.kind, + status: preview.status, + jobId: requiredString(preview.jobId, "procurement job.preview.jobId"), + delivery: { verified: true, report }, + anchors, + }; +} + +function parseProcurementFinalisation(value: unknown): ProcurementFinalisation { + const finalisation = requiredRecord(value, "procurement job.finalisation"); + if (finalisation.status !== "running" && finalisation.status !== "complete" && finalisation.status !== "failed") { + throw new ButlerContractError("procurement job.finalisation.status", '"running", "complete", or "failed"'); + } + const attempts = requiredNumber(finalisation.attempts, "procurement job.finalisation.attempts"); + if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 3) { + throw new ButlerContractError("procurement job.finalisation.attempts", "an integer from 1 to 3"); + } + return { + status: finalisation.status, + startedAt: requiredString(finalisation.startedAt, "procurement job.finalisation.startedAt"), + updatedAt: requiredString(finalisation.updatedAt, "procurement job.finalisation.updatedAt"), + attempts, + lastError: optionalString(finalisation.lastError, "procurement job.finalisation.lastError"), + }; +} + export function parseProcurementJob(value: unknown): ProcurementJob { const job = requiredRecord(value, "procurement job"); if (job.status !== "running" && job.status !== "complete" && job.status !== "failed") { @@ -461,6 +558,25 @@ export function parseProcurementJob(value: unknown): ProcurementJob { }; }); if (job.status === "complete") requiredRecord(job.result, "procurement job.result"); + // The gateway clears its persisted nonterminal preview to JSON null when + // DACS-5 completes; older jobs omit the field. Both mean "no preview". + const preview = job.preview === undefined || job.preview === null ? undefined : parseProcurementPreview(job.preview); + const finalisation = job.finalisation === undefined ? undefined : parseProcurementFinalisation(job.finalisation); + if (preview && !finalisation) { + throw new ButlerContractError("procurement job.finalisation", "state for the nonterminal delivery preview"); + } + if (finalisation?.status === "running" && !preview) { + throw new ButlerContractError("procurement job.preview", "verified delivery while DACS-5 finalisation is running"); + } + if (job.status === "complete" && preview) { + throw new ButlerContractError("procurement job.preview", "no preview after terminal completion"); + } + if (job.status === "complete" && finalisation && finalisation.status !== "complete") { + throw new ButlerContractError("procurement job.finalisation.status", '"complete" for a completed job'); + } + if (job.status === "running" && finalisation && finalisation.status !== "running") { + throw new ButlerContractError("procurement job.finalisation.status", '"running" for a running job'); + } if (job.failedBeforePayment !== undefined && typeof job.failedBeforePayment !== "boolean") { throw new ButlerContractError("procurement job.failedBeforePayment", "a boolean"); } @@ -492,6 +608,8 @@ export function parseProcurementJob(value: unknown): ProcurementJob { status: job.status, phase: requiredString(job.phase, "procurement job.phase"), events, + preview, + finalisation, result: job.result, error: optionalString(job.error, "procurement job.error"), failedBeforePayment: job.failedBeforePayment, diff --git a/reference-implementations/dacs-directory/test/butler-contract.test.ts b/reference-implementations/dacs-directory/test/butler-contract.test.ts index cc513f7..806fd59 100644 --- a/reference-implementations/dacs-directory/test/butler-contract.test.ts +++ b/reference-implementations/dacs-directory/test/butler-contract.test.ts @@ -181,6 +181,135 @@ test("accepts running and completed procurement envelopes", () => { assert.equal(complete.result, acceptedReport); }); +test("accepts verified delivery previews without treating them as terminal results", () => { + const previewJob = { + id: "job-preview-1", + status: "running", + phase: "delivering", + events: [{ phase: "delivering", label: "Seller delivery verified", at: "2026-07-31T06:39:22.000Z" }], + preview: { + kind: "dacs-procurement-delivery-preview", + status: "delivery-verified-finalising-dacs5", + jobId: "web-oracle-1", + delivery: { + verified: true, + report: { + kind: "oracle-attested-value", + preset: "crypto-price", + value: 1_903.44, + url: "https://api.example.test/price", + }, + }, + anchors: { + listing: "stor-listing", + agreement: "stor-agreement", + commitment: "stor-commitment", + paymentEvidence: "stor-payment-evidence", + delivery: "stor-delivery", + deliveryEvidence: "stor-delivery-evidence", + }, + }, + finalisation: { + status: "running", + startedAt: "2026-07-31T06:39:53.000Z", + updatedAt: "2026-07-31T06:39:53.000Z", + attempts: 1, + }, + } as const; + + const parsed = parseProcurementJob(previewJob); + assert.equal(parsed.result, undefined, "a preview must remain nonterminal"); + assert.equal(parsed.preview?.delivery.verified, true); + assert.equal(parsed.preview?.delivery.report.value, 1_903.44); + assert.equal(parsed.preview?.anchors.paymentEvidence, "stor-payment-evidence"); + assert.equal(parsed.finalisation?.status, "running"); + + const security = parseProcurementJob({ + ...previewJob, + phase: "verifying", + preview: { + ...previewJob.preview, + status: "report-verified-finalising-dacs5", + jobId: "web-auditor-1", + delivery: { + verified: true, + report: { + version: 1, + target: "(posted content)", + findings: [{ id: "A1", severity: "high", title: "Unsafe dynamic execution" }], + }, + }, + }, + }); + assert.equal(security.preview?.status, "report-verified-finalising-dacs5"); + assert.equal(Array.isArray(security.preview?.delivery.report.findings), true); +}); + +test("delivery previews and DACS-5 finalisation state fail closed on contract drift", () => { + const preview = { + kind: "dacs-procurement-delivery-preview", + status: "delivery-verified-finalising-dacs5", + jobId: "web-oracle-1", + delivery: { verified: true, report: { kind: "oracle-attested-value", value: 42 } }, + anchors: { paymentEvidence: "stor-payment", delivery: "stor-delivery" }, + }; + const finalisation = { + status: "running", + startedAt: "2026-07-31T06:39:53.000Z", + updatedAt: "2026-07-31T06:39:53.000Z", + attempts: 1, + }; + const running = { + id: "job-preview-1", + status: "running", + phase: "delivering", + events: [], + preview, + finalisation, + }; + + assert.throws(() => parseProcurementJob({ ...running, finalisation: undefined }), ButlerContractError); + assert.throws(() => parseProcurementJob({ ...running, preview: undefined }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + preview: { ...preview, delivery: { ...preview.delivery, verified: false } }, + }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + preview: { ...preview, delivery: { ...preview.delivery, report: {} } }, + }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + preview: { ...preview, anchors: { delivery: "stor-delivery" } }, + }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + finalisation: { ...finalisation, attempts: 0 }, + }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + finalisation: { ...finalisation, attempts: 4 }, + }), ButlerContractError); + assert.throws(() => parseProcurementJob({ + ...running, + status: "complete", + phase: "complete", + result: acceptedReport, + }), ButlerContractError); + + const completed = parseProcurementJob({ + id: "job-preview-1", + status: "complete", + phase: "complete", + events: [], + preview: null, + result: acceptedReport, + finalisation: { ...finalisation, status: "complete", attempts: 2 }, + }); + assert.equal(completed.preview, undefined); + assert.equal(completed.finalisation?.status, "complete"); +}); + test("procurement queue metadata fails closed on malformed state or position", () => { const running = { id: "job-1", status: "running", phase: "queued", events: [] }; assert.throws(() => parseProcurementJob({ From ea9c5c6eab84b8fb87b58df671d7673cf74c81a8 Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 14:09:32 +0100 Subject: [PATCH 2/2] fix(directory): match the gateway preview contract --- .../dacs-directory/e2e/try-dacs-fixtures.ts | 19 +++----- .../dacs-directory/e2e/try-dacs.spec.ts | 21 +++++++++ .../dacs-directory/src/components/TryDacs.tsx | 37 ++++++++++----- .../src/components/try-dacs-contract.ts | 46 +------------------ .../test/butler-contract.test.ts | 30 +----------- 5 files changed, 57 insertions(+), 96 deletions(-) diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts index 582f417..e80b787 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts @@ -137,12 +137,6 @@ export const completedJob = { status: "complete", phase: "complete", preview: null, - finalisation: { - status: "complete", - startedAt: at, - updatedAt: at, - attempts: 1, - }, events: [ { phase: "discovering", label: "Signed listing verified", at, txRef: "mock-listing-tx" }, { phase: "selecting", label: "Counterparty vet anchored", at, txRef: "mock-vet-tx" }, @@ -189,12 +183,13 @@ export const securityPreviewJob = { deliveryEvidence: "mock-delivery-evidence-anchor", }, }, - finalisation: { - status: "running", - startedAt: at, - updatedAt: at, - attempts: 1, - }, +}; + +export const failedSecurityPreviewJob = { + ...securityPreviewJob, + status: "failed", + phase: "verifying", + error: "Buyer DACS-5 bundle anchoring failed after payment evidence was recorded.", }; const x402PaymentTx = `0x${"ab".repeat(32)}`; diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts index ecb572d..7deaf6d 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts @@ -5,6 +5,7 @@ import { chooseProcurementExample, completedJob, expectAcceptedEvidence, + failedSecurityPreviewJob, installMockGateway, securityPreviewJob, x402CompletedJob, @@ -248,4 +249,24 @@ test.describe("/try procurement browser safety", () => { await expectAcceptedEvidence(page); await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toHaveCount(0); }); + + test("9. preserves a verified delivery when DACS-5 finalisation fails without claiming acceptance", async ({ context, page }) => { + await installMockGateway(context, { + onProcurementPost: (route) => fulfillJson(route, securityPreviewJob), + onProcurementGet: (route) => fulfillJson(route, failedSecurityPreviewJob), + }); + await chooseProcurementExample(page); + + await page.getByRole("button", { name: /Run the full deal/ }).click(); + + await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); + await expect(page.getByText("DACS-5 needs recovery", { exact: true })).toBeVisible(); + await expect(page.getByText("Finalisation stopped safely", { exact: true })).toBeVisible(); + await expect(page.getByText(failedSecurityPreviewJob.error, { exact: true }).first()).toBeVisible(); + await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); + await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); + await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); + await expect(page.getByText(/Retrying reuses the same idempotency key/)).toHaveCount(0); + }); }); diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 9c2ed46..5fcb84d 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -19,7 +19,6 @@ import { type OutputReceipt, type PaymentRail, type ProcurementEvent, - type ProcurementFinalisation, type ProcurementJob, type ProcurementPreview, type ProcurementProfile, @@ -258,11 +257,13 @@ function ProcurementReport({ value, events, profile, rail }: { value: unknown; e function ProcurementPreviewReport({ preview, - finalisation, + jobStatus, + error, profile, }: { preview: ProcurementPreview; - finalisation: ProcurementFinalisation; + jobStatus: ProcurementJob["status"]; + error?: string; profile: ProcurementProfile; }) { const report = preview.delivery.report; @@ -273,20 +274,23 @@ function ProcurementPreviewReport({ value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean", ).slice(0, 8); const anchorEntries = Object.entries(preview.anchors).filter((entry): entry is [string, string] => typeof entry[1] === "string"); - const finalising = finalisation.status === "running"; + const finalising = jobStatus === "running"; + const finalisationDetail = finalising + ? "The job remains running until the mandatory buyer and seller bundles verify and reconcile." + : error ?? "The verified delivery remains inspectable, but DACS-5 did not reach terminal acceptance."; const heading = preview.status === "report-verified-finalising-dacs5" ? "Verified security report is ready" : `Verified ${profile.agentName} delivery is ready`; return ( -
+
VERIFIED DELIVERY · NONTERMINAL

{heading}

{finalising ? "DACS-5 finalising" : "DACS-5 needs recovery"}
Result available nowThe seller-signed delivery and confirmed payment evidence have been verified.
-
{finalising ? "Two bundle copies anchoring" : "Finalisation stopped safely"}{finalising ? `Attempt ${finalisation.attempts} · the job remains running until both sides reconcile.` : finalisation.lastError ?? "The verified delivery remains inspectable while the gateway recovers DACS-5."}
+
{finalising ? "Two bundle copies anchoring" : "Finalisation stopped safely"}{finalisationDetail}
{profile.id === "oracle-auto-accept" ? (
@@ -491,7 +495,6 @@ export default function TryDacs() { const isProcurementSel = selectedProfileId !== null; const procEvents = procurementJob?.events ?? []; const procurementPreview = procurementJob?.preview; - const procurementFinalisation = procurementJob?.finalisation; const procurementWaiting = phase === "running" && procurementJob?.queue?.status === "waiting"; // Fail-closed staging: a terminal "failed" (or unknown-phase) event attaches // to the stage the run had actually reached and never advances progress. @@ -513,11 +516,11 @@ export default function TryDacs() { chain, }; } - if (stageIdx === 4 && procurementPreview && procurementFinalisation?.status === "running") { + if (stageIdx === 4 && procurementPreview && procurementJob?.status === "running") { return { state: "active", summary: "Verified delivery ready; two DACS-5 copies are finalising", - detail: `Attempt ${procurementFinalisation.attempts} · completion remains pending until both bundles reconcile`, + detail: "Completion remains pending until both bundles reconcile", chain, }; } @@ -833,7 +836,12 @@ export default function TryDacs() { await waitWithSignal(Math.min(2_000, Math.max(0, deadline - Date.now())), controller.signal); const { response: poll, body: pollBody } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(current.id)}`, { signal: controller.signal }, deadline); if (!poll.ok) throw new Error(message(pollBody)); - current = parseProcurementJob(pollBody); setProcurementJob(current); + current = parseProcurementJob(pollBody); + // Keep the synchronous ref aligned with the rendered state. The outer + // catch uses it before React effects run to distinguish a terminal + // gateway failure from a transient watcher error. + procurementJobRef.current = current; + setProcurementJob(current); } if (current.status === "failed") { // Only the gateway's explicit failedBeforePayment=true proves no money @@ -1199,9 +1207,14 @@ export default function TryDacs() {
- {procurementPreview && procurementFinalisation && selectedProfile && result === undefined && ( + {procurementPreview && selectedProfile && result === undefined && (
- +
)} diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts index b6ea3b2..a4cb25e 100644 --- a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts +++ b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts @@ -100,14 +100,6 @@ export type ProcurementPreview = { }; }; -export type ProcurementFinalisation = { - status: "running" | "complete" | "failed"; - startedAt: string; - updatedAt: string; - attempts: number; - lastError?: string; -}; - export type ProcurementJob = { id: string; status: "running" | "complete" | "failed"; @@ -118,8 +110,6 @@ export type ProcurementJob = { * This is deliberately nonterminal and must never be treated as acceptance. */ preview?: ProcurementPreview; - /** Persisted two-sided DACS-5 finalisation state. */ - finalisation?: ProcurementFinalisation; result?: unknown; error?: string; /** @@ -522,24 +512,6 @@ function parseProcurementPreview(value: unknown): ProcurementPreview { }; } -function parseProcurementFinalisation(value: unknown): ProcurementFinalisation { - const finalisation = requiredRecord(value, "procurement job.finalisation"); - if (finalisation.status !== "running" && finalisation.status !== "complete" && finalisation.status !== "failed") { - throw new ButlerContractError("procurement job.finalisation.status", '"running", "complete", or "failed"'); - } - const attempts = requiredNumber(finalisation.attempts, "procurement job.finalisation.attempts"); - if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 3) { - throw new ButlerContractError("procurement job.finalisation.attempts", "an integer from 1 to 3"); - } - return { - status: finalisation.status, - startedAt: requiredString(finalisation.startedAt, "procurement job.finalisation.startedAt"), - updatedAt: requiredString(finalisation.updatedAt, "procurement job.finalisation.updatedAt"), - attempts, - lastError: optionalString(finalisation.lastError, "procurement job.finalisation.lastError"), - }; -} - export function parseProcurementJob(value: unknown): ProcurementJob { const job = requiredRecord(value, "procurement job"); if (job.status !== "running" && job.status !== "complete" && job.status !== "failed") { @@ -558,25 +530,12 @@ export function parseProcurementJob(value: unknown): ProcurementJob { }; }); if (job.status === "complete") requiredRecord(job.result, "procurement job.result"); - // The gateway clears its persisted nonterminal preview to JSON null when - // DACS-5 completes; older jobs omit the field. Both mean "no preview". + // A terminal producer may omit the nonterminal preview or serialize it as + // JSON null. Both mean "no preview" and neither manufactures a result. const preview = job.preview === undefined || job.preview === null ? undefined : parseProcurementPreview(job.preview); - const finalisation = job.finalisation === undefined ? undefined : parseProcurementFinalisation(job.finalisation); - if (preview && !finalisation) { - throw new ButlerContractError("procurement job.finalisation", "state for the nonterminal delivery preview"); - } - if (finalisation?.status === "running" && !preview) { - throw new ButlerContractError("procurement job.preview", "verified delivery while DACS-5 finalisation is running"); - } if (job.status === "complete" && preview) { throw new ButlerContractError("procurement job.preview", "no preview after terminal completion"); } - if (job.status === "complete" && finalisation && finalisation.status !== "complete") { - throw new ButlerContractError("procurement job.finalisation.status", '"complete" for a completed job'); - } - if (job.status === "running" && finalisation && finalisation.status !== "running") { - throw new ButlerContractError("procurement job.finalisation.status", '"running" for a running job'); - } if (job.failedBeforePayment !== undefined && typeof job.failedBeforePayment !== "boolean") { throw new ButlerContractError("procurement job.failedBeforePayment", "a boolean"); } @@ -609,7 +568,6 @@ export function parseProcurementJob(value: unknown): ProcurementJob { phase: requiredString(job.phase, "procurement job.phase"), events, preview, - finalisation, result: job.result, error: optionalString(job.error, "procurement job.error"), failedBeforePayment: job.failedBeforePayment, diff --git a/reference-implementations/dacs-directory/test/butler-contract.test.ts b/reference-implementations/dacs-directory/test/butler-contract.test.ts index 806fd59..b87c1c3 100644 --- a/reference-implementations/dacs-directory/test/butler-contract.test.ts +++ b/reference-implementations/dacs-directory/test/butler-contract.test.ts @@ -181,7 +181,7 @@ test("accepts running and completed procurement envelopes", () => { assert.equal(complete.result, acceptedReport); }); -test("accepts verified delivery previews without treating them as terminal results", () => { +test("accepts gateway-shaped verified delivery previews without treating them as terminal results", () => { const previewJob = { id: "job-preview-1", status: "running", @@ -209,12 +209,6 @@ test("accepts verified delivery previews without treating them as terminal resul deliveryEvidence: "stor-delivery-evidence", }, }, - finalisation: { - status: "running", - startedAt: "2026-07-31T06:39:53.000Z", - updatedAt: "2026-07-31T06:39:53.000Z", - attempts: 1, - }, } as const; const parsed = parseProcurementJob(previewJob); @@ -222,7 +216,6 @@ test("accepts verified delivery previews without treating them as terminal resul assert.equal(parsed.preview?.delivery.verified, true); assert.equal(parsed.preview?.delivery.report.value, 1_903.44); assert.equal(parsed.preview?.anchors.paymentEvidence, "stor-payment-evidence"); - assert.equal(parsed.finalisation?.status, "running"); const security = parseProcurementJob({ ...previewJob, @@ -245,7 +238,7 @@ test("accepts verified delivery previews without treating them as terminal resul assert.equal(Array.isArray(security.preview?.delivery.report.findings), true); }); -test("delivery previews and DACS-5 finalisation state fail closed on contract drift", () => { +test("delivery previews fail closed on contract drift", () => { const preview = { kind: "dacs-procurement-delivery-preview", status: "delivery-verified-finalising-dacs5", @@ -253,23 +246,14 @@ test("delivery previews and DACS-5 finalisation state fail closed on contract dr delivery: { verified: true, report: { kind: "oracle-attested-value", value: 42 } }, anchors: { paymentEvidence: "stor-payment", delivery: "stor-delivery" }, }; - const finalisation = { - status: "running", - startedAt: "2026-07-31T06:39:53.000Z", - updatedAt: "2026-07-31T06:39:53.000Z", - attempts: 1, - }; const running = { id: "job-preview-1", status: "running", phase: "delivering", events: [], preview, - finalisation, }; - assert.throws(() => parseProcurementJob({ ...running, finalisation: undefined }), ButlerContractError); - assert.throws(() => parseProcurementJob({ ...running, preview: undefined }), ButlerContractError); assert.throws(() => parseProcurementJob({ ...running, preview: { ...preview, delivery: { ...preview.delivery, verified: false } }, @@ -282,14 +266,6 @@ test("delivery previews and DACS-5 finalisation state fail closed on contract dr ...running, preview: { ...preview, anchors: { delivery: "stor-delivery" } }, }), ButlerContractError); - assert.throws(() => parseProcurementJob({ - ...running, - finalisation: { ...finalisation, attempts: 0 }, - }), ButlerContractError); - assert.throws(() => parseProcurementJob({ - ...running, - finalisation: { ...finalisation, attempts: 4 }, - }), ButlerContractError); assert.throws(() => parseProcurementJob({ ...running, status: "complete", @@ -304,10 +280,8 @@ test("delivery previews and DACS-5 finalisation state fail closed on contract dr events: [], preview: null, result: acceptedReport, - finalisation: { ...finalisation, status: "complete", attempts: 2 }, }); assert.equal(completed.preview, undefined); - assert.equal(completed.finalisation?.status, "complete"); }); test("procurement queue metadata fails closed on malformed state or position", () => {