Skip to content
Draft
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
42 changes: 42 additions & 0 deletions docs/console-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,48 @@ A source failure remains visible in `sources` and produces inbox
`status=read_failed`, even if other sources returned items. An unreadable history
returns HTTP 503 with `ReadFailed`, never an empty success.

### Decision action todo lifecycle

Inbox items and `GET /api/decisions/:id` reviews add `todo_lifecycle`
(`qsl.todo_lifecycle.v1`). Existing consumers may ignore the additive field;
existing eligibility, writes, IDs, pagination and runtime readback are unchanged.
This is the lifecycle of the **decision recording action**, with
`scope=decision_intent`. It does not complete application, setup effectiveness,
reconciliation, notification delivery or broker execution.

| Field | Meaning |
|---|---|
| `owner` | Existing decision writers require `admin`. Person assignment is not stored by these sources: `assignment_status=not_recorded`, `actor_alias=null`. This is an explicit gap, never an inferred assignee. Credential maintenance has no supported inbox writer and a null role. |
| `state` | `pending`, `blocked`, `expired`, `acknowledged`, `unknown` or `unsupported`. A producer's decided state, applied status or notification does not establish acknowledgement. |
| `blockers` | Stable code and `resume_condition` for each observed blocker. Unknown source codes collapse to `decision_blocked_unknown`; raw producer text is omitted. Conditions describe evidence to restore, not execution authority. |
| `setup_decision` | Existing relative decision-page link plus exact decision ID, revision and material digest. Missing or inconsistent bindings return `unavailable` and null fields. Reload the current review before any existing action; the link itself grants no permission. |
| `completion` | `pending` means the exact current durable authority was read and has no decision. `acknowledged` requires the existing receipt integrity validator and matching kind, subject, full material and promotion review digest. `unknown` means missing, mismatched or unreadable evidence; credential actions are `unsupported`. Only pseudonymous actor, timestamp and receipt digest are exposed. |

Resume conditions map stale evidence to `refresh_evidence`, expiry to
`refresh_decision_material`, missing authority to `restore_authority_read`, and
recovery blockers to `restore_recovery_readiness`. An already-decided source points
to `read_existing_decision_receipt`; an unknown completion points to
`restore_exact_decision_receipt`. Other invalid/ineligible material requires
`restore_verified_decision_material`. A credential action points to
`use_existing_maintenance_process`. These conditions never reopen a skipped period.

Reads reuse current durable decision records and `humanDecisionReceipt`; there is
no new storage, completion mutation or task endpoint. The inbox validates only its
returned page, without cross-request caching. Receipt read failure preserves the
item, sets completion/state to `unknown`, and makes an otherwise healthy inbox
`partial`; existing source `read_failed` wins. Detail uses its own authority read
and validator and adds `decision_completion_unknown` to `errors` on failure.
Historical terminal decisions remain addressable through the existing by-ID
fallback. Repeated reads and a Worker restart reconstruct the same acknowledgement
from storage. Changed material or review bindings invalidate a projected ACK.
Promotion acknowledgement requires a valid 64-character lowercase hexadecimal
review digest. Historical/v1 receipts with missing, invalid or null review digests
remain `unknown`, including a null/null comparison; they do not prove completion.
An acknowledged actor is evidence of who recorded the decision, not an assigned
owner. Actual owner assignment and broader work completion remain unrecorded;
this projection does not invent them. No frontend adoption or runtime acceptance
is implied by this backend contract.

## Today, strategies and notification context

`/api/runtime/today` chooses today's date on the server in `America/New_York`,
Expand Down
27 changes: 25 additions & 2 deletions tests/decision_cockpit_api_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const options = {
kvNamespaces: ["STRATEGY_SWITCH_CONFIG"],
outboundService: () => new Response("offline only", { status: 503 }),
};
const mf = new Miniflare(options);
let mf = new Miniflare(options);
async function initializeDirectory(runtime) {
const namespace = await runtime.getDurableObjectNamespace("STRATEGY_SWITCH_RUNTIME_INSTANCES");
const response = await namespace.get(namespace.idFromName("console-kv-directory-v1")).fetch("https://fixture.invalid/kv-directory", {
Expand Down Expand Up @@ -173,6 +173,9 @@ const ownerItem = inbox.body.items.find((item) => item.decision_id === "owner_de
assert.ok(ownerItem, "owner decision item is present");
assert.match(ownerItem.revision, /^rev1-[0-9a-f]{16}$/);
assert.equal(ownerItem.actionable, true);
assert.equal(ownerItem.todo_lifecycle.completion.status, "pending");
assert.equal(ownerItem.todo_lifecycle.owner.assignment_status, "not_recorded");
validate("todo_lifecycle", ownerItem.todo_lifecycle);
assert.ok(ownerItem.expires_at, "actionable decisions must carry expiry");
assert.ok(ownerItem.allowed_actions.some((entry) => entry.action === "keep_parked"));
assert.equal(ownerItem.request_id_required, true);
Expand Down Expand Up @@ -263,6 +266,24 @@ assert.ok(decidedReview.body.current_eligibility.reason_codes.includes("decision
assert.equal(decidedReview.body.decision.allowed_actions.length, 0);
assert.equal(decidedReview.body.decision.decision_state.status, "decided");
validate("decision_review", decidedReview.body);
const todoAck = decidedReview.body.decision.todo_lifecycle;
assert.equal(todoAck.state, "acknowledged");
assert.equal(todoAck.completion.receipt_sha256, decided.body.decision_receipt.receipt_sha256);
assert.match(todoAck.completion.actor_alias, /^actor-[0-9a-f]{16}$/);
assert.doesNotMatch(JSON.stringify(todoAck), /cockpit-admin/);
validate("todo_lifecycle", todoAck);
for (let attempt = 0; attempt < 2; attempt++) {
const queue = await call("/api/decisions-inbox");
const same = queue.body.items.find(item => item.id === ownerItem.id);
assert.deepEqual(same.todo_lifecycle.completion, todoAck.completion);
}
// Reconstruct the same exact decision from durable storage with a fresh Worker.
await mf.dispose();
mf = new Miniflare(options);
const restartedReview = await call(`/api/decisions/${encodeURIComponent(ownerItem.id)}`);
assert.equal(restartedReview.status, 200);
assert.deepEqual(restartedReview.body.decision.todo_lifecycle.completion, todoAck.completion);

// A new operation id is a new intent even when it repeats the recorded action.
const otherOperationSameAction = await call(actionUrl, { method: "POST", body: {
action: "keep_parked", expected_revision: ownerItem.revision, operation_id: "cockpit-request-other",
Expand Down Expand Up @@ -618,4 +639,6 @@ assert.equal(missingRuntime.body.observed_at, null);
assert.equal(missingRuntime.body.policy.execution_authority_granted, false);

await mf.dispose();
console.log("decision_cockpit_api_validation: PASS");
console.log("decision_cockpit_api_validation: PASS (including durable todo ACK restart)");

await import("./todo_lifecycle_validation.mjs");
129 changes: 129 additions & 0 deletions tests/todo_lifecycle_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { inbox } from "../web/strategy-switch-console/application/console_api/inbox.js";
const material = "a".repeat(64), review = "b".repeat(64);
const row = { ticket_id: "<PROJECT_ID>-todo", state: "awaiting_human", expires_at: "2099-01-01T00:00:00Z",
decision_binding: { kind: "promotion", subject_id: "<PROJECT_ID>-todo", material_sha256: material, review_binding: { review_sha256: review } },
decision_authority: { material_sha256: material, eligible: true, expires_at: "2099-01-01T00:00:00Z" } };
const request = new Request("https://fixture.invalid/api/decisions-inbox");
const empty = async () => Response.json({ data_status: "ready", candidates: [], tickets: [], recoveries: [] });
const ack = { status: "found", kind: "promotion", subject_id: row.ticket_id, material_sha256: material, review_sha256: review,
action: "accept", actor_alias: "actor-0123456789abcdef", acknowledged_at: "2030-01-01T00:00:00Z", receipt_sha256: "c".repeat(64) };
async function read(entry = row, reader = async () => ({ status: "not_found" }), options) {
return inbox(request, {}, { owner: empty, recovery: empty, credential: empty,
promotion: async () => Response.json({ data_status: "ready", tickets: [entry] }), decisionCompletion: reader }, options);
}
const marked = { ...row, human_decision: "accept", state: "human_accepted", application: { status: "applied_paused" },
completion: { status: "acknowledged", decided_by: "<PROJECT_ID>-private@example.invalid" }, blocker_codes: ["<PROJECT_ID>-private@example.invalid"] };

test("owner and revision-bound setup link", async () => {
const first = (await read()).items[0];
assert.ok(first.todo_lifecycle, "existing list must expose lifecycle, not only decision fields");
assert.equal(first.todo_lifecycle.owner.assignment_status, "not_recorded");
assert.equal(first.todo_lifecycle.owner.required_role, "admin");
assert.equal(first.todo_lifecycle.completion.status, "pending");
assert.equal(first.todo_lifecycle.setup_decision.revision, first.revision);
assert.equal(first.todo_lifecycle.setup_decision.material_sha256, material);
assert.equal(first.todo_lifecycle.setup_decision.href, `?page=decisions&decision=${encodeURIComponent(first.id)}`);
});
test("redaction and explicit resume conditions", async () => {
const unverified = (await read(marked)).items[0].todo_lifecycle;
assert.equal(unverified.completion.status, "pending");
assert.notEqual(unverified.state, "acknowledged");
assert.doesNotMatch(JSON.stringify(unverified), /private|example\.invalid|applied_paused/);
assert.ok(unverified.blockers.some(b => b.code === "decision_blocked_unknown" && b.resume_condition === "restore_verified_decision_material"));
const blocked = (await read({ ...row, blocker_codes: ["decision_evidence_stale", "recovery_blocked"] })).items[0].todo_lifecycle;
assert.ok(blocked.blockers.some(b => b.resume_condition === "refresh_evidence"));
assert.ok(blocked.blockers.some(b => b.resume_condition === "restore_recovery_readiness"));
const missing = (await read({ ...row, decision_binding: null })).items[0].todo_lifecycle;
assert.equal(missing.setup_decision.status, "unavailable");
assert.equal(missing.setup_decision.href, null);
assert.equal(missing.completion.status, "unknown");
});
test("completion requires exact verified durable acknowledgement", async () => {
const completed = (await read(marked, async () => ack)).items[0].todo_lifecycle;
assert.equal(completed.state, "acknowledged");
assert.equal(completed.completion.scope, "decision_intent");
assert.equal(completed.completion.actor_alias, ack.actor_alias);
assert.equal(completed.completion.receipt_sha256, ack.receipt_sha256);
assert.equal(completed.execution_authority_granted, false);
for (const bad of [{ ...ack, material_sha256: "d".repeat(64) }, { ...ack, subject_id: "<PROJECT_ID>-other" },
{ ...ack, kind: "owner" }, { ...ack, review_sha256: "d".repeat(64) }, { ...ack, actor_alias: "<PROJECT_ID>-private@example.invalid" },
{ ...ack, receipt_sha256: null }, { ...ack, acknowledged_at: "invalid" }, { ...ack, action: "unexpected" }]) {
const result = await read(marked, async () => bad);
assert.equal(result.status, "partial");
assert.equal(result.items[0].todo_lifecycle.completion.status, "unknown");
}
for (const reader of [async () => { throw Error("<PROJECT_ID>-private@example.invalid"); }, async () => ({ status: "unavailable" }), async () => null]) {
const result = await read(marked, reader);
assert.equal(result.status, "partial");
assert.equal(result.items.length, 1);
assert.equal(result.items[0].todo_lifecycle.state, "unknown");
assert.doesNotMatch(JSON.stringify(result.items[0].todo_lifecycle), /private|example\.invalid/);
}
});
test("current reads survive restart and authority drift without cached ACK", async () => {
let current = ack;
assert.equal((await read(marked, async () => current)).items[0].todo_lifecycle.completion.status, "acknowledged");
current = { status: "not_found" };
assert.equal((await read(marked, async () => current)).items[0].todo_lifecycle.completion.status, "pending");
let calls = 0;
await read(row, async () => { calls++; return ack; }, { paginate: false });
assert.equal(calls, 0, "by-id detail owns its own exact receipt read");
});

test("bounded page reads and concurrent requests keep independent authority", async () => {
const rows = Array.from({ length: 30 }, (_, n) => ({ ...row, ticket_id: `<PROJECT_ID>-${n}`,
decision_binding: { ...row.decision_binding, subject_id: `<PROJECT_ID>-${n}` } }));
let reads = 0;
const queue = await inbox(new Request("https://fixture.invalid/api/decisions-inbox?limit=3"), {}, {
owner: empty, recovery: empty, credential: empty,
promotion: async () => Response.json({ data_status: "ready", tickets: rows }),
decisionCompletion: async () => { reads++; return { status: "not_found" }; },
});
assert.equal(reads, 3);
assert.equal(queue.items.length, 3);
const [found, missing] = await Promise.all([read(row, async () => ack), read(row, async () => ({ status: "not_found" }))]);
assert.equal(found.items[0].todo_lifecycle.completion.status, "acknowledged");
assert.equal(missing.items[0].todo_lifecycle.completion.status, "pending");
});

test("malformed blockers and mismatched revisions cannot leak or invent setup links", async () => {
const { todoLifecycle } = await import("../web/strategy-switch-console/application/console_api/todo_lifecycle.js");
const item = (await read()).items[0];
const result = todoLifecycle({ ...item, revision: "rev1-" + "0".repeat(16),
blocking_reason_codes: [{ toString: null }, "<PROJECT_ID>-private@example.invalid"] }, ack);
assert.equal(result.setup_decision.status, "unavailable");
assert.equal(result.completion.status, "unknown");
assert.doesNotMatch(JSON.stringify(result), /private|example\.invalid/);
});

test("promotion acknowledgement rejects unqualified historical review digests", async () => {
const { todoLifecycle } = await import("../web/strategy-switch-console/application/console_api/todo_lifecycle.js");
const { decisionRevision } = await import("../web/strategy-switch-console/application/console_api/decision_contract.js");
const current = (await read()).items[0];
for (const category of ["strategy_promotion", "paper_account_application"]) {
for (const reviewBinding of [undefined, null, {}, { review_sha256: null },
{ review_sha256: "invalid" }, { review_sha256: "b".repeat(63) }]) {
const binding = { ...row.decision_binding, review_binding: reviewBinding };
const id = `${category}:${row.ticket_id}`;
const item = { ...current, id, category, decision_binding: binding, revision: decisionRevision(binding, id) };
for (const receiptReview of [undefined, null, reviewBinding?.review_sha256]) {
const result = todoLifecycle(item, { ...ack, review_sha256: receiptReview });
assert.equal(result.completion.status, "unknown", `${category}: unqualified digest must not ACK`);
assert.equal(result.state, "unknown");
assert.equal(result.completion.actor_alias, null);
assert.equal(result.completion.receipt_sha256, null);
assert.ok(result.blockers.some(b => b.code === "decision_completion_unknown"));
}
}
const qualified = { ...current, id: `${category}:${row.ticket_id}`, category };
assert.equal(todoLifecycle(qualified, ack).completion.status, "acknowledged");
for (const receiptReview of [undefined, null, "invalid", "d".repeat(64)])
assert.equal(todoLifecycle(qualified, { ...ack, review_sha256: receiptReview }).completion.status, "unknown");
}
const legacy = { ...row, decision_binding: { ...row.decision_binding, review_binding: null } };
const result = await read(legacy, async () => ({ ...ack, review_sha256: null }));
assert.equal(result.status, "partial");
assert.equal(result.items[0].todo_lifecycle.completion.status, "unknown");
});
10 changes: 8 additions & 2 deletions web/strategy-switch-console/application/console_api/inbox.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { todoLifecycle, todoLifecycleItem } from "./todo_lifecycle.js";
import { canonicalItem } from "./presentation.js";
import { riskTierPolicyCatalog } from "./risk_tiers.js";
import { directoryStatus } from "../../kv_directory.js";
Expand Down Expand Up @@ -120,13 +121,18 @@ export async function inbox(request, env, deps, { paginate = true } = {}) {
(a.created_at || "").localeCompare(b.created_at || "") ||
String(a.id ?? "").localeCompare(String(b.id ?? "")),
);
const items = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered;
const selected = page ? ordered.slice(page.offset, page.offset + page.limit) : ordered;
const items = page
? await Promise.all(selected.map(item => todoLifecycleItem(item, deps.decisionCompletion
? current => deps.decisionCompletion(current, env) : null)))
: selected.map(item => ({ ...item, todo_lifecycle: todoLifecycle(item) }));
const completionUnknown = page && items.some(item => item.todo_lifecycle.completion.status === "unknown");
return {
schema_version: "qsl.decision_inbox.v1",
server_time: new Date().toISOString(),
status: results.some((r) => r.status === "read_failed")
? "read_failed"
: results.some((r) => r.status !== "ok")
: completionUnknown || results.some((r) => r.status !== "ok")
? "partial"
: "ok",
error_state: failure?.error_state || null,
Expand Down
Loading
Loading