Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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",
Expand Down
151 changes: 151 additions & 0 deletions apps/server/src/approvals/delivery.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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,
};
}
214 changes: 214 additions & 0 deletions apps/server/test/approval-delivery.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<ApprovalAuditEvent, "seq" | "previousHash" | "hash">;

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> = {}): 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);
});
Loading