From 1f2465aac8b2ef16400b329eb050e17910d2a901 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 19:36:51 +0900 Subject: [PATCH 01/36] docs(dispatch): design least-privilege continuation broker --- ...2026-09-30-continuation-dispatch-broker.md | 159 ++++++++++++++ ...-30-continuation-dispatch-broker-design.md | 195 ++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md create mode 100644 docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md new file mode 100644 index 000000000..73ac0aa1d --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -0,0 +1,159 @@ +# Continuation Dispatch Broker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a versioned Noema broker that validates an exact GitHub Actions identity and live PR, emits one allowlisted central continuation event without returning a GitHub credential, and returns an idempotent signed receipt. + +**Architecture:** Keep `/exchange` unchanged. Route `POST /v1/continuation-dispatches` through the existing bounded edge, rate-limit, workflow-trust, and OIDC verification layers; delegate closed-schema parsing, idempotency, central-only GitHub egress, and receipt signing to focused continuation-dispatch modules and a SQLite Durable Object. + +**Tech Stack:** TypeScript 5.9, Cloudflare Workers/SQLite Durable Objects, Web Crypto Ed25519/SHA-256, Vitest 4, OpenAPI 3. + +**Spec:** `docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md` + +## Global Constraints + +- No consumer-visible GitHub credential, PAT fallback, inherited central secret, raw OIDC logging, or mutable workflow authority. +- Only `ContextualWisdomLab/.github` and the two released continuation actions/events are selectable. +- Every trust-boundary parser fails closed on duplicate/unknown keys, typed lookalikes, whitespace, multiple JSON values, and unbounded input. +- Exact replay returns stored evidence without a second GitHub call; conflict and indeterminate state never auto-dispatch. +- Runtime secrets are typed Worker bindings, never `process.env`. +- `/exchange` behavior and response schema remain unchanged. + +--- + +### Task 1: Closed request and canonical digest + +**Files:** +- Create: `src/continuation-dispatch/contract.ts` +- Create: `test/continuation-dispatch-contract.test.ts` + +**Interfaces:** +- Produces: `parseContinuationDispatchRequest(text: string): ContinuationDispatchRequest` +- Produces: `canonicalContinuationRequest(request, workflowIdentity): string` +- Produces: `continuationRequestDigest(...): Promise` +- Produces: `dispatchMapping(action): { eventType: "noema-review" | "strix-scan" }` + +- [ ] Write RED tests for the exact valid request and every rejected unknown, duplicate, typed, whitespace, multiple-value, SHA/ref, target, action, and retry case. +- [ ] Run `corepack npm exec vitest run test/continuation-dispatch-contract.test.ts`; expect the module import to fail. +- [ ] Implement the closed parser, lexicographic RFC 8785 canonicalizer for the admitted JSON value domain, action mapping, and SHA-256 base64url digest without dependencies. +- [ ] Run the focused test; expect PASS with no warning output. +- [ ] Commit `test+feat(dispatch): define closed continuation contract`. + +### Task 2: Durable exactly-once state + +**Files:** +- Create: `src/continuation-dispatch/dispatch-state.ts` +- Create: `test/continuation-dispatch-state.test.ts` +- Modify: `src/runtime-entrypoint.ts` +- Modify: `wrangler.toml` + +**Interfaces:** +- Produces: `NoemaContinuationDispatchState` +- Produces: `reserveContinuationDispatch(env, identity, digest): Promise` +- Produces: `commitContinuationOutcome(env, reservation, receipt): Promise` + +- [ ] Write RED tests for first reservation, exact replay, conflicting digest, accepted/denied/indeterminate persistence, and malformed stored records. +- [ ] Run the focused state test; expect missing implementation failure. +- [ ] Implement one SQLite transaction per logical identity, bounded stored JSON, and fail-closed private command parsing. +- [ ] Export/bind `NOEMA_CONTINUATION_DISPATCH_STATE` in `runtime-entrypoint.ts` and `wrangler.toml`. +- [ ] Run state tests and `corepack npm run typecheck`; expect PASS. +- [ ] Commit `test+feat(dispatch): add exactly-once continuation state`. + +### Task 3: Live PR and fixed GitHub adapter + +**Files:** +- Create: `src/continuation-dispatch/github-adapter.ts` +- Create: `test/continuation-dispatch-github-adapter.test.ts` +- Modify: `src/index.ts` + +**Interfaces:** +- Consumes: verified `JwtPayload`, `ContinuationDispatchRequest`, and existing GitHub App JWT/token helpers. +- Produces: `readAndVerifyLivePullRequest(...)` +- Produces: `dispatchCentralContinuation(...)` + +- [ ] Write RED tests for moved/closed/draft/fork PRs, wrong repository/base/ref, fixed target/path/event/payload, redirect denial, GitHub 403/404/422/5xx/network classification, and token absence from errors/logs. +- [ ] Run the focused adapter test; expect missing implementation failure. +- [ ] Extract only the existing App-JWT and bounded GitHub JSON helpers needed by both `/exchange` and the broker; do not duplicate them. +- [ ] Implement read-only source App lookup and a separate central-only dispatch App token used solely for `POST /repos/ContextualWisdomLab/.github/dispatches`. +- [ ] Run adapter tests, existing `test/worker.test.ts`, and typecheck; expect PASS. +- [ ] Commit `test+feat(dispatch): bind live PR and fixed central event`. + +### Task 4: Credential-free signed receipt + +**Files:** +- Create: `src/continuation-dispatch/receipt.ts` +- Create: `test/continuation-dispatch-receipt.test.ts` + +**Interfaces:** +- Produces: `signContinuationReceipt(fields, env): Promise` +- Produces: `verifyContinuationReceipt(receipt, publicKey): Promise` for tests and documented consumer conformance. + +- [ ] Write RED tests for deterministic RFC 8785 bytes, Ed25519 verification, OIDC-derived expiry, field mutation failure, and absence of token/bearer/assertion/private-key material. +- [ ] Run the focused receipt test; expect missing implementation failure. +- [ ] Implement dedicated Ed25519 PKCS#8 import, key-id validation, detached base64url signature, and public verification helper. +- [ ] Run receipt tests and typecheck; expect PASS. +- [ ] Commit `test+feat(dispatch): issue signed continuation receipts`. + +### Task 5: Public route integration + +**Files:** +- Create: `src/continuation-dispatch/handler.ts` +- Create: `test/continuation-dispatch-worker.test.ts` +- Modify: `src/index.ts` +- Modify: `src/worker.ts` +- Modify: `src/entrypoint.ts` +- Modify: `src/runtime-entrypoint.ts` +- Modify: `src/runtime-readiness.ts` +- Modify: `src/error-codes.ts` + +**Interfaces:** +- Consumes: Tasks 1-4. +- Produces: `POST /v1/continuation-dispatches` standard success/error envelope. + +- [ ] Write end-to-end RED tests proving method/content-type/body bounds, exact URL, rate limit, workflow SHA, single-use OIDC, live PR, one dispatch, exact replay, conflict, receipt signature, and operational headers. +- [ ] Run the focused worker test; expect 404/missing route failures. +- [ ] Admit the new route through each existing layer, add the six dispatch error codes/hints, require all new secret/DO bindings in readiness, and preserve `/exchange` byte-for-byte behavior tests. +- [ ] Run focused worker tests, all existing Worker tests, and typecheck; expect PASS. +- [ ] Commit `test+feat(dispatch): expose versioned continuation broker`. + +### Task 6: Product, security, and operability contracts + +**Files:** +- Modify: `openapi.json` +- Modify: `docs/api-spec.md` +- Modify: `docs/api-stability-contract.md` +- Modify: `docs/PRD.md` +- Modify: `docs/TRD.md` +- Create: `docs/adr/0019-continuation-dispatch-broker.md` +- Modify: `docs/adr/README.md` +- Modify: `docs/threat-model.md` +- Modify: `docs/runbook.md` +- Modify: `docs/product-technical-gap-baseline.md` +- Modify: `CHANGELOG.md` +- Modify: `README.md` +- Create: `test/continuation-dispatch-docs-contract.test.ts` + +**Interfaces:** +- Publishes: v1 OpenAPI schema, public receipt verification contract, configuration/runbook, and immutable release prerequisites. + +- [ ] Write RED docs-contract tests for endpoint/schema/error codes, required secret/DO bindings, no-token response, ADR status Proposed, and downstream `.github#2540` release order. +- [ ] Run the docs-contract test; expect missing-contract failures. +- [ ] Update every listed contract with the exact design and primary-source references; keep the ADR Proposed until protected integration. +- [ ] Run docs tests, typecheck, focused broker tests, `corepack npm test`, `corepack npm run security:scan`, and `corepack npm run release:verify`. +- [ ] Record the two pre-existing local baseline failures verbatim if they remain; do not filter, downgrade, or call the full suite GREEN. +- [ ] Commit `docs(dispatch): publish broker contracts and release gates`. + +### Task 7: Exact-head owner PR and downstream handoff + +**Files:** +- Modify only evidence text if exact SHA/check identifiers are added after publication. + +**Interfaces:** +- Produces: Draft Noema owner PR linked to #735 and an exact downstream handoff for `.github#2540`. + +- [ ] Fetch protected `main` and the live branch; non-force merge concurrent commits before publication. +- [ ] Re-run focused broker tests, typecheck, full suite, security, SBOM/provenance gates, and `git diff --check` on the exact candidate tree. +- [ ] Push without force, open Draft PR, and record head/tree/parents plus RED/GREEN evidence. +- [ ] Wait for exact-head required Checks and independent review without manual rerun; repair actual findings on the same branch. +- [ ] After ordinary merge, publish an immutable semantic version and verify release assets/provenance. +- [ ] Update `.github#2540` to the released endpoint/schema, remove the legacy exchange plus local `gh api` sequence, and prove a real consumer continuation and fresh exact-head model verdict. diff --git a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md new file mode 100644 index 000000000..1d32b877d --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md @@ -0,0 +1,195 @@ +# Continuation Dispatch Broker Design + +Status: Proposed. This document designs the canonical-owner repair for +`ContextualWisdomLab/noema#735`; it is not release, deployment, or merge +authority. + +## Problem + +Required Noema and Strix workflows execute in consumer repositories. Their +consumer-scoped `github.token` cannot create a `repository_dispatch` event in +`ContextualWisdomLab/.github`. Returning a broader installation token to the +consumer is also unacceptable: GitHub requires `Contents: write` for the +repository-dispatch endpoint, so the token would carry content mutation power +beyond the single intended action. + +The retained protected failure and the current recurrence are bound in +`ContextualWisdomLab/.github#2540`: the existing consumer OIDC exchange can +succeed and the following central dispatch can still fail HTTP 403. Therefore +Noema must own the target/action authorization and perform the fixed dispatch +server-side without returning a GitHub credential. + +## Context and responsibility + +Noema adds a separate public mutation endpoint: + +`POST /v1/continuation-dispatches` + +The existing `POST /exchange` contract is unchanged. The new endpoint is a +synchronous broker inside the Noema Identity/Federation bounded context: + +1. authenticate the GitHub Actions OIDC bearer; +2. bind the caller to the immutable reusable-workflow source; +3. validate one closed continuation request; +4. read the live source PR with the existing read-only source App; +5. atomically reserve the exact request identity; +6. mint a separate central-only dispatch App token inside Noema; +7. emit one internally mapped `repository_dispatch` request; +8. persist the outcome and return a signed, credential-free receipt. + +`.github` remains the consumer adapter and central event handler. It never +reads Noema storage or source and may consume only the released API/schema. + +## Closed request contract + +The body is exactly one UTF-8 JSON object. Duplicate keys, unknown keys, +additional JSON values, non-integer numbers, non-string typed lookalikes, +whitespace-bearing identifiers, and bodies over the existing public mutation +body bound fail before private-key use. + +Required members: + +| Member | Contract | +|---|---| +| `contract_version` | exact string `noema.continuation-dispatch.v1` | +| `dispatch_action` | `noema_review_continuation` or `strix_scan_continuation` | +| `central_repository` | exact string `ContextualWisdomLab/.github` | +| `source_repository` | canonical `owner/name`; equals verified OIDC `repository` | +| `pull_request_number` | positive safe integer | +| `expected_head_sha` | lowercase 40-hex commit SHA | +| `expected_base_sha` | lowercase 40-hex commit SHA | +| `expected_base_ref` | nonempty canonical Git ref name without whitespace | +| `transport_retry_attempt` | integer `1` or `2`, consuming the existing two-attempt contract in `.github` ADR-0031 rather than defining a new retry budget | + +The caller cannot provide a URL, REST path, event type, arbitrary payload, +installation id, permission set, or GitHub token. Noema maps actions internally: + +- `noema_review_continuation` -> `event_type=noema-review`; +- `strix_scan_continuation` -> `event_type=strix-scan`. + +The `client_payload` contains only the validated source repository, PR number, +head/base/ref, and retry attempt. + +## Identity and live-state admission + +The existing OIDC verifier remains the single cryptographic verification path. +Admission additionally requires: + +- exact GitHub issuer and configured audience; +- exact organization id and source repository identity; +- exact reusable-workflow path and `job_workflow_sha`; +- bounded temporal claims and unused `jti`; +- OIDC `repository` equals `source_repository`; +- a fresh GitHub PR read reports open, non-draft, same-organization source and + base repositories, exact head SHA, base SHA, and base ref. + +GitHub documents `job_workflow_sha` as the reusable workflow file's commit SHA, +so it is immutable workflow-source authority rather than a mutable branch +label: . + +## Credential separation and GitHub egress + +The existing GitHub App remains read-only for source PR inspection. A distinct +broker App is installed only on `ContextualWisdomLab/.github` and holds the +`Contents: write` permission GitHub requires for repository dispatch. Its App +id, private key, and central installation id are separate typed Worker secret +bindings. The installation token is created only after admission, stays inside +the fixed dispatch adapter, is never returned or logged, and is not cached +outside the request. + +GitHub's repository-dispatch permission contract is the reason for the broker +shape: . + +All GitHub requests use the exact configured GitHub Cloud origin, fixed paths, +`redirect: "error"`, bounded JSON responses, and existing secret-redaction +rules. No caller string participates in target origin or path selection. + +## Exactly-once state + +Add `NoemaContinuationDispatchState`, a SQLite-backed Durable Object selected +by SHA-256 of an RFC 8785 canonical JSON identity object. Its named members are +`contract_version`, verified `workflow_sha`, `source_repository`, +`pull_request_number`, exact head/base/ref, `dispatch_action`, +`central_repository`, and `transport_retry_attempt`. Named canonical members +avoid the delimiter ambiguity of concatenated strings. + +Canonical bytes contain only that closed identity schema. RFC 8785 defines +deterministic JSON bytes suitable for hashing: +. + +The Durable Object transaction stores the request digest and one state: + +- `reserved`: the first admitted request owns the only dispatch attempt; +- `accepted`: GitHub returned 204; +- `denied`: GitHub returned an authorization/validation denial; +- `indeterminate`: a network error, 5xx, deadline, or unreadable upstream + response makes external side-effect completion unknowable. + +An exact replay returns the stored signed receipt without another GitHub call. +The same logical key with a different digest fails closed. `indeterminate` is +never silently redispatched; operator reconciliation is required. + +## Signed receipt + +Noema returns `{ ok: true, data, trace_id }`; `data` contains no credential. +Receipt fields are: + +- contract/receipt version, receipt id, key id, and `Ed25519` algorithm; +- canonical request digest and idempotency identity; +- verified source repository, PR, head/base/ref; +- verified reusable-workflow ref and SHA; +- fixed central repository and mapped event type; +- exact emitted payload digest; +- outcome `accepted`, `denied`, or `indeterminate` and bounded upstream status; +- issued-at and expiry copied from the admitted OIDC lifetime bound; +- trace id and detached base64url signature. + +The signing private key and key id are dedicated Worker bindings and are not +the GitHub App key. The public key is documented for offline verification. +Cloudflare Workers Web Crypto supports Ed25519, including the standards-based +Secure Curves form: . +The receipt is evidence only and authorizes no later dispatch, merge, release, +or deployment. + +## Error taxonomy + +The response envelope adds these stable codes: + +- `ERR_DISPATCH_REQUEST_INVALID` (400); +- `ERR_DISPATCH_IDENTITY_DENIED` (401/403); +- `ERR_DISPATCH_LIVE_STATE_STALE` (409); +- `ERR_DISPATCH_REPLAY_CONFLICT` (409); +- `ERR_GITHUB_DISPATCH_AUTHORIZATION` (502); +- `ERR_GITHUB_DISPATCH_UPSTREAM` (503). + +Provider capacity and user cancellation are never used for broker failures. +Logs contain only bounded identifiers, digests, status classes, and trace ids. + +## Rejected alternatives + +- Extend `/exchange`: rejected because it returns reusable credential + authority and would silently change an existing stable contract. +- Return a target installation token: rejected because GitHub requires + `Contents: write`, which exceeds a continuation-only consumer capability. +- PAT, inherited central secret, or consumer workflow secret: rejected because + it bypasses OIDC source identity and central owner policy. +- Fire-and-forget retry: rejected because an unknown upstream result can + duplicate an external side effect. +- Consumer copy of broker logic or direct Noema storage access: rejected by + owner/consumer and DDD boundaries. + +## Verification and release order + +1. RED unit/contract tests cover closed JSON, claims, live PR movement, + target/action closure, exact replay/conflict, GitHub denial/unknown outcome, + signature verification, and secret absence. +2. Implement the broker with focused tests and 100% owned-production coverage. +3. Update OpenAPI, API stability, PRD/TRD/ADR, threat model, runbook, + CHANGELOG, and product gap baseline. +4. Run typecheck, focused tests, complete coverage suite, security scan, SBOM, + and provenance on one exact owner head. The two pre-existing local baseline + failures (Unix-socket EPERM and strict-allow-scripts harness behavior) remain + explicit until hosted Linux evidence resolves them. +5. Merge ordinarily after exact-head required Checks and review, publish an + immutable semantic version, then update `.github#2540` to the released + contract and prove a real consumer continuation plus fresh model verdict. From 70e5a16d15ae560a1a51689f187602d52d1a1aae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 20:29:24 +0900 Subject: [PATCH 02/36] test+feat(dispatch): define closed continuation contract --- ...2026-09-30-continuation-dispatch-broker.md | 10 +- ...-30-continuation-dispatch-broker-design.md | 2 +- src/continuation-dispatch/contract.ts | 307 ++++++++++++++++++ test/continuation-dispatch-contract.test.ts | 239 ++++++++++++++ 4 files changed, 552 insertions(+), 6 deletions(-) create mode 100644 src/continuation-dispatch/contract.ts create mode 100644 test/continuation-dispatch-contract.test.ts diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 73ac0aa1d..1987c0bf9 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -33,11 +33,11 @@ - Produces: `continuationRequestDigest(...): Promise` - Produces: `dispatchMapping(action): { eventType: "noema-review" | "strix-scan" }` -- [ ] Write RED tests for the exact valid request and every rejected unknown, duplicate, typed, whitespace, multiple-value, SHA/ref, target, action, and retry case. -- [ ] Run `corepack npm exec vitest run test/continuation-dispatch-contract.test.ts`; expect the module import to fail. -- [ ] Implement the closed parser, lexicographic RFC 8785 canonicalizer for the admitted JSON value domain, action mapping, and SHA-256 base64url digest without dependencies. -- [ ] Run the focused test; expect PASS with no warning output. -- [ ] Commit `test+feat(dispatch): define closed continuation contract`. +- [x] Write RED tests for the exact valid request and every rejected unknown, duplicate, typed, whitespace, multiple-value, SHA/ref, target, action, and retry case. +- [x] Run `corepack npm exec vitest run test/continuation-dispatch-contract.test.ts`; expect the module import to fail. +- [x] Implement the closed parser, lexicographic RFC 8785 canonicalizer for the admitted JSON value domain, action mapping, and lowercase hexadecimal SHA-256 digest without dependencies. +- [x] Run the focused test; expect PASS with no warning output. +- [x] Commit `test+feat(dispatch): define closed continuation contract`. ### Task 2: Durable exactly-once state diff --git a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md index 1d32b877d..b698ad799 100644 --- a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md +++ b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md @@ -107,7 +107,7 @@ rules. No caller string participates in target origin or path selection. ## Exactly-once state Add `NoemaContinuationDispatchState`, a SQLite-backed Durable Object selected -by SHA-256 of an RFC 8785 canonical JSON identity object. Its named members are +by the lowercase hexadecimal SHA-256 digest of an RFC 8785 canonical JSON identity object. Its named members are `contract_version`, verified `workflow_sha`, `source_repository`, `pull_request_number`, exact head/base/ref, `dispatch_action`, `central_repository`, and `transport_retry_attempt`. Named canonical members diff --git a/src/continuation-dispatch/contract.ts b/src/continuation-dispatch/contract.ts new file mode 100644 index 000000000..0fdf76a31 --- /dev/null +++ b/src/continuation-dispatch/contract.ts @@ -0,0 +1,307 @@ +const CONTRACT_VERSION = "noema.continuation-dispatch.v1" as const; +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github" as const; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const SOURCE_REPOSITORY_PATTERN = /^ContextualWisdomLab\/[A-Za-z0-9._-]+$/u; +const FORBIDDEN_REF_CHARACTER_PATTERN = /[\u0000-\u0020~^:?*\[\\]/u; +const REQUEST_KEYS = [ + "central_repository", + "contract_version", + "dispatch_action", + "expected_base_ref", + "expected_base_sha", + "expected_head_sha", + "pull_request_number", + "source_repository", + "transport_retry_attempt", +] as const; +const REQUEST_KEY_SET = JSON.stringify([...REQUEST_KEYS].sort()); +const WORKFLOW_IDENTITY_KEY_SET = JSON.stringify(["workflow_sha"]); + +/** + * Names the only continuation actions released by the v1 Noema broker contract. + * Each value maps to one fixed central event and cannot select a provider or workflow. + */ +export type ContinuationDispatchAction = + | "noema_review_continuation" + | "strix_scan_continuation"; + +/** + * Carries one validated v1 request for an exact central continuation dispatch. + * Every identity remains immutable after admission so it can safely enter idempotency state. + */ +export interface ContinuationDispatchRequest { + readonly contract_version: typeof CONTRACT_VERSION; + readonly dispatch_action: ContinuationDispatchAction; + readonly central_repository: typeof CENTRAL_REPOSITORY; + readonly source_repository: string; + readonly pull_request_number: number; + readonly expected_head_sha: string; + readonly expected_base_sha: string; + readonly expected_base_ref: string; + readonly transport_retry_attempt: 1 | 2; +} + +/** Verified immutable reusable-workflow identity included in dispatch idempotency. */ +export interface ContinuationWorkflowIdentity { + readonly workflow_sha: string; +} + +/** + * Exposes the fixed central event selected by one released continuation action. + * Callers cannot supply an arbitrary GitHub event type through this mapping. + */ +export interface ContinuationDispatchMapping { + readonly eventType: "noema-review" | "strix-scan"; +} + +/** Raised when untrusted continuation request or identity material is not canonical. */ +export class ContinuationDispatchContractError extends Error { + /** Creates a fail-closed continuation contract error with a stable diagnostic. */ + constructor(message: string) { + super(message); + this.name = "ContinuationDispatchContractError"; + } +} + +function rejectContract(message: string): never { + throw new ContinuationDispatchContractError(message); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: string): boolean { + return JSON.stringify(Object.keys(value).sort()) === expected; +} + +function isCanonicalCommitSha(value: unknown): value is string { + return typeof value === "string" && SHA_PATTERN.test(value); +} + +function isCanonicalSourceRepository(value: unknown): value is string { + if (typeof value !== "string" || !SOURCE_REPOSITORY_PATTERN.test(value)) return false; + const repositoryName = value.slice(value.indexOf("/") + 1); + return repositoryName !== "." && repositoryName !== ".."; +} + +function containsUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (index + 1 >= value.length || nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +function isCanonicalBaseRef(value: unknown): value is string { + if ( + typeof value !== "string" + || value.length === 0 + || value === "@" + || value.startsWith("-") + || value.startsWith("/") + || value.endsWith("/") + || value.endsWith(".") + || value.includes("..") + || value.includes("@{") + || value.includes("//") + || FORBIDDEN_REF_CHARACTER_PATTERN.test(value) + || /\s/u.test(value) + || containsUnpairedSurrogate(value) + ) { + return false; + } + return value.split("/").every( + (component) => component.length > 0 && !component.startsWith(".") && !component.endsWith(".lock"), + ); +} + +function decodedRootKeysContainDuplicate(text: string): boolean { + let depth = 0; + let inString = false; + let escaped = false; + let stringStart = -1; + const seen = new Set(); + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character !== "\"") continue; + + inString = false; + if (depth !== 1) continue; + let lookahead = index + 1; + while (lookahead < text.length && /\s/u.test(text[lookahead]!)) lookahead += 1; + if (text[lookahead] !== ":") continue; + + const encodedKey = text.slice(stringStart + 1, index); + // The complete text has already passed JSON.parse, so this exact encoded key is valid JSON string content. + const decodedKey = JSON.parse(`"${encodedKey}"`) as string; + if (seen.has(decodedKey)) return true; + seen.add(decodedKey); + continue; + } + + if (character === "\"") { + inString = true; + stringStart = index; + } else if (character === "{" || character === "[") { + depth += 1; + } else if (character === "}" || character === "]") { + depth -= 1; + } + } + return false; +} + +function admitContinuationDispatchRequest(value: unknown): ContinuationDispatchRequest { + if (!isRecord(value) || !hasExactKeys(value, REQUEST_KEY_SET)) { + return rejectContract("continuation dispatch request must contain exactly the released members"); + } + if (value.contract_version !== CONTRACT_VERSION) { + return rejectContract("continuation dispatch request contract_version is not released"); + } + if ( + value.dispatch_action !== "noema_review_continuation" + && value.dispatch_action !== "strix_scan_continuation" + ) { + return rejectContract("continuation dispatch request dispatch_action is not released"); + } + if (value.central_repository !== CENTRAL_REPOSITORY) { + return rejectContract("continuation dispatch request central_repository is not canonical"); + } + if (!isCanonicalSourceRepository(value.source_repository)) { + return rejectContract("continuation dispatch request source_repository is not canonical"); + } + if ( + typeof value.pull_request_number !== "number" + || !Number.isSafeInteger(value.pull_request_number) + || value.pull_request_number <= 0 + ) { + return rejectContract("continuation dispatch request pull_request_number is not a positive safe integer"); + } + if (!isCanonicalCommitSha(value.expected_head_sha)) { + return rejectContract("continuation dispatch request expected_head_sha is not canonical"); + } + if (!isCanonicalCommitSha(value.expected_base_sha)) { + return rejectContract("continuation dispatch request expected_base_sha is not canonical"); + } + if (!isCanonicalBaseRef(value.expected_base_ref)) { + return rejectContract("continuation dispatch request expected_base_ref is not canonical"); + } + if (value.transport_retry_attempt !== 1 && value.transport_retry_attempt !== 2) { + return rejectContract("continuation dispatch request transport_retry_attempt is not released"); + } + + return Object.freeze({ + contract_version: value.contract_version, + dispatch_action: value.dispatch_action, + central_repository: value.central_repository, + source_repository: value.source_repository, + pull_request_number: value.pull_request_number, + expected_head_sha: value.expected_head_sha, + expected_base_sha: value.expected_base_sha, + expected_base_ref: value.expected_base_ref, + transport_retry_attempt: value.transport_retry_attempt, + }); +} + +function admitWorkflowIdentity(value: unknown): ContinuationWorkflowIdentity { + if ( + !isRecord(value) + || !hasExactKeys(value, WORKFLOW_IDENTITY_KEY_SET) + || !isCanonicalCommitSha(value.workflow_sha) + ) { + return rejectContract("continuation dispatch workflow_sha is not canonical"); + } + return Object.freeze({ workflow_sha: value.workflow_sha }); +} + +function canonicalFlatObject(value: Readonly>): string { + return `{${Object.keys(value).sort().map( + (key) => `${JSON.stringify(key)}:${JSON.stringify(value[key])}`, + ).join(",")}}`; +} + +/** + * Parses one UTF-8-decoded JSON text as the exact v1 continuation request. + * Duplicate decoded root keys, additional values, unknown members, typed lookalikes, and non-canonical identities fail closed. + * @param text Untrusted request text after the public request-body byte and UTF-8 boundary. + * @returns An immutable request containing only validated contract members. + * @throws {ContinuationDispatchContractError} When syntax, shape, type, or canonical identity validation fails. + */ +export function parseContinuationDispatchRequest(text: string): ContinuationDispatchRequest { + if (typeof text !== "string") { + return rejectContract("continuation dispatch request must be JSON text"); + } + let value: unknown; + try { + value = JSON.parse(text) as unknown; + } catch { + return rejectContract("continuation dispatch request is not one complete JSON value"); + } + if (decodedRootKeysContainDuplicate(text)) { + return rejectContract("continuation dispatch request contains duplicate decoded members"); + } + return admitContinuationDispatchRequest(value); +} + +/** + * Maps one released caller action to the only central event type it may emit. + * @param action Candidate continuation action, revalidated at runtime despite its static type. + * @returns The immutable fixed central event mapping. + * @throws {ContinuationDispatchContractError} When the action is not released. + */ +export function dispatchMapping(action: ContinuationDispatchAction): ContinuationDispatchMapping { + if (action === "noema_review_continuation") return Object.freeze({ eventType: "noema-review" }); + if (action === "strix_scan_continuation") return Object.freeze({ eventType: "strix-scan" }); + return rejectContract("continuation dispatch action is not released"); +} + +/** + * Serializes the exact request and verified workflow SHA using RFC 8785 lexicographic object-key order. + * @param request Candidate request; every member is revalidated before it gains idempotency authority. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns Deterministic canonical JSON bytes represented as a string. + * @throws {ContinuationDispatchContractError} When request or workflow identity material is not canonical. + */ +export function canonicalContinuationRequest( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): string { + const admittedRequest = admitContinuationDispatchRequest(request); + const admittedWorkflow = admitWorkflowIdentity(workflowIdentity); + return canonicalFlatObject({ ...admittedRequest, workflow_sha: admittedWorkflow.workflow_sha }); +} + +/** + * Computes the lowercase SHA-256 digest of the exact canonical continuation identity. + * @param request Candidate request revalidated by the canonical serializer. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns A 64-character lowercase hexadecimal digest. + * @throws {ContinuationDispatchContractError} When canonical request material is invalid. + */ +export async function continuationRequestDigest( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): Promise { + const canonicalBytes = new TextEncoder().encode( + canonicalContinuationRequest(request, workflowIdentity), + ); + const digest = await crypto.subtle.digest("SHA-256", canonicalBytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/test/continuation-dispatch-contract.test.ts b/test/continuation-dispatch-contract.test.ts new file mode 100644 index 000000000..f3d637cb0 --- /dev/null +++ b/test/continuation-dispatch-contract.test.ts @@ -0,0 +1,239 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + canonicalContinuationRequest, + continuationRequestDigest, + dispatchMapping, + parseContinuationDispatchRequest, + type ContinuationDispatchRequest, +} from "../src/continuation-dispatch/contract"; + +const validRequest = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/example-product", + pull_request_number: 42, + expected_head_sha: "a".repeat(40), + expected_base_sha: "b".repeat(40), + expected_base_ref: "main", + transport_retry_attempt: 1, +} as const; + +const workflowIdentity = { workflow_sha: "c".repeat(40) } as const; + +function parseValue(value: unknown): ContinuationDispatchRequest { + return parseContinuationDispatchRequest(JSON.stringify(value)); +} + +describe("continuation dispatch request contract", () => { + it("accepts exactly the released closed request", () => { + const parsed = parseValue(validRequest); + + expect(parsed).toEqual(validRequest); + expect(Object.isFrozen(parsed)).toBe(true); + }); + + it("accepts JSON grammar whitespace between a decoded member name and its colon", () => { + const raw = JSON.stringify(validRequest).replace('"contract_version":', '"contract_version" \n :'); + + expect(parseContinuationDispatchRequest(raw)).toEqual(validRequest); + }); + + it("accepts both released actions and the inherited second continuation attempt", () => { + const parsed = parseValue({ + ...validRequest, + dispatch_action: "strix_scan_continuation", + transport_retry_attempt: 2, + }); + + expect(parsed.dispatch_action).toBe("strix_scan_continuation"); + expect(parsed.transport_retry_attempt).toBe(2); + }); + + it.each([ + ["unknown member", { ...validRequest, event_type: "noema-review" }], + ["unknown nested member", { ...validRequest, arbitrary: { nested: "member" } }], + ["array root", [validRequest]], + ["null root", null], + ["string root", "request"], + ])("rejects %s", (_label, value) => { + expect(() => parseValue(value)).toThrow(/continuation dispatch request/i); + }); + + it.each([ + ["direct duplicate", `{"contract_version":"ignored","contract_version":"${validRequest.contract_version}","dispatch_action":"${validRequest.dispatch_action}","central_repository":"${validRequest.central_repository}","source_repository":"${validRequest.source_repository}","pull_request_number":42,"expected_head_sha":"${validRequest.expected_head_sha}","expected_base_sha":"${validRequest.expected_base_sha}","expected_base_ref":"main","transport_retry_attempt":1}`], + ["escape-equivalent duplicate", `{"contract_version":"${validRequest.contract_version}","dispatch_action":"ignored","dispatch_acti\\u006fn":"${validRequest.dispatch_action}","central_repository":"${validRequest.central_repository}","source_repository":"${validRequest.source_repository}","pull_request_number":42,"expected_head_sha":"${validRequest.expected_head_sha}","expected_base_sha":"${validRequest.expected_base_sha}","expected_base_ref":"main","transport_retry_attempt":1}`], + ])("rejects %s decoded keys before last-key-wins parsing", (_label, raw) => { + expect(() => parseContinuationDispatchRequest(raw)).toThrow(/duplicate/i); + }); + + it.each([ + ["contract version", { ...validRequest, contract_version: 1 }], + ["action", { ...validRequest, dispatch_action: [validRequest.dispatch_action] }], + ["central repository", { ...validRequest, central_repository: null }], + ["source repository", { ...validRequest, source_repository: {} }], + ["pull request number", { ...validRequest, pull_request_number: "42" }], + ["head SHA", { ...validRequest, expected_head_sha: true }], + ["base SHA", { ...validRequest, expected_base_sha: [] }], + ["base ref", { ...validRequest, expected_base_ref: 7 }], + ["retry attempt", { ...validRequest, transport_retry_attempt: "1" }], + ])("rejects typed lookalike for %s", (_label, value) => { + expect(() => parseValue(value)).toThrow(/continuation dispatch request/i); + }); + + it.each([ + " ContextualWisdomLab/example-product", + "ContextualWisdomLab/example-product ", + "OtherOrganization/example-product", + "ContextualWisdomLab", + "ContextualWisdomLab/", + "ContextualWisdomLab/.", + "ContextualWisdomLab/..", + "ContextualWisdomLab/first/second", + "ContextualWisdomLab/example product", + ])("rejects non-canonical source repository %j", (source_repository) => { + expect(() => parseValue({ ...validRequest, source_repository })).toThrow(/source_repository/i); + }); + + it.each([ + 0, + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects non-positive-safe pull request number %j", (pull_request_number) => { + expect(() => parseValue({ ...validRequest, pull_request_number })).toThrow(/pull_request_number/i); + }); + + it.each([ + "A".repeat(40), + "a".repeat(39), + "g".repeat(40), + ])("rejects non-canonical head SHA %j", (expected_head_sha) => { + expect(() => parseValue({ ...validRequest, expected_head_sha })).toThrow(/expected_head_sha/i); + }); + + it.each([ + "B".repeat(40), + "b".repeat(39), + "z".repeat(40), + ])("rejects non-canonical base SHA %j", (expected_base_sha) => { + expect(() => parseValue({ ...validRequest, expected_base_sha })).toThrow(/expected_base_sha/i); + }); + + it.each([ + "", + "@", + "/main", + " main", + "main ", + "main.", + "feature..branch", + "feature.lock", + "feature@{branch", + "feature//branch", + "feature\\branch", + "feature~branch", + "feature^branch", + "feature:branch", + "feature?branch", + "feature*branch", + "feature[branch", + "-main", + ".hidden", + "feature/\ud800", + "feature/\udc00", + "feature/", + ])("rejects base ref outside git check-ref-format --branch %j", (expected_base_ref) => { + expect(() => parseValue({ ...validRequest, expected_base_ref })).toThrow(/expected_base_ref/i); + }); + + it("accepts a canonical slash-delimited base ref", () => { + expect(parseValue({ ...validRequest, expected_base_ref: "release/next" }).expected_base_ref) + .toBe("release/next"); + expect(parseValue({ ...validRequest, expected_base_ref: "release/🚀" }).expected_base_ref) + .toBe("release/🚀"); + }); + + it("rejects every non-canonical target", () => { + for (const central_repository of [ + "ContextualWisdomLab/contextual-orchestrator", + " ContextualWisdomLab/.github", + "ContextualWisdomLab/.github ", + ]) { + expect(() => parseValue({ ...validRequest, central_repository })).toThrow(/central_repository/i); + } + }); + + it("rejects arbitrary actions", () => { + expect(() => parseValue({ ...validRequest, dispatch_action: "repository_dispatch" })) + .toThrow(/dispatch_action/i); + }); + + it.each([0, 3, 1.5, true, null])("rejects retry attempt %j", (transport_retry_attempt) => { + expect(() => parseValue({ ...validRequest, transport_retry_attempt })).toThrow(/transport_retry_attempt/i); + }); + + it.each([ + "", + "{} {}", + `${JSON.stringify(validRequest)} null`, + "{", + ])("rejects malformed or additional JSON values %j", (raw) => { + expect(() => parseContinuationDispatchRequest(raw)).toThrow(/continuation dispatch request/i); + }); + + it("rejects a non-string runtime argument before JSON parsing", () => { + const parseRuntimeValue = parseContinuationDispatchRequest as (value: unknown) => ContinuationDispatchRequest; + + expect(() => parseRuntimeValue(null)).toThrow(/JSON text/i); + }); +}); + +describe("continuation dispatch canonical identity", () => { + it("maps only released actions to fixed event types", () => { + expect(dispatchMapping("noema_review_continuation")).toEqual({ eventType: "noema-review" }); + expect(dispatchMapping("strix_scan_continuation")).toEqual({ eventType: "strix-scan" }); + expect(() => dispatchMapping("arbitrary" as never)).toThrow(/dispatch action/i); + }); + + it("serializes the named identity in RFC 8785 lexicographic key order", () => { + const canonical = canonicalContinuationRequest(parseValue(validRequest), workflowIdentity); + + expect(canonical).toBe( + `{"central_repository":"ContextualWisdomLab/.github","contract_version":"noema.continuation-dispatch.v1","dispatch_action":"noema_review_continuation","expected_base_ref":"main","expected_base_sha":"${"b".repeat(40)}","expected_head_sha":"${"a".repeat(40)}","pull_request_number":42,"source_repository":"ContextualWisdomLab/example-product","transport_retry_attempt":1,"workflow_sha":"${"c".repeat(40)}"}`, + ); + }); + + it("produces the exact lowercase SHA-256 digest of the canonical bytes", async () => { + const request = parseValue(validRequest); + const canonical = canonicalContinuationRequest(request, workflowIdentity); + const expected = createHash("sha256").update(canonical, "utf8").digest("hex"); + + await expect(continuationRequestDigest(request, workflowIdentity)).resolves.toBe(expected); + expect(expected).toMatch(/^[0-9a-f]{64}$/u); + }); + + it("fails closed when runtime workflow identity is not a lowercase full commit SHA", async () => { + const request = parseValue(validRequest); + + expect(() => canonicalContinuationRequest(request, { workflow_sha: "C".repeat(40) })) + .toThrow(/workflow_sha/i); + await expect(continuationRequestDigest(request, { workflow_sha: "c".repeat(39) })) + .rejects.toThrow(/workflow_sha/i); + }); + + it("rejects non-object and open workflow identities", () => { + const request = parseValue(validRequest); + const canonicalRuntimeIdentity = canonicalContinuationRequest as ( + candidateRequest: ContinuationDispatchRequest, + candidateIdentity: unknown, + ) => string; + + expect(() => canonicalRuntimeIdentity(request, null)).toThrow(/workflow_sha/i); + expect(() => canonicalRuntimeIdentity(request, { + workflow_sha: workflowIdentity.workflow_sha, + mutable_ref: "main", + })).toThrow(/workflow_sha/i); + }); +}); From 4d9b04047772ecbc89ba53e0b00e1c47431273a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 21:38:35 +0900 Subject: [PATCH 03/36] test+feat(dispatch): add exactly-once continuation state --- ...2026-09-30-continuation-dispatch-broker.md | 10 +- src/continuation-dispatch/dispatch-state.ts | 646 ++++++++++++++++++ src/runtime-entrypoint.ts | 4 +- test/continuation-dispatch-state.test.ts | 643 +++++++++++++++++ wrangler.toml | 8 + 5 files changed, 1305 insertions(+), 6 deletions(-) create mode 100644 src/continuation-dispatch/dispatch-state.ts create mode 100644 test/continuation-dispatch-state.test.ts diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 1987c0bf9..1660dc8f0 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -52,11 +52,11 @@ - Produces: `reserveContinuationDispatch(env, identity, digest): Promise` - Produces: `commitContinuationOutcome(env, reservation, receipt): Promise` -- [ ] Write RED tests for first reservation, exact replay, conflicting digest, accepted/denied/indeterminate persistence, and malformed stored records. -- [ ] Run the focused state test; expect missing implementation failure. -- [ ] Implement one SQLite transaction per logical identity, bounded stored JSON, and fail-closed private command parsing. -- [ ] Export/bind `NOEMA_CONTINUATION_DISPATCH_STATE` in `runtime-entrypoint.ts` and `wrangler.toml`. -- [ ] Run state tests and `corepack npm run typecheck`; expect PASS. +- [x] Write RED tests for first reservation, exact replay, conflicting digest, accepted/denied/indeterminate persistence, and malformed stored records. +- [x] Run the focused state test; expect missing implementation failure. +- [x] Implement one SQLite transaction per logical identity, bounded stored JSON, and fail-closed private command parsing. +- [x] Export/bind `NOEMA_CONTINUATION_DISPATCH_STATE` in `runtime-entrypoint.ts` and `wrangler.toml`. +- [x] Run state tests and `corepack npm run typecheck`; expect PASS. - [ ] Commit `test+feat(dispatch): add exactly-once continuation state`. ### Task 3: Live PR and fixed GitHub adapter diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts new file mode 100644 index 000000000..7422fa39f --- /dev/null +++ b/src/continuation-dispatch/dispatch-state.ts @@ -0,0 +1,646 @@ +import { isJsonMediaType } from "../rate-limit"; + +const STATE_VERSION = "noema.continuation-dispatch-state.v1" as const; +const STATE_RECORD_KEY = "continuation-dispatch-state"; +const INTERNAL_ENDPOINT = "https://noema-continuation-dispatch-state.internal/command"; +// This private command and retained receipt inherit the released 8 KiB public mutation envelope. +const MAX_STATE_JSON_BYTES = 8_192; +const DIGEST_PATTERN = /^[0-9a-f]{64}$/u; +const RESERVATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const TERMINAL_OUTCOMES = new Set([ + "accepted", + "denied", + "indeterminate", +]); + +/** + * Supplies the single Durable Object namespace that owns continuation-dispatch reservations. + * The binding is mandatory because an absent serialization authority must never fall back in process. + */ +export interface ContinuationDispatchStateEnv { + readonly NOEMA_CONTINUATION_DISPATCH_STATE: DurableObjectNamespace; +} + +/** + * Names the only persisted terminal results of one continuation dispatch attempt. + * Indeterminate outcomes remain terminal because retrying could duplicate an external side effect. + */ +export type ContinuationDispatchOutcome = "accepted" | "denied" | "indeterminate"; + +/** + * Describes the bounded JSON receipt persisted after one terminal dispatch attempt. + * Task 4 supplies the released signed receipt schema while this boundary enforces its terminal outcome. + */ +export type ContinuationDispatchReceipt = Readonly> & { + readonly outcome: ContinuationDispatchOutcome; +}; + +/** + * Reports whether a caller owns a fresh reservation, observed an in-progress exact replay, + * or received immutable evidence for a previously completed exact request. + */ +export type ContinuationDispatchReservation = + | { + readonly kind: "reserved"; + readonly identity: string; + readonly digest: string; + readonly reservationId: string; + } + | { + readonly kind: "in_progress"; + readonly identity: string; + readonly digest: string; + } + | { + readonly kind: "replay"; + readonly identity: string; + readonly digest: string; + readonly outcome: ContinuationDispatchOutcome; + readonly receipt: ContinuationDispatchReceipt; + }; + +type NewReservation = Extract; + +type StoredDispatchState = { + readonly version: typeof STATE_VERSION; + readonly identity: string; + readonly request_digest: string; + readonly status: "reserved" | ContinuationDispatchOutcome; + readonly reservation_id: string; + readonly receipt?: ContinuationDispatchReceipt; +}; + +type ReserveCommand = { + readonly operation: "reserve"; + readonly identity: string; + readonly digest: string; +}; + +type CommitCommand = { + readonly operation: "commit"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type DispatchStateCommand = ReserveCommand | CommitCommand; + +type CommandRead = + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly status: 400 | 413; readonly error: "invalid_request" | "request_too_large" }; + +/** Signals that one logical continuation identity is already bound to different immutable authority. */ +export class ContinuationDispatchStateConflict extends Error { + /** Creates a stable fail-closed conflict without exposing retained receipt contents. */ + constructor(message = "continuation dispatch state conflicts with retained authority") { + super(message); + this.name = "ContinuationDispatchStateConflict"; + Object.setPrototypeOf(this, ContinuationDispatchStateConflict.prototype); + } +} + +/** Signals that the continuation state authority could not return a trustworthy decision. */ +export class ContinuationDispatchStateUnavailable extends Error { + /** Creates a stable infrastructure failure that callers must treat as non-authoritative. */ + constructor(message: string) { + super(message); + this.name = "ContinuationDispatchStateUnavailable"; + Object.setPrototypeOf(this, ContinuationDispatchStateUnavailable.prototype); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && DIGEST_PATTERN.test(value); +} + +function isReservationId(value: unknown): value is string { + return typeof value === "string" && RESERVATION_ID_PATTERN.test(value); +} + +function isOutcome(value: unknown): value is ContinuationDispatchOutcome { + return typeof value === "string" && TERMINAL_OUTCOMES.has(value as ContinuationDispatchOutcome); +} + +function isJsonValue(value: unknown): boolean { + try { + const pending: unknown[] = [value]; + const visited = new WeakSet(); + while (pending.length > 0) { + const current = pending.pop(); + if ( + current === null + || typeof current === "string" + || typeof current === "boolean" + || (typeof current === "number" && Number.isFinite(current)) + ) { + continue; + } + if (typeof current !== "object") return false; + if (visited.has(current)) return false; + visited.add(current); + if (Array.isArray(current)) { + pending.push(...current); + continue; + } + if (Object.getPrototypeOf(current) !== Object.prototype) return false; + pending.push(...Object.values(current as Record)); + } + return true; + } catch { + return false; + } +} + +function encodedJsonBytes(value: unknown): number | undefined { + if (!isJsonValue(value)) return undefined; + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; + } catch { + return undefined; + } +} + +function isReceipt(value: unknown): value is ContinuationDispatchReceipt { + const encodedBytes = encodedJsonBytes(value); + return ( + isRecord(value) + && isOutcome(value.outcome) + && encodedBytes !== undefined + && encodedBytes <= MAX_STATE_JSON_BYTES + ); +} + +function isStoredState(value: unknown): value is StoredDispatchState { + if (!isRecord(value)) return false; + if ( + value.version !== STATE_VERSION + || !isDigest(value.identity) + || !isDigest(value.request_digest) + || !isReservationId(value.reservation_id) + ) { + return false; + } + if (value.status === "reserved") { + return hasExactKeys(value, [ + "version", + "identity", + "request_digest", + "status", + "reservation_id", + ]); + } + const encodedBytes = encodedJsonBytes(value); + return ( + isOutcome(value.status) + && hasExactKeys(value, [ + "version", + "identity", + "request_digest", + "status", + "reservation_id", + "receipt", + ]) + && isReceipt(value.receipt) + && value.receipt.outcome === value.status + && encodedBytes !== undefined + && encodedBytes <= MAX_STATE_JSON_BYTES + ); +} + +function duplicateDecodedObjectKey(text: string): boolean { + const stack: Array<{ readonly kind: "array" } | { readonly kind: "object"; readonly keys: Set }> = []; + let inString = false; + let escaped = false; + let stringStart = -1; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (inString) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === "\"") { + inString = false; + const scope = stack[stack.length - 1]; + if (scope?.kind !== "object") continue; + let lookahead = index + 1; + while (lookahead < text.length && /\s/u.test(text[lookahead]!)) lookahead += 1; + if (text[lookahead] !== ":") continue; + const decoded = JSON.parse(`"${text.slice(stringStart + 1, index)}"`) as string; + if (scope.keys.has(decoded)) return true; + scope.keys.add(decoded); + } + continue; + } + if (character === "\"") { + inString = true; + stringStart = index; + } else if (character === "{") { + stack.push({ kind: "object", keys: new Set() }); + } else if (character === "[") { + stack.push({ kind: "array" }); + } else if (character === "}" || character === "]") { + stack.pop(); + } + } + return false; +} + +function ignoreCancellationBestEffort(cancel: () => Promise): void { + void Promise.resolve().then(cancel).catch(() => undefined); +} + +function cancelBodyBestEffort(body: ReadableStream | null, reason: string): void { + if (body !== null) ignoreCancellationBestEffort(() => body.cancel(reason)); +} + +async function readCommand(request: Request): Promise { + const declaredLength = request.headers.get("content-length"); + if ( + declaredLength !== null + && (!/^(?:0|[1-9][0-9]*)$/u.test(declaredLength) || Number(declaredLength) > MAX_STATE_JSON_BYTES) + ) { + cancelBodyBestEffort(request.body, "continuation state request has an invalid declared length"); + return { ok: false, status: 413, error: "request_too_large" }; + } + if (request.body === null) return { ok: false, status: 400, error: "invalid_request" }; + + let reader: ReadableStreamDefaultReader; + try { + reader = request.body.getReader(); + } catch { + return { ok: false, status: 400, error: "invalid_request" }; + } + const storage = new Uint8Array(MAX_STATE_JSON_BYTES); + let totalBytes = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + if (result.value.byteLength > MAX_STATE_JSON_BYTES - totalBytes) { + ignoreCancellationBestEffort(() => reader.cancel("continuation state request exceeded its byte envelope")); + return { ok: false, status: 413, error: "request_too_large" }; + } + storage.set(result.value, totalBytes); + totalBytes += result.value.byteLength; + } + } catch { + ignoreCancellationBestEffort(() => reader.cancel("continuation state request body could not be read")); + return { ok: false, status: 400, error: "invalid_request" }; + } finally { + reader.releaseLock(); + } + + let text: string; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(storage.subarray(0, totalBytes)); + const value = JSON.parse(text) as unknown; + if (duplicateDecodedObjectKey(text)) { + return { ok: false, status: 400, error: "invalid_request" }; + } + return { ok: true, value }; + } catch { + return { ok: false, status: 400, error: "invalid_request" }; + } +} + +function parseCommand(value: unknown): DispatchStateCommand | undefined { + if (!isRecord(value) || typeof value.operation !== "string") return undefined; + if (value.operation === "reserve") { + if (!hasExactKeys(value, ["operation", "identity", "digest"])) return undefined; + if (!isDigest(value.identity) || !isDigest(value.digest)) return undefined; + return { operation: "reserve", identity: value.identity, digest: value.digest }; + } + if (value.operation === "commit") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + ) { + return undefined; + } + return { + operation: "commit", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } + return undefined; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + }, + }); +} + +function reservationFromStored(stored: StoredDispatchState): ContinuationDispatchReservation { + if (stored.status === "reserved") { + return { kind: "in_progress", identity: stored.identity, digest: stored.request_digest }; + } + return { + kind: "replay", + identity: stored.identity, + digest: stored.request_digest, + outcome: stored.status, + receipt: stored.receipt!, + }; +} + +function isReservation(value: unknown): value is ContinuationDispatchReservation { + if (!isRecord(value) || !isDigest(value.identity) || !isDigest(value.digest)) return false; + if (value.kind === "reserved") { + return hasExactKeys(value, ["kind", "identity", "digest", "reservationId"]) + && isReservationId(value.reservationId); + } + if (value.kind === "in_progress") { + return hasExactKeys(value, ["kind", "identity", "digest"]); + } + return value.kind === "replay" + && hasExactKeys(value, ["kind", "identity", "digest", "outcome", "receipt"]) + && isOutcome(value.outcome) + && isReceipt(value.receipt) + && value.receipt.outcome === value.outcome; +} + +async function readDecision(response: Response): Promise { + if (!isJsonMediaType(response.headers.get("content-type"))) { + cancelBodyBestEffort(response.body, "continuation state response has an invalid content type"); + throw new ContinuationDispatchStateUnavailable("continuation state returned an invalid content type"); + } + const declaredLength = response.headers.get("content-length"); + if (declaredLength !== null && Number(declaredLength) > MAX_STATE_JSON_BYTES) { + cancelBodyBestEffort(response.body, "continuation state response exceeded its declared byte envelope"); + throw new ContinuationDispatchStateUnavailable("continuation state response exceeded its byte envelope"); + } + if (response.body === null) { + throw new ContinuationDispatchStateUnavailable("continuation state returned an empty decision"); + } + let reader: ReadableStreamDefaultReader; + try { + reader = response.body.getReader(); + } catch { + throw new ContinuationDispatchStateUnavailable("continuation state decision body could not be read"); + } + const storage = new Uint8Array(MAX_STATE_JSON_BYTES); + let totalBytes = 0; + try { + while (true) { + const result = await reader.read(); + if (result.done) break; + if (result.value.byteLength > MAX_STATE_JSON_BYTES - totalBytes) { + ignoreCancellationBestEffort(() => reader.cancel("continuation state response exceeded its byte envelope")); + throw new ContinuationDispatchStateUnavailable("continuation state response exceeded its byte envelope"); + } + storage.set(result.value, totalBytes); + totalBytes += result.value.byteLength; + } + const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }) + .decode(storage.subarray(0, totalBytes)); + if (duplicateDecodedObjectKey(text)) { + throw new ContinuationDispatchStateUnavailable("continuation state returned ambiguous JSON"); + } + return JSON.parse(text) as unknown; + } catch (error) { + if (error instanceof ContinuationDispatchStateUnavailable) throw error; + ignoreCancellationBestEffort(() => reader.cancel("continuation state response body could not be read")); + throw new ContinuationDispatchStateUnavailable("continuation state returned malformed JSON"); + } finally { + reader.releaseLock(); + } +} + +async function stateStub( + env: ContinuationDispatchStateEnv, + identity: string, +): Promise { + if (!isDigest(identity)) { + throw new ContinuationDispatchStateUnavailable("continuation identity is not a lowercase SHA-256 digest"); + } + try { + const objectId = env.NOEMA_CONTINUATION_DISPATCH_STATE.idFromName(`continuation:${identity}`); + return env.NOEMA_CONTINUATION_DISPATCH_STATE.get(objectId); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } +} + +/** + * Atomically reserves one logical continuation identity or returns its exact retained state. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param identity Lowercase SHA-256 idempotency identity used only to select one serialization point. + * @param digest Lowercase SHA-256 digest of the complete canonical request and workflow identity. + * @returns A fresh reservation capability, an in-progress replay, or a completed immutable replay receipt. + * @throws {ContinuationDispatchStateConflict} When the logical identity is retained with another digest. + * @throws {ContinuationDispatchStateUnavailable} When binding, transport, parsing, or retained state is untrustworthy. + */ +export async function reserveContinuationDispatch( + env: ContinuationDispatchStateEnv, + identity: string, + digest: string, +): Promise { + if (!isDigest(digest)) { + throw new ContinuationDispatchStateUnavailable("continuation request digest is not canonical"); + } + const stub = await stateStub(env, identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 && response.status !== 201) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } + if (!isRecord(decision) || !hasExactKeys(decision, ["ok", "data"]) || decision.ok !== true || !isReservation(decision.data)) { + throw new ContinuationDispatchStateUnavailable("continuation state returned an invalid reservation"); + } + if (decision.data.identity !== identity || decision.data.digest !== digest) { + throw new ContinuationDispatchStateUnavailable("continuation state returned mismatched authority"); + } + return decision.data; +} + +/** + * Commits one terminal receipt only for the capability returned by the first successful reservation. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Fresh reservation capability that owns the only allowed dispatch attempt. + * @param receipt Bounded JSON receipt whose outcome is accepted, denied, or indeterminate. + * @returns A promise that resolves only after the exact terminal state is durably committed. + * @throws {ContinuationDispatchStateConflict} When the reservation is stale, wrong, or already terminal. + * @throws {ContinuationDispatchStateUnavailable} When receipt, transport, parsing, or storage authority is untrustworthy. + */ +export async function commitContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + ) { + throw new ContinuationDispatchStateUnavailable("continuation outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "commit", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if ( + response.status !== 200 + || !isRecord(decision) + || !hasExactKeys(decision, ["ok"]) + || decision.ok !== true + ) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Cloudflare Durable Object that serializes one logical continuation identity through reservation and terminal receipt. + * Its closed private protocol preserves indeterminate outcomes so concurrent or retried callers never duplicate dispatch. + */ +export class NoemaContinuationDispatchState { + private readonly storage: DurableObjectStorage; + private readonly objectName: string | undefined; + + /** Creates one identity-scoped state authority from Cloudflare's Durable Object state. */ + constructor(state: DurableObjectState) { + this.storage = state.storage; + this.objectName = state.id.name; + } + + /** + * Applies one bounded reserve or commit command at the fixed private endpoint. + * @param request Internal JSON command containing no bearer, App token, or private-key material. + * @returns A closed JSON decision; corrupt state and storage faults fail closed without replacing evidence. + */ + async fetch(request: Request): Promise { + if (request.method !== "POST" || request.url !== INTERNAL_ENDPOINT) { + cancelBodyBestEffort(request.body, "continuation state request used the wrong endpoint or method"); + return jsonResponse({ ok: false, error: "not_found" }, 404); + } + if (!isJsonMediaType(request.headers.get("content-type"))) { + cancelBodyBestEffort(request.body, "continuation state request requires JSON"); + return jsonResponse({ ok: false, error: "content_type_required" }, 415); + } + const read = await readCommand(request); + if (!read.ok) return jsonResponse({ ok: false, error: read.error }, read.status); + const command = parseCommand(read.value); + if (command === undefined || this.objectName !== `continuation:${command.identity}`) { + return jsonResponse({ ok: false, error: "invalid_request" }, 400); + } + + try { + if (command.operation === "reserve") { + const decision = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (stored !== undefined) { + if (!isStoredState(stored)) return { kind: "invalid" } as const; + if (stored.identity !== command.identity || stored.request_digest !== command.digest) { + return { kind: "conflict" } as const; + } + return { kind: "existing", reservation: reservationFromStored(stored) } as const; + } + const reservationId = crypto.randomUUID(); + const next: StoredDispatchState = { + version: STATE_VERSION, + identity: command.identity, + request_digest: command.digest, + status: "reserved", + reservation_id: reservationId, + }; + await transaction.put(STATE_RECORD_KEY, next); + return { + kind: "created", + reservation: { + kind: "reserved", + identity: command.identity, + digest: command.digest, + reservationId, + } satisfies NewReservation, + } as const; + }); + if (decision.kind === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (decision.kind === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true, data: decision.reservation }, decision.kind === "created" ? 201 : 200); + } + + const committed = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "reserved" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + ) { + return "conflict"; + } + const next: StoredDispatchState = { + ...stored, + status: command.receipt.outcome, + receipt: command.receipt, + }; + if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_STATE_JSON_BYTES) return "invalid"; + await transaction.put(STATE_RECORD_KEY, next); + return "committed"; + }); + if (committed === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (committed === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } catch { + return jsonResponse({ ok: false, error: "storage_unavailable" }, 503); + } + } +} diff --git a/src/runtime-entrypoint.ts b/src/runtime-entrypoint.ts index 60c02eca1..28208a6a7 100644 --- a/src/runtime-entrypoint.ts +++ b/src/runtime-entrypoint.ts @@ -6,8 +6,10 @@ import entrypoint, { } from "./entrypoint"; import { normalizeGitHubAppPrivateKeyPem } from "./github-app-private-key"; import { evaluateRuntimeReadiness } from "./runtime-readiness"; +import { type ContinuationDispatchStateEnv } from "./continuation-dispatch/dispatch-state"; export { NoemaOidcReplayGuard, NoemaRateLimiter }; +export { NoemaContinuationDispatchState } from "./continuation-dispatch/dispatch-state"; export { NoemaWorkflowState } from "./workflow-task-execution/workflow-state-durable-object"; export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-extension-lifecycle-durable-object"; @@ -17,7 +19,7 @@ export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-exte * consumed by the delegated application entrypoint and adds the immutable source revision * expected for the configured central reusable workflow. */ -export interface Env extends BaseEnv { +export interface Env extends BaseEnv, ContinuationDispatchStateEnv { ALLOWED_WORKFLOW_SHA?: string; } diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts new file mode 100644 index 000000000..ea0caad2b --- /dev/null +++ b/test/continuation-dispatch-state.test.ts @@ -0,0 +1,643 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + ContinuationDispatchStateConflict, + ContinuationDispatchStateUnavailable, + NoemaContinuationDispatchState, + commitContinuationOutcome, + reserveContinuationDispatch, + type ContinuationDispatchReservation, + type ContinuationDispatchStateEnv, +} from "../src/continuation-dispatch/dispatch-state"; +import * as runtimeEntrypoint from "../src/runtime-entrypoint"; + +const identity = "a".repeat(64); +const digest = "b".repeat(64); +const endpoint = "https://noema-continuation-dispatch-state.internal/command"; + +class MemoryStorage { + readonly records = new Map(); + transactionCount = 0; + + async get(key: string): Promise { + return structuredClone(this.records.get(key)) as T | undefined; + } + + async put(key: string, value: T): Promise { + this.records.set(key, structuredClone(value)); + } + + async transaction(callback: (transaction: MemoryStorage) => Promise): Promise { + this.transactionCount += 1; + return callback(this); + } + + replaceOnlyRecord(value: unknown): void { + const key = [...this.records.keys()][0]; + if (key === undefined) throw new Error("fixture has no continuation state record"); + this.records.set(key, structuredClone(value)); + } +} + +class InProcessNamespace { + readonly storageByName = new Map(); + + idFromName(name: string): DurableObjectId { + return { name, toString: () => name } as unknown as DurableObjectId; + } + + get(objectId: DurableObjectId): DurableObjectStub { + const name = objectId.toString(); + const storage = this.storageByName.get(name) ?? new MemoryStorage(); + this.storageByName.set(name, storage); + const object = new NoemaContinuationDispatchState({ + id: { name } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + return { + fetch: (input: RequestInfo | URL, init?: RequestInit) => object.fetch(new Request(input, init)), + } as unknown as DurableObjectStub; + } +} + +function fixture(): { + env: ContinuationDispatchStateEnv; + namespace: InProcessNamespace; +} { + const namespace = new InProcessNamespace(); + return { + env: { + NOEMA_CONTINUATION_DISPATCH_STATE: namespace as unknown as DurableObjectNamespace, + }, + namespace, + }; +} + +function requireNewReservation( + reservation: ContinuationDispatchReservation, +): Extract { + if (reservation.kind !== "reserved") { + throw new Error(`expected a new reservation, received ${reservation.kind}`); + } + return reservation; +} + +function fixedReservation(): Extract { + return { + kind: "reserved", + identity, + digest, + reservationId: "00000000-0000-4000-8000-000000000000", + }; +} + +function envWithTransport( + fetch: DurableObjectStub["fetch"], + idFromName: (name: string) => DurableObjectId = (name) => ( + { name, toString: () => name } as unknown as DurableObjectId + ), +): ContinuationDispatchStateEnv { + return { + NOEMA_CONTINUATION_DISPATCH_STATE: { + idFromName, + get: () => ({ fetch }) as DurableObjectStub, + } as unknown as DurableObjectNamespace, + }; +} + +describe("continuation dispatch exactly-once state", () => { + it("grants only the first atomic reservation while an exact concurrent replay stays in progress", async () => { + const { env, namespace } = fixture(); + + const first = await reserveContinuationDispatch(env, identity, digest); + const replay = await reserveContinuationDispatch(env, identity, digest); + + expect(first).toMatchObject({ kind: "reserved", identity, digest }); + expect(first).toHaveProperty("reservationId"); + expect(replay).toEqual({ kind: "in_progress", identity, digest }); + expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(2); + }); + + it.each(["accepted", "denied", "indeterminate"] as const)( + "persists an exact %s outcome and replays its receipt without a new dispatch reservation", + async (outcome) => { + const { env, namespace } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest), + ); + const receipt = { + outcome, + receipt_id: `receipt-${outcome}`, + request_digest: digest, + } as const; + + await commitContinuationOutcome(env, reservation, receipt); + const replay = await reserveContinuationDispatch(env, identity, digest); + + expect(replay).toEqual({ kind: "replay", identity, digest, outcome, receipt }); + expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(3); + }, + ); + + it("rejects the same logical identity with a different request digest without changing retained state", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const retained = structuredClone([...storage.records.values()][0]); + + await expect(reserveContinuationDispatch(env, identity, "c".repeat(64))) + .rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + expect([...storage.records.values()][0]).toEqual(retained); + }); + + it("requires the original reservation capability before committing a terminal outcome", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest), + ); + + await expect(commitContinuationOutcome(env, { + ...reservation, + reservationId: crypto.randomUUID(), + }, { + outcome: "accepted", + receipt_id: "wrong-capability", + })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + kind: "in_progress", + identity, + digest, + }); + }); + + it.each([ + null, + "corrupt", + {}, + { version: "wrong", identity, request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity: "A".repeat(64), request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity, request_digest: digest, status: "accepted", reservation_id: crypto.randomUUID() }, + { version: "noema.continuation-dispatch-state.v1", identity, request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID(), receipt: {} }, + ])("fails closed without replacing malformed retained state %#", async (corrupt) => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + storage.replaceOnlyRecord(corrupt); + + await expect(reserveContinuationDispatch(env, identity, digest)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + expect([...storage.records.values()][0]).toEqual(corrupt); + }); + + it("rejects an oversized receipt while preserving the in-progress reservation", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest), + ); + + await expect(commitContinuationOutcome(env, reservation, { + outcome: "accepted", + evidence: "x".repeat(16_384), + })).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + kind: "in_progress", + identity, + digest, + }); + }); + + it("retains nested JSON receipt evidence without changing its semantic structure", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest), + ); + const receipt = { + outcome: "accepted", + evidence: [{ claim: "verified", samples: [1, true, null, "exact"] }], + } as const; + + await commitContinuationOutcome(env, reservation, receipt); + + await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + kind: "replay", + identity, + digest, + outcome: "accepted", + receipt, + }); + }); + + it("fails closed when receipt property access is not deterministic JSON", async () => { + const receipt = Object.defineProperty({ outcome: "accepted" }, "evidence", { + enumerable: true, + get: () => { + throw new Error("getter failed"); + }, + }); + + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([ + { outcome: "accepted", evidence: undefined }, + { outcome: "accepted", evidence: new Date(0) }, + { outcome: "accepted", evidence: Number.NaN }, + (() => { + const receipt: Record = { outcome: "accepted" }; + receipt.evidence = receipt; + return receipt; + })(), + ])("rejects non-JSON receipt values %#", async (receipt) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt as { readonly outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("fails closed when receipt serialization changes after validation", async () => { + let reads = 0; + const receipt = Object.defineProperty({ outcome: "accepted" }, "evidence", { + enumerable: true, + get: () => { + reads += 1; + if (reads === 1) return "initial"; + throw new Error("serialization changed"); + }, + }); + + await expect(commitContinuationOutcome( + envWithTransport(async () => new Response()), + fixedReservation(), + receipt, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); +}); + +describe("continuation dispatch private command boundary", () => { + function object(): NoemaContinuationDispatchState { + return new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: new MemoryStorage() as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + } + + it.each([ + new Request(endpoint, { method: "GET" }), + new Request(`${endpoint}/extra`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }), + ])("rejects a wrong endpoint or method", async (request) => { + const response = await object().fetch(request); + expect(response.status).toBe(404); + }); + + it("rejects non-JSON, malformed, duplicate, open, and oversized commands before storage", async () => { + const requests = [ + new Request(endpoint, { method: "POST", body: "{}" }), + new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: "{" }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"operation":"reserve","operation":"commit","identity":"${identity}","digest":"${digest}"}`, + }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest, extra: true }), + }), + new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest, padding: "x".repeat(16_384) }), + }), + ]; + + const statuses: number[] = []; + for (const request of requests) statuses.push((await object().fetch(request)).status); + + expect(statuses).toEqual([415, 400, 400, 400, 413]); + }); + + it("rejects non-canonical command variants before they can reach storage", async () => { + const reservationId = fixedReservation().reservationId; + const requests = [ + { operation: "commit", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" }, extra: true }, + { operation: "commit", identity, digest, reservation_id: reservationId, receipt: { outcome: "pending" } }, + { operation: "unknown", identity, digest }, + { identity, digest }, + ].map((body) => new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + })); + + for (const request of requests) { + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + } + }); + + it("rejects decoded duplicate keys inside escaped nested array evidence", async () => { + const body = `{"operation":"commit","identity":"${identity}","digest":"${digest}",` + + `"reservation_id":"${fixedReservation().reservationId}",` + + '"receipt":{"outcome":"accepted","items":[{"key":1,"k\\u0065y":2}]}}'; + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + })); + + expect(response.status).toBe(400); + }); + + it("accepts insignificant whitespace while scanning decoded object keys", async () => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"operation" :"reserve","identity":"${identity}","digest":"${digest}"}`, + })); + + expect(response.status).toBe(201); + }); + + it.each(["8193", "01", "invalid"])( + "rejects invalid or over-limit declared request length %s", + async (contentLength) => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": contentLength, + }, + body: "{}", + })); + + expect(response.status).toBe(413); + }, + ); + + it("rejects an invalid declared length and an absent request body without cancellation", async () => { + const invalidLength = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json", "content-length": "invalid" }, + })); + const noBody = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + })); + + expect([invalidLength.status, noBody.status]).toEqual([413, 400]); + }); + + it("rejects a reserve command with a non-canonical digest", async () => { + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest: "invalid" }), + })); + + expect(response.status).toBe(400); + }); + + it("rejects a non-JSON request with no body", async () => { + const response = await object().fetch(new Request(endpoint, { method: "POST" })); + + expect(response.status).toBe(415); + }); + + it("fails closed when the request body stream cannot be read", async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error("request stream failed")); + }, + }); + const response = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" })); + + expect(response.status).toBe(400); + }); + + it("fails closed when the request body reader cannot be acquired", async () => { + const request = new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: new ReadableStream({}), + duplex: "half", + } as RequestInit & { duplex: "half" }); + const lock = request.body!.getReader(); + + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + lock.releaseLock(); + }); + + it("maps storage transaction failure to a closed unavailable response", async () => { + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: { + transaction: async () => { + throw new Error("storage failed"); + }, + } as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const response = await state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest }), + })); + + expect(response.status).toBe(503); + }); + + it("distinguishes absent and corrupt retained state during commit", async () => { + const reservation = fixedReservation(); + const command = JSON.stringify({ + operation: "commit", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "accepted" }, + }); + const empty = await object().fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + + const storage = new MemoryStorage(); + storage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const invalid = await corrupt.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + + expect([empty.status, invalid.status]).toEqual([409, 500]); + }); + + it("rejects a terminal record whose retained state crosses the byte envelope", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const receipt = { outcome: "accepted", evidence: "x".repeat(7_900) } as const; + + await expect(commitContinuationOutcome(env, reservation, receipt)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("exports and declares the SQLite Durable Object binding", () => { + expect(runtimeEntrypoint.NoemaContinuationDispatchState) + .toBe(NoemaContinuationDispatchState); + const wrangler = readFileSync(new URL("../wrangler.toml", import.meta.url), "utf8"); + expect(wrangler).toContain('name = "NOEMA_CONTINUATION_DISPATCH_STATE"'); + expect(wrangler).toContain('class_name = "NoemaContinuationDispatchState"'); + expect(wrangler).toContain('[exports.NoemaContinuationDispatchState]\ntype = "durable-object"\nstorage = "sqlite"'); + }); +}); + +describe("continuation dispatch state client boundary", () => { + it.each([ + new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }), + new Response(null, { status: 200, headers: { "content-type": "text/plain" } }), + new Response("{}", { status: 200, headers: { "content-type": "application/json", "content-length": "8193" } }), + new Response(null, { status: 200, headers: { "content-type": "application/json", "content-length": "8193" } }), + new Response(null, { status: 200, headers: { "content-type": "application/json" } }), + new Response("{", { status: 200, headers: { "content-type": "application/json" } }), + new Response('{"ok":true,"ok":false}', { status: 200, headers: { "content-type": "application/json" } }), + ])("rejects an untrustworthy reservation response %#", async (response) => { + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("rejects a streamed reservation response that exceeds the byte envelope", async () => { + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("x".repeat(8_193))); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it("normalizes decision reader acquisition failure", async () => { + const response = new Response(new ReadableStream({}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const lock = response.body!.getReader(); + + await expect(reserveContinuationDispatch( + envWithTransport(async () => response), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + lock.releaseLock(); + }); + + it.each(["identity", "digest"] as const)("rejects an invalid %s before transport", async (field) => { + const env = envWithTransport(async () => { + throw new Error("transport must not run"); + }); + + await expect(reserveContinuationDispatch( + env, + field === "identity" ? "invalid" : identity, + field === "digest" ? "invalid" : digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("namespace failed"), "namespace failed"])( + "maps namespace lookup failure %# without leaking transport details", + async (failure) => { + const env = envWithTransport( + async () => new Response(), + () => { + throw failure; + }, + ); + + await expect(reserveContinuationDispatch(env, identity, digest)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps reservation transport failure %# to unavailable", + async (failure) => { + await expect(reserveContinuationDispatch( + envWithTransport(async () => { + throw failure; + }), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it("rejects invalid and mismatched successful reservation decisions", async () => { + const invalid = new Response(JSON.stringify({ ok: true, data: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + await expect(reserveContinuationDispatch( + envWithTransport(async () => invalid), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + + const mismatched = new Response(JSON.stringify({ + ok: true, + data: { kind: "in_progress", identity: "c".repeat(64), digest }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + await expect(reserveContinuationDispatch( + envWithTransport(async () => mismatched), + identity, + digest, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps commit transport failure %# to unavailable", + async (failure) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => { + throw failure; + }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([ + new Response(JSON.stringify({ ok: false }), { status: 200, headers: { "content-type": "application/json" } }), + new Response(JSON.stringify({ ok: true }), { status: 201, headers: { "content-type": "application/json" } }), + ])("rejects an invalid commit decision %#", async (response) => { + await expect(commitContinuationOutcome( + envWithTransport(async () => response), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index eb5937904..f3d2821c0 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -18,6 +18,10 @@ class_name = "NoemaWorkflowState" name = "NOEMA_EXTERNAL_EXTENSION_LIFECYCLE" class_name = "NoemaExternalExtensionLifecycle" +[[durable_objects.bindings]] +name = "NOEMA_CONTINUATION_DISPATCH_STATE" +class_name = "NoemaContinuationDispatchState" + [exports.NoemaRateLimiter] type = "durable-object" storage = "sqlite" @@ -34,6 +38,10 @@ storage = "sqlite" type = "durable-object" storage = "sqlite" +[exports.NoemaContinuationDispatchState] +type = "durable-object" +storage = "sqlite" + [vars] ALLOWED_ISSUER = "https://token.actions.githubusercontent.com" ALLOWED_AUDIENCE = "cwl-noema-review" From 9509527fd15b572bcf60d6392853865ca49c16c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 21:40:33 +0900 Subject: [PATCH 04/36] docs(dispatch): record exactly-once state completion --- .../plans/2026-09-30-continuation-dispatch-broker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 1660dc8f0..465d6e1fc 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -57,7 +57,7 @@ - [x] Implement one SQLite transaction per logical identity, bounded stored JSON, and fail-closed private command parsing. - [x] Export/bind `NOEMA_CONTINUATION_DISPATCH_STATE` in `runtime-entrypoint.ts` and `wrangler.toml`. - [x] Run state tests and `corepack npm run typecheck`; expect PASS. -- [ ] Commit `test+feat(dispatch): add exactly-once continuation state`. +- [x] Commit `test+feat(dispatch): add exactly-once continuation state`. ### Task 3: Live PR and fixed GitHub adapter From 700c7fa43868da56e7f2037576c2b08e921c9d0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 22:26:07 +0900 Subject: [PATCH 05/36] test+feat(dispatch): bind live PR and fixed central event --- ...2026-09-30-continuation-dispatch-broker.md | 10 +- src/continuation-dispatch/github-adapter.ts | 237 ++++++++++++ src/index.ts | 93 ++++- ...ntinuation-dispatch-github-adapter.test.ts | 348 ++++++++++++++++++ 4 files changed, 671 insertions(+), 17 deletions(-) create mode 100644 src/continuation-dispatch/github-adapter.ts create mode 100644 test/continuation-dispatch-github-adapter.test.ts diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 465d6e1fc..8d551a848 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -71,11 +71,11 @@ - Produces: `readAndVerifyLivePullRequest(...)` - Produces: `dispatchCentralContinuation(...)` -- [ ] Write RED tests for moved/closed/draft/fork PRs, wrong repository/base/ref, fixed target/path/event/payload, redirect denial, GitHub 403/404/422/5xx/network classification, and token absence from errors/logs. -- [ ] Run the focused adapter test; expect missing implementation failure. -- [ ] Extract only the existing App-JWT and bounded GitHub JSON helpers needed by both `/exchange` and the broker; do not duplicate them. -- [ ] Implement read-only source App lookup and a separate central-only dispatch App token used solely for `POST /repos/ContextualWisdomLab/.github/dispatches`. -- [ ] Run adapter tests, existing `test/worker.test.ts`, and typecheck; expect PASS. +- [x] Write RED tests for moved/closed/draft/fork PRs, wrong repository/base/ref, fixed target/path/event/payload, redirect denial, GitHub 403/404/422/5xx/network classification, and token absence from errors/logs. +- [x] Run the focused adapter test; expect missing implementation failure. +- [x] Extract only the existing App-JWT and bounded GitHub JSON helpers needed by both `/exchange` and the broker; do not duplicate them. +- [x] Implement read-only source App lookup and a separate central-only dispatch App token used solely for `POST /repos/ContextualWisdomLab/.github/dispatches`. +- [x] Run adapter tests, existing `test/worker.test.ts`, and typecheck; expect PASS. - [ ] Commit `test+feat(dispatch): bind live PR and fixed central event`. ### Task 4: Credential-free signed receipt diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts new file mode 100644 index 000000000..7e5892a7f --- /dev/null +++ b/src/continuation-dispatch/github-adapter.ts @@ -0,0 +1,237 @@ +import { + ApiError, + createGitHubInstallationToken, + githubApiRequest, + githubJson, + type GitHubAppEnv, + type JwtPayload, +} from "../index"; +import { + dispatchMapping, + type ContinuationDispatchRequest, +} from "./contract"; + +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github"; + +/** Runtime bindings needed to read a source PR and emit one central continuation. */ +export interface ContinuationGitHubAdapterEnv extends GitHubAppEnv { + CONTINUATION_DISPATCH_GITHUB_APP_ID: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: string; +} + +/** + * Stable credential-free adapter failure classes consumed by the public broker + * handler to distinguish authorization, live-state drift, and upstream outages. + */ +export type ContinuationGitHubFailureClassification = + | "identity_denied" + | "live_state_stale" + | "upstream_unavailable"; + +/** + * A credential-free GitHub adapter failure retaining only a stable classification + * and optional bounded upstream status for handler and persisted-outcome decisions. + */ +export class ContinuationGitHubAdapterError extends Error { + /** Creates a bounded error without retaining request or credential material. */ + constructor( + public readonly classification: ContinuationGitHubFailureClassification, + public readonly upstreamStatus?: number, + ) { + super("Continuation GitHub admission failed"); + this.name = "ContinuationGitHubAdapterError"; + } +} + +/** + * Exact live pull-request identity retained after source-state verification, + * excluding GitHub response metadata that is not part of continuation authority. + */ +export interface VerifiedLivePullRequest { + readonly repository: string; + readonly pullRequestNumber: number; + readonly headSha: string; + readonly baseSha: string; + readonly baseRef: string; +} + +/** + * Credential-free terminal result of the one allowed central repository-dispatch + * attempt, suitable for exactly-once persistence before a receipt is returned. + */ +export type CentralContinuationDispatchResult = { + readonly outcome: "accepted" | "denied" | "indeterminate"; + readonly upstreamStatus?: number; + readonly eventType: "noema-review" | "strix-scan"; +}; + +type GithubRepository = { readonly full_name?: unknown }; +type GithubPullRequestSide = { + readonly sha?: unknown; + readonly ref?: unknown; + readonly repo?: GithubRepository | null; +}; +type GithubPullRequest = { + readonly state?: unknown; + readonly draft?: unknown; + readonly head?: GithubPullRequestSide | null; + readonly base?: GithubPullRequestSide | null; +}; + +function sourceReadFailure(error: unknown): ContinuationGitHubAdapterError { + if (error instanceof ApiError) { + if (error.upstreamStatus === 403) { + return new ContinuationGitHubAdapterError("identity_denied", 403); + } + if (error.upstreamStatus === 404 || error.upstreamStatus === 422) { + return new ContinuationGitHubAdapterError("live_state_stale", error.upstreamStatus); + } + return new ContinuationGitHubAdapterError("upstream_unavailable", error.upstreamStatus); + } + return new ContinuationGitHubAdapterError("upstream_unavailable"); +} + +function centralCredentialFailure(error: unknown): ContinuationGitHubAdapterError { + return new ContinuationGitHubAdapterError( + "upstream_unavailable", + error instanceof ApiError ? error.upstreamStatus : undefined, + ); +} + +function centralAppEnv(env: ContinuationGitHubAdapterEnv): GitHubAppEnv { + return { + ...env, + GITHUB_APP_ID: env.CONTINUATION_DISPATCH_GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY_PEM: env.CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM, + GITHUB_APP_INSTALLATION_ID: env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID, + }; +} + +/** + * Reads the source PR through a read-only installation token and admits only the + * exact open, non-draft, non-fork state bound into the verified request. + * @param claims Cryptographically verified GitHub Actions OIDC claims. + * @param request Closed and canonical continuation dispatch request. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns The exact credential-free live PR identity admitted for dispatch. + * @throws {ContinuationGitHubAdapterError} When identity, live state, or GitHub access fails closed. + */ +export async function readAndVerifyLivePullRequest( + claims: JwtPayload, + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, +): Promise { + if (claims.repository !== request.source_repository) { + throw new ContinuationGitHubAdapterError("identity_denied"); + } + + let installationToken: string; + try { + const installation = await createGitHubInstallationToken( + request.source_repository, + env, + { pull_requests: "read" }, + ); + installationToken = installation.token; + } catch (error) { + throw centralCredentialFailure(error); + } + + let pullRequest: GithubPullRequest; + try { + pullRequest = await githubJson( + `/repos/${request.source_repository}/pulls/${request.pull_request_number}`, + { + method: "GET", + headers: { authorization: `Bearer ${installationToken}` }, + }, + env, + 200, + ) as GithubPullRequest; + } catch (error) { + throw sourceReadFailure(error); + } + + if ( + pullRequest.state !== "open" + || pullRequest.draft !== false + || pullRequest.head?.repo?.full_name !== request.source_repository + || pullRequest.base?.repo?.full_name !== request.source_repository + || pullRequest.head.sha !== request.expected_head_sha + || pullRequest.base.sha !== request.expected_base_sha + || pullRequest.base.ref !== request.expected_base_ref + ) { + throw new ContinuationGitHubAdapterError("live_state_stale"); + } + + return Object.freeze({ + repository: request.source_repository, + pullRequestNumber: request.pull_request_number, + headSha: request.expected_head_sha, + baseSha: request.expected_base_sha, + baseRef: request.expected_base_ref, + }); +} + +/** + * Mints a distinct central-only installation token and uses it solely for the + * fixed `.github` repository-dispatch endpoint and closed event payload. + * @param request Closed request whose released action maps to one fixed event. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns A terminal accepted, denied, or indeterminate dispatch result without credentials. + * @throws {ContinuationGitHubAdapterError} When central credential minting fails before dispatch. + */ +export async function dispatchCentralContinuation( + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, +): Promise { + const eventType = dispatchMapping(request.dispatch_action).eventType; + let installationToken: string; + try { + const installation = await createGitHubInstallationToken( + CENTRAL_REPOSITORY, + centralAppEnv(env), + { contents: "write" }, + ); + installationToken = installation.token; + } catch (error) { + throw centralCredentialFailure(error); + } + + let response: Response; + try { + response = await githubApiRequest( + "/repos/ContextualWisdomLab/.github/dispatches", + { + method: "POST", + headers: { + authorization: `Bearer ${installationToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + event_type: eventType, + client_payload: { + source_repository: request.source_repository, + pull_request_number: request.pull_request_number, + expected_head_sha: request.expected_head_sha, + expected_base_sha: request.expected_base_sha, + expected_base_ref: request.expected_base_ref, + transport_retry_attempt: request.transport_retry_attempt, + }, + }), + }, + env, + ); + } catch { + return Object.freeze({ outcome: "indeterminate", eventType }); + } + + if (response.status === 204) { + return Object.freeze({ outcome: "accepted", upstreamStatus: 204, eventType }); + } + if (response.status === 403 || response.status === 404 || response.status === 422) { + return Object.freeze({ outcome: "denied", upstreamStatus: response.status, eventType }); + } + return Object.freeze({ outcome: "indeterminate", upstreamStatus: response.status, eventType }); +} diff --git a/src/index.ts b/src/index.ts index 4e9ab35d4..f2943ce9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,17 @@ export interface Env extends OidcReplayProtectionEnv { NOEMA_INSTALLATION_CACHE_TTL_SECONDS?: string; } -type JwtPayload = { +/** Minimal GitHub App configuration shared by bounded repository API adapters. */ +export interface GitHubAppEnv { + GITHUB_API_BASE: string; + GITHUB_APP_ID: string; + GITHUB_APP_PRIVATE_KEY_PEM: string; + GITHUB_APP_INSTALLATION_ID?: string; + NOEMA_INSTALLATION_CACHE_TTL_SECONDS?: string; +} + +/** Verified GitHub Actions OIDC claims retained for downstream authorization checks. */ +export type JwtPayload = { iss?: string; aud?: string | string[]; repository?: string; @@ -107,7 +117,9 @@ const maxLocalRateLimitBuckets = 10_000; let oidcKeysCache: TimedCache | undefined; const installationIdCache = new Map>(); -class ApiError extends Error { +/** Bounded internal API failure carrying only stable response and upstream status metadata. */ +export class ApiError extends Error { + /** Creates one sanitized API failure without retaining request or credential values. */ constructor( public code: ErrorCode, public status: number, @@ -763,7 +775,14 @@ async function importGithubAppPrivateKey(pem: string): Promise { return crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]); } -async function createGitHubAppJwt(env: Env): Promise { +/** + * Creates a short-lived GitHub App assertion from one typed Worker credential binding. + * @param env GitHub App id and PKCS#8 private-key bindings. + * @returns A signed compact JWT accepted only by GitHub App endpoints. + */ +export async function createGitHubAppJwt( + env: Pick, +): Promise { const now = Math.floor(Date.now() / 1000); const header = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" }))); const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ iat: now - 60, exp: now + 540, iss: env.GITHUB_APP_ID }))); @@ -772,18 +791,30 @@ async function createGitHubAppJwt(env: Env): Promise { return `${header}.${payload}.${base64UrlEncode(signature)}`; } -type GitHubJsonRequestInit = RequestInit & { +/** + * GitHub request options requiring an explicit reviewed header record so shared + * fixed-origin egress never inherits ambient or caller-selected authorization. + */ +export type GitHubJsonRequestInit = RequestInit & { headers: Record; }; -async function githubJson( +/** + * Sends one GitHub REST request through the reviewed fixed-origin header and + * redirect policy shared by credential exchange and continuation dispatch. + * @param path Reviewed GitHub REST path, never a caller-selected absolute URL. + * @param init Explicit request method, body, and authorization headers. + * @param env Exact GitHub Cloud API base binding validated by the public edge. + * @returns The unconsumed GitHub response for operation-specific status handling. + */ +export async function githubApiRequest( path: string, init: GitHubJsonRequestInit, - env: Env, - expectedStatus: number, -): Promise> { - const response = await fetch(new URL(path, env.GITHUB_API_BASE), { + env: Pick, +): Promise { + return fetch(new URL(path, env.GITHUB_API_BASE), { ...init, + redirect: "error", headers: { accept: "application/vnd.github+json", "user-agent": "noema", @@ -791,6 +822,24 @@ async function githubJson( ...init.headers, }, }); +} + +/** + * Reads one successful bounded, duplicate-key-free GitHub JSON object response. + * @param path Reviewed GitHub REST path. + * @param init Explicit request options and authorization header. + * @param env Exact GitHub Cloud API base binding. + * @param expectedStatus Only successful status accepted by this operation. + * @returns The decoded top-level JSON object. + * @throws {ApiError} When status, media type, body bounds, UTF-8, or JSON shape fails closed. + */ +export async function githubJson( + path: string, + init: GitHubJsonRequestInit, + env: Pick, + expectedStatus: number, +): Promise> { + const response = await githubApiRequest(path, init, env); if (!response.ok) { if (response.status === 429) { throw new ApiError("ERR_RATE_LIMIT", 429, "GitHub API rate limit reached", undefined, response.status); @@ -830,7 +879,7 @@ async function githubJson( async function resolveInstallationId( appJwt: string, repository: string, - env: Env, + env: GitHubAppEnv, ): Promise { const configuredInstallationId = env.GITHUB_APP_INSTALLATION_ID; if (configuredInstallationId) { @@ -885,14 +934,26 @@ async function resolveInstallationId( }; } -async function createInstallationToken(repository: string, env: Env): Promise { +/** + * Mints one repository-limited GitHub App installation token with explicit permissions. + * @param repository Canonical owner/name repository selected by the calling trust boundary. + * @param env GitHub App and fixed API configuration. + * @param permissions Exact GitHub installation-token permissions for this operation. + * @returns The bounded token and canonical GitHub expiry timestamp. + * @throws {ApiError} When installation lookup, minting, or token validation fails closed. + */ +export async function createGitHubInstallationToken( + repository: string, + env: GitHubAppEnv, + permissions: Readonly>, +): Promise { const appJwt = await createGitHubAppJwt(env); let installationResolution = await resolveInstallationId(appJwt, repository, env); let installationId = installationResolution.value; const mintInstallationToken = (id: string) => githubJson(`/app/installations/${id}/access_tokens`, { method: "POST", headers: { authorization: `Bearer ${appJwt}` }, - body: JSON.stringify({ repositories: [repository.split("/", 2)[1]], permissions: { pull_requests: "write", contents: "read", checks: "read" } }), + body: JSON.stringify({ repositories: [repository.split("/", 2)[1]], permissions }), }, env, 201); let token: Record; @@ -956,6 +1017,14 @@ async function createInstallationToken(repository: string, env: Env): Promise { + return createGitHubInstallationToken(repository, env, { + pull_requests: "write", + contents: "read", + checks: "read", + }); +} + async function parseExchangeRequestBody(request: Request): Promise { if (request.body === null) return {}; const contentType = request.headers.get("content-type") || ""; diff --git a/test/continuation-dispatch-github-adapter.test.ts b/test/continuation-dispatch-github-adapter.test.ts new file mode 100644 index 000000000..d35dd8e41 --- /dev/null +++ b/test/continuation-dispatch-github-adapter.test.ts @@ -0,0 +1,348 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + ContinuationGitHubAdapterError, + dispatchCentralContinuation, + readAndVerifyLivePullRequest, + type ContinuationGitHubAdapterEnv, +} from "../src/continuation-dispatch/github-adapter"; +import type { ContinuationDispatchRequest } from "../src/continuation-dispatch/contract"; +import type { JwtPayload } from "../src/index"; + +const SOURCE_REPOSITORY = "ContextualWisdomLab/noema"; +const CENTRAL_REPOSITORY = "ContextualWisdomLab/.github"; +const HEAD_SHA = "a".repeat(40); +const BASE_SHA = "b".repeat(40); +const BASE_REF = "main"; +const SOURCE_TOKEN = "source-installation-token-secret"; +const CENTRAL_TOKEN = "central-installation-token-secret"; + +let appPrivateKeyPem: string; + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +beforeAll(async () => { + const appKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function request(overrides: Partial = {}): ContinuationDispatchRequest { + return { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: CENTRAL_REPOSITORY, + source_repository: SOURCE_REPOSITORY, + pull_request_number: 42, + expected_head_sha: HEAD_SHA, + expected_base_sha: BASE_SHA, + expected_base_ref: BASE_REF, + transport_retry_attempt: 1, + ...overrides, + }; +} + +function claims(overrides: Partial = {}): JwtPayload { + return { + repository: SOURCE_REPOSITORY, + repository_owner: "ContextualWisdomLab", + ...overrides, + }; +} + +function env(): ContinuationGitHubAdapterEnv { + return { + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "101", + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + GITHUB_APP_INSTALLATION_ID: "1001", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "202", + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem, + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "2002", + }; +} + +function json(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function installationToken(token: string): Response { + return json({ token, expires_at: new Date(Date.now() + 30 * 60_000).toISOString() }, 201); +} + +function livePullRequest(overrides: Record = {}): Record { + return { + state: "open", + draft: false, + head: { + sha: HEAD_SHA, + ref: "repair/noema-pr-736-task3", + repo: { full_name: SOURCE_REPOSITORY }, + }, + base: { + sha: BASE_SHA, + ref: BASE_REF, + repo: { full_name: SOURCE_REPOSITORY }, + }, + ...overrides, + }; +} + +function mockSourceRead(pullRequest: Response | (() => Response)): ReturnType { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + expect(init?.redirect).toBe("error"); + if (url === `https://api.github.com/repos/${SOURCE_REPOSITORY}/installation`) { + return json({ id: 1001 }); + } + if (url === "https://api.github.com/app/installations/1001/access_tokens") { + expect(JSON.parse(String(init?.body))).toEqual({ + repositories: ["noema"], + permissions: { pull_requests: "read" }, + }); + return installationToken(SOURCE_TOKEN); + } + if (url === `https://api.github.com/repos/${SOURCE_REPOSITORY}/pulls/42`) { + expect(init?.method).toBe("GET"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${SOURCE_TOKEN}`); + return typeof pullRequest === "function" ? pullRequest() : pullRequest; + } + return new Response("unexpected request", { status: 500 }); + }); +} + +describe("live pull request admission", () => { + it("classifies a missing source App installation as unavailable, not PR stale", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 404 })); + + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification: "upstream_unavailable", + upstreamStatus: 404, + }); + }); + + it("returns the exact verified live PR identity", async () => { + mockSourceRead(json(livePullRequest())); + + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).resolves.toEqual({ + repository: SOURCE_REPOSITORY, + pullRequestNumber: 42, + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseRef: BASE_REF, + }); + }); + + it("rejects an OIDC repository mismatch before GitHub credential use", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await expect(readAndVerifyLivePullRequest( + claims({ repository: "ContextualWisdomLab/other" }), + request(), + env(), + )).rejects.toMatchObject({ classification: "identity_denied" }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["closed", { state: "closed" }], + ["draft", { draft: true }], + ["moved head", { head: { sha: "c".repeat(40), ref: "other", repo: { full_name: SOURCE_REPOSITORY } } }], + ["fork", { head: { sha: HEAD_SHA, ref: "branch", repo: { full_name: "ContextualWisdomLab/fork" } } }], + ["wrong base repository", { base: { sha: BASE_SHA, ref: BASE_REF, repo: { full_name: "ContextualWisdomLab/other" } } }], + ["wrong base sha", { base: { sha: "d".repeat(40), ref: BASE_REF, repo: { full_name: SOURCE_REPOSITORY } } }], + ["wrong base ref", { base: { sha: BASE_SHA, ref: "release", repo: { full_name: SOURCE_REPOSITORY } } }], + ])("rejects a %s PR as stale", async (_label, override) => { + mockSourceRead(json(livePullRequest(override))); + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification: "live_state_stale", + }); + }); + + it.each([ + [403, "identity_denied"], + [404, "live_state_stale"], + [422, "live_state_stale"], + [500, "upstream_unavailable"], + ] as const)("classifies GitHub PR status %i as %s", async (status, classification) => { + mockSourceRead(new Response(null, { status })); + await expect(readAndVerifyLivePullRequest(claims(), request(), env())).rejects.toMatchObject({ + classification, + upstreamStatus: status, + }); + }); + + it("classifies a GitHub PR network failure without leaking credentials", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + mockSourceRead(() => { + throw new Error(`network failed near ${SOURCE_TOKEN}`); + }); + + let failure: unknown; + try { + await readAndVerifyLivePullRequest(claims(), request(), env()); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ContinuationGitHubAdapterError); + expect(failure).toMatchObject({ classification: "upstream_unavailable" }); + expect(String(failure)).not.toContain(SOURCE_TOKEN); + expect(logSpy.mock.calls.flat().join("\n")).not.toContain(SOURCE_TOKEN); + }); +}); + +function mockCentralDispatch(dispatchResponse: Response | (() => Response)): ReturnType { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + expect(init?.redirect).toBe("error"); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + expect(JSON.parse(String(init?.body))).toEqual({ + repositories: [".github"], + permissions: { contents: "write" }, + }); + return installationToken(CENTRAL_TOKEN); + } + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/dispatches`) { + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${CENTRAL_TOKEN}`); + expect(JSON.parse(String(init?.body))).toEqual({ + event_type: "noema-review", + client_payload: { + source_repository: SOURCE_REPOSITORY, + pull_request_number: 42, + expected_head_sha: HEAD_SHA, + expected_base_sha: BASE_SHA, + expected_base_ref: BASE_REF, + transport_retry_attempt: 1, + }, + }); + return typeof dispatchResponse === "function" ? dispatchResponse() : dispatchResponse; + } + return new Response("unexpected request", { status: 500 }); + }); +} + +describe("fixed central dispatch", () => { + it("classifies a missing central App installation as unavailable, never source-PR stale", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + expect(init?.redirect).toBe("error"); + const url = String(input); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + return new Response(null, { status: 404 }); + } + return new Response("unexpected request", { status: 500 }); + }); + + await expect(dispatchCentralContinuation(request(), env())).rejects.toMatchObject({ + classification: "upstream_unavailable", + upstreamStatus: 404, + }); + }); + + it("sanitizes a central App installation-token network failure", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/installation`) { + return json({ id: 2002 }); + } + if (url === "https://api.github.com/app/installations/2002/access_tokens") { + throw new Error(`network failed near ${CENTRAL_TOKEN}`); + } + return new Response("unexpected request", { status: 500 }); + }); + + let failure: unknown; + try { + await dispatchCentralContinuation(request(), env()); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ classification: "upstream_unavailable" }); + expect(String(failure)).not.toContain(CENTRAL_TOKEN); + }); + + it("uses only the fixed repository, path, event, payload, and central App", async () => { + const fetchSpy = mockCentralDispatch(new Response(null, { status: 204 })); + + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it("maps the released Strix action without caller-selected dispatch authority", async () => { + mockCentralDispatch(new Response(null, { status: 204 })).mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/installation")) return json({ id: 2002 }); + if (url.endsWith("/access_tokens")) return installationToken(CENTRAL_TOKEN); + expect(JSON.parse(String(init?.body))).toMatchObject({ event_type: "strix-scan" }); + return new Response(null, { status: 204 }); + }); + + await expect(dispatchCentralContinuation( + request({ dispatch_action: "strix_scan_continuation" }), + env(), + )).resolves.toMatchObject({ outcome: "accepted", eventType: "strix-scan" }); + }); + + it.each([403, 404, 422] as const)("persists GitHub dispatch status %i as denied", async (status) => { + mockCentralDispatch(new Response(null, { status })); + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "denied", + upstreamStatus: status, + eventType: "noema-review", + }); + }); + + it("persists GitHub dispatch 5xx as indeterminate", async () => { + mockCentralDispatch(new Response(null, { status: 503 })); + await expect(dispatchCentralContinuation(request(), env())).resolves.toEqual({ + outcome: "indeterminate", + upstreamStatus: 503, + eventType: "noema-review", + }); + }); + + it("persists redirect denial or another dispatch network failure as indeterminate without leaking its token", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + mockCentralDispatch(() => { + throw new TypeError(`redirect blocked for ${CENTRAL_TOKEN}`); + }); + + const result = await dispatchCentralContinuation(request(), env()); + expect(result).toEqual({ + outcome: "indeterminate", + eventType: "noema-review", + }); + expect(JSON.stringify(result)).not.toContain(CENTRAL_TOKEN); + expect(errorSpy.mock.calls.flat().join("\n")).not.toContain(CENTRAL_TOKEN); + }); +}); From 35d5bfecd9642c580d228586ce8be7ca60ee7ac4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 22:26:46 +0900 Subject: [PATCH 06/36] docs(dispatch): record Task 3 completion --- .../plans/2026-09-30-continuation-dispatch-broker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 8d551a848..c68af7d05 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -76,7 +76,7 @@ - [x] Extract only the existing App-JWT and bounded GitHub JSON helpers needed by both `/exchange` and the broker; do not duplicate them. - [x] Implement read-only source App lookup and a separate central-only dispatch App token used solely for `POST /repos/ContextualWisdomLab/.github/dispatches`. - [x] Run adapter tests, existing `test/worker.test.ts`, and typecheck; expect PASS. -- [ ] Commit `test+feat(dispatch): bind live PR and fixed central event`. +- [x] Commit `test+feat(dispatch): bind live PR and fixed central event`. ### Task 4: Credential-free signed receipt From f55b412d7e071893be6dfdc9cda6d4561e9314c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 23:45:13 +0900 Subject: [PATCH 07/36] test+feat(dispatch): issue signed continuation receipts --- src/continuation-dispatch/receipt.ts | 422 +++++++++++++++++++++ test/continuation-dispatch-receipt.test.ts | 373 ++++++++++++++++++ 2 files changed, 795 insertions(+) create mode 100644 src/continuation-dispatch/receipt.ts create mode 100644 test/continuation-dispatch-receipt.test.ts diff --git a/src/continuation-dispatch/receipt.ts b/src/continuation-dispatch/receipt.ts new file mode 100644 index 000000000..51f779af4 --- /dev/null +++ b/src/continuation-dispatch/receipt.ts @@ -0,0 +1,422 @@ +import type { JwtPayload } from "../index"; +import { + canonicalContinuationRequest, + continuationRequestDigest, + dispatchMapping, + type ContinuationDispatchRequest, +} from "./contract"; +import type { CentralContinuationDispatchResult } from "./github-adapter"; + +const RECEIPT_VERSION = "noema.continuation-dispatch-receipt.v1" as const; +const SIGNATURE_ALGORITHM = "Ed25519" as const; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const SHA_PATTERN = /^[0-9a-f]{40}$/u; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const KEY_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; +const TRACE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; +const WORKFLOW_REF_PATTERN = /^[^\u0000-\u0020\u007f]+$/u; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; +const FIELD_KEYS = [ + "dispatchResult", + "emittedPayloadDigest", + "idempotencyIdentity", + "oidcLifetime", + "receiptId", + "request", + "requestDigest", + "traceId", + "workflowRef", + "workflowSha", +] as const; +const RECEIPT_KEYS = [ + "algorithm", + "central_repository", + "contract_version", + "emitted_payload_digest", + "event_type", + "expires_at", + "idempotency_identity", + "issued_at", + "key_id", + "outcome", + "pull_request_number", + "receipt_id", + "receipt_version", + "request_digest", + "signature", + "source_base_ref", + "source_base_sha", + "source_head_sha", + "source_repository", + "trace_id", + "transport_retry_attempt", + "upstream_status", + "workflow_ref", + "workflow_sha", +] as const; + +/** + * Dedicated Worker secret bindings for continuation-receipt signing. + * The private key never crosses the signing boundary, while the key id names the public verification key. + */ +export interface ContinuationReceiptSigningEnv { + readonly CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: string; + readonly CONTINUATION_RECEIPT_SIGNING_KEY_ID: string; +} + +/** Exact admitted values from which one credential-free continuation receipt is built. */ +export interface ContinuationReceiptFields { + readonly request: ContinuationDispatchRequest; + readonly requestDigest: string; + readonly idempotencyIdentity: string; + readonly workflowRef: string; + readonly workflowSha: string; + readonly emittedPayloadDigest: string; + readonly dispatchResult: CentralContinuationDispatchResult; + readonly oidcLifetime: Pick; + readonly receiptId: string; + readonly traceId: string; +} + +/** + * Signed v1 evidence for one exact, terminal continuation dispatch attempt. + * The detached signature authenticates every credential-free field but grants no later dispatch authority. + */ +export interface SignedContinuationReceipt { + readonly algorithm: typeof SIGNATURE_ALGORITHM; + readonly central_repository: "ContextualWisdomLab/.github"; + readonly contract_version: "noema.continuation-dispatch.v1"; + readonly emitted_payload_digest: string; + readonly event_type: "noema-review" | "strix-scan"; + readonly expires_at: number; + readonly idempotency_identity: string; + readonly issued_at: number; + readonly key_id: string; + readonly outcome: "accepted" | "denied" | "indeterminate"; + readonly pull_request_number: number; + readonly receipt_id: string; + readonly receipt_version: typeof RECEIPT_VERSION; + readonly request_digest: string; + readonly source_base_ref: string; + readonly source_base_sha: string; + readonly source_head_sha: string; + readonly source_repository: string; + readonly trace_id: string; + readonly transport_retry_attempt: 1 | 2; + readonly upstream_status: number | null; + readonly workflow_ref: string; + readonly workflow_sha: string; + readonly signature: string; +} + +type UnsignedContinuationReceipt = Omit; + +/** Fail-closed receipt construction error that never retains signing material. */ +export class ContinuationReceiptError extends Error { + /** Creates one stable receipt error without embedding candidate field values. */ + constructor(message: string) { + super(message); + this.name = "ContinuationReceiptError"; + Object.setPrototypeOf(this, ContinuationReceiptError.prototype); + } +} + +function rejectReceipt(message: string): never { + throw new ContinuationReceiptError(message); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function containsUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (index + 1 >= value.length || nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return false; +} + +function canonicalJson(value: Readonly>): string { + return `{${Object.keys(value).sort().map( + (key) => `${JSON.stringify(key)}:${JSON.stringify(value[key])}`, + ).join(",")}}`; +} + +function pemDer(value: unknown, label: "PRIVATE KEY" | "PUBLIC KEY"): Uint8Array | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.replace(/\r\n/gu, "\n"); + const match = new RegExp( + `^-----BEGIN ${label}-----\\n([A-Za-z0-9+/=\\n]+)\\n-----END ${label}-----\\n?$`, + "u", + ).exec(normalized); + if (match === null) return undefined; + const encoded = match[1]!.replace(/\n/gu, ""); + try { + const binary = atob(encoded); + if (btoa(binary) !== encoded) return undefined; + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return undefined; + } +} + +function encodeBase64Url(value: ArrayBuffer): string { + const binary = String.fromCharCode(...new Uint8Array(value)); + return btoa(binary).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, ""); +} + +function decodeBase64Url(value: unknown): Uint8Array | undefined { + if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) return undefined; + try { + const padded = value.replace(/-/gu, "+").replace(/_/gu, "/") + + "=".repeat((4 - value.length % 4) % 4); + const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + return encodeBase64Url(bytes.buffer) === value ? bytes : undefined; + } catch { + return undefined; + } +} + +function canonicalOidcLifetime(value: unknown): { readonly iat: number; readonly exp: number } { + if ( + !isRecord(value) + || !hasExactKeys(value, ["exp", "iat"]) + || !Number.isSafeInteger(value.iat) + || !Number.isSafeInteger(value.exp) + || (value.iat as number) <= 0 + || (value.exp as number) <= (value.iat as number) + ) { + return rejectReceipt("continuation receipt OIDC lifetime must be one ordered positive integer interval"); + } + return { iat: value.iat as number, exp: value.exp as number }; +} + +function canonicalDispatchResult( + value: unknown, + expectedEventType: "noema-review" | "strix-scan", +): CentralContinuationDispatchResult { + if (!isRecord(value)) return rejectReceipt("continuation receipt dispatch result is not canonical"); + const hasStatus = Object.hasOwn(value, "upstreamStatus"); + if (!hasExactKeys(value, hasStatus ? ["eventType", "outcome", "upstreamStatus"] : ["eventType", "outcome"])) { + return rejectReceipt("continuation receipt dispatch result members are not canonical"); + } + if (value.eventType !== expectedEventType) { + return rejectReceipt("continuation receipt event type does not match the released action"); + } + if (value.outcome === "accepted" && value.upstreamStatus === 204) { + return { outcome: "accepted", upstreamStatus: 204, eventType: expectedEventType }; + } + if ( + value.outcome === "denied" + && (value.upstreamStatus === 403 || value.upstreamStatus === 404 || value.upstreamStatus === 422) + ) { + return { outcome: "denied", upstreamStatus: value.upstreamStatus, eventType: expectedEventType }; + } + if ( + value.outcome === "indeterminate" + && ( + value.upstreamStatus === undefined + || (Number.isSafeInteger(value.upstreamStatus) + && (value.upstreamStatus as number) >= 100 + && (value.upstreamStatus as number) <= 599) + ) + ) { + return value.upstreamStatus === undefined + ? { outcome: "indeterminate", eventType: expectedEventType } + : { outcome: "indeterminate", upstreamStatus: value.upstreamStatus as number, eventType: expectedEventType }; + } + return rejectReceipt("continuation receipt dispatch outcome is not canonical"); +} + +function canonicalUnsignedReceipt(value: Record): UnsignedContinuationReceipt | undefined { + if ( + value.algorithm !== SIGNATURE_ALGORITHM + || value.central_repository !== "ContextualWisdomLab/.github" + || value.contract_version !== "noema.continuation-dispatch.v1" + || value.receipt_version !== RECEIPT_VERSION + || typeof value.emitted_payload_digest !== "string" + || !SHA256_PATTERN.test(value.emitted_payload_digest) + || (value.event_type !== "noema-review" && value.event_type !== "strix-scan") + || !Number.isSafeInteger(value.issued_at) + || !Number.isSafeInteger(value.expires_at) + || (value.issued_at as number) <= 0 + || (value.expires_at as number) <= (value.issued_at as number) + || typeof value.idempotency_identity !== "string" + || !SHA256_PATTERN.test(value.idempotency_identity) + || typeof value.key_id !== "string" + || !KEY_ID_PATTERN.test(value.key_id) + || (value.outcome !== "accepted" && value.outcome !== "denied" && value.outcome !== "indeterminate") + || !Number.isSafeInteger(value.pull_request_number) + || (value.pull_request_number as number) <= 0 + || typeof value.receipt_id !== "string" + || !UUID_V4_PATTERN.test(value.receipt_id) + || typeof value.request_digest !== "string" + || !SHA256_PATTERN.test(value.request_digest) + || typeof value.source_base_ref !== "string" + || value.source_base_ref.length === 0 + || typeof value.source_base_sha !== "string" + || !SHA_PATTERN.test(value.source_base_sha) + || typeof value.source_head_sha !== "string" + || !SHA_PATTERN.test(value.source_head_sha) + || typeof value.source_repository !== "string" + || typeof value.trace_id !== "string" + || !TRACE_ID_PATTERN.test(value.trace_id) + || (value.transport_retry_attempt !== 1 && value.transport_retry_attempt !== 2) + || typeof value.workflow_ref !== "string" + || !WORKFLOW_REF_PATTERN.test(value.workflow_ref) + || containsUnpairedSurrogate(value.workflow_ref) + || typeof value.workflow_sha !== "string" + || !SHA_PATTERN.test(value.workflow_sha) + ) { + return undefined; + } + if ( + (value.outcome === "accepted" && value.upstream_status !== 204) + || (value.outcome === "denied" && value.upstream_status !== 403 + && value.upstream_status !== 404 && value.upstream_status !== 422) + || (value.outcome === "indeterminate" && value.upstream_status !== null + && (!Number.isSafeInteger(value.upstream_status) + || (value.upstream_status as number) < 100 || (value.upstream_status as number) > 599)) + ) { + return undefined; + } + return value as unknown as UnsignedContinuationReceipt; +} + +function unsignedReceipt(receipt: unknown): UnsignedContinuationReceipt | undefined { + if (!isRecord(receipt) || !hasExactKeys(receipt, RECEIPT_KEYS)) return undefined; + const { signature: _signature, ...unsigned } = receipt; + return canonicalUnsignedReceipt(unsigned); +} + +/** + * Creates one deterministic detached Ed25519 signature over the exact canonical v1 receipt. + * OIDC issued-at and expiry are copied without introducing a broker timeout, and no credential enters the result. + * @param fields Exact request, workflow, dispatch, digest, OIDC lifetime, and trace evidence. + * @param env Dedicated Ed25519 PKCS#8 signing key and canonical public key identifier. + * @returns A deeply immutable, credential-free signed continuation receipt. + * @throws {ContinuationReceiptError} When any input, key, or signing operation is not canonical. + */ +export async function signContinuationReceipt( + fields: ContinuationReceiptFields, + env: ContinuationReceiptSigningEnv, +): Promise { + if (!isRecord(fields) || !hasExactKeys(fields, FIELD_KEYS)) { + return rejectReceipt("continuation receipt fields must contain exactly the released members"); + } + if (!isRecord(env)) return rejectReceipt("continuation receipt signing environment is not configured"); + canonicalContinuationRequest(fields.request, { workflow_sha: fields.workflowSha }); + if (!SHA256_PATTERN.test(fields.requestDigest) || !SHA256_PATTERN.test(fields.idempotencyIdentity)) { + return rejectReceipt("continuation receipt request identity is not canonical"); + } + if ( + fields.requestDigest + !== await continuationRequestDigest(fields.request, { workflow_sha: fields.workflowSha }) + ) { + return rejectReceipt("continuation receipt request digest does not bind the canonical request"); + } + if (!SHA256_PATTERN.test(fields.emittedPayloadDigest)) { + return rejectReceipt("continuation receipt emitted payload digest is not canonical"); + } + if ( + typeof fields.workflowRef !== "string" + || !WORKFLOW_REF_PATTERN.test(fields.workflowRef) + || containsUnpairedSurrogate(fields.workflowRef) + ) { + return rejectReceipt("continuation receipt workflow ref is not canonical"); + } + if (!UUID_V4_PATTERN.test(fields.receiptId) || !TRACE_ID_PATTERN.test(fields.traceId)) { + return rejectReceipt("continuation receipt or trace identity is not canonical"); + } + if ( + typeof env.CONTINUATION_RECEIPT_SIGNING_KEY_ID !== "string" + || !KEY_ID_PATTERN.test(env.CONTINUATION_RECEIPT_SIGNING_KEY_ID) + ) { + return rejectReceipt("continuation receipt signing key id is not canonical"); + } + + const lifetime = canonicalOidcLifetime(fields.oidcLifetime); + const eventType = dispatchMapping(fields.request.dispatch_action).eventType; + const dispatchResult = canonicalDispatchResult(fields.dispatchResult, eventType); + const unsigned: UnsignedContinuationReceipt = Object.freeze({ + algorithm: SIGNATURE_ALGORITHM, + central_repository: fields.request.central_repository, + contract_version: fields.request.contract_version, + emitted_payload_digest: fields.emittedPayloadDigest, + event_type: dispatchResult.eventType, + expires_at: lifetime.exp, + idempotency_identity: fields.idempotencyIdentity, + issued_at: lifetime.iat, + key_id: env.CONTINUATION_RECEIPT_SIGNING_KEY_ID, + outcome: dispatchResult.outcome, + pull_request_number: fields.request.pull_request_number, + receipt_id: fields.receiptId, + receipt_version: RECEIPT_VERSION, + request_digest: fields.requestDigest, + source_base_ref: fields.request.expected_base_ref, + source_base_sha: fields.request.expected_base_sha, + source_head_sha: fields.request.expected_head_sha, + source_repository: fields.request.source_repository, + trace_id: fields.traceId, + transport_retry_attempt: fields.request.transport_retry_attempt, + upstream_status: dispatchResult.upstreamStatus ?? null, + workflow_ref: fields.workflowRef, + workflow_sha: fields.workflowSha, + }); + const der = pemDer(env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM, "PRIVATE KEY"); + if (der === undefined) return rejectReceipt("continuation receipt signing private key is not canonical PKCS8 PEM"); + + try { + const key = await crypto.subtle.importKey("pkcs8", der, SIGNATURE_ALGORITHM, false, ["sign"]); + const signature = await crypto.subtle.sign( + SIGNATURE_ALGORITHM, + key, + new TextEncoder().encode(canonicalJson(unsigned)), + ); + return Object.freeze({ ...unsigned, signature: encodeBase64Url(signature) }); + } catch { + return rejectReceipt("continuation receipt signing private key or operation is invalid"); + } +} + +/** + * Verifies one untrusted signed receipt with an Ed25519 SubjectPublicKeyInfo PEM key. + * Malformed fields, unknown members, non-canonical base64url, key errors, and mutations return false. + * @param receipt Candidate v1 receipt received from the broker or retained state. + * @param publicKey Exact Ed25519 SPKI PEM public key from the released consumer contract. + * @returns Whether the detached signature authenticates the complete canonical receipt. + */ +export async function verifyContinuationReceipt( + receipt: unknown, + publicKey: string, +): Promise { + if (!isRecord(receipt) || typeof receipt.signature !== "string") return false; + const unsigned = unsignedReceipt(receipt); + const signature = decodeBase64Url(receipt.signature); + const der = pemDer(publicKey, "PUBLIC KEY"); + if (unsigned === undefined || signature === undefined || der === undefined) return false; + try { + const key = await crypto.subtle.importKey("spki", der, SIGNATURE_ALGORITHM, false, ["verify"]); + return crypto.subtle.verify( + SIGNATURE_ALGORITHM, + key, + signature, + new TextEncoder().encode(canonicalJson(unsigned)), + ); + } catch { + return false; + } +} diff --git a/test/continuation-dispatch-receipt.test.ts b/test/continuation-dispatch-receipt.test.ts new file mode 100644 index 000000000..f5a66915e --- /dev/null +++ b/test/continuation-dispatch-receipt.test.ts @@ -0,0 +1,373 @@ +import { + createPrivateKey, + createPublicKey, + sign as nodeSign, + verify as nodeVerify, +} from "node:crypto"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + continuationRequestDigest, + type ContinuationDispatchRequest, +} from "../src/continuation-dispatch/contract"; +import { + signContinuationReceipt, + verifyContinuationReceipt, + type ContinuationReceiptFields, + type ContinuationReceiptSigningEnv, +} from "../src/continuation-dispatch/receipt"; + +const request: ContinuationDispatchRequest = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/noema", + pull_request_number: 736, + expected_head_sha: "a".repeat(40), + expected_base_sha: "b".repeat(40), + expected_base_ref: "main", + transport_retry_attempt: 1, +}; + +const requestDigest = "444f21c4b7de67b163b30e7a45108340d32a4d7011000487cd177ff511a6bb62"; +const identity = "d".repeat(64); +const emittedPayloadDigest = "e".repeat(64); +const receiptId = "00000000-0000-4000-8000-000000000000"; +const traceId = "11111111-1111-4111-8111-111111111111"; +const keyId = "noema-continuation-receipt-2026-09"; +const issuedAt = 1_798_646_400; +const expiresAt = 1_798_647_000; + +let privateKeyPem: string; +let publicKeyPem: string; +let otherPublicKeyPem: string; + +function pem(label: "PRIVATE KEY" | "PUBLIC KEY", der: ArrayBuffer): string { + const base64 = Buffer.from(der).toString("base64"); + const body = base64.match(/.{1,64}/gu)?.join("\n") ?? base64; + return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----`; +} + +function decodeBase64Url(value: string): Buffer { + return Buffer.from(value, "base64url"); +} + +beforeAll(async () => { + expect(await continuationRequestDigest(request, { workflow_sha: "f".repeat(40) })) + .toBe(requestDigest); + const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + privateKeyPem = pem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", pair.privateKey)); + publicKeyPem = pem("PUBLIC KEY", await crypto.subtle.exportKey("spki", pair.publicKey)); + + const otherPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + otherPublicKeyPem = pem( + "PUBLIC KEY", + await crypto.subtle.exportKey("spki", otherPair.publicKey), + ); +}); + +function env(overrides: Partial = {}): ContinuationReceiptSigningEnv { + return { + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: privateKeyPem, + CONTINUATION_RECEIPT_SIGNING_KEY_ID: keyId, + ...overrides, + }; +} + +function fields(overrides: Partial = {}): ContinuationReceiptFields { + return { + request, + requestDigest, + idempotencyIdentity: identity, + workflowRef: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + workflowSha: "f".repeat(40), + emittedPayloadDigest, + dispatchResult: { + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }, + oidcLifetime: { iat: issuedAt, exp: expiresAt }, + receiptId, + traceId, + ...overrides, + }; +} + +describe("continuation dispatch signed receipt", () => { + it("signs the exact RFC 8785 flat receipt bytes with deterministic Ed25519", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + const expectedCanonical = JSON.stringify({ + algorithm: "Ed25519", + central_repository: "ContextualWisdomLab/.github", + contract_version: "noema.continuation-dispatch.v1", + emitted_payload_digest: emittedPayloadDigest, + event_type: "noema-review", + expires_at: expiresAt, + idempotency_identity: identity, + issued_at: issuedAt, + key_id: keyId, + outcome: "accepted", + pull_request_number: 736, + receipt_id: receiptId, + receipt_version: "noema.continuation-dispatch-receipt.v1", + request_digest: requestDigest, + source_base_ref: "main", + source_base_sha: "b".repeat(40), + source_head_sha: "a".repeat(40), + source_repository: "ContextualWisdomLab/noema", + trace_id: traceId, + transport_retry_attempt: 1, + upstream_status: 204, + workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + workflow_sha: "f".repeat(40), + }); + const privateKey = createPrivateKey(privateKeyPem); + const publicKey = createPublicKey(publicKeyPem); + const expectedSignature = nodeSign(null, Buffer.from(expectedCanonical), privateKey); + + expect(decodeBase64Url(receipt.signature)).toEqual(expectedSignature); + expect(nodeVerify( + null, + Buffer.from(expectedCanonical), + publicKey, + decodeBase64Url(receipt.signature), + )).toBe(true); + expect((await signContinuationReceipt(fields(), env())).signature).toBe(receipt.signature); + }); + + it("verifies a signed receipt with the released public-key contract", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + await expect(verifyContinuationReceipt(receipt, otherPublicKeyPem)).resolves.toBe(false); + }); + + it("copies issued-at and expiry from the admitted OIDC lifetime without inventing a timeout", async () => { + const receipt = await signContinuationReceipt(fields({ + oidcLifetime: { iat: 1_798_650_001, exp: 1_798_650_777 }, + }), env()); + + expect(receipt.issued_at).toBe(1_798_650_001); + expect(receipt.expires_at).toBe(1_798_650_777); + }); + + it("preserves a valid supplementary-plane workflow ref", async () => { + const workflowRef = "ContextualWisdomLab/.github/.github/workflows/continuation-🚀.yml@refs/heads/main"; + const receipt = await signContinuationReceipt(fields({ workflowRef }), env()); + + expect(receipt.workflow_ref).toBe(workflowRef); + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + }); + + it("rejects a request digest that does not bind the canonical request and workflow SHA", async () => { + await expect(signContinuationReceipt(fields({ requestDigest: "c".repeat(64) }), env())) + .rejects.toThrow(/request digest/i); + }); + + it.each([ + [{ outcome: "denied", upstreamStatus: 403, eventType: "noema-review" }, 403], + [{ outcome: "denied", upstreamStatus: 404, eventType: "noema-review" }, 404], + [{ outcome: "denied", upstreamStatus: 422, eventType: "noema-review" }, 422], + [{ outcome: "indeterminate", eventType: "noema-review" }, null], + [{ outcome: "indeterminate", upstreamStatus: 503, eventType: "noema-review" }, 503], + ] as const)("signs the terminal dispatch result %j without retry authority", async (dispatchResult, status) => { + const receipt = await signContinuationReceipt(fields({ dispatchResult }), env()); + + expect(receipt.outcome).toBe(dispatchResult.outcome); + expect(receipt.upstream_status).toBe(status); + await expect(verifyContinuationReceipt(receipt, publicKeyPem)).resolves.toBe(true); + }); + + it.each([ + [null, /result/i], + [{ outcome: "accepted", eventType: "noema-review" }, /outcome/i], + [{ outcome: "accepted", upstreamStatus: 204, eventType: "strix-scan" }, /event type/i], + [{ outcome: "accepted", upstreamStatus: 204, eventType: "noema-review", token: "secret" }, /members/i], + [{ outcome: "denied", upstreamStatus: 401, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 99, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 600, eventType: "noema-review" }, /outcome/i], + [{ outcome: "indeterminate", upstreamStatus: 503.5, eventType: "noema-review" }, /outcome/i], + ])("rejects a dispatch result outside the fixed adapter contract: %j", async (dispatchResult, message) => { + await expect(signContinuationReceipt(fields({ + dispatchResult: dispatchResult as ContinuationReceiptFields["dispatchResult"], + }), env())).rejects.toThrow(message); + }); + + it.each([ + [{ iat: issuedAt, exp: issuedAt }, "ordered"], + [{ iat: issuedAt, exp: issuedAt - 1 }, "ordered"], + [{ iat: issuedAt + 0.5, exp: expiresAt }, "integer"], + [{ iat: issuedAt, exp: Number.POSITIVE_INFINITY }, "integer"], + ])("rejects an OIDC lifetime that is not a canonical positive interval: %j", async (oidcLifetime, _label) => { + await expect(signContinuationReceipt(fields({ oidcLifetime }), env())) + .rejects.toThrow(/OIDC lifetime/i); + }); + + it.each([ + ["outcome", { outcome: "denied" }], + ["request digest", { request_digest: "0".repeat(64) }], + ["expiry", { expires_at: expiresAt + 1 }], + ["event", { event_type: "strix-scan" }], + ["unknown member", { unexpected: "authority" }], + ])("fails verification after %s mutation", async (_label, mutation) => { + const receipt = await signContinuationReceipt(fields(), env()); + const mutated = { ...receipt, ...mutation }; + + await expect(verifyContinuationReceipt(mutated, publicKeyPem)).resolves.toBe(false); + }); + + it.each([ + ["algorithm", { algorithm: "ES256" }], + ["central repository", { central_repository: "ContextualWisdomLab/noema" }], + ["contract version", { contract_version: "noema.continuation-dispatch.v2" }], + ["receipt version", { receipt_version: "noema.continuation-dispatch-receipt.v2" }], + ["payload digest type", { emitted_payload_digest: 7 }], + ["payload digest shape", { emitted_payload_digest: "0" }], + ["event type", { event_type: "arbitrary-event" }], + ["issued-at type", { issued_at: "now" }], + ["expiry type", { expires_at: "later" }], + ["issued-at sign", { issued_at: 0 }], + ["expiry order", { expires_at: issuedAt }], + ["idempotency type", { idempotency_identity: 8 }], + ["idempotency shape", { idempotency_identity: "0" }], + ["key-id type", { key_id: 9 }], + ["key-id shape", { key_id: "bad key" }], + ["outcome", { outcome: "unknown" }], + ["PR type", { pull_request_number: "736" }], + ["PR sign", { pull_request_number: 0 }], + ["receipt-id type", { receipt_id: 1 }], + ["receipt-id shape", { receipt_id: "receipt" }], + ["request-digest type", { request_digest: 2 }], + ["request-digest shape", { request_digest: "digest" }], + ["base-ref type", { source_base_ref: 3 }], + ["base-ref empty", { source_base_ref: "" }], + ["base-SHA type", { source_base_sha: 4 }], + ["base-SHA shape", { source_base_sha: "b" }], + ["head-SHA type", { source_head_sha: 5 }], + ["head-SHA shape", { source_head_sha: "a" }], + ["source repository", { source_repository: 6 }], + ["trace type", { trace_id: 7 }], + ["trace shape", { trace_id: "bad trace" }], + ["retry attempt", { transport_retry_attempt: 3 }], + ["workflow-ref type", { workflow_ref: 8 }], + ["workflow-ref shape", { workflow_ref: "bad\nref" }], + ["workflow-SHA type", { workflow_sha: 9 }], + ["workflow-SHA shape", { workflow_sha: "f" }], + ])("fails closed when the signed %s schema is malformed", async (_label, mutation) => { + const receipt = await signContinuationReceipt(fields(), env()); + + await expect(verifyContinuationReceipt({ ...receipt, ...mutation }, publicKeyPem)) + .resolves.toBe(false); + }); + + it("rejects outcome/status combinations that are impossible for the fixed adapter", async () => { + const accepted = await signContinuationReceipt(fields(), env()); + const denied = await signContinuationReceipt(fields({ + dispatchResult: { outcome: "denied", upstreamStatus: 403, eventType: "noema-review" }, + }), env()); + const indeterminate = await signContinuationReceipt(fields({ + dispatchResult: { outcome: "indeterminate", eventType: "noema-review" }, + }), env()); + + for (const candidate of [ + { ...accepted, upstream_status: 200 }, + { ...denied, upstream_status: 401 }, + { ...indeterminate, upstream_status: "503" }, + { ...indeterminate, upstream_status: 99 }, + { ...indeterminate, upstream_status: 600 }, + ]) { + await expect(verifyContinuationReceipt(candidate, publicKeyPem)).resolves.toBe(false); + } + }); + + it("returns a credential-free receipt and rejects secret-shaped input members", async () => { + const signingEnv = env(); + const receipt = await signContinuationReceipt(fields(), signingEnv); + const serialized = JSON.stringify(receipt); + + expect(serialized).not.toContain(signingEnv.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM); + expect(serialized).not.toMatch(/token|bearer|assertion|private[_-]?key/iu); + + for (const secretMember of ["token", "bearer", "assertion", "private_key"] as const) { + await expect(signContinuationReceipt({ + ...fields(), + [secretMember]: "must-not-cross", + } as ContinuationReceiptFields, signingEnv)).rejects.toThrow(/members/i); + } + }); + + it("uses only the dedicated bindings when the Worker passes its composite environment", async () => { + const receipt = await signContinuationReceipt(fields(), { + ...env(), + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_PRIVATE_KEY_PEM: "must-not-cross", + } as ContinuationReceiptSigningEnv); + + expect(receipt.key_id).toBe(keyId); + expect(JSON.stringify(receipt)).not.toContain("must-not-cross"); + }); + + it.each([ + [() => null, () => env(), /fields/i], + [() => [], () => env(), /fields/i], + [() => fields(), () => null, /environment/i], + [() => ({ ...fields(), requestDigest: "0" }), () => env(), /request identity/i], + [() => ({ ...fields(), idempotencyIdentity: "0" }), () => env(), /request identity/i], + [() => ({ ...fields(), emittedPayloadDigest: "0" }), () => env(), /payload digest/i], + [() => ({ ...fields(), workflowRef: 8 }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "bad ref" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "valid\ud800" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), workflowRef: "valid\udc00" }), () => env(), /workflow ref/i], + [() => ({ ...fields(), receiptId: "receipt" }), () => env(), /receipt or trace/i], + [() => ({ ...fields(), traceId: "bad trace" }), () => env(), /receipt or trace/i], + ])("rejects non-canonical signing input", async (fieldsFactory, envFactory, message) => { + await expect(signContinuationReceipt( + fieldsFactory() as ContinuationReceiptFields, + envFactory() as ContinuationReceiptSigningEnv, + )).rejects.toThrow(message); + }); + + it.each(["", " key", "key ", "key\nline", "\u0000key", ["key"]])( + "rejects a non-canonical signing key id %j", + async (candidate) => { + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_KEY_ID: candidate, + }))).rejects.toThrow(/key id/i); + }, + ); + + it("rejects a non-PKCS8 signing key and fails closed for a non-SPKI verification key", async () => { + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: publicKeyPem, + }))).rejects.toThrow(/private key/i); + await expect(verifyContinuationReceipt( + await signContinuationReceipt(fields(), env()), + privateKeyPem, + )).resolves.toBe(false); + + await expect(signContinuationReceipt(fields(), env({ + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: publicKeyPem + .replaceAll("PUBLIC KEY", "PRIVATE KEY"), + }))).rejects.toThrow(/private key or operation/i); + await expect(verifyContinuationReceipt( + await signContinuationReceipt(fields(), env()), + privateKeyPem.replaceAll("PRIVATE KEY", "PUBLIC KEY"), + )).resolves.toBe(false); + }); + + it("fails closed for malformed PEM and detached-signature encodings", async () => { + const receipt = await signContinuationReceipt(fields(), env()); + const malformedPem = "-----BEGIN PUBLIC KEY-----\nAA\n-----END PUBLIC KEY-----"; + const invalidPaddedPem = "-----BEGIN PUBLIC KEY-----\nA===\n-----END PUBLIC KEY-----"; + + await expect(verifyContinuationReceipt(receipt, null as unknown as string)).resolves.toBe(false); + await expect(verifyContinuationReceipt(receipt, malformedPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt(receipt, invalidPaddedPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt(null, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: 1 }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "=" }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "A" }, publicKeyPem)).resolves.toBe(false); + await expect(verifyContinuationReceipt({ ...receipt, signature: "_x" }, publicKeyPem)).resolves.toBe(false); + }); +}); From 6f7b7dc8e4d67dd8eb4827423f9e14fbeae7fd3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 23:45:36 +0900 Subject: [PATCH 08/36] docs(dispatch): record Task 4 completion --- .../plans/2026-09-30-continuation-dispatch-broker.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index c68af7d05..4493afbdc 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -88,11 +88,11 @@ - Produces: `signContinuationReceipt(fields, env): Promise` - Produces: `verifyContinuationReceipt(receipt, publicKey): Promise` for tests and documented consumer conformance. -- [ ] Write RED tests for deterministic RFC 8785 bytes, Ed25519 verification, OIDC-derived expiry, field mutation failure, and absence of token/bearer/assertion/private-key material. -- [ ] Run the focused receipt test; expect missing implementation failure. -- [ ] Implement dedicated Ed25519 PKCS#8 import, key-id validation, detached base64url signature, and public verification helper. -- [ ] Run receipt tests and typecheck; expect PASS. -- [ ] Commit `test+feat(dispatch): issue signed continuation receipts`. +- [x] Write RED tests for deterministic RFC 8785 bytes, Ed25519 verification, OIDC-derived expiry, field mutation failure, and absence of token/bearer/assertion/private-key material. +- [x] Run the focused receipt test; expect missing implementation failure. +- [x] Implement dedicated Ed25519 PKCS#8 import, key-id validation, detached base64url signature, and public verification helper. +- [x] Run receipt tests and typecheck; expect PASS. +- [x] Commit `test+feat(dispatch): issue signed continuation receipts`. ### Task 5: Public route integration From d47d84019d193248c36cd6f6a89f8b0812b15d9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 01:16:43 +0900 Subject: [PATCH 09/36] fix(security): update patch-validator Node runtime --- .../workflows/acquisition-readiness-scan.yml | 6 +- .github/workflows/cd.yml | 6 +- .github/workflows/ci.yml | 6 +- .../workflows/hourly-commercial-readiness.yml | 6 +- .../workflows/maintainer-app-readiness.yml | 4 +- .github/workflows/patch-validator-image.yml | 8 +-- .../private-vulnerability-reporting-audit.yml | 4 +- .github/workflows/readiness-scan.yml | 6 +- .github/workflows/release-evidence.yml | 12 ++-- CHANGELOG.md | 5 +- Dockerfile.patch-validator | 10 +-- .../codegraph-sqlite-warning-remediation.md | 4 +- .../package-manager-reproducibility.md | 4 +- ...bedded-runtime-applicability-2026-09-09.md | 8 +++ docs/doctoring/patch-validator-image.md | 16 ++--- docs/patch-validator-image.md | 8 +-- package.json | 8 +-- ...lidator-embedded-runtime-applicability.mjs | 30 ++++----- ...h-validator-embedded-runtime-inventory.mjs | 2 +- ...atch-validator-static-runtime-evidence.mjs | 2 +- ...readiness-scan-toolchain-integrity.test.ts | 6 +- test/cd-toolchain-integrity.test.ts | 8 +-- test/ci-exact-head-contract.test.ts | 6 +- test/codegraph-experimental-warning.test.ts | 2 +- ...cial-readiness-toolchain-integrity.test.ts | 6 +- ...-app-readiness-toolchain-integrity.test.ts | 4 +- ...package-manager-doctoring-contract.test.ts | 6 +- ...ge-manager-predecessor-integration.test.ts | 6 +- test/package-manager-reproducibility.test.ts | 12 ++-- test/package-manager-review-contract.test.ts | 2 +- ...r-binary-grype-database-provenance.test.ts | 8 +-- ...tch-validator-cpe-match-provenance.test.ts | 8 +-- ...idator-embedded-runtime-assessment.test.ts | 8 +-- ...or-embedded-runtime-catalog-parity.test.ts | 2 +- ...or-embedded-runtime-digest-binding.test.ts | 2 +- ...lidator-embedded-runtime-inventory.test.ts | 8 +-- ...alidator-grype-provider-provenance.test.ts | 8 +-- ...grype-timestamp-calendar-integrity.test.ts | 8 +-- ...r-grype-timestamp-future-integrity.test.ts | 8 +-- ...h-validator-image-build-regression.test.ts | 21 +++++-- test/patch-validator-image-contract.test.ts | 10 +-- ...idator-image-documentation-current.test.ts | 2 +- ...lidator-openssl-quic-applicability.test.ts | 8 +-- ...ch-validator-package-note-identity.test.ts | 2 +- ...h-validator-reviewed-applicability.test.ts | 62 +++++++++---------- ...validator-scanner-artifact-version.test.ts | 4 +- ...r-static-binary-vulnerability-scan.test.ts | 2 +- ...-validator-static-runtime-evidence.test.ts | 22 +++---- ...or-static-runtime-identity-binding.test.ts | 8 +-- ...-validator-static-runtime-metadata.test.ts | 10 +-- test/patch-validator-static-runtime.test.ts | 10 +-- test/patch-validator-workflow.test.ts | 2 +- ...nerability-reporting-ambient-token.test.ts | 22 +++---- ...readiness-scan-toolchain-integrity.test.ts | 6 +- ...lease-evidence-toolchain-integrity.test.ts | 8 +-- 55 files changed, 247 insertions(+), 225 deletions(-) diff --git a/.github/workflows/acquisition-readiness-scan.yml b/.github/workflows/acquisition-readiness-scan.yml index 97a19b615..5341eb32f 100644 --- a/.github/workflows/acquisition-readiness-scan.yml +++ b/.github/workflows/acquisition-readiness-scan.yml @@ -26,15 +26,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: verify tracked acquisition source before evidence generation shell: bash diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6e8d67388..4aaf7f43e 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -64,15 +64,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify deployment package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: Verify immutable release and exact source identity id: identity diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb976be14..5d6f903d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,15 +44,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: verify live pull-request base before lockfile control if: github.event_name == 'pull_request' diff --git a/.github/workflows/hourly-commercial-readiness.yml b/.github/workflows/hourly-commercial-readiness.yml index 35113eac8..b28113551 100644 --- a/.github/workflows/hourly-commercial-readiness.yml +++ b/.github/workflows/hourly-commercial-readiness.yml @@ -135,15 +135,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: install lockfile dependencies run: npm ci --legacy-peer-deps=false --install-links=false diff --git a/.github/workflows/maintainer-app-readiness.yml b/.github/workflows/maintainer-app-readiness.yml index 532581764..857fd1fa8 100644 --- a/.github/workflows/maintainer-app-readiness.yml +++ b/.github/workflows/maintainer-app-readiness.yml @@ -66,13 +66,13 @@ jobs: - name: setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" - name: verify Node.js runtime shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" + test "$(node --version)" = "v24.21.0" - name: audit active main governance id: governance diff --git a/.github/workflows/patch-validator-image.yml b/.github/workflows/patch-validator-image.yml index fd37c4ada..350fb9882 100644 --- a/.github/workflows/patch-validator-image.yml +++ b/.github/workflows/patch-validator-image.yml @@ -138,15 +138,15 @@ jobs: - name: Set up exact dependency materialization toolchain uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Materialize exact patch-validator dependencies shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" deps_dir="$RUNNER_TEMP/patch-validator-deps" rm -rf "$deps_dir" @@ -220,7 +220,7 @@ jobs: shell: bash run: | set -euo pipefail - test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0" + test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0" - name: Inspect exact image metadata and exclude dynamic runtime payloads shell: bash diff --git a/.github/workflows/private-vulnerability-reporting-audit.yml b/.github/workflows/private-vulnerability-reporting-audit.yml index cb4cc0fac..17bd64458 100644 --- a/.github/workflows/private-vulnerability-reporting-audit.yml +++ b/.github/workflows/private-vulnerability-reporting-audit.yml @@ -33,7 +33,7 @@ jobs: - name: setup node without repository token uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" token: "" - name: verify exact checkout @@ -93,7 +93,7 @@ jobs: - name: setup node without repository token uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" token: "" - name: verify exact checkout diff --git a/.github/workflows/readiness-scan.yml b/.github/workflows/readiness-scan.yml index 122bee81b..6571eed5f 100644 --- a/.github/workflows/readiness-scan.yml +++ b/.github/workflows/readiness-scan.yml @@ -33,15 +33,15 @@ jobs: - name: setup node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: install run: npm ci --legacy-peer-deps=false --install-links=false diff --git a/.github/workflows/release-evidence.yml b/.github/workflows/release-evidence.yml index 8621da117..936fa311e 100644 --- a/.github/workflows/release-evidence.yml +++ b/.github/workflows/release-evidence.yml @@ -65,15 +65,15 @@ jobs: - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify package-manager toolchain shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" - name: Verify tag, commit, and package version binding id: identity @@ -154,15 +154,15 @@ jobs: - name: Set up Node.js for sterile materialization uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "24.19.0" + node-version: "24.21.0" cache: npm - name: Verify sterile materialization toolchain and source identity shell: bash run: | set -euo pipefail - test "$(node --version)" = "v24.19.0" - test "$(npm --version)" = "11.17.0" + test "$(node --version)" = "v24.21.0" + test "$(npm --version)" = "11.19.0" test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT_SHA" test "$(git rev-parse "${RELEASE_TAG}^{commit}")" = "$RELEASE_COMMIT_SHA" package_version="$(node --input-type=module -e "import packageJson from './package.json' with { type: 'json' }; process.stdout.write(packageJson.version)")" diff --git a/CHANGELOG.md b/CHANGELOG.md index f2aff108f..c2c46ff46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. - Protected #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`, integrated by GitHub-verified normal merge `4433c3009d4c6bc900bf5c8346f75b07185ff985`, hardens the Noema Policy / Approval procedural ledger without widening its authority. Complete retained approval-chain cryptographic verification and immutable next-event SHA-256 derivation occur before `DurableObjectStorage.transaction()`; append and exact replay then re-read current retained approval state inside the short transaction, require canonical root/event/dense-array structured-clone shape, compare the complete current retained structure with the exact preverified structure using field-by-field/`Object.is` equality, and perform only exact replay return or one durable write. Runtime-untrusted retained identity/digest values must already be canonical strings before regex/hash use, so coercible non-string lookalikes such as `BigInt` fail closed as Policy / Approval conflicts rather than becoming authority through JavaScript coercion. Hostile regressions cover transaction-active digest instrumentation, a legal interleaving winner, post-verification internal event drift, JSON-invisible unknown fields on append/replay, sparse or compensating arrays, coercible non-string digest/identity values, and event-array cardinality drift. Exact decision replay, approve/revoke state-machine rules, history binding, capacity, stale-writer rejection and `activationAuthorized:false` remain unchanged; this protected source/check/review/merge evidence is not an immutable release, deployed Durable Object recovery/contention result, production p95/heap evidence, graph publication, or activation authority. @@ -90,7 +91,7 @@ - strict production KPI provenance를 fail-closed로 강화한다. `collectedAt`은 canonical UTC 시각만 허용하고 미래·존재하지 않는 달력값을 거부하며, `records`는 coercion 없는 양의 safe-integer JSON number로 authenticated log record count와 일치해야 한다. production log는 `O_NOFOLLOW`로 연 verified regular-file descriptor의 identity·size·mode·mtime·ctime을 재검증하면서 동일 verified bytes로 snapshot을 생성해 symlink·pathname replacement·descriptor drift가 KPI authority를 제조하지 못하게 한다. - 인수 준비성 감사의 buyer/data-room 증거 경계를 실패-폐쇄로 강화한다. `updated_at`은 canonical ISO date 또는 timezone-bearing timestamp만 허용하고 존재하지 않는 달력값·비정규 형식·미래 시각을 거부하며, 명시된 evidence max-age 설정과 매출·고객·LOI·비율 지표는 coercion 없이 유효 도메인을 만족해야 한다. immutable release asset의 `bytes`도 실제 양의 safe-integer JSON number만 인정해 문자열·범위 밖 값이 매각 준비 증거를 제조하지 못하게 한다. - 루트 `README.md`를 구매자/운영자 표면으로 재작성한다. 제품 정의, 단독 실행, 호스트 호출(`/health`·`/ready`·`/exchange`, `contracts/orchestrator-gateway.json`), GitHub App 권한, 운영자가 설정하는 Worker 변수, naruon·곁(gyeot) 허브-리프 호출을 앞에 둔다. 시간당 상업 루프, 시간당 제품 개발 절차, 매각/판매 가능 프로그램, KPI provenance 수집 레시피, readiness-scan 내부는 README에서 빼고 `docs/internal/README.md`로 안내한다. CloudAgent/OpenCode 절차, PR 스택, exact-head CI, writer/agent 경계는 기존 `CONTRIBUTING.md`와 `docs/development/contributor-and-agent-procedure.md`에 남기며 삭제하지 않는다. `test/readme-customer-facing.test.ts`가 README에 봇 매뉴얼 잔여가 남지 않는지 고정한다. -- repository-owned patch-validator image는 Node.js 24.19.0을 fully static으로 빌드한 `scratch` runtime을 사용하고, `process.versions` 기반 embedded dependency inventory, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 결합한다. +- repository-owned patch-validator image는 현재 Node.js 24.21.0을 fully static으로 빌드한 `scratch` runtime을 사용하고, `process.versions` 기반 embedded dependency inventory, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 결합한다. - 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다. - 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더러울 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. @@ -152,4 +153,4 @@ - `/exchange` 401 응답에 `WWW-Authenticate: Bearer realm="noema"` challenge를 추가하고 인증 누락은 `invalid_request`, 잘못된 토큰은 `invalid_token`으로 구분. - `x-request-id`/`x-correlation-id` 및 client IP 계열 헤더를 길이/문자 기준으로 제한해 로그 오염과 rate-limit key 폭주를 방지. - `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증. -- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. \ No newline at end of file +- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. diff --git a/Dockerfile.patch-validator b/Dockerfile.patch-validator index caf1ab9e1..cac4681a1 100644 --- a/Dockerfile.patch-validator +++ b/Dockerfile.patch-validator @@ -2,8 +2,8 @@ FROM alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f AS node_builder -ARG NODE_VERSION=24.19.0 -ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f +ARG NODE_VERSION=24.21.0 +ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc ARG OPENSSL_VERSION=3.5.8 ARG OPENSSL_SOURCE_SHA256=a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2 ARG CARES_VERSION=1.34.8 @@ -108,7 +108,7 @@ RUN ./configure \ && make -j"$(getconf _NPROCESSORS_ONLN)" V= \ && make install \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ - && test "$(/opt/node/bin/npm --version)" = "11.17.0" \ + && test "$(/opt/node/bin/npm --version)" = "11.19.0" \ && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ && /opt/node/bin/node --input-type=module --eval='/\p{ID_Continue}/u.test("a")' \ @@ -116,14 +116,14 @@ RUN ./configure \ && ! readelf -d /opt/node/bin/node | grep -q '(NEEDED)' \ && strip /opt/node/bin/node \ && printf '%s\n' \ - '{"name":"node","version":"24.19.0","cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","license":"MIT","type":"binary","architecture":"x86_64","appCpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*","system":"nodejs","vendor":"nodejs","sourceRepo":"https://github.com/nodejs/node"}' \ + '{"name":"node","version":"24.21.0","cpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*","license":"MIT","type":"binary","architecture":"x86_64","appCpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*","system":"nodejs","vendor":"nodejs","sourceRepo":"https://github.com/nodejs/node"}' \ >/tmp/node-package-note.json \ && objcopy \ --add-section .note.package=/tmp/node-package-note.json \ --set-section-flags .note.package=noload,readonly \ /opt/node/bin/node \ && readelf -p .note.package /opt/node/bin/node \ - | grep -Fq 'cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*' \ + | grep -Fq 'cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*' \ && test "$(/opt/node/bin/node --version)" = "v${NODE_VERSION}" \ && CARES_VERSION="${CARES_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.ares !== process.env.CARES_VERSION) throw new Error(`unexpected c-ares ${process.versions.ares}`)' \ && OPENSSL_VERSION="${OPENSSL_VERSION}" /opt/node/bin/node --input-type=module --eval='if (process.versions.openssl !== process.env.OPENSSL_VERSION) throw new Error(`unexpected OpenSSL ${process.versions.openssl}`)' \ diff --git a/docs/doctoring/codegraph-sqlite-warning-remediation.md b/docs/doctoring/codegraph-sqlite-warning-remediation.md index a40ad61e3..c0f88914e 100644 --- a/docs/doctoring/codegraph-sqlite-warning-remediation.md +++ b/docs/doctoring/codegraph-sqlite-warning-remediation.md @@ -8,7 +8,7 @@ Noema's direct CodeGraph sandbox launchers must not pass `--disable-warning=Expe CodeGraph v1.4.1 is published as a self-contained bundle. Its tagged `scripts/build-bundle.sh` defaults the embedded runtime to Node `v24.16.0`, and the tagged release workflow invokes that build script without overriding the Node version for each platform. This means the lock-pinned Linux package Noema mounts into its quarantine boundary carries Node v24.16.0 rather than inheriting an ambient host Node. -Node changed `node:sqlite` from experimental Stability 1.1 to release-candidate Stability 1.2 in v24.15.0. The exact Node v24.14.0 `lib/sqlite.js` still imports `emitExperimentalWarning` and calls `emitExperimentalWarning('SQLite')`; the exact Node v24.16.0 file contains only the internal SQLite binding export and no warning emitter. Noema's canonical hosted development runtime is newer again, Node v24.19.0, and the executable regression loads `node:sqlite` in that exact hosted runtime and rejects either `ExperimentalWarning` or the former `SQLite is an experimental feature` diagnostic. +Node changed `node:sqlite` from experimental Stability 1.1 to release-candidate Stability 1.2 in v24.15.0. The exact Node v24.14.0 `lib/sqlite.js` still imports `emitExperimentalWarning` and calls `emitExperimentalWarning('SQLite')`; the exact Node v24.16.0 file contains only the internal SQLite binding export and no warning emitter. Noema's canonical hosted development runtime is newer again, Node v24.21.0, and the executable regression loads `node:sqlite` in that exact hosted runtime and rejects either `ExperimentalWarning` or the former `SQLite is an experimental feature` diagnostic. Therefore a CodeGraph version bump is neither necessary nor sufficient for this warning repair. The warning source disappeared in the embedded Node line before the currently pinned CodeGraph 1.4.1 bundle was published. Upstream CodeGraph main still carries compatibility warning-suppression logic for other launch paths and older Node lines; that does not justify keeping a blanket suppression in Noema's reviewed direct launcher when Noema's pinned bundle identity already excludes the emitting runtime. @@ -25,7 +25,7 @@ If a future pinned CodeGraph bundle changes its embedded Node line and an `Exper - Node v24.14.0 `lib/sqlite.js`: emits the `SQLite` experimental warning. - Node v24.16.0 `lib/sqlite.js`: no experimental-warning emitter remains. - Node v24.16.0 SQLite documentation records the v24.15.0 promotion to Stability 1.2 (release candidate). -- Noema `.github/workflows/ci.yml` pins the canonical hosted Node toolchain to v24.19.0. +- Noema `.github/workflows/ci.yml` pins the canonical hosted Node toolchain to v24.21.0. ## References diff --git a/docs/doctoring/package-manager-reproducibility.md b/docs/doctoring/package-manager-reproducibility.md index 15f54f175..fd52b8e98 100644 --- a/docs/doctoring/package-manager-reproducibility.md +++ b/docs/doctoring/package-manager-reproducibility.md @@ -2,7 +2,7 @@ ## 결정 -Noema의 검토·CI·lockfile 재생성 기준 개발 도구 체인은 **Node.js 24.19.0**과 **npm 11.17.0**으로 고정한다. `package.json`의 `packageManager`와 `devEngines`가 그 identity를 선언하고, CI는 설치 전에 실제 `node --version`과 `npm --version`을 exact-match로 다시 확인한다. 제품 자체의 지원 범위인 `engines.node >=22`와 개발·lockfile 생성 도구 체인은 별도 계약이다. +Noema의 검토·CI·lockfile 재생성 기준 개발 도구 체인은 **Node.js 24.21.0**과 **npm 11.19.0**으로 고정한다. `package.json`의 `packageManager`와 `devEngines`가 그 identity를 선언하고, CI는 설치 전에 실제 `node --version`과 `npm --version`을 exact-match로 다시 확인한다. 제품 자체의 지원 범위인 `engines.node >=22`와 개발·lockfile 생성 도구 체인은 별도 계약이다. CI의 JavaScript Action도 mutable major tag를 실행 근거로 사용하지 않는다. `actions/checkout` 6.0.2와 `actions/setup-node` 6.4.0의 검토된 full commit SHA를 workflow에 고정한다. 두 action 계열은 Node 24 runtime을 사용하는 현재 release line이다. 이 Action source pin 결정의 범위는 `.github/workflows/ci.yml`이다. @@ -72,7 +72,7 @@ Predecessor head의 green run, model comment, status-only signal, stale base, sy ## 재생성·검증 절차 -1. Node.js 24.19.0 / npm 11.17.0 exact identity를 확인한다. +1. Node.js 24.21.0 / npm 11.19.0 exact identity를 확인한다. 2. 현재 protected/base lockfile을 immutable 비교 기준으로 보존한다. 3. 목표 dependency만 변경하고 unrelated metadata churn을 별도 근거 없이 수용하지 않는다. 4. lockfile diff가 존재하면 schemaVersion 3 policy를 exact base, exact package before/after digest, exact `topLevelMetadataDigests`, 그리고 필요 시 `bulkChange` evidence에 결합한다. diff --git a/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md index 2fb01b3f4..7704d86b3 100644 --- a/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md +++ b/docs/doctoring/patch-validator-embedded-runtime-applicability-2026-09-09.md @@ -44,6 +44,12 @@ The primary V8 fix for Chromium issue 542403045 changes `JSCallReducer::ReduceAr The derived applicability receipt may remove `CVE-2026-85046` only for exact Node 24.19.0, exact V8 `13.6.233.17-node.51`, reviewed CPE `cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*`, normalized scanner artifact version `13.6.233.17`, Grype 0.116.1, and NVD-CPE provenance. A Node/V8/CPE/scanner/finding mismatch must fail closed. +## 2026-09-30 exact-runtime revalidation + +The exact-head image scan later exposed two blocking High-severity findings in Node 24.19.0's embedded `undici@7.29.0`: GHSA-rfgv-xxqx-mfg5 and GHSA-w293-vg96-wgc3. Node.js 24.21.0 updates the embedded Undici runtime to 7.29.1. Noema authenticates the official source archive with SHA-256 `a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc`; the signed `v24.21.0` tag dereferences to commit `955266bfdd854cd280dffd47548673914484e4c0`. The official Linux x64 distribution independently reports Node 24.21.0, npm 11.19.0, Undici 7.29.1, nghttp2 1.70.0, SQLite 3.53.4, V8 13.6.233.17-node.53, and zlib 1.3.2.1-motley-8002e91 through `process.versions` and bundled npm metadata. + +The prior applicability conclusions do not transfer by version-range assumption. Inspection of the authenticated 24.21.0 source again finds neither `JSCallReducer::ReduceArraySort` nor `MaglevGraphBuilder::TryReduceArrayPrototypeSort`; SQLite 3.53.4 remains above every reviewed affected range; the nghttp2 finding remains specific to the separate nghttpx proxy; and the zlib finding remains specific to the RubyGems wrapper. The executable contract therefore binds those new identities exactly. Any later Node or embedded-component change again invalidates the derived applicability receipt and must fail closed pending a new review. + ## Risk and follow-up Applicability review is deliberately exact-version rather than an open-ended semantic-version rule. A future Node, V8, SQLite, zlib, scanner, or CPE change loses the exception automatically and must be reviewed again. c-ares remains a source repair, not an exception. The image lane must rebuild from the new exact head and prove the patched c-ares version, raw scan retention, derived reviewed receipt, strict final verifier, isolation smoke, SBOM, and unchanged image/source binding before integration. @@ -67,3 +73,5 @@ Open Source Vulnerabilities. (2025; modified 2026). *BIT-sqlite-2025-6965*. http V8 Project. (2026, August 7). *[compiler] Don't inline Array.prototype.sort on mixed elements kinds* (commit `e0562d87ad9c17042b581582c99237d798572e67`). https://github.com/v8/v8/commit/e0562d87ad9c17042b581582c99237d798572e67 Node.js. (2026, August 3). *Node.js v24.19.0 Krypton (LTS) release* (signed tag; commit `cdc1b38d40cb567b7ad0b39c86addf830a0af0ae`). https://github.com/nodejs/node/releases/tag/v24.19.0 + +Node.js. (2026, September 8). *Node.js v24.21.0 Krypton (LTS) release*. https://nodejs.org/en/blog/release/v24.21.0 diff --git a/docs/doctoring/patch-validator-image.md b/docs/doctoring/patch-validator-image.md index 9cc23b811..91ac19247 100644 --- a/docs/doctoring/patch-validator-image.md +++ b/docs/doctoring/patch-validator-image.md @@ -18,7 +18,7 @@ PR #65 establishes a host-side boundary that authenticates an exact Git source, The current goal is narrower than production publication: prove that one exact pull-request head can build and execute a credential-free validator image under a least-privileged, fail-closed verification process without conflating image execution with trusted evidence, independent approval, protected merge, provenance, release, or deployment authority. -On 2026-08-07 the runtime design changed from a distribution runtime to a fully static Node.js 24.19.0 executable copied into a `scratch` final image. That change removed the previously observed Debian runtime CVEs and distribution attack surface, but it also removed package-manager metadata. Two related threats therefore had to be addressed: a package-oriented scanner could report a clean image while never classifying the self-compiled Node executable, and a Node-level classification could still hide bundled dependency versions inside the static binary. The current design uses independent binary presence evidence plus an exact-image `process.versions` inventory and per-component vulnerability evidence. +On 2026-08-07 the runtime design changed from a distribution runtime to a fully static Node.js 24.19.0 executable copied into a `scratch` final image. That change removed the previously observed Debian runtime CVEs and distribution attack surface, but it also removed package-manager metadata. Two related threats therefore had to be addressed: a package-oriented scanner could report a clean image while never classifying the self-compiled Node executable, and a Node-level classification could still hide bundled dependency versions inside the static binary. The current design uses independent binary presence evidence plus an exact-image `process.versions` inventory and per-component vulnerability evidence. On 2026-09-30, the same design advanced to Node.js 24.21.0 to remove the embedded undici 7.29.0 vulnerabilities without changing those evidence boundaries. ## Evidence classification @@ -29,7 +29,7 @@ The pull-request workflow and tests measure or enforce: - exact pull-request head checkout and live-head equality before and after verification; - a clean worktree and immutable Dockerfile frontend; - a digest-pinned Node builder; -- SHA-256 verification of the official Node.js 24.19.0 source archive; +- SHA-256 verification of the official Node.js 24.21.0 source archive; - a fully static Node build copied into a `scratch` final image; - rejection of dynamic interpreters, dynamic `NEEDED` dependencies, shared libraries, native addons, shells, and package managers in the final runtime; - numeric non-root runtime identity and a fixed exec-form entrypoint; @@ -41,7 +41,7 @@ The pull-request workflow and tests measure or enforce: - Trivy CycloneDX and vulnerability receipts for package and JavaScript dependency coverage; - checksum-manifest-pinned Syft 1.50.0 inventory of the same exact local image; - checksum-manifest-pinned Grype 0.116.1 vulnerability scanning of the same exact local image; -- fail-closed verification that Syft identifies exactly one Node 24.19.0 executable at `/nodejs/bin/node` with the expected Node.js CPE; +- fail-closed verification that Syft identifies exactly one Node 24.21.0 executable at `/nodejs/bin/node` with the expected Node.js CPE; - exact-image `process.versions` capture with exact component-set equality against the reviewed inventory; - `modules` and `napi` handled only as reviewed ABI/runtime metadata and never fabricated as vulnerability packages; - every other `process.versions` key classified as a bundled dependency with a reviewed PURL or CPE; @@ -61,7 +61,7 @@ The external sources support these general requirements: - SBOM and vulnerability evidence must identify the artifact actually consumed; an empty finding set is not useful if the relevant component was never inventoried. - Trivy's OS-package vulnerability scanner does not support third-party or self-compiled packages/binaries. Therefore Trivy alone cannot serve as positive evidence that the self-compiled Node runtime was assessed. - Syft's binary classifier catalog includes Node executable classification and Node.js CPE evidence, which supports an explicit runtime-presence assertion. -- Node.js 24.19.0 documents `process.versions` as an object of version strings for Node.js and its dependencies. This supports using the exact built runtime as the source of reviewed embedded dependency-version declarations, while not claiming it is a complete binary-composition proof. +- Node.js 24.21.0 documents `process.versions` as an object of version strings for Node.js and its dependencies. This supports using the exact built runtime as the source of reviewed embedded dependency-version declarations, while not claiming it is a complete binary-composition proof. - Grype supports container/SBOM inputs and package identities including PURLs and CPEs; matcher selection depends on package type and can fall back to CPE/NVD matching for otherwise modeled packages. Its output remains vulnerability-database- and identity-quality-dependent. - Build provenance, artifact signatures, independent approval, branch protection, release acceptance, and deployment authority are separate evidence/authority planes. @@ -82,7 +82,7 @@ Noema keeps Trivy for its existing package/language coverage and adds an indepen 3. authenticate each Linux/amd64 archive through the already authenticated manifest before extraction; 4. run Syft against `docker:` and retain native Syft JSON; 5. require Syft's source image ID to equal the trusted Docker image ID; -6. require exactly one `node` package at version 24.19.0 located at `/nodejs/bin/node` and carrying a Node.js 24.19.0 CPE; +6. require exactly one `node` package at version 24.21.0 located at `/nodejs/bin/node` and carrying a Node.js 24.21.0 CPE; 7. run Grype independently against the same exact local Docker image with `--config /dev/null`, preventing a repository-local `.grype.yaml` from silently introducing ignore policy; 8. require Grype's source image ID to equal the trusted Docker image ID; 9. reject any non-empty `ignoredMatches` collection; @@ -104,7 +104,7 @@ Node.js exposes the runtime's own dependency version declarations through `proce The exact-image embedded-runtime lane now: 1. executes the exact built `/nodejs/bin/node` and bounds the serialized `process.versions` record; -2. requires `process.versions.node` to equal the reviewed Node.js version `24.19.0`; +2. requires `process.versions.node` to equal the reviewed Node.js version `24.21.0`; 3. requires the reviewed inventory component keys to equal every non-`node` `process.versions` key exactly; 4. treats only `modules` and `napi` as runtime metadata, with exact reviewed meanings, and forbids package identities for those counters; 5. requires every other key to be a `bundled_dependency` with an explicit reviewed PURL or CPE; @@ -280,7 +280,7 @@ Before describing an exact head as verified, retain or link: - exact immutable workflow source; - local image metadata and content ID; - Trivy CycloneDX and vulnerability receipts; -- Syft native binary inventory proving Node 24.19.0 presence; +- Syft native binary inventory proving Node 24.21.0 presence; - Grype exact-image vulnerability receipt with no ignored/blocking findings; - bounded exact-image `process.versions` record; - reviewed embedded-runtime inventory with exact component-set equality; @@ -316,7 +316,7 @@ National Institute of Standards and Technology. (2017). *Application container s National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-218 -Node.js. (2026). *Process: `process.versions` (Node.js v24.19.0 documentation)*. https://nodejs.org/download/release/v24.19.0/docs/api/process.html#processversions +Node.js. (2026). *Process: `process.versions` (Node.js v24.21.0 documentation)*. https://nodejs.org/download/release/v24.21.0/docs/api/process.html#processversions Open Container Initiative. (2024). *OCI image format specification* (Version 1.1.1). https://specs.opencontainers.org/image-spec/?v=v1.1.1 diff --git a/docs/patch-validator-image.md b/docs/patch-validator-image.md index 8d5655ba4..dee96c42d 100644 --- a/docs/patch-validator-image.md +++ b/docs/patch-validator-image.md @@ -10,7 +10,7 @@ The current slice verifies a locally built Linux/amd64 image from the exact pull - checks and re-checks the live exact PR head with read-only GitHub authority; - builds with an immutable Dockerfile frontend and digest-pinned builders; -- compiles Node.js 24.19.0 from the official source tarball after fixed SHA-256 authentication; +- compiles Node.js 24.21.0 from the official source tarball after fixed SHA-256 authentication; - links Node fully statically and copies it into a `scratch` final image; - retains the exact Node.js source-distribution `LICENSE` inside the final image, bounds and verifies that notice from the exported runtime, and preserves it with the verification evidence; - excludes a shell, package manager, native addon, shared library, dynamic interpreter, and dynamic `NEEDED` dependency from the final runtime; @@ -75,7 +75,7 @@ The following authorities remain separate: source authentication, image build, u ## Image identity and runtime contents -`Dockerfile.patch-validator` pins its Dockerfile frontend and non-final builders by SHA-256 digest. The Node builder downloads the official Node.js 24.19.0 source tarball with a fixed SHA-256 and compiles it with the fully-static configuration. The final stage is `scratch`; it does not inherit a distribution runtime or package database. +`Dockerfile.patch-validator` pins its Dockerfile frontend and non-final builders by SHA-256 digest. The Node builder downloads the official Node.js 24.21.0 source tarball with a fixed SHA-256 and compiles it with the fully-static configuration. The final stage is `scratch`; it does not inherit a distribution runtime or package database. The static Node package note carries the reviewed Node.js application CPE and metadata. It deliberately does not claim a `pkg:generic` identity. Generic package URLs are not accepted as evidence that the configured vulnerability matcher can identify a component. @@ -107,13 +107,13 @@ Container stdout, stderr, and private result contents are not trusted as identit ## Static-runtime vulnerability boundary -Trivy covers the ordinary image/package and JavaScript dependency surface. Syft separately proves that the self-compiled executable is catalogued as Node 24.19.0 at `/nodejs/bin/node` with the reviewed Node.js CPE. Grype separately scans that exact local image. The trusted verifier binds scanner descriptors, source targets, image identity, match structure, ignored-match policy, and severity policy. +Trivy covers the ordinary image/package and JavaScript dependency surface. Syft separately proves that the self-compiled executable is catalogued as Node 24.21.0 at `/nodejs/bin/node` with the reviewed Node.js CPE. Grype separately scans that exact local image. The trusted verifier binds scanner descriptors, source targets, image identity, match structure, ignored-match policy, and severity policy. A zero match count is not independently interpreted as proof that every static dependency was evaluated. The embedded-runtime boundary below exists specifically to avoid that inference. ## Embedded-runtime component evidence -The workflow runs the exact built Node executable and records `process.versions`. The trusted verifier requires `node` to equal `24.19.0`. It accepts `modules` and `napi` only as reviewed runtime metadata with explicit reasons; every other key must be represented by the reviewed component-identity catalog. +The workflow runs the exact built Node executable and records `process.versions`. The trusted verifier requires `node` to equal `24.21.0`. It accepts `modules` and `napi` only as reviewed runtime metadata with explicit reasons; every other key must be represented by the reviewed component-identity catalog. For each reviewed embedded dependency, the catalog binds the exact `process.versions` key, inventory name, version, and either a supported npm PURL package identity or a reviewed application CPE vendor/product identity. Wildcards, placeholders, partial identities, arbitrary aliases, version substitution, package substitution, and unknown components fail closed. diff --git a/package.json b/package.json index 3f8b58d59..8347cb25f 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,19 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "npm@11.17.0", + "packageManager": "npm@11.19.0", "engines": { "node": ">=22" }, "devEngines": { "runtime": { "name": "node", - "version": "24.19.0", + "version": "24.21.0", "onFail": "error" }, "packageManager": { "name": "npm", - "version": "11.17.0", + "version": "11.19.0", "onFail": "error" } }, @@ -76,6 +76,6 @@ }, "overrides": { "postcss": "^8.5.18", - "undici": "7.29.0" + "undici": "7.29.1" } } diff --git a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs index 79686f16d..8c773f6b7 100644 --- a/scripts/lib/patch-validator-embedded-runtime-applicability.mjs +++ b/scripts/lib/patch-validator-embedded-runtime-applicability.mjs @@ -19,27 +19,27 @@ const OPENSSL_NON_APPLICABLE_REASON = const NGHTTP2_NON_APPLICABLE_REASON = "CVE affects the nghttpx proxy, not Node's embedded libnghttp2 runtime"; const LEGACY_V8_NON_APPLICABLE_REASON = - "Exact Node 24.19.0 V8 runtime is newer than the reviewed affected legacy V8 releases"; + "Exact Node 24.21.0 V8 runtime is newer than the reviewed affected legacy V8 releases"; const V8_ARRAY_SORT_NON_APPLICABLE_REASON = - "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers"; + "Exact Node 24.21.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers"; const SQLITE_NON_APPLICABLE_REASON = - "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges"; + "Exact SQLite 3.53.4 runtime is newer than the reviewed affected SQLite ranges"; const ZLIB_NON_APPLICABLE_REASON = "Advisory applies to the Ruby zlib gem GzipReader wrapper, not Node's embedded C zlib runtime"; -const EXPECTED_NODE_VERSION = "24.19.0"; -const EXPECTED_NGHTTP2_VERSION = "1.69.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; +const EXPECTED_NGHTTP2_VERSION = "1.70.0"; const EXPECTED_NGHTTP2_CPE = - "cpe:2.3:a:nghttp2:nghttp2:1.69.0:*:*:*:*:*:*:*"; -const EXPECTED_V8_VERSION = "13.6.233.17-node.51"; + "cpe:2.3:a:nghttp2:nghttp2:1.70.0:*:*:*:*:*:*:*"; +const EXPECTED_V8_VERSION = "13.6.233.17-node.53"; const EXPECTED_V8_SCANNER_VERSION = "13.6.233.17"; const EXPECTED_V8_CPE = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; -const EXPECTED_SQLITE_VERSION = "3.53.3"; +const EXPECTED_SQLITE_VERSION = "3.53.4"; const EXPECTED_SQLITE_CPE = - "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; -const EXPECTED_ZLIB_VERSION = "1.3.2.1-motley-3246f1b"; + "cpe:2.3:a:sqlite:sqlite:3.53.4:*:*:*:*:*:*:*"; +const EXPECTED_ZLIB_VERSION = "1.3.2.1-motley-8002e91"; const EXPECTED_ZLIB_CPE = - "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; + "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-8002e91:*:*:*:*:*:*:*"; function isRecord(value) { return Object.prototype.toString.call(value) === "[object Object]"; @@ -208,15 +208,15 @@ function scanMatchesExactComponent(rawComponentScan, component) { * - OpenSSL CVE-2026-14456 requires exact evidence that both QUIC transport * dependencies are disabled in this Node build. * - nghttp2 CVE-2026-58055 describes the nghttpx proxy request-forwarding - * behavior; the exact Node 24.19.0 runtime inventories libnghttp2 as a + * behavior; the exact Node 24.21.0 runtime inventories libnghttp2 as a * statically bundled dependency, not the nghttpx proxy executable. * - legacy V8 advisories are bounded to historical affected V8/Node releases - * that predate the exact Node 24.19.0 / V8 13.6.233.17-node.51 runtime. + * that predate the exact Node 24.21.0 / V8 13.6.233.17-node.53 runtime. * - CVE-2026-85046 is tied to V8's inlined Array.prototype.sort reducers. The - * exact signed Node 24.19.0 V8 branch contains neither affected reducer, so + * exact signed Node 24.21.0 V8 branch contains neither affected reducer, so * the generic V8 CPE range is not execution-path evidence for this runtime. * - the three SQLite findings have reviewed vulnerable ranges ending no later - * than 3.50.1, while this exact embedded runtime is SQLite 3.53.3. + * than 3.50.1, while this exact embedded runtime is SQLite 3.53.4. * - GHSA-g857-hhfv-j68w is a RubyGems zlib/GzipReader advisory, not an * upstream C zlib-library advisory; the exact Node component is the latter. * diff --git a/scripts/lib/patch-validator-embedded-runtime-inventory.mjs b/scripts/lib/patch-validator-embedded-runtime-inventory.mjs index 283ec0957..c61c2c4de 100644 --- a/scripts/lib/patch-validator-embedded-runtime-inventory.mjs +++ b/scripts/lib/patch-validator-embedded-runtime-inventory.mjs @@ -6,7 +6,7 @@ import { } from "./patch-validator-embedded-runtime-catalog.mjs"; const COMPONENT_KEY = /^[a-z0-9][a-z0-9_-]{0,63}$/; -const EXPECTED_NODE_VERSION = "24.19.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; const VALIDATOR_IMAGE_DIGEST = /^sha256:[0-9a-f]{64}$/; function isRecord(value) { diff --git a/scripts/lib/patch-validator-static-runtime-evidence.mjs b/scripts/lib/patch-validator-static-runtime-evidence.mjs index 513d07d72..a8b785779 100644 --- a/scripts/lib/patch-validator-static-runtime-evidence.mjs +++ b/scripts/lib/patch-validator-static-runtime-evidence.mjs @@ -11,7 +11,7 @@ const RFC3339_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-](\d{2}):(\d{2}))$/; const GRYPE_DATABASE_SCHEMA = /^v\d+\.\d+\.\d+$/; const PROVIDER_NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/; -const EXPECTED_NODE_VERSION = "24.19.0"; +const EXPECTED_NODE_VERSION = "24.21.0"; const EXPECTED_NODE_CPE = `cpe:2.3:a:nodejs:node.js:${EXPECTED_NODE_VERSION}:*:*:*:*:*:*:*`; const EMBEDDED_INVENTORY_SCHEMA = diff --git a/test/acquisition-readiness-scan-toolchain-integrity.test.ts b/test/acquisition-readiness-scan-toolchain-integrity.test.ts index 48c89b1e4..6f148cab5 100644 --- a/test/acquisition-readiness-scan-toolchain-integrity.test.ts +++ b/test/acquisition-readiness-scan-toolchain-integrity.test.ts @@ -24,9 +24,9 @@ describe("acquisition-readiness workflow supply-chain integrity", () => { }); it("uses exact Node/npm identities without installing dependency code", () => { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).not.toContain('node-version: "24"'); expect(workflow).not.toMatch(/\bnpm\s+(?:ci|install|i)\b/); expect(workflow).not.toContain(" - name: install"); diff --git a/test/cd-toolchain-integrity.test.ts b/test/cd-toolchain-integrity.test.ts index ebeffa704..6d7715ddd 100644 --- a/test/cd-toolchain-integrity.test.ts +++ b/test/cd-toolchain-integrity.test.ts @@ -8,11 +8,11 @@ describe("production deployment toolchain integrity", () => { expect(workflow).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); - const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.17.0"'); + const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.19.0"'); const installIndex = workflow.indexOf( "npm ci --legacy-peer-deps=false --install-links=false", ); diff --git a/test/ci-exact-head-contract.test.ts b/test/ci-exact-head-contract.test.ts index 82a00e724..9b486caf0 100644 --- a/test/ci-exact-head-contract.test.ts +++ b/test/ci-exact-head-contract.test.ts @@ -37,9 +37,9 @@ function expectExactHeadContract(workflow: string, firstExecutionStep: string): /** Assert that application CI uses the reviewed Node/npm identity and explicit tree-shaping flags. */ function expectPinnedApplicationToolchain(workflow: string): void { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).toContain("npm ci --legacy-peer-deps=false --install-links=false"); const exactHeadGate = workflow.indexOf("- name: verify exact checkout"); diff --git a/test/codegraph-experimental-warning.test.ts b/test/codegraph-experimental-warning.test.ts index d4b1156d7..b1776bb4a 100644 --- a/test/codegraph-experimental-warning.test.ts +++ b/test/codegraph-experimental-warning.test.ts @@ -12,7 +12,7 @@ const RUNNER_PATHS = [ describe("CodeGraph SQLite warning boundary", () => { it("proves the canonical hosted Node line loads node:sqlite without ExperimentalWarning", () => { - expect(PACKAGE.devEngines?.runtime?.version).toBe("24.19.0"); + expect(PACKAGE.devEngines?.runtime?.version).toBe("24.21.0"); const probe = spawnSync( process.execPath, diff --git a/test/hourly-commercial-readiness-toolchain-integrity.test.ts b/test/hourly-commercial-readiness-toolchain-integrity.test.ts index 7fcdfccce..c85467723 100644 --- a/test/hourly-commercial-readiness-toolchain-integrity.test.ts +++ b/test/hourly-commercial-readiness-toolchain-integrity.test.ts @@ -45,11 +45,11 @@ describe("commercial writer toolchain integrity", () => { expect(setup.block).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(setup.block).toContain('node-version: "24.19.0"'); + expect(setup.block).toContain('node-version: "24.21.0"'); expect(setup.block).not.toContain('node-version: "24"'); - expect(verify.block).toContain('test "$(node --version)" = "v24.19.0"'); - expect(verify.block).toContain('test "$(npm --version)" = "11.17.0"'); + expect(verify.block).toContain('test "$(node --version)" = "v24.21.0"'); + expect(verify.block).toContain('test "$(npm --version)" = "11.19.0"'); expect(install.block).toContain( "run: npm ci --legacy-peer-deps=false --install-links=false", diff --git a/test/maintainer-app-readiness-toolchain-integrity.test.ts b/test/maintainer-app-readiness-toolchain-integrity.test.ts index f3e2d81aa..3900a31b6 100644 --- a/test/maintainer-app-readiness-toolchain-integrity.test.ts +++ b/test/maintainer-app-readiness-toolchain-integrity.test.ts @@ -46,9 +46,9 @@ describe("maintainer App readiness runtime integrity", () => { expect(setup.block).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(setup.block).toContain('node-version: "24.19.0"'); + expect(setup.block).toContain('node-version: "24.21.0"'); expect(setup.block).not.toContain('node-version: "24"'); - expect(verify.block).toContain('test "$(node --version)" = "v24.19.0"'); + expect(verify.block).toContain('test "$(node --version)" = "v24.21.0"'); expect(setup.index).toBeLessThan(verify.index); expect(verify.index).toBeLessThan(governance.index); diff --git a/test/package-manager-doctoring-contract.test.ts b/test/package-manager-doctoring-contract.test.ts index bfa16c7d7..ad4c8edc9 100644 --- a/test/package-manager-doctoring-contract.test.ts +++ b/test/package-manager-doctoring-contract.test.ts @@ -20,8 +20,8 @@ describe("package-manager reproducibility doctoring", () => { const decision = markdownSection(document, "결정"); const installScripts = markdownSection(document, "Install-script 실행 권한"); - expect(decision).toContain("Node.js 24.19.0"); - expect(decision).toContain("npm 11.17.0"); + expect(decision).toContain("Node.js 24.21.0"); + expect(decision).toContain("npm 11.19.0"); expect(decision).toContain("`.github/workflows/ci.yml`"); expect(installScripts).toContain("`strict-allow-scripts=true`"); @@ -75,7 +75,7 @@ describe("package-manager reproducibility doctoring", () => { expect(changelog).toMatch(/^## Unreleased$/m); const unreleased = markdownSection(changelog, "Unreleased"); - expect(unreleased).toContain("Node.js 24.19.0/npm 11.17.0"); + expect(unreleased).toContain("Node.js 24.21.0/npm 11.19.0"); expect(unreleased).toContain("strict-allow-scripts=true"); expect(unreleased).toContain("schema v3"); }); diff --git a/test/package-manager-predecessor-integration.test.ts b/test/package-manager-predecessor-integration.test.ts index 7e5130e46..71ba94e79 100644 --- a/test/package-manager-predecessor-integration.test.ts +++ b/test/package-manager-predecessor-integration.test.ts @@ -22,15 +22,15 @@ describe("deterministic package-manager work integrated after the nanoid predece integrity: "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", }); - expect(packageJson.packageManager).toBe("npm@11.17.0"); + expect(packageJson.packageManager).toBe("npm@11.19.0"); expect(packageJson.devEngines?.runtime).toEqual({ name: "node", - version: "24.19.0", + version: "24.21.0", onFail: "error", }); expect(packageJson.devEngines?.packageManager).toEqual({ name: "npm", - version: "11.17.0", + version: "11.19.0", onFail: "error", }); }); diff --git a/test/package-manager-reproducibility.test.ts b/test/package-manager-reproducibility.test.ts index fd29ce6f1..6adf778f8 100644 --- a/test/package-manager-reproducibility.test.ts +++ b/test/package-manager-reproducibility.test.ts @@ -52,15 +52,15 @@ function runFixtureNpm( describe("package-manager reproducibility contract", () => { it("pins the reviewed Node and npm identities in repository metadata", () => { - expect(packageJson.packageManager).toBe("npm@11.17.0"); + expect(packageJson.packageManager).toBe("npm@11.19.0"); expect(packageJson.devEngines?.runtime).toEqual({ name: "node", - version: "24.19.0", + version: "24.21.0", onFail: "error", }); expect(packageJson.devEngines?.packageManager).toEqual({ name: "npm", - version: "11.17.0", + version: "11.19.0", onFail: "error", }); }); @@ -144,13 +144,13 @@ describe("package-manager reproducibility contract", () => { }); it("pins CI to the same Node distribution and verifies toolchain identity before install", () => { - expect(ciWorkflow).toContain('node-version: "24.19.0"'); + expect(ciWorkflow).toContain('node-version: "24.21.0"'); const toolchainGate = ciWorkflow.indexOf("name: verify package-manager toolchain"); const install = ciWorkflow.indexOf("name: install"); expect(toolchainGate).toBeGreaterThan(-1); expect(install).toBeGreaterThan(toolchainGate); - expect(ciWorkflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(ciWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(ciWorkflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(ciWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); }); it("checks out and verifies the exact pull-request head instead of GitHub's synthetic merge ref", () => { diff --git a/test/package-manager-review-contract.test.ts b/test/package-manager-review-contract.test.ts index 9782b0d78..ed6fe8a48 100644 --- a/test/package-manager-review-contract.test.ts +++ b/test/package-manager-review-contract.test.ts @@ -78,7 +78,7 @@ describe("package-manager review contracts", () => { ]); const unreleased = markdownSection(changelog, "## Unreleased"); - expect(unreleased).toContain("Node.js 24.19.0/npm 11.17.0"); + expect(unreleased).toContain("Node.js 24.21.0/npm 11.19.0"); expect(unreleased).toContain("strict-allow-scripts=true"); expect(unreleased).toContain("schema v3"); }); diff --git a/test/patch-validator-binary-grype-database-provenance.test.ts b/test/patch-validator-binary-grype-database-provenance.test.ts index c8d6a8796..b6c3483bb 100644 --- a/test/patch-validator-binary-grype-database-provenance.test.ts +++ b/test/patch-validator-binary-grype-database-provenance.test.ts @@ -116,9 +116,9 @@ describe("patch-validator binary Grype database provenance", () => { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], - cpes: ["cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"], + cpes: ["cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"], }, ], }); @@ -131,8 +131,8 @@ describe("patch-validator binary Grype database provenance", () => { const embeddedInventory = writeJson(root, "embedded-inventory.json", { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", undici: "7.13.0" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", undici: "7.13.0" }, components: [ { key: "undici", diff --git a/test/patch-validator-cpe-match-provenance.test.ts b/test/patch-validator-cpe-match-provenance.test.ts index 645bf671c..ca4a5a424 100644 --- a/test/patch-validator-cpe-match-provenance.test.ts +++ b/test/patch-validator-cpe-match-provenance.test.ts @@ -4,7 +4,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; function scannerOutput(matches: unknown[]) { @@ -41,7 +41,7 @@ function inputFor(matches: unknown[]) { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -56,8 +56,8 @@ function inputFor(matches: unknown[]) { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", openssl: "3.5.2" }, components: [ { key: "openssl", diff --git a/test/patch-validator-embedded-runtime-assessment.test.ts b/test/patch-validator-embedded-runtime-assessment.test.ts index 557299bfa..72dbef7da 100644 --- a/test/patch-validator-embedded-runtime-assessment.test.ts +++ b/test/patch-validator-embedded-runtime-assessment.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; const imageDigest = `sha256:${"7".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; function inputWithUnassessedZeroMatchComponent(): any { @@ -15,7 +15,7 @@ function inputWithUnassessedZeroMatchComponent(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -30,9 +30,9 @@ function inputWithUnassessedZeroMatchComponent(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", }, components: [ diff --git a/test/patch-validator-embedded-runtime-catalog-parity.test.ts b/test/patch-validator-embedded-runtime-catalog-parity.test.ts index 94a0608d7..4d610537e 100644 --- a/test/patch-validator-embedded-runtime-catalog-parity.test.ts +++ b/test/patch-validator-embedded-runtime-catalog-parity.test.ts @@ -39,7 +39,7 @@ describe("patch-validator embedded-runtime reviewed identity catalog parity", () }); it("binds Node's patched V8 runtime to the reviewed upstream V8 CPE version", () => { - const processVersion = "13.6.233.17-node.51"; + const processVersion = "13.6.233.17-node.53"; const expected = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; const identity = reviewedIdentityFor("v8", processVersion); diff --git a/test/patch-validator-embedded-runtime-digest-binding.test.ts b/test/patch-validator-embedded-runtime-digest-binding.test.ts index 7fdccd15b..7b7bf39c6 100644 --- a/test/patch-validator-embedded-runtime-digest-binding.test.ts +++ b/test/patch-validator-embedded-runtime-digest-binding.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { generateEmbeddedRuntimeInventory } from "../scripts/lib/patch-validator-embedded-runtime-inventory.mjs"; const versions = { - node: "24.19.0", + node: "24.21.0", acorn: "8.15.0", }; diff --git a/test/patch-validator-embedded-runtime-inventory.test.ts b/test/patch-validator-embedded-runtime-inventory.test.ts index 521cfd0d0..6ea1e1652 100644 --- a/test/patch-validator-embedded-runtime-inventory.test.ts +++ b/test/patch-validator-embedded-runtime-inventory.test.ts @@ -8,7 +8,7 @@ describe("patch-validator embedded runtime inventory", () => { it("uses reviewed c-ares and Brotli identities and keeps disabled QUIC keys explicit", () => { const { inventory, scanPlan } = generateEmbeddedRuntimeInventory( { - node: "24.19.0", + node: "24.21.0", ares: "1.34.6", brotli: "1.2.0", cldr: "48.0", @@ -51,7 +51,7 @@ describe("patch-validator embedded runtime inventory", () => { it("keeps Node-internal ncrypto version evidence out of the external vulnerability scan plan", () => { const { inventory, scanPlan } = generateEmbeddedRuntimeInventory( { - node: "24.19.0", + node: "24.21.0", ares: "1.34.6", ncrypto: "0.0.1", }, @@ -76,7 +76,7 @@ describe("patch-validator embedded runtime inventory", () => { it("fails closed on an unreviewed non-empty bundled dependency", () => { expect(() => generateEmbeddedRuntimeInventory( - { node: "24.19.0", unknown_native_dependency: "1.2.3" }, + { node: "24.21.0", unknown_native_dependency: "1.2.3" }, imageDigest, ), ).toThrow(/no reviewed vulnerability identity/i); @@ -85,7 +85,7 @@ describe("patch-validator embedded runtime inventory", () => { it("rejects an empty version unless the key is a reviewed disabled feature", () => { expect(() => generateEmbeddedRuntimeInventory( - { node: "24.19.0", openssl: "" }, + { node: "24.21.0", openssl: "" }, imageDigest, ), ).toThrow(/invalid version/i); diff --git a/test/patch-validator-grype-provider-provenance.test.ts b/test/patch-validator-grype-provider-provenance.test.ts index 32d17a905..6957f43cc 100644 --- a/test/patch-validator-grype-provider-provenance.test.ts +++ b/test/patch-validator-grype-provider-provenance.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validator-static-runtime-evidence.mjs"; const imageDigest = `sha256:${"2".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const undiciPurl = "pkg:npm/undici@7.13.0"; function inputWithProviderDigest(providerInput: string): any { @@ -15,7 +15,7 @@ function inputWithProviderDigest(providerInput: string): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -30,8 +30,8 @@ function inputWithProviderDigest(providerInput: string): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", undici: "7.13.0" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", undici: "7.13.0" }, components: [ { key: "undici", diff --git a/test/patch-validator-grype-timestamp-calendar-integrity.test.ts b/test/patch-validator-grype-timestamp-calendar-integrity.test.ts index 31e13bbd7..982afc042 100644 --- a/test/patch-validator-grype-timestamp-calendar-integrity.test.ts +++ b/test/patch-validator-grype-timestamp-calendar-integrity.test.ts @@ -4,7 +4,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"8".repeat(64)}`; const providerDigest = `sha256:${"9".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; function validInput(): any { @@ -16,7 +16,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -31,8 +31,8 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", openssl: "3.5.2" }, components: [ { key: "openssl", diff --git a/test/patch-validator-grype-timestamp-future-integrity.test.ts b/test/patch-validator-grype-timestamp-future-integrity.test.ts index fcebfa6d1..8a32cb4e9 100644 --- a/test/patch-validator-grype-timestamp-future-integrity.test.ts +++ b/test/patch-validator-grype-timestamp-future-integrity.test.ts @@ -4,7 +4,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"8".repeat(64)}`; const providerDigest = `sha256:${"9".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; function validInput(): any { @@ -16,7 +16,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -31,8 +31,8 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", - process_versions: { node: "24.19.0", openssl: "3.5.2" }, + node_version: "24.21.0", + process_versions: { node: "24.21.0", openssl: "3.5.2" }, components: [ { key: "openssl", diff --git a/test/patch-validator-image-build-regression.test.ts b/test/patch-validator-image-build-regression.test.ts index 89570bfda..45569182e 100644 --- a/test/patch-validator-image-build-regression.test.ts +++ b/test/patch-validator-image-build-regression.test.ts @@ -3,11 +3,24 @@ import { describe, expect, it } from "vitest"; const dockerfile = readFileSync("Dockerfile.patch-validator", "utf8"); const imageWorkflow = readFileSync(".github/workflows/patch-validator-image.yml", "utf8"); +const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + devEngines: { runtime: { version: string } }; + overrides: { undici: string }; +}; describe("patch-validator exact-toolchain image build regression", () => { + it("pins the Node release that embeds the patched undici runtime", () => { + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); + expect(dockerfile).toContain( + "ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc", + ); + expect(packageJson.devEngines.runtime.version).toBe("24.21.0"); + expect(packageJson.overrides.undici).toBe("7.29.1"); + }); + it("builds the static runtime with the exact Node/npm toolchain and patched c-ares source", () => { - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); - expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.17.0"'); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); + expect(dockerfile).toContain('test "$(/opt/node/bin/npm --version)" = "11.19.0"'); expect(dockerfile).toContain("ARG CARES_VERSION=1.34.8"); expect(dockerfile).toContain( "ARG CARES_SOURCE_SHA256=c222b6d681096f9444d2c4863d2c1174019e27cacca0a4a5c114d36dd7d7bf78", @@ -36,8 +49,8 @@ describe("patch-validator exact-toolchain image build regression", () => { it("materializes lockfile dependencies before Docker and forbids npm registry access in the image build", () => { expect(imageWorkflow).toContain("Set up exact dependency materialization toolchain"); expect(imageWorkflow).toContain("Materialize exact patch-validator dependencies"); - expect(imageWorkflow).toContain('node-version: "24.19.0"'); - expect(imageWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(imageWorkflow).toContain('node-version: "24.21.0"'); + expect(imageWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(imageWorkflow).toContain("npm ci --include=optional --ignore-scripts --no-audit --no-fund"); expect(imageWorkflow).toContain( "uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294", diff --git a/test/patch-validator-image-contract.test.ts b/test/patch-validator-image-contract.test.ts index 453b99d6e..5dbd12dde 100644 --- a/test/patch-validator-image-contract.test.ts +++ b/test/patch-validator-image-contract.test.ts @@ -42,9 +42,9 @@ describe("patch-validator image contract", () => { expect(fromLines[0]).toMatch(/@sha256:[0-9a-f]{64}(?:\s|$)/); expect(dockerfile).not.toContain("FROM validator_deps"); - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); expect(dockerfile).toContain( - "ARG NODE_SOURCE_SHA256=f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f", + "ARG NODE_SOURCE_SHA256=a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc", ); expect(dockerfile).toContain("--fully-static"); expect(dockerfile).not.toContain("--without-npm"); @@ -52,15 +52,15 @@ describe("patch-validator image contract", () => { expect(dockerfile).toContain("WORKDIR /usr/src/node"); expect(dockerfile).not.toContain("&& cd /usr/src/node"); expect(dockerfile).toContain( - 'test "$(/opt/node/bin/npm --version)" = "11.17.0"', + 'test "$(/opt/node/bin/npm --version)" = "11.19.0"', ); expect(dockerfile).toContain("readelf -l /opt/node/bin/node"); expect(dockerfile).toContain("readelf -d /opt/node/bin/node"); expect(dockerfile).not.toContain("npm ci"); expect(dockerfile).not.toContain("npm prune"); - expect(imageWorkflow).toContain('node-version: "24.19.0"'); - expect(imageWorkflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(imageWorkflow).toContain('node-version: "24.21.0"'); + expect(imageWorkflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(imageWorkflow).toContain( "npm ci --include=optional --ignore-scripts --no-audit --no-fund", ); diff --git a/test/patch-validator-image-documentation-current.test.ts b/test/patch-validator-image-documentation-current.test.ts index 8402b36c1..e1293a19c 100644 --- a/test/patch-validator-image-documentation-current.test.ts +++ b/test/patch-validator-image-documentation-current.test.ts @@ -20,7 +20,7 @@ describe("patch-validator image documentation", () => { .find((line) => line.startsWith("- repository-owned patch-validator image")); expect(imageEntry).toBeDefined(); - expect(imageEntry).toContain("Node.js 24.19.0"); + expect(imageEntry).toContain("Node.js 24.21.0"); expect(imageEntry).toContain("`scratch`"); expect(imageEntry).toContain("`process.versions`"); expect(imageEntry).not.toContain("Distroless runtime digest"); diff --git a/test/patch-validator-openssl-quic-applicability.test.ts b/test/patch-validator-openssl-quic-applicability.test.ts index eed887bd4..2d1725f82 100644 --- a/test/patch-validator-openssl-quic-applicability.test.ts +++ b/test/patch-validator-openssl-quic-applicability.test.ts @@ -5,7 +5,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.7:*:*:*:*:*:*:*"; function scannerOutput(vulnerabilityId: string) { @@ -54,7 +54,7 @@ function scannerOutput(vulnerabilityId: string) { function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) { const processVersions: Record = { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.7", }; const components: any[] = [ @@ -96,7 +96,7 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -111,7 +111,7 @@ function inputFor(vulnerabilityId: string, includeDisabledQuicEvidence: boolean) embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: processVersions, components, }, diff --git a/test/patch-validator-package-note-identity.test.ts b/test/patch-validator-package-note-identity.test.ts index a07694357..ecb7d659f 100644 --- a/test/patch-validator-package-note-identity.test.ts +++ b/test/patch-validator-package-note-identity.test.ts @@ -14,7 +14,7 @@ describe("patch-validator package note identity", () => { expect(dockerfile).not.toContain("pkg:generic/"); expect(dockerfile).toContain( - '"cpe":"cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"', + '"cpe":"cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"', ); }); diff --git a/test/patch-validator-reviewed-applicability.test.ts b/test/patch-validator-reviewed-applicability.test.ts index f5ceb6ce7..656d65149 100644 --- a/test/patch-validator-reviewed-applicability.test.ts +++ b/test/patch-validator-reviewed-applicability.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { applyReviewedEmbeddedRuntimeApplicability } from "../scripts/lib/patch-validator-embedded-runtime-applicability.mjs"; -const nghttp2Cpe = "cpe:2.3:a:nghttp2:nghttp2:1.69.0:*:*:*:*:*:*:*"; +const nghttp2Cpe = "cpe:2.3:a:nghttp2:nghttp2:1.70.0:*:*:*:*:*:*:*"; const v8Cpe = "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*"; -const sqliteCpe = "cpe:2.3:a:sqlite:sqlite:3.53.3:*:*:*:*:*:*:*"; -const zlibCpe = "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-3246f1b:*:*:*:*:*:*:*"; +const sqliteCpe = "cpe:2.3:a:sqlite:sqlite:3.53.4:*:*:*:*:*:*:*"; +const zlibCpe = "cpe:2.3:a:zlib:zlib:1.3.2.1-motley-8002e91:*:*:*:*:*:*:*"; function componentScan( key: string, @@ -45,40 +45,40 @@ function componentScan( function inventory() { return { - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", - nghttp2: "1.69.0", - sqlite: "3.53.3", - v8: "13.6.233.17-node.51", - zlib: "1.3.2.1-motley-3246f1b", + node: "24.21.0", + nghttp2: "1.70.0", + sqlite: "3.53.4", + v8: "13.6.233.17-node.53", + zlib: "1.3.2.1-motley-8002e91", }, components: [ { key: "nghttp2", name: "nghttp2", - version: "1.69.0", + version: "1.70.0", classification: "bundled_dependency", cpe: nghttp2Cpe, }, { key: "sqlite", name: "sqlite", - version: "3.53.3", + version: "3.53.4", classification: "bundled_dependency", cpe: sqliteCpe, }, { key: "v8", name: "v8", - version: "13.6.233.17-node.51", + version: "13.6.233.17-node.53", classification: "bundled_dependency", cpe: v8Cpe, }, { key: "zlib", name: "zlib", - version: "1.3.2.1-motley-3246f1b", + version: "1.3.2.1-motley-8002e91", classification: "bundled_dependency", cpe: zlibCpe, }, @@ -93,8 +93,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "nghttp2", "nghttp2", - "1.69.0", - "1.69.0", + "1.70.0", + "1.70.0", nghttp2Cpe, "CVE-2026-58055", "Medium", @@ -116,14 +116,14 @@ describe("reviewed embedded-runtime applicability", () => { }); it.each(["CVE-2015-5380", "CVE-2011-5037", "CVE-2011-3886"])( - "marks legacy V8 advisory %s non-applicable to the exact Node 24.19.0 V8 runtime even when Grype reports the normalized CPE version", + "marks legacy V8 advisory %s non-applicable to the exact Node 24.21.0 V8 runtime even when Grype reports the normalized CPE version", (vulnerabilityId) => { const scan = { components: [ componentScan( "v8", "v8", - "13.6.233.17-node.51", + "13.6.233.17-node.53", "13.6.233.17", v8Cpe, vulnerabilityId, @@ -141,18 +141,18 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "v8", vulnerability_id: vulnerabilityId, - reason: "Exact Node 24.19.0 V8 runtime is newer than the reviewed affected legacy V8 releases", + reason: "Exact Node 24.21.0 V8 runtime is newer than the reviewed affected legacy V8 releases", }); }, ); - it("marks CVE-2026-85046 non-applicable only to the exact Node 24.19.0 V8 branch/CPE", () => { + it("marks CVE-2026-85046 non-applicable only to the exact Node 24.21.0 V8 branch/CPE", () => { const scan = { components: [ componentScan( "v8", "v8", - "13.6.233.17-node.51", + "13.6.233.17-node.53", "13.6.233.17", v8Cpe, "CVE-2026-85046", @@ -170,7 +170,7 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "v8", vulnerability_id: "CVE-2026-85046", - reason: "Exact Node 24.19.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers", + reason: "Exact Node 24.21.0 V8 branch lacks the vulnerable inlined Array.prototype.sort reducers", }); const differentNode = inventory(); @@ -189,15 +189,15 @@ describe("reviewed embedded-runtime applicability", () => { "BIT-sqlite-2025-29088", "BIT-sqlite-2025-6965", ])( - "marks reviewed fixed-range SQLite finding %s non-applicable to exact SQLite 3.53.3", + "marks reviewed fixed-range SQLite finding %s non-applicable to exact SQLite 3.53.4", (vulnerabilityId) => { const scan = { components: [ componentScan( "sqlite", "sqlite", - "3.53.3", - "3.53.3", + "3.53.4", + "3.53.4", sqliteCpe, vulnerabilityId, "High", @@ -214,7 +214,7 @@ describe("reviewed embedded-runtime applicability", () => { expect(reviewed.nonApplicableMatches).toContainEqual({ component_key: "sqlite", vulnerability_id: vulnerabilityId, - reason: "Exact SQLite 3.53.3 runtime is newer than the reviewed affected SQLite ranges", + reason: "Exact SQLite 3.53.4 runtime is newer than the reviewed affected SQLite ranges", }); }, ); @@ -225,8 +225,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "zlib", "zlib", - "1.3.2.1-motley-3246f1b", - "1.3.2.1-motley-3246f1b", + "1.3.2.1-motley-8002e91", + "1.3.2.1-motley-8002e91", zlibCpe, "GHSA-g857-hhfv-j68w", "Medium", @@ -253,8 +253,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "sqlite", "sqlite", - "3.53.3", - "3.53.3", + "3.53.4", + "3.53.4", sqliteCpe, "CVE-2099-4242", "High", @@ -277,8 +277,8 @@ describe("reviewed embedded-runtime applicability", () => { componentScan( "nghttp2", "nghttp2", - "1.69.0", - "1.69.0", + "1.70.0", + "1.70.0", nghttp2Cpe, "CVE-2026-58055", "Medium", diff --git a/test/patch-validator-scanner-artifact-version.test.ts b/test/patch-validator-scanner-artifact-version.test.ts index 16aeabc1d..eed4f56c8 100644 --- a/test/patch-validator-scanner-artifact-version.test.ts +++ b/test/patch-validator-scanner-artifact-version.test.ts @@ -7,7 +7,7 @@ describe("patch-validator embedded scanner artifact version binding", () => { expect( expectedScannerArtifactVersion( "cpe:2.3:a:google:v8:13.6.233.17:*:*:*:*:*:*:*", - "13.6.233.17-node.51", + "13.6.233.17-node.53", ), ).toBe("13.6.233.17"); }); @@ -26,7 +26,7 @@ describe("patch-validator embedded scanner artifact version binding", () => { it("fails closed instead of accepting an unreviewable CPE shape", () => { expect(() => - expectedScannerArtifactVersion("cpe:2.3:a:google:v8", "13.6.233.17-node.51"), + expectedScannerArtifactVersion("cpe:2.3:a:google:v8", "13.6.233.17-node.53"), ).toThrow(/reviewed CPE identity is invalid/i); }); }); diff --git a/test/patch-validator-static-binary-vulnerability-scan.test.ts b/test/patch-validator-static-binary-vulnerability-scan.test.ts index 8a5e2388c..e689fbf22 100644 --- a/test/patch-validator-static-binary-vulnerability-scan.test.ts +++ b/test/patch-validator-static-binary-vulnerability-scan.test.ts @@ -79,6 +79,6 @@ describe("patch-validator static binary vulnerability boundary", () => { expect(staticRuntimeVerifier).toContain("binarySbom"); expect(staticRuntimeVerifier).toContain("binaryVulnerabilityScan"); expect(staticRuntimeVerifier).toContain("cpe:2.3:a:nodejs:node.js:"); - expect(staticRuntimeVerifier).toContain("24.19.0"); + expect(staticRuntimeVerifier).toContain("24.21.0"); }); }); diff --git a/test/patch-validator-static-runtime-evidence.test.ts b/test/patch-validator-static-runtime-evidence.test.ts index f0789ccbe..066b126b5 100644 --- a/test/patch-validator-static-runtime-evidence.test.ts +++ b/test/patch-validator-static-runtime-evidence.test.ts @@ -4,7 +4,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"2".repeat(64)}`; const providerDigest = `sha256:${"a".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; const undiciPurl = "pkg:npm/undici@7.13.0"; @@ -77,7 +77,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [{ cpe: nodeCpe, source: "syft-generated" }], }, @@ -107,9 +107,9 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", undici: "7.13.0", }, @@ -165,7 +165,7 @@ describe("static runtime binary evidence verifier", () => { expect(verifyStaticRuntimeBinaryEvidence(validInput())).toEqual({ binary_cataloger: "syft@1.50.0", binary_vulnerability_scanner: "grype@0.116.1", - node_runtime_version: "24.19.0", + node_runtime_version: "24.21.0", binary_package_count: 2, binary_vulnerability_match_count: 2, blocked_binary_vulnerability_count: 0, @@ -181,7 +181,7 @@ describe("static runtime binary evidence verifier", () => { input.binarySbom.source.metadata = { imageId: imageDigest }; input.binarySbom.artifacts[0].locations = [{ accessPath: "/nodejs/bin/node" }]; input.binarySbom.artifacts[0].cpes = [nodeCpe]; - expect(verifyStaticRuntimeBinaryEvidence(input).node_runtime_version).toBe("24.19.0"); + expect(verifyStaticRuntimeBinaryEvidence(input).node_runtime_version).toBe("24.21.0"); }); it("rejects a blocking advisory on an embedded runtime dependency even when the Node CPE lane is clean", () => { @@ -189,7 +189,7 @@ describe("static runtime binary evidence verifier", () => { input.embeddedRuntimeInventory = { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", components: [ { key: "openssl", @@ -237,8 +237,8 @@ describe("static runtime binary evidence verifier", () => { ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].locations = [{ path: "/other" }]; }], ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = null; }], ["Syft package CPE", (x) => { x.binarySbom.artifacts[0].cpes = [42]; }], - ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = [{ cpe: "cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*" }]; }], - ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = ["cpe:2.3:a:other:node:24.19.0:*:*:*:*:*:*:*"]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = [{ cpe: "cpe:2.3:a:other:node:24.21.0:*:*:*:*:*:*:*" }]; }], + ["exactly one expected static Node", (x) => { x.binarySbom.artifacts[0].cpes = ["cpe:2.3:a:other:node:24.21.0:*:*:*:*:*:*:*"]; }], ["exactly one expected static Node", (x) => { x.binarySbom.artifacts.push({ ...x.binarySbom.artifacts[0] }); }], ["Grype vulnerability record", (x) => { x.binaryVulnerabilityScan = null; }], ["Grype descriptor", (x) => { x.binaryVulnerabilityScan.descriptor = null; }], @@ -274,9 +274,9 @@ describe("static runtime binary evidence verifier", () => { ["ignored embedded runtime vulnerability", (x) => { x.embeddedVulnerabilityScan.ignoredMatches = [{}]; }], ["process.versions", (x) => { x.embeddedRuntimeInventory.process_versions = null; }], ["process.versions Node version", (x) => { x.embeddedRuntimeInventory.process_versions.node = "24.18.0"; }], - ["dependencies must be", (x) => { x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; }], + ["dependencies must be", (x) => { x.embeddedRuntimeInventory.process_versions = { node: "24.21.0" }; }], ["dependencies must be", (x) => { - x.embeddedRuntimeInventory.process_versions = { node: "24.19.0" }; + x.embeddedRuntimeInventory.process_versions = { node: "24.21.0" }; for (let index = 0; index < 129; index += 1) x.embeddedRuntimeInventory.process_versions[`dep${index}`] = "1"; }], ["embedded runtime component", (x) => { x.embeddedRuntimeInventory.components[0] = null; }], diff --git a/test/patch-validator-static-runtime-identity-binding.test.ts b/test/patch-validator-static-runtime-identity-binding.test.ts index a57339bdc..487c2daaa 100644 --- a/test/patch-validator-static-runtime-identity-binding.test.ts +++ b/test/patch-validator-static-runtime-identity-binding.test.ts @@ -5,7 +5,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"4".repeat(64)}`; const providerDigestA = `sha256:${"a".repeat(64)}`; const providerDigestB = `sha256:${"b".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; const undiciPurl = "pkg:npm/undici@7.13.0"; @@ -67,7 +67,7 @@ function validInput(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -82,9 +82,9 @@ function validInput(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", openssl: "3.5.2", undici: "7.13.0", }, diff --git a/test/patch-validator-static-runtime-metadata.test.ts b/test/patch-validator-static-runtime-metadata.test.ts index 9f6099c06..961765689 100644 --- a/test/patch-validator-static-runtime-metadata.test.ts +++ b/test/patch-validator-static-runtime-metadata.test.ts @@ -4,7 +4,7 @@ import { verifyStaticRuntimeBinaryEvidence } from "../scripts/lib/patch-validato const imageDigest = `sha256:${"7".repeat(64)}`; const providerDigest = `sha256:${"8".repeat(64)}`; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; const opensslCpe = "cpe:2.3:a:openssl:openssl:3.5.2:*:*:*:*:*:*:*"; function rawScannerOutput(identity: string): any { @@ -44,7 +44,7 @@ function inputWithRuntimeMetadata(): any { artifacts: [ { name: "node", - version: "24.19.0", + version: "24.21.0", locations: [{ path: "/nodejs/bin/node" }], cpes: [nodeCpe], }, @@ -59,9 +59,9 @@ function inputWithRuntimeMetadata(): any { embeddedRuntimeInventory: { schema_version: "noema.patch-validator-embedded-runtime-inventory.v1", validator_image_digest: imageDigest, - node_version: "24.19.0", + node_version: "24.21.0", process_versions: { - node: "24.19.0", + node: "24.21.0", modules: "137", napi: "10", openssl: "3.5.2", @@ -175,7 +175,7 @@ describe("static runtime metadata classification", () => { ); }); - it("does not let a scanner blind spot clear the bundled ngtcp2 version from Node 24.19.0", () => { + it("does not let a scanner blind spot clear the bundled ngtcp2 version from Node 24.21.0", () => { const input = inputWithRuntimeMetadata(); addNgtcp2(input, "1.15.1"); diff --git a/test/patch-validator-static-runtime.test.ts b/test/patch-validator-static-runtime.test.ts index 89b560726..11cd2a9c0 100644 --- a/test/patch-validator-static-runtime.test.ts +++ b/test/patch-validator-static-runtime.test.ts @@ -11,13 +11,13 @@ const workflow = readFileSync( const alpineBuilder = "alpine:3.24.1@sha256:79ff19e9084a00eece421b2523fb93e22d730e2c0e525905de047e848e56d95f"; const nodeSourceSha256 = - "f6d95e10a0431ee1067fc6aabe9f762908b4716dd35324e1ddb4b1466b76659f"; -const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.19.0:*:*:*:*:*:*:*"; + "a6f54defb6fd7c84f41dba13d61e78e9b4e0961712cf61f29715c05f5ced94fc"; +const nodeCpe = "cpe:2.3:a:nodejs:node.js:24.21.0:*:*:*:*:*:*:*"; describe("patch-validator static scratch runtime", () => { it("builds and inventories the checksum-pinned current Node 24 source", () => { expect(dockerfile).toContain(`FROM ${alpineBuilder} AS node_builder`); - expect(dockerfile).toContain("ARG NODE_VERSION=24.19.0"); + expect(dockerfile).toContain("ARG NODE_VERSION=24.21.0"); expect(dockerfile).toContain(`ARG NODE_SOURCE_SHA256=${nodeSourceSha256}`); expect(dockerfile).toContain( [ @@ -36,7 +36,7 @@ describe("patch-validator static scratch runtime", () => { expect(dockerfile).not.toContain("--with-intl=none"); expect(dockerfile).toContain("--with-intl=small-icu"); expect(dockerfile).toContain( - 'test "$(/opt/node/bin/npm --version)" = "11.17.0"', + 'test "$(/opt/node/bin/npm --version)" = "11.19.0"', ); expect(dockerfile).toContain("--without-corepack"); expect(dockerfile).toContain( @@ -79,7 +79,7 @@ describe("patch-validator static scratch runtime", () => { expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); expect(workflow).toContain("Verify static Node runtime identity"); expect(workflow).toContain( - 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0"', ); expect(workflow).toContain("--severity MEDIUM,HIGH,CRITICAL"); expect(workflow).toContain("--exit-code 1"); diff --git a/test/patch-validator-workflow.test.ts b/test/patch-validator-workflow.test.ts index dfe514ab6..775d39c08 100644 --- a/test/patch-validator-workflow.test.ts +++ b/test/patch-validator-workflow.test.ts @@ -94,7 +94,7 @@ describe("patch-validator pull-request image verification", () => { expect(workflow).not.toContain("keyless@distroless.iam.gserviceaccount.com"); expect(workflow).toContain("Verify static Node runtime identity"); expect(workflow).toContain( - 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.19.0"', + 'test "$(docker run --rm --pull=never --entrypoint=/nodejs/bin/node "$IMAGE_TAG" --version)" = "v24.21.0"', ); expect(workflow).toContain("readelf -l \"$node_binary\""); expect(workflow).toContain("readelf -d \"$node_binary\""); diff --git a/test/private-vulnerability-reporting-ambient-token.test.ts b/test/private-vulnerability-reporting-ambient-token.test.ts index 09d380e8e..7421544a8 100644 --- a/test/private-vulnerability-reporting-ambient-token.test.ts +++ b/test/private-vulnerability-reporting-ambient-token.test.ts @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; const SETUP_NODE_ACTION = "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"; const CANONICAL_SETTING_JOB_SHA256 = - "fe43d9d1d563fd939e7464f79e73e86fd4752bf1e88922c6ae996315a3d6cb04"; + "25913611efd46734b5bdc127d3655fabaebed43863dcb0764a0b79ab4d2cf6da"; const REVIEWED_SETUP_NODE_BLOCK = [ " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION} # v6.4.0`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -62,7 +62,7 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: unrelated step", " with:", ' token: ""', @@ -80,11 +80,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe setup node", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -99,11 +99,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe escaped setup node", ' uses: "actions/setup\\u002dnode@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"', " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -119,11 +119,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " uses: >-", ` ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); @@ -138,11 +138,11 @@ describe("private vulnerability reporting ambient token boundary", () => { " - name: unsafe quoted-key setup node", ` "uses": ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', " - name: setup node without repository token", ` uses: ${SETUP_NODE_ACTION}`, " with:", - ' node-version: "24.19.0"', + ' node-version: "24.21.0"', ' token: ""', ].join("\n"); diff --git a/test/readiness-scan-toolchain-integrity.test.ts b/test/readiness-scan-toolchain-integrity.test.ts index 887d8c59e..f2aa3f902 100644 --- a/test/readiness-scan-toolchain-integrity.test.ts +++ b/test/readiness-scan-toolchain-integrity.test.ts @@ -25,9 +25,9 @@ describe("saleable-readiness workflow supply-chain integrity", () => { }); it("uses the exact protected-CI Node/npm identities and frozen install flags", () => { - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); expect(workflow).toContain( "npm ci --legacy-peer-deps=false --install-links=false", ); diff --git a/test/release-evidence-toolchain-integrity.test.ts b/test/release-evidence-toolchain-integrity.test.ts index 551574038..6007e53da 100644 --- a/test/release-evidence-toolchain-integrity.test.ts +++ b/test/release-evidence-toolchain-integrity.test.ts @@ -8,11 +8,11 @@ describe("release evidence toolchain integrity", () => { expect(workflow).toContain( "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", ); - expect(workflow).toContain('node-version: "24.19.0"'); - expect(workflow).toContain('test "$(node --version)" = "v24.19.0"'); - expect(workflow).toContain('test "$(npm --version)" = "11.17.0"'); + expect(workflow).toContain('node-version: "24.21.0"'); + expect(workflow).toContain('test "$(node --version)" = "v24.21.0"'); + expect(workflow).toContain('test "$(npm --version)" = "11.19.0"'); - const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.17.0"'); + const toolchainIndex = workflow.indexOf('test "$(npm --version)" = "11.19.0"'); const installIndex = workflow.indexOf( "npm ci --legacy-peer-deps=false --install-links=false", ); From c61867761c95523834eaa72594f4c66c55f392e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 01:17:10 +0900 Subject: [PATCH 10/36] docs: record patch-validator security RCA --- docs/product-technical-gap-baseline.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 335279780..d35dfb8fb 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -8,6 +8,8 @@ Current protected source는 mutation·merge·release 시점에 live protected `m Dated central control-plane observation for this repair는 central `.github/main@cb0872c9a20d5584703dffacca65c096fc034c6c`다. Noema runtime의 reviewed immutable central consumer pin은 `c9052e607e5f3cc76e73207e7786b21500721b79`이고 runtime authority 표현은 `ALLOWED_WORKFLOW_SHA = c9052e607e5f3cc76e73207e7786b21500721b79`다. Moving foreign head와 reviewed immutable pin을 같은 권위로 취급하지 않으며 central moving head가 전진했다고 consumer pin을 자동 승격하지 않는다. +Open candidate PR #736의 predecessor exact head `6f7b7dc8e4d67dd8eb4827423f9e14fbeae7fd3f`에서 application CI `36731606439`, reviewer-ci `36731606039`, required Security Scan `36731605921`은 terminal SUCCESS였지만 patch-validator-image `36731605769`는 terminal FAILURE였다. Retained artifact `11104958913`의 exact embedded-runtime receipt는 Node 24.19.0에 포함된 `undici@7.29.0`에서 High-severity GHSA-rfgv-xxqx-mfg5와 GHSA-w293-vg96-wgc3를 보고했고 strict verifier는 `blocking embedded runtime vulnerabilities are not allowed`로 실패 폐쇄했다. Source-fix commit `6a297372d431df7057800740c8eb65480da29950`은 scanner waiver 없이 authenticated Node 24.21.0 source와 embedded `undici@7.29.1`, npm 11.19.0, 새 exact `process.versions` identity/applicability 계약으로 수리한다. 이 commit의 local evidence는 typecheck, `npm audit --audit-level=high` 0건, focused 53/53, repository 5,408/5,409 pass다. 남은 1건은 실행 환경이 Unix-domain socket 생성을 `EPERM`으로 금지해 fixture setup 전에 종료된 local-only 결과이며 성공으로 승격하지 않는다. PR #736의 다음 exact head는 hosted patch-validator image build/raw scan/strict receipt와 모든 required Checks를 새로 통과하기 전까지 Draft/Proposed candidate이며 merge 또는 immutable publication authority가 아니다. + Protected history에는 merged PR #535 exact `82b20b293f0a5f0ac0e69857c1b61dddfe478491`, merged PR #540 exact `05bc2d47c3899ebe17538070f9a30172f90307ac`, merged PR #542 exact `ca839298fcaeec409091dc909789b6f87eb67fdc`, merged PR #547 exact `30b7e7e5cdab8de65715834a16f994b2047eafa6`, merged PR #550 exact `f2ec2dc6709814070cc3e3d6932ce280aee966db`, merged PR #553 exact `3bd9f543e97ce856f78b1c608141436298ce9e74`, merged PR #558 exact `2f91bf8641212ecae435b5fbcc9084cc0acd6295`, merged PR #556 exact `860714cba46dba06260a5dce09d0e9152fcb0a8c`, merged PR #560 exact `5aab7c098f3478069127f34e398326415ec599a4`, merged PR #582 exact `0f20a4dc78e423fd5df49e137a4eb286c7075ea4`, documentation-authority integration #583, protected procedural graph source #585/#586, documentation convergence #587/#588/#590/#595, workflow-backed current-state integration #589, procedural decision/evaluation/authentication integrations #591/#592/#593/#594/#596, State / Checkpoint history #597, Policy / Approval CAS #601, publication preflight #603, Workflow / Task exact-object operability integration #605, documentation-authority convergence #606, merged PR #607 exact `0afd68d2e33b7fd9be2307ba78b370b534cc0f54`, merged PR #610 exact `0a516bc54d682817ffea3206ec8b24b214aa94ea`, merged PR #612 exact `a919be3bc2a04068e25a4592e0c9c26f8e63534b`, merged PR #614 exact `77c5116a2e58e8a154ee893a3825da19c9c1f357`, merged PR #616 exact `da8461f1f5a6d02d926dd295fc88abe2c6167dfa`, documentation-authority convergence PR #617 exact `38b3b56837d875d19ae458f3f5491951ad7afb1c`, merged PR #618 exact `fb166ec6ecc769a19fd4ae4502f6d0994fc89e33`, merged PR #620 exact `b746846e4890c84b568bb29937594e1b17364fa1`, documentation-authority convergence #639, merged PR #640 exact `72ff4897822b5a8122540509346be4f3278a1379`, merged PR #641 exact `73f13b3a3826c5d6f7aafc89d1ff23747f8cf8e9`, merged PR #642 exact `3427e5a66d7a0ea9379ddc7915ba2b3d1872c492`, merged PR #643 exact `b13903e3208232086871d7f117f8f20e753eb583`, merged PR #644 exact `79a89f5775d0fb56de9f1908c1927df0b498b70f`, merged PR #645 exact `1b4dc38581e99b92342769dd03059f5015ee9f04`, merged PR #646 exact `3005086fca2c6376fae951b774230fa1c06ba587`, merged PR #647 exact `ea3a36bc65df88153aa69a05e3144f0b5352b5a6`, merged PR #648 exact `133ce20c4bb7fb9ba05e3e4bedeb0492334faf38`, merged PR #649 exact `f334f40193da8811a6e15efcb2d589aa80104931`, merged PR #650 exact `f59fce008a4da70f89e9927f692b823506594ed5`, documentation-authority convergence #651, merged PR #652 exact `23fabd95f3ef886c6bef0ae9ff6ead50dc0ce342`, documentation-authority convergence PR #653 exact `41ff085c89e27a97a00ce85ced968931f506a147`, merged PR #654 exact `f8d4b6b6b095c7674037a9ea5281b9ffa2f0f864`, merged PR #655 exact `dc851fc9ea67e38799e8c30cf4916a31c9e9661e`, merged PR #657 exact `fa4a2ab25231fa4c8b9a8451a4da6f29e04abaf7`, documentation-authority convergence PR #658 exact `5fe54442068646ad9581d39b23dd6d60a737aa66`, merged PR #659 exact `6357ac65db6a0db9f3bbb3f82196853b787f33cc`, merged PR #661 exact `5a5e147c5a7168277332eb828652b4aea4f7f2ba`, merged PR #663 exact `29dfa79692ec55cdc23ed892081425630ac916b5`, merged PR #665 exact `592ae00f090f642e8cb71ffb132d4a8b7bfe993a`, merged PR #668 exact `efaaebae161a71b1274f5aa0f26e27376a3edb3a`, merged PR #670 exact `14b4c9a46692df0084a03b9a3b2ae4acf1178a83`, merged PR #673 exact `6f9dc4ca047817e4c9ef5ae8a55816493b1c558d`, merged PR #676 exact `f8de7fc27d475c76477271fa50dd7032afd82a6c`, merged PR #681 exact `7072822253b1975cea76dcd7922e1a01d67e7681`, merged PR #678 exact `ac367cb535525742a1b6f3682d3ab5b14ef66f23`, merged PR #683 exact `01adb5af3d26788926164003bc44a761f5ff3654`, merged PR #685 exact `93dcf4c50018ce051e3ca917a4e1d63cbca3102d`, merged PR #687 exact `98ec07552f4081bd4ff6b25f1f3ed691c0f3f2b9`, merged PR #689 exact `b838c352a7cd0f7f58a905857537a3a9675a56f2`, merged PR #708 exact `8e4a35c28418fa3049e1dc13470088e8dacf2306`, merged PR #710 exact `8bc768756a10bab1d32b14039cdcfdb48d001931`, merged PR #714 exact `a1228e72565d4280d01e1f877377ee0d3e107178`, merged PR #717 exact `8f1fe8acdf96e23e5f110e958b08499e8e3a9803`, 그리고 merged PR #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`가 포함돼 있다. 이 식별자는 역사 증거이지 open-candidate authority가 아니다. 이 baseline과 executable documentation-authority test는 active documentation-authority lane 하나만 write한다. mutation 직전 open PR/Issue/branch를 fresh-read해 writer를 결정하며 merged/closed historical PR 번호를 active sole writer로 고정하지 않는다. 다른 feature lane의 과거 baseline blob은 ordinary/non-force semantic convergence 때 current authority로 승계하지 않는다. @@ -132,7 +134,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Procedural graph publication boundary | advisory/evaluation/preflight가 publication/activation으로 오인될 위험 | protected #585/#586/#589/#597/#714/#601/#719/#603/#652/#663/#678 + issue #584 | source integrated; #714 State / Checkpoint and #719 Policy / Approval minimal-transaction/complete-revalidation/type-admission hardening integrated; response-bound, cancellation-liveness and locked-reader hardening integrated; publication/activation unavailable | released graph contract + live signer trust + lifecycle/revocation + deployed evidence + graph publication/canary | prerequisites 전 graph publication fail closed 유지 | | P0 | Durable workflow/state production evidence | source Durable Object logic·object-size observation이 deployed recovery/SLO로 오인될 위험 | protected #542 + #605 / ADR 0013 / issue #541 | source + exact-object observation integrated; ADR 0013 Proposed | immutable release/deployment + exact-object transaction/restart/recovery + representative storage-growth denominator + p95 + PITR/rollback | approved deployment owner에서 exact protected release 대상으로 runtime/recovery receipt 확보 | | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | -| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 | source/image integrated; publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | operational receipt 뒤 publication/signing 검증 | +| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 / open PR #736 | protected source/image integrated; predecessor scan exposed embedded undici vulnerability; source repair proposed; exact-head hosted receipt와 publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #736 exact-head image build/raw scan/strict receipt를 재검증한 뒤 operational publication/signing 검증 | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | From df3c8e349c1ecfff7eeec3eacc2a64615e2ac971 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:03:37 +0900 Subject: [PATCH 11/36] test+feat(dispatch): expose versioned continuation broker --- ...2026-09-30-continuation-dispatch-broker.md | 10 +- src/continuation-dispatch/handler.ts | 378 ++++++++++++++++++ src/entrypoint.ts | 74 +++- src/error-codes.ts | 12 + src/index.ts | 39 ++ src/runtime-entrypoint.ts | 49 ++- src/runtime-readiness.ts | 76 +++- src/worker.ts | 12 +- test/continuation-dispatch-worker.test.ts | 299 ++++++++++++++ test/runtime-readiness-cache.test.ts | 11 +- ...runtime-readiness-durable-bindings.test.ts | 3 +- ...untime-readiness-egress-capability.test.ts | 3 +- test/runtime-readiness-fixture.ts | 29 ++ test/runtime-readiness-missing-config.test.ts | 8 +- test/runtime-readiness-ref-format.test.ts | 3 +- ...untime-readiness-trace-correlation.test.ts | 3 +- test/runtime-readiness.test.ts | 3 +- 17 files changed, 973 insertions(+), 39 deletions(-) create mode 100644 src/continuation-dispatch/handler.ts create mode 100644 test/continuation-dispatch-worker.test.ts create mode 100644 test/runtime-readiness-fixture.ts diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 4493afbdc..545f74ad7 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -110,11 +110,11 @@ - Consumes: Tasks 1-4. - Produces: `POST /v1/continuation-dispatches` standard success/error envelope. -- [ ] Write end-to-end RED tests proving method/content-type/body bounds, exact URL, rate limit, workflow SHA, single-use OIDC, live PR, one dispatch, exact replay, conflict, receipt signature, and operational headers. -- [ ] Run the focused worker test; expect 404/missing route failures. -- [ ] Admit the new route through each existing layer, add the six dispatch error codes/hints, require all new secret/DO bindings in readiness, and preserve `/exchange` byte-for-byte behavior tests. -- [ ] Run focused worker tests, all existing Worker tests, and typecheck; expect PASS. -- [ ] Commit `test+feat(dispatch): expose versioned continuation broker`. +- [x] Write end-to-end RED tests proving method/content-type/body bounds, exact URL, rate limit, workflow SHA, single-use OIDC, live PR, one dispatch, exact replay, conflict, receipt signature, and operational headers. +- [x] Run the focused worker test; expect 404/missing route failures. +- [x] Admit the new route through each existing layer, add the six dispatch error codes/hints, require all new secret/DO bindings in readiness, and preserve `/exchange` byte-for-byte behavior tests. +- [x] Run focused worker tests, all existing Worker tests, and typecheck; expect PASS. +- [x] Commit `test+feat(dispatch): expose versioned continuation broker`. ### Task 6: Product, security, and operability contracts diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts new file mode 100644 index 000000000..eea5aeaad --- /dev/null +++ b/src/continuation-dispatch/handler.ts @@ -0,0 +1,378 @@ +import { parseExactBearerToken } from "../bearer-authorization"; +import type { JwtPayload } from "../index"; +import { errorHints, type ErrorCode } from "../error-codes"; +import { + ContinuationDispatchContractError, + continuationRequestDigest, + parseContinuationDispatchRequest, + type ContinuationDispatchRequest, +} from "./contract"; +import { + commitContinuationOutcome, + ContinuationDispatchStateConflict, + ContinuationDispatchStateUnavailable, + reserveContinuationDispatch, + type ContinuationDispatchReceipt, + type ContinuationDispatchReservation, + type ContinuationDispatchStateEnv, +} from "./dispatch-state"; +import { + ContinuationGitHubAdapterError, + dispatchCentralContinuation, + readAndVerifyLivePullRequest, + type CentralContinuationDispatchResult, + type ContinuationGitHubAdapterEnv, + type VerifiedLivePullRequest, +} from "./github-adapter"; +import { + signContinuationReceipt, + type ContinuationReceiptSigningEnv, + type SignedContinuationReceipt, +} from "./receipt"; + +const MAX_BODY_BYTES = 8_192; +const JSON_MEDIA_TYPE = /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu; + +/** Runtime authority required by the public continuation-dispatch handler. */ +export interface ContinuationDispatchHandlerEnv + extends ContinuationDispatchStateEnv, + ContinuationGitHubAdapterEnv, + ContinuationReceiptSigningEnv { + readonly ALLOWED_WORKFLOW_SHA?: string; +} + +/** Injected cryptographic and external boundaries used by the production handler and focused tests. */ +export interface ContinuationDispatchHandlerDependencies { + verifyOidc(token: string, env: ContinuationDispatchHandlerEnv): Promise; + claimOidc(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): Promise; + readLivePullRequest( + claims: JwtPayload, + request: ContinuationDispatchRequest, + env: ContinuationDispatchHandlerEnv, + ): Promise; + reserve( + env: ContinuationDispatchStateEnv, + identity: string, + digest: string, + ): Promise; + dispatch( + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, + ): Promise; + commit: typeof commitContinuationOutcome; +} + +/** Default owner implementations for every boundary except the existing OIDC verifier/replay claimant. */ +export const continuationDispatchDependencies = { + readLivePullRequest: readAndVerifyLivePullRequest, + reserve: reserveContinuationDispatch, + dispatch: dispatchCentralContinuation, + commit: commitContinuationOutcome, +}; + +type HandlerErrorCode = Extract< + ErrorCode, + | "ERR_DISPATCH_REQUEST_INVALID" + | "ERR_DISPATCH_IDENTITY_DENIED" + | "ERR_DISPATCH_LIVE_STATE_STALE" + | "ERR_DISPATCH_REPLAY_CONFLICT" + | "ERR_GITHUB_DISPATCH_AUTHORIZATION" + | "ERR_GITHUB_DISPATCH_UPSTREAM" +>; + +function responseHeaders(traceId: string, startedAt: number): Headers { + return new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": String(Math.round(performance.now() - startedAt)), + }); +} + +function errorResponse( + code: HandlerErrorCode, + status: number, + message: string, + traceId: string, + startedAt: number, + details?: Record, +): Response { + const headers = responseHeaders(traceId, startedAt); + if (status === 405) headers.set("allow", "POST"); + return new Response(JSON.stringify({ + ok: false, + error_code: code, + message, + details: { hint: errorHints[code], ...(details ?? {}) }, + trace_id: traceId, + }), { status, headers }); +} + +function successResponse( + receipt: unknown, + traceId: string, + startedAt: number, + replay = false, +): Response { + const headers = responseHeaders(traceId, startedAt); + headers.set("x-oidc-replay-protection", "verified-before-dispatch"); + if (replay) headers.set("x-continuation-replay", "exact"); + return new Response(JSON.stringify({ ok: true, data: { receipt }, trace_id: traceId }), { + status: 200, + headers, + }); +} + +function cancelBestEffort(body: ReadableStream | null, reason: string): void { + if (body === null) return; + try { + void body.cancel(reason).catch(() => undefined); + } catch { + // Rejection is already authoritative; cleanup must not replace it. + } +} + +function isUnavailableBoundary(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const candidate = error as { status?: unknown }; + return typeof candidate.status === "number" + && Number.isInteger(candidate.status) + && candidate.status >= 500 + && candidate.status <= 599; +} + +async function readBoundedBody(request: Request): Promise { + const contentType = request.headers.get("content-type") ?? ""; + if (!JSON_MEDIA_TYPE.test(contentType)) { + cancelBestEffort(request.body, "continuation dispatch requires application/json"); + return new Response(null, { status: 415 }); + } + const length = request.headers.get("content-length"); + if (length !== null && (!/^(?:0|[1-9][0-9]*)$/u.test(length) || Number(length) > MAX_BODY_BYTES)) { + cancelBestEffort(request.body, "continuation dispatch body exceeds its byte envelope"); + return new Response(null, { status: 413 }); + } + if (request.body === null) return ""; + let reader: ReadableStreamDefaultReader; + try { + reader = request.body.getReader(); + } catch { + return new Response(null, { status: 400 }); + } + const bytes = new Uint8Array(MAX_BODY_BYTES); + let used = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + if (chunk.value.byteLength > MAX_BODY_BYTES - used) { + void reader.cancel("continuation dispatch body exceeds its byte envelope").catch(() => undefined); + return new Response(null, { status: 413 }); + } + bytes.set(chunk.value, used); + used += chunk.value.byteLength; + } + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, used)); + } catch { + return new Response(null, { status: 400 }); + } finally { + try { + reader.releaseLock(); + } catch { + // A pending cancellation can retain the lock briefly without changing the response. + } + } +} + +async function sha256(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function emittedPayloadDigest(request: ContinuationDispatchRequest): Promise { + const eventType = request.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan"; + return sha256(JSON.stringify({ + event_type: eventType, + client_payload: { + source_repository: request.source_repository, + pull_request_number: request.pull_request_number, + expected_head_sha: request.expected_head_sha, + expected_base_sha: request.expected_base_sha, + expected_base_ref: request.expected_base_ref, + transport_retry_attempt: request.transport_retry_attempt, + }, + })); +} + +function workflowIdentity(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): { + workflowRef: string; + workflowSha: string; + iat: number; + exp: number; +} | undefined { + const workflowRef = claims.job_workflow_ref ?? claims.workflow_ref; + const workflowSha = claims.job_workflow_ref ? claims.job_workflow_sha : claims.workflow_sha; + if ( + typeof workflowRef !== "string" + || typeof workflowSha !== "string" + || workflowSha !== env.ALLOWED_WORKFLOW_SHA + || !Number.isSafeInteger(claims.iat) + || !Number.isSafeInteger(claims.exp) + ) { + return undefined; + } + return { workflowRef, workflowSha, iat: claims.iat!, exp: claims.exp! }; +} + +/** + * Executes one versioned, credential-free continuation dispatch from bounded request through signed evidence. + * Exact replays return retained evidence and all uncertain external outcomes become terminal before responding. + */ +export async function handleContinuationDispatch( + request: Request, + env: ContinuationDispatchHandlerEnv, + traceId: string, + dependencies: ContinuationDispatchHandlerDependencies, +): Promise { + const startedAt = performance.now(); + if (request.method !== "POST") { + cancelBestEffort(request.body, "continuation dispatch method is not allowed"); + return errorResponse("ERR_DISPATCH_REQUEST_INVALID", 405, "Method not allowed", traceId, startedAt); + } + + const body = await readBoundedBody(request); + if (body instanceof Response) { + const status = body.status; + return errorResponse( + "ERR_DISPATCH_REQUEST_INVALID", + status, + status === 415 ? "Continuation dispatch requires application/json" : "Continuation dispatch body is invalid", + traceId, + startedAt, + ); + } + + let parsed: ContinuationDispatchRequest; + try { + parsed = parseContinuationDispatchRequest(body); + } catch (error) { + if (!(error instanceof ContinuationDispatchContractError)) throw error; + return errorResponse("ERR_DISPATCH_REQUEST_INVALID", 400, "Continuation dispatch request is invalid", traceId, startedAt); + } + + const bearer = parseExactBearerToken(request.headers.get("authorization") ?? ""); + if (bearer === undefined) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Missing or invalid bearer token", traceId, startedAt); + } + + let claims: JwtPayload; + try { + claims = await dependencies.verifyOidc(bearer, env); + } catch (error) { + if (isUnavailableBoundary(error)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "OIDC verification unavailable", traceId, startedAt); + } + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was denied", traceId, startedAt); + } + const workflow = workflowIdentity(claims, env); + if (workflow === undefined || claims.repository !== parsed.source_repository) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); + } + + try { + await dependencies.claimOidc(claims, env); + } catch (error) { + if (isUnavailableBoundary(error)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "OIDC replay protection unavailable", traceId, startedAt); + } + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was already used or unavailable", traceId, startedAt); + } + + try { + await dependencies.readLivePullRequest(claims, parsed, env); + } catch (error) { + if (error instanceof ContinuationGitHubAdapterError) { + if (error.classification === "identity_denied") { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); + } + if (error.classification === "live_state_stale") { + return errorResponse("ERR_DISPATCH_LIVE_STATE_STALE", 409, "Pull request state no longer matches", traceId, startedAt); + } + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "GitHub live-state verification unavailable", traceId, startedAt); + } + + const digest = await continuationRequestDigest(parsed, { workflow_sha: workflow.workflowSha }); + let reservation: ContinuationDispatchReservation; + try { + reservation = await dependencies.reserve(env, digest, digest); + } catch (error) { + if (error instanceof ContinuationDispatchStateConflict) { + return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation replay conflicts with retained authority", traceId, startedAt); + } + if (error instanceof ContinuationDispatchStateUnavailable) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation state unavailable", traceId, startedAt); + } + throw error; + } + if (reservation.kind === "in_progress") { + return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation dispatch is already in progress", traceId, startedAt); + } + if (reservation.kind === "replay") { + return successResponse(reservation.receipt, traceId, startedAt, true); + } + + let dispatchResult: CentralContinuationDispatchResult; + try { + dispatchResult = await dependencies.dispatch(parsed, env); + } catch { + dispatchResult = { outcome: "indeterminate", eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan" }; + } + + let receipt: SignedContinuationReceipt; + try { + receipt = await signContinuationReceipt({ + request: parsed, + requestDigest: digest, + idempotencyIdentity: digest, + workflowRef: workflow.workflowRef, + workflowSha: workflow.workflowSha, + emittedPayloadDigest: await emittedPayloadDigest(parsed), + dispatchResult, + oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, + receiptId: crypto.randomUUID(), + traceId, + }, env); + await dependencies.commit( + env, + reservation, + receipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); + } + + if (dispatchResult.outcome === "accepted") { + return successResponse(receipt, traceId, startedAt); + } + if (dispatchResult.outcome === "denied") { + return errorResponse( + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + 502, + "GitHub rejected the fixed continuation dispatch", + traceId, + startedAt, + { receipt }, + ); + } + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt }, + ); +} diff --git a/src/entrypoint.ts b/src/entrypoint.ts index d7a32885d..80b883aa4 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -36,6 +36,8 @@ type EgressFailure = { | "github-cloud-exact-origin" | "github-app-id-canonical" | "github-app-installation-id-canonical" + | "continuation-dispatch-app-id-canonical" + | "continuation-dispatch-installation-id-canonical" | "credential-fetch-no-redirect"; }; @@ -567,6 +569,31 @@ function recordExchangeBodyFailure(request: Request, failure: ExchangeBodyFailur } } +function dispatchBoundaryResponse( + request: Request, + status: 400 | 401 | 405, + message: string, +): Response { + const traceId = traceIdFromRequest(request); + const headers = new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": "0", + }); + if (status === 405) headers.set("allow", "POST"); + return new Response(JSON.stringify({ + ok: false, + error_code: (status === 401 + ? "ERR_DISPATCH_IDENTITY_DENIED" + : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode, + message, + trace_id: traceId, + }), { status, headers }); +} + /** * Public Cloudflare Worker entrypoint that enforces request-body, OIDC-envelope, and * GitHub egress policy before delegating to the credential-exchange worker. It fails closed @@ -575,21 +602,29 @@ function recordExchangeBodyFailure(request: Request, failure: ExchangeBodyFailur export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname === "/exchange") { + const isExchange = url.pathname === "/exchange"; + const isContinuationDispatch = url.pathname === "/v1/continuation-dispatches"; + if (isExchange || isContinuationDispatch) { if (request.method !== "POST") { - return exchangeMethodResponse(request); + return isExchange + ? exchangeMethodResponse(request) + : dispatchBoundaryResponse(request, 405, "Method not allowed"); } if (!isBoundedOidcBearer(request.headers.get("authorization"))) { recordOidcEnvelopeFailure(request); - return oidcEnvelopeResponse(request); + return isExchange + ? oidcEnvelopeResponse(request) + : dispatchBoundaryResponse(request, 401, "Continuation identity is malformed"); } - const boundedRequest = await boundExchangeJsonBody(request); - if ("failure" in boundedRequest) { - recordExchangeBodyFailure(request, boundedRequest.failure); - return exchangeBodyResponse(request, boundedRequest.failure); + if (isExchange) { + const boundedRequest = await boundExchangeJsonBody(request); + if ("failure" in boundedRequest) { + recordExchangeBodyFailure(request, boundedRequest.failure); + return exchangeBodyResponse(request, boundedRequest.failure); + } + request = boundedRequest.request; } - request = boundedRequest.request; const appIdFailure: EgressFailure = { hint: "Configure GITHUB_APP_ID as a canonical positive decimal safe integer.", @@ -612,6 +647,27 @@ export default { return githubApiConfigurationResponse(request, installationIdFailure); } + if (isContinuationDispatch) { + const centralAppIdFailure: EgressFailure = { + hint: "Configure CONTINUATION_DISPATCH_GITHUB_APP_ID as a canonical positive decimal safe integer.", + outcome: "misconfigured", + policy: "continuation-dispatch-app-id-canonical", + }; + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_ID ?? "")) { + recordConfigurationFailure(request, centralAppIdFailure); + return githubApiConfigurationResponse(request, centralAppIdFailure); + } + const centralInstallationFailure: EgressFailure = { + hint: "Configure CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID as a canonical positive decimal safe integer.", + outcome: "misconfigured", + policy: "continuation-dispatch-installation-id-canonical", + }; + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID ?? "")) { + recordConfigurationFailure(request, centralInstallationFailure); + return githubApiConfigurationResponse(request, centralInstallationFailure); + } + } + const originFailure: EgressFailure = { hint: "Configure GITHUB_API_BASE as the exact GitHub Cloud REST API origin.", outcome: "misconfigured", @@ -639,4 +695,4 @@ export default { } return worker.fetch(request, env); }, -}; \ No newline at end of file +}; diff --git a/src/error-codes.ts b/src/error-codes.ts index 5300ce4ce..85af6f6fb 100644 --- a/src/error-codes.ts +++ b/src/error-codes.ts @@ -19,6 +19,12 @@ export type ErrorCode = | "ERR_GITHUB_API" | "ERR_GITHUB_INSTALLATION" | "ERR_RATE_LIMIT" + | "ERR_DISPATCH_REQUEST_INVALID" + | "ERR_DISPATCH_IDENTITY_DENIED" + | "ERR_DISPATCH_LIVE_STATE_STALE" + | "ERR_DISPATCH_REPLAY_CONFLICT" + | "ERR_GITHUB_DISPATCH_AUTHORIZATION" + | "ERR_GITHUB_DISPATCH_UPSTREAM" | "ERR_SERVICE_NOT_READY" | "ERR_INTERNAL"; @@ -39,6 +45,12 @@ export const errorHints: Record = { ERR_GITHUB_API: "Retry after checking GitHub API availability and the app installation state.", ERR_GITHUB_INSTALLATION: "Verify the GitHub App is installed on the target repository.", ERR_RATE_LIMIT: "Back off and retry after the rate-limit window resets.", + ERR_DISPATCH_REQUEST_INVALID: "Send one bounded request matching noema.continuation-dispatch.v1.", + ERR_DISPATCH_IDENTITY_DENIED: "Request a fresh OIDC token from the exact released reusable workflow and source repository.", + ERR_DISPATCH_LIVE_STATE_STALE: "Refresh the pull request head, base, ref, and draft state before requesting continuation.", + ERR_DISPATCH_REPLAY_CONFLICT: "Do not redispatch; reconcile the retained continuation identity and terminal receipt.", + ERR_GITHUB_DISPATCH_AUTHORIZATION: "Repair the central dispatch App installation or fixed repository permission before retrying with a new identity.", + ERR_GITHUB_DISPATCH_UPSTREAM: "Reconcile the retained continuation state before any retry because the external outcome may be unknown.", ERR_SERVICE_NOT_READY: "Repair the listed configuration checks before routing credential-exchange traffic.", ERR_INTERNAL: "Use trace_id to find the matching operational log entry.", }; diff --git a/src/index.ts b/src/index.ts index f2943ce9b..1631152cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,11 @@ import { OidcReplayUnavailable, type OidcReplayProtectionEnv, } from "./oidc-replay"; +import { + continuationDispatchDependencies, + handleContinuationDispatch, + type ContinuationDispatchHandlerEnv, +} from "./continuation-dispatch/handler"; /** * Runtime configuration consumed by Noema's base credential-exchange worker. These @@ -24,6 +29,12 @@ export interface Env extends OidcReplayProtectionEnv { GITHUB_APP_ID: string; GITHUB_APP_PRIVATE_KEY_PEM: string; GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM?: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM?: string; + CONTINUATION_RECEIPT_SIGNING_KEY_ID?: string; + NOEMA_CONTINUATION_DISPATCH_STATE?: DurableObjectNamespace; NOEMA_RATE_LIMIT_PER_MINUTE?: string; NOEMA_OIDC_JWKS_CACHE_TTL_SECONDS?: string; NOEMA_INSTALLATION_CACHE_TTL_SECONDS?: string; @@ -1136,6 +1147,14 @@ async function handleExchange(request: Request, env: Env, traceId: string): Prom }; } +const productionContinuationDispatchDependencies = { + ...continuationDispatchDependencies, + verifyOidc: (token: string, env: ContinuationDispatchHandlerEnv) => + verifyGithubOidcJwt(token, env as unknown as Env), + claimOidc: (claims: JwtPayload, env: ContinuationDispatchHandlerEnv) => + claimVerifiedOidcUsage(claims, env as unknown as Env), +}; + /** * Base public Worker entrypoint for Noema health and credential exchange. It validates * methods, OIDC/GitHub App exchange policy, local rate limits, structured errors, and @@ -1197,6 +1216,26 @@ export default { }); return withOperationalHeaders(response, traceId, latency_ms); } + if (url.pathname === "/v1/continuation-dispatches") { + enforceRateLimit(request, env, route); + const response = await handleContinuationDispatch( + request, + env as Env & ContinuationDispatchHandlerEnv, + traceId, + productionContinuationDispatchDependencies, + ); + status = response.status; + const latency_ms = Math.round(performance.now() - startedAt); + logRequest({ + route, + method, + status_code: status, + latency_ms, + trace_id: traceId, + ...(status >= 400 ? { error_code: (await response.clone().json() as { error_code?: ErrorCode }).error_code } : {}), + }); + return withOperationalHeaders(response, traceId, latency_ms); + } status = 404; const response = errorResponse("ERR_VALIDATION_INPUT", 404, "Endpoint not found", traceId, { path: route }); const latency_ms = Math.round(performance.now() - startedAt); diff --git a/src/runtime-entrypoint.ts b/src/runtime-entrypoint.ts index 28208a6a7..f527a6ba2 100644 --- a/src/runtime-entrypoint.ts +++ b/src/runtime-entrypoint.ts @@ -6,7 +6,6 @@ import entrypoint, { } from "./entrypoint"; import { normalizeGitHubAppPrivateKeyPem } from "./github-app-private-key"; import { evaluateRuntimeReadiness } from "./runtime-readiness"; -import { type ContinuationDispatchStateEnv } from "./continuation-dispatch/dispatch-state"; export { NoemaOidcReplayGuard, NoemaRateLimiter }; export { NoemaContinuationDispatchState } from "./continuation-dispatch/dispatch-state"; @@ -19,8 +18,9 @@ export { NoemaExternalExtensionLifecycle } from "./tool-capability/external-exte * consumed by the delegated application entrypoint and adds the immutable source revision * expected for the configured central reusable workflow. */ -export interface Env extends BaseEnv, ContinuationDispatchStateEnv { +export interface Env extends BaseEnv { ALLOWED_WORKFLOW_SHA?: string; + NOEMA_CONTINUATION_DISPATCH_STATE: DurableObjectNamespace; } const canonicalTraceHeaderPattern = /^[A-Za-z0-9._:-]+$/; @@ -162,16 +162,43 @@ function exchangeUrlResponse(request: Request): Response { }); } -function recordExchangeUrlFailure(request: Request): void { +function continuationDispatchUrlResponse(request: Request): Response { + const traceId = traceIdFromRequest(request); + return new Response(JSON.stringify({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID" satisfies ErrorCode, + message: "Continuation dispatch URL contains unreviewed authority", + details: { + hint: "Send the exact /v1/continuation-dispatches resource URL without a query or fragment.", + policy: "exact-continuation-dispatch-url", + }, + trace_id: traceId, + }), { + status: 400, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": "0", + }, + }); +} + +function recordExactUrlFailure(request: Request, route: string): void { + const exchangeRoute = route === "/exchange"; try { console.log(JSON.stringify({ - event: "exchange_url", - route: "/exchange", + event: exchangeRoute ? "exchange_url" : "continuation_dispatch_url", + route, method: request.method, status_code: 400, - error_code: "ERR_VALIDATION_INPUT" satisfies ErrorCode, + error_code: exchangeRoute + ? "ERR_VALIDATION_INPUT" satisfies ErrorCode + : "ERR_DISPATCH_REQUEST_INVALID" satisfies ErrorCode, outcome: "rejected", - policy: "exact-exchange-url", + policy: exchangeRoute ? "exact-exchange-url" : "exact-continuation-dispatch-url", })); } catch { // Logging must not convert a fail-closed input response into an exception. @@ -260,11 +287,13 @@ export default { const boundedRequest = canonicalTraceRequest(request); const url = new URL(boundedRequest.url); if ( - url.pathname === "/exchange" + (url.pathname === "/exchange" || url.pathname === "/v1/continuation-dispatches") && url.href !== `${url.origin}${url.pathname}` ) { - recordExchangeUrlFailure(boundedRequest); - return exchangeUrlResponse(boundedRequest); + recordExactUrlFailure(boundedRequest, url.pathname); + return url.pathname === "/exchange" + ? exchangeUrlResponse(boundedRequest) + : continuationDispatchUrlResponse(boundedRequest); } const runtimeEnv = runtimeCredentialEnv(env); if (url.pathname === "/ready") { diff --git a/src/runtime-readiness.ts b/src/runtime-readiness.ts index e1f8be0f1..83cd3fc23 100644 --- a/src/runtime-readiness.ts +++ b/src/runtime-readiness.ts @@ -29,7 +29,13 @@ export type RuntimeReadinessFailure = | "github_app_private_key" | "github_app_installation_id" | "noema_rate_limiter" - | "noema_oidc_replay_guard"; + | "noema_oidc_replay_guard" + | "continuation_dispatch_github_app_id" + | "continuation_dispatch_github_app_private_key" + | "continuation_dispatch_github_app_installation_id" + | "continuation_receipt_signing_private_key" + | "continuation_receipt_signing_key_id" + | "noema_continuation_dispatch_state"; /** * Environment values required to decide whether Noema can safely accept @@ -52,6 +58,12 @@ export interface RuntimeReadinessEnv { GITHUB_APP_INSTALLATION_ID?: string; NOEMA_RATE_LIMITER?: DurableObjectNamespace; NOEMA_OIDC_REPLAY_GUARD?: DurableObjectNamespace; + CONTINUATION_DISPATCH_GITHUB_APP_ID?: string; + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM?: string; + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID?: string; + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM?: string; + CONTINUATION_RECEIPT_SIGNING_KEY_ID?: string; + NOEMA_CONTINUATION_DISPATCH_STATE?: DurableObjectNamespace; } /** @@ -75,6 +87,14 @@ const privateKeyReadinessCache = new WeakMap< RuntimeReadinessEnv, PrivateKeyReadinessCacheEntry >(); +const continuationAppKeyReadinessCache = new WeakMap< + RuntimeReadinessEnv, + PrivateKeyReadinessCacheEntry +>(); +const continuationReceiptKeyReadinessCache = new WeakMap< + RuntimeReadinessEnv, + PrivateKeyReadinessCacheEntry +>(); /** * Admit only the canonical organization-owned reusable-workflow repository. @@ -224,6 +244,19 @@ async function isImportablePrivateKey(value: string | undefined): Promise { + try { + const match = privateKeyPattern.exec(value ?? ""); + if (!match) return false; + const binary = atob(match[1].replace(/\s+/gu, "")); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + await crypto.subtle.importKey("pkcs8", bytes, "Ed25519", false, ["sign"]); + return true; + } catch { + return false; + } +} + /** Reuse only an unchanged private-key import while other bindings stay live. */ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise { const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY_PEM; @@ -235,6 +268,19 @@ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise, + env: RuntimeReadinessEnv, + privateKeyPem: string | undefined, + importer: (value: string | undefined) => Promise, +): Promise { + const cached = cache.get(env); + if (cached && cached.privateKeyPem === privateKeyPem) return cached.importability; + const importability = importer(privateKeyPem); + cache.set(env, { privateKeyPem, importability }); + return importability; +} + /** * Evaluate the offline configuration required for credential-exchange traffic. * @@ -302,6 +348,34 @@ export async function evaluateRuntimeReadiness( if (!isDurableObjectNamespace(env.NOEMA_OIDC_REPLAY_GUARD)) { failedChecks.push("noema_oidc_replay_guard"); } + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_ID)) { + failedChecks.push("continuation_dispatch_github_app_id"); + } + if (!await cachedConfiguredKeyImportability( + continuationAppKeyReadinessCache, + env, + env.CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM, + isImportablePrivateKey, + )) { + failedChecks.push("continuation_dispatch_github_app_private_key"); + } + if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID)) { + failedChecks.push("continuation_dispatch_github_app_installation_id"); + } + if (!await cachedConfiguredKeyImportability( + continuationReceiptKeyReadinessCache, + env, + env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM, + isImportableEd25519PrivateKey, + )) { + failedChecks.push("continuation_receipt_signing_private_key"); + } + if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(env.CONTINUATION_RECEIPT_SIGNING_KEY_ID ?? "")) { + failedChecks.push("continuation_receipt_signing_key_id"); + } + if (!isDurableObjectNamespace(env.NOEMA_CONTINUATION_DISPATCH_STATE)) { + failedChecks.push("noema_continuation_dispatch_state"); + } return { ready: failedChecks.length === 0, diff --git a/src/worker.ts b/src/worker.ts index ec57e5eec..d6cdf7059 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -316,7 +316,10 @@ function withDistributedRateLimitHeaders( export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname !== "/exchange") { + if ( + url.pathname !== "/exchange" + && url.pathname !== "/v1/continuation-dispatches" + ) { return baseWorker.fetch(request, env); } @@ -407,7 +410,10 @@ export default { return withDistributedRateLimitHeaders(response, decision); } - if (response.headers.get("x-oidc-replay-protection") === "verified-before-mint") { + if ( + response.headers.get("x-oidc-replay-protection") === "verified-before-mint" + || response.headers.get("x-oidc-replay-protection") === "verified-before-dispatch" + ) { const headers = new Headers(response.headers); headers.set("x-oidc-replay-protection", "single-use"); return withDistributedRateLimitHeaders(new Response(response.body, { @@ -493,4 +499,4 @@ export default { headers, }), decision); }, -}; \ No newline at end of file +}; diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts new file mode 100644 index 000000000..5879d9ecf --- /dev/null +++ b/test/continuation-dispatch-worker.test.ts @@ -0,0 +1,299 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import baseWorker from "../src/index"; +import runtimeWorker from "../src/runtime-entrypoint"; +import protectedWorker from "../src/worker"; +import { + handleContinuationDispatch, + type ContinuationDispatchHandlerDependencies, +} from "../src/continuation-dispatch/handler"; +import { ContinuationDispatchStateConflict } from "../src/continuation-dispatch/dispatch-state"; +import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; + +const workflowSha = "1".repeat(40); +const headSha = "2".repeat(40); +const baseSha = "3".repeat(40); +const workflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const testJwt = [ + Buffer.from(JSON.stringify({ alg: "RS256", kid: "task-5" })).toString("base64url"), + Buffer.from(JSON.stringify({ sub: "task-5" })).toString("base64url"), + Buffer.from([0]).toString("base64url"), +].join("."); +const requestBody = { + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/noema", + pull_request_number: 736, + expected_head_sha: headSha, + expected_base_sha: baseSha, + expected_base_ref: "main", + transport_retry_attempt: 1, +} as const; + +let signingPrivateKeyPem = ""; +let signingPublicKeyPem = ""; + +function pem(label: "PRIVATE KEY" | "PUBLIC KEY", bytes: ArrayBuffer): string { + const base64 = Buffer.from(bytes).toString("base64"); + return `-----BEGIN ${label}-----\n${base64.match(/.{1,64}/g)?.join("\n")}\n-----END ${label}-----`; +} + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); + signingPrivateKeyPem = pem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", pair.privateKey)); + signingPublicKeyPem = pem("PUBLIC KEY", await crypto.subtle.exportKey("spki", pair.publicKey)); +}); + +function request(body: string = JSON.stringify(requestBody), init: RequestInit = {}): Request { + return new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "x-request-id": "trace-task-5", + ...(init.headers ?? {}), + }, + body, + ...init, + }); +} + +function harness() { + const calls: string[] = []; + let retained: Record | undefined; + const reservation = { + kind: "reserved" as const, + identity: "a".repeat(64), + digest: "a".repeat(64), + reservationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }; + const dependencies: ContinuationDispatchHandlerDependencies = { + verifyOidc: async () => ({ + repository: requestBody.source_repository, + job_workflow_ref: workflowRef, + job_workflow_sha: workflowSha, + iat: 1_800_000_000, + exp: 1_800_000_300, + jti: "task-5-jti", + }), + claimOidc: async () => { + calls.push("claim"); + return true; + }, + readLivePullRequest: async () => { + calls.push("live-pr"); + return { + repository: requestBody.source_repository, + pullRequestNumber: requestBody.pull_request_number, + headSha, + baseSha, + baseRef: "main", + }; + }, + reserve: async (_env, identity, digest) => { + calls.push("reserve"); + if (retained !== undefined) { + return { + kind: "replay" as const, + identity, + digest, + outcome: "accepted" as const, + receipt: retained, + }; + } + return { ...reservation, identity, digest }; + }, + dispatch: async () => { + calls.push("dispatch"); + return { outcome: "accepted" as const, upstreamStatus: 204, eventType: "noema-review" as const }; + }, + commit: async (_env, _reservation, receipt) => { + calls.push("commit"); + retained = receipt; + }, + }; + const env = { + ALLOWED_WORKFLOW_SHA: workflowSha, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: signingPrivateKeyPem, + CONTINUATION_RECEIPT_SIGNING_KEY_ID: "noema-continuation-2026-09", + } as never; + return { calls, dependencies, env }; +} + +describe("continuation dispatch public route", () => { + it("applies the distributed rate limit before dispatch authentication", async () => { + const limiterFetch = vi.fn(async () => Response.json({ + allowed: false, + limit: 60, + remaining: 0, + retry_after_seconds: 37, + })); + const limiter = { + idFromName: () => ({ toString: () => "continuation-client" }) as DurableObjectId, + get: () => ({ fetch: limiterFetch }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace; + + const response = await protectedWorker.fetch( + request(JSON.stringify(requestBody), { + headers: { + authorization: "Bearer invalid", + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.20", + }, + }), + { + NOEMA_RATE_LIMITER: limiter, + NOEMA_RATE_LIMIT_PER_MINUTE: "60", + } as never, + ); + + expect(limiterFetch).toHaveBeenCalledOnce(); + expect(response.status).toBe(429); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_RATE_LIMIT", + }); + }); + + it("rejects query authority on the exact public resource before runtime configuration", async () => { + const response = await runtimeWorker.fetch( + new Request("https://noema.example/v1/continuation-dispatches?authority=attacker"), + {} as never, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID", + }); + }); + + it("is admitted by the base worker instead of falling through to 404", async () => { + const response = await baseWorker.fetch( + new Request("https://noema.example/v1/continuation-dispatches"), + {} as never, + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REQUEST_INVALID", + }); + }); + + it("verifies, claims, checks live state, dispatches once, commits, and returns a verifiable receipt", async () => { + const { calls, dependencies, env } = harness(); + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { ok: true; data: { receipt: unknown }; trace_id: string }; + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("x-trace-id")).toBe("trace-task-5"); + expect(response.headers.get("x-oidc-replay-protection")).toBe("verified-before-dispatch"); + expect(payload.ok).toBe(true); + expect(payload.trace_id).toBe("trace-task-5"); + expect(calls).toEqual(["claim", "live-pr", "reserve", "dispatch", "commit"]); + await expect(verifyContinuationReceipt(payload.data.receipt, signingPublicKeyPem)).resolves.toBe(true); + expect(JSON.stringify(payload)).not.toMatch(/token|bearer|assertion|private_key/i); + }); + + it("returns the exact retained receipt on replay without a second dispatch", async () => { + const { calls, dependencies, env } = harness(); + const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const firstPayload = await first.json() as { data: { receipt: unknown } }; + const second = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const secondPayload = await second.json() as { data: { receipt: unknown } }; + + expect(second.status).toBe(200); + expect(second.headers.get("x-continuation-replay")).toBe("exact"); + expect(secondPayload.data.receipt).toEqual(firstPayload.data.receipt); + expect(calls.filter((call) => call === "dispatch")).toHaveLength(1); + }); + + it("fails a retained digest conflict closed before dispatch", async () => { + const { dependencies, env } = harness(); + dependencies.reserve = async () => { + throw new ContinuationDispatchStateConflict(); + }; + const dispatch = vi.spyOn(dependencies, "dispatch"); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_REPLAY_CONFLICT", + }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it.each(["verifyOidc", "claimOidc"] as const)( + "classifies unavailable %s infrastructure as upstream instead of invalid identity", + async (boundary) => { + const { dependencies, env } = harness(); + dependencies[boundary] = vi.fn(async () => { + throw { code: boundary === "verifyOidc" ? "ERR_OIDC_VERIFICATION" : "ERR_AUTH_REPLAY", status: 503 }; + }) as never; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + }); + }, + ); + + it("rejects a workflow SHA mismatch before replay claim or GitHub access", async () => { + const { calls, dependencies, env } = harness(); + dependencies.verifyOidc = async () => ({ + repository: requestBody.source_repository, + job_workflow_ref: workflowRef, + job_workflow_sha: "f".repeat(40), + iat: 1_800_000_000, + exp: 1_800_000_300, + jti: "wrong-workflow-sha", + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(403); + expect(calls).toEqual([]); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + }); + }); + + it.each([ + ["GET", undefined, 405], + ["POST", { "content-type": "text/plain" }, 415], + ])("rejects %s or invalid media type before OIDC verification", async (method, headers, status) => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const candidate = method === "GET" + ? new Request("https://noema.example/v1/continuation-dispatches", { method }) + : request(JSON.stringify(requestBody), { headers }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(verify).not.toHaveBeenCalled(); + }); + + it("rejects a declared or streamed body above 8 KiB before OIDC verification", async () => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const response = await handleContinuationDispatch( + request(`{"padding":"${"x".repeat(8_192)}"}`), + env, + "trace-task-5", + dependencies, + ); + + expect(response.status).toBe(413); + expect(verify).not.toHaveBeenCalled(); + }); +}); diff --git a/test/runtime-readiness-cache.test.ts b/test/runtime-readiness-cache.test.ts index 8d7509113..73e96c9ba 100644 --- a/test/runtime-readiness-cache.test.ts +++ b/test/runtime-readiness-cache.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMITER: dummyNamespace(), NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), @@ -67,7 +68,7 @@ describe("runtime-readiness private-key import cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(1); + expect(importKey).toHaveBeenCalledTimes(3); }); it("evaluates separate deployment environments independently", async () => { @@ -80,7 +81,7 @@ describe("runtime-readiness private-key import cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(2); + expect(importKey).toHaveBeenCalledTimes(6); }); it("re-evaluates non-key bindings when Cloudflare updates them in a reused isolate", async () => { @@ -97,7 +98,7 @@ describe("runtime-readiness private-key import cache", () => { error_code: "ERR_SERVICE_NOT_READY", details: { failed_checks: "github_app_id" }, }); - expect(importKey).toHaveBeenCalledTimes(1); + expect(importKey).toHaveBeenCalledTimes(3); }); it("imports a rotated private-key binding again in a reused isolate", async () => { @@ -110,6 +111,6 @@ describe("runtime-readiness private-key import cache", () => { expect(beforeRotation.status).toBe(200); expect(afterRotation.status).toBe(200); - expect(importKey).toHaveBeenCalledTimes(2); + expect(importKey).toHaveBeenCalledTimes(4); }); }); diff --git a/test/runtime-readiness-durable-bindings.test.ts b/test/runtime-readiness-durable-bindings.test.ts index a17e6a843..a73fe4800 100644 --- a/test/runtime-readiness-durable-bindings.test.ts +++ b/test/runtime-readiness-durable-bindings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -59,7 +60,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness-egress-capability.test.ts b/test/runtime-readiness-egress-capability.test.ts index 961f4602e..a47609eae 100644 --- a/test/runtime-readiness-egress-capability.test.ts +++ b/test/runtime-readiness-egress-capability.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -43,7 +44,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness-fixture.ts b/test/runtime-readiness-fixture.ts new file mode 100644 index 000000000..822f23d4a --- /dev/null +++ b/test/runtime-readiness-fixture.ts @@ -0,0 +1,29 @@ +async function privateKeyPem(algorithm: RsaHashedKeyGenParams | "Ed25519"): Promise { + const pair = await crypto.subtle.generateKey( + algorithm, + true, + ["sign", "verify"], + ); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + const base64 = Buffer.from(pkcs8).toString("base64"); + return `-----BEGIN PRIVATE KEY-----\n${base64.match(/.{1,64}/g)?.join("\n")}\n-----END PRIVATE KEY-----`; +} + +/** Supply complete, importable continuation bindings to readiness tests focused on another boundary. */ +export async function continuationReadyBindings(namespace: DurableObjectNamespace) { + const githubPrivateKey = await privateKeyPem({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }); + return { + GITHUB_APP_PRIVATE_KEY_PEM: githubPrivateKey, + CONTINUATION_DISPATCH_GITHUB_APP_ID: "223344", + CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM: githubPrivateKey, + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "445566", + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: await privateKeyPem("Ed25519"), + CONTINUATION_RECEIPT_SIGNING_KEY_ID: "noema-continuation-test", + NOEMA_CONTINUATION_DISPATCH_STATE: namespace, + }; +} diff --git a/test/runtime-readiness-missing-config.test.ts b/test/runtime-readiness-missing-config.test.ts index aece7afd8..8abdcfc8f 100644 --- a/test/runtime-readiness-missing-config.test.ts +++ b/test/runtime-readiness-missing-config.test.ts @@ -27,9 +27,15 @@ describe("Noema readiness with absent configuration", () => { "github_app_private_key", "noema_rate_limiter", "noema_oidc_replay_guard", + "continuation_dispatch_github_app_id", + "continuation_dispatch_github_app_private_key", + "continuation_dispatch_github_app_installation_id", + "continuation_receipt_signing_private_key", + "continuation_receipt_signing_key_id", + "noema_continuation_dispatch_state", ].join(","), ); - expect(payload.details.failed_checks).not.toContain("github_app_installation_id"); + expect(payload.details.failed_checks.split(",")).not.toContain("github_app_installation_id"); vi.restoreAllMocks(); }); diff --git a/test/runtime-readiness-ref-format.test.ts b/test/runtime-readiness-ref-format.test.ts index 3ddb1834d..5905f40da 100644 --- a/test/runtime-readiness-ref-format.test.ts +++ b/test/runtime-readiness-ref-format.test.ts @@ -3,6 +3,7 @@ import { evaluateRuntimeReadiness, type RuntimeReadinessEnv, } from "../src/runtime-readiness"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -45,7 +46,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMITER: dummyNamespace(), NOEMA_OIDC_REPLAY_GUARD: dummyNamespace(), diff --git a/test/runtime-readiness-trace-correlation.test.ts b/test/runtime-readiness-trace-correlation.test.ts index 81afe1358..bd1e5ae21 100644 --- a/test/runtime-readiness-trace-correlation.test.ts +++ b/test/runtime-readiness-trace-correlation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "17052a7ca3c16db90932a4d6036b43165ddee418", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), diff --git a/test/runtime-readiness.test.ts b/test/runtime-readiness.test.ts index b3292d6b1..134618a40 100644 --- a/test/runtime-readiness.test.ts +++ b/test/runtime-readiness.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import entrypoint, { type Env } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; function dummyNamespace(): DurableObjectNamespace { return { @@ -42,7 +43,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", - GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), + ...(await continuationReadyBindings(dummyNamespace())), GITHUB_APP_INSTALLATION_ID: "987654", NOEMA_RATE_LIMIT_PER_MINUTE: "60", NOEMA_RATE_LIMITER: dummyNamespace(), From 17b80d83de24d4b74354c4d90b11b1d347afacd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:03:58 +0900 Subject: [PATCH 12/36] docs(dispatch): publish broker contracts and release gates --- CHANGELOG.md | 3 +- README.md | 1 + docs/PRD.md | 4 +- docs/TRD.md | 11 ++- docs/adr/0019-continuation-dispatch-broker.md | 37 ++++++++ docs/adr/README.md | 1 + docs/api-spec.md | 10 +++ docs/api-stability-contract.md | 9 ++ docs/product-technical-gap-baseline.md | 4 + docs/runbook.md | 6 ++ ...2026-09-30-continuation-dispatch-broker.md | 6 +- docs/threat-model.md | 4 + openapi.json | 89 +++++++++++++++++++ src/continuation-dispatch/handler.ts | 7 +- ...ontinuation-dispatch-docs-contract.test.ts | 87 ++++++++++++++++++ test/github-app-private-key-envelope.test.ts | 8 +- test/openapi-contract.test.ts | 9 +- ...me-private-key-normalization-cache.test.ts | 10 ++- 18 files changed, 289 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0019-continuation-dispatch-broker.md create mode 100644 test/continuation-dispatch-docs-contract.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2aff108f..6a921f85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, terminal idempotency, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. It never returns GitHub credentials. Protected integration, immutable Noema release, and the later `ContextualWisdomLab/.github#2540` consumer pin remain required. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. - Protected #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`, integrated by GitHub-verified normal merge `4433c3009d4c6bc900bf5c8346f75b07185ff985`, hardens the Noema Policy / Approval procedural ledger without widening its authority. Complete retained approval-chain cryptographic verification and immutable next-event SHA-256 derivation occur before `DurableObjectStorage.transaction()`; append and exact replay then re-read current retained approval state inside the short transaction, require canonical root/event/dense-array structured-clone shape, compare the complete current retained structure with the exact preverified structure using field-by-field/`Object.is` equality, and perform only exact replay return or one durable write. Runtime-untrusted retained identity/digest values must already be canonical strings before regex/hash use, so coercible non-string lookalikes such as `BigInt` fail closed as Policy / Approval conflicts rather than becoming authority through JavaScript coercion. Hostile regressions cover transaction-active digest instrumentation, a legal interleaving winner, post-verification internal event drift, JSON-invisible unknown fields on append/replay, sparse or compensating arrays, coercible non-string digest/identity values, and event-array cardinality drift. Exact decision replay, approve/revoke state-machine rules, history binding, capacity, stale-writer rejection and `activationAuthorized:false` remain unchanged; this protected source/check/review/merge evidence is not an immutable release, deployed Durable Object recovery/contention result, production p95/heap evidence, graph publication, or activation authority. @@ -152,4 +153,4 @@ - `/exchange` 401 응답에 `WWW-Authenticate: Bearer realm="noema"` challenge를 추가하고 인증 누락은 `invalid_request`, 잘못된 토큰은 `invalid_token`으로 구분. - `x-request-id`/`x-correlation-id` 및 client IP 계열 헤더를 길이/문자 기준으로 제한해 로그 오염과 rate-limit key 폭주를 방지. - `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증. -- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. \ No newline at end of file +- Protected #695 normalizes synchronous reader acquisition on the contextual-orchestrator `/healthz` response consumer at exact source `c9d526093e543167645383e937096fd089b337f8`. If a locked or otherwise non-stream-readable `/healthz` response body makes `response.body.getReader()` throw synchronously, Noema fails closed with `contextual-orchestrator health response body is not stream-readable` instead of leaking a runtime-specific stream exception. The existing 65,536-byte ceiling, optional caller-supplied deadline with no invented default timeout, fixed retained buffer, best-effort cancellation, fatal UTF-8, duplicate-key/JSON admission, and reader-lock lifecycle after successful acquisition remain unchanged. Contextual-orchestrator service/provider/model routing, credentials, outbound authority, quarantine/security authority, release/deployment authority, and foreign-domain truth remain with their existing owners; protected source is not an immutable release or deployed availability/p95/recovery evidence. PR #695. diff --git a/README.md b/README.md index 99030df59..07121fa55 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The Cloudflare Worker exposes three intentionally distinct HTTP surfaces: | `GET` | `/health` | Process liveness only | | `GET` / `HEAD` | `/ready` | Runtime readiness without reflecting secrets | | `POST` | `/exchange` | Exchange an authorized GitHub Actions OIDC bearer for short-lived repository capability | +| `POST` | `/v1/continuation-dispatches` | Emit one exact Noema/Strix continuation and return a credential-free signed receipt | The public contract is published as [`openapi.json`](./openapi.json), with narrative details in [`docs/api-spec.md`](./docs/api-spec.md). diff --git a/docs/PRD.md b/docs/PRD.md index b3758e9bd..0619f68d8 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -56,7 +56,7 @@ Noema is an evidence-producing credential and maintenance control plane. Its run ### 4.1 Credential exchange -The Cloudflare Worker exposes `/health`, `/ready`, and `/exchange`. +The Cloudflare Worker exposes `/health`, `/ready`, `/exchange`, and the Proposed `/v1/continuation-dispatches` broker. The broker performs one fixed central continuation without returning `Contents: write` capability: it binds exact workflow/source/PR evidence, retains terminal idempotency state, and returns only a credential-free signed receipt. Immutable Noema release must precede the `ContextualWisdomLab/.github#2540` consumer pin. The observed protected branch point uses `ALLOWED_WORKFLOW_REF_PREFIX` as one **exact full workflow ref** despite the legacy variable name and pairs it with immutable `ALLOWED_WORKFLOW_SHA` trust. Cryptographic OIDC verification validates issuer, audience, repository identity, exact workflow ref, the immutable `job_workflow_sha` workflow-source identity, token time semantics, and replay requirements before GitHub App token exchange. `wrangler.toml` pins the trusted central `.github` workflow source commit; that dependency remains read-only from the Noema writer and any future central movement requires an explicit Noema roll-forward plus fresh evidence. @@ -120,7 +120,7 @@ Procedural graph ownership follows existing CWL boundaries. Any released cross-s | ID | Requirement | | --- | --- | -| FR-001 | `/health`, `/ready`, and `/exchange` retain distinct liveness, readiness, and credential-exchange semantics. | +| FR-001 | `/health`, `/ready`, `/exchange`, and `/v1/continuation-dispatches` retain distinct liveness, readiness, credential-exchange, and fixed-continuation semantics. | | FR-002 | Validate OIDC issuer, audience, repository/organization, the configured exact full workflow ref, and immutable configured workflow-source SHA before credential minting. | | FR-003 | Treat workflow-ref/SHA configuration as exact authority and fail closed on absent, malformed, mismatched, stale, or non-canonical trust identity; do not promote a central source movement without explicit Noema roll-forward evidence. | | FR-004 | Restrict credential-bearing GitHub/OIDC requests by reviewed origin, path, method, redirect, timeout, and bounded body/response behavior. | diff --git a/docs/TRD.md b/docs/TRD.md index 064f5898a..215d1c654 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -27,12 +27,19 @@ src/runtime-entrypoint.ts ├─ replay protection → NoemaOidcReplayGuard └─ src/index.ts ├─ /health - └─ /exchange core OIDC + GitHub App protocol + ├─ /exchange core OIDC + GitHub App protocol + └─ /v1/continuation-dispatches → live PR ACL → SQLite idempotency → fixed dispatch → Ed25519 receipt ``` 자세한 구현과 route ownership은 `ARCHITECTURE.md`, `docs/api-spec.md`를 따릅니다. -### 2.1 `/exchange` inbound body deadline +### 2.1 Continuation dispatch broker + +`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Closed request와 live PR을 검증한 뒤 `NOEMA_CONTINUATION_DISPATCH_STATE`의 identity-scoped SQLite transaction으로 reserve하고, 별도 central App으로 fixed `ContextualWisdomLab/.github` event만 호출하며, terminal accepted/denied/indeterminate receipt를 commit한다. 응답에는 GitHub credential이 없다. + +Required bindings: `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, `NOEMA_CONTINUATION_DISPATCH_STATE`. + +### 2.2 `/exchange` inbound body deadline `POST /exchange`의 JSON body는 UTF-8 wire bytes 기준 최대 **8,192 bytes**이고, body read가 시작된 뒤 전체 stream은 **10,000 ms의 절대 wall-clock deadline** 안에 완료되어야 합니다. 작은 chunk를 반복해서 보내더라도 deadline은 재설정되지 않습니다. 제한시간을 넘긴 incomplete stream은 best-effort로 취소하고 **HTTP 408**의 Noema 표준 JSON error envelope로 실패-폐쇄하며, 이 경계는 distributed rate-limit delegation, OIDC/JWKS 검증, GitHub App private-key 사용과 GitHub API 호출보다 앞에서 적용됩니다. diff --git a/docs/adr/0019-continuation-dispatch-broker.md b/docs/adr/0019-continuation-dispatch-broker.md new file mode 100644 index 000000000..c1bbd8894 --- /dev/null +++ b/docs/adr/0019-continuation-dispatch-broker.md @@ -0,0 +1,37 @@ +# ADR 0019: Continuation dispatch broker + +Status: Proposed + +## Problem and constraints + +GitHub repository dispatch requires `Contents: write`; a consumer-visible “dispatch-only” installation token is therefore not least privilege. Noema/Strix continuations must cross from an exact source PR to `ContextualWisdomLab/.github` without exposing that mutation authority. Mutable refs, stale PR snapshots, replay, caller-selected events, duplicate effects, and uncertain network results must fail closed. + +## Decision + +Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, rereads the live PR, reserves a canonical RFC 8785 request digest in identity-scoped SQLite Durable Object state, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. It commits accepted, denied, or indeterminate as terminal before returning an Ed25519-signed credential-free receipt. Exact replay returns retained evidence without another dispatch. + +The existing `/exchange` remains independent. `.github` owns the consumer workflow and event handler; Noema owns identity, idempotency, fixed dispatch, and receipt evidence only. Model verdict, formal review, merge, release, deployment, and product-domain truth remain separate authorities. + +## Alternatives rejected + +- Return a central installation token: rejected because `Contents: write` enables broader mutations. +- Consumer PAT/App secret: rejected for long-lived and cross-repository blast radius. +- Caller-selected target URL/event: rejected as an authority and SSRF boundary violation. +- Automatic retry after timeout/5xx: rejected because the external effect may already exist. +- Extend `/exchange`: rejected because credential delivery and fixed server-side dispatch have different response/idempotency contracts. + +## Effects and operational scenes + +Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. Public verification keys remain available by immutable release/key id; private material never appears in receipts or logs. + +## Acceptance and release order + +This ADR stays Proposed until exact-head tests/security/SBOM/provenance, independent review, protected integration, immutable Noema release, and deployment/recovery evidence exist. The immutable Noema release must be published before `ContextualWisdomLab/.github#2540` pins the endpoint/schema and removes its legacy exchange-plus-local-dispatch sequence. A real same-head continuation and fresh model verdict are downstream acceptance. + +## References + +Cloudflare. (2026). *Web Crypto*. Cloudflare Workers documentation. + +GitHub. (2026). *Configuring OpenID Connect in cloud providers*; *Create a repository dispatch event*. GitHub Docs. + +Rundgren, A., Jordan, B., & Erdtman, S. (2020). JSON Canonicalization Scheme (JCS) (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785 diff --git a/docs/adr/README.md b/docs/adr/README.md index ebb552cb6..255b40790 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ ADR은 **왜 이 구조를 선택했는지**를 기록합니다. 구현 상태 | [0015](./0015-external-extension-admission.md) | Proposed | Claude community plugin은 exact commit/path/digest와 별도 scan 영수증으로만 승인하고, 마켓플레이스 설치·Anthropic 리뷰·제품 런타임 래퍼는 실패-폐쇄한다. | | [0016](./0016-avoid-repository-git-conversion-execution-in-acquisition-preflight.md) | Proposed | acquisition preflight는 repository-owned Git clean/process conversion helper를 실행하지 않고 immutable exact-tree와 descriptor-bound raw bytes로 checkout identity를 검증한다. | | [0017](./0017-procedural-graph-guidance.md) | Proposed | procedural graph는 execution-pinned advisory context와 held-out non-regression screening만 제공하며 tool·policy·approval·activation authority를 부여하지 않는다. | +| [0019](./0019-continuation-dispatch-broker.md) | Proposed | Noema performs fixed central continuation and returns only a credential-free signed receipt. | ## ADR lifecycle diff --git a/docs/api-spec.md b/docs/api-spec.md index 1a69a3cc6..9c83d2ed7 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -50,6 +50,16 @@ - 상세: `docs/runtime-readiness.md` ### `POST /exchange` + +### `POST /v1/continuation-dispatches` + +`noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. + +성공 응답은 `{ ok: true, data: { receipt }, trace_id }`이다. Receipt contains no token and no GitHub credential. RFC 8785 canonical receipt bytes의 detached Ed25519 signature는 문서화된 SPKI public key로 검증한다. 안정 오류 코드는 `ERR_DISPATCH_REQUEST_INVALID`, `ERR_DISPATCH_IDENTITY_DENIED`, `ERR_DISPATCH_LIVE_STATE_STALE`, `ERR_DISPATCH_REPLAY_CONFLICT`, `ERR_GITHUB_DISPATCH_AUTHORIZATION`, `ERR_GITHUB_DISPATCH_UPSTREAM`이다. + +Primary references: GitHub. (2026). *OpenID Connect reference*; GitHub. (2026). *Create a repository dispatch event*; Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785; Cloudflare. (2026). *Web Crypto*. + +### `/exchange` request contract 헤더: - `authorization: Bearer ` - `content-type: application/json` (선택) diff --git a/docs/api-stability-contract.md b/docs/api-stability-contract.md index 405fd3cd1..d7e58cf0c 100644 --- a/docs/api-stability-contract.md +++ b/docs/api-stability-contract.md @@ -47,6 +47,15 @@ GitHub installation token 응답의 `token`/`expires_at` 결함은 `ERR_GITHUB_I ## 에러 코드 표준 +`POST /v1/continuation-dispatches`는 credential-free signed receipt만 반환하는 additive v1 surface다. Closed request, fixed action/event mapping, 8,192-byte ceiling, exact replay, and no-GitHub-credential response are compatibility commitments. + +- `ERR_DISPATCH_REQUEST_INVALID` +- `ERR_DISPATCH_IDENTITY_DENIED` +- `ERR_DISPATCH_LIVE_STATE_STALE` +- `ERR_DISPATCH_REPLAY_CONFLICT` +- `ERR_GITHUB_DISPATCH_AUTHORIZATION` +- `ERR_GITHUB_DISPATCH_UPSTREAM` + - `ERR_VALIDATION_INPUT` - `ERR_AUTH_MISSING` - `ERR_AUTH_INVALID` diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 335279780..15b5249eb 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,9 @@ # Noema Product and Technical Gap Baseline +## Proposed continuation dispatch broker (#735/#736) + +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–5 cover closed request/digest, SQLite terminal idempotency, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, replay, readiness, and stable errors. Status remains Proposed until contract publication, exact-head hosted review/gates, protected integration, immutable Noema release, deployment/recovery evidence, and downstream `ContextualWisdomLab/.github#2540` pin. + ## Authority and update rule 이 문서는 protected source, active candidate, transient workflow evidence와 foreign-owner authority를 분리한다. Open PR exact head, protected base, required workflow, review thread, release와 central dependency는 mutation·merge·release 직전에 다시 읽는다. predecessor GREEN, queued/pending/in_progress/skipped/cancelled run, 오래된 PR base snapshot과 scanner/model judgement는 다음 revision의 merge authority로 전용하지 않는다. queued는 GREEN이 아니다. diff --git a/docs/runbook.md b/docs/runbook.md index edfe249e9..5baaa1a4d 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -5,6 +5,12 @@ - `trace_id`를 기준으로 요청, 에러, 장애 대응을 상호 연결합니다. - 토큰 응답/비밀번호/비밀키를 절대로 로그에 출력하지 않습니다. +## Continuation dispatch configuration + +`POST /v1/continuation-dispatches` requires `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Never place a secret value in logs, issues, receipts, or source. Rotate central-App and receipt keys independently and retain the public receipt key by key id. + +For `ERR_DISPATCH_REPLAY_CONFLICT` or indeterminate evidence, inspect retained state and never blind-retry. For `ERR_GITHUB_DISPATCH_AUTHORIZATION`, repair the central App installation/permission and use a fresh identity. For `ERR_GITHUB_DISPATCH_UPSTREAM`, reconcile GitHub outcome before a new logical request. + ## 장애 대응 우선순위 ### 1) `/exchange` 5xx 급증 diff --git a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md index 545f74ad7..abcfaa960 100644 --- a/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md +++ b/docs/superpowers/plans/2026-09-30-continuation-dispatch-broker.md @@ -136,9 +136,9 @@ **Interfaces:** - Publishes: v1 OpenAPI schema, public receipt verification contract, configuration/runbook, and immutable release prerequisites. -- [ ] Write RED docs-contract tests for endpoint/schema/error codes, required secret/DO bindings, no-token response, ADR status Proposed, and downstream `.github#2540` release order. -- [ ] Run the docs-contract test; expect missing-contract failures. -- [ ] Update every listed contract with the exact design and primary-source references; keep the ADR Proposed until protected integration. +- [x] Write RED docs-contract tests for endpoint/schema/error codes, required secret/DO bindings, no-token response, ADR status Proposed, and downstream `.github#2540` release order. +- [x] Run the docs-contract test; expect missing-contract failures. +- [x] Update every listed contract with the exact design and primary-source references; keep the ADR Proposed until protected integration. - [ ] Run docs tests, typecheck, focused broker tests, `corepack npm test`, `corepack npm run security:scan`, and `corepack npm run release:verify`. - [ ] Record the two pre-existing local baseline failures verbatim if they remain; do not filter, downgrade, or call the full suite GREEN. - [ ] Commit `docs(dispatch): publish broker contracts and release gates`. diff --git a/docs/threat-model.md b/docs/threat-model.md index 98d23d2d2..668b08ff7 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -7,6 +7,10 @@ - 감사 로그(trace_id, 에러 코드, 레이턴시) - Cloudflare Worker isolate의 128MB 메모리와 요청 처리 가용성 +## Continuation dispatch boundary + +`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. + ## 위협 1. 위조된 OIDC 토큰으로 허가되지 않은 토큰 발급 시도 2. JWT 페이로드 위변조 또는 만료 토큰 재사용 diff --git a/openapi.json b/openapi.json index d92ed2f06..b84617df5 100644 --- a/openapi.json +++ b/openapi.json @@ -153,6 +153,33 @@ "503": { "$ref": "#/components/responses/ServiceUnavailable" } } } + }, + "/v1/continuation-dispatches": { + "post": { + "tags": ["Exchange"], + "operationId": "createContinuationDispatch", + "summary": "Dispatch one exact allowlisted continuation", + "description": "Accepts only noema.continuation-dispatch.v1. Validates the exact GitHub Actions OIDC workflow SHA, live pull request state, distributed single-use identity, and durable idempotency reservation before emitting one fixed central event. The credential-free response contains only an Ed25519-signed receipt. Stable failures are ERR_DISPATCH_REQUEST_INVALID, ERR_DISPATCH_IDENTITY_DENIED, ERR_DISPATCH_LIVE_STATE_STALE, ERR_DISPATCH_REPLAY_CONFLICT, ERR_GITHUB_DISPATCH_AUTHORIZATION, and ERR_GITHUB_DISPATCH_UPSTREAM.", + "security": [{ "githubActionsOidc": [] }], + "x-request-body-limit-bytes": 8192, + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchRequest" } } } + }, + "responses": { + "200": { "description": "Accepted dispatch or exact replay evidence.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchSuccess" } } } }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "description": "Identity is missing, invalid, or already consumed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "405": { "description": "Only POST is admitted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "409": { "description": "Live state, in-progress state, or retained identity conflicts.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, + "429": { "description": "Distributed rate limit exceeded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "502": { "$ref": "#/components/responses/BadGateway" }, + "503": { "$ref": "#/components/responses/ServiceUnavailable" } + } + } } }, "components": { @@ -283,6 +310,68 @@ "trace_id": { "type": "string" } } }, + "ContinuationDispatchRequest": { + "type": "object", + "additionalProperties": false, + "required": ["contract_version", "dispatch_action", "central_repository", "source_repository", "pull_request_number", "expected_head_sha", "expected_base_sha", "expected_base_ref", "transport_retry_attempt"], + "properties": { + "contract_version": { "type": "string", "const": "noema.continuation-dispatch.v1" }, + "dispatch_action": { "type": "string", "enum": ["noema_review_continuation", "strix_scan_continuation"] }, + "central_repository": { "type": "string", "const": "ContextualWisdomLab/.github" }, + "source_repository": { "$ref": "#/components/schemas/RepositoryLocator" }, + "pull_request_number": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "expected_head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "expected_base_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "expected_base_ref": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "transport_retry_attempt": { "type": "integer", "enum": [1, 2] } + } + }, + "SignedContinuationReceipt": { + "type": "object", + "additionalProperties": false, + "required": ["receipt_version", "contract_version", "algorithm", "key_id", "receipt_id", "trace_id", "request_digest", "idempotency_identity", "workflow_ref", "workflow_sha", "source_repository", "pull_request_number", "source_head_sha", "source_base_sha", "source_base_ref", "central_repository", "event_type", "emitted_payload_digest", "transport_retry_attempt", "outcome", "upstream_status", "issued_at", "expires_at", "signature"], + "properties": { + "receipt_version": { "type": "string", "const": "noema.continuation-receipt.v1" }, + "contract_version": { "type": "string", "const": "noema.continuation-dispatch.v1" }, + "algorithm": { "type": "string", "const": "Ed25519" }, + "key_id": { "type": "string" }, + "receipt_id": { "type": "string", "format": "uuid" }, + "trace_id": { "type": "string" }, + "request_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "idempotency_identity": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "workflow_ref": { "type": "string" }, + "workflow_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_repository": { "$ref": "#/components/schemas/RepositoryLocator" }, + "pull_request_number": { "type": "integer", "minimum": 1 }, + "source_head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_base_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_base_ref": { "type": "string" }, + "central_repository": { "type": "string", "const": "ContextualWisdomLab/.github" }, + "event_type": { "type": "string", "enum": ["noema-review", "strix-scan"] }, + "emitted_payload_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "transport_retry_attempt": { "type": "integer", "enum": [1, 2] }, + "outcome": { "type": "string", "enum": ["accepted", "denied", "indeterminate"] }, + "upstream_status": { "type": ["integer", "null"] }, + "issued_at": { "type": "integer" }, + "expires_at": { "type": "integer" }, + "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" } + } + }, + "ContinuationDispatchSuccess": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "data", "trace_id"], + "properties": { + "ok": { "type": "boolean", "const": true }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["receipt"], + "properties": { "receipt": { "$ref": "#/components/schemas/SignedContinuationReceipt" } } + }, + "trace_id": { "type": "string" } + } + }, "ErrorResponse": { "type": "object", "required": ["ok", "error_code", "message", "trace_id"], diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index eea5aeaad..04e828672 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -33,7 +33,7 @@ import { const MAX_BODY_BYTES = 8_192; const JSON_MEDIA_TYPE = /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu; -/** Runtime authority required by the public continuation-dispatch handler. */ +/** Runtime authority required by the public continuation-dispatch handler, including state, GitHub adapters, receipt signing, and exact workflow revision. */ export interface ContinuationDispatchHandlerEnv extends ContinuationDispatchStateEnv, ContinuationGitHubAdapterEnv, @@ -229,6 +229,11 @@ function workflowIdentity(claims: JwtPayload, env: ContinuationDispatchHandlerEn /** * Executes one versioned, credential-free continuation dispatch from bounded request through signed evidence. * Exact replays return retained evidence and all uncertain external outcomes become terminal before responding. + * @param request Bounded public HTTP request whose method, media type, bearer, and closed JSON body are admitted here. + * @param env Typed Worker bindings for exact workflow trust, GitHub adapters, durable state, and receipt signing. + * @param traceId Non-secret canonical request correlation identifier. + * @param dependencies Injected verification, state, and dispatch boundaries used by production and tests. + * @returns A standard no-store JSON response containing either one signed receipt or a stable dispatch error. */ export async function handleContinuationDispatch( request: Request, diff --git a/test/continuation-dispatch-docs-contract.test.ts b/test/continuation-dispatch-docs-contract.test.ts new file mode 100644 index 000000000..f05186a42 --- /dev/null +++ b/test/continuation-dispatch-docs-contract.test.ts @@ -0,0 +1,87 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function text(path: string): string { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +const requiredDocs = [ + "README.md", + "CHANGELOG.md", + "docs/api-spec.md", + "docs/api-stability-contract.md", + "docs/PRD.md", + "docs/TRD.md", + "docs/threat-model.md", + "docs/runbook.md", + "docs/product-technical-gap-baseline.md", +] as const; + +describe("continuation dispatch publication contracts", () => { + it("publishes a closed, credential-free OpenAPI operation", () => { + const document = JSON.parse(text("openapi.json")) as { + paths?: Record }>; + components?: { schemas?: Record }; + }; + const operation = document.paths?.["/v1/continuation-dispatches"]?.post; + expect(operation).toMatchObject({ + operationId: "createContinuationDispatch", + security: [{ githubActionsOidc: [] }], + "x-request-body-limit-bytes": 8192, + }); + expect(JSON.stringify(operation)).toContain("noema.continuation-dispatch.v1"); + expect(JSON.stringify(operation)).toContain("ERR_DISPATCH_REPLAY_CONFLICT"); + expect(JSON.stringify(operation)).not.toMatch(/installation[_ -]?token|access[_ -]?token/i); + expect(document.components?.schemas).toHaveProperty("ContinuationDispatchRequest"); + expect(document.components?.schemas).toHaveProperty("SignedContinuationReceipt"); + }); + + it("publishes the endpoint and all six stable dispatch errors across buyer contracts", () => { + const api = text("docs/api-spec.md"); + const stability = text("docs/api-stability-contract.md"); + for (const code of [ + "ERR_DISPATCH_REQUEST_INVALID", + "ERR_DISPATCH_IDENTITY_DENIED", + "ERR_DISPATCH_LIVE_STATE_STALE", + "ERR_DISPATCH_REPLAY_CONFLICT", + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + "ERR_GITHUB_DISPATCH_UPSTREAM", + ]) { + expect(api).toContain(code); + expect(stability).toContain(code); + } + for (const path of requiredDocs) { + expect(text(path), path).toContain("/v1/continuation-dispatches"); + } + }); + + it("documents every required runtime binding without publishing secret values", () => { + const runbook = text("docs/runbook.md"); + for (const binding of [ + "CONTINUATION_DISPATCH_GITHUB_APP_ID", + "CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM", + "CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID", + "CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM", + "CONTINUATION_RECEIPT_SIGNING_KEY_ID", + "NOEMA_CONTINUATION_DISPATCH_STATE", + ]) { + expect(runbook).toContain(binding); + } + expect(runbook).toContain("secret value"); + }); + + it("keeps ADR 0019 Proposed and release-before-consumer order explicit", () => { + const adr = text("docs/adr/0019-continuation-dispatch-broker.md"); + expect(adr).toMatch(/Status:\s*Proposed/u); + expect(adr).toContain("ContextualWisdomLab/.github#2540"); + expect(adr).toMatch(/immutable Noema release[\s\S]*ContextualWisdomLab\/.github#2540/u); + expect(text("docs/adr/README.md")).toContain("0019-continuation-dispatch-broker.md"); + }); + + it("states that receipts contain no GitHub credential or bearer", () => { + const api = text("docs/api-spec.md"); + expect(api).toMatch(/receipt[\s\S]{0,500}(?:no token|no GitHub credential|credential-free)/iu); + expect(api).toContain("Ed25519"); + expect(api).toContain("RFC 8785"); + }); +}); diff --git a/test/github-app-private-key-envelope.test.ts b/test/github-app-private-key-envelope.test.ts index cbc29d211..0cd22fb24 100644 --- a/test/github-app-private-key-envelope.test.ts +++ b/test/github-app-private-key-envelope.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import worker, { type Env } from "../src/index"; import { normalizeGitHubAppPrivateKeyPem } from "../src/github-app-private-key"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; @@ -107,12 +108,13 @@ function acceptingReplayGuard(): DurableObjectNamespace { } as unknown as DurableObjectNamespace; } -function readinessEnv(privateKeyPem: string): RuntimeEnv { +async function readinessEnv(privateKeyPem: string): Promise { const namespace = { idFromName: vi.fn(), get: vi.fn(), } as unknown as DurableObjectNamespace; return { + ...await continuationReadyBindings(namespace), ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", ALLOWED_AUDIENCE: "cwl-noema-review", ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", @@ -194,7 +196,7 @@ describe("GitHub App private-key authority", () => { it("makes the production runtime ready with the PKCS#1 RSA key format downloaded from GitHub Apps", async () => { const response = await runtimeWorker.fetch( new Request("https://noema.example/ready"), - readinessEnv(appPrivateKeyPkcs1Pem), + await readinessEnv(appPrivateKeyPkcs1Pem), ); expect(response.status).toBe(200); @@ -210,7 +212,7 @@ describe("GitHub App private-key authority", () => { it("makes the production runtime ready with a canonical PKCS#8 key ending in one newline", async () => { const response = await runtimeWorker.fetch( new Request("https://noema.example/ready"), - readinessEnv(`${appPrivateKeyPem}\n`), + await readinessEnv(`${appPrivateKeyPem}\n`), ); expect(response.status).toBe(200); diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index 8e9b50359..e38bc048f 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -41,7 +41,12 @@ describe("machine-readable public HTTP contract", () => { expect(spec.openapi).toBe("3.1.0"); expect(spec.info).toMatchObject({ title: "Noema API", version: "0.1.0" }); - expect(Object.keys(spec.paths).sort()).toEqual(["/exchange", "/health", "/ready"]); + expect(Object.keys(spec.paths).sort()).toEqual([ + "/exchange", + "/health", + "/ready", + "/v1/continuation-dispatches", + ]); expect(spec.paths["/health"].get.responses["200"]).toBeDefined(); expect(spec.paths["/ready"].get.responses["200"]).toBeDefined(); expect(spec.paths["/ready"].get.responses["503"]).toBeDefined(); @@ -59,6 +64,8 @@ describe("machine-readable public HTTP contract", () => { expect(spec.paths["/exchange"].post.responses["500"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["502"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["503"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["200"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["409"]).toBeDefined(); }); it("keeps authentication and bounded-input semantics explicit", async () => { diff --git a/test/runtime-private-key-normalization-cache.test.ts b/test/runtime-private-key-normalization-cache.test.ts index c85a8dc75..212017e69 100644 --- a/test/runtime-private-key-normalization-cache.test.ts +++ b/test/runtime-private-key-normalization-cache.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; let appPrivateKeyPem: string; @@ -24,12 +25,13 @@ beforeAll(async () => { appPrivateKeyPem = pemFromPkcs8(pkcs8); }); -function readinessEnv(privateKeyPem: string): RuntimeEnv { +async function readinessEnv(privateKeyPem: string): Promise { const namespace = { idFromName: vi.fn(), get: vi.fn(), } as unknown as DurableObjectNamespace; return { + ...await continuationReadyBindings(namespace), ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", ALLOWED_AUDIENCE: "cwl-noema-review", ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", @@ -48,7 +50,7 @@ function readinessEnv(privateKeyPem: string): RuntimeEnv { describe("runtime private-key normalization cache", () => { it("reuses the WebCrypto import decision for an unchanged newline-terminated secret", async () => { - const env = readinessEnv(`${appPrivateKeyPem}\n`); + const env = await readinessEnv(`${appPrivateKeyPem}\n`); const importSpy = vi.spyOn(crypto.subtle, "importKey"); const first = await runtimeWorker.fetch( @@ -62,11 +64,11 @@ describe("runtime private-key normalization cache", () => { expect(first.status).toBe(200); expect(second.status).toBe(200); - expect(importSpy).toHaveBeenCalledTimes(1); + expect(importSpy).toHaveBeenCalledTimes(3); }); it("does not retain a withdrawn binding in the cached normalized environment", async () => { - const env = readinessEnv(`${appPrivateKeyPem}\n`); + const env = await readinessEnv(`${appPrivateKeyPem}\n`); const first = await runtimeWorker.fetch( new Request("https://noema.example/ready"), From c87db11a2c1dc27e366bcefe5761c389ce865f6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:04:16 +0900 Subject: [PATCH 13/36] fix(dispatch): serialize retries and preserve terminal evidence --- CHANGELOG.md | 2 +- docs/TRD.md | 2 +- docs/adr/0019-continuation-dispatch-broker.md | 4 +- docs/api-spec.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- ...-30-continuation-dispatch-broker-design.md | 18 +- docs/threat-model.md | 2 +- openapi.json | 2 +- src/continuation-dispatch/contract.ts | 43 +++- src/continuation-dispatch/dispatch-state.ts | 202 +++++++++++++++++- src/continuation-dispatch/handler.ts | 151 ++++++++++--- test/continuation-dispatch-contract.test.ts | 16 ++ ...ontinuation-dispatch-docs-contract.test.ts | 7 + test/continuation-dispatch-state.test.ts | 48 +++++ test/continuation-dispatch-worker.test.ts | 114 +++++++++- 15 files changed, 562 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a921f85b..b0dcfa6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased -- Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, terminal idempotency, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. It never returns GitHub credentials. Protected integration, immutable Noema release, and the later `ContextualWisdomLab/.github#2540` consumer pin remain required. +- Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. - Protected #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`, integrated by GitHub-verified normal merge `4433c3009d4c6bc900bf5c8346f75b07185ff985`, hardens the Noema Policy / Approval procedural ledger without widening its authority. Complete retained approval-chain cryptographic verification and immutable next-event SHA-256 derivation occur before `DurableObjectStorage.transaction()`; append and exact replay then re-read current retained approval state inside the short transaction, require canonical root/event/dense-array structured-clone shape, compare the complete current retained structure with the exact preverified structure using field-by-field/`Object.is` equality, and perform only exact replay return or one durable write. Runtime-untrusted retained identity/digest values must already be canonical strings before regex/hash use, so coercible non-string lookalikes such as `BigInt` fail closed as Policy / Approval conflicts rather than becoming authority through JavaScript coercion. Hostile regressions cover transaction-active digest instrumentation, a legal interleaving winner, post-verification internal event drift, JSON-invisible unknown fields on append/replay, sparse or compensating arrays, coercible non-string digest/identity values, and event-array cardinality drift. Exact decision replay, approve/revoke state-machine rules, history binding, capacity, stale-writer rejection and `activationAuthorized:false` remain unchanged; this protected source/check/review/merge evidence is not an immutable release, deployed Durable Object recovery/contention result, production p95/heap evidence, graph publication, or activation authority. diff --git a/docs/TRD.md b/docs/TRD.md index 215d1c654..6de227dec 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -35,7 +35,7 @@ src/runtime-entrypoint.ts ### 2.1 Continuation dispatch broker -`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Closed request와 live PR을 검증한 뒤 `NOEMA_CONTINUATION_DISPATCH_STATE`의 identity-scoped SQLite transaction으로 reserve하고, 별도 central App으로 fixed `ContextualWisdomLab/.github` event만 호출하며, terminal accepted/denied/indeterminate receipt를 commit한다. 응답에는 GitHub credential이 없다. +`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Transport retry metadata를 제외한 logical-effect identity로 `NOEMA_CONTINUATION_DISPATCH_STATE`의 SQLite owner를 고르되 complete request digest는 retry attempt를 포함한다. Exact replay는 live PR 재조회보다 먼저 retained receipt를 반환하며, fresh reservation만 live PR을 검증한다. 외부 dispatch 전에 signed `indeterminate` evidence를 durable commit하고, 204/denial 뒤에는 같은 receipt authority를 accepted/denied로만 강화한다. 따라서 final commit 실패나 crash는 두 번째 dispatch authority를 만들지 않는다. 별도 central App은 fixed `ContextualWisdomLab/.github` event만 호출하고 응답에는 GitHub credential이 없다. Required bindings: `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, `NOEMA_CONTINUATION_DISPATCH_STATE`. diff --git a/docs/adr/0019-continuation-dispatch-broker.md b/docs/adr/0019-continuation-dispatch-broker.md index c1bbd8894..4718c87ac 100644 --- a/docs/adr/0019-continuation-dispatch-broker.md +++ b/docs/adr/0019-continuation-dispatch-broker.md @@ -8,7 +8,7 @@ GitHub repository dispatch requires `Contents: write`; a consumer-visible “dis ## Decision -Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, rereads the live PR, reserves a canonical RFC 8785 request digest in identity-scoped SQLite Durable Object state, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. It commits accepted, denied, or indeterminate as terminal before returning an Ed25519-signed credential-free receipt. Exact replay returns retained evidence without another dispatch. +Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, serializes transport attempts through one logical-effect identity, rereads the live PR after reservation, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. Before the external call it commits a signed indeterminate receipt; accepted or denied evidence may strengthen that same retained authority afterward, while a failed final commit leaves replayable indeterminate evidence and never grants another dispatch. Exact replay returns retained evidence without another live-state read or dispatch. The existing `/exchange` remains independent. `.github` owns the consumer workflow and event handler; Noema owns identity, idempotency, fixed dispatch, and receipt evidence only. Model verdict, formal review, merge, release, deployment, and product-domain truth remain separate authorities. @@ -22,7 +22,7 @@ The existing `/exchange` remains independent. `.github` owns the consumer workfl ## Effects and operational scenes -Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. Public verification keys remain available by immutable release/key id; private material never appears in receipts or logs. +Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. The current Draft does not publish a verification key. Release acceptance requires an immutable `key_id` to SPKI artifact plus digest mapping before any offline-verification claim; private material never appears in receipts or logs. ## Acceptance and release order diff --git a/docs/api-spec.md b/docs/api-spec.md index 9c83d2ed7..36e0ffe7c 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -55,7 +55,7 @@ `noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. -성공 응답은 `{ ok: true, data: { receipt }, trace_id }`이다. Receipt contains no token and no GitHub credential. RFC 8785 canonical receipt bytes의 detached Ed25519 signature는 문서화된 SPKI public key로 검증한다. 안정 오류 코드는 `ERR_DISPATCH_REQUEST_INVALID`, `ERR_DISPATCH_IDENTITY_DENIED`, `ERR_DISPATCH_LIVE_STATE_STALE`, `ERR_DISPATCH_REPLAY_CONFLICT`, `ERR_GITHUB_DISPATCH_AUTHORIZATION`, `ERR_GITHUB_DISPATCH_UPSTREAM`이다. +성공 응답은 `{ ok: true, data: { receipt }, trace_id }`이다. Receipt contains no token and no GitHub credential. `receipt_version`은 `noema.continuation-dispatch-receipt.v1`이다. Detached Ed25519 signature의 offline verification은 immutable release가 `key_id`에 대응하는 SPKI public-key artifact와 digest를 게시한 뒤에만 소비자 acceptance evidence가 된다. 이 Draft source에는 공개키나 retrieval URL이 아직 없으므로 현재 revision은 offline verification 가능성을 주장하지 않는다. 안정 오류 코드는 `ERR_DISPATCH_REQUEST_INVALID`, `ERR_DISPATCH_IDENTITY_DENIED`, `ERR_DISPATCH_LIVE_STATE_STALE`, `ERR_DISPATCH_REPLAY_CONFLICT`, `ERR_GITHUB_DISPATCH_AUTHORIZATION`, `ERR_GITHUB_DISPATCH_UPSTREAM`이다. Primary references: GitHub. (2026). *OpenID Connect reference*; GitHub. (2026). *Create a repository dispatch event*; Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785; Cloudflare. (2026). *Web Crypto*. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 15b5249eb..2ccd5f5bf 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ ## Proposed continuation dispatch broker (#735/#736) -The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–5 cover closed request/digest, SQLite terminal idempotency, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, replay, readiness, and stable errors. Status remains Proposed until contract publication, exact-head hosted review/gates, protected integration, immutable Noema release, deployment/recovery evidence, and downstream `ContextualWisdomLab/.github#2540` pin. +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. ## Authority and update rule diff --git a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md index b698ad799..d094dfa1c 100644 --- a/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md +++ b/docs/superpowers/specs/2026-09-30-continuation-dispatch-broker-design.md @@ -107,10 +107,12 @@ rules. No caller string participates in target origin or path selection. ## Exactly-once state Add `NoemaContinuationDispatchState`, a SQLite-backed Durable Object selected -by the lowercase hexadecimal SHA-256 digest of an RFC 8785 canonical JSON identity object. Its named members are +by the lowercase hexadecimal SHA-256 digest of an RFC 8785 logical-effect identity object. Its named members are `contract_version`, verified `workflow_sha`, `source_repository`, `pull_request_number`, exact head/base/ref, `dispatch_action`, -`central_repository`, and `transport_retry_attempt`. Named canonical members +and `central_repository`. `transport_retry_attempt` remains in the complete +request digest but is excluded from the object-selection identity, so attempts +1 and 2 cannot acquire separate dispatch authority. Named canonical members avoid the delimiter ambiguity of concatenated strings. Canonical bytes contain only that closed identity schema. RFC 8785 defines @@ -125,8 +127,12 @@ The Durable Object transaction stores the request digest and one state: - `indeterminate`: a network error, 5xx, deadline, or unreadable upstream response makes external side-effect completion unknowable. -An exact replay returns the stored signed receipt without another GitHub call. -The same logical key with a different digest fails closed. `indeterminate` is +An exact replay returns the stored signed receipt before another live-PR read or +dispatch. The same logical key with a different complete request digest fails +closed. Before the external call, Noema durably commits a signed +`indeterminate` receipt; accepted or denied evidence may strengthen that same +authority afterward. A crash or finalization failure therefore retains +replayable evidence and cannot authorize a second dispatch. `indeterminate` is never silently redispatched; operator reconciliation is required. ## Signed receipt @@ -145,7 +151,9 @@ Receipt fields are: - trace id and detached base64url signature. The signing private key and key id are dedicated Worker bindings and are not -the GitHub App key. The public key is documented for offline verification. +the GitHub App key. Offline-verification acceptance begins only when an +immutable release publishes the exact key-id-to-SPKI artifact and digest; +Draft source does not claim that a verification key is already available. Cloudflare Workers Web Crypto supports Ed25519, including the standards-based Secure Curves form: . The receipt is evidence only and authorizes no later dispatch, merge, release, diff --git a/docs/threat-model.md b/docs/threat-model.md index 668b08ff7..9266d2392 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -9,7 +9,7 @@ ## Continuation dispatch boundary -`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. +`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Retry 1/2는 하나의 logical-effect owner에서 직렬화되고 complete-digest drift는 conflict다. 외부 호출 전에 signed indeterminate evidence를 durable commit하므로 post-effect crash/finalization failure가 재dispatch를 허용하지 않는다. GitHub live-read와 repository-dispatch 사이를 원자화할 수 없으므로 event는 exact expected head/base/ref를 보존하며, downstream `.github#2540`은 실행 직전 이를 다시 검증해야 한다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. ## 위협 1. 위조된 OIDC 토큰으로 허가되지 않은 토큰 발급 시도 diff --git a/openapi.json b/openapi.json index b84617df5..d06ecf3a9 100644 --- a/openapi.json +++ b/openapi.json @@ -331,7 +331,7 @@ "additionalProperties": false, "required": ["receipt_version", "contract_version", "algorithm", "key_id", "receipt_id", "trace_id", "request_digest", "idempotency_identity", "workflow_ref", "workflow_sha", "source_repository", "pull_request_number", "source_head_sha", "source_base_sha", "source_base_ref", "central_repository", "event_type", "emitted_payload_digest", "transport_retry_attempt", "outcome", "upstream_status", "issued_at", "expires_at", "signature"], "properties": { - "receipt_version": { "type": "string", "const": "noema.continuation-receipt.v1" }, + "receipt_version": { "type": "string", "const": "noema.continuation-dispatch-receipt.v1" }, "contract_version": { "type": "string", "const": "noema.continuation-dispatch.v1" }, "algorithm": { "type": "string", "const": "Ed25519" }, "key_id": { "type": "string" }, diff --git a/src/continuation-dispatch/contract.ts b/src/continuation-dispatch/contract.ts index 0fdf76a31..5f88c867a 100644 --- a/src/continuation-dispatch/contract.ts +++ b/src/continuation-dispatch/contract.ts @@ -288,6 +288,43 @@ export function canonicalContinuationRequest( return canonicalFlatObject({ ...admittedRequest, workflow_sha: admittedWorkflow.workflow_sha }); } +/** + * Serializes the logical continuation identity independently of transport retry metadata. + * @param request Candidate request; every member is revalidated before it gains idempotency authority. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns Deterministic canonical JSON identifying one possible external continuation effect. + * @throws {ContinuationDispatchContractError} When request or workflow identity material is not canonical. + */ +export function canonicalContinuationIdentity( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): string { + const { transport_retry_attempt: _transportRetryAttempt, ...logicalRequest } = + admitContinuationDispatchRequest(request); + const admittedWorkflow = admitWorkflowIdentity(workflowIdentity); + return canonicalFlatObject({ ...logicalRequest, workflow_sha: admittedWorkflow.workflow_sha }); +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +/** + * Computes the lowercase SHA-256 identity of one external continuation effect. + * Transport retry attempt is intentionally excluded so attempts 1 and 2 serialize through one owner. + * @param request Candidate request revalidated by the canonical serializer. + * @param workflowIdentity Verified reusable-workflow commit identity. + * @returns A 64-character lowercase hexadecimal identity. + * @throws {ContinuationDispatchContractError} When canonical request material is invalid. + */ +export async function continuationDispatchIdentity( + request: ContinuationDispatchRequest, + workflowIdentity: ContinuationWorkflowIdentity, +): Promise { + return sha256Hex(canonicalContinuationIdentity(request, workflowIdentity)); +} + /** * Computes the lowercase SHA-256 digest of the exact canonical continuation identity. * @param request Candidate request revalidated by the canonical serializer. @@ -299,9 +336,5 @@ export async function continuationRequestDigest( request: ContinuationDispatchRequest, workflowIdentity: ContinuationWorkflowIdentity, ): Promise { - const canonicalBytes = new TextEncoder().encode( - canonicalContinuationRequest(request, workflowIdentity), - ); - const digest = await crypto.subtle.digest("SHA-256", canonicalBytes); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); + return sha256Hex(canonicalContinuationRequest(request, workflowIdentity)); } diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index 7422fa39f..cb28634ce 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -84,7 +84,22 @@ type CommitCommand = { readonly receipt: ContinuationDispatchReceipt; }; -type DispatchStateCommand = ReserveCommand | CommitCommand; +type AbortCommand = { + readonly operation: "abort"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; +}; + +type FinalizeCommand = { + readonly operation: "finalize"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type DispatchStateCommand = ReserveCommand | CommitCommand | AbortCommand | FinalizeCommand; type CommandRead = | { readonly ok: true; readonly value: unknown } @@ -343,9 +358,53 @@ function parseCommand(value: unknown): DispatchStateCommand | undefined { receipt: value.receipt, }; } + if (value.operation === "abort") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id"])) return undefined; + if (!isDigest(value.identity) || !isDigest(value.digest) || !isReservationId(value.reservation_id)) { + return undefined; + } + return { + operation: "abort", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + }; + } + if (value.operation === "finalize") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + || value.receipt.outcome === "indeterminate" + ) { + return undefined; + } + return { + operation: "finalize", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } return undefined; } +function sameReceiptAuthority( + retained: ContinuationDispatchReceipt, + candidate: ContinuationDispatchReceipt, +): boolean { + const omitted = new Set(["outcome", "signature", "upstream_status"]); + const stable = (receipt: ContinuationDispatchReceipt) => Object.fromEntries( + Object.entries(receipt).filter(([key]) => !omitted.has(key)), + ); + return JSON.stringify(stable(retained)) === JSON.stringify(stable(candidate)); +} + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -546,6 +605,99 @@ export async function commitContinuationOutcome( } } +/** + * Releases a fresh reservation before any external side effect has been attempted. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Unguessable fresh reservation capability returned by reserve. + * @returns A promise that resolves only after the exact reservation is removed. + * @throws {ContinuationDispatchStateConflict} When the reservation is stale, wrong, or already terminal. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function abortContinuationReservation( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + ) { + throw new ContinuationDispatchStateUnavailable("continuation reservation is not canonical"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "abort", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + +/** + * Replaces a pre-dispatch indeterminate receipt with the exact accepted or denied result. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Original reservation capability retained across the dispatch attempt. + * @param receipt Accepted or denied receipt with identical immutable authority fields. + * @returns A promise that resolves only after the stronger terminal evidence is durable. + * @throws {ContinuationDispatchStateConflict} When state or immutable receipt authority differs. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function finalizeContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + || receipt.outcome === "indeterminate" + ) { + throw new ContinuationDispatchStateUnavailable("continuation final outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "finalize", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + /** * Cloudflare Durable Object that serializes one logical continuation identity through reservation and terminal receipt. * Its closed private protocol preserves indeterminate outcomes so concurrent or retried callers never duplicate dispatch. @@ -616,6 +768,54 @@ export class NoemaContinuationDispatchState { return jsonResponse({ ok: true, data: decision.reservation }, decision.kind === "created" ? 201 : 200); } + if (command.operation === "abort") { + const aborted = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "reserved" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + ) { + return "conflict"; + } + await transaction.delete(STATE_RECORD_KEY); + return "aborted"; + }); + if (aborted === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (aborted === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + + if (command.operation === "finalize") { + const finalized = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "indeterminate" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + || stored.receipt === undefined + || !sameReceiptAuthority(stored.receipt, command.receipt) + ) { + return "conflict"; + } + const next: StoredDispatchState = { + ...stored, + status: command.receipt.outcome, + receipt: command.receipt, + }; + if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_STATE_JSON_BYTES) return "invalid"; + await transaction.put(STATE_RECORD_KEY, next); + return "finalized"; + }); + if (finalized === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (finalized === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + const committed = await this.storage.transaction(async (transaction) => { const stored = await transaction.get(STATE_RECORD_KEY); if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index 04e828672..18f1f2543 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -3,12 +3,15 @@ import type { JwtPayload } from "../index"; import { errorHints, type ErrorCode } from "../error-codes"; import { ContinuationDispatchContractError, + continuationDispatchIdentity, continuationRequestDigest, parseContinuationDispatchRequest, type ContinuationDispatchRequest, } from "./contract"; import { + abortContinuationReservation, commitContinuationOutcome, + finalizeContinuationOutcome, ContinuationDispatchStateConflict, ContinuationDispatchStateUnavailable, reserveContinuationDispatch, @@ -31,6 +34,7 @@ import { } from "./receipt"; const MAX_BODY_BYTES = 8_192; +const BODY_READ_DEADLINE_MS = 10_000; const JSON_MEDIA_TYPE = /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu; /** Runtime authority required by the public continuation-dispatch handler, including state, GitHub adapters, receipt signing, and exact workflow revision. */ @@ -60,6 +64,8 @@ export interface ContinuationDispatchHandlerDependencies { env: ContinuationGitHubAdapterEnv, ): Promise; commit: typeof commitContinuationOutcome; + abort: typeof abortContinuationReservation; + finalize: typeof finalizeContinuationOutcome; } /** Default owner implementations for every boundary except the existing OIDC verifier/replay claimant. */ @@ -68,6 +74,8 @@ export const continuationDispatchDependencies = { reserve: reserveContinuationDispatch, dispatch: dispatchCentralContinuation, commit: commitContinuationOutcome, + abort: abortContinuationReservation, + finalize: finalizeContinuationOutcome, }; type HandlerErrorCode = Extract< @@ -163,21 +171,39 @@ async function readBoundedBody(request: Request): Promise { } const bytes = new Uint8Array(MAX_BODY_BYTES); let used = 0; - try { + let timeoutHandle!: ReturnType; + const cancelReaderBestEffort = (reason: string) => { + try { + void reader.cancel(reason).catch(() => undefined); + } catch { + // Cleanup must not replace an established body decision. + } + }; + const deadline = new Promise((resolve) => { + timeoutHandle = setTimeout(() => { + cancelReaderBestEffort("continuation dispatch body read deadline exceeded"); + resolve(new Response(null, { status: 408 })); + }, BODY_READ_DEADLINE_MS); + }); + const readBody = (async (): Promise => { while (true) { const chunk = await reader.read(); if (chunk.done) break; if (chunk.value.byteLength > MAX_BODY_BYTES - used) { - void reader.cancel("continuation dispatch body exceeds its byte envelope").catch(() => undefined); + cancelReaderBestEffort("continuation dispatch body exceeds its byte envelope"); return new Response(null, { status: 413 }); } bytes.set(chunk.value, used); used += chunk.value.byteLength; } return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, used)); + })(); + try { + return await Promise.race([readBody, deadline]); } catch { return new Response(null, { status: 400 }); } finally { + clearTimeout(timeoutHandle); try { reader.releaseLock(); } catch { @@ -253,7 +279,11 @@ export async function handleContinuationDispatch( return errorResponse( "ERR_DISPATCH_REQUEST_INVALID", status, - status === 415 ? "Continuation dispatch requires application/json" : "Continuation dispatch body is invalid", + status === 415 + ? "Continuation dispatch requires application/json" + : status === 408 + ? "Continuation dispatch body read deadline exceeded" + : "Continuation dispatch body is invalid", traceId, startedAt, ); @@ -287,7 +317,9 @@ export async function handleContinuationDispatch( } try { - await dependencies.claimOidc(claims, env); + if (!await dependencies.claimOidc(claims, env)) { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was already used or unavailable", traceId, startedAt); + } } catch (error) { if (isUnavailableBoundary(error)) { return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "OIDC replay protection unavailable", traceId, startedAt); @@ -295,24 +327,11 @@ export async function handleContinuationDispatch( return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 401, "Continuation identity was already used or unavailable", traceId, startedAt); } - try { - await dependencies.readLivePullRequest(claims, parsed, env); - } catch (error) { - if (error instanceof ContinuationGitHubAdapterError) { - if (error.classification === "identity_denied") { - return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); - } - if (error.classification === "live_state_stale") { - return errorResponse("ERR_DISPATCH_LIVE_STATE_STALE", 409, "Pull request state no longer matches", traceId, startedAt); - } - } - return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "GitHub live-state verification unavailable", traceId, startedAt); - } - const digest = await continuationRequestDigest(parsed, { workflow_sha: workflow.workflowSha }); + const identity = await continuationDispatchIdentity(parsed, { workflow_sha: workflow.workflowSha }); let reservation: ContinuationDispatchReservation; try { - reservation = await dependencies.reserve(env, digest, digest); + reservation = await dependencies.reserve(env, identity, digest); } catch (error) { if (error instanceof ContinuationDispatchStateConflict) { return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation replay conflicts with retained authority", traceId, startedAt); @@ -329,6 +348,63 @@ export async function handleContinuationDispatch( return successResponse(reservation.receipt, traceId, startedAt, true); } + try { + await dependencies.readLivePullRequest(claims, parsed, env); + } catch (error) { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + if (error instanceof ContinuationGitHubAdapterError) { + if (error.classification === "identity_denied") { + return errorResponse("ERR_DISPATCH_IDENTITY_DENIED", 403, "Continuation identity was denied", traceId, startedAt); + } + if (error.classification === "live_state_stale") { + return errorResponse("ERR_DISPATCH_LIVE_STATE_STALE", 409, "Pull request state no longer matches", traceId, startedAt); + } + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "GitHub live-state verification unavailable", traceId, startedAt); + } + + const receiptId = crypto.randomUUID(); + const payloadDigest = await emittedPayloadDigest(parsed); + let indeterminateReceipt: SignedContinuationReceipt; + try { + indeterminateReceipt = await signContinuationReceipt({ + request: parsed, + requestDigest: digest, + idempotencyIdentity: identity, + workflowRef: workflow.workflowRef, + workflowSha: workflow.workflowSha, + emittedPayloadDigest: payloadDigest, + dispatchResult: { + outcome: "indeterminate", + eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan", + }, + oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, + receiptId, + traceId, + }, env); + } catch { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be signed", traceId, startedAt); + } + + try { + await dependencies.commit( + env, + reservation, + indeterminateReceipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); + } + let dispatchResult: CentralContinuationDispatchResult; try { dispatchResult = await dependencies.dispatch(parsed, env); @@ -336,27 +412,45 @@ export async function handleContinuationDispatch( dispatchResult = { outcome: "indeterminate", eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan" }; } + if (dispatchResult.outcome === "indeterminate") { + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt: indeterminateReceipt }, + ); + } + let receipt: SignedContinuationReceipt; try { receipt = await signContinuationReceipt({ request: parsed, requestDigest: digest, - idempotencyIdentity: digest, + idempotencyIdentity: identity, workflowRef: workflow.workflowRef, workflowSha: workflow.workflowSha, - emittedPayloadDigest: await emittedPayloadDigest(parsed), + emittedPayloadDigest: payloadDigest, dispatchResult, oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, - receiptId: crypto.randomUUID(), + receiptId, traceId, }, env); - await dependencies.commit( + await dependencies.finalize( env, reservation, receipt as unknown as ContinuationDispatchReceipt, ); } catch { - return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Continuation final evidence could not be committed", + traceId, + startedAt, + { receipt: indeterminateReceipt }, + ); } if (dispatchResult.outcome === "accepted") { @@ -372,12 +466,5 @@ export async function handleContinuationDispatch( { receipt }, ); } - return errorResponse( - "ERR_GITHUB_DISPATCH_UPSTREAM", - 503, - "GitHub continuation dispatch outcome is indeterminate", - traceId, - startedAt, - { receipt }, - ); + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Unexpected continuation outcome", traceId, startedAt); } diff --git a/test/continuation-dispatch-contract.test.ts b/test/continuation-dispatch-contract.test.ts index f3d637cb0..d07588b0d 100644 --- a/test/continuation-dispatch-contract.test.ts +++ b/test/continuation-dispatch-contract.test.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { + canonicalContinuationIdentity, canonicalContinuationRequest, + continuationDispatchIdentity, continuationRequestDigest, dispatchMapping, parseContinuationDispatchRequest, @@ -214,6 +216,20 @@ describe("continuation dispatch canonical identity", () => { expect(expected).toMatch(/^[0-9a-f]{64}$/u); }); + it("serializes retry attempts through one logical identity while retaining distinct request digests", async () => { + const first = parseValue(validRequest); + const second = parseValue({ ...validRequest, transport_retry_attempt: 2 }); + const canonicalIdentity = canonicalContinuationIdentity(first, workflowIdentity); + + expect(canonicalIdentity).not.toContain("transport_retry_attempt"); + await expect(continuationDispatchIdentity(first, workflowIdentity)).resolves.toBe( + await continuationDispatchIdentity(second, workflowIdentity), + ); + await expect(continuationRequestDigest(first, workflowIdentity)).resolves.not.toBe( + await continuationRequestDigest(second, workflowIdentity), + ); + }); + it("fails closed when runtime workflow identity is not a lowercase full commit SHA", async () => { const request = parseValue(validRequest); diff --git a/test/continuation-dispatch-docs-contract.test.ts b/test/continuation-dispatch-docs-contract.test.ts index f05186a42..b200799bf 100644 --- a/test/continuation-dispatch-docs-contract.test.ts +++ b/test/continuation-dispatch-docs-contract.test.ts @@ -34,6 +34,11 @@ describe("continuation dispatch publication contracts", () => { expect(JSON.stringify(operation)).not.toMatch(/installation[_ -]?token|access[_ -]?token/i); expect(document.components?.schemas).toHaveProperty("ContinuationDispatchRequest"); expect(document.components?.schemas).toHaveProperty("SignedContinuationReceipt"); + expect(document.components?.schemas?.SignedContinuationReceipt).toMatchObject({ + properties: { + receipt_version: { const: "noema.continuation-dispatch-receipt.v1" }, + }, + }); }); it("publishes the endpoint and all six stable dispatch errors across buyer contracts", () => { @@ -83,5 +88,7 @@ describe("continuation dispatch publication contracts", () => { expect(api).toMatch(/receipt[\s\S]{0,500}(?:no token|no GitHub credential|credential-free)/iu); expect(api).toContain("Ed25519"); expect(api).toContain("RFC 8785"); + expect(api).toMatch(/immutable release[\s\S]{0,300}key_id[\s\S]{0,300}SPKI/iu); + expect(api).toMatch(/Draft[\s\S]{0,300}offline verification[\s\S]{0,200}(?:not|않)/iu); }); }); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index ea0caad2b..ee7a34ade 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -2,10 +2,12 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { + abortContinuationReservation, ContinuationDispatchStateConflict, ContinuationDispatchStateUnavailable, NoemaContinuationDispatchState, commitContinuationOutcome, + finalizeContinuationOutcome, reserveContinuationDispatch, type ContinuationDispatchReservation, type ContinuationDispatchStateEnv, @@ -28,6 +30,10 @@ class MemoryStorage { this.records.set(key, structuredClone(value)); } + async delete(key: string): Promise { + return this.records.delete(key); + } + async transaction(callback: (transaction: MemoryStorage) => Promise): Promise { this.transactionCount += 1; return callback(this); @@ -151,6 +157,48 @@ describe("continuation dispatch exactly-once state", () => { expect([...storage.records.values()][0]).toEqual(retained); }); + it("releases only the exact fresh reservation before an external effect", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + + await abortContinuationReservation(env, reservation); + + await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toMatchObject({ + kind: "reserved", + identity, + digest, + }); + }); + + it("upgrades retained indeterminate evidence without changing immutable receipt authority", async () => { + const { env } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const pending = { + outcome: "indeterminate" as const, + receipt_id: "same-receipt", + request_digest: digest, + upstream_status: null, + signature: "pending-signature", + }; + const accepted = { + ...pending, + outcome: "accepted" as const, + upstream_status: 204, + signature: "accepted-signature", + }; + + await commitContinuationOutcome(env, reservation, pending); + await finalizeContinuationOutcome(env, reservation, accepted); + + await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + kind: "replay", + identity, + digest, + outcome: "accepted", + receipt: accepted, + }); + }); + it("requires the original reservation capability before committing a terminal outcome", async () => { const { env } = fixture(); const reservation = requireNewReservation( diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index 5879d9ecf..3998bb229 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import baseWorker from "../src/index"; import runtimeWorker from "../src/runtime-entrypoint"; import protectedWorker from "../src/worker"; @@ -7,6 +7,7 @@ import { type ContinuationDispatchHandlerDependencies, } from "../src/continuation-dispatch/handler"; import { ContinuationDispatchStateConflict } from "../src/continuation-dispatch/dispatch-state"; +import { ContinuationGitHubAdapterError } from "../src/continuation-dispatch/github-adapter"; import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; const workflowSha = "1".repeat(40); @@ -44,6 +45,11 @@ beforeAll(async () => { signingPublicKeyPem = pem("PUBLIC KEY", await crypto.subtle.exportKey("spki", pair.publicKey)); }); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + function request(body: string = JSON.stringify(requestBody), init: RequestInit = {}): Request { return new Request("https://noema.example/v1/continuation-dispatches", { method: "POST", @@ -111,6 +117,13 @@ function harness() { calls.push("commit"); retained = receipt; }, + abort: async () => { + calls.push("abort"); + }, + finalize: async (_env, _reservation, receipt) => { + calls.push("finalize"); + retained = receipt; + }, }; const env = { ALLOWED_WORKFLOW_SHA: workflowSha, @@ -193,7 +206,7 @@ describe("continuation dispatch public route", () => { expect(response.headers.get("x-oidc-replay-protection")).toBe("verified-before-dispatch"); expect(payload.ok).toBe(true); expect(payload.trace_id).toBe("trace-task-5"); - expect(calls).toEqual(["claim", "live-pr", "reserve", "dispatch", "commit"]); + expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch", "finalize"]); await expect(verifyContinuationReceipt(payload.data.receipt, signingPublicKeyPem)).resolves.toBe(true); expect(JSON.stringify(payload)).not.toMatch(/token|bearer|assertion|private_key/i); }); @@ -209,6 +222,56 @@ describe("continuation dispatch public route", () => { expect(second.headers.get("x-continuation-replay")).toBe("exact"); expect(secondPayload.data.receipt).toEqual(firstPayload.data.receipt); expect(calls.filter((call) => call === "dispatch")).toHaveLength(1); + expect(calls.filter((call) => call === "live-pr")).toHaveLength(1); + }); + + it("serializes retry attempt two through the first attempt identity without a second dispatch", async () => { + const { calls, dependencies, env } = harness(); + let retainedIdentity: string | undefined; + let retainedDigest: string | undefined; + dependencies.reserve = async (_env, identity, digest) => { + calls.push("reserve"); + if (retainedIdentity === undefined) { + retainedIdentity = identity; + retainedDigest = digest; + return { + kind: "reserved", + identity, + digest, + reservationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }; + } + expect(identity).toBe(retainedIdentity); + expect(digest).not.toBe(retainedDigest); + throw new ContinuationDispatchStateConflict(); + }; + + const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const second = await handleContinuationDispatch( + request(JSON.stringify({ ...requestBody, transport_retry_attempt: 2 })), + env, + "trace-task-5", + dependencies, + ); + + expect(first.status).toBe(200); + expect(second.status).toBe(409); + expect(calls.filter((call) => call === "dispatch")).toHaveLength(1); + }); + + it("commits indeterminate evidence before dispatch and preserves it when finalization fails", async () => { + const { calls, dependencies, env } = harness(); + dependencies.finalize = async () => { + calls.push("finalize"); + throw new Error("state unavailable"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: { outcome: string } } }; + + expect(response.status).toBe(503); + expect(calls.indexOf("commit")).toBeLessThan(calls.indexOf("dispatch")); + expect(payload.details.receipt.outcome).toBe("indeterminate"); }); it("fails a retained digest conflict closed before dispatch", async () => { @@ -228,6 +291,19 @@ describe("continuation dispatch public route", () => { expect(dispatch).not.toHaveBeenCalled(); }); + it("releases a fresh reservation when the final live PR check is stale", async () => { + const { calls, dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { + calls.push("live-pr"); + throw new ContinuationGitHubAdapterError("live_state_stale"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(409); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + it.each(["verifyOidc", "claimOidc"] as const)( "classifies unavailable %s infrastructure as upstream instead of invalid identity", async (boundary) => { @@ -296,4 +372,38 @@ describe("continuation dispatch public route", () => { expect(response.status).toBe(413); expect(verify).not.toHaveBeenCalled(); }); + + it("rejects a stalled body after the absolute read deadline before OIDC verification", async () => { + vi.useFakeTimers(); + const cancellations: string[] = []; + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"contract_version":')); + }, + cancel(reason) { + cancellations.push(String(reason)); + }, + }); + const pending = handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + }, + body: stream, + duplex: "half", + } as RequestInit, + ), env, "trace-task-5", dependencies); + + await vi.advanceTimersByTimeAsync(10_000); + const response = await pending; + + expect(response.status).toBe(408); + expect(verify).not.toHaveBeenCalled(); + expect(cancellations).toEqual(["continuation dispatch body read deadline exceeded"]); + }); }); From 08d501cb1a20e630eb7eaab23e8b5d0f8e5dbd95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:04:22 +0900 Subject: [PATCH 14/36] docs: integrate concurrent patch validator repair --- CHANGELOG.md | 5 +++-- docs/product-technical-gap-baseline.md | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0dcfa6e5..e6fe70338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - `packages/noema-core/README.md`의 `build_agent` 서명을 코드와 ADR-0014에 맞춘다. caller-visible `retries=3` 표기를 제거하고, 이 경계의 model-attempt retry는 `Agent(..., retries=0)`으로 고정되며 orchestration retry/failover는 contextual-orchestrator 소유임을 명시한다. 구현·ADR 상태·provider 정책은 바꾸지 않는다. - PR #724 makes private-vulnerability-reporting setting receipts a fail-closed strict-release prerequisite without turning setting evidence into release or deployment truth. The consumer validates bounded stable/no-follow receipt files with fatal UTF-8 and duplicate-key rejection, canonical repository/source/timestamp identity, bounded future skew and freshness, and explicit `PASS + enabled:true + failures:[]`; all observations are validated before ordering and the newest observation wins even when it is FAIL, so a historical PASS cannot mask later contradictory evidence. Protected CD recollects the setting in the same job from the exact immutable-release commit through a repository-scoped `Metadata: read` GitHub App capability, moves the short-lived bearer into an owner-only capability file, unsets the raw bootstrap value before Node execution, feeds the same-run receipt into `release:verify:strict`, and retains the receipt with deployment evidence. The ordinary pre-tag source gate remains `release:verify`; bare pre-publication `release:verify:strict` without a fresh matching receipt is not an authorized procedure. External reporter visibility, staffing/notification, benign private-case handling, immutable-release publication, deployment, KPI and acquisition authority remain independent evidence classes. @@ -91,14 +92,14 @@ - strict production KPI provenance를 fail-closed로 강화한다. `collectedAt`은 canonical UTC 시각만 허용하고 미래·존재하지 않는 달력값을 거부하며, `records`는 coercion 없는 양의 safe-integer JSON number로 authenticated log record count와 일치해야 한다. production log는 `O_NOFOLLOW`로 연 verified regular-file descriptor의 identity·size·mode·mtime·ctime을 재검증하면서 동일 verified bytes로 snapshot을 생성해 symlink·pathname replacement·descriptor drift가 KPI authority를 제조하지 못하게 한다. - 인수 준비성 감사의 buyer/data-room 증거 경계를 실패-폐쇄로 강화한다. `updated_at`은 canonical ISO date 또는 timezone-bearing timestamp만 허용하고 존재하지 않는 달력값·비정규 형식·미래 시각을 거부하며, 명시된 evidence max-age 설정과 매출·고객·LOI·비율 지표는 coercion 없이 유효 도메인을 만족해야 한다. immutable release asset의 `bytes`도 실제 양의 safe-integer JSON number만 인정해 문자열·범위 밖 값이 매각 준비 증거를 제조하지 못하게 한다. - 루트 `README.md`를 구매자/운영자 표면으로 재작성한다. 제품 정의, 단독 실행, 호스트 호출(`/health`·`/ready`·`/exchange`, `contracts/orchestrator-gateway.json`), GitHub App 권한, 운영자가 설정하는 Worker 변수, naruon·곁(gyeot) 허브-리프 호출을 앞에 둔다. 시간당 상업 루프, 시간당 제품 개발 절차, 매각/판매 가능 프로그램, KPI provenance 수집 레시피, readiness-scan 내부는 README에서 빼고 `docs/internal/README.md`로 안내한다. CloudAgent/OpenCode 절차, PR 스택, exact-head CI, writer/agent 경계는 기존 `CONTRIBUTING.md`와 `docs/development/contributor-and-agent-procedure.md`에 남기며 삭제하지 않는다. `test/readme-customer-facing.test.ts`가 README에 봇 매뉴얼 잔여가 남지 않는지 고정한다. -- repository-owned patch-validator image는 Node.js 24.19.0을 fully static으로 빌드한 `scratch` runtime을 사용하고, `process.versions` 기반 embedded dependency inventory, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 결합한다. +- repository-owned patch-validator image는 현재 Node.js 24.21.0을 fully static으로 빌드한 `scratch` runtime을 사용하고, `process.versions` 기반 embedded dependency inventory, exact image SHA-256, CycloneDX/Syft/Grype/Trivy evidence, no-network/read-only/non-root smoke를 동일 exact-head receipt에 결합한다. - 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다. - 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더러울 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. - production `operations:runner-assignment` audit는 `NOEMA_MAINTAINER_TOKEN_PATH`의 owner-only capability file만 읽고, ambient `GH_TOKEN`만 있으면 실패-폐쇄한다. `gh` spawn/stderr 진단은 활성 토큰을 exact-match로 `[REDACTED]` 치환하며, 빈 secret에 대해서는 원문 진단을 보존한다. assignment authority는 양의 `runner_id` 또는 비어 있지 않은 `runner_name`만 인정하며 queued `started_at`은 assignment evidence가 아니다. 운영자는 `printf '%s'`로 capability file을 만들고(`echo`/`printf '%s\n'`는 trailing newline 때문에 실패-폐쇄), Actions workflow-run/job read만 가진 짧은 토큰을 준비한 뒤 PASS를 required Check·formal review·merge 권한으로 해석하지 마십시오. - coordinated vulnerability disclosure 정책과 evidence-preserving vulnerability handling lifecycle, read-only private-vulnerability-reporting setting audit를 추가한다. 이 source 변경은 live private reporting 활성화·notification staffing·end-to-end advisory exercise·release/deployment authority를 증명하지 않는다. - 개발 의존성 체인의 transitive `nanoid` lockfile resolution을 `3.3.17`에서 `3.3.18`로 최소 갱신하여 GHSA-2v37-7h3g-55p8 / CVE-2026-67213 보안 게이트를 복구한다. PostCSS의 선언 범위 `^3.3.16`과 다른 package metadata는 변경하지 않으며 audit waiver·ignore·severity 완화 없이 `npm ci`/`npm audit --audit-level=high`가 exact head에서 재검증되도록 유지한다. -- lockfile 재생성 도구 체인을 Node.js 24.19.0/npm 11.17.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v3 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. exact package before/after digest에 더해 top-level metadata digest와 대규모 package-set bulk evidence를 결합하며, 선행 `nanoid@3.3.18` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존한다. package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority는 별도 증거 계층으로 유지한다. +- lockfile 재생성 도구 체인을 Node.js 24.21.0/npm 11.19.0으로 정확히 고정하고, `strict-allow-scripts=true` 아래 승인된 install-script identity만 실행하며 schema v3 exact-base lockfile change control로 package metadata drift를 실패-폐쇄한다. exact package before/after digest에 더해 top-level metadata digest와 대규모 package-set bulk evidence를 결합하며, 선행 `nanoid@3.3.18` 보안 수정과 explicit `npm ci --legacy-peer-deps=false --install-links=false` 계약을 보존한다. package-manager/toolchain·install-script authority·vulnerability audit·review/merge authority는 별도 증거 계층으로 유지한다. - `hourly-product-development`가 `NVIDIA_NIM_API_KEY`뿐 아니라 `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY` 존재를 checkout·OpenCode 설치·NVIDIA 호출 전에 검증한다. 게시 경로가 준비되지 않았으면 `maintainer_app_unavailable`로 실패 폐쇄하여 알려진 실패에 추론 비용을 쓰지 않으며, `dry_run`은 credential 없이 queue와 task contract를 검토하는 경로로 유지한다. 기존 reviewer App 및 `NOEMA_LLM_API_KEY`·`contextual-orchestrator` reviewer credential 경계는 변경하지 않는다. - zero open pull requests일 때만 `NVIDIA_NIM_API_KEY` 전용 OpenCode 1.17.13 세션을 실행하는 proposal-only `hourly-product-development` 루프를 추가. minute-47 schedule·non-cancelling single flight·OpenCode binary SHA-256 pin·NVIDIA NIM model fallback·후보 실패 시 clean reset·GitHub/OIDC credential 제거·reviewer key 비참조·full release verification·40-file/500,000-byte proposal budget·trusted one-PR packaging을 강제한다. 각 후보 실행은 900초와 30초 kill grace로 제한하고, 실패 후 `npm ci --ignore-scripts` 재설치는 별도 60초와 10초 kill grace로 제한한다. 재설치가 실패하거나 시간 초과되면 불완전한 dependency tree로 다음 후보를 실행하지 않고 실패 폐쇄한다. 세 후보의 실행·종료 2,790초, 두 번의 후보 간 재설치 140초, 300초 setup/diagnostic reserve를 합친 3,230초가 55분(3,300초) job budget에 들어가며 70초 여유를 남긴다. 마지막 후보가 실패하면 불필요한 reset·clean·재설치를 생략하고 안정적인 전체 후보 실패 진단으로 곧바로 종료한다. 모델 실행, 제안 코드 검증, publication credential을 각각 별도의 GitHub-hosted runner로 분리하고, immutable artifact의 exact ID·workflow-run ID·archive digest와 patch SHA-256·base SHA·file/byte count를 교차 검증하며 symlink(`120000`)와 gitlink(`160000`)를 세 경계 모두에서 차단한다. 제안 코드를 실행한 runner에는 Maintainer App secret/token을 절대 제공하지 않고, 세 번째 non-executing publisher에서만 late-bound repository-scoped App token을 발급한다. merge/release/deploy authority는 기존 `hourly-commercial-readiness` exact-head governance에 유지하며, 운영 Runbook과 OpenCode/NVIDIA/GitHub Actions/NIST SP 800-218 근거를 APA 7th doctoring에 기록했다. package version은 release·deployment·production KPI evidence를 발행하지 않으므로 유지한다. - `/health` liveness와 분리된 unauthenticated `GET`/`HEAD /ready` runtime readiness endpoint를 추가. GitHub Actions OIDC issuer·audience·organization/workflow binding·exact workflow ref·GitHub Cloud API origin·GitHub App identifiers·PKCS#8 private key를 외부 호출 없이 검증하며, 불완전한 설정은 secret/config value를 반사하지 않는 deterministic failure codes와 `503 ERR_SERVICE_NOT_READY`, `Retry-After`, no-store/nosniff/trace/latency headers로 실패-폐쇄한다. exact workflow named ref는 Git `check-ref-format`의 모호성·유효성 경계(`..`, `//`, dot-leading/`.lock` component, revision-expression 문자, trailing dot/slash 등)를 만족해야 하므로 GitHub가 실제로 표현할 수 없는 ref에서 false-ready가 발생하지 않는다. 배포 smoke contract가 liveness·runtime readiness·unauthenticated exchange challenge를 모두 요구하도록 확장하고 Kubernetes probe separation, RFC 9110, NIST SSDF, Git ref-format 근거를 APA 7th doctoring에 기록했다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2ccd5f5bf..703754f64 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -12,6 +12,8 @@ Current protected source는 mutation·merge·release 시점에 live protected `m Dated central control-plane observation for this repair는 central `.github/main@cb0872c9a20d5584703dffacca65c096fc034c6c`다. Noema runtime의 reviewed immutable central consumer pin은 `c9052e607e5f3cc76e73207e7786b21500721b79`이고 runtime authority 표현은 `ALLOWED_WORKFLOW_SHA = c9052e607e5f3cc76e73207e7786b21500721b79`다. Moving foreign head와 reviewed immutable pin을 같은 권위로 취급하지 않으며 central moving head가 전진했다고 consumer pin을 자동 승격하지 않는다. +Open candidate PR #736의 predecessor exact head `6f7b7dc8e4d67dd8eb4827423f9e14fbeae7fd3f`에서 application CI `36731606439`, reviewer-ci `36731606039`, required Security Scan `36731605921`은 terminal SUCCESS였지만 patch-validator-image `36731605769`는 terminal FAILURE였다. Retained artifact `11104958913`의 exact embedded-runtime receipt는 Node 24.19.0에 포함된 `undici@7.29.0`에서 High-severity GHSA-rfgv-xxqx-mfg5와 GHSA-w293-vg96-wgc3를 보고했고 strict verifier는 `blocking embedded runtime vulnerabilities are not allowed`로 실패 폐쇄했다. Source-fix commit `6a297372d431df7057800740c8eb65480da29950`은 scanner waiver 없이 authenticated Node 24.21.0 source와 embedded `undici@7.29.1`, npm 11.19.0, 새 exact `process.versions` identity/applicability 계약으로 수리한다. 이 commit의 local evidence는 typecheck, `npm audit --audit-level=high` 0건, focused 53/53, repository 5,408/5,409 pass다. 남은 1건은 실행 환경이 Unix-domain socket 생성을 `EPERM`으로 금지해 fixture setup 전에 종료된 local-only 결과이며 성공으로 승격하지 않는다. PR #736의 다음 exact head는 hosted patch-validator image build/raw scan/strict receipt와 모든 required Checks를 새로 통과하기 전까지 Draft/Proposed candidate이며 merge 또는 immutable publication authority가 아니다. + Protected history에는 merged PR #535 exact `82b20b293f0a5f0ac0e69857c1b61dddfe478491`, merged PR #540 exact `05bc2d47c3899ebe17538070f9a30172f90307ac`, merged PR #542 exact `ca839298fcaeec409091dc909789b6f87eb67fdc`, merged PR #547 exact `30b7e7e5cdab8de65715834a16f994b2047eafa6`, merged PR #550 exact `f2ec2dc6709814070cc3e3d6932ce280aee966db`, merged PR #553 exact `3bd9f543e97ce856f78b1c608141436298ce9e74`, merged PR #558 exact `2f91bf8641212ecae435b5fbcc9084cc0acd6295`, merged PR #556 exact `860714cba46dba06260a5dce09d0e9152fcb0a8c`, merged PR #560 exact `5aab7c098f3478069127f34e398326415ec599a4`, merged PR #582 exact `0f20a4dc78e423fd5df49e137a4eb286c7075ea4`, documentation-authority integration #583, protected procedural graph source #585/#586, documentation convergence #587/#588/#590/#595, workflow-backed current-state integration #589, procedural decision/evaluation/authentication integrations #591/#592/#593/#594/#596, State / Checkpoint history #597, Policy / Approval CAS #601, publication preflight #603, Workflow / Task exact-object operability integration #605, documentation-authority convergence #606, merged PR #607 exact `0afd68d2e33b7fd9be2307ba78b370b534cc0f54`, merged PR #610 exact `0a516bc54d682817ffea3206ec8b24b214aa94ea`, merged PR #612 exact `a919be3bc2a04068e25a4592e0c9c26f8e63534b`, merged PR #614 exact `77c5116a2e58e8a154ee893a3825da19c9c1f357`, merged PR #616 exact `da8461f1f5a6d02d926dd295fc88abe2c6167dfa`, documentation-authority convergence PR #617 exact `38b3b56837d875d19ae458f3f5491951ad7afb1c`, merged PR #618 exact `fb166ec6ecc769a19fd4ae4502f6d0994fc89e33`, merged PR #620 exact `b746846e4890c84b568bb29937594e1b17364fa1`, documentation-authority convergence #639, merged PR #640 exact `72ff4897822b5a8122540509346be4f3278a1379`, merged PR #641 exact `73f13b3a3826c5d6f7aafc89d1ff23747f8cf8e9`, merged PR #642 exact `3427e5a66d7a0ea9379ddc7915ba2b3d1872c492`, merged PR #643 exact `b13903e3208232086871d7f117f8f20e753eb583`, merged PR #644 exact `79a89f5775d0fb56de9f1908c1927df0b498b70f`, merged PR #645 exact `1b4dc38581e99b92342769dd03059f5015ee9f04`, merged PR #646 exact `3005086fca2c6376fae951b774230fa1c06ba587`, merged PR #647 exact `ea3a36bc65df88153aa69a05e3144f0b5352b5a6`, merged PR #648 exact `133ce20c4bb7fb9ba05e3e4bedeb0492334faf38`, merged PR #649 exact `f334f40193da8811a6e15efcb2d589aa80104931`, merged PR #650 exact `f59fce008a4da70f89e9927f692b823506594ed5`, documentation-authority convergence #651, merged PR #652 exact `23fabd95f3ef886c6bef0ae9ff6ead50dc0ce342`, documentation-authority convergence PR #653 exact `41ff085c89e27a97a00ce85ced968931f506a147`, merged PR #654 exact `f8d4b6b6b095c7674037a9ea5281b9ffa2f0f864`, merged PR #655 exact `dc851fc9ea67e38799e8c30cf4916a31c9e9661e`, merged PR #657 exact `fa4a2ab25231fa4c8b9a8451a4da6f29e04abaf7`, documentation-authority convergence PR #658 exact `5fe54442068646ad9581d39b23dd6d60a737aa66`, merged PR #659 exact `6357ac65db6a0db9f3bbb3f82196853b787f33cc`, merged PR #661 exact `5a5e147c5a7168277332eb828652b4aea4f7f2ba`, merged PR #663 exact `29dfa79692ec55cdc23ed892081425630ac916b5`, merged PR #665 exact `592ae00f090f642e8cb71ffb132d4a8b7bfe993a`, merged PR #668 exact `efaaebae161a71b1274f5aa0f26e27376a3edb3a`, merged PR #670 exact `14b4c9a46692df0084a03b9a3b2ae4acf1178a83`, merged PR #673 exact `6f9dc4ca047817e4c9ef5ae8a55816493b1c558d`, merged PR #676 exact `f8de7fc27d475c76477271fa50dd7032afd82a6c`, merged PR #681 exact `7072822253b1975cea76dcd7922e1a01d67e7681`, merged PR #678 exact `ac367cb535525742a1b6f3682d3ab5b14ef66f23`, merged PR #683 exact `01adb5af3d26788926164003bc44a761f5ff3654`, merged PR #685 exact `93dcf4c50018ce051e3ca917a4e1d63cbca3102d`, merged PR #687 exact `98ec07552f4081bd4ff6b25f1f3ed691c0f3f2b9`, merged PR #689 exact `b838c352a7cd0f7f58a905857537a3a9675a56f2`, merged PR #708 exact `8e4a35c28418fa3049e1dc13470088e8dacf2306`, merged PR #710 exact `8bc768756a10bab1d32b14039cdcfdb48d001931`, merged PR #714 exact `a1228e72565d4280d01e1f877377ee0d3e107178`, merged PR #717 exact `8f1fe8acdf96e23e5f110e958b08499e8e3a9803`, 그리고 merged PR #719 exact `de91e24fdac66c9963c92ca77971d870a48778e4`가 포함돼 있다. 이 식별자는 역사 증거이지 open-candidate authority가 아니다. 이 baseline과 executable documentation-authority test는 active documentation-authority lane 하나만 write한다. mutation 직전 open PR/Issue/branch를 fresh-read해 writer를 결정하며 merged/closed historical PR 번호를 active sole writer로 고정하지 않는다. 다른 feature lane의 과거 baseline blob은 ordinary/non-force semantic convergence 때 current authority로 승계하지 않는다. @@ -136,7 +138,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Procedural graph publication boundary | advisory/evaluation/preflight가 publication/activation으로 오인될 위험 | protected #585/#586/#589/#597/#714/#601/#719/#603/#652/#663/#678 + issue #584 | source integrated; #714 State / Checkpoint and #719 Policy / Approval minimal-transaction/complete-revalidation/type-admission hardening integrated; response-bound, cancellation-liveness and locked-reader hardening integrated; publication/activation unavailable | released graph contract + live signer trust + lifecycle/revocation + deployed evidence + graph publication/canary | prerequisites 전 graph publication fail closed 유지 | | P0 | Durable workflow/state production evidence | source Durable Object logic·object-size observation이 deployed recovery/SLO로 오인될 위험 | protected #542 + #605 / ADR 0013 / issue #541 | source + exact-object observation integrated; ADR 0013 Proposed | immutable release/deployment + exact-object transaction/restart/recovery + representative storage-growth denominator + p95 + PITR/rollback | approved deployment owner에서 exact protected release 대상으로 runtime/recovery receipt 확보 | | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | -| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 | source/image integrated; publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | operational receipt 뒤 publication/signing 검증 | +| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 / open PR #736 | protected source/image integrated; predecessor scan exposed embedded undici vulnerability; source repair proposed; exact-head hosted receipt와 publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #736 exact-head image build/raw scan/strict receipt를 재검증한 뒤 operational publication/signing 검증 | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | From 09610dab140a4c2069f1073b4625eb41f8a1fc96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:37:49 +0900 Subject: [PATCH 15/36] test(dispatch): close continuation coverage gaps --- src/continuation-dispatch/handler.ts | 33 +- test/continuation-dispatch-entrypoint.test.ts | 164 +++++++++ test/continuation-dispatch-state.test.ts | 139 ++++++++ test/continuation-dispatch-worker.test.ts | 317 +++++++++++++++++- 4 files changed, 633 insertions(+), 20 deletions(-) create mode 100644 test/continuation-dispatch-entrypoint.test.ts diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index 18f1f2543..1e719caa9 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -2,7 +2,6 @@ import { parseExactBearerToken } from "../bearer-authorization"; import type { JwtPayload } from "../index"; import { errorHints, type ErrorCode } from "../error-codes"; import { - ContinuationDispatchContractError, continuationDispatchIdentity, continuationRequestDigest, parseContinuationDispatchRequest, @@ -13,7 +12,6 @@ import { commitContinuationOutcome, finalizeContinuationOutcome, ContinuationDispatchStateConflict, - ContinuationDispatchStateUnavailable, reserveContinuationDispatch, type ContinuationDispatchReceipt, type ContinuationDispatchReservation, @@ -292,8 +290,7 @@ export async function handleContinuationDispatch( let parsed: ContinuationDispatchRequest; try { parsed = parseContinuationDispatchRequest(body); - } catch (error) { - if (!(error instanceof ContinuationDispatchContractError)) throw error; + } catch { return errorResponse("ERR_DISPATCH_REQUEST_INVALID", 400, "Continuation dispatch request is invalid", traceId, startedAt); } @@ -336,10 +333,7 @@ export async function handleContinuationDispatch( if (error instanceof ContinuationDispatchStateConflict) { return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation replay conflicts with retained authority", traceId, startedAt); } - if (error instanceof ContinuationDispatchStateUnavailable) { - return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation state unavailable", traceId, startedAt); - } - throw error; + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation state unavailable", traceId, startedAt); } if (reservation.kind === "in_progress") { return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation dispatch is already in progress", traceId, startedAt); @@ -412,6 +406,10 @@ export async function handleContinuationDispatch( dispatchResult = { outcome: "indeterminate", eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan" }; } + if (!(["accepted", "denied", "indeterminate"] as readonly unknown[]).includes(dispatchResult.outcome)) { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Unexpected continuation outcome", traceId, startedAt); + } + if (dispatchResult.outcome === "indeterminate") { return errorResponse( "ERR_GITHUB_DISPATCH_UPSTREAM", @@ -456,15 +454,12 @@ export async function handleContinuationDispatch( if (dispatchResult.outcome === "accepted") { return successResponse(receipt, traceId, startedAt); } - if (dispatchResult.outcome === "denied") { - return errorResponse( - "ERR_GITHUB_DISPATCH_AUTHORIZATION", - 502, - "GitHub rejected the fixed continuation dispatch", - traceId, - startedAt, - { receipt }, - ); - } - return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Unexpected continuation outcome", traceId, startedAt); + return errorResponse( + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + 502, + "GitHub rejected the fixed continuation dispatch", + traceId, + startedAt, + { receipt }, + ); } diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts new file mode 100644 index 000000000..3cabf06be --- /dev/null +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import entrypoint, { type Env } from "../src/entrypoint"; + +const workflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const workflowSha = "1".repeat(40); +const headSha = "2".repeat(40); +const baseSha = "3".repeat(40); +const signingKid = "continuation-dispatch-production-wiring"; +const discoveryUrl = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const jwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +async function signedJwt(payload: Record): Promise { + const header = encodeJson({ alg: "RS256", kid: signingKid }); + const claims = encodeJson(payload); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${header}.${claims}`), + ); + return `${header}.${claims}.${Buffer.from(signature).toString("base64url")}`; +} + +function dispatchBody(): string { + return JSON.stringify({ + contract_version: "noema.continuation-dispatch.v1", + dispatch_action: "noema_review_continuation", + central_repository: "ContextualWisdomLab/.github", + source_repository: "ContextualWisdomLab/.github", + pull_request_number: 736, + expected_head_sha: headSha, + expected_base_sha: baseSha, + expected_base_ref: "main", + transport_retry_attempt: 1, + }); +} + +function continuationRequest(token: string): Request { + return new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.31", + }, + body: dispatchBody(), + }); +} + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey({ + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, true, ["sign", "verify"]) as CryptoKeyPair; + signingPrivateKey = pair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("continuation dispatch production entrypoint", () => { + it("returns the versioned method boundary and validates both central GitHub App identifiers", async () => { + const wrongMethod = await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches"), + {} as Env, + ); + const missingCentralApp = await entrypoint.fetch(continuationRequest("a.b.c"), { + GITHUB_APP_ID: "1", + } as Env); + const missingCentralInstallation = await entrypoint.fetch(continuationRequest("a.b.c"), { + GITHUB_APP_ID: "1", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "2", + } as Env); + + expect(wrongMethod.status).toBe(405); + expect(wrongMethod.headers.get("allow")).toBe("POST"); + expect([missingCentralApp.status, missingCentralInstallation.status]).toEqual([503, 503]); + }); + + it("verifies a signed production OIDC identity before failing closed on a missing replay guard", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === discoveryUrl) return Response.json({ jwks_uri: jwksUrl }); + if (url === jwksUrl) { + return Response.json({ keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + const now = Math.floor(Date.now() / 1_000); + const token = await signedJwt({ + iss: "https://token.actions.githubusercontent.com", + aud: "cwl-noema-review", + repository_owner: "ContextualWisdomLab", + repository_owner_id: "295022177", + repository: "ContextualWisdomLab/.github", + repository_id: "1274066402", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + job_workflow_ref: workflowRef, + job_workflow_sha: workflowSha, + jti: "continuation-production-wiring-jti", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const { default: isolatedEntrypoint } = await import("../src/entrypoint"); + + const response = await isolatedEntrypoint.fetch(continuationRequest(token), { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: workflowRef, + ALLOWED_WORKFLOW_SHA: workflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-replay-claim", + CONTINUATION_DISPATCH_GITHUB_APP_ID: "2", + CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID: "3", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: { + idFromName: (name: string) => ({ toString: () => name }) as DurableObjectId, + get: () => ({ + fetch: async () => Response.json({ + allowed: true, + limit: 1000, + remaining: 999, + retry_after_seconds: 0, + }), + }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace, + NOEMA_OIDC_REPLAY_GUARD: { + idFromName: (name: string) => ({ toString: () => name }) as DurableObjectId, + get: () => ({ + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { + expires_at_epoch_seconds: number; + }; + return Response.json({ + accepted: true, + expires_at_epoch_seconds: body.expires_at_epoch_seconds, + }, { status: 201 }); + }, + }) as unknown as DurableObjectStub, + } as unknown as DurableObjectNamespace, + } as Env); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "Continuation state unavailable", + }); + }); +}); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index ee7a34ade..bf221d6c9 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -389,6 +389,24 @@ describe("continuation dispatch private command boundary", () => { } }); + it("rejects malformed abort and finalize capabilities before storage", async () => { + const reservationId = fixedReservation().reservationId; + const requests = [ + { operation: "abort", identity, digest, reservation_id: reservationId, extra: true }, + { operation: "abort", identity: "invalid", digest, reservation_id: reservationId }, + { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" }, extra: true }, + { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "indeterminate" } }, + ].map((body) => new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + })); + + for (const request of requests) { + await expect(object().fetch(request).then((response) => response.status)).resolves.toBe(400); + } + }); + it("rejects decoded duplicate keys inside escaped nested array evidence", async () => { const body = `{"operation":"commit","identity":"${identity}","digest":"${digest}",` + `"reservation_id":"${fixedReservation().reservationId}",` @@ -534,6 +552,77 @@ describe("continuation dispatch private command boundary", () => { expect([empty.status, invalid.status]).toEqual([409, 500]); }); + it.each(["abort", "finalize"] as const)( + "distinguishes absent, corrupt, and mismatched retained state during %s", + async (operation) => { + const reservation = fixedReservation(); + const command = { + operation, + identity, + digest, + reservation_id: reservation.reservationId, + ...(operation === "finalize" ? { receipt: { outcome: "accepted" } } : {}), + }; + const invoke = (state: NoemaContinuationDispatchState) => state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(command), + })); + + const absent = await invoke(object()); + const corruptStorage = new MemoryStorage(); + corruptStorage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: corruptStorage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + const { env } = fixture(); + const fresh = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + if (operation === "finalize") { + await commitContinuationOutcome(env, fresh, { outcome: "accepted" }); + } else { + await commitContinuationOutcome(env, fresh, { outcome: "indeterminate" }); + } + const namespace = env.NOEMA_CONTINUATION_DISPATCH_STATE as unknown as InProcessNamespace; + const mismatched = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: namespace.storageByName.get(`continuation:${identity}`)! as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + + expect([absent.status, corrupt.status, mismatched.status]).toEqual([409, 500, 409]); + }, + ); + + it("rejects a finalized receipt that would exceed the retained-state byte envelope", async () => { + const storage = new MemoryStorage(); + const reservation = fixedReservation(); + storage.records.set("continuation-dispatch-state", { + version: "noema.continuation-dispatch-state.v1", + identity, + request_digest: digest, + status: "indeterminate", + reservation_id: reservation.reservationId, + receipt: { outcome: "indeterminate", receipt_id: "same", signature: "small" }, + }); + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const response = await state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "finalize", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "accepted", receipt_id: "same", signature: "x".repeat(7_850) }, + }), + })); + + expect(response.status).toBe(500); + }); + it("rejects a terminal record whose retained state crosses the byte envelope", async () => { const { env } = fixture(); const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); @@ -688,4 +777,54 @@ describe("continuation dispatch state client boundary", () => { { outcome: "accepted" }, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); + + it.each([ + ["abort", abortContinuationReservation, fixedReservation(), undefined], + ["finalize", finalizeContinuationOutcome, fixedReservation(), { outcome: "accepted" }], + ] as const)("rejects a non-canonical %s request before transport", async (_name, operation, reservation, receipt) => { + const invalid = { ...reservation, identity: "invalid" }; + const pending = receipt === undefined + ? operation(envWithTransport(async () => new Response()), invalid as never) + : operation(envWithTransport(async () => new Response()), invalid as never, receipt as never); + + await expect(pending).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps abort transport failure %# to unavailable", + async (failure) => { + await expect(abortContinuationReservation( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps finalize transport failure %# to unavailable", + async (failure) => { + await expect(finalizeContinuationOutcome( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it.each([ + ["abort", abortContinuationReservation, undefined], + ["finalize", finalizeContinuationOutcome, { outcome: "accepted" }], + ] as const)("maps %s conflicts and malformed success decisions", async (_name, operation, receipt) => { + for (const response of [ + new Response(JSON.stringify({ ok: false }), { status: 409, headers: { "content-type": "application/json" } }), + new Response(JSON.stringify({ ok: false }), { status: 200, headers: { "content-type": "application/json" } }), + ]) { + const pending = receipt === undefined + ? operation(envWithTransport(async () => response), fixedReservation()) + : operation(envWithTransport(async () => response), fixedReservation(), receipt); + await expect(pending).rejects.toBeInstanceOf( + response.status === 409 ? ContinuationDispatchStateConflict : ContinuationDispatchStateUnavailable, + ); + } + }); }); diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index 3998bb229..11b767fc3 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -6,7 +6,10 @@ import { handleContinuationDispatch, type ContinuationDispatchHandlerDependencies, } from "../src/continuation-dispatch/handler"; -import { ContinuationDispatchStateConflict } from "../src/continuation-dispatch/dispatch-state"; +import { + ContinuationDispatchStateConflict, + ContinuationDispatchStateUnavailable, +} from "../src/continuation-dispatch/dispatch-state"; import { ContinuationGitHubAdapterError } from "../src/continuation-dispatch/github-adapter"; import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; @@ -211,6 +214,31 @@ describe("continuation dispatch public route", () => { expect(JSON.stringify(payload)).not.toMatch(/token|bearer|assertion|private_key/i); }); + it("accepts the workflow_ref fallback and emits the fixed Strix event identity", async () => { + const { calls, dependencies, env } = harness(); + dependencies.verifyOidc = async () => ({ + repository: requestBody.source_repository, + workflow_ref: workflowRef, + workflow_sha: workflowSha, + iat: 1_800_000_000, + exp: 1_800_000_300, + jti: "task-5-strix-jti", + }); + dependencies.dispatch = async (candidate) => { + calls.push("dispatch"); + expect(candidate.dispatch_action).toBe("strix_scan_continuation"); + return { outcome: "accepted", upstreamStatus: 204, eventType: "strix-scan" }; + }; + + const response = await handleContinuationDispatch(request(JSON.stringify({ + ...requestBody, + dispatch_action: "strix_scan_continuation", + })), env, "trace-task-5", dependencies); + + expect(response.status).toBe(200); + expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch", "finalize"]); + }); + it("returns the exact retained receipt on replay without a second dispatch", async () => { const { calls, dependencies, env } = harness(); const first = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); @@ -291,6 +319,37 @@ describe("continuation dispatch public route", () => { expect(dispatch).not.toHaveBeenCalled(); }); + it("fails unavailable and in-progress state closed before live GitHub access", async () => { + for (const { reserve, status } of [ + { + reserve: async () => { throw new ContinuationDispatchStateUnavailable("state unavailable"); }, + status: 503, + }, + { + reserve: async () => ({ kind: "in_progress" as const, identity: "a".repeat(64), digest: "b".repeat(64) }), + status: 409, + }, + ]) { + const { calls, dependencies, env } = harness(); + dependencies.reserve = reserve; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(calls).toEqual(["claim"]); + } + }); + + it("maps an unexpected reservation implementation failure to unavailable", async () => { + const { calls, dependencies, env } = harness(); + dependencies.reserve = async () => { throw new Error("unexpected state failure"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim"]); + }); + it("releases a fresh reservation when the final live PR check is stale", async () => { const { calls, dependencies, env } = harness(); dependencies.readLivePullRequest = async () => { @@ -304,6 +363,123 @@ describe("continuation dispatch public route", () => { expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); }); + it.each([ + [new ContinuationGitHubAdapterError("identity_denied"), 403], + [new ContinuationGitHubAdapterError("upstream_unavailable"), 503], + [new Error("GitHub unavailable"), 503], + ] as const)("releases the reservation before classifying live-state failure %#", async (failure, status) => { + const { calls, dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { + calls.push("live-pr"); + throw failure; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + + it("fails closed when a reservation cannot be released after live-state failure", async () => { + const { dependencies, env } = harness(); + dependencies.readLivePullRequest = async () => { throw new Error("GitHub unavailable"); }; + dependencies.abort = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + + it("releases the reservation when receipt signing is misconfigured", async () => { + const { calls, dependencies, env } = harness(); + const response = await handleContinuationDispatch(request(), { + ...env, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: "invalid", + }, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); + }); + + it("reports signing failure as state-unavailable when its reservation cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.abort = async () => { throw new Error("state unavailable"); }; + const response = await handleContinuationDispatch(request(), { + ...env, + CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM: "invalid", + }, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + + it("does not dispatch when pre-dispatch evidence cannot be committed", async () => { + const { calls, dependencies, env } = harness(); + dependencies.commit = async () => { + calls.push("commit"); + throw new Error("state unavailable"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "commit"]); + }); + + it.each(["noema_review_continuation", "strix_scan_continuation"] as const)( + "returns retained indeterminate evidence when the %s dispatch transport throws", + async (dispatchAction) => { + const { calls, dependencies, env } = harness(); + dependencies.dispatch = async () => { + calls.push("dispatch"); + throw new Error("transport failed"); + }; + + const response = await handleContinuationDispatch(request(JSON.stringify({ + ...requestBody, + dispatch_action: dispatchAction, + })), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: { outcome: string } } }; + + expect(response.status).toBe(503); + expect(payload.details.receipt.outcome).toBe("indeterminate"); + expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch"]); + }, + ); + + it("returns a signed denied receipt when GitHub rejects the fixed dispatch", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => ({ + outcome: "denied", + upstreamStatus: 403, + eventType: "noema-review", + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + const payload = await response.json() as { details: { receipt: unknown } }; + + expect(response.status).toBe(502); + expect(await verifyContinuationReceipt(payload.details.receipt, signingPublicKeyPem)).toBe(true); + }); + + it("fails closed on an impossible adapter outcome", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => ({ + outcome: "unexpected", + eventType: "noema-review", + }) as never; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ message: "Unexpected continuation outcome" }); + }); + it.each(["verifyOidc", "claimOidc"] as const)( "classifies unavailable %s infrastructure as upstream instead of invalid identity", async (boundary) => { @@ -322,6 +498,32 @@ describe("continuation dispatch public route", () => { }, ); + it.each([ + ["verify", async (dependencies: ContinuationDispatchHandlerDependencies) => { + dependencies.verifyOidc = async () => { throw "invalid identity"; }; + }], + ["claim", async (dependencies: ContinuationDispatchHandlerDependencies) => { + dependencies.claimOidc = async () => false; + }], + ] as const)("denies a non-infrastructure %s failure", async (_boundary, configure) => { + const { dependencies, env } = harness(); + await configure(dependencies); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error_code: "ERR_DISPATCH_IDENTITY_DENIED" }); + }); + + it("denies a non-infrastructure replay-claim exception", async () => { + const { dependencies, env } = harness(); + dependencies.claimOidc = async () => { throw "claim denied"; }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(401); + }); + it("rejects a workflow SHA mismatch before replay claim or GitHub access", async () => { const { calls, dependencies, env } = harness(); dependencies.verifyOidc = async () => ({ @@ -359,6 +561,119 @@ describe("continuation dispatch public route", () => { expect(verify).not.toHaveBeenCalled(); }); + it("rejects malformed JSON and a missing bearer before verification", async () => { + const { dependencies, env } = harness(); + const verify = vi.spyOn(dependencies, "verifyOidc"); + const malformed = await handleContinuationDispatch(request("{"), env, "trace-task-5", dependencies); + const missingBearer = await handleContinuationDispatch(request(JSON.stringify(requestBody), { + headers: { authorization: "", "content-type": "application/json" }, + }), env, "trace-task-5", dependencies); + + expect([malformed.status, missingBearer.status]).toEqual([400, 401]); + expect(verify).not.toHaveBeenCalled(); + }); + + it("rejects an invalid declared length and an absent body", async () => { + const { dependencies, env } = harness(); + const invalidLength = await handleContinuationDispatch(request(JSON.stringify(requestBody), { + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "content-length": "01", + }, + }), env, "trace-task-5", dependencies); + const absent = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { authorization: `Bearer ${testJwt}`, "content-type": "application/json" }, + }, + ), env, "trace-task-5", dependencies); + + expect([invalidLength.status, absent.status]).toEqual([413, 400]); + }); + + it("rejects an absent media type, an oversized declared length, and an absent bearer", async () => { + const { dependencies, env } = harness(); + const noMediaType = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { method: "POST" }, + ), env, "trace-task-5", dependencies); + const declaredOversize = await handleContinuationDispatch(request("{}", { + headers: { + authorization: `Bearer ${testJwt}`, + "content-type": "application/json", + "content-length": "8193", + }, + }), env, "trace-task-5", dependencies); + const noBearer = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }, + ), env, "trace-task-5", dependencies); + + expect([noMediaType.status, declaredOversize.status, noBearer.status]).toEqual([415, 413, 401]); + }); + + it("normalizes body reader acquisition and stream failures", async () => { + const { dependencies, env } = harness(); + const lockedRequest = request(); + const lock = lockedRequest.body!.getReader(); + const locked = await handleContinuationDispatch(lockedRequest, env, "trace-task-5", dependencies); + lock.releaseLock(); + const streamFailure = await handleContinuationDispatch(new Request( + "https://noema.example/v1/continuation-dispatches", + { + method: "POST", + headers: { authorization: `Bearer ${testJwt}`, "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { controller.error(new Error("stream failed")); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }, + ), env, "trace-task-5", dependencies); + + expect([locked.status, streamFailure.status]).toEqual([400, 400]); + }); + + it("keeps rejection authoritative when request-body cancellation rejects", async () => { + const { dependencies, env } = harness(); + const candidate = new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: new ReadableStream({ + cancel() { throw new Error("cancel failed"); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + await Promise.resolve(); + + expect(response.status).toBe(415); + }); + + it("keeps an oversized-stream rejection authoritative when reader cancellation rejects", async () => { + const { dependencies, env } = harness(); + const candidate = new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(8_193)); }, + cancel() { throw new Error("cancel failed"); }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + const response = await handleContinuationDispatch(candidate, env, "trace-task-5", dependencies); + await Promise.resolve(); + + expect(response.status).toBe(413); + }); + it("rejects a declared or streamed body above 8 KiB before OIDC verification", async () => { const { dependencies, env } = harness(); const verify = vi.spyOn(dependencies, "verifyOidc"); From f7bcee6f7e223640c8616fa2cdec645ea4795264 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 02:58:27 +0900 Subject: [PATCH 16/36] test(dispatch): cover integrated broker boundaries --- ...continuation-dispatch-base-success.test.ts | 48 +++++++++++++++++++ test/continuation-dispatch-entrypoint.test.ts | 22 +++++++++ test/runtime-private-key-canonicality.test.ts | 17 +++++++ 3 files changed, 87 insertions(+) create mode 100644 test/continuation-dispatch-base-success.test.ts diff --git a/test/continuation-dispatch-base-success.test.ts b/test/continuation-dispatch-base-success.test.ts new file mode 100644 index 000000000..ef7a3006a --- /dev/null +++ b/test/continuation-dispatch-base-success.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("continuation dispatch base-worker success telemetry", () => { + afterEach(() => { + vi.doUnmock("../src/continuation-dispatch/handler"); + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("records an accepted broker response without fabricating an error code", async () => { + const handleContinuationDispatch = vi.fn(async () => Response.json({ + ok: true, + data: { receipt: { outcome: "accepted" } }, + trace_id: "continuation-success", + })); + vi.doMock("../src/continuation-dispatch/handler", async (importOriginal) => ({ + ...await importOriginal(), + handleContinuationDispatch, + })); + const { default: worker } = await import("../src/index"); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const response = await worker.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": "continuation-success", + }, + body: "{}", + }), + { NOEMA_RATE_LIMIT_PER_MINUTE: "1000" } as never, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("x-trace-id")).toBe("continuation-success"); + expect(handleContinuationDispatch).toHaveBeenCalledOnce(); + const record = JSON.parse(String(log.mock.calls.at(-1)?.[0])) as Record; + expect(record).toMatchObject({ + event: "http_request", + route: "/v1/continuation-dispatches", + method: "POST", + status_code: 200, + trace_id: "continuation-success", + }); + expect(record).not.toHaveProperty("error_code"); + }); +}); diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts index 3cabf06be..54923a97e 100644 --- a/test/continuation-dispatch-entrypoint.test.ts +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -87,6 +87,28 @@ describe("continuation dispatch production entrypoint", () => { expect([missingCentralApp.status, missingCentralInstallation.status]).toEqual([503, 503]); }); + it("rejects a malformed continuation bearer with the dispatch identity contract", async () => { + const response = await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { + authorization: "Bearer invalid", + "content-type": "application/json", + }, + body: dispatchBody(), + }), + {} as Env, + ); + + expect(response.status).toBe(401); + expect(response.headers.get("allow")).toBeNull(); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + message: "Continuation identity is malformed", + }); + }); + it("verifies a signed production OIDC identity before failing closed on a missing replay guard", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { const url = String(input); diff --git a/test/runtime-private-key-canonicality.test.ts b/test/runtime-private-key-canonicality.test.ts index a17ac4b90..f1c04f3af 100644 --- a/test/runtime-private-key-canonicality.test.ts +++ b/test/runtime-private-key-canonicality.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { normalizeGitHubAppPrivateKeyPem } from "../src/github-app-private-key"; import runtimeWorker, { type Env as RuntimeEnv } from "../src/runtime-entrypoint"; import { evaluateRuntimeReadiness } from "../src/runtime-readiness"; +import { continuationReadyBindings } from "./runtime-readiness-fixture"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; @@ -86,4 +87,20 @@ describe("runtime GitHub App private-key canonical authority", () => { expect(result.ready).toBe(false); expect(result.failedChecks).toContain("github_app_private_key"); }); + + it("rejects a syntactically wrapped but non-importable continuation receipt key", async () => { + const keyPair = await generateRsaKeyPair(); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const env = readinessEnv(pemFromPkcs8(pkcs8)); + Object.assign(env, await continuationReadyBindings(env.NOEMA_RATE_LIMITER)); + env.CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM = + "-----BEGIN PRIVATE KEY-----\nAA==\n-----END PRIVATE KEY-----"; + + const result = await evaluateRuntimeReadiness(env); + + expect(result).toEqual({ + ready: false, + failedChecks: ["continuation_receipt_signing_private_key"], + }); + }); }); From fb360d3fb568f956eac2f0903230c1a1b8805d65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:09:50 +0900 Subject: [PATCH 17/36] test(dispatch): expose review boundary gaps --- test/continuation-dispatch-entrypoint.test.ts | 2 + test/continuation-dispatch-state.test.ts | 34 ++++++++++- test/continuation-dispatch-worker.test.ts | 61 ++++++++++++++++--- test/openapi-contract.test.ts | 5 ++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts index 54923a97e..e9505cc72 100644 --- a/test/continuation-dispatch-entrypoint.test.ts +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -102,10 +102,12 @@ describe("continuation dispatch production entrypoint", () => { expect(response.status).toBe(401); expect(response.headers.get("allow")).toBeNull(); + expect(response.headers.get("www-authenticate")).toContain("Bearer"); await expect(response.json()).resolves.toMatchObject({ ok: false, error_code: "ERR_DISPATCH_IDENTITY_DENIED", message: "Continuation identity is malformed", + details: { hint: expect.any(String) }, }); }); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index bf221d6c9..56d27b81f 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { abortContinuationReservation, @@ -21,6 +21,7 @@ const endpoint = "https://noema-continuation-dispatch-state.internal/command"; class MemoryStorage { readonly records = new Map(); transactionCount = 0; + alarmAt: number | null = null; async get(key: string): Promise { return structuredClone(this.records.get(key)) as T | undefined; @@ -39,6 +40,14 @@ class MemoryStorage { return callback(this); } + async getAlarm(): Promise { return this.alarmAt; } + + async setAlarm(scheduledTime: number | Date): Promise { + this.alarmAt = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; + } + + async deleteAlarm(): Promise { this.alarmAt = null; } + replaceOnlyRecord(value: unknown): void { const key = [...this.records.keys()][0]; if (key === undefined) throw new Error("fixture has no continuation state record"); @@ -113,6 +122,7 @@ function envWithTransport( } describe("continuation dispatch exactly-once state", () => { + afterEach(() => vi.restoreAllMocks()); it("grants only the first atomic reservation while an exact concurrent replay stays in progress", async () => { const { env, namespace } = fixture(); @@ -123,6 +133,28 @@ describe("continuation dispatch exactly-once state", () => { expect(first).toHaveProperty("reservationId"); expect(replay).toEqual({ kind: "in_progress", identity, digest }); expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(2); + expect(namespace.storageByName.get(`continuation:${identity}`)?.alarmAt).not.toBeNull(); + }); + + it("removes only an expired reservation when its alarm fires", async () => { + const storage = new MemoryStorage(); + const object = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await object.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest }), + })); + expect(storage.records.size).toBe(1); + vi.spyOn(Date, "now").mockReturnValue(storage.alarmAt! + 1); + + await object.alarm(); + + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); }); it.each(["accepted", "denied", "indeterminate"] as const)( diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index 11b767fc3..33699ef9a 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -112,7 +112,8 @@ function harness() { } return { ...reservation, identity, digest }; }, - dispatch: async () => { + dispatch: async (_request, _env, beforeDispatch) => { + await beforeDispatch(); calls.push("dispatch"); return { outcome: "accepted" as const, upstreamStatus: 204, eventType: "noema-review" as const }; }, @@ -224,7 +225,8 @@ describe("continuation dispatch public route", () => { exp: 1_800_000_300, jti: "task-5-strix-jti", }); - dependencies.dispatch = async (candidate) => { + dependencies.dispatch = async (candidate, _env, beforeDispatch) => { + await beforeDispatch(); calls.push("dispatch"); expect(candidate.dispatch_action).toBe("strix_scan_continuation"); return { outcome: "accepted", upstreamStatus: 204, eventType: "strix-scan" }; @@ -253,6 +255,29 @@ describe("continuation dispatch public route", () => { expect(calls.filter((call) => call === "live-pr")).toHaveLength(1); }); + it.each([ + ["denied", 502, "ERR_GITHUB_DISPATCH_AUTHORIZATION"], + ["indeterminate", 503, "ERR_GITHUB_DISPATCH_UPSTREAM"], + ] as const)("replays retained %s evidence with its original failure class", async (outcome, status, errorCode) => { + const { dependencies, env } = harness(); + dependencies.reserve = async (_stateEnv, identity, digest) => ({ + kind: "replay", + identity, + digest, + outcome, + receipt: { outcome, receipt_id: `retained-${outcome}` }, + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(response.headers.get("x-continuation-replay")).toBe("exact"); + await expect(response.json()).resolves.toMatchObject({ + error_code: errorCode, + details: { receipt: { outcome } }, + }); + }); + it("serializes retry attempt two through the first attempt identity without a second dispatch", async () => { const { calls, dependencies, env } = harness(); let retainedIdentity: string | undefined; @@ -428,14 +453,27 @@ describe("continuation dispatch public route", () => { const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); expect(response.status).toBe(503); - expect(calls).toEqual(["claim", "reserve", "live-pr", "commit"]); + expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "abort"]); + }); + + it("aborts a fresh reservation when dispatch credential preparation fails before commit", async () => { + const { calls, dependencies, env } = harness(); + dependencies.dispatch = async () => { + throw new ContinuationGitHubAdapterError("upstream_unavailable"); + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "abort"]); }); it.each(["noema_review_continuation", "strix_scan_continuation"] as const)( "returns retained indeterminate evidence when the %s dispatch transport throws", async (dispatchAction) => { const { calls, dependencies, env } = harness(); - dependencies.dispatch = async () => { + dependencies.dispatch = async (_request, _env, beforeDispatch) => { + await beforeDispatch(); calls.push("dispatch"); throw new Error("transport failed"); }; @@ -454,11 +492,14 @@ describe("continuation dispatch public route", () => { it("returns a signed denied receipt when GitHub rejects the fixed dispatch", async () => { const { dependencies, env } = harness(); - dependencies.dispatch = async () => ({ + dependencies.dispatch = async (_request, _env, beforeDispatch) => { + await beforeDispatch(); + return { outcome: "denied", upstreamStatus: 403, eventType: "noema-review", - }); + }; + }; const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); const payload = await response.json() as { details: { receipt: unknown } }; @@ -469,10 +510,13 @@ describe("continuation dispatch public route", () => { it("fails closed on an impossible adapter outcome", async () => { const { dependencies, env } = harness(); - dependencies.dispatch = async () => ({ + dependencies.dispatch = async (_request, _env, beforeDispatch) => { + await beforeDispatch(); + return { outcome: "unexpected", eventType: "noema-review", - }) as never; + } as never; + }; const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); @@ -570,6 +614,7 @@ describe("continuation dispatch public route", () => { }), env, "trace-task-5", dependencies); expect([malformed.status, missingBearer.status]).toEqual([400, 401]); + expect(missingBearer.headers.get("www-authenticate")).toContain("Bearer"); expect(verify).not.toHaveBeenCalled(); }); diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index e38bc048f..ac1b0b350 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -65,6 +65,7 @@ describe("machine-readable public HTTP contract", () => { expect(spec.paths["/exchange"].post.responses["502"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["503"]).toBeDefined(); expect(spec.paths["/v1/continuation-dispatches"].post.responses["200"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["408"]).toBeDefined(); expect(spec.paths["/v1/continuation-dispatches"].post.responses["409"]).toBeDefined(); }); @@ -86,6 +87,10 @@ describe("machine-readable public HTTP contract", () => { expect(exchange["x-request-body-read-deadline-ms"]).toBe(10000); expect(exchange.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); expect(exchange.responses["429"].headers["Retry-After"]).toBeDefined(); + + const continuation = spec.paths["/v1/continuation-dispatches"].post; + expect(continuation["x-request-body-read-deadline-ms"]).toBe(10000); + expect(continuation.responses["408"]).toEqual({ $ref: "#/components/responses/RequestTimeout" }); }); it("executes the RE2-safe repository locator against realistic owner/name values", async () => { From 220988246035c8025b8b3e3f01390e642d0da30b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:10:38 +0900 Subject: [PATCH 18/36] fix(dispatch): preserve pre-effect retry authority --- CHANGELOG.md | 1 + docs/product-technical-gap-baseline.md | 3 +- openapi.json | 6 +- src/continuation-dispatch/dispatch-state.ts | 40 +++++++++- src/continuation-dispatch/github-adapter.ts | 30 +++++--- src/continuation-dispatch/handler.ts | 75 ++++++++++++------- src/entrypoint.ts | 11 ++- ...ntinuation-dispatch-github-adapter.test.ts | 20 ++++- test/continuation-dispatch-state.test.ts | 45 +++++++++++ 9 files changed, 184 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8dc705a..a2d08f3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Harden Proposed #736 after exact review: retained denied/indeterminate replays keep their original HTTP failure class, every 401 advertises a Bearer challenge and stable hint, the OpenAPI route publishes its 10-second body deadline, and one shared serializer now defines both the dispatched body and signed digest. Central App credential preparation occurs before durable indeterminate commit; a pre-effect credential or commit failure releases the reservation best-effort, while a five-minute Durable Object alarm removes only abandoned reserved state. These source repairs remain Proposed pending fresh exact-head hosted Checks, independent approval, ordinary merge, immutable receipt-key publication, and downstream consumer revalidation. - exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index bc7151799..66277b829 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ ## Proposed continuation dispatch broker (#735/#736) -The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds outcome-faithful replay status, Bearer challenges/hints, one exact body/digest serializer, central credential preparation before terminal commit, best-effort release on pre-effect failure, and a bounded alarm that removes only expired reservations. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. ## Authority and update rule @@ -140,6 +140,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | | P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 / open PR #736 | protected source/image integrated; predecessor scan exposed embedded Undici vulnerability; source repair and exact final-verifier assertion proposed; fresh exact-head hosted receipt와 publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #736 fresh exact-head image build/raw scan/strict receipt를 재검증한 뒤 operational publication/signing 검증 | | P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단하고, internally consistent한 non-reviewed Undici identity가 final verifier를 통과할 위험 | issue #734; open PRs #736/#737; dependent Draft PR #733 | #737의 exact Undici verifier RED→GREEN delta를 #736에 통합 중이며 local focused verification은 GREEN; fresh exact-head hosted checks와 independent approval open | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | #736 fresh exact-head review/check를 통과시킨 뒤 ordinary merge; #737/#733은 complete carryover와 release 전까지 유지 | +| P0 | Continuation dispatch pre-effect recovery | credential/commit failure or Worker termination can strand a reservation, while replay status or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | RED→GREEN source repair complete locally; exact-head hosted review/Checks and immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | publish ordinary child, re-run exact-head gates, then release and revalidate `.github#2540` against the immutable contract | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | diff --git a/openapi.json b/openapi.json index d06ecf3a9..0aab4640f 100644 --- a/openapi.json +++ b/openapi.json @@ -162,6 +162,7 @@ "description": "Accepts only noema.continuation-dispatch.v1. Validates the exact GitHub Actions OIDC workflow SHA, live pull request state, distributed single-use identity, and durable idempotency reservation before emitting one fixed central event. The credential-free response contains only an Ed25519-signed receipt. Stable failures are ERR_DISPATCH_REQUEST_INVALID, ERR_DISPATCH_IDENTITY_DENIED, ERR_DISPATCH_LIVE_STATE_STALE, ERR_DISPATCH_REPLAY_CONFLICT, ERR_GITHUB_DISPATCH_AUTHORIZATION, and ERR_GITHUB_DISPATCH_UPSTREAM.", "security": [{ "githubActionsOidc": [] }], "x-request-body-limit-bytes": 8192, + "x-request-body-read-deadline-ms": 10000, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchRequest" } } } @@ -172,6 +173,7 @@ "401": { "description": "Identity is missing, invalid, or already consumed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "403": { "$ref": "#/components/responses/Forbidden" }, "405": { "description": "Only POST is admitted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "408": { "$ref": "#/components/responses/RequestTimeout" }, "409": { "description": "Live state, in-progress state, or retained identity conflicts.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, @@ -463,7 +465,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "RequestTimeout": { - "description": "Exchange JSON wire body did not complete within the 10,000 ms absolute read deadline.", + "description": "JSON wire body did not complete within the operation's 10,000 ms absolute read deadline.", "headers": { "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, "Pragma": { "$ref": "#/components/headers/Pragma" }, @@ -474,7 +476,7 @@ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "PayloadTooLarge": { - "description": "Exchange JSON wire body exceeds 8,192 bytes.", + "description": "JSON wire body exceeds the operation's 8,192-byte limit.", "headers": { "Cache-Control": { "$ref": "#/components/headers/CacheControl" }, "Pragma": { "$ref": "#/components/headers/Pragma" }, diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index cb28634ce..7ca7beaef 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -5,6 +5,7 @@ const STATE_RECORD_KEY = "continuation-dispatch-state"; const INTERNAL_ENDPOINT = "https://noema-continuation-dispatch-state.internal/command"; // This private command and retained receipt inherit the released 8 KiB public mutation envelope. const MAX_STATE_JSON_BYTES = 8_192; +const RESERVATION_TTL_MS = 5 * 60_000; const DIGEST_PATTERN = /^[0-9a-f]{64}$/u; const RESERVATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; const TERMINAL_OUTCOMES = new Set([ @@ -67,6 +68,7 @@ type StoredDispatchState = { readonly request_digest: string; readonly status: "reserved" | ContinuationDispatchOutcome; readonly reservation_id: string; + readonly reservation_expires_at_ms?: number; readonly receipt?: ContinuationDispatchReceipt; }; @@ -207,12 +209,15 @@ function isStoredState(value: unknown): value is StoredDispatchState { return false; } if (value.status === "reserved") { - return hasExactKeys(value, [ + return Number.isSafeInteger(value.reservation_expires_at_ms) + && (value.reservation_expires_at_ms as number) > 0 + && hasExactKeys(value, [ "version", "identity", "request_digest", "status", "reservation_id", + "reservation_expires_at_ms", ]); } const encodedBytes = encodedJsonBytes(value); @@ -745,14 +750,17 @@ export class NoemaContinuationDispatchState { return { kind: "existing", reservation: reservationFromStored(stored) } as const; } const reservationId = crypto.randomUUID(); + const reservationExpiresAtMs = Date.now() + RESERVATION_TTL_MS; const next: StoredDispatchState = { version: STATE_VERSION, identity: command.identity, request_digest: command.digest, status: "reserved", reservation_id: reservationId, + reservation_expires_at_ms: reservationExpiresAtMs, }; await transaction.put(STATE_RECORD_KEY, next); + await transaction.setAlarm(reservationExpiresAtMs); return { kind: "created", reservation: { @@ -781,6 +789,7 @@ export class NoemaContinuationDispatchState { return "conflict"; } await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); return "aborted"; }); if (aborted === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); @@ -828,12 +837,16 @@ export class NoemaContinuationDispatchState { return "conflict"; } const next: StoredDispatchState = { - ...stored, + version: stored.version, + identity: stored.identity, + request_digest: stored.request_digest, status: command.receipt.outcome, + reservation_id: stored.reservation_id, receipt: command.receipt, }; if (new TextEncoder().encode(JSON.stringify(next)).byteLength > MAX_STATE_JSON_BYTES) return "invalid"; await transaction.put(STATE_RECORD_KEY, next); + await transaction.deleteAlarm(); return "committed"; }); if (committed === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); @@ -843,4 +856,27 @@ export class NoemaContinuationDispatchState { return jsonResponse({ ok: false, error: "storage_unavailable" }, 503); } } + + /** Removes an abandoned reservation only after its bounded pre-effect lifetime expires. */ + async alarm(): Promise { + await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (stored === undefined) { + await transaction.deleteAlarm(); + return; + } + if (!isStoredState(stored)) return; + if (stored.status !== "reserved") { + await transaction.deleteAlarm(); + return; + } + const expiry = stored.reservation_expires_at_ms!; + if (expiry > Date.now()) { + await transaction.setAlarm(expiry); + return; + } + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + }); + } } diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts index 7e5892a7f..1b1ef5f0a 100644 --- a/src/continuation-dispatch/github-adapter.ts +++ b/src/continuation-dispatch/github-adapter.ts @@ -79,6 +79,21 @@ type GithubPullRequest = { readonly base?: GithubPullRequestSide | null; }; +/** Builds the only repository-dispatch body admitted by the continuation broker. */ +export function centralContinuationDispatchBody(request: ContinuationDispatchRequest): string { + return JSON.stringify({ + event_type: dispatchMapping(request.dispatch_action).eventType, + client_payload: { + source_repository: request.source_repository, + pull_request_number: request.pull_request_number, + expected_head_sha: request.expected_head_sha, + expected_base_sha: request.expected_base_sha, + expected_base_ref: request.expected_base_ref, + transport_retry_attempt: request.transport_retry_attempt, + }, + }); +} + function sourceReadFailure(error: unknown): ContinuationGitHubAdapterError { if (error instanceof ApiError) { if (error.upstreamStatus === 403) { @@ -185,6 +200,7 @@ export async function readAndVerifyLivePullRequest( export async function dispatchCentralContinuation( request: ContinuationDispatchRequest, env: ContinuationGitHubAdapterEnv, + beforeDispatch: () => Promise = async () => undefined, ): Promise { const eventType = dispatchMapping(request.dispatch_action).eventType; let installationToken: string; @@ -199,6 +215,8 @@ export async function dispatchCentralContinuation( throw centralCredentialFailure(error); } + await beforeDispatch(); + let response: Response; try { response = await githubApiRequest( @@ -209,17 +227,7 @@ export async function dispatchCentralContinuation( authorization: `Bearer ${installationToken}`, "content-type": "application/json", }, - body: JSON.stringify({ - event_type: eventType, - client_payload: { - source_repository: request.source_repository, - pull_request_number: request.pull_request_number, - expected_head_sha: request.expected_head_sha, - expected_base_sha: request.expected_base_sha, - expected_base_ref: request.expected_base_ref, - transport_retry_attempt: request.transport_retry_attempt, - }, - }), + body: centralContinuationDispatchBody(request), }, env, ); diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index 1e719caa9..6cfa01f7d 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -4,6 +4,7 @@ import { errorHints, type ErrorCode } from "../error-codes"; import { continuationDispatchIdentity, continuationRequestDigest, + dispatchMapping, parseContinuationDispatchRequest, type ContinuationDispatchRequest, } from "./contract"; @@ -18,6 +19,7 @@ import { type ContinuationDispatchStateEnv, } from "./dispatch-state"; import { + centralContinuationDispatchBody, ContinuationGitHubAdapterError, dispatchCentralContinuation, readAndVerifyLivePullRequest, @@ -60,6 +62,7 @@ export interface ContinuationDispatchHandlerDependencies { dispatch( request: ContinuationDispatchRequest, env: ContinuationGitHubAdapterEnv, + beforeDispatch: () => Promise, ): Promise; commit: typeof commitContinuationOutcome; abort: typeof abortContinuationReservation; @@ -107,6 +110,7 @@ function errorResponse( ): Response { const headers = responseHeaders(traceId, startedAt); if (status === 405) headers.set("allow", "POST"); + if (status === 401) headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); return new Response(JSON.stringify({ ok: false, error_code: code, @@ -131,6 +135,30 @@ function successResponse( }); } +function replayResponse( + reservation: Extract, + traceId: string, + startedAt: number, +): Response { + if (reservation.outcome === "accepted") { + return successResponse(reservation.receipt, traceId, startedAt, true); + } + const response = errorResponse( + reservation.outcome === "denied" + ? "ERR_GITHUB_DISPATCH_AUTHORIZATION" + : "ERR_GITHUB_DISPATCH_UPSTREAM", + reservation.outcome === "denied" ? 502 : 503, + reservation.outcome === "denied" + ? "GitHub rejected the fixed continuation dispatch" + : "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt: reservation.receipt }, + ); + response.headers.set("x-continuation-replay", "exact"); + return response; +} + function cancelBestEffort(body: ReadableStream | null, reason: string): void { if (body === null) return; try { @@ -216,18 +244,7 @@ async function sha256(value: string): Promise { } async function emittedPayloadDigest(request: ContinuationDispatchRequest): Promise { - const eventType = request.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan"; - return sha256(JSON.stringify({ - event_type: eventType, - client_payload: { - source_repository: request.source_repository, - pull_request_number: request.pull_request_number, - expected_head_sha: request.expected_head_sha, - expected_base_sha: request.expected_base_sha, - expected_base_ref: request.expected_base_ref, - transport_retry_attempt: request.transport_retry_attempt, - }, - })); + return sha256(centralContinuationDispatchBody(request)); } function workflowIdentity(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): { @@ -339,7 +356,7 @@ export async function handleContinuationDispatch( return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation dispatch is already in progress", traceId, startedAt); } if (reservation.kind === "replay") { - return successResponse(reservation.receipt, traceId, startedAt, true); + return replayResponse(reservation, traceId, startedAt); } try { @@ -374,7 +391,7 @@ export async function handleContinuationDispatch( emittedPayloadDigest: payloadDigest, dispatchResult: { outcome: "indeterminate", - eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan", + eventType: dispatchMapping(parsed.dispatch_action).eventType, }, oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, receiptId, @@ -389,21 +406,27 @@ export async function handleContinuationDispatch( return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be signed", traceId, startedAt); } - try { - await dependencies.commit( - env, - reservation, - indeterminateReceipt as unknown as ContinuationDispatchReceipt, - ); - } catch { - return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); - } - + let evidenceCommitted = false; let dispatchResult: CentralContinuationDispatchResult; try { - dispatchResult = await dependencies.dispatch(parsed, env); + dispatchResult = await dependencies.dispatch(parsed, env, async () => { + await dependencies.commit( + env, + reservation, + indeterminateReceipt as unknown as ContinuationDispatchReceipt, + ); + evidenceCommitted = true; + }); } catch { - dispatchResult = { outcome: "indeterminate", eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan" }; + if (!evidenceCommitted) { + try { + await dependencies.abort(env, reservation); + } catch { + // An ambiguous commit may already have retained indeterminate evidence. + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation dispatch could not be prepared", traceId, startedAt); + } + dispatchResult = { outcome: "indeterminate", eventType: dispatchMapping(parsed.dispatch_action).eventType }; } if (!(["accepted", "denied", "indeterminate"] as readonly unknown[]).includes(dispatchResult.outcome)) { diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 80b883aa4..fab21043b 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -1,4 +1,4 @@ -import { type ErrorCode } from "./error-codes"; +import { errorHints, type ErrorCode } from "./error-codes"; import { ensureGlobalOutboundFetchPolicy, } from "./outbound-fetch-policy"; @@ -584,12 +584,15 @@ function dispatchBoundaryResponse( "x-latency-ms": "0", }); if (status === 405) headers.set("allow", "POST"); + if (status === 401) headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); + const code = (status === 401 + ? "ERR_DISPATCH_IDENTITY_DENIED" + : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode; return new Response(JSON.stringify({ ok: false, - error_code: (status === 401 - ? "ERR_DISPATCH_IDENTITY_DENIED" - : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode, + error_code: code, message, + details: { hint: errorHints[code] }, trace_id: traceId, }), { status, headers }); } diff --git a/test/continuation-dispatch-github-adapter.test.ts b/test/continuation-dispatch-github-adapter.test.ts index d35dd8e41..b1f054f1b 100644 --- a/test/continuation-dispatch-github-adapter.test.ts +++ b/test/continuation-dispatch-github-adapter.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { + centralContinuationDispatchBody, ContinuationGitHubAdapterError, dispatchCentralContinuation, readAndVerifyLivePullRequest, @@ -228,6 +229,7 @@ function mockCentralDispatch(dispatchResponse: Response | (() => Response)): Ret if (url === `https://api.github.com/repos/${CENTRAL_REPOSITORY}/dispatches`) { expect(init?.method).toBe("POST"); expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${CENTRAL_TOKEN}`); + expect(init?.body).toBe(centralContinuationDispatchBody(request())); expect(JSON.parse(String(init?.body))).toEqual({ event_type: "noema-review", client_payload: { @@ -247,6 +249,7 @@ function mockCentralDispatch(dispatchResponse: Response | (() => Response)): Ret describe("fixed central dispatch", () => { it("classifies a missing central App installation as unavailable, never source-PR stale", async () => { + const beforeDispatch = vi.fn(async () => undefined); vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { expect(init?.redirect).toBe("error"); const url = String(input); @@ -259,10 +262,11 @@ describe("fixed central dispatch", () => { return new Response("unexpected request", { status: 500 }); }); - await expect(dispatchCentralContinuation(request(), env())).rejects.toMatchObject({ + await expect(dispatchCentralContinuation(request(), env(), beforeDispatch)).rejects.toMatchObject({ classification: "upstream_unavailable", upstreamStatus: 404, }); + expect(beforeDispatch).not.toHaveBeenCalled(); }); it("sanitizes a central App installation-token network failure", async () => { @@ -298,6 +302,20 @@ describe("fixed central dispatch", () => { expect(fetchSpy).toHaveBeenCalledTimes(3); }); + it("commits retained indeterminate evidence after credential acquisition and before dispatch", async () => { + const order: string[] = []; + mockCentralDispatch(() => { + order.push("dispatch"); + return new Response(null, { status: 204 }); + }); + + await dispatchCentralContinuation(request(), env(), async () => { + order.push("commit"); + }); + + expect(order).toEqual(["commit", "dispatch"]); + }); + it("maps the released Strix action without caller-selected dispatch authority", async () => { mockCentralDispatch(new Response(null, { status: 204 })).mockImplementation(async (input, init) => { const url = String(input); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index 56d27b81f..c24e07667 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -157,6 +157,51 @@ describe("continuation dispatch exactly-once state", () => { expect(storage.alarmAt).toBeNull(); }); + it("keeps malformed and unexpired state fail closed while clearing irrelevant alarms", async () => { + const makeObject = (storage: MemoryStorage) => new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + + const empty = new MemoryStorage(); + empty.alarmAt = 10; + await makeObject(empty).alarm(); + expect(empty.alarmAt).toBeNull(); + + const malformed = new MemoryStorage(); + malformed.records.set("continuation-dispatch-state", { status: "reserved" }); + malformed.alarmAt = 20; + await makeObject(malformed).alarm(); + expect(malformed.records.size).toBe(1); + expect(malformed.alarmAt).toBe(20); + + const terminal = new MemoryStorage(); + terminal.records.set("continuation-dispatch-state", { + version: "noema.continuation-dispatch-state.v1", + identity, + request_digest: digest, + status: "accepted", + reservation_id: "00000000-0000-4000-8000-000000000000", + receipt: { outcome: "accepted" }, + }); + terminal.alarmAt = 30; + await makeObject(terminal).alarm(); + expect(terminal.records.size).toBe(1); + expect(terminal.alarmAt).toBeNull(); + + const unexpired = new MemoryStorage(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await makeObject(unexpired).fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operation: "reserve", identity, digest }), + })); + const scheduled = unexpired.alarmAt; + await makeObject(unexpired).alarm(); + expect(unexpired.records.size).toBe(1); + expect(unexpired.alarmAt).toBe(scheduled); + }); + it.each(["accepted", "denied", "indeterminate"] as const)( "persists an exact %s outcome and replays its receipt without a new dispatch reservation", async (outcome) => { From 5c6d2f82821ad3039038d1102b225d018d472b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:10:42 +0900 Subject: [PATCH 19/36] docs(dispatch): specify shared payload contract --- src/continuation-dispatch/github-adapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts index 1b1ef5f0a..2c7467802 100644 --- a/src/continuation-dispatch/github-adapter.ts +++ b/src/continuation-dispatch/github-adapter.ts @@ -79,7 +79,11 @@ type GithubPullRequest = { readonly base?: GithubPullRequestSide | null; }; -/** Builds the only repository-dispatch body admitted by the continuation broker. */ +/** + * Builds the only repository-dispatch body admitted by the continuation broker. + * @param request Closed continuation request whose allowlisted action selects the fixed event type. + * @returns The exact JSON bytes used by both GitHub transport and signed payload-digest evidence. + */ export function centralContinuationDispatchBody(request: ContinuationDispatchRequest): string { return JSON.stringify({ event_type: dispatchMapping(request.dispatch_action).eventType, From e8e1532672610719d1096342c0ec1a99cfb4aede Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:35:55 +0900 Subject: [PATCH 20/36] test(dispatch): reproduce review contract gaps --- test/continuation-dispatch-entrypoint.test.ts | 4 + ...ntinuation-dispatch-github-adapter.test.ts | 15 ++ test/continuation-dispatch-state.test.ts | 151 +++++++++++++++--- test/continuation-dispatch-worker.test.ts | 90 ++++++++++- test/openapi-contract.test.ts | 4 + 5 files changed, 234 insertions(+), 30 deletions(-) diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts index 54923a97e..a115b881d 100644 --- a/test/continuation-dispatch-entrypoint.test.ts +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -102,10 +102,14 @@ describe("continuation dispatch production entrypoint", () => { expect(response.status).toBe(401); expect(response.headers.get("allow")).toBeNull(); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_token"', + ); await expect(response.json()).resolves.toMatchObject({ ok: false, error_code: "ERR_DISPATCH_IDENTITY_DENIED", message: "Continuation identity is malformed", + details: { hint: expect.any(String) }, }); }); diff --git a/test/continuation-dispatch-github-adapter.test.ts b/test/continuation-dispatch-github-adapter.test.ts index d35dd8e41..7398b0efe 100644 --- a/test/continuation-dispatch-github-adapter.test.ts +++ b/test/continuation-dispatch-github-adapter.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { ContinuationGitHubAdapterError, dispatchCentralContinuation, + prepareCentralContinuation, readAndVerifyLivePullRequest, type ContinuationGitHubAdapterEnv, } from "../src/continuation-dispatch/github-adapter"; @@ -246,6 +247,20 @@ function mockCentralDispatch(dispatchResponse: Response | (() => Response)): Ret } describe("fixed central dispatch", () => { + it("prepares the central credential before the external dispatch can begin", async () => { + const fetchSpy = mockCentralDispatch(new Response(null, { status: 204 })); + + const prepared = await prepareCentralContinuation(env()); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + await expect(prepared.send(request())).resolves.toEqual({ + outcome: "accepted", + upstreamStatus: 204, + eventType: "noema-review", + }); + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + it("classifies a missing central App installation as unavailable, never source-PR stale", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { expect(init?.redirect).toBe("error"); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index bf221d6c9..0b50c446d 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { abortContinuationReservation, @@ -16,11 +16,13 @@ import * as runtimeEntrypoint from "../src/runtime-entrypoint"; const identity = "a".repeat(64); const digest = "b".repeat(64); +const reservationExpiresAtEpochSeconds = 1_800_000_300; const endpoint = "https://noema-continuation-dispatch-state.internal/command"; class MemoryStorage { readonly records = new Map(); transactionCount = 0; + alarmAt: number | undefined; async get(key: string): Promise { return structuredClone(this.records.get(key)) as T | undefined; @@ -39,6 +41,10 @@ class MemoryStorage { return callback(this); } + async setAlarm(timestamp: number): Promise { + this.alarmAt = timestamp; + } + replaceOnlyRecord(value: unknown): void { const key = [...this.records.keys()][0]; if (key === undefined) throw new Error("fixture has no continuation state record"); @@ -113,11 +119,88 @@ function envWithTransport( } describe("continuation dispatch exactly-once state", () => { + it("schedules reservation recovery at the exact OIDC authorization expiry", async () => { + const { env, namespace } = fixture(); + + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + + expect(namespace.storageByName.get(`continuation:${identity}`)?.alarmAt) + .toBe(reservationExpiresAtEpochSeconds * 1_000); + }); + + it("releases an expired reserved state so a crashed pre-dispatch attempt can be retried", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await state.alarm(); + dateNow.mockRestore(); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).resolves.toMatchObject({ kind: "reserved", identity, digest }); + }); + + it("reschedules a non-expired reservation at its exact retained expiry", async () => { + const { env, namespace } = fixture(); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + storage.alarmAt = undefined; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue( + reservationExpiresAtEpochSeconds * 1_000 - 1, + ); + + await state.alarm(); + dateNow.mockRestore(); + + expect(storage.records.size).toBe(1); + expect(storage.alarmAt).toBe(reservationExpiresAtEpochSeconds * 1_000); + }); + + it("preserves immutable terminal evidence when the reservation alarm fires", async () => { + const { env, namespace } = fixture(); + const reservation = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )); + await commitContinuationOutcome(env, reservation, { outcome: "accepted", receipt_id: "retained" }); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + const state = new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: storage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState); + const dateNow = vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await state.alarm(); + dateNow.mockRestore(); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).resolves.toMatchObject({ kind: "replay", outcome: "accepted" }); + }); + it("grants only the first atomic reservation while an exact concurrent replay stays in progress", async () => { const { env, namespace } = fixture(); - const first = await reserveContinuationDispatch(env, identity, digest); - const replay = await reserveContinuationDispatch(env, identity, digest); + const first = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const replay = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); expect(first).toMatchObject({ kind: "reserved", identity, digest }); expect(first).toHaveProperty("reservationId"); @@ -130,7 +213,7 @@ describe("continuation dispatch exactly-once state", () => { async (outcome) => { const { env, namespace } = fixture(); const reservation = requireNewReservation( - await reserveContinuationDispatch(env, identity, digest), + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), ); const receipt = { outcome, @@ -139,7 +222,7 @@ describe("continuation dispatch exactly-once state", () => { } as const; await commitContinuationOutcome(env, reservation, receipt); - const replay = await reserveContinuationDispatch(env, identity, digest); + const replay = await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); expect(replay).toEqual({ kind: "replay", identity, digest, outcome, receipt }); expect(namespace.storageByName.get(`continuation:${identity}`)?.transactionCount).toBe(3); @@ -148,22 +231,22 @@ describe("continuation dispatch exactly-once state", () => { it("rejects the same logical identity with a different request digest without changing retained state", async () => { const { env, namespace } = fixture(); - await reserveContinuationDispatch(env, identity, digest); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); const storage = namespace.storageByName.get(`continuation:${identity}`)!; const retained = structuredClone([...storage.records.values()][0]); - await expect(reserveContinuationDispatch(env, identity, "c".repeat(64))) + await expect(reserveContinuationDispatch(env, identity, "c".repeat(64), reservationExpiresAtEpochSeconds)) .rejects.toBeInstanceOf(ContinuationDispatchStateConflict); expect([...storage.records.values()][0]).toEqual(retained); }); it("releases only the exact fresh reservation before an external effect", async () => { const { env } = fixture(); - const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); await abortContinuationReservation(env, reservation); - await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toMatchObject({ + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toMatchObject({ kind: "reserved", identity, digest, @@ -172,7 +255,7 @@ describe("continuation dispatch exactly-once state", () => { it("upgrades retained indeterminate evidence without changing immutable receipt authority", async () => { const { env } = fixture(); - const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); const pending = { outcome: "indeterminate" as const, receipt_id: "same-receipt", @@ -190,7 +273,7 @@ describe("continuation dispatch exactly-once state", () => { await commitContinuationOutcome(env, reservation, pending); await finalizeContinuationOutcome(env, reservation, accepted); - await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ kind: "replay", identity, digest, @@ -202,7 +285,7 @@ describe("continuation dispatch exactly-once state", () => { it("requires the original reservation capability before committing a terminal outcome", async () => { const { env } = fixture(); const reservation = requireNewReservation( - await reserveContinuationDispatch(env, identity, digest), + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), ); await expect(commitContinuationOutcome(env, { @@ -213,7 +296,7 @@ describe("continuation dispatch exactly-once state", () => { receipt_id: "wrong-capability", })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); - await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ kind: "in_progress", identity, digest, @@ -230,11 +313,11 @@ describe("continuation dispatch exactly-once state", () => { { version: "noema.continuation-dispatch-state.v1", identity, request_digest: digest, status: "reserved", reservation_id: crypto.randomUUID(), receipt: {} }, ])("fails closed without replacing malformed retained state %#", async (corrupt) => { const { env, namespace } = fixture(); - await reserveContinuationDispatch(env, identity, digest); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); const storage = namespace.storageByName.get(`continuation:${identity}`)!; storage.replaceOnlyRecord(corrupt); - await expect(reserveContinuationDispatch(env, identity, digest)) + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); expect([...storage.records.values()][0]).toEqual(corrupt); }); @@ -242,14 +325,14 @@ describe("continuation dispatch exactly-once state", () => { it("rejects an oversized receipt while preserving the in-progress reservation", async () => { const { env } = fixture(); const reservation = requireNewReservation( - await reserveContinuationDispatch(env, identity, digest), + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), ); await expect(commitContinuationOutcome(env, reservation, { outcome: "accepted", evidence: "x".repeat(16_384), })).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); - await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ kind: "in_progress", identity, digest, @@ -259,7 +342,7 @@ describe("continuation dispatch exactly-once state", () => { it("retains nested JSON receipt evidence without changing its semantic structure", async () => { const { env } = fixture(); const reservation = requireNewReservation( - await reserveContinuationDispatch(env, identity, digest), + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), ); const receipt = { outcome: "accepted", @@ -268,7 +351,7 @@ describe("continuation dispatch exactly-once state", () => { await commitContinuationOutcome(env, reservation, receipt); - await expect(reserveContinuationDispatch(env, identity, digest)).resolves.toEqual({ + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)).resolves.toEqual({ kind: "replay", identity, digest, @@ -424,7 +507,8 @@ describe("continuation dispatch private command boundary", () => { const response = await object().fetch(new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, - body: `{"operation" :"reserve","identity":"${identity}","digest":"${digest}"}`, + body: `{"operation" :"reserve","identity":"${identity}","digest":"${digest}",` + + `"reservation_expires_at":${reservationExpiresAtEpochSeconds}}`, })); expect(response.status).toBe(201); @@ -463,7 +547,12 @@ describe("continuation dispatch private command boundary", () => { const response = await object().fetch(new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ operation: "reserve", identity, digest: "invalid" }), + body: JSON.stringify({ + operation: "reserve", + identity, + digest: "invalid", + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), })); expect(response.status).toBe(400); @@ -516,7 +605,12 @@ describe("continuation dispatch private command boundary", () => { const response = await state.fetch(new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ operation: "reserve", identity, digest }), + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), })); expect(response.status).toBe(503); @@ -577,7 +671,7 @@ describe("continuation dispatch private command boundary", () => { storage: corruptStorage as unknown as DurableObjectStorage, } as unknown as DurableObjectState)); const { env } = fixture(); - const fresh = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const fresh = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); if (operation === "finalize") { await commitContinuationOutcome(env, fresh, { outcome: "accepted" }); } else { @@ -625,7 +719,7 @@ describe("continuation dispatch private command boundary", () => { it("rejects a terminal record whose retained state crosses the byte envelope", async () => { const { env } = fixture(); - const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest)); + const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); const receipt = { outcome: "accepted", evidence: "x".repeat(7_900) } as const; await expect(commitContinuationOutcome(env, reservation, receipt)) @@ -656,6 +750,7 @@ describe("continuation dispatch state client boundary", () => { envWithTransport(async () => response), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); @@ -671,6 +766,7 @@ describe("continuation dispatch state client boundary", () => { envWithTransport(async () => response), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); @@ -685,6 +781,7 @@ describe("continuation dispatch state client boundary", () => { envWithTransport(async () => response), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); lock.releaseLock(); }); @@ -698,6 +795,7 @@ describe("continuation dispatch state client boundary", () => { env, field === "identity" ? "invalid" : identity, field === "digest" ? "invalid" : digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); @@ -711,7 +809,7 @@ describe("continuation dispatch state client boundary", () => { }, ); - await expect(reserveContinuationDispatch(env, identity, digest)) + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }, ); @@ -725,6 +823,7 @@ describe("continuation dispatch state client boundary", () => { }), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }, ); @@ -738,6 +837,7 @@ describe("continuation dispatch state client boundary", () => { envWithTransport(async () => invalid), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); const mismatched = new Response(JSON.stringify({ @@ -751,6 +851,7 @@ describe("continuation dispatch state client boundary", () => { envWithTransport(async () => mismatched), identity, digest, + reservationExpiresAtEpochSeconds, )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index 11b767fc3..c64d116c7 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -112,6 +112,10 @@ function harness() { } return { ...reservation, identity, digest }; }, + prepareDispatch: async () => { + calls.push("prepare-dispatch"); + return { send: async () => ({ outcome: "accepted", upstreamStatus: 204, eventType: "noema-review" }) }; + }, dispatch: async () => { calls.push("dispatch"); return { outcome: "accepted" as const, upstreamStatus: 204, eventType: "noema-review" as const }; @@ -209,7 +213,15 @@ describe("continuation dispatch public route", () => { expect(response.headers.get("x-oidc-replay-protection")).toBe("verified-before-dispatch"); expect(payload.ok).toBe(true); expect(payload.trace_id).toBe("trace-task-5"); - expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch", "finalize"]); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); await expect(verifyContinuationReceipt(payload.data.receipt, signingPublicKeyPem)).resolves.toBe(true); expect(JSON.stringify(payload)).not.toMatch(/token|bearer|assertion|private_key/i); }); @@ -236,7 +248,15 @@ describe("continuation dispatch public route", () => { })), env, "trace-task-5", dependencies); expect(response.status).toBe(200); - expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch", "finalize"]); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); }); it("returns the exact retained receipt on replay without a second dispatch", async () => { @@ -253,6 +273,34 @@ describe("continuation dispatch public route", () => { expect(calls.filter((call) => call === "live-pr")).toHaveLength(1); }); + it.each([ + ["denied", 502, "ERR_GITHUB_DISPATCH_AUTHORIZATION"], + ["indeterminate", 503, "ERR_GITHUB_DISPATCH_UPSTREAM"], + ] as const)("preserves the %s outcome contract on exact replay", async (outcome, status, errorCode) => { + const { calls, dependencies, env } = harness(); + dependencies.reserve = async (_env, identity, digest) => { + calls.push("reserve"); + return { + kind: "replay", + identity, + digest, + outcome, + receipt: { outcome, receipt_id: `retained-${outcome}` }, + }; + }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(status); + expect(response.headers.get("x-continuation-replay")).toBe("exact"); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: errorCode, + details: { receipt: { outcome, receipt_id: `retained-${outcome}` } }, + }); + expect(calls).toEqual(["claim", "reserve"]); + }); + it("serializes retry attempt two through the first attempt identity without a second dispatch", async () => { const { calls, dependencies, env } = harness(); let retainedIdentity: string | undefined; @@ -428,7 +476,26 @@ describe("continuation dispatch public route", () => { const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); expect(response.status).toBe(503); - expect(calls).toEqual(["claim", "reserve", "live-pr", "commit"]); + expect(calls).toEqual(["claim", "reserve", "live-pr", "prepare-dispatch", "commit", "abort"]); + }); + + it("releases the reservation when the central credential cannot be prepared", async () => { + const { calls, dependencies, env } = harness(); + Object.assign(dependencies, { + prepareDispatch: async () => { + calls.push("prepare-dispatch"); + throw new ContinuationGitHubAdapterError("upstream_unavailable", 503); + }, + }); + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + expect(calls).toEqual(["claim", "reserve", "live-pr", "prepare-dispatch", "abort"]); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "Central dispatch credential unavailable", + }); }); it.each(["noema_review_continuation", "strix_scan_continuation"] as const)( @@ -448,7 +515,14 @@ describe("continuation dispatch public route", () => { expect(response.status).toBe(503); expect(payload.details.receipt.outcome).toBe("indeterminate"); - expect(calls).toEqual(["claim", "reserve", "live-pr", "commit", "dispatch"]); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + ]); }, ); @@ -512,7 +586,13 @@ describe("continuation dispatch public route", () => { const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); expect(response.status).toBe(401); - await expect(response.json()).resolves.toMatchObject({ error_code: "ERR_DISPATCH_IDENTITY_DENIED" }); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_token"', + ); + await expect(response.json()).resolves.toMatchObject({ + error_code: "ERR_DISPATCH_IDENTITY_DENIED", + details: { hint: expect.any(String) }, + }); }); it("denies a non-infrastructure replay-claim exception", async () => { diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index e38bc048f..1fd7d1830 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -65,6 +65,7 @@ describe("machine-readable public HTTP contract", () => { expect(spec.paths["/exchange"].post.responses["502"]).toBeDefined(); expect(spec.paths["/exchange"].post.responses["503"]).toBeDefined(); expect(spec.paths["/v1/continuation-dispatches"].post.responses["200"]).toBeDefined(); + expect(spec.paths["/v1/continuation-dispatches"].post.responses["408"]).toBeDefined(); expect(spec.paths["/v1/continuation-dispatches"].post.responses["409"]).toBeDefined(); }); @@ -86,6 +87,9 @@ describe("machine-readable public HTTP contract", () => { expect(exchange["x-request-body-read-deadline-ms"]).toBe(10000); expect(exchange.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); expect(exchange.responses["429"].headers["Retry-After"]).toBeDefined(); + + const continuation = spec.paths["/v1/continuation-dispatches"].post; + expect(continuation["x-request-body-read-deadline-ms"]).toBe(10000); }); it("executes the RE2-safe repository locator against realistic owner/name values", async () => { From a6ad7254b45c080bc2fdcb99621601f4b99e87ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:36:19 +0900 Subject: [PATCH 21/36] fix(dispatch): harden pre-effect recovery --- CHANGELOG.md | 1 + docs/TRD.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- docs/threat-model.md | 2 +- openapi.json | 2 + src/continuation-dispatch/dispatch-state.ts | 67 ++++++++++-- src/continuation-dispatch/github-adapter.ts | 109 +++++++++++++------- src/continuation-dispatch/handler.ts | 80 ++++++++++---- src/entrypoint.ts | 13 ++- test/continuation-dispatch-state.test.ts | 13 +++ test/continuation-dispatch-worker.test.ts | 3 +- 11 files changed, 221 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8dc705a..a9ba8cbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. - exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. diff --git a/docs/TRD.md b/docs/TRD.md index 6de227dec..3027f6bea 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -35,7 +35,7 @@ src/runtime-entrypoint.ts ### 2.1 Continuation dispatch broker -`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Transport retry metadata를 제외한 logical-effect identity로 `NOEMA_CONTINUATION_DISPATCH_STATE`의 SQLite owner를 고르되 complete request digest는 retry attempt를 포함한다. Exact replay는 live PR 재조회보다 먼저 retained receipt를 반환하며, fresh reservation만 live PR을 검증한다. 외부 dispatch 전에 signed `indeterminate` evidence를 durable commit하고, 204/denial 뒤에는 같은 receipt authority를 accepted/denied로만 강화한다. 따라서 final commit 실패나 crash는 두 번째 dispatch authority를 만들지 않는다. 별도 central App은 fixed `ContextualWisdomLab/.github` event만 호출하고 응답에는 GitHub credential이 없다. +`POST /v1/continuation-dispatches`는 exact-URL, bearer envelope, distributed rate limit, workflow trust, OIDC verification/replay 계층을 재사용한다. Transport retry metadata를 제외한 logical-effect identity로 `NOEMA_CONTINUATION_DISPATCH_STATE`의 SQLite owner를 고르되 complete request digest는 retry attempt를 포함한다. Exact replay는 live PR 재조회보다 먼저 retained receipt를 반환하며, fresh reservation만 live PR을 검증한다. denied/indeterminate replay는 최초 결과와 동일한 `502`/`503` 상태를 유지하고 exact-replay header를 반환한다. Central App credential은 외부 effect가 없는 준비 단계에서 먼저 발급한 뒤, exact dispatch body digest에 결속된 signed `indeterminate` evidence를 durable commit한다. 준비 또는 commit 실패는 best-effort reservation abort 뒤 실패-폐쇄하며 dispatch를 수행하지 않는다. Commit 이후에는 준비된 sender만 fixed event를 전송하고, 204/denial 뒤 같은 receipt authority를 accepted/denied로만 강화한다. 준비/commit 사이 crash로 남은 `reserved` record는 임의 TTL이 아니라 retained OIDC `exp`의 정확한 시각에 Durable Object alarm으로만 회수한다. Terminal record는 alarm이 삭제하지 않는다. 따라서 post-effect finalization failure나 crash는 두 번째 dispatch authority를 만들지 않는다. 별도 central App은 fixed `ContextualWisdomLab/.github` event만 호출하고 응답에는 GitHub credential이 없다. Required bindings: `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH_GITHUB_APP_PRIVATE_KEY_PEM`, `CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID`, `CONTINUATION_RECEIPT_SIGNING_PRIVATE_KEY_PEM`, `CONTINUATION_RECEIPT_SIGNING_KEY_ID`, `NOEMA_CONTINUATION_DISPATCH_STATE`. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index bc7151799..5d89b6216 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ ## Proposed continuation dispatch broker (#735/#736) -The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. The expiry is authorization evidence, not an invented TTL or retry heuristic; terminal accepted/denied/indeterminate evidence remains immutable. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. ## Authority and update rule diff --git a/docs/threat-model.md b/docs/threat-model.md index 9266d2392..4794bcecd 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -9,7 +9,7 @@ ## Continuation dispatch boundary -`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Retry 1/2는 하나의 logical-effect owner에서 직렬화되고 complete-digest drift는 conflict다. 외부 호출 전에 signed indeterminate evidence를 durable commit하므로 post-effect crash/finalization failure가 재dispatch를 허용하지 않는다. GitHub live-read와 repository-dispatch 사이를 원자화할 수 없으므로 event는 exact expected head/base/ref를 보존하며, downstream `.github#2540`은 실행 직전 이를 다시 검증해야 한다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. +`POST /v1/continuation-dispatches`는 mutable alias/TOCTOU, repository/workflow identity confusion, stale PR evidence, replay, caller-selected destination/event, broad GitHub capability exfiltration, duplicate effect와 indeterminate outcome을 위협으로 다룬다. Exact URL, closed JSON, OIDC workflow SHA, fresh PR, fixed event, terminal state와 RFC 8785/Ed25519 receipt를 결합하며 GitHub credential을 반환하지 않는다. Retry 1/2는 하나의 logical-effect owner에서 직렬화되고 complete-digest drift는 conflict다. Central App credential 준비는 durable pre-dispatch commit보다 먼저 끝내며, 준비/commit 실패는 reservation을 해제하고 external effect 없이 실패-폐쇄한다. Commit 뒤 crash/finalization failure는 retained indeterminate evidence 때문에 재dispatch를 허용하지 않는다. Commit 전 crash로 고립된 reservation만 retained OIDC authorization `exp`의 정확한 alarm에서 회수하며 terminal evidence는 보존한다. 임의 timeout·retry 횟수·fallback은 recovery authority가 아니다. GitHub live-read와 repository-dispatch 사이를 원자화할 수 없으므로 event는 exact expected head/base/ref를 보존하며, downstream `.github#2540`은 실행 직전 이를 다시 검증해야 한다. Missing state/signing/App/replay authority와 ambiguous outcome은 실패-폐쇄한다. ## 위협 1. 위조된 OIDC 토큰으로 허가되지 않은 토큰 발급 시도 diff --git a/openapi.json b/openapi.json index d06ecf3a9..986d3dc25 100644 --- a/openapi.json +++ b/openapi.json @@ -162,6 +162,7 @@ "description": "Accepts only noema.continuation-dispatch.v1. Validates the exact GitHub Actions OIDC workflow SHA, live pull request state, distributed single-use identity, and durable idempotency reservation before emitting one fixed central event. The credential-free response contains only an Ed25519-signed receipt. Stable failures are ERR_DISPATCH_REQUEST_INVALID, ERR_DISPATCH_IDENTITY_DENIED, ERR_DISPATCH_LIVE_STATE_STALE, ERR_DISPATCH_REPLAY_CONFLICT, ERR_GITHUB_DISPATCH_AUTHORIZATION, and ERR_GITHUB_DISPATCH_UPSTREAM.", "security": [{ "githubActionsOidc": [] }], "x-request-body-limit-bytes": 8192, + "x-request-body-read-deadline-ms": 10000, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationDispatchRequest" } } } @@ -172,6 +173,7 @@ "401": { "description": "Identity is missing, invalid, or already consumed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "403": { "$ref": "#/components/responses/Forbidden" }, "405": { "description": "Only POST is admitted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "408": { "$ref": "#/components/responses/RequestTimeout" }, "409": { "description": "Live state, in-progress state, or retained identity conflicts.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index cb28634ce..59db340f4 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -67,6 +67,7 @@ type StoredDispatchState = { readonly request_digest: string; readonly status: "reserved" | ContinuationDispatchOutcome; readonly reservation_id: string; + readonly reservation_expires_at: number; readonly receipt?: ContinuationDispatchReceipt; }; @@ -74,6 +75,7 @@ type ReserveCommand = { readonly operation: "reserve"; readonly identity: string; readonly digest: string; + readonly reservation_expires_at: number; }; type CommitCommand = { @@ -143,6 +145,12 @@ function isReservationId(value: unknown): value is string { return typeof value === "string" && RESERVATION_ID_PATTERN.test(value); } +function isEpochSeconds(value: unknown): value is number { + return Number.isSafeInteger(value) + && (value as number) > 0 + && (value as number) <= Math.floor(Number.MAX_SAFE_INTEGER / 1_000); +} + function isOutcome(value: unknown): value is ContinuationDispatchOutcome { return typeof value === "string" && TERMINAL_OUTCOMES.has(value as ContinuationDispatchOutcome); } @@ -203,6 +211,7 @@ function isStoredState(value: unknown): value is StoredDispatchState { || !isDigest(value.identity) || !isDigest(value.request_digest) || !isReservationId(value.reservation_id) + || !isEpochSeconds(value.reservation_expires_at) ) { return false; } @@ -213,6 +222,7 @@ function isStoredState(value: unknown): value is StoredDispatchState { "request_digest", "status", "reservation_id", + "reservation_expires_at", ]); } const encodedBytes = encodedJsonBytes(value); @@ -224,6 +234,7 @@ function isStoredState(value: unknown): value is StoredDispatchState { "request_digest", "status", "reservation_id", + "reservation_expires_at", "receipt", ]) && isReceipt(value.receipt) @@ -334,9 +345,22 @@ async function readCommand(request: Request): Promise { function parseCommand(value: unknown): DispatchStateCommand | undefined { if (!isRecord(value) || typeof value.operation !== "string") return undefined; if (value.operation === "reserve") { - if (!hasExactKeys(value, ["operation", "identity", "digest"])) return undefined; - if (!isDigest(value.identity) || !isDigest(value.digest)) return undefined; - return { operation: "reserve", identity: value.identity, digest: value.digest }; + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_expires_at"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isEpochSeconds(value.reservation_expires_at) + ) { + return undefined; + } + return { + operation: "reserve", + identity: value.identity, + digest: value.digest, + reservation_expires_at: value.reservation_expires_at, + }; } if (value.operation === "commit") { if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { @@ -514,6 +538,7 @@ async function stateStub( * @param env Runtime binding for the continuation-dispatch Durable Object namespace. * @param identity Lowercase SHA-256 idempotency identity used only to select one serialization point. * @param digest Lowercase SHA-256 digest of the complete canonical request and workflow identity. + * @param reservationExpiresAtEpochSeconds Exact OIDC authorization expiry that bounds crash recovery. * @returns A fresh reservation capability, an in-progress replay, or a completed immutable replay receipt. * @throws {ContinuationDispatchStateConflict} When the logical identity is retained with another digest. * @throws {ContinuationDispatchStateUnavailable} When binding, transport, parsing, or retained state is untrustworthy. @@ -522,9 +547,10 @@ export async function reserveContinuationDispatch( env: ContinuationDispatchStateEnv, identity: string, digest: string, + reservationExpiresAtEpochSeconds: number, ): Promise { - if (!isDigest(digest)) { - throw new ContinuationDispatchStateUnavailable("continuation request digest is not canonical"); + if (!isDigest(digest) || !isEpochSeconds(reservationExpiresAtEpochSeconds)) { + throw new ContinuationDispatchStateUnavailable("continuation reservation request is not canonical"); } const stub = await stateStub(env, identity); let response: Response; @@ -532,7 +558,12 @@ export async function reserveContinuationDispatch( response = await stub.fetch(INTERNAL_ENDPOINT, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ operation: "reserve", identity, digest }), + body: JSON.stringify({ + operation: "reserve", + identity, + digest, + reservation_expires_at: reservationExpiresAtEpochSeconds, + }), }); } catch (error) { const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; @@ -751,8 +782,10 @@ export class NoemaContinuationDispatchState { request_digest: command.digest, status: "reserved", reservation_id: reservationId, + reservation_expires_at: command.reservation_expires_at, }; await transaction.put(STATE_RECORD_KEY, next); + await transaction.setAlarm(command.reservation_expires_at * 1_000); return { kind: "created", reservation: { @@ -843,4 +876,26 @@ export class NoemaContinuationDispatchState { return jsonResponse({ ok: false, error: "storage_unavailable" }, 503); } } + + /** + * Recovers only an expired pre-dispatch reservation at its retained OIDC authorization boundary. + * Terminal evidence is immutable, corrupt state fails closed, and a premature alarm is rescheduled exactly. + * @returns A promise that resolves after cleanup or exact rescheduling is durably serialized. + */ + async alarm(): Promise { + await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (stored === undefined) return; + if (!isStoredState(stored)) { + throw new Error("continuation dispatch alarm found invalid retained state"); + } + if (stored.status !== "reserved") return; + const expiresAtMilliseconds = stored.reservation_expires_at * 1_000; + if (expiresAtMilliseconds <= Date.now()) { + await transaction.delete(STATE_RECORD_KEY); + return; + } + await transaction.setAlarm(expiresAtMilliseconds); + }); + } } diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts index 7e5892a7f..b6b0df2d8 100644 --- a/src/continuation-dispatch/github-adapter.ts +++ b/src/continuation-dispatch/github-adapter.ts @@ -66,6 +66,12 @@ export type CentralContinuationDispatchResult = { readonly eventType: "noema-review" | "strix-scan"; }; +/** Opaque central-only dispatch capability prepared before any indeterminate receipt is committed. */ +export interface PreparedCentralContinuationDispatch { + /** Sends one closed continuation request without exposing the captured installation token. */ + send(request: ContinuationDispatchRequest): Promise; +} + type GithubRepository = { readonly full_name?: unknown }; type GithubPullRequestSide = { readonly sha?: unknown; @@ -108,6 +114,25 @@ function centralAppEnv(env: ContinuationGitHubAdapterEnv): GitHubAppEnv { }; } +/** + * Serializes the exact fixed repository-dispatch body used for both transport and receipt hashing. + * @param request Closed continuation request whose action maps to one released central event. + * @returns Canonical adapter-owned JSON bytes shared by digest calculation and GitHub transport. + */ +export function centralDispatchBody(request: ContinuationDispatchRequest): string { + return JSON.stringify({ + event_type: dispatchMapping(request.dispatch_action).eventType, + client_payload: { + source_repository: request.source_repository, + pull_request_number: request.pull_request_number, + expected_head_sha: request.expected_head_sha, + expected_base_sha: request.expected_base_sha, + expected_base_ref: request.expected_base_ref, + transport_retry_attempt: request.transport_retry_attempt, + }, + }); +} + /** * Reads the source PR through a read-only installation token and admits only the * exact open, non-draft, non-fork state bound into the verified request. @@ -177,16 +202,13 @@ export async function readAndVerifyLivePullRequest( /** * Mints a distinct central-only installation token and uses it solely for the * fixed `.github` repository-dispatch endpoint and closed event payload. - * @param request Closed request whose released action maps to one fixed event. * @param env Separate source-read and central-dispatch GitHub App bindings. - * @returns A terminal accepted, denied, or indeterminate dispatch result without credentials. + * @returns An opaque prepared sender that retains the short-lived token without exposing it. * @throws {ContinuationGitHubAdapterError} When central credential minting fails before dispatch. */ -export async function dispatchCentralContinuation( - request: ContinuationDispatchRequest, +export async function prepareCentralContinuation( env: ContinuationGitHubAdapterEnv, -): Promise { - const eventType = dispatchMapping(request.dispatch_action).eventType; +): Promise { let installationToken: string; try { const installation = await createGitHubInstallationToken( @@ -199,39 +221,48 @@ export async function dispatchCentralContinuation( throw centralCredentialFailure(error); } - let response: Response; - try { - response = await githubApiRequest( - "/repos/ContextualWisdomLab/.github/dispatches", - { - method: "POST", - headers: { - authorization: `Bearer ${installationToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - event_type: eventType, - client_payload: { - source_repository: request.source_repository, - pull_request_number: request.pull_request_number, - expected_head_sha: request.expected_head_sha, - expected_base_sha: request.expected_base_sha, - expected_base_ref: request.expected_base_ref, - transport_retry_attempt: request.transport_retry_attempt, + return Object.freeze({ + async send(request: ContinuationDispatchRequest): Promise { + const eventType = dispatchMapping(request.dispatch_action).eventType; + let response: Response; + try { + response = await githubApiRequest( + "/repos/ContextualWisdomLab/.github/dispatches", + { + method: "POST", + headers: { + authorization: `Bearer ${installationToken}`, + "content-type": "application/json", + }, + body: centralDispatchBody(request), }, - }), - }, - env, - ); - } catch { - return Object.freeze({ outcome: "indeterminate", eventType }); - } + env, + ); + } catch { + return Object.freeze({ outcome: "indeterminate", eventType }); + } - if (response.status === 204) { - return Object.freeze({ outcome: "accepted", upstreamStatus: 204, eventType }); - } - if (response.status === 403 || response.status === 404 || response.status === 422) { - return Object.freeze({ outcome: "denied", upstreamStatus: response.status, eventType }); - } - return Object.freeze({ outcome: "indeterminate", upstreamStatus: response.status, eventType }); + if (response.status === 204) { + return Object.freeze({ outcome: "accepted", upstreamStatus: 204, eventType }); + } + if (response.status === 403 || response.status === 404 || response.status === 422) { + return Object.freeze({ outcome: "denied", upstreamStatus: response.status, eventType }); + } + return Object.freeze({ outcome: "indeterminate", upstreamStatus: response.status, eventType }); + }, + }); +} + +/** + * Prepares the central credential and immediately sends one fixed dispatch for direct adapter callers. + * @param request Closed continuation request whose action maps to one released central event. + * @param env Separate source-read and central-dispatch GitHub App bindings. + * @returns A terminal accepted, denied, or indeterminate dispatch result without credentials. + * @throws {ContinuationGitHubAdapterError} When central credential preparation fails before dispatch. + */ +export async function dispatchCentralContinuation( + request: ContinuationDispatchRequest, + env: ContinuationGitHubAdapterEnv, +): Promise { + return (await prepareCentralContinuation(env)).send(request); } diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index 1e719caa9..4eb92e0e2 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -4,6 +4,7 @@ import { errorHints, type ErrorCode } from "../error-codes"; import { continuationDispatchIdentity, continuationRequestDigest, + dispatchMapping, parseContinuationDispatchRequest, type ContinuationDispatchRequest, } from "./contract"; @@ -18,10 +19,12 @@ import { type ContinuationDispatchStateEnv, } from "./dispatch-state"; import { + centralDispatchBody, ContinuationGitHubAdapterError, - dispatchCentralContinuation, + prepareCentralContinuation, readAndVerifyLivePullRequest, type CentralContinuationDispatchResult, + type PreparedCentralContinuationDispatch, type ContinuationGitHubAdapterEnv, type VerifiedLivePullRequest, } from "./github-adapter"; @@ -56,10 +59,14 @@ export interface ContinuationDispatchHandlerDependencies { env: ContinuationDispatchStateEnv, identity: string, digest: string, + reservationExpiresAtEpochSeconds: number, ): Promise; + prepareDispatch( + env: ContinuationGitHubAdapterEnv, + ): Promise; dispatch( request: ContinuationDispatchRequest, - env: ContinuationGitHubAdapterEnv, + prepared: PreparedCentralContinuationDispatch, ): Promise; commit: typeof commitContinuationOutcome; abort: typeof abortContinuationReservation; @@ -70,7 +77,10 @@ export interface ContinuationDispatchHandlerDependencies { export const continuationDispatchDependencies = { readLivePullRequest: readAndVerifyLivePullRequest, reserve: reserveContinuationDispatch, - dispatch: dispatchCentralContinuation, + prepareDispatch: prepareCentralContinuation, + dispatch: (request: ContinuationDispatchRequest, prepared: PreparedCentralContinuationDispatch) => ( + prepared.send(request) + ), commit: commitContinuationOutcome, abort: abortContinuationReservation, finalize: finalizeContinuationOutcome, @@ -107,6 +117,9 @@ function errorResponse( ): Response { const headers = responseHeaders(traceId, startedAt); if (status === 405) headers.set("allow", "POST"); + if (status === 401) { + headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); + } return new Response(JSON.stringify({ ok: false, error_code: code, @@ -216,18 +229,7 @@ async function sha256(value: string): Promise { } async function emittedPayloadDigest(request: ContinuationDispatchRequest): Promise { - const eventType = request.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan"; - return sha256(JSON.stringify({ - event_type: eventType, - client_payload: { - source_repository: request.source_repository, - pull_request_number: request.pull_request_number, - expected_head_sha: request.expected_head_sha, - expected_base_sha: request.expected_base_sha, - expected_base_ref: request.expected_base_ref, - transport_retry_attempt: request.transport_retry_attempt, - }, - })); + return sha256(centralDispatchBody(request)); } function workflowIdentity(claims: JwtPayload, env: ContinuationDispatchHandlerEnv): { @@ -328,7 +330,7 @@ export async function handleContinuationDispatch( const identity = await continuationDispatchIdentity(parsed, { workflow_sha: workflow.workflowSha }); let reservation: ContinuationDispatchReservation; try { - reservation = await dependencies.reserve(env, identity, digest); + reservation = await dependencies.reserve(env, identity, digest, workflow.exp); } catch (error) { if (error instanceof ContinuationDispatchStateConflict) { return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation replay conflicts with retained authority", traceId, startedAt); @@ -339,7 +341,28 @@ export async function handleContinuationDispatch( return errorResponse("ERR_DISPATCH_REPLAY_CONFLICT", 409, "Continuation dispatch is already in progress", traceId, startedAt); } if (reservation.kind === "replay") { - return successResponse(reservation.receipt, traceId, startedAt, true); + if (reservation.outcome === "accepted") { + return successResponse(reservation.receipt, traceId, startedAt, true); + } + const replay = reservation.outcome === "denied" + ? errorResponse( + "ERR_GITHUB_DISPATCH_AUTHORIZATION", + 502, + "GitHub rejected the fixed continuation dispatch", + traceId, + startedAt, + { receipt: reservation.receipt }, + ) + : errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "GitHub continuation dispatch outcome is indeterminate", + traceId, + startedAt, + { receipt: reservation.receipt }, + ); + replay.headers.set("x-continuation-replay", "exact"); + return replay; } try { @@ -374,7 +397,7 @@ export async function handleContinuationDispatch( emittedPayloadDigest: payloadDigest, dispatchResult: { outcome: "indeterminate", - eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan", + eventType: dispatchMapping(parsed.dispatch_action).eventType, }, oidcLifetime: { iat: workflow.iat, exp: workflow.exp }, receiptId, @@ -389,6 +412,18 @@ export async function handleContinuationDispatch( return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be signed", traceId, startedAt); } + let preparedDispatch: PreparedCentralContinuationDispatch; + try { + preparedDispatch = await dependencies.prepareDispatch(env); + } catch { + try { + await dependencies.abort(env, reservation); + } catch { + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation reservation could not be released", traceId, startedAt); + } + return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Central dispatch credential unavailable", traceId, startedAt); + } + try { await dependencies.commit( env, @@ -396,14 +431,19 @@ export async function handleContinuationDispatch( indeterminateReceipt as unknown as ContinuationDispatchReceipt, ); } catch { + try { + await dependencies.abort(env, reservation); + } catch { + // A concurrent durable commit remains authoritative and must not be replaced. + } return errorResponse("ERR_GITHUB_DISPATCH_UPSTREAM", 503, "Continuation evidence could not be committed", traceId, startedAt); } let dispatchResult: CentralContinuationDispatchResult; try { - dispatchResult = await dependencies.dispatch(parsed, env); + dispatchResult = await dependencies.dispatch(parsed, preparedDispatch); } catch { - dispatchResult = { outcome: "indeterminate", eventType: parsed.dispatch_action === "noema_review_continuation" ? "noema-review" : "strix-scan" }; + dispatchResult = { outcome: "indeterminate", eventType: dispatchMapping(parsed.dispatch_action).eventType }; } if (!(["accepted", "denied", "indeterminate"] as readonly unknown[]).includes(dispatchResult.outcome)) { diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 80b883aa4..1bcaaa822 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -1,4 +1,4 @@ -import { type ErrorCode } from "./error-codes"; +import { errorHints, type ErrorCode } from "./error-codes"; import { ensureGlobalOutboundFetchPolicy, } from "./outbound-fetch-policy"; @@ -584,12 +584,17 @@ function dispatchBoundaryResponse( "x-latency-ms": "0", }); if (status === 405) headers.set("allow", "POST"); + if (status === 401) { + headers.set("www-authenticate", 'Bearer realm="noema", error="invalid_token"'); + } + const code = (status === 401 + ? "ERR_DISPATCH_IDENTITY_DENIED" + : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode; return new Response(JSON.stringify({ ok: false, - error_code: (status === 401 - ? "ERR_DISPATCH_IDENTITY_DENIED" - : "ERR_DISPATCH_REQUEST_INVALID") satisfies ErrorCode, + error_code: code, message, + details: { hint: errorHints[code] }, trace_id: traceId, }), { status, headers }); } diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index 0b50c446d..033fa6e92 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -696,6 +696,7 @@ describe("continuation dispatch private command boundary", () => { request_digest: digest, status: "indeterminate", reservation_id: reservation.reservationId, + reservation_expires_at: reservationExpiresAtEpochSeconds, receipt: { outcome: "indeterminate", receipt_id: "same", signature: "small" }, }); const state = new NoemaContinuationDispatchState({ @@ -799,6 +800,18 @@ describe("continuation dispatch state client boundary", () => { )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); }); + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER])( + "rejects a non-canonical reservation expiry %s before transport", + async (reservationExpiry) => { + const env = envWithTransport(async () => { + throw new Error("transport must not run"); + }); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiry)) + .rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + it.each([new Error("namespace failed"), "namespace failed"])( "maps namespace lookup failure %# without leaking transport details", async (failure) => { diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index c64d116c7..27bdd46c2 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -99,8 +99,9 @@ function harness() { baseRef: "main", }; }, - reserve: async (_env, identity, digest) => { + reserve: async (_env, identity, digest, reservationExpiresAt) => { calls.push("reserve"); + expect(reservationExpiresAt).toBe(1_800_000_300); if (retained !== undefined) { return { kind: "replay" as const, From c87d001fa33bd5034a6c1fdf8958c4287bf97615 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:44:41 +0900 Subject: [PATCH 22/36] test(dispatch): cover exact expiry recovery paths --- test/continuation-dispatch-state.test.ts | 18 ++++++++++++++ test/continuation-dispatch-worker.test.ts | 30 +++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index 3699e1a1f..53bd79169 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -149,6 +149,24 @@ describe("continuation dispatch exactly-once state", () => { expect(storage.alarmAt).toBeNull(); }); + it("removes an expired retained reservation before rejecting its stale authority", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + + await expect(reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + + expect(storage.records.size).toBe(0); + expect(storage.alarmAt).toBeNull(); + }); + it("cannot commit after exact OIDC expiry even when the alarm is delayed", async () => { const { env, namespace } = fixture(); vi.spyOn(Date, "now").mockReturnValue(1_000); diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index 7c5edd57d..e0d03fa95 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -3,6 +3,7 @@ import baseWorker from "../src/index"; import runtimeWorker from "../src/runtime-entrypoint"; import protectedWorker from "../src/worker"; import { + continuationDispatchDependencies, handleContinuationDispatch, type ContinuationDispatchHandlerDependencies, } from "../src/continuation-dispatch/handler"; @@ -145,6 +146,20 @@ function harness() { } describe("continuation dispatch public route", () => { + it("delegates the default dispatch boundary to the prepared credential", async () => { + const send = vi.fn(async () => ({ + outcome: "accepted" as const, + upstreamStatus: 204, + eventType: "noema-review" as const, + })); + + await expect(continuationDispatchDependencies.dispatch( + requestBody, + { assertFresh: () => undefined, send }, + )).resolves.toMatchObject({ outcome: "accepted", upstreamStatus: 204 }); + expect(send).toHaveBeenCalledWith(requestBody); + }); + it("applies the distributed rate limit before dispatch authentication", async () => { const limiterFetch = vi.fn(async () => Response.json({ allowed: false, @@ -535,6 +550,21 @@ describe("continuation dispatch public route", () => { }); }); + it("fails closed when a preparation failure reservation cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.prepareDispatch = async () => { + throw new ContinuationGitHubAdapterError("upstream_unavailable", 503); + }; + dependencies.abort = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Continuation reservation could not be released", + }); + }); + it("releases the reservation when the prepared central credential expires before commit", async () => { const { calls, dependencies, env } = harness(); dependencies.prepareDispatch = async () => { From c66fb9ad634b851fb109094c72134823173cb35a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:05:00 +0900 Subject: [PATCH 23/36] test(dispatch): cover fresh reserve after expiry --- test/continuation-dispatch-state.test.ts | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index 53bd79169..1efd569b3 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -16,7 +16,7 @@ import * as runtimeEntrypoint from "../src/runtime-entrypoint"; const identity = "a".repeat(64); const digest = "b".repeat(64); -const reservationExpiresAtEpochSeconds = 1_800_000_300; +const reservationExpiresAtEpochSeconds = Math.floor(Date.now() / 1_000) + 3_600; const endpoint = "https://noema-continuation-dispatch-state.internal/command"; class MemoryStorage { @@ -167,6 +167,31 @@ describe("continuation dispatch exactly-once state", () => { expect(storage.alarmAt).toBeNull(); }); + it("creates a fresh reservation after expired retained authority is removed", async () => { + const { env, namespace } = fixture(); + vi.spyOn(Date, "now").mockReturnValue(1_000); + const first = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + reservationExpiresAtEpochSeconds, + )); + const storage = namespace.storageByName.get(`continuation:${identity}`)!; + vi.spyOn(Date, "now").mockReturnValue(reservationExpiresAtEpochSeconds * 1_000); + const freshExpiry = reservationExpiresAtEpochSeconds + 60; + + const replacement = requireNewReservation(await reserveContinuationDispatch( + env, + identity, + digest, + freshExpiry, + )); + + expect(replacement.reservationId).not.toBe(first.reservationId); + expect(storage.records.size).toBe(1); + expect(storage.alarmAt).toBe(freshExpiry * 1_000); + }); + it("cannot commit after exact OIDC expiry even when the alarm is delayed", async () => { const { env, namespace } = fixture(); vi.spyOn(Date, "now").mockReturnValue(1_000); From 7965976904a46277a39ea84cd67c8da5dc21447d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:05:18 +0900 Subject: [PATCH 24/36] fix(dispatch): reserve after expired authority cleanup --- src/continuation-dispatch/dispatch-state.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index b112ce3db..d1a2f2dad 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -767,20 +767,23 @@ export class NoemaContinuationDispatchState { if (command.operation === "reserve") { const decision = await this.storage.transaction(async (transaction) => { const stored = await transaction.get(STATE_RECORD_KEY); - if (stored !== undefined) { - if (!isStoredState(stored)) return { kind: "invalid" } as const; + let retained = stored; + if (retained !== undefined) { + if (!isStoredState(retained)) return { kind: "invalid" } as const; if ( - stored.status === "reserved" - && stored.reservation_expires_at! * 1_000 <= Date.now() + retained.status === "reserved" + && retained.reservation_expires_at! * 1_000 <= Date.now() ) { await transaction.delete(STATE_RECORD_KEY); await transaction.deleteAlarm(); - return { kind: "expired" } as const; + retained = undefined; } - if (stored.identity !== command.identity || stored.request_digest !== command.digest) { + } + if (retained !== undefined) { + if (retained.identity !== command.identity || retained.request_digest !== command.digest) { return { kind: "conflict" } as const; } - return { kind: "existing", reservation: reservationFromStored(stored) } as const; + return { kind: "existing", reservation: reservationFromStored(retained) } as const; } if (command.reservation_expires_at * 1_000 <= Date.now()) { return { kind: "expired" } as const; From 427151064896f10a67561eba039589c3853d41a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:06:30 +0900 Subject: [PATCH 25/36] docs(gap): record exact-expiry reservation recovery --- docs/product-technical-gap-baseline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e323d0973..c84970b28 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ ## Proposed continuation dispatch broker (#735/#736) -The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, exact GitHub App token-expiry checks before commit/send, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. Reservation creation and commit enforce that expiry inside the serialized transaction even when alarm delivery is late; existing v1 terminal evidence remains replayable while a legacy reservation without exact expiry fails closed. The expiry is authorization evidence, not an invented TTL or retry heuristic; terminal accepted/denied/indeterminate evidence remains immutable. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, exact GitHub App token-expiry checks before commit/send, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. Reservation creation and commit enforce that expiry inside the serialized transaction even when alarm delivery is late; existing v1 terminal evidence remains replayable while a legacy reservation without exact expiry fails closed. The expiry is authorization evidence, not an invented TTL or retry heuristic; an expired retained reservation is deleted and treated as absent in the same transaction, so only a newly presented still-live OIDC expiry can create a replacement while stale incoming authority remains a conflict. Terminal accepted/denied/indeterminate evidence remains immutable. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. ## Authority and update rule From b1dddb78bb223aeee52eb2268bd627f4f87f8180 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:06:42 +0900 Subject: [PATCH 26/36] docs(changelog): record expiry recovery repair --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c01c6cc43..ae922efc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased -- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late, legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. +- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative state tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. - exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. From 5d8aff6ababc5e540fc23dd78bbf96316622edc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:08:20 +0900 Subject: [PATCH 27/36] fix(dispatch): narrow retained state before reuse --- src/continuation-dispatch/dispatch-state.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index d1a2f2dad..78c0acdbb 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -767,24 +767,21 @@ export class NoemaContinuationDispatchState { if (command.operation === "reserve") { const decision = await this.storage.transaction(async (transaction) => { const stored = await transaction.get(STATE_RECORD_KEY); - let retained = stored; - if (retained !== undefined) { - if (!isStoredState(retained)) return { kind: "invalid" } as const; + if (stored !== undefined) { + if (!isStoredState(stored)) return { kind: "invalid" } as const; if ( - retained.status === "reserved" - && retained.reservation_expires_at! * 1_000 <= Date.now() + stored.status === "reserved" + && stored.reservation_expires_at! * 1_000 <= Date.now() ) { await transaction.delete(STATE_RECORD_KEY); await transaction.deleteAlarm(); - retained = undefined; + } else { + if (stored.identity !== command.identity || stored.request_digest !== command.digest) { + return { kind: "conflict" } as const; + } + return { kind: "existing", reservation: reservationFromStored(stored) } as const; } } - if (retained !== undefined) { - if (retained.identity !== command.identity || retained.request_digest !== command.digest) { - return { kind: "conflict" } as const; - } - return { kind: "existing", reservation: reservationFromStored(retained) } as const; - } if (command.reservation_expires_at * 1_000 <= Date.now()) { return { kind: "expired" } as const; } From 8f46eb33e4cb255a17e74928217edd40449eda34 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:09:49 +0900 Subject: [PATCH 28/36] test(dispatch): remove fixed OIDC expiry time bomb --- test/continuation-dispatch-worker.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index e0d03fa95..af3ddb931 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -14,6 +14,8 @@ import { import { ContinuationGitHubAdapterError } from "../src/continuation-dispatch/github-adapter"; import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; +const oidcIssuedAtEpochSeconds = Math.floor(Date.now() / 1_000); +const oidcExpiresAtEpochSeconds = oidcIssuedAtEpochSeconds + 300; const workflowSha = "1".repeat(40); const headSha = "2".repeat(40); const baseSha = "3".repeat(40); @@ -82,8 +84,8 @@ function harness() { repository: requestBody.source_repository, job_workflow_ref: workflowRef, job_workflow_sha: workflowSha, - iat: 1_800_000_000, - exp: 1_800_000_300, + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, jti: "task-5-jti", }), claimOidc: async () => { @@ -102,7 +104,7 @@ function harness() { }, reserve: async (_env, identity, digest, reservationExpiresAt) => { calls.push("reserve"); - expect(reservationExpiresAt).toBe(1_800_000_300); + expect(reservationExpiresAt).toBe(oidcExpiresAtEpochSeconds); if (retained !== undefined) { return { kind: "replay" as const, @@ -251,8 +253,8 @@ describe("continuation dispatch public route", () => { repository: requestBody.source_repository, workflow_ref: workflowRef, workflow_sha: workflowSha, - iat: 1_800_000_000, - exp: 1_800_000_300, + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, jti: "task-5-strix-jti", }); dependencies.dispatch = async (candidate) => { @@ -700,8 +702,8 @@ describe("continuation dispatch public route", () => { repository: requestBody.source_repository, job_workflow_ref: workflowRef, job_workflow_sha: "f".repeat(40), - iat: 1_800_000_000, - exp: 1_800_000_300, + iat: oidcIssuedAtEpochSeconds, + exp: oidcExpiresAtEpochSeconds, jti: "wrong-workflow-sha", }); From 1de7255ae093c95e7f4ed22d158b79a310194f49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:10:09 +0900 Subject: [PATCH 29/36] docs(changelog): cover all expiry fixtures --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae922efc3..bbd2bd9b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased -- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative state tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. +- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative continuation state and Worker tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. - exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. From 20fd1aa7e756797cfea0a8bdaf06645e685d8adc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:11:26 +0900 Subject: [PATCH 30/36] fix(api): document continuation configuration failures --- openapi.json | 5 ++++- test/openapi-contract.test.ts | 17 +++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/openapi.json b/openapi.json index 15de9124b..1a4dddc27 100644 --- a/openapi.json +++ b/openapi.json @@ -223,7 +223,10 @@ "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, "X-Continuation-Replay": { "$ref": "#/components/headers/ContinuationReplay" } }, - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContinuationErrorResponse" } } } + "content": { "application/json": { "schema": { "oneOf": [ + { "$ref": "#/components/schemas/ContinuationErrorResponse" }, + { "$ref": "#/components/schemas/ErrorResponse" } + ] } } } } } } diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index f29117951..dd4505cf0 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -93,12 +93,17 @@ describe("machine-readable public HTTP contract", () => { expect(continuation.responses["408"]).toEqual({ $ref: "#/components/responses/RequestTimeout" }); expect(continuation.responses["401"].headers["WWW-Authenticate"]).toBeDefined(); expect(continuation.responses["200"].headers["X-Continuation-Replay"]).toBeDefined(); - for (const status of ["502", "503"]) { - expect(continuation.responses[status].headers["X-Continuation-Replay"]).toBeDefined(); - expect(continuation.responses[status].content["application/json"].schema).toEqual({ - $ref: "#/components/schemas/ContinuationErrorResponse", - }); - } + expect(continuation.responses["502"].headers["X-Continuation-Replay"]).toBeDefined(); + expect(continuation.responses["502"].content["application/json"].schema).toEqual({ + $ref: "#/components/schemas/ContinuationErrorResponse", + }); + expect(continuation.responses["503"].headers["X-Continuation-Replay"]).toBeDefined(); + expect(continuation.responses["503"].content["application/json"].schema).toEqual({ + oneOf: [ + { $ref: "#/components/schemas/ContinuationErrorResponse" }, + { $ref: "#/components/schemas/ErrorResponse" }, + ], + }); expect(spec.components.schemas.ContinuationErrorDetails).toMatchObject({ type: "object", additionalProperties: false, From 1bd5fce7e8df5a3ecbda109071d9b2bbd7a5917b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:15:38 +0900 Subject: [PATCH 31/36] fix(api): validate overlapping 503 error contracts --- openapi.json | 2 +- test/openapi-contract.test.ts | 59 +++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index 1a4dddc27..2f3de3938 100644 --- a/openapi.json +++ b/openapi.json @@ -223,7 +223,7 @@ "X-Latency-Ms": { "$ref": "#/components/headers/LatencyMs" }, "X-Continuation-Replay": { "$ref": "#/components/headers/ContinuationReplay" } }, - "content": { "application/json": { "schema": { "oneOf": [ + "content": { "application/json": { "schema": { "anyOf": [ { "$ref": "#/components/schemas/ContinuationErrorResponse" }, { "$ref": "#/components/schemas/ErrorResponse" } ] } } } diff --git a/test/openapi-contract.test.ts b/test/openapi-contract.test.ts index dd4505cf0..466e826ae 100644 --- a/test/openapi-contract.test.ts +++ b/test/openapi-contract.test.ts @@ -35,6 +35,39 @@ function schemaMatchesString(spec: Record, schema: Record, schema: Record, value: unknown): boolean { + const resolved = resolveLocalRef(spec, schema); + if (Array.isArray(resolved.allOf) && !resolved.allOf.every((part: Record) => schemaMatchesValue(spec, part, value))) { + return false; + } + if (Array.isArray(resolved.anyOf) && !resolved.anyOf.some((part: Record) => schemaMatchesValue(spec, part, value))) { + return false; + } + if ( + Array.isArray(resolved.oneOf) + && resolved.oneOf.filter((part: Record) => schemaMatchesValue(spec, part, value)).length !== 1 + ) { + return false; + } + if (resolved.const !== undefined && value !== resolved.const) return false; + if (Array.isArray(resolved.enum) && !resolved.enum.includes(value)) return false; + if (resolved.type === "string") { + return typeof value === "string" + && (typeof resolved.pattern !== "string" || new RegExp(resolved.pattern).test(value)); + } + if (resolved.type === "boolean") return typeof value === "boolean"; + if (resolved.type !== "object") return true; + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + + const record = value as Record; + if (Array.isArray(resolved.required) && resolved.required.some((key: string) => !(key in record))) return false; + const properties = resolved.properties ?? {}; + if (resolved.additionalProperties === false && Object.keys(record).some((key) => !(key in properties))) return false; + return Object.entries(properties).every(([key, propertySchema]) => ( + !(key in record) || schemaMatchesValue(spec, propertySchema as Record, record[key]) + )); +} + describe("machine-readable public HTTP contract", () => { it("publishes the supported health, readiness, and exchange surface", async () => { const spec = await loadOpenApi(); @@ -98,12 +131,34 @@ describe("machine-readable public HTTP contract", () => { $ref: "#/components/schemas/ContinuationErrorResponse", }); expect(continuation.responses["503"].headers["X-Continuation-Replay"]).toBeDefined(); - expect(continuation.responses["503"].content["application/json"].schema).toEqual({ - oneOf: [ + const unavailableSchema = continuation.responses["503"].content["application/json"].schema; + expect(unavailableSchema).toEqual({ + anyOf: [ { $ref: "#/components/schemas/ContinuationErrorResponse" }, { $ref: "#/components/schemas/ErrorResponse" }, ], }); + const dispatchUnavailable = { + ok: false, + error_code: "ERR_GITHUB_DISPATCH_UPSTREAM", + message: "GitHub continuation dispatch outcome is indeterminate", + details: { hint: "Retry only with the same idempotency identity." }, + trace_id: "trace-dispatch", + }; + const configurationUnavailable = { + ok: false, + error_code: "ERR_GITHUB_API", + message: "GitHub API configuration is invalid", + details: { policy: "CONTINUATION_DISPATCH_GITHUB_APP_ID must be canonical." }, + trace_id: "trace-configuration", + }; + const [continuationErrorSchema, errorSchema] = unavailableSchema.anyOf; + expect(schemaMatchesValue(spec, continuationErrorSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, errorSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, unavailableSchema, dispatchUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, continuationErrorSchema, configurationUnavailable)).toBe(false); + expect(schemaMatchesValue(spec, errorSchema, configurationUnavailable)).toBe(true); + expect(schemaMatchesValue(spec, unavailableSchema, configurationUnavailable)).toBe(true); expect(spec.components.schemas.ContinuationErrorDetails).toMatchObject({ type: "object", additionalProperties: false, From 2a4488c6210921b9fa1af254868c0dfb764d5be9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:21:19 +0900 Subject: [PATCH 32/36] test(dispatch): reproduce post-commit pre-effect expiry --- ...ntinuation-dispatch-github-adapter.test.ts | 1 + test/continuation-dispatch-state.test.ts | 96 +++++++++++++++++++ test/continuation-dispatch-worker.test.ts | 56 ++++++++++- 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/test/continuation-dispatch-github-adapter.test.ts b/test/continuation-dispatch-github-adapter.test.ts index 30bfa8567..e831ddb11 100644 --- a/test/continuation-dispatch-github-adapter.test.ts +++ b/test/continuation-dispatch-github-adapter.test.ts @@ -325,6 +325,7 @@ describe("fixed central dispatch", () => { expect(() => prepared.assertFresh()).toThrow(ContinuationGitHubAdapterError); await expect(prepared.send(request())).rejects.toMatchObject({ + name: "ContinuationDispatchNotStartedError", classification: "upstream_unavailable", }); expect(fetchSpy).toHaveBeenCalledTimes(2); diff --git a/test/continuation-dispatch-state.test.ts b/test/continuation-dispatch-state.test.ts index 1efd569b3..cb86983ac 100644 --- a/test/continuation-dispatch-state.test.ts +++ b/test/continuation-dispatch-state.test.ts @@ -8,6 +8,7 @@ import { NoemaContinuationDispatchState, commitContinuationOutcome, finalizeContinuationOutcome, + releasePreEffectContinuationOutcome, reserveContinuationDispatch, type ContinuationDispatchReservation, type ContinuationDispatchStateEnv, @@ -443,6 +444,44 @@ describe("continuation dispatch exactly-once state", () => { }); }); + it("releases only the exact committed indeterminate receipt when dispatch never started", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome: "indeterminate" as const, + receipt_id: "pre-effect-receipt", + request_digest: digest, + signature: "pre-effect-signature", + }; + await commitContinuationOutcome(env, reservation, receipt); + await releasePreEffectContinuationOutcome(env, reservation, receipt); + + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .resolves.toMatchObject({ kind: "reserved", identity, digest }); + }); + + it("retains committed evidence when the pre-effect receipt does not match exactly", async () => { + const { env } = fixture(); + const reservation = requireNewReservation( + await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds), + ); + const receipt = { + outcome: "indeterminate" as const, + receipt_id: "retained-receipt", + signature: "retained-signature", + }; + await commitContinuationOutcome(env, reservation, receipt); + + await expect(releasePreEffectContinuationOutcome(env, reservation, { + ...receipt, + signature: "different-signature", + })).rejects.toBeInstanceOf(ContinuationDispatchStateConflict); + await expect(reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)) + .resolves.toMatchObject({ kind: "replay", outcome: "indeterminate", receipt }); + }); + it("upgrades retained indeterminate evidence without changing immutable receipt authority", async () => { const { env } = fixture(); const reservation = requireNewReservation(await reserveContinuationDispatch(env, identity, digest, reservationExpiresAtEpochSeconds)); @@ -669,6 +708,8 @@ describe("continuation dispatch private command boundary", () => { { operation: "abort", identity: "invalid", digest, reservation_id: reservationId }, { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" }, extra: true }, { operation: "finalize", identity, digest, reservation_id: reservationId, receipt: { outcome: "indeterminate" } }, + { operation: "release_pre_effect", identity, digest, reservation_id: reservationId, receipt: { outcome: "accepted" } }, + { operation: "release_pre_effect", identity, digest, reservation_id: reservationId, receipt: { outcome: "indeterminate" }, extra: true }, ].map((body) => new Request(endpoint, { method: "POST", headers: { "content-type": "application/json" }, @@ -877,6 +918,31 @@ describe("continuation dispatch private command boundary", () => { }, ); + it("distinguishes absent and corrupt retained state during pre-effect release", async () => { + const reservation = fixedReservation(); + const command = JSON.stringify({ + operation: "release_pre_effect", + identity, + digest, + reservation_id: reservation.reservationId, + receipt: { outcome: "indeterminate" }, + }); + const invoke = (state: NoemaContinuationDispatchState) => state.fetch(new Request(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: command, + })); + const absent = await invoke(object()); + const corruptStorage = new MemoryStorage(); + corruptStorage.records.set("continuation-dispatch-state", "corrupt"); + const corrupt = await invoke(new NoemaContinuationDispatchState({ + id: { name: `continuation:${identity}` } as DurableObjectId, + storage: corruptStorage as unknown as DurableObjectStorage, + } as unknown as DurableObjectState)); + + expect([absent.status, corrupt.status]).toEqual([409, 500]); + }); + it("rejects a finalized receipt that would exceed the retained-state byte envelope", async () => { const storage = new MemoryStorage(); const reservation = fixedReservation(); @@ -1114,6 +1180,36 @@ describe("continuation dispatch state client boundary", () => { }, ); + it("rejects a non-canonical pre-effect release before transport", async () => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => { throw new Error("transport must not run"); }), + fixedReservation(), + { outcome: "accepted" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + + it.each([new Error("fetch failed"), "fetch failed"])( + "maps pre-effect release transport failure %# to unavailable", + async (failure) => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => { throw failure; }), + fixedReservation(), + { outcome: "indeterminate" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }, + ); + + it("rejects a malformed successful pre-effect release decision", async () => { + await expect(releasePreEffectContinuationOutcome( + envWithTransport(async () => new Response( + JSON.stringify({ ok: false }), + { status: 200, headers: { "content-type": "application/json" } }, + )), + fixedReservation(), + { outcome: "indeterminate" }, + )).rejects.toBeInstanceOf(ContinuationDispatchStateUnavailable); + }); + it.each([ ["abort", abortContinuationReservation, undefined], ["finalize", finalizeContinuationOutcome, { outcome: "accepted" }], diff --git a/test/continuation-dispatch-worker.test.ts b/test/continuation-dispatch-worker.test.ts index af3ddb931..47bf00864 100644 --- a/test/continuation-dispatch-worker.test.ts +++ b/test/continuation-dispatch-worker.test.ts @@ -11,7 +11,10 @@ import { ContinuationDispatchStateConflict, ContinuationDispatchStateUnavailable, } from "../src/continuation-dispatch/dispatch-state"; -import { ContinuationGitHubAdapterError } from "../src/continuation-dispatch/github-adapter"; +import { + ContinuationDispatchNotStartedError, + ContinuationGitHubAdapterError, +} from "../src/continuation-dispatch/github-adapter"; import { verifyContinuationReceipt } from "../src/continuation-dispatch/receipt"; const oidcIssuedAtEpochSeconds = Math.floor(Date.now() / 1_000); @@ -134,6 +137,10 @@ function harness() { abort: async () => { calls.push("abort"); }, + releasePreEffect: async () => { + calls.push("release-pre-effect"); + retained = undefined; + }, finalize: async (_env, _reservation, receipt) => { calls.push("finalize"); retained = receipt; @@ -614,6 +621,53 @@ describe("continuation dispatch public route", () => { }, ); + it("retries when the committed dispatch provably never started", async () => { + const { calls, dependencies, env } = harness(); + let attempts = 0; + dependencies.dispatch = async () => { + calls.push("dispatch"); + attempts += 1; + if (attempts === 1) throw new ContinuationDispatchNotStartedError(); + return { outcome: "accepted", upstreamStatus: 204, eventType: "noema-review" }; + }; + + const first = await handleContinuationDispatch(request(), env, "trace-task-5-first", dependencies); + const retry = await handleContinuationDispatch(request(), env, "trace-task-5-retry", dependencies); + + expect(first.status).toBe(503); + expect(retry.status).toBe(200); + expect(calls).toEqual([ + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "release-pre-effect", + "claim", + "reserve", + "live-pr", + "prepare-dispatch", + "commit", + "dispatch", + "finalize", + ]); + }); + + it("fails closed when pre-effect committed evidence cannot be released", async () => { + const { dependencies, env } = harness(); + dependencies.dispatch = async () => { throw new ContinuationDispatchNotStartedError(); }; + dependencies.releasePreEffect = async () => { throw new Error("state unavailable"); }; + + const response = await handleContinuationDispatch(request(), env, "trace-task-5", dependencies); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + message: "Pre-effect continuation evidence could not be released", + details: { hint: expect.any(String) }, + }); + }); + it("returns a signed denied receipt when GitHub rejects the fixed dispatch", async () => { const { dependencies, env } = harness(); dependencies.dispatch = async () => { From 350a36656343caf7730d107625e695575ef7e238 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:21:32 +0900 Subject: [PATCH 33/36] fix(dispatch): recover proven pre-effect expiry --- src/continuation-dispatch/dispatch-state.ts | 115 +++++++++++++++++++- src/continuation-dispatch/github-adapter.ts | 14 ++- src/continuation-dispatch/handler.ts | 30 ++++- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/continuation-dispatch/dispatch-state.ts b/src/continuation-dispatch/dispatch-state.ts index 78c0acdbb..589d9f95b 100644 --- a/src/continuation-dispatch/dispatch-state.ts +++ b/src/continuation-dispatch/dispatch-state.ts @@ -101,7 +101,20 @@ type FinalizeCommand = { readonly receipt: ContinuationDispatchReceipt; }; -type DispatchStateCommand = ReserveCommand | CommitCommand | AbortCommand | FinalizeCommand; +type ReleasePreEffectCommand = { + readonly operation: "release_pre_effect"; + readonly identity: string; + readonly digest: string; + readonly reservation_id: string; + readonly receipt: ContinuationDispatchReceipt; +}; + +type DispatchStateCommand = + | ReserveCommand + | CommitCommand + | AbortCommand + | FinalizeCommand + | ReleasePreEffectCommand; type CommandRead = | { readonly ok: true; readonly value: unknown } @@ -414,9 +427,37 @@ function parseCommand(value: unknown): DispatchStateCommand | undefined { receipt: value.receipt, }; } + if (value.operation === "release_pre_effect") { + if (!hasExactKeys(value, ["operation", "identity", "digest", "reservation_id", "receipt"])) { + return undefined; + } + if ( + !isDigest(value.identity) + || !isDigest(value.digest) + || !isReservationId(value.reservation_id) + || !isReceipt(value.receipt) + || value.receipt.outcome !== "indeterminate" + ) { + return undefined; + } + return { + operation: "release_pre_effect", + identity: value.identity, + digest: value.digest, + reservation_id: value.reservation_id, + receipt: value.receipt, + }; + } return undefined; } +function sameReceiptExact( + retained: ContinuationDispatchReceipt, + candidate: ContinuationDispatchReceipt, +): boolean { + return JSON.stringify(retained) === JSON.stringify(candidate); +} + function sameReceiptAuthority( retained: ContinuationDispatchReceipt, candidate: ContinuationDispatchReceipt, @@ -679,6 +720,55 @@ export async function abortContinuationReservation( } } +/** + * Removes committed indeterminate evidence only when the caller proves that dispatch never started. + * @param env Runtime binding for the continuation-dispatch Durable Object namespace. + * @param reservation Original reservation capability retained across the pre-effect commit. + * @param receipt Exact indeterminate receipt committed before dispatch. + * @returns A promise that resolves only after the matching terminal record is removed. + * @throws {ContinuationDispatchStateConflict} When capability, state, or exact receipt differs. + * @throws {ContinuationDispatchStateUnavailable} When transport or storage authority is unavailable. + */ +export async function releasePreEffectContinuationOutcome( + env: ContinuationDispatchStateEnv, + reservation: NewReservation, + receipt: ContinuationDispatchReceipt, +): Promise { + if ( + reservation.kind !== "reserved" + || !isDigest(reservation.identity) + || !isDigest(reservation.digest) + || !isReservationId(reservation.reservationId) + || !isReceipt(receipt) + || receipt.outcome !== "indeterminate" + ) { + throw new ContinuationDispatchStateUnavailable("continuation pre-effect outcome is not canonical bounded JSON"); + } + const stub = await stateStub(env, reservation.identity); + let response: Response; + try { + response = await stub.fetch(INTERNAL_ENDPOINT, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + operation: "release_pre_effect", + identity: reservation.identity, + digest: reservation.digest, + reservation_id: reservation.reservationId, + receipt, + }), + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "unknown Durable Object failure"; + throw new ContinuationDispatchStateUnavailable(detail); + } + const decision = await readDecision(response); + if (response.status === 409) throw new ContinuationDispatchStateConflict(); + if (response.status !== 200 || !isRecord(decision) || !hasExactKeys(decision, ["ok"]) || decision.ok !== true) { + throw new ContinuationDispatchStateUnavailable(`continuation state returned HTTP ${response.status}`); + } +} + /** * Replaces a pre-dispatch indeterminate receipt with the exact accepted or denied result. * @param env Runtime binding for the continuation-dispatch Durable Object namespace. @@ -862,6 +952,29 @@ export class NoemaContinuationDispatchState { return jsonResponse({ ok: true }); } + if (command.operation === "release_pre_effect") { + const released = await this.storage.transaction(async (transaction) => { + const stored = await transaction.get(STATE_RECORD_KEY); + if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; + if ( + stored.status !== "indeterminate" + || stored.identity !== command.identity + || stored.request_digest !== command.digest + || stored.reservation_id !== command.reservation_id + || stored.receipt === undefined + || !sameReceiptExact(stored.receipt, command.receipt) + ) { + return "conflict"; + } + await transaction.delete(STATE_RECORD_KEY); + await transaction.deleteAlarm(); + return "released"; + }); + if (released === "invalid") return jsonResponse({ ok: false, error: "invalid_state" }, 500); + if (released === "conflict") return jsonResponse({ ok: false, error: "conflict" }, 409); + return jsonResponse({ ok: true }); + } + const committed = await this.storage.transaction(async (transaction) => { const stored = await transaction.get(STATE_RECORD_KEY); if (!isStoredState(stored)) return stored === undefined ? "conflict" : "invalid"; diff --git a/src/continuation-dispatch/github-adapter.ts b/src/continuation-dispatch/github-adapter.ts index 5e6f50b71..1861744fe 100644 --- a/src/continuation-dispatch/github-adapter.ts +++ b/src/continuation-dispatch/github-adapter.ts @@ -44,6 +44,18 @@ export class ContinuationGitHubAdapterError extends Error { } } +/** + * Signals that exact installation-token expiry was observed before the fixed + * central GitHub dispatch request could begin, preserving safe retry authority. + */ +export class ContinuationDispatchNotStartedError extends ContinuationGitHubAdapterError { + /** Creates a credential-free pre-effect failure. */ + constructor() { + super("upstream_unavailable"); + this.name = "ContinuationDispatchNotStartedError"; + } +} + /** * Exact live pull-request identity retained after source-state verification, * excluding GitHub response metadata that is not part of continuation authority. @@ -227,7 +239,7 @@ export async function prepareCentralContinuation( const assertFresh = (): void => { if (Date.now() >= installationTokenExpiresAtMilliseconds) { - throw new ContinuationGitHubAdapterError("upstream_unavailable"); + throw new ContinuationDispatchNotStartedError(); } }; diff --git a/src/continuation-dispatch/handler.ts b/src/continuation-dispatch/handler.ts index f44f52d73..2386cd322 100644 --- a/src/continuation-dispatch/handler.ts +++ b/src/continuation-dispatch/handler.ts @@ -12,6 +12,7 @@ import { abortContinuationReservation, commitContinuationOutcome, finalizeContinuationOutcome, + releasePreEffectContinuationOutcome, ContinuationDispatchStateConflict, reserveContinuationDispatch, type ContinuationDispatchReceipt, @@ -20,6 +21,7 @@ import { } from "./dispatch-state"; import { centralContinuationDispatchBody, + ContinuationDispatchNotStartedError, ContinuationGitHubAdapterError, prepareCentralContinuation, readAndVerifyLivePullRequest, @@ -70,6 +72,7 @@ export interface ContinuationDispatchHandlerDependencies { ): Promise; commit: typeof commitContinuationOutcome; abort: typeof abortContinuationReservation; + releasePreEffect: typeof releasePreEffectContinuationOutcome; finalize: typeof finalizeContinuationOutcome; } @@ -83,6 +86,7 @@ export const continuationDispatchDependencies = { ), commit: commitContinuationOutcome, abort: abortContinuationReservation, + releasePreEffect: releasePreEffectContinuationOutcome, finalize: finalizeContinuationOutcome, }; @@ -446,7 +450,31 @@ export async function handleContinuationDispatch( let dispatchResult: CentralContinuationDispatchResult; try { dispatchResult = await dependencies.dispatch(parsed, preparedDispatch); - } catch { + } catch (error) { + if (error instanceof ContinuationDispatchNotStartedError) { + try { + await dependencies.releasePreEffect( + env, + reservation, + indeterminateReceipt as unknown as ContinuationDispatchReceipt, + ); + } catch { + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Pre-effect continuation evidence could not be released", + traceId, + startedAt, + ); + } + return errorResponse( + "ERR_GITHUB_DISPATCH_UPSTREAM", + 503, + "Central dispatch did not start", + traceId, + startedAt, + ); + } dispatchResult = { outcome: "indeterminate", eventType: dispatchMapping(parsed.dispatch_action).eventType }; } From 81568910dccff168681b9b923e2c7667861b1063 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 08:23:17 +0900 Subject: [PATCH 34/36] docs(dispatch): bind pre-effect retry evidence --- CHANGELOG.md | 2 +- docs/adr/0019-continuation-dispatch-broker.md | 4 ++-- docs/api-spec.md | 2 +- docs/product-technical-gap-baseline.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd2bd9b1..b8d1d5f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased -- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; the central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative continuation state and Worker tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. +- Review repair for Proposed #736 makes the continuation boundary's error and recovery contracts executable: the OpenAPI route now declares its 10-second body-read deadline and `408`; every `401` includes the Bearer challenge and stable hint; retained denied/indeterminate replays preserve `502`/`503` plus exact-replay evidence; and the overlapping generic/signed 503 shapes use `anyOf` with a representative payload validation rather than a vacuous `oneOf`. The central App credential is prepared before the durable pre-dispatch commit and checked against its exact GitHub `expires_at` immediately before commit and send; pre-effect preparation/commit failures abort the reservation; and a crash-stranded reservation expires only at the exact retained OIDC `exp` through a Durable Object alarm. Reservation creation and commit transactionally reject expired authority even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, allowing replacement only when the newly presented OIDC expiry remains live. Calendar-relative continuation state and Worker tests remove the fixed-2027 expiry time bomb. Legacy v1 terminal evidence remains replayable, and legacy reservations without exact expiry fail closed. If the prepared central token expires after the indeterminate commit but before the HTTP request starts, a typed no-effect result releases only the exact matching indeterminate receipt and preserves retry authority; transport ambiguity after a request begins remains terminal indeterminate. The adapter and receipt digest share one canonical dispatch body, and dispatch response mapping uses the contract owner. No arbitrary TTL, retry threshold, provider/model policy, or second-dispatch fallback is introduced. Hosted exact-head gates, ordinary merge, immutable release, deployment recovery evidence, and the downstream consumer pin remain required. - exact-head patch-validator image scan에서 차단된 embedded `undici@7.29.0`의 GHSA-rfgv-xxqx-mfg5/GHSA-w293-vg96-wgc3를 source에서 제거한다. 공식 SHA-256으로 인증한 Node.js 24.21.0 source(`process.versions.undici === 7.29.1`)와 그 배포판에 포함된 Node.js 24.21.0/npm 11.19.0 개발 도구 체인으로 repository-owned runtime·GitHub Actions exact-version 계약을 함께 올리고, 새 `process.versions`의 nghttp2/V8/SQLite/zlib identity를 exact applicability 계약에 재결합한다. 기존 `strict-allow-scripts=true`와 schema v3 lockfile change control을 보존하고 scanner ignore·waiver·severity 완화는 추가하지 않으며 raw scan, exact image/source binding, strict receipt verifier와 fail-closed 동작을 유지한다. - Proposed #736 adds `POST /v1/continuation-dispatches`, binding a closed request to exact OIDC workflow/source identity, fresh PR state, retry-independent logical-effect idempotency, pre-dispatch signed indeterminate evidence, fixed central event mapping, and an RFC 8785/Ed25519 credential-free receipt. Exact replay returns retained evidence before another live read, retry-attempt digest drift fails closed without a second dispatch, and stalled bodies have a 10-second absolute deadline. It never returns GitHub credentials. Protected integration, immutable Noema release with its key-id-to-SPKI artifact/digest, and the later `ContextualWisdomLab/.github#2540` exact-head consumer revalidation remain required. - PR #737의 patch-validator static-runtime receipt verifier가 `process.versions.node`뿐 아니라 bundled Undici를 reviewed fixed version `7.29.1`로 exact-match한다. 서로 내부적으로 일치하더라도 Undici `7.29.0` inventory/component/PURL/raw scan evidence는 fail closed하며, simplified verifier fixtures도 실제 Node 24.21.0 runtime identity에 맞춘다. Source/test GREEN은 새 exact-head hosted image/SBOM/provenance/scanner, 독립 승인, ordinary merge, immutable release를 대신하지 않는다. diff --git a/docs/adr/0019-continuation-dispatch-broker.md b/docs/adr/0019-continuation-dispatch-broker.md index 4718c87ac..803ccc217 100644 --- a/docs/adr/0019-continuation-dispatch-broker.md +++ b/docs/adr/0019-continuation-dispatch-broker.md @@ -8,7 +8,7 @@ GitHub repository dispatch requires `Contents: write`; a consumer-visible “dis ## Decision -Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, serializes transport attempts through one logical-effect identity, rereads the live PR after reservation, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. Before the external call it commits a signed indeterminate receipt; accepted or denied evidence may strengthen that same retained authority afterward, while a failed final commit leaves replayable indeterminate evidence and never grants another dispatch. Exact replay returns retained evidence without another live-state read or dispatch. +Noema owns `POST /v1/continuation-dispatches`. It authenticates GitHub Actions OIDC, binds repository plus immutable reusable-workflow SHA, serializes transport attempts through one logical-effect identity, rereads the live PR after reservation, and uses a distinct central App only for one fixed repository-dispatch path/event mapping. Before the external call it commits a signed indeterminate receipt; accepted or denied evidence may strengthen that same retained authority afterward, while a failed final commit leaves replayable indeterminate evidence and never grants another dispatch. Exact replay returns retained evidence without another live-state read or dispatch. A central token that expires after the commit but before the fixed HTTP request begins produces a typed no-effect failure. Only that proof may delete the exact matching indeterminate receipt under the original reservation capability; an ordinary transport failure remains terminal indeterminate because the external effect may exist. The existing `/exchange` remains independent. `.github` owns the consumer workflow and event handler; Noema owns identity, idempotency, fixed dispatch, and receipt evidence only. Model verdict, formal review, merge, release, deployment, and product-domain truth remain separate authorities. @@ -22,7 +22,7 @@ The existing `/exchange` remains independent. `.github` owns the consumer workfl ## Effects and operational scenes -Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. The current Draft does not publish a verification key. Release acceptance requires an immutable `key_id` to SPKI artifact plus digest mapping before any offline-verification claim; private material never appears in receipts or logs. +Operators manage separate source and central Apps, an Ed25519 signing key/key id, and `NOEMA_CONTINUATION_DISPATCH_STATE`. Key loss, Durable Object unavailability, stale PR evidence, or GitHub ambiguity blocks dispatch. Denied/indeterminate receipts require reconciliation rather than blind retry. A 503 without a receipt after the typed pre-effect-expiry path permits a fresh OIDC retry; a 503 carrying indeterminate receipt evidence does not. If exact pre-effect release itself is unavailable, Noema returns a generic 503 and the next call re-reads Durable Object authority rather than assuming deletion or retention. The current Draft does not publish a verification key. Release acceptance requires an immutable `key_id` to SPKI artifact plus digest mapping before any offline-verification claim; private material never appears in receipts or logs. ## Acceptance and release order diff --git a/docs/api-spec.md b/docs/api-spec.md index 36e0ffe7c..a3f07291f 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -53,7 +53,7 @@ ### `POST /v1/continuation-dispatches` -`noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. +`noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. 다만 pre-dispatch indeterminate commit 뒤 central installation token이 exact expiry에 도달했고 HTTP 요청이 아직 시작되지 않았음이 typed adapter result로 증명되면, Durable Object는 동일 reservation capability와 동일 receipt 전체가 일치할 때만 그 record를 제거한다. 이 503 응답에는 terminal receipt가 없으며 새 OIDC 호출은 재시도할 수 있다. 요청 시작 뒤 transport 결과가 불명확하면 indeterminate receipt는 terminal로 유지되어 blind retry를 차단한다. 성공 응답은 `{ ok: true, data: { receipt }, trace_id }`이다. Receipt contains no token and no GitHub credential. `receipt_version`은 `noema.continuation-dispatch-receipt.v1`이다. Detached Ed25519 signature의 offline verification은 immutable release가 `key_id`에 대응하는 SPKI public-key artifact와 digest를 게시한 뒤에만 소비자 acceptance evidence가 된다. 이 Draft source에는 공개키나 retrieval URL이 아직 없으므로 현재 revision은 offline verification 가능성을 주장하지 않는다. 안정 오류 코드는 `ERR_DISPATCH_REQUEST_INVALID`, `ERR_DISPATCH_IDENTITY_DENIED`, `ERR_DISPATCH_LIVE_STATE_STALE`, `ERR_DISPATCH_REPLAY_CONFLICT`, `ERR_GITHUB_DISPATCH_AUTHORIZATION`, `ERR_GITHUB_DISPATCH_UPSTREAM`이다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index c84970b28..3c105281c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2,7 +2,7 @@ ## Proposed continuation dispatch broker (#735/#736) -The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, exact GitHub App token-expiry checks before commit/send, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. Reservation creation and commit enforce that expiry inside the serialized transaction even when alarm delivery is late; existing v1 terminal evidence remains replayable while a legacy reservation without exact expiry fails closed. The expiry is authorization evidence, not an invented TTL or retry heuristic; an expired retained reservation is deleted and treated as absent in the same transaction, so only a newly presented still-live OIDC expiry can create a replacement while stale incoming authority remains a conflict. Terminal accepted/denied/indeterminate evidence remains immutable. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. +The Proposed `POST /v1/continuation-dispatches` source replaces the impossible consumer-side “dispatch-only token” design by retaining `Contents: write` authority inside Noema. Tasks 1–6 cover closed request/digest, retry-independent logical-effect identity, SQLite terminal idempotency, replay-before-live-read recovery, pre-effect reservation abort, pre-dispatch signed indeterminate commit, accepted/denied evidence strengthening, live PR ACL, fixed central adapter, RFC 8785/Ed25519 credential-free receipt, exact URL, distributed rate limit, workflow SHA, stalled-body deadline, readiness, OpenAPI, and stable errors. Review repair adds the missing OpenAPI `408`/deadline declaration, Bearer challenge and stable 401 hint, outcome-preserving exact replay, a single canonical dispatch body, App-credential preparation before the pre-dispatch commit, exact GitHub App token-expiry checks before commit/send, best-effort pre-effect abort, and crash-safe reserved-state recovery at the exact retained OIDC `exp`. Reservation creation and commit enforce that expiry inside the serialized transaction even when alarm delivery is late; expired retained reservations are deleted and treated as absent in the same transaction, so only a newly presented still-live OIDC expiry can create a replacement while stale incoming authority remains a conflict. Calendar-relative state tests remove the fixed-2027 expiry time bomb. Existing v1 terminal evidence remains replayable while a legacy reservation without exact expiry fails closed. RED `2a4488c6` and GREEN `350a3665` close the post-commit/pre-request expiry race: only `ContinuationDispatchNotStartedError` may release the exact matching indeterminate receipt under the original capability, while any transport ambiguity after request start remains terminal. Exact-head predecessor `1bd5fce7` makes the overlapping continuation/generic 503 schemas executable with representative signed and generic payloads and replaces invalid `oneOf` exclusivity with `anyOf`. The expiry is authorization evidence, not an invented TTL or retry heuristic. The GitHub live-read/dispatch interval is not an atomic GitHub primitive; exact expected head/base/ref therefore remains in the fixed event and downstream `ContextualWisdomLab/.github#2540` must revalidate it immediately before review execution. Status remains Proposed until exact-head hosted review/gates, protected integration, immutable Noema release with key-id-to-SPKI artifact/digest, deployment/recovery evidence, and downstream consumer pin. ## Authority and update rule @@ -140,7 +140,7 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | | P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 / open PR #736 | protected source/image integrated; predecessor scan exposed embedded Undici vulnerability; source repair and exact final-verifier assertion proposed; fresh exact-head hosted receipt와 publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #736 fresh exact-head image build/raw scan/strict receipt를 재검증한 뒤 operational publication/signing 검증 | | P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단하고, internally consistent한 non-reviewed Undici identity가 final verifier를 통과할 위험 | issue #734; open PRs #736/#737; dependent Draft PR #733 | #737의 exact Undici verifier RED→GREEN delta를 #736에 통합 중이며 local focused verification은 GREEN; fresh exact-head hosted checks와 independent approval open | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | #736 fresh exact-head review/check를 통과시킨 뒤 ordinary merge; #737/#733은 complete carryover와 release 전까지 유지 | -| P0 | Continuation dispatch pre-effect recovery | credential/commit failure or Worker termination can strand a reservation, while replay status or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | RED→GREEN source repair complete locally; exact-head hosted review/Checks and immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | publish ordinary child, re-run exact-head gates, then release and revalidate `.github#2540` against the immutable contract | +| P0 | Continuation dispatch pre-effect recovery | credential/commit failure, post-commit/pre-request token expiry, or Worker termination can strand a reservation, while replay status, OpenAPI response composition, or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | expiry RED `2a4488c6`→GREEN `350a3665`; OpenAPI executable-schema successor `1bd5fce7`; focused 185/185, typecheck and diff-check GREEN; full local gate remains environment-blocked by Node/npm identity, Unix-socket restriction, and workspace capacity; exact-head hosted review/Checks and immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/exact-receipt-release/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | publish ordinary child, re-run exact-head gates, then release and revalidate `.github#2540` against the immutable contract | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | From 7487485a858f3d26827cabf153bdad3925ab577d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 17:11:52 +0900 Subject: [PATCH 35/36] fix(dispatch): attribute boundary failures to requested route Repair CodeRabbit findings by keeping each API heading with its contract, renumbering TRD sections, and recording the actual continuation-dispatch path for OIDC and configuration failures. Add a RED-to-GREEN regression test for route attribution and update protected documentation assertions. --- docs/TRD.md | 6 ++--- docs/api-spec.md | 4 +--- src/entrypoint.ts | 21 +++++++++-------- test/continuation-dispatch-entrypoint.test.ts | 23 +++++++++++++++++++ ...umentation-current-trust-authority.test.ts | 2 +- ...l-protected-documentation-contract.test.ts | 4 ++-- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index d2591659e..48e0fe0d6 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -45,11 +45,11 @@ Required bindings: `CONTINUATION_DISPATCH_GITHUB_APP_ID`, `CONTINUATION_DISPATCH 배포 acceptance는 기존 unauthenticated 401 contract와 별도로 `scripts/smoke-readiness.sh`의 stalled-body deployment smoke가 실제 408/JSON error response를 관찰해야 합니다. Executable proof는 `test/exchange-body-read-deadline.test.ts`, `test/smoke-readiness.test.ts`, `test/smoke-readiness-endpoint-safety.test.ts`, OpenAPI contract와 `docs/api-spec.md`를 함께 사용합니다. -### 2.2 External Claude plugin admission +### 2.3 External Claude plugin admission Tool / Capability Boundary의 로컬 포트 `src/tool-capability/external-extension-admission.ts`는 Claude community plugin 서술자를 exact repository/commit/path/digest와 독립적으로 pin된 AppGuardrail·격리 영수증에 결합한다. 가변 브랜치/태그, 로컬 경로, 마켓플레이스/카탈로그 불일치, 공급자 키, 광역 GitHub 권한, 미선언 셸/파일/네트워크/비밀/MCP, 다른 제품 승인, 만료·롤백, 카탈로그 drift, 관측 내용의 정책 승격, 제품 런타임 플러그인 래퍼는 실패-폐쇄한다. 이 포트는 HTTP API가 아니며 `/exchange` 권한을 바꾸지 않는다. `context-graph-contracts` 불변 계약이 나오기 전에는 로컬 ACL/테스트 더블이다. -### 2.3 Durable external-extension lifecycle evidence +### 2.4 Durable external-extension lifecycle evidence Protected source includes `DurableExternalExtensionLifecycleRepository` under the Tool Capability / State / Checkpoint boundary. A lifecycle stream is keyed by the canonical `external_extension_id` plus exact upstream repository/commit/path, artifact SHA-256, and marketplace-entry SHA-256. The implementation stores an append-only versioned event chain, a transition-ID idempotency index, and a compact current `head` projection in Durable Object storage; it does not reuse the bounded Workflow / Task observability ledger as canonical lifecycle history. @@ -59,7 +59,7 @@ Exact duplicate transition replay is returned only after immutable request/event Corrupt/truncated audit evidence is not repaired by the application path. Full recovery procedure, restore constraints, rollback semantics, future compaction constraints, and actual Durable Object recovery rehearsal requirements are defined in `docs/external-extension-lifecycle-recovery.md`. ADR 0015 remains `Proposed` while real-backend performance/recovery, immutable owner-issued activation evidence, release and deployment acceptance remain incomplete. -### 2.4 Protected procedural graph advisory runtime +### 2.5 Protected procedural graph advisory runtime Protected source includes four library-only Agent Runtime modules: `procedural-input.ts`, `procedural-graph.ts`, `procedural-evolution.ts`, and `procedural-execution.ts`. The admission path snapshots exact-key plain records and dense bounded arrays through data descriptors, rejects accessors/proxies/extra authority-shaped fields, applies canonical execution identity and bounded procedural identity rules, canonicalizes graph ordering, and computes SHA-256 graph and structure identities under an explicit serialized byte ceiling. These digests are local content identities, not signatures or a released cross-language wire standard. diff --git a/docs/api-spec.md b/docs/api-spec.md index a3f07291f..5734a7b69 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -49,8 +49,6 @@ - 헤더: 공통 no-store/nosniff/trace/latency에 더해 `X-Noema-Readiness: ready|not-ready` - 상세: `docs/runtime-readiness.md` -### `POST /exchange` - ### `POST /v1/continuation-dispatches` `noema.continuation-dispatch.v1`의 닫힌 JSON 요청만 받으며 UTF-8 wire body는 8,192 bytes 이하이다. OIDC repository와 exact reusable-workflow ref/SHA, live open/non-draft/non-fork PR head/base/ref를 모두 결합하고 `ContextualWisdomLab/.github`의 `noema-review` 또는 `strix-scan` 이벤트만 서버 내부에서 선택한다. 같은 canonical digest는 저장된 terminal receipt를 반환하고 두 번째 dispatch를 만들지 않는다. 다만 pre-dispatch indeterminate commit 뒤 central installation token이 exact expiry에 도달했고 HTTP 요청이 아직 시작되지 않았음이 typed adapter result로 증명되면, Durable Object는 동일 reservation capability와 동일 receipt 전체가 일치할 때만 그 record를 제거한다. 이 503 응답에는 terminal receipt가 없으며 새 OIDC 호출은 재시도할 수 있다. 요청 시작 뒤 transport 결과가 불명확하면 indeterminate receipt는 terminal로 유지되어 blind retry를 차단한다. @@ -59,7 +57,7 @@ Primary references: GitHub. (2026). *OpenID Connect reference*; GitHub. (2026). *Create a repository dispatch event*; Rundgren, A., Jordan, B., & Erdtman, S. (2020). *JSON Canonicalization Scheme (JCS)* (RFC 8785). RFC Editor. https://doi.org/10.17487/RFC8785; Cloudflare. (2026). *Web Crypto*. -### `/exchange` request contract +### `POST /exchange` 헤더: - `authorization: Bearer ` - `content-type: application/json` (선택) diff --git a/src/entrypoint.ts b/src/entrypoint.ts index 1bcaaa822..682a4006d 100644 --- a/src/entrypoint.ts +++ b/src/entrypoint.ts @@ -517,12 +517,13 @@ function exchangeBodyResponse(request: Request, failure: ExchangeBodyFailure): R function recordConfigurationFailure( request: Request, + route: string, failure: EgressFailure, ): void { try { console.log(JSON.stringify({ event: "github_api_egress", - route: "/exchange", + route, method: request.method, status_code: 503, error_code: "ERR_GITHUB_API" satisfies ErrorCode, @@ -534,11 +535,11 @@ function recordConfigurationFailure( } } -function recordOidcEnvelopeFailure(request: Request): void { +function recordOidcEnvelopeFailure(request: Request, route: string): void { try { console.log(JSON.stringify({ event: "oidc_token_envelope", - route: "/exchange", + route, method: request.method, status_code: 400, error_code: "ERR_TOKEN_MALFORMED" satisfies ErrorCode, @@ -616,7 +617,7 @@ export default { : dispatchBoundaryResponse(request, 405, "Method not allowed"); } if (!isBoundedOidcBearer(request.headers.get("authorization"))) { - recordOidcEnvelopeFailure(request); + recordOidcEnvelopeFailure(request, url.pathname); return isExchange ? oidcEnvelopeResponse(request) : dispatchBoundaryResponse(request, 401, "Continuation identity is malformed"); @@ -637,7 +638,7 @@ export default { policy: "github-app-id-canonical", }; if (!isCanonicalPositiveSafeInteger(env.GITHUB_APP_ID)) { - recordConfigurationFailure(request, appIdFailure); + recordConfigurationFailure(request, url.pathname, appIdFailure); return githubApiConfigurationResponse(request, appIdFailure); } @@ -648,7 +649,7 @@ export default { outcome: "misconfigured", policy: "github-app-installation-id-canonical", }; - recordConfigurationFailure(request, installationIdFailure); + recordConfigurationFailure(request, url.pathname, installationIdFailure); return githubApiConfigurationResponse(request, installationIdFailure); } @@ -659,7 +660,7 @@ export default { policy: "continuation-dispatch-app-id-canonical", }; if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_ID ?? "")) { - recordConfigurationFailure(request, centralAppIdFailure); + recordConfigurationFailure(request, url.pathname, centralAppIdFailure); return githubApiConfigurationResponse(request, centralAppIdFailure); } const centralInstallationFailure: EgressFailure = { @@ -668,7 +669,7 @@ export default { policy: "continuation-dispatch-installation-id-canonical", }; if (!isCanonicalPositiveSafeInteger(env.CONTINUATION_DISPATCH_GITHUB_APP_INSTALLATION_ID ?? "")) { - recordConfigurationFailure(request, centralInstallationFailure); + recordConfigurationFailure(request, url.pathname, centralInstallationFailure); return githubApiConfigurationResponse(request, centralInstallationFailure); } } @@ -679,7 +680,7 @@ export default { policy: "github-cloud-exact-origin", }; if (!isTrustedGithubApiBase(env.GITHUB_API_BASE)) { - recordConfigurationFailure(request, originFailure); + recordConfigurationFailure(request, url.pathname, originFailure); return githubApiConfigurationResponse(request, originFailure); } @@ -689,7 +690,7 @@ export default { policy: "credential-fetch-no-redirect", }; if (!ensureGlobalOutboundFetchPolicy()) { - recordConfigurationFailure(request, redirectFailure); + recordConfigurationFailure(request, url.pathname, redirectFailure); return githubApiConfigurationResponse(request, redirectFailure); } diff --git a/test/continuation-dispatch-entrypoint.test.ts b/test/continuation-dispatch-entrypoint.test.ts index a115b881d..5013135f2 100644 --- a/test/continuation-dispatch-entrypoint.test.ts +++ b/test/continuation-dispatch-entrypoint.test.ts @@ -69,6 +69,29 @@ afterEach(() => { }); describe("continuation dispatch production entrypoint", () => { + it("attributes boundary failures to the continuation dispatch route", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await entrypoint.fetch( + new Request("https://noema.example/v1/continuation-dispatches", { + method: "POST", + headers: { authorization: "Bearer malformed" }, + body: dispatchBody(), + }), + {} as Env, + ); + await entrypoint.fetch(continuationRequest("a.b.c"), {} as Env); + + const records = logSpy.mock.calls.map(([record]) => JSON.parse(String(record)) as { + route?: string; + }); + expect(records).toHaveLength(2); + expect(records.map(({ route }) => route)).toEqual([ + "/v1/continuation-dispatches", + "/v1/continuation-dispatches", + ]); + }); + it("returns the versioned method boundary and validates both central GitHub App identifiers", async () => { const wrongMethod = await entrypoint.fetch( new Request("https://noema.example/v1/continuation-dispatches"), diff --git a/test/documentation-current-trust-authority.test.ts b/test/documentation-current-trust-authority.test.ts index ad7c57e58..fd9dedb3e 100644 --- a/test/documentation-current-trust-authority.test.ts +++ b/test/documentation-current-trust-authority.test.ts @@ -85,7 +85,7 @@ describe("current protected trust authority documentation", () => { expect(prd).not.toContain("graph/evaluation slice is intentionally non-durable"); expect(prd).not.toContain("authenticated evaluation evidence, Policy / Approval, durable graph/rejection history"); - expect(trd).toContain("### 2.4 Protected procedural graph advisory runtime"); + expect(trd).toContain("### 2.5 Protected procedural graph advisory runtime"); expect(trd).not.toContain("Active PR #585 adds"); expect(trd).not.toContain("## Candidate implementation — PR #585"); expect(trd).toContain("bounded durable evaluation/rejection history"); diff --git a/test/procedural-protected-documentation-contract.test.ts b/test/procedural-protected-documentation-contract.test.ts index 7fb773960..4d050c29e 100644 --- a/test/procedural-protected-documentation-contract.test.ts +++ b/test/procedural-protected-documentation-contract.test.ts @@ -119,7 +119,7 @@ describe("protected procedural documentation authority", () => { ); const trdSection = section( document("docs/TRD.md"), - "### 2.4 Protected procedural graph advisory runtime", + "### 2.5 Protected procedural graph advisory runtime", ); const baselineSection = section( document("docs/product-technical-gap-baseline.md"), @@ -172,7 +172,7 @@ describe("protected procedural documentation authority", () => { ); const trdSection = section( document("docs/TRD.md"), - "### 2.4 Protected procedural graph advisory runtime", + "### 2.5 Protected procedural graph advisory runtime", ); const baselineSection = section( document("docs/product-technical-gap-baseline.md"), From 9e8bd9bad04623ca17686fd2704a6ed8173ed3cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 2 Oct 2026 08:06:21 +0900 Subject: [PATCH 36/36] docs(gap): reconcile continuation merge evidence --- docs/product-technical-gap-baseline.md | 6 +++--- ...ct-technical-gap-current-candidate-contract.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 00d2d6a1e..c41543a77 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -156,9 +156,9 @@ PR 0은 useful work를 닫아 제조하지 않는다. Open lane은 normal merge | P0 | Procedural graph publication boundary | advisory/evaluation/preflight가 publication/activation으로 오인될 위험 | protected #585/#586/#589/#597/#714/#601/#719/#603/#652/#663/#678 + issue #584 | source integrated; #714 State / Checkpoint and #719 Policy / Approval minimal-transaction/complete-revalidation/type-admission hardening integrated; response-bound, cancellation-liveness and locked-reader hardening integrated; publication/activation unavailable | released graph contract + live signer trust + lifecycle/revocation + deployed evidence + graph publication/canary | prerequisites 전 graph publication fail closed 유지 | | P0 | Durable workflow/state production evidence | source Durable Object logic·object-size observation이 deployed recovery/SLO로 오인될 위험 | protected #542 + #605 / ADR 0013 / issue #541 | source + exact-object observation integrated; ADR 0013 Proposed | immutable release/deployment + exact-object transaction/restart/recovery + representative storage-growth denominator + p95 + PITR/rollback | approved deployment owner에서 exact protected release 대상으로 runtime/recovery receipt 확보 | | P0 | Protected-main governance closure | Security workflow 하나로 PR/review/history/deletion/bypass 통제를 과대 주장할 위험 | issue #27 | external control evidence open | live ruleset + PR/review/conversation/history/deletion + bypass evidence | admin/owner control을 독립 검증 | -| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66 / open PR #736 | protected source/image integrated; predecessor scan exposed embedded Undici vulnerability; source repair and exact final-verifier assertion proposed; fresh exact-head hosted receipt와 publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #736 fresh exact-head image build/raw scan/strict receipt를 재검증한 뒤 operational publication/signing 검증 | -| P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단하고, internally consistent한 non-reviewed Undici identity가 final verifier를 통과할 위험 | issue #734; open PRs #736/#737; dependent Draft PR #733 | #737의 exact Undici verifier RED→GREEN delta를 #736에 통합 중이며 local focused verification은 GREEN; fresh exact-head hosted checks와 independent approval open | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | #736 fresh exact-head review/check를 통과시킨 뒤 ordinary merge; #737/#733은 complete carryover와 release 전까지 유지 | -| P0 | Continuation dispatch pre-effect recovery | credential/commit failure, post-commit/pre-request token expiry, or Worker termination can strand a reservation, while replay status, OpenAPI response composition, or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | expiry RED `2a4488c6`→GREEN `350a3665`; OpenAPI executable-schema successor `1bd5fce7`; focused 185/185, typecheck and diff-check GREEN; full local gate remains environment-blocked by Node/npm identity, Unix-socket restriction, and workspace capacity; exact-head hosted review/Checks and immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/exact-receipt-release/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | publish ordinary child, re-run exact-head gates, then release and revalidate `.github#2540` against the immutable contract | +| P0 | Patch-validator operational publication | PR image CI가 immutable runtime publication으로 오인될 위험 | issue #66; active PR #737; dependent Draft PRs #733/#736 | #737 exact-head source/image/reviewer/Security evidence GREEN; independent approval, ordinary merge, immutable publication open | protected-main execution + immutable image/signature/SBOM/provenance/rollback | #737 ordinary merge 뒤 #736을 retarget/revalidate하고 operational publication/signing 검증 | +| P0 | Patch-validator bundled Undici vulnerability | protected Node 24.19.0 static image의 bundled Undici 7.29.0/GHSA-3wwx-pv8p-q78v가 exact-image scanner gate를 차단하고, internally consistent한 non-reviewed Undici identity가 final verifier를 통과할 위험 | issue #734; active PR #737; dependent Draft PRs #733/#736 | #737 final receipt exact Undici assertion, released gateway endpoint admission, review-contract, bounded scanner acquisition, CI oracle repair가 exact-head hosted GREEN; qualifying independent approval과 ordinary merge open | exact PR-head build/smoke + Node/Undici inventory + final verifier exact Undici assertion + raw scanner receipt with no blocking finding + full CI/typecheck/coverage + independent approval + ordinary merge + immutable release evidence | #737 ordinary merge 후 #733/#736을 non-force restack하고 fresh exact-head evidence를 재검증 | +| P0 | Continuation dispatch pre-effect recovery | credential/commit failure, post-commit/pre-request token expiry, or Worker termination can strand a reservation, while replay status, OpenAPI response composition, or payload-digest drift can misstate retained authority | issue #735 / open PR #736 | expiry RED `2a4488c6`→GREEN `350a3665`; OpenAPI executable-schema successor `1bd5fce7`; prerequisite #737 final exact head가 ordinary two-parent merge로 통합됐고 dependent fresh exact-head review/Checks와 immutable release evidence open | executable replay/401/OpenAPI/body-digest/credential-order/exact-receipt-release/alarm contracts + exact-head hosted GREEN + independent approval + ordinary merge + immutable key artifact | fresh exact-head gates를 재검증한 뒤 release와 `.github#2540` immutable-contract consumption을 재검증 | | P0 | Authentic production KPI evidence | synthetic/source KPI가 실제 운영 성능으로 오인될 위험 | issue #3 | >=30-day production window absent | authenticated production bytes + provenance + strict KPI gate | 실제 production evidence만 수집 | | P0 | Acquisition coordination | source/docs completion이 buyer/legal/transfer readiness로 오인될 위험 | issue #5 | evidence families incomplete | exact release/deployment/operational/legal evidence | owner별 evidence family 수렴 | | P0 | External Maintainer/Reviewer App identity | source preflight가 실제 App installation/reviewer authority로 오인될 위험 | issues #29 / #227 | live identity evidence absent | installation/key custody/permission/reviewer eligibility | external control-plane에서 독립 검증 | diff --git a/test/product-technical-gap-current-candidate-contract.test.ts b/test/product-technical-gap-current-candidate-contract.test.ts index 8aaba64b4..6d6dbfade 100644 --- a/test/product-technical-gap-current-candidate-contract.test.ts +++ b/test/product-technical-gap-current-candidate-contract.test.ts @@ -12,6 +12,16 @@ describe("product-technical gap current authority", () => { expect(baseline).toContain("queued is not GREEN"); }); + it("records the continuation merge-forward without retaining superseded proposal language", () => { + const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8"); + + expect(baseline).toContain("CI oracle repair가 exact-head hosted GREEN"); + expect(baseline).toContain("ordinary two-parent merge로 통합"); + expect(baseline).toContain("prerequisite #737 final exact head"); + expect(baseline).not.toContain("source repair and exact final-verifier assertion proposed"); + expect(baseline).not.toContain("publish ordinary child"); + }); + it("records integrated external-extension admission and the durable lifecycle successor without overclaiming completion", () => { const baseline = readFileSync("docs/product-technical-gap-baseline.md", "utf8");