From f9c5bf5ec86e00d8c3ad698fb8ca9dbe2fef621d Mon Sep 17 00:00:00 2001 From: mobmob719 <318678110+mobmob719@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:08:16 +0800 Subject: [PATCH] feat(approvals): read buyer delivery acceptance off the existing approval contract (#539) --- apps/desktop/packaging/runtime-layout.cjs | 1 + apps/server/src/approvals/delivery.ts | 151 +++++++++++++++ apps/server/test/approval-delivery.test.ts | 214 +++++++++++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 apps/server/src/approvals/delivery.ts create mode 100644 apps/server/test/approval-delivery.test.ts diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index ba1d12a4..11476bf6 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -65,6 +65,7 @@ const SERVER_RUNTIME_FILES = [ "dist/src/agent-binding.js", "dist/src/agent-registry.js", "dist/src/approvals/context.js", + "dist/src/approvals/delivery.js", "dist/src/approvals/policy.js", "dist/src/approvals/risk-overlay.js", "dist/src/approvals/service.js", diff --git a/apps/server/src/approvals/delivery.ts b/apps/server/src/approvals/delivery.ts new file mode 100644 index 00000000..c9865b05 --- /dev/null +++ b/apps/server/src/approvals/delivery.ts @@ -0,0 +1,151 @@ +import crypto from "node:crypto"; +import type { ApprovalAuditEvent, ApprovalRecord } from "@roleweave/shared"; + +/** + * Buyer delivery acceptance, carried on the existing approval contract (C2). + * + * Acceptance is **not** a new approval kind. `action.kind` stays `tool` — the + * only vocabulary entry `ruleRiskFromKind` maps to `medium` — and the + * commercial meaning rides in `action.target` behind `DELIVERY_TARGET_PREFIX`. + * RoleWeave never parses past the prefix: buyer, order and listing stay out of + * this repository and are attached by `digital-employee-platform`. + * + * Everything below is projection over records the existing machinery already + * writes. Nothing here widens the vocabulary, the schema version, or the event + * set, and nothing here decides who a buyer is. + * + * Inherited invariants, deliberately not re-created here: + * - A record can only exist because a running turn emitted + * `approval.requested` (`approvals/service.ts`), which is why + * `source.turnId` / `source.runId` are required — an acceptance cannot be + * minted without a turn behind it. + * - The verdict returns through `approval.granted` on the sealed envelope of + * the resumption turn (#187 Option 1, terminal-and-resume). There is no + * suspended-turn path and no second settlement channel. + */ +export const DELIVERY_TARGET_PREFIX = "delivery:"; + +/** Same bound the approval store applies to `action.target`. */ +const TARGET_MAX_CHARS = 8192; + +/** Build the platform-facing target for a delivery reference. */ +export function deliveryTarget(ref: string): string { + return `${DELIVERY_TARGET_PREFIX}${ref}`; +} + +/** The reference behind the prefix, or undefined when the target is not a + * delivery acceptance target. The reference itself is opaque — it belongs to + * the platform, so it is returned whole and never interpreted. */ +export function deliveryTargetRef(target: string | undefined): string | undefined { + if (target === undefined || !target.startsWith(DELIVERY_TARGET_PREFIX)) return undefined; + const ref = target.slice(DELIVERY_TARGET_PREFIX.length); + return ref.length > 0 && target.length <= TARGET_MAX_CHARS ? ref : undefined; +} + +/** True when this approval encodes a delivery acceptance. + * + * `kind` is pinned to `tool` on purpose: adding a `delivery` value would mean + * editing the engine #187 vocabulary first (shared/turns.ts mirrors it + * verbatim), which C2 exists to avoid. */ +export function isDeliveryAcceptance(record: ApprovalRecord): boolean { + if (record.action.kind !== "tool") return false; + if (deliveryTargetRef(record.action.target) === undefined) return false; + // An acceptance is only real while it is bound to the turn that asked for it. + return typeof record.source?.turnId === "string" && record.source.turnId.length > 0 && + typeof record.source?.runId === "string" && record.source.runId.length > 0; +} + +export type DeliveryAcceptanceState = "pending" | "accepted" | "declined" | "indeterminate"; + +export type DeliveryIndeterminateReason = + /** The hash chain or seq continuity of the audit log does not hold. */ + | "audit_chain_broken" + /** A decision was recorded without the policy it was decided under. */ + | "policy_digest_missing" + /** The record and the audit disagree about the outcome. */ + | "record_status_mismatch" + /** Settled as expired / cancelled / indeterminate, so it never decided. */ + | "not_settled"; + +export interface DeliveryAcceptance { + /** Engine-side approval id, not the local identity hash. */ + approvalId: string; + /** Opaque platform reference carried in `action.target`. */ + deliveryRef: string; + state: DeliveryAcceptanceState; + /** Present only when `state` is `indeterminate`. */ + reason?: DeliveryIndeterminateReason; + decidedAt?: string; + /** The actor the audit recorded. Buyer identity is not modelled yet: the + * engine protocol still marks `decidedBy` as `operator`. */ + decidedBy?: string; + policyVersion?: string; + policyDigest?: string; +} + +function auditDigest(body: Record): string { + return `sha256:${crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex")}`; +} + +/** Recompute the append-only audit chain: seq continuity, `previousHash` + * back-reference, and each event's own digest. Mirrors the writer in + * `approvals/store.ts`, so a hand-edited or truncated log fails closed. + * + * The argument is the **whole workspace log**, never one approval's slice of + * it. `store.ts` numbers `seq` from `existing.length + 1` across every + * approval in the workspace, and chains each event to whatever event was + * appended before it. Slicing by `approvalId` first therefore destroys both + * properties for every approval except the first one ever written: the slice + * no longer starts at 1 and no longer starts from an absent `previousHash`. */ +export function verifyApprovalAuditChain(events: readonly ApprovalAuditEvent[]): boolean { + let previous: string | undefined; + for (const [index, event] of events.entries()) { + const { hash, ...unsigned } = event; + if (event.seq !== index + 1) return false; + if (event.previousHash !== previous) return false; + if (auditDigest(unsigned) !== hash) return false; + previous = hash; + } + return true; +} + +/** Read acceptance off the existing `approval.granted` audit chain. + * + * Returns undefined when the record is not a delivery acceptance, so callers + * can filter without a second predicate. Settlement is read-only: the verdict + * is whatever the sealed envelope already recorded. */ +export function deliveryAcceptance( + record: ApprovalRecord, + audit: readonly ApprovalAuditEvent[], +): DeliveryAcceptance | undefined { + if (!isDeliveryAcceptance(record)) return undefined; + const ref = deliveryTargetRef(record.action.target); + if (ref === undefined) return undefined; + const base: DeliveryAcceptance = { approvalId: record.approvalId, deliveryRef: ref, state: "pending" }; + // Verify before selecting, not after: the chain is a property of the log. + if (!verifyApprovalAuditChain(audit)) { + return { ...base, state: "indeterminate", reason: "audit_chain_broken" }; + } + const events = audit.filter(event => event.approvalId === record.id); + // A partial grant under a multi-approver policy is still pending; the + // threshold belongs to policy.ts and is not recomputed here. + if (record.status === "pending") return base; + if (record.status !== "granted" && record.status !== "denied") { + return { ...base, state: "indeterminate", reason: "not_settled" }; + } + const decision = events.filter(event => event.type === "decision").at(-1); + if (!decision || decision.decision !== record.status) { + return { ...base, state: "indeterminate", reason: "record_status_mismatch" }; + } + if (!decision.policyDigest) { + return { ...base, state: "indeterminate", reason: "policy_digest_missing" }; + } + return { + ...base, + state: decision.decision === "granted" ? "accepted" : "declined", + decidedAt: decision.timestamp, + ...(decision.actor === undefined ? {} : { decidedBy: decision.actor }), + policyVersion: decision.policyVersion, + policyDigest: decision.policyDigest, + }; +} diff --git a/apps/server/test/approval-delivery.test.ts b/apps/server/test/approval-delivery.test.ts new file mode 100644 index 00000000..3c369405 --- /dev/null +++ b/apps/server/test/approval-delivery.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import test from "node:test"; +import { turnEngines, type ApprovalAuditEvent, type ApprovalRecord } from "@roleweave/shared"; +import { + DELIVERY_TARGET_PREFIX, + deliveryAcceptance, + deliveryTarget, + deliveryTargetRef, + isDeliveryAcceptance, + verifyApprovalAuditChain, +} from "../src/approvals/delivery.js"; +import { ruleRiskFromKind } from "../src/approvals/risk-overlay.js"; + +const ENGINE = turnEngines[0]!; +const NOW = "2026-09-29T00:00:00.000Z"; + +function record(overrides: Partial = {}): ApprovalRecord { + const base: ApprovalRecord = { + schemaVersion: "workbench-approval.v2", + id: "a".repeat(64), + version: 1, + approvalId: "appr-delivery-1", + source: { + kind: "session", + positionId: "pos-1", + conversationId: "conv-1", + turnId: "11111111-1111-4111-8111-111111111111", + runId: "run-1", + engine: ENGINE, + }, + action: { kind: "tool", description: "交付验收", target: deliveryTarget("order-7") }, + requestedAt: NOW, + status: "pending", + execution: { phase: "not_started" }, + createdAt: NOW, + updatedAt: NOW, + }; + return { ...base, ...overrides, source: { ...base.source, ...(overrides.source ?? {}) }, action: { ...base.action, ...(overrides.action ?? {}) } }; +} + +/** Mirrors the writer in approvals/store.ts: seq, then the event, then + * previousHash, with the digest appended last. */ +type AuditSeed = Omit; + +function chain(seeds: AuditSeed[]): ApprovalAuditEvent[] { + const events: ApprovalAuditEvent[] = []; + for (const seed of seeds) { + const previous = events.at(-1); + const body = { seq: events.length + 1, ...seed, ...(previous ? { previousHash: previous.hash } : {}) }; + const hash = `sha256:${crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex")}`; + events.push({ ...body, hash }); + } + return events; +} + +function requested(approvalId: string): AuditSeed { + return { approvalId, timestamp: NOW, type: "requested", policyVersion: "local-operator-v1", policyDigest: "sha256:" + "b".repeat(64) }; +} + +function decided(approvalId: string, decision: "granted" | "denied", overrides: Partial = {}): AuditSeed { + return { + approvalId, + requestId: "22222222-2222-4222-8222-222222222222", + timestamp: "2026-09-29T00:00:05.000Z", + type: "decision", + actor: "operator", + decision, + scope: "once", + policyVersion: "local-operator-v1", + policyDigest: "sha256:" + "b".repeat(64), + ...overrides, + }; +} + +test("delivery target round-trips the platform reference", () => { + assert.equal(deliveryTarget("order-7"), `${DELIVERY_TARGET_PREFIX}order-7`); + assert.equal(deliveryTargetRef(deliveryTarget("order-7")), "order-7"); +}); + +test("targets outside the convention are not delivery references", () => { + assert.equal(deliveryTargetRef(undefined), undefined); + assert.equal(deliveryTargetRef("order-7"), undefined); + assert.equal(deliveryTargetRef("delivery:"), undefined, "an empty reference carries no delivery"); + assert.equal(deliveryTargetRef(deliveryTarget("x".repeat(9000))), undefined, "over-long targets stay out"); +}); + +test("only the tool kind carries acceptance — the vocabulary is untouched", () => { + assert.equal(isDeliveryAcceptance(record()), true); + assert.equal(isDeliveryAcceptance(record({ action: { kind: "write", description: "d", target: deliveryTarget("order-7") } })), false); + assert.equal(isDeliveryAcceptance(record({ action: { kind: "exec", description: "d", target: deliveryTarget("order-7") } })), false); + assert.equal(isDeliveryAcceptance(record({ action: { kind: "network", description: "d", target: deliveryTarget("order-7") } })), false); + assert.equal(isDeliveryAcceptance(record({ action: { kind: "tool", description: "d", target: undefined } })), false, "no target, no delivery"); +}); + +test("an acceptance must stay bound to the turn that requested it", () => { + assert.equal(isDeliveryAcceptance(record({ source: { ...record().source, turnId: "" } })), false); + assert.equal(isDeliveryAcceptance(record({ source: { ...record().source, runId: "" } })), false); +}); + +test("acceptance lands on medium risk, which is why no new kind is needed", () => { + const acceptance = record(); + assert.equal(acceptance.action.kind, "tool"); + assert.equal(ruleRiskFromKind(acceptance.action.kind), "medium"); +}); + +test("a granted audit chain settles as accepted", () => { + const approval = record({ status: "granted" }); + const audit = chain([requested(approval.id), decided(approval.id, "granted")]); + const result = deliveryAcceptance(approval, audit); + assert.equal(result?.state, "accepted"); + assert.equal(result?.deliveryRef, "order-7"); + assert.equal(result?.decidedAt, "2026-09-29T00:00:05.000Z"); + assert.equal(result?.policyDigest, "sha256:" + "b".repeat(64)); +}); + +test("a denied audit chain settles as declined", () => { + const approval = record({ status: "denied" }); + const audit = chain([requested(approval.id), decided(approval.id, "denied")]); + assert.equal(deliveryAcceptance(approval, audit)?.state, "declined"); +}); + +test("a pending approval stays pending even under a multi-approver policy", () => { + const approval = record({ status: "pending" }); + const audit = chain([requested(approval.id), decided(approval.id, "granted")]); + assert.equal(deliveryAcceptance(approval, audit)?.state, "pending", "a partial grant does not settle"); +}); + +test("another approval's events do not leak into this verdict", () => { + const other = "c".repeat(64); + const approval = record({ status: "denied" }); + // One workspace log: a neighbouring approval is granted at seq 1-2, ours is + // denied, and only our own decision may settle the record. + const audit = chain([ + requested(other), decided(other, "granted"), + requested(approval.id), decided(approval.id, "denied"), + ]); + const result = deliveryAcceptance(approval, audit); + assert.equal(result?.state, "declined"); +}); + +test("an acceptance that is not the first approval in the workspace log still settles", () => { + // seq is workspace-global (store.ts:212: `seq: existing.length + 1`), so the + // interesting case is an approval whose own events start mid-log. Verifying + // a per-approval slice used to fail closed here for every approval but the + // first one ever written. + const earlier = "d".repeat(64); + const approval = record({ status: "granted" }); + const audit = chain([ + requested(earlier), decided(earlier, "granted"), + requested(approval.id), decided(approval.id, "granted"), + ]); + const mine = audit.filter(event => event.approvalId === approval.id); + assert.deepEqual(mine.map(event => event.seq), [3, 4], "our events do not start at 1"); + assert.notEqual(mine[0]?.previousHash, undefined, "our first event back-references the earlier approval"); + assert.equal(deliveryAcceptance(approval, audit)?.state, "accepted"); +}); + +test("tampering outside this approval's own events fails closed", () => { + // Our approval is the *first* one in the log here, so a per-approval slice + // would verify cleanly. Only verifying the log whole can see this. + const approval = record({ status: "granted" }); + const other = "e".repeat(64); + const audit = chain([ + requested(approval.id), decided(approval.id, "granted"), + requested(other), decided(other, "granted"), + ]); + const tampered = audit.map(event => + event.approvalId === other && event.type === "decision" ? { ...event, actor: "someone-else" } : event); + assert.equal(verifyApprovalAuditChain(tampered), false); + const result = deliveryAcceptance(approval, tampered); + assert.equal(result?.state, "indeterminate"); + assert.equal(result?.reason, "audit_chain_broken"); +}); + +test("a tampered audit chain fails closed", () => { + const approval = record({ status: "granted" }); + const audit = chain([requested(approval.id), decided(approval.id, "granted")]); + const tampered = audit.map(event => event.type === "decision" ? { ...event, decision: "denied" as const } : event); + assert.equal(verifyApprovalAuditChain(tampered), false); + const result = deliveryAcceptance(approval, tampered); + assert.equal(result?.state, "indeterminate"); + assert.equal(result?.reason, "audit_chain_broken"); +}); + +test("a decision recorded without its policy fails closed", () => { + const approval = record({ status: "granted" }); + const audit = chain([requested(approval.id), decided(approval.id, "granted", { policyDigest: "" })]); + const result = deliveryAcceptance(approval, audit); + assert.equal(result?.state, "indeterminate"); + assert.equal(result?.reason, "policy_digest_missing"); +}); + +test("a record that disagrees with its audit fails closed", () => { + const approval = record({ status: "granted" }); + const audit = chain([requested(approval.id), decided(approval.id, "denied")]); + const result = deliveryAcceptance(approval, audit); + assert.equal(result?.state, "indeterminate"); + assert.equal(result?.reason, "record_status_mismatch"); +}); + +test("an approval that never decided never settles", () => { + const approval = record({ status: "expired" }); + const audit = chain([requested(approval.id)]); + const result = deliveryAcceptance(approval, audit); + assert.equal(result?.state, "indeterminate"); + assert.equal(result?.reason, "not_settled"); +}); + +test("an approval without a delivery target is not an acceptance", () => { + const approval = record({ status: "granted", action: { kind: "tool", description: "d", target: "tool:shell" } }); + const audit = chain([requested(approval.id), decided(approval.id, "granted")]); + assert.equal(deliveryAcceptance(approval, audit), undefined); +});