From 7cfbfc95c6da4e65fc10a710d29ff929630760fe Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 11:53:14 +0200 Subject: [PATCH 01/20] feat: add commit-bound Autoflow state machine Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 + .../007-autoflow-state-machine.md | 470 ++++++ src/domain/index.ts | 45 + src/domain/workflow-transitions.ts | 1324 +++++++++++++++++ src/domain/workflow.ts | 704 +++++++++ tests/domain/reader-parity.test.ts | 99 +- tests/domain/workflow-fixtures.ts | 464 ++++++ tests/domain/workflow-invariants.test.ts | 903 +++++++++++ tests/domain/workflow-transitions.test.ts | 1195 +++++++++++++++ 9 files changed, 5187 insertions(+), 19 deletions(-) create mode 100644 docs/architecture/007-autoflow-state-machine.md create mode 100644 src/domain/workflow-transitions.ts create mode 100644 src/domain/workflow.ts create mode 100644 tests/domain/workflow-fixtures.ts create mode 100644 tests/domain/workflow-invariants.test.ts create mode 100644 tests/domain/workflow-transitions.test.ts diff --git a/README.md b/README.md index 2e56f25..63dbc3b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ PR 005 adds review ingestion: untrusted reviewer output is normalized into commi PR 006 adds the provider-neutral agent invocation boundary: a commit-bound record of what AgentBridge asked which agent to do, and normalization of what that agent reported back. An agent's report is an untrusted **claim** — PR 006 records that an artifact was claimed, never that it exists remotely, is integrated, is validated, or is authorized. Provider identity is inert: it never implies a role or grants authority, and roles remain configurable. The layer invokes nothing, models no lifecycle transitions, and performs no I/O. See `docs/architecture/006-agent-invocation-boundary.md`. +PR 007 adds the Autoflow state machine: the smallest durable orchestration state model that binds a unit of work to an exact repository, pull request, and commit, and records — in order — what was requested and what was independently established. It consumes only the outputs of PR 004, 005, and 006; it never normalizes agent output, never judges freshness, and never grants authority. Provider identity, invocation purpose, and agent-reported status are inert: no transition's legality depends on any of them, and a claim can never become an observation. State is an immutable value produced by a pure, total transition function, and the layer performs no I/O. See `docs/architecture/007-autoflow-state-machine.md`. + ## Runtime - Node.js 24 LTS diff --git a/docs/architecture/007-autoflow-state-machine.md b/docs/architecture/007-autoflow-state-machine.md new file mode 100644 index 0000000..787ccb8 --- /dev/null +++ b/docs/architecture/007-autoflow-state-machine.md @@ -0,0 +1,470 @@ +# Autoflow State Machine (PR 007) + +Status: V1. Superseded only by an explicit architecture decision. + +## Purpose + +PR 007 implements the Autoflow engine's state model: the commit-bound record of +what has been requested and what has been independently established for one unit +of work. + + trusted workflow binding + one already-normalized event + -> immutable WorkflowState | rejection + +It answers exactly one question: + +> Given everything recorded so far for this repository at this exact commit, is +> this event a legal thing to record, and what is the resulting state? + +It does **not** answer what should happen next. Legality is domain; selection is +policy, and policy is a later PR. There is deliberately no projection, +recommendation, ranking, or next-action API — not even a `legalEventKinds` +helper, because a public enumeration of what is permitted is one refactor away +from being read as advice. + +**PR 007 performs no I/O and invokes nothing.** No agent execution, dispatch, or +transport; no provider adapters; no GitHub, Claude, OpenAI, Gemini, or CodeRabbit +calls; no network, filesystem, subprocess, database, or Evidence Store +persistence; no clock, timer, retry, backoff, timeout, polling, queue, +scheduling, or concurrency control; no Promises or async of any kind; no +artifact verification, integration detection, freshness decision, authority +decision, or merge logic; no identifier generation. Both exported functions are +pure functions of their arguments. + +## State is not authority + +A workflow state records that something was asked for and that something was +observed. It never states that an agent may act, that a pull request may merge, +that a repair should be dispatched, or that enough has been established. PR 003's +policy gate plus a human remain the only authority boundary. + +The model deliberately contains no `exists`, `verified`, `observed`, +`integrated`, `merged`, `applied`, `validated`, `authorized`, `mergeable`, +`approved`, `mayMerge`, `ready`, `readyForMerge`, `blocking`, `findingCount`, +`freshness`, `current`, `stale`, `nextAction`, `attempt`, `retries`, +`maxAttempts`, `budget`, `deadline`, `timeout`, `backoff`, `cost`, or `converged` +field, and a test asserts that none can appear even when every event payload +plants all of them. A second test asserts no `ALLOW`, `DENY`, `ESCALATE`, +`AUTONOMOUS`, `CURRENT`, or `STALE` value reaches a serialized state, and a third +asserts the state exposes no boolean field at all. + +## Fact, state, evidence, authority + +The distinction the layer exists to preserve: + +| Category | Definition | Owner | +| --- | --- | --- | +| **Claim** | an agent said something | PR 006 | +| **Observation** | an adapter independently observed something at an exact SHA | PR 004 evidence, produced by an adapter | +| **Derived judgment** | a pure verdict from evidence plus a trusted target | PR 004 freshness | +| **Orchestration state** | what was requested and what was admitted, in order, for one bound commit | **PR 007** | +| **Authority** | permission to act | PR 003 gate plus a human | +| **Policy** | how much, how long, how many times, whether to stop | a later PR | + +Read against the lifecycle a real AgentBridge pull request goes through, only +six of eighteen distinctions are orchestration state: + +| Lifecycle distinction | Category | PR 007 | +| --- | --- | --- | +| implementation requested | orchestration state | tracked invocation, `REQUESTED` | +| implementation artifact produced | claim | recorded by PR 006; never promoted | +| artifact exists remotely | observation | admitted as PR 004 evidence | +| review requested | orchestration state | tracked invocation | +| findings ingested | orchestration state | review admission | +| findings CURRENT / STALE / INVALID | derived judgment | **PR 004 only** | +| bounded repair requested | state plus policy | invocation tracked here; "bounded" is a later PR | +| repair artifact produced | claim | recorded; never promoted | +| repair integrated | observation | evidence plus `HEAD_OBSERVED` | +| HEAD changed | observation | the one event that advances a revision | +| old evidence became stale | derived judgment | implied by revision; never recomputed | +| audit / fresh review requested and completed | orchestration state | invocation `REQUESTED` then `REPORTED` | +| CI passed | observation | PR 004 evidence, `kind: 'ci-result'` | +| ready for human merge | authority plus policy | **deliberately not a state** | +| human merge executed | observation | evidence; the workflow simply closes | + +## Modules + +| Module | Responsibility | +| --- | --- | +| `src/domain/workflow.ts` | vocabularies, bounds, domain types, hardened readers | +| `src/domain/workflow-transitions.ts` | `openWorkflow`, `applyWorkflowEvent` | + +## Vocabularies + +`WorkflowStatus` is `OPEN | AWAITING_HUMAN_DECISION | CLOSED`. Three members, +deliberately: any addition such as `AWAITING_REVIEW`, `REPAIR_IN_PROGRESS`, or +`READY_FOR_MERGE` would encode either routing (repository policy) or sufficiency +(termination policy). `AWAITING_HUMAN_DECISION` states only *that* a human was +asked, never what they were asked or what an answer would permit. + +`InvocationState` is `REQUESTED | REPORTED`. PR 006's `AgentReportStatus` is +terminal-only and says why: "a non-terminal state requires something to +transition it." This is that state and nothing more. There is no `CANCELLED`, +`TIMED_OUT`, `ABANDONED`, or `SUPERSEDED` — cancellation and deadlines are +termination policy, and supersession is derivable from `targetCommitSha` against +the bound commit, so deriving it here would be a second freshness answer. + +`WorkflowClosure` is `HUMAN_DECISION_RECORDED | CALLER_CLOSED`. No `MERGED`, +`COMPLETED`, or `SUCCEEDED`: a merge is an observation, not a closure semantic. + +`TransitionOutcome` is `APPLIED | REJECTED`. Binary on purpose — a three-valued +outcome with a "benign no-op" member would invite a caller to treat some +rejections as harmless. The nuance lives in the rejection reason, where it +cannot be skipped. + +Nothing derivable is stored twice. "Work outstanding" is +`invocations.some(state === REQUESTED)` and is computed by the caller: two fields +that can disagree are a defect waiting to happen. + +## Transition table + +The governing principle: **recording a fact is legal whenever the workflow is not +closed; initiating work is not.** + +| Event | `OPEN` | `AWAITING_HUMAN_DECISION` | `CLOSED` | +| --- | --- | --- | --- | +| `INVOCATION_REQUESTED` | applied | `WORKFLOW_AWAITING_HUMAN` | `WORKFLOW_CLOSED` | +| `INVOCATION_REPORTED` | applied | applied | `WORKFLOW_CLOSED` | +| `REVIEW_ADMITTED` | applied | applied | `WORKFLOW_CLOSED` | +| `EVIDENCE_ADMITTED`, kind ≠ `human-decision` | applied | applied, status unchanged | `WORKFLOW_CLOSED` | +| `EVIDENCE_ADMITTED`, kind = `human-decision` | applied, stays `OPEN` | applied, clears the gate → `OPEN` | `WORKFLOW_CLOSED` | +| `HEAD_OBSERVED`, different commit | applied, `revision + 1` | applied, `revision + 1`, clears the gate → `OPEN` | `WORKFLOW_CLOSED` | +| `HEAD_OBSERVED`, same commit | `HEAD_UNCHANGED` | `HEAD_UNCHANGED` | `WORKFLOW_CLOSED` | +| `HUMAN_GATE_OPENED` | applied → `AWAITING_HUMAN_DECISION` | `HUMAN_GATE_ALREADY_OPEN` | `WORKFLOW_CLOSED` | +| `CLOSE_REQUESTED` | applied → `CLOSED` | applied → `CLOSED` | `WORKFLOW_CLOSED` | + +`CLOSED` is absolutely terminal: no reopen, no resurrection, no exception. A new +unit of work is a new workflow. + +There is deliberately **no `HUMAN_DECISION_RECORDED` event**. A human decision is +PR 004 evidence of kind `human-decision`, arriving through `EVIDENCE_ADMITTED`. +`EvidenceFreshness` carries no verdict field, so this layer records *that* a +human decided and is structurally unable to learn *what* they decided. + +### Evaluation precedence + +Determinism requires one ordering. The first failure returns, and the order never +varies: + +1. state readable — `WORKFLOW_UNREADABLE` +2. event readable — `EVENT_UNREADABLE` +3. kind recognised — `EVENT_KIND_UNKNOWN` +4. status is not `CLOSED` — `WORKFLOW_CLOSED` +5. payload slot well-formed — `EVENT_PAYLOAD_INVALID` +6. status posture — `WORKFLOW_AWAITING_HUMAN` / `HUMAN_GATE_ALREADY_OPEN` +7. upstream outcome — `INPUT_NOT_INGESTED` / `EVIDENCE_NOT_CURRENT` +8. payload fields — `EVENT_PAYLOAD_INVALID` +9. binding — `BINDING_MISMATCH` / `HEAD_UNCHANGED` +10. identity and replay — `DUPLICATE_*` / `UNKNOWN_INVOCATION` / `INVOCATION_ALREADY_REPORTED` +11. capacity — `CAPACITY_EXCEEDED` +12. apply + +`INVOCATION_REPORTED` is the one event whose binding check follows its identity +check, because the commit it compares against comes from the tracked invocation +rather than from the workflow. + +## Revision and sequence + +`sequence` starts at 0 and advances by exactly one on every applied transition. +It is the total ordering and the natural optimistic-concurrency token for a later +persistence layer. It is the deliberate substitute for a clock: **this layer +reads no clock and generates no timestamp.** + +`revision` starts at 0 and advances **only** on an applied `HEAD_OBSERVED`. It is +the admission key. + +The two remain distinct and are never collapsed. They answer different questions: +which commit generation, versus which transition. + +## HEAD is supplied, never inferred + +`HEAD_OBSERVED` carries a trusted `observedCommitSha`, exactly as PR 004's +`EvidenceTarget.currentHeadSha` is a trusted argument separate from the evidence. +There is no field on any agent-controlled payload through which HEAD could be +supplied, and it cannot be an `EvidenceFreshness` because a new HEAD evaluated +against the old target is by definition `STALE`. + +On an applied `HEAD_OBSERVED` the workflow rebinds, `revision` advances, prior +admissions and outstanding invocations are **retained unchanged**, and the status +becomes `OPEN` with no gate open. + +**Commit ordering is never inferred.** A SHA is opaque: there is no parent check, +no ancestry test, and no "is this newer". A HEAD that returns to a previous +commit still advances the revision, so evidence admitted earlier cannot +resurrect. **Revision, not SHA alone, is the admission key**, which is what +defends against a hostile or buggy adapter replaying a HEAD. + +Retained admissions from earlier revisions are not worthless — they remain true +at their own revision and commit, following PR 004's reasoning that `STALE` is +not a synonym for discarded. They simply stop counting. + +### A human gate clears on a HEAD advance + +A human gate is commit-bound orchestration state, not authority. It is opened +against the bound commit, so once that binding moves the gate is as stale as any +old-revision fact, and it is cleared. + +Clearing removes no human authority. PR 003's gate plus the human remain the only +authority boundary, no approval is inferred, nothing is cancelled, and a decision +recorded against the superseded commit is subsequently refused as not current. A +later PR may open a new gate at the new revision when its policy requires one. + +The consequence is a pinned invariant: `humanGateOpenedAtRevision` is always +`null` or exactly `revision`. Its only non-derivable content is whether a gate was +open at closure, which is why it is retained when a workflow closes. The +relationship is enforced when a state is read back and asserted by a test, so the +two values cannot disagree. + +## Admission semantics + +### Pull-request binding: absent and unreadable are not the same + +A workflow with no pull request never consults an input's pull-request field — +there is nothing to bind against. A workflow that has one accepts an **absent** +field, because an implementation invocation may precede any pull request, and +requires an exact match otherwise. + +A field that is present but **unreadable** — oversized, blank, non-string, or +behind a throwing getter — is not "absent". Treating it as absent would skip the +comparison and silently discard the exact pull-request binding, so it rejects: +`EVENT_PAYLOAD_INVALID` on the trusted invocation path, where trusted context is +all-or-nothing, and `BINDING_MISMATCH` on the report and review paths. + +### Evidence — the anti-duplication mechanism + +`EVIDENCE_ADMITTED` takes a PR 004 `EvidenceFreshness`, not an `EvidenceRecord`. +Freshness is never re-derived here; PR 004 already answered the question, and its +result carries the target it was answered against. The only thing left to check +is that the answer is about *this* workflow's binding: + +- `state` is `CURRENT` and `reason` is `BOUND_TO_CURRENT_HEAD`; +- `targetRepositoryId` equals the workflow's repository; +- `targetHeadSha` equals the bound commit. + +A caller therefore cannot launder stale evidence by judging it against a +convenient target and handing over the verdict. **No change to PR 004 was +required**: its result shape already carries everything this check needs. + +Admissions are unique per `(id, revision)`. The same evidence can legitimately be +current again at a later revision, and is then a fresh admission. + +### Reviews — pointers only, and unsolicited ones count as facts + +`REVIEW_ADMITTED` takes a PR 005 `ReviewResult` and reads only its `outcome`, +`reviewId`, `repositoryId`, `pullRequestId`, and `reviewedCommitSha`. **Findings +are never read** — no text, no severity, no classification, and no count. A +derived summary would be a second answer that can drift from PR 005's, and a +severity reaching this layer would make findings look like policy. Findings +remain evidence. + +`reviewId` is optional in PR 005 and required here: an admission nobody can +attribute is not worth recording. That is a documented caller obligation. + +A review need **not** correspond to a tracked invocation. Automated forge +reviewers and human reviewers produce real reviews AgentBridge did not request, +and refusing them would make those invisible to orchestration. Admitting one: + +- does not transition any invocation — only `INVOCATION_REPORTED` does that; +- does not imply it was requested, and records nothing that distinguishes the two; +- does not imply sufficiency, policy satisfaction, or authority; +- does not trigger repair, escalation, or any subsequent action. + +Whether a *requested*, *attributable*, *independent*, or *specific* review is +required for a given orchestration decision is a policy question owned by a later +PR, which can determine attribution itself by cross-referencing `reviewId` +against tracked invocation ids. + +### Reports bind to their own invocation + +`INVOCATION_REPORTED` compares `targetCommitSha` against the **tracked +invocation's** commit, not the workflow's current one. A report arriving after +HEAD moved is a true historical fact and is recorded; discarding it would be +worse than recording it. It admits no evidence, because admission is keyed on the +current revision and runs through a different event entirely. + +`reportedStatus` is carried verbatim and re-narrowed defensively, failing closed +to `unknown`. It is recorded, never branched on. + +## The claim ladder is unchanged + +| Rung | Assertion | Owner | PR 007 | +| --- | --- | --- | --- | +| 1 requested | AgentBridge asked agent X to do Y at SHA S | PR 006 | tracks | +| 2 reported complete | the provider says it finished | PR 006 | carries, never reads | +| 3 artifact claimed | the provider says it produced R | PR 006 | never reads | +| 4 remotely observed | an adapter verified R exists | adapter, as PR 004 evidence | admits, as a separate event | +| 5 integrated | HEAD moved | PR 004 against a new trusted HEAD | records the HEAD advance | +| 6 validated | CI, tests, or a fresh review at the new HEAD | PR 004 + PR 005 | admits | +| 7 authorized | this may merge or mutate | PR 003 gate + human | delegates | + +**PR 007 adds no rung.** There is no code path from `INVOCATION_REPORTED` into +any admission list — a test slices the handler out of the source and asserts it +never mentions the admission lists or their types. Reaching rung 4 still requires +a *new record built from an independent observation*, arriving as a separate +event. + +## Trust boundary + +| Input | Trust | Contributes | +| --- | --- | --- | +| `WorkflowBinding` | **trusted for binding** | the identity of the run | +| `HEAD_OBSERVED.observedCommitSha` | **trusted** adapter observation | the only thing that advances a revision | +| `HUMAN_GATE_OPENED.atCommitSha` | **trusted** | which commit the gate is about | +| `CLOSE_REQUESTED.closureReason` | **trusted, inert** | an audit label that grants nothing | +| `AgentInvocation` | **trusted for binding, inert as authority** | identity, target commit, provider, agent, purpose | +| `InvocationReportResult` | pre-normalized, re-validated | reported status only | +| `ReviewResult` | pre-normalized, re-validated | that an attributable review exists | +| `EvidenceFreshness` | pre-judged, re-validated | that a CURRENT observation exists at this binding | +| `WorkflowState` argument | pre-produced, re-validated | prior state | +| `AgentReport`, `ReviewSubmission`, `EvidenceRecord` | **never accepted** | — | + +Two structural rules make this enforceable: + +1. **PR 007 consumes only the outputs of PR 004, PR 005, and PR 006.** There is + no signature that accepts a raw agent payload, so a second normalizer is a + compile-time impossibility rather than a review comment. +2. **Trusting the type is not trusting the value.** Every pre-normalized input is + still read as hostile at runtime — own-only property access, one read per + field into a local, guarded dereference — because a caller can hand this layer + a hand-built object shaped like a PR 006 result. + +Properties are read **own-only**. An inherited value, including one planted on +`Object.prototype` through a `__proto__` payload, is treated as absent. + +A state is rebuilt from its validated snapshot on every applied transition, so +any extra property a caller attached is dropped rather than carried forward. + +## Provider neutrality + +**Legality never depends on `providerId`, `agentId`, `purpose`, or +`reportedStatus`.** They are recorded for audit and read by no branch. No +provider is permanently an implementer or a reviewer, no purpose grants +authority, and a provider saying it finished is not a fact: `reported-complete` +and `reported-failed` produce indistinguishable transitions. + +A parametrized test runs all 128 combinations of eight provider labels — +including `system`, `root`, `admin`, and `agentbridge-internal` — four purposes, +and four reported statuses, and asserts the resulting states are identical once +the three recorded label fields are normalized. A `repair` invocation produces no +field a `review` invocation lacks. + +## Identifiers reject; nothing truncates + +Every identifier is exact: never trimmed, case-folded, normalised, or truncated. +Comparison is exact and case-sensitive, so a commit differing by case or padding +does not match, which fails closed — the same reasoning that keeps PR 002's +action matching and PR 004's SHA comparison exact. + +There is no `truncated` flag on any PR 007 type, because there is nothing it +could describe. This layer stores no prose. `clampText` and `readText` exist only +so the reader set stays byte-equivalent to PR 005's and PR 006's and can be +pinned by the parity guard; every stored field goes through +`readExactIdentifier`. + +## Bounds + +| Bound | Value | Rationale | +| --- | --- | --- | +| `MAX_IDENTIFIER_LENGTH` | 256 | must equal PR 005's and PR 006's; oversize rejects | +| `MAX_TRACKED_INVOCATIONS` | 256 | caps synchronous work and memory per workflow | +| `MAX_ADMITTED_EVIDENCE` | 1 024 | as above | +| `MAX_ADMITTED_REVIEWS` | 256 | as above | +| `MAX_REVISION` | 1 000 000 | far beyond any real pull request's HEAD churn | +| `MAX_SEQUENCE` | 1 000 000 | as above | + +Exceeding a bound **rejects the transition** with `CAPACITY_EXCEEDED` and returns +the identical prior state. This is a deliberate third convention: PR 004 collapses +an over-length evidence set to zero and PR 005/006 truncate and flag, but both +operate on elements of a single hostile payload. A transition instead carries +**one discrete fact**, so refusing it visibly at the call site is the only outcome +that loses nothing — silently dropping orchestration history would be the +dangerous result. A workflow that reaches a bound is an escalation signal for a +later PR, not a concern of this one. + +A state whose list exceeds its own bound could not have been produced here and is +`WORKFLOW_UNREADABLE`. + +## Determinism + +Given identical arguments, a transition produces byte-equivalent output: no +clock, no randomness, no filesystem, no network, no environment, no mutable +global state, no identifier generation, no hashing, no async. Ordering comes from +`sequence` and `revision`. Nothing is sorted, grouped, deduplicated, or +reordered; append order is preserved, and invalid-field lists and the rejection +precedence follow fixed declaration orders. + +Intrinsics (`Object.freeze`, `Object.defineProperty`, `Object.hasOwn`, +`Object.is`, `Array.isArray`, `Number.isInteger`, `String.prototype.trim`/`slice`, +`Reflect.apply`) are captured at module load, before any untrusted property +access, following the pattern established in PR 004, PR 005, and PR 006. Array +building avoids `push`, `filter`, `map`, spread over untrusted values, and +ordinary indexed assignment, so neither poisoned prototype methods nor inherited +index setters are on the path. Every field is read exactly once into a local, so +a getter that returns a different value on each read cannot validate one value +and store another. + +`-0` is rejected wherever a count is read: it compares equal to `0` but does not +survive a JSON round trip as the same value, which would break byte identity. + +## Immutability and fail-closed behaviour + +The input state is never mutated. An applied transition returns a deeply frozen +state — the object, all three lists, and every element. A rejection returns the +**identical prior reference**, which is testable proof that nothing was partially +applied: no counter moved, no list grew, no status changed. + +Every malformed input fails closed rather than throwing. `applyWorkflowEvent` is +total for every runtime input of every type: non-objects, arrays, revoked +Proxies, throwing getters, unstable getters, and prototype-polluted payloads all +produce a deterministic rejection. + +## Reader independence + +`workflow.ts` defines its own `clampText`, `readText`, `readExactIdentifier`, +`readOwnProperty`, `readCount`, `containsValue`, and `append` rather than +importing the equivalents from `review.ts` or `agent-invocation.ts`. This is the +same trade PR 006 recorded: the architectural independence of the hostile-input +boundaries takes precedence over deduplicating a handful of small readers, and +each module captures its own intrinsics at its own load time. + +The cost of duplication is drift, so drift stays mechanically detectable. +`tests/domain/reader-parity.test.ts` now runs one shared hostile-input corpus +through **three** copies of every reader whose contract overlaps and asserts +identical results. It is the only place the three modules meet, and it is a test, +so it introduces no production dependency. + +PR 007 does import frozen **vocabulary constants** from PR 004, PR 005, and +PR 006 — `FRESHNESS`, `FRESHNESS_REASON`, `EVIDENCE_KIND(S)`, +`INGESTION_OUTCOME`, `REPORT_OUTCOME`, `AGENT_REPORT_STATUSES`, +`INVOCATION_PURPOSES`. Redeclaring them would create a divergent second answer to +a question those layers own. It imports no reader, normalizer, or validator +function from any of them; in particular it does not reuse PR 006's +`findInvalidInvocationFields`, because that returns field *names* and reusing it +would force a second read of each field, reintroducing the very double-read +hazard a hostile getter exploits. + +**Extracting a shared `untrusted-input.ts` remains deferred.** Doing it here +would mean modifying two already-hardened, security-reviewed boundaries inside a +PR whose scope is a state machine, and it would trade per-module intrinsic +capture — an independence property — for a single point of failure. The natural +moment to revisit it is a dedicated refactor before adapters land. + +## Non-goals + +No agent execution, dispatch, or transport. No callable adapter port, Promise, or +async. No Claude, OpenAI, Gemini, Codex, CodeRabbit, or GitHub API calls. No +network, filesystem, subprocess, database, Evidence Store, or persistence. No +clock, timestamp generation, or identifier generation. No polling, queues, +schedulers, retries, backoff, timeouts, deadlines, attempt limits, repair +budgets, cost or token ceilings, cancellation policy, loop termination, +convergence detection, or escalation policy. No provider, reviewer, or repair +routing — roles remain configuration resolved before an invocation is +constructed. No GitHub mutations. No artifact verification, existence checking, +or reference dereferencing. No integration detection. No freshness or staleness +judgment. No merge-readiness policy, approval logic, or authority logic. No +human-approval UI, notifications, dashboards, or metrics. No commit ancestry or +ordering inference. No invocation graph: no parent, supersession, replacement, or +causal field. Revision containment is the only relationship, and any future +causal field must be caller-supplied, optional, and inert. + +This is one layer of the frozen V1 pipeline, not the pipeline. diff --git a/src/domain/index.ts b/src/domain/index.ts index da11dd3..2b0e341 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -152,3 +152,48 @@ export { type FreshnessReason, type FreshnessState, } from './evidence-freshness.js'; + +export { + INVOCATION_STATE, + INVOCATION_STATES, + isInvocationState, + isWorkflowClosure, + isWorkflowEventKind, + isWorkflowStatus, + REQUIRED_BINDING_FIELDS, + TRANSITION_OUTCOME, + TRANSITION_OUTCOMES, + TRANSITION_REJECTION, + TRANSITION_REJECTIONS, + WORKFLOW_BINDING_FIELD_ORDER, + WORKFLOW_BOUNDS, + WORKFLOW_CLOSURE, + WORKFLOW_CLOSURES, + WORKFLOW_EVENT_KIND, + WORKFLOW_EVENT_KINDS, + WORKFLOW_STATUS, + WORKFLOW_STATUSES, + type AdmittedEvidence, + type AdmittedReview, + type CloseRequestedEvent, + type EvidenceAdmittedEvent, + type HeadObservedEvent, + type HumanGateOpenedEvent, + type InvocationReportedEvent, + type InvocationRequestedEvent, + type InvocationState, + type ReviewAdmittedEvent, + type TrackedInvocation, + type TransitionOutcome, + type TransitionRejection, + type TransitionResult, + type WorkflowBinding, + type WorkflowClosure, + type WorkflowEvent, + type WorkflowEventKind, + type WorkflowOpenResult, + type WorkflowState, + type WorkflowStatus, +} from './workflow.js'; + +export { applyWorkflowEvent, openWorkflow } from './workflow-transitions.js'; diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts new file mode 100644 index 0000000..4556ad6 --- /dev/null +++ b/src/domain/workflow-transitions.ts @@ -0,0 +1,1324 @@ +/** + * Deterministic transitions over immutable Autoflow workflow state. + * + * trusted workflow binding + one already-normalized event + * -> immutable WorkflowState | rejection + * + * PR 007 scope: state transition only. Nothing here invokes an agent, calls + * GitHub, Claude, OpenAI, or CodeRabbit, opens a socket, reads a clock, touches + * the filesystem, spawns a process, persists anything, verifies an artifact, + * detects integration, judges freshness, selects a provider or reviewer, + * retries, backs off, schedules, polls, counts cost, or makes a merge decision. + * Both exported functions are pure functions of their arguments. + * + * The epistemic ladder from PR 006 is unchanged, and PR 007 adds no rung: + * + * 1. requested <- PR 006 AgentInvocation, tracked here + * 2. reported complete <- PR 006 reportedStatus, carried here + * 3. artifact claimed <- PR 006 ClaimedArtifact, never read here + * 4. remotely observed -> adapter observation recorded as PR 004 evidence + * 5. integrated -> PR 004 freshness against a new trusted HEAD + * 6. validated -> PR 004 + PR 005 + * 7. authorized -> PR 003 gate + human approval + * + * **There is no code path from `INVOCATION_REPORTED` into any admission list.** + * A claim reaches rung 4 only through a *new record built from an independent + * observation*, arriving as a separate `EVIDENCE_ADMITTED` event. Promotion is + * structurally impossible, not merely absent. + * + * Fixed evaluation precedence — the first failure returns, and the order never + * varies, so rejection reasons are deterministic: + * + * 1 state readable WORKFLOW_UNREADABLE + * 2 event readable EVENT_UNREADABLE + * 3 kind recognised EVENT_KIND_UNKNOWN + * 4 status is not CLOSED WORKFLOW_CLOSED + * 5 payload slot well-formed EVENT_PAYLOAD_INVALID (shallow) + * 6 status posture WORKFLOW_AWAITING_HUMAN / HUMAN_GATE_ALREADY_OPEN + * 7 upstream outcome INPUT_NOT_INGESTED / EVIDENCE_NOT_CURRENT + * 8 payload fields EVENT_PAYLOAD_INVALID (deep) + * 9 binding BINDING_MISMATCH / HEAD_UNCHANGED + * 10 identity and replay DUPLICATE_* / UNKNOWN_INVOCATION / INVOCATION_ALREADY_REPORTED + * 11 capacity CAPACITY_EXCEEDED + * 12 apply + * + * `INVOCATION_REPORTED` is the one event whose binding check follows its + * identity check, because the SHA it compares against comes from the tracked + * invocation rather than from the workflow. + */ + +import { EVIDENCE_KIND, EVIDENCE_KINDS } from './evidence.js'; +import { FRESHNESS, FRESHNESS_REASON } from './evidence-freshness.js'; +import { INGESTION_OUTCOME } from './review.js'; +import { + AGENT_REPORT_STATUSES, + INVOCATION_PURPOSES, + REPORT_OUTCOME, + type AgentReportStatus, + type InvocationPurpose, +} from './agent-invocation.js'; +import { + type AdmittedEvidence, + type AdmittedReview, + append, + INVOCATION_STATE, + INVOCATION_STATES, + isVocabularyMember, + isWorkflowClosure, + isWorkflowEventKind, + isWorkflowStatus, + readCount, + readExactIdentifier, + readOwnProperty, + REQUIRED_BINDING_FIELDS, + type TrackedInvocation, + TRANSITION_OUTCOME, + TRANSITION_REJECTION, + type TransitionRejection, + type TransitionResult, + WORKFLOW_BOUNDS, + WORKFLOW_EVENT_KIND, + WORKFLOW_STATUS, + type WorkflowBinding, + type WorkflowClosure, + type WorkflowEvent, + type WorkflowOpenResult, + type WorkflowState, + type WorkflowStatus, +} from './workflow.js'; + +/** + * Intrinsics captured at module load, before any untrusted property access. + * Array handling avoids `push`, `filter`, `map`, spread over untrusted values, + * and ordinary indexed assignment, so neither poisoned prototype methods nor + * inherited index setters are on the path. + */ +const objectFreeze = Object.freeze; +const objectHasOwn = Object.hasOwn; +const arrayIsArray = Array.isArray; + +/** Shared frozen empty list, so an empty result is byte-identical every time. */ +const NO_FIELDS: readonly string[] = objectFreeze([]); + +/** + * The validated, self-consistent view of a caller-supplied state. + * + * A state is rebuilt from this snapshot on every applied transition, so any + * extra property a caller attached is dropped rather than carried forward. + */ +interface WorkflowSnapshot { + readonly workflowId: string; + readonly repositoryId: string; + readonly pullRequestId: string | null; + readonly boundCommitSha: string; + readonly revision: number; + readonly sequence: number; + readonly status: WorkflowStatus; + readonly closureReason: WorkflowClosure | null; + readonly humanGateOpenedAtRevision: number | null; + readonly invocations: readonly TrackedInvocation[]; + readonly evidence: readonly AdmittedEvidence[]; + readonly reviews: readonly AdmittedReview[]; +} + +/** + * Narrow an untrusted value to a plain object. + * + * Arrays are excluded: an array is `typeof 'object'` but is never a plausible + * record, and keeping the rejection reasons honest matters for audit. + * `Array.isArray` itself throws on a revoked Proxy, so even that call is + * guarded and a throw fails closed. + */ +function asRecord(value: unknown): object | null { + if (typeof value !== 'object' || value === null) { + return null; + } + let isArray = true; + try { + isArray = arrayIsArray(value); + } catch { + return null; + } + return isArray ? null : value; +} + +/** + * Read an optional own property that may legitimately be absent. + * + * `Object.hasOwn` itself throws on a revoked Proxy, so the failure is reported + * separately from a genuine absence: a read that threw must never look like a + * field the caller simply did not send. + */ +function readOptionalOwn(target: object, key: string): { + readonly value: unknown; + readonly failed: boolean; +} { + try { + if (!objectHasOwn(target, key)) { + return { value: undefined, failed: false }; + } + return { value: (target as Record)[key], failed: false }; + } catch { + return { value: undefined, failed: true }; + } +} + +/** + * Materialise an untrusted list with guarded reads and a hard length cap. + * + * A throwing element read returns `null` rather than dropping the element: + * silently shortening orchestration history is exactly the outcome this layer + * refuses. Iteration avoids the collection's own `map`, because an array can + * carry an own non-function `map`, a throwing `map` getter, or inherit a + * poisoned `Array.prototype.map`. + */ +function readList(value: unknown, limit: number): readonly unknown[] | null { + let elements: readonly unknown[] | null = null; + try { + elements = arrayIsArray(value) ? (value as readonly unknown[]) : null; + } catch { + return null; + } + if (elements === null) { + return null; + } + + let rawLength: unknown; + try { + rawLength = elements.length; + } catch { + return null; + } + const length = readCount(rawLength, limit); + if (length === null) { + return null; + } + + const materialised: unknown[] = []; + for (let index = 0; index < length; index += 1) { + let element: unknown; + try { + element = elements[index]; + } catch { + return null; + } + append(materialised, element); + } + return materialised; +} + +/** + * Validate one tracked invocation from a caller-supplied state. + * + * Recorded revisions and sequences must not exceed the workflow's own, and the + * reported trio must be all-null or all-present in step with `state`. A forged + * aggregate that violates either is unreadable rather than partially trusted. + */ +function readTrackedInvocation( + candidate: unknown, + revision: number, + sequence: number, +): TrackedInvocation | null { + const record = asRecord(candidate); + if (record === null) { + return null; + } + + const invocationId = readExactIdentifier(readOwnProperty(record, 'invocationId')); + const targetCommitSha = readExactIdentifier(readOwnProperty(record, 'targetCommitSha')); + const rawPurpose = readOwnProperty(record, 'purpose'); + const providerId = readExactIdentifier(readOwnProperty(record, 'providerId')); + const agentId = readExactIdentifier(readOwnProperty(record, 'agentId')); + const requestedAtRevision = readCount( + readOwnProperty(record, 'requestedAtRevision'), + revision, + ); + const requestedAtSequence = readCount( + readOwnProperty(record, 'requestedAtSequence'), + sequence, + ); + const rawState = readOwnProperty(record, 'state'); + const rawReportedStatus = readOwnProperty(record, 'reportedStatus'); + const rawReportedAtRevision = readOwnProperty(record, 'reportedAtRevision'); + const rawReportedAtSequence = readOwnProperty(record, 'reportedAtSequence'); + + if ( + invocationId === null || + targetCommitSha === null || + providerId === null || + agentId === null || + requestedAtRevision === null || + requestedAtSequence === null || + requestedAtSequence < 1 || + !isVocabularyMember(INVOCATION_PURPOSES, rawPurpose) || + !isVocabularyMember(INVOCATION_STATES, rawState) + ) { + return null; + } + + let reportedStatus: AgentReportStatus | null = null; + let reportedAtRevision: number | null = null; + let reportedAtSequence: number | null = null; + + if (rawState === INVOCATION_STATE.REPORTED) { + if (!isVocabularyMember(AGENT_REPORT_STATUSES, rawReportedStatus)) { + return null; + } + reportedStatus = rawReportedStatus; + reportedAtRevision = readCount(rawReportedAtRevision, revision); + reportedAtSequence = readCount(rawReportedAtSequence, sequence); + if ( + reportedAtRevision === null || + reportedAtSequence === null || + reportedAtRevision < requestedAtRevision || + reportedAtSequence <= requestedAtSequence + ) { + return null; + } + } else if ( + rawReportedStatus !== null || + rawReportedAtRevision !== null || + rawReportedAtSequence !== null + ) { + return null; + } + + return objectFreeze({ + invocationId, + targetCommitSha, + purpose: rawPurpose, + providerId, + agentId, + requestedAtRevision, + requestedAtSequence, + state: rawState, + reportedStatus, + reportedAtRevision, + reportedAtSequence, + }); +} + +/** Validate one evidence admission from a caller-supplied state. */ +function readAdmittedEvidence( + candidate: unknown, + revision: number, + sequence: number, +): AdmittedEvidence | null { + const record = asRecord(candidate); + if (record === null) { + return null; + } + + const evidenceId = readExactIdentifier(readOwnProperty(record, 'evidenceId')); + const rawKind = readOwnProperty(record, 'kind'); + const admittedAtCommitSha = readExactIdentifier( + readOwnProperty(record, 'admittedAtCommitSha'), + ); + const admittedAtRevision = readCount(readOwnProperty(record, 'admittedAtRevision'), revision); + const admittedAtSequence = readCount(readOwnProperty(record, 'admittedAtSequence'), sequence); + + if ( + evidenceId === null || + admittedAtCommitSha === null || + admittedAtRevision === null || + admittedAtSequence === null || + admittedAtSequence < 1 || + !isVocabularyMember(EVIDENCE_KINDS, rawKind) + ) { + return null; + } + + return objectFreeze({ + evidenceId, + kind: rawKind, + admittedAtCommitSha, + admittedAtRevision, + admittedAtSequence, + }); +} + +/** Validate one review admission from a caller-supplied state. */ +function readAdmittedReview( + candidate: unknown, + revision: number, + sequence: number, +): AdmittedReview | null { + const record = asRecord(candidate); + if (record === null) { + return null; + } + + const reviewId = readExactIdentifier(readOwnProperty(record, 'reviewId')); + const admittedAtCommitSha = readExactIdentifier( + readOwnProperty(record, 'admittedAtCommitSha'), + ); + const admittedAtRevision = readCount(readOwnProperty(record, 'admittedAtRevision'), revision); + const admittedAtSequence = readCount(readOwnProperty(record, 'admittedAtSequence'), sequence); + + if ( + reviewId === null || + admittedAtCommitSha === null || + admittedAtRevision === null || + admittedAtSequence === null || + admittedAtSequence < 1 + ) { + return null; + } + + return objectFreeze({ + reviewId, + admittedAtCommitSha, + admittedAtRevision, + admittedAtSequence, + }); +} + +/** + * Validate and snapshot a caller-supplied state. + * + * Every field is read **exactly once** into a local: a getter can return a + * different value on each read, so validating one read and storing another + * would let a state pass validation with one identity and be rebuilt under a + * different one. + * + * Structural invariants enforced here, each of which a state this layer + * produced always satisfies: + * + * - `closureReason` is non-null exactly when `status` is `CLOSED`; + * - `humanGateOpenedAtRevision` is `null` while `OPEN`, and otherwise equals + * `revision` — a HEAD advance clears the gate, so a gate can never outlive + * the revision it was opened at; + * - `AWAITING_HUMAN_DECISION` always carries an open gate; + * - every recorded revision and sequence is within the workflow's own. + */ +function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { + const record = asRecord(state); + if (record === null) { + return null; + } + + const workflowId = readExactIdentifier(readOwnProperty(record, 'workflowId')); + const repositoryId = readExactIdentifier(readOwnProperty(record, 'repositoryId')); + const rawPullRequestId = readOwnProperty(record, 'pullRequestId'); + const boundCommitSha = readExactIdentifier(readOwnProperty(record, 'boundCommitSha')); + const revision = readCount(readOwnProperty(record, 'revision'), WORKFLOW_BOUNDS.MAX_REVISION); + const sequence = readCount(readOwnProperty(record, 'sequence'), WORKFLOW_BOUNDS.MAX_SEQUENCE); + const rawStatus = readOwnProperty(record, 'status'); + const rawClosureReason = readOwnProperty(record, 'closureReason'); + const rawHumanGate = readOwnProperty(record, 'humanGateOpenedAtRevision'); + const rawInvocations = readOwnProperty(record, 'invocations'); + const rawEvidence = readOwnProperty(record, 'evidence'); + const rawReviews = readOwnProperty(record, 'reviews'); + + if ( + workflowId === null || + repositoryId === null || + boundCommitSha === null || + revision === null || + sequence === null || + !isWorkflowStatus(rawStatus) + ) { + return null; + } + + const pullRequestId = + rawPullRequestId === null ? null : readExactIdentifier(rawPullRequestId); + if (rawPullRequestId !== null && pullRequestId === null) { + return null; + } + + const closureReason = rawClosureReason === null ? null : rawClosureReason; + if (closureReason !== null && !isWorkflowClosure(closureReason)) { + return null; + } + if ((closureReason !== null) !== (rawStatus === WORKFLOW_STATUS.CLOSED)) { + return null; + } + + const humanGateOpenedAtRevision = + rawHumanGate === null ? null : readCount(rawHumanGate, WORKFLOW_BOUNDS.MAX_REVISION); + if (rawHumanGate !== null && humanGateOpenedAtRevision === null) { + return null; + } + if (humanGateOpenedAtRevision !== null && humanGateOpenedAtRevision !== revision) { + return null; + } + if (rawStatus === WORKFLOW_STATUS.OPEN && humanGateOpenedAtRevision !== null) { + return null; + } + if ( + rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION && + humanGateOpenedAtRevision === null + ) { + return null; + } + + const invocationCandidates = readList( + rawInvocations, + WORKFLOW_BOUNDS.MAX_TRACKED_INVOCATIONS, + ); + const evidenceCandidates = readList(rawEvidence, WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE); + const reviewCandidates = readList(rawReviews, WORKFLOW_BOUNDS.MAX_ADMITTED_REVIEWS); + if ( + invocationCandidates === null || + evidenceCandidates === null || + reviewCandidates === null + ) { + return null; + } + + const invocations: TrackedInvocation[] = []; + for (let index = 0; index < invocationCandidates.length; index += 1) { + const tracked = readTrackedInvocation(invocationCandidates[index], revision, sequence); + if (tracked === null) { + return null; + } + append(invocations, tracked); + } + + const evidence: AdmittedEvidence[] = []; + for (let index = 0; index < evidenceCandidates.length; index += 1) { + const admitted = readAdmittedEvidence(evidenceCandidates[index], revision, sequence); + if (admitted === null) { + return null; + } + append(evidence, admitted); + } + + const reviews: AdmittedReview[] = []; + for (let index = 0; index < reviewCandidates.length; index += 1) { + const admitted = readAdmittedReview(reviewCandidates[index], revision, sequence); + if (admitted === null) { + return null; + } + append(reviews, admitted); + } + + return { + workflowId, + repositoryId, + pullRequestId, + boundCommitSha, + revision, + sequence, + status: rawStatus, + closureReason, + humanGateOpenedAtRevision, + invocations, + evidence, + reviews, + }; +} + +/** Freeze a state and all three of its lists. Elements are already frozen. */ +function freezeState(next: WorkflowSnapshot): WorkflowState { + return objectFreeze({ + workflowId: next.workflowId, + repositoryId: next.repositoryId, + pullRequestId: next.pullRequestId, + boundCommitSha: next.boundCommitSha, + revision: next.revision, + sequence: next.sequence, + status: next.status, + closureReason: next.closureReason, + humanGateOpenedAtRevision: next.humanGateOpenedAtRevision, + invocations: objectFreeze(next.invocations), + evidence: objectFreeze(next.evidence), + reviews: objectFreeze(next.reviews), + }); +} + +/** Copy a list and append one element, without prototype methods or spread. */ +function appendTo(list: readonly T[], value: T): readonly T[] { + const next: T[] = []; + for (let index = 0; index < list.length; index += 1) { + const element = list[index]; + if (element !== undefined) { + append(next, element); + } + } + append(next, value); + return objectFreeze(next); +} + +/** Copy a list, replacing one element in place so ordering is preserved. */ +function replaceAt(list: readonly T[], target: number, value: T): readonly T[] { + const next: T[] = []; + for (let index = 0; index < list.length; index += 1) { + const element = list[index]; + if (index === target) { + append(next, value); + } else if (element !== undefined) { + append(next, element); + } + } + return objectFreeze(next); +} + +/** A rejection echoes the caller's own state object by reference. */ +function rejected( + state: WorkflowState, + rejection: TransitionRejection, + invalidFields: readonly string[], +): TransitionResult { + return objectFreeze({ + outcome: TRANSITION_OUTCOME.REJECTED, + state, + rejection, + invalidFields: objectFreeze(invalidFields), + }); +} + +/** An applied transition returns a freshly built, deeply frozen state. */ +function applied(state: WorkflowState): TransitionResult { + return objectFreeze({ + outcome: TRANSITION_OUTCOME.APPLIED, + state, + rejection: null, + invalidFields: NO_FIELDS, + }); +} + +/** Every required binding field, for a binding that cannot be read at all. */ +function allRequiredBindingFields(): readonly string[] { + const all: string[] = []; + for (let index = 0; index < REQUIRED_BINDING_FIELDS.length; index += 1) { + const field = REQUIRED_BINDING_FIELDS[index]; + if (field !== undefined) { + append(all, `binding.${field}`); + } + } + return objectFreeze(all); +} + +/** + * Open a workflow against a trusted binding. + * + * Pure, total, and deterministic: equal arguments always yield an equal result, + * and no input throws. No clock, no randomness, no identifier generation — the + * caller mints `workflowId`, and uniqueness is the caller's obligation. + * + * Binding is **all-or-nothing**: a *present* `pullRequestId` that does not + * validate invalidates the whole binding, exactly as in PR 005 and PR 006. + * There is no partially accepted binding and no field that degrades silently. + */ +export function openWorkflow(binding: WorkflowBinding): WorkflowOpenResult { + const record = asRecord(binding); + if (record === null) { + return openRejected( + TRANSITION_REJECTION.WORKFLOW_UNREADABLE, + allRequiredBindingFields(), + ); + } + + const workflowId = readExactIdentifier(readOwnProperty(record, 'workflowId')); + const repositoryId = readExactIdentifier(readOwnProperty(record, 'repositoryId')); + const pull = readOptionalOwn(record, 'pullRequestId'); + const pullRequestId = pull.value === undefined ? null : readExactIdentifier(pull.value); + const boundCommitSha = readExactIdentifier(readOwnProperty(record, 'boundCommitSha')); + + const invalid: string[] = []; + if (workflowId === null) { + append(invalid, 'binding.workflowId'); + } + if (repositoryId === null) { + append(invalid, 'binding.repositoryId'); + } + if (pull.failed || (pull.value !== undefined && pullRequestId === null)) { + append(invalid, 'binding.pullRequestId'); + } + if (boundCommitSha === null) { + append(invalid, 'binding.boundCommitSha'); + } + + if ( + workflowId === null || + repositoryId === null || + boundCommitSha === null || + invalid.length > 0 + ) { + return openRejected(TRANSITION_REJECTION.WORKFLOW_UNREADABLE, invalid); + } + + return objectFreeze({ + outcome: TRANSITION_OUTCOME.APPLIED, + state: freezeState({ + workflowId, + repositoryId, + pullRequestId, + boundCommitSha, + revision: 0, + sequence: 0, + status: WORKFLOW_STATUS.OPEN, + closureReason: null, + humanGateOpenedAtRevision: null, + invocations: [], + evidence: [], + reviews: [], + }), + rejection: null, + invalidFields: NO_FIELDS, + }); +} + +function openRejected( + rejection: TransitionRejection, + invalidFields: readonly string[], +): WorkflowOpenResult { + return objectFreeze({ + outcome: TRANSITION_OUTCOME.REJECTED, + state: null, + rejection, + invalidFields: objectFreeze(invalidFields), + }); +} + +/** + * Apply one event to a workflow. + * + * Pure, total, and deterministic: equal arguments always yield an equal result, + * and no input of any runtime type throws. No clock, no randomness, no I/O, no + * global mutation, no identifier generation. + * + * **Legality never depends on `providerId`, `agentId`, `purpose`, or + * `reportedStatus`.** Those are recorded for audit and are read by no branch in + * this module. No provider is permanently an implementer or a reviewer, no + * purpose grants authority, and a provider saying it finished is not a fact. + * + * On rejection the caller's own state object is returned **by reference**, so a + * rejection is provably a no-op. + */ +export function applyWorkflowEvent( + state: WorkflowState, + event: WorkflowEvent, +): TransitionResult { + const snapshot = snapshotWorkflow(state); + if (snapshot === null) { + return rejected(state, TRANSITION_REJECTION.WORKFLOW_UNREADABLE, ['workflow']); + } + + const eventRecord = asRecord(event); + if (eventRecord === null) { + return rejected(state, TRANSITION_REJECTION.EVENT_UNREADABLE, ['event']); + } + + const kind = readOwnProperty(eventRecord, 'kind'); + if (!isWorkflowEventKind(kind)) { + return rejected(state, TRANSITION_REJECTION.EVENT_KIND_UNKNOWN, ['event.kind']); + } + + if (snapshot.status === WORKFLOW_STATUS.CLOSED) { + return rejected(state, TRANSITION_REJECTION.WORKFLOW_CLOSED, ['workflow.status']); + } + + switch (kind) { + case WORKFLOW_EVENT_KIND.INVOCATION_REQUESTED: + return applyInvocationRequested(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.INVOCATION_REPORTED: + return applyInvocationReported(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.REVIEW_ADMITTED: + return applyReviewAdmitted(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.EVIDENCE_ADMITTED: + return applyEvidenceAdmitted(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.HEAD_OBSERVED: + return applyHeadObserved(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.HUMAN_GATE_OPENED: + return applyHumanGateOpened(state, snapshot, eventRecord); + case WORKFLOW_EVENT_KIND.CLOSE_REQUESTED: + return applyCloseRequested(state, snapshot, eventRecord); + } +} + +/** True when one more applied transition would exceed the sequence bound. */ +function sequenceExhausted(snapshot: WorkflowSnapshot): boolean { + return snapshot.sequence >= WORKFLOW_BOUNDS.MAX_SEQUENCE; +} + +/** + * Compare an optional input pull request against the workflow's. + * + * A workflow without a pull request never consults the field: there is nothing + * to mismatch against. A workflow with one accepts an absent input field, + * because an implementation invocation may precede any pull request, and + * requires an exact match when the field is present. + */ +function pullRequestMismatch( + snapshot: WorkflowSnapshot, + candidate: string | null, +): boolean { + return ( + snapshot.pullRequestId !== null && + candidate !== null && + candidate !== snapshot.pullRequestId + ); +} + +/** + * Read a pull-request binding that may legitimately be absent. + * + * **Absent and unreadable are kept apart.** A field that is present but + * oversized, blank, non-string, or behind a throwing getter must never be + * treated as "not sent": doing so would skip the mismatch check and silently + * discard the exact pull-request binding on a workflow that has one. Only a + * genuinely absent or `null` field is acceptable, and only then is the + * comparison skipped. + */ +function readBoundPullRequest( + record: object, + key: string, +): { readonly value: string | null; readonly unreadable: boolean } { + const raw = readOptionalOwn(record, key); + if (raw.failed) { + return { value: null, unreadable: true }; + } + if (raw.value === undefined || raw.value === null) { + return { value: null, unreadable: false }; + } + const value = readExactIdentifier(raw.value); + return value === null ? { value: null, unreadable: true } : { value, unreadable: false }; +} + +/** True when a present-but-unreadable or mismatched pull request must reject. */ +function pullRequestRejects( + snapshot: WorkflowSnapshot, + bound: { readonly value: string | null; readonly unreadable: boolean }, +): boolean { + if (snapshot.pullRequestId === null) { + return false; + } + return bound.unreadable || pullRequestMismatch(snapshot, bound.value); +} + +/** Index of a tracked invocation, or `-1`. Exact string equality only. */ +function indexOfInvocation(snapshot: WorkflowSnapshot, invocationId: string): number { + for (let index = 0; index < snapshot.invocations.length; index += 1) { + const tracked = snapshot.invocations[index]; + if (tracked !== undefined && tracked.invocationId === invocationId) { + return index; + } + } + return -1; +} + +function applyInvocationRequested( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const invocationRecord = asRecord(readOwnProperty(eventRecord, 'invocation')); + if (invocationRecord === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ + 'event.invocation', + ]); + } + + // Work-initiating: refused while a human gate is open. Recording a fact is + // always legal; starting new agent work while a human is deciding is not. + if (snapshot.status === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION) { + return rejected(original, TRANSITION_REJECTION.WORKFLOW_AWAITING_HUMAN, [ + 'workflow.status', + ]); + } + + const invocationId = readExactIdentifier(readOwnProperty(invocationRecord, 'invocationId')); + const repositoryId = readExactIdentifier(readOwnProperty(invocationRecord, 'repositoryId')); + const pull = readOptionalOwn(invocationRecord, 'pullRequestId'); + const pullRequestId = pull.value === undefined ? null : readExactIdentifier(pull.value); + const targetCommitSha = readExactIdentifier( + readOwnProperty(invocationRecord, 'targetCommitSha'), + ); + const providerId = readExactIdentifier(readOwnProperty(invocationRecord, 'providerId')); + const agentId = readExactIdentifier(readOwnProperty(invocationRecord, 'agentId')); + const rawPurpose = readOwnProperty(invocationRecord, 'purpose'); + const purpose = isVocabularyMember(INVOCATION_PURPOSES, rawPurpose) + ? rawPurpose + : null; + // Validated because trusted context is all-or-nothing, then deliberately not + // stored: this layer reads no clock and has nothing to compare a timestamp to. + const requestedAt = readExactIdentifier(readOwnProperty(invocationRecord, 'requestedAt')); + + const invalid: string[] = []; + if (invocationId === null) { + append(invalid, 'invocation.invocationId'); + } + if (repositoryId === null) { + append(invalid, 'invocation.repositoryId'); + } + if (pull.failed || (pull.value !== undefined && pullRequestId === null)) { + append(invalid, 'invocation.pullRequestId'); + } + if (targetCommitSha === null) { + append(invalid, 'invocation.targetCommitSha'); + } + if (providerId === null) { + append(invalid, 'invocation.providerId'); + } + if (agentId === null) { + append(invalid, 'invocation.agentId'); + } + if (purpose === null) { + append(invalid, 'invocation.purpose'); + } + if (requestedAt === null) { + append(invalid, 'invocation.requestedAt'); + } + + if ( + invocationId === null || + repositoryId === null || + targetCommitSha === null || + providerId === null || + agentId === null || + purpose === null || + requestedAt === null || + invalid.length > 0 + ) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, invalid); + } + + const mismatched: string[] = []; + if (repositoryId !== snapshot.repositoryId) { + append(mismatched, 'invocation.repositoryId'); + } + if (targetCommitSha !== snapshot.boundCommitSha) { + append(mismatched, 'invocation.targetCommitSha'); + } + if (pullRequestMismatch(snapshot, pullRequestId)) { + append(mismatched, 'invocation.pullRequestId'); + } + if (mismatched.length > 0) { + return rejected(original, TRANSITION_REJECTION.BINDING_MISMATCH, mismatched); + } + + // Invocation identity is permanent and workflow-wide, unlike an admission, + // which is scoped to a revision: a reused invocation id is an attribution + // hazard, whereas an observation genuinely can be current again later. + if (indexOfInvocation(snapshot, invocationId) !== -1) { + return rejected(original, TRANSITION_REJECTION.DUPLICATE_INVOCATION_ID, [ + 'invocation.invocationId', + ]); + } + + if ( + snapshot.invocations.length >= WORKFLOW_BOUNDS.MAX_TRACKED_INVOCATIONS || + sequenceExhausted(snapshot) + ) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, [ + 'workflow.invocations', + ]); + } + + const sequence = snapshot.sequence + 1; + const tracked: TrackedInvocation = objectFreeze({ + invocationId, + targetCommitSha, + purpose, + providerId, + agentId, + requestedAtRevision: snapshot.revision, + requestedAtSequence: sequence, + state: INVOCATION_STATE.REQUESTED, + reportedStatus: null, + reportedAtRevision: null, + reportedAtSequence: null, + }); + + return applied( + freezeState({ + ...snapshot, + sequence, + invocations: appendTo(snapshot.invocations, tracked), + }), + ); +} + +function applyInvocationReported( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const reportRecord = asRecord(readOwnProperty(eventRecord, 'report')); + if (reportRecord === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, ['event.report']); + } + + if (readOwnProperty(reportRecord, 'outcome') !== REPORT_OUTCOME.INGESTED) { + return rejected(original, TRANSITION_REJECTION.INPUT_NOT_INGESTED, ['report.outcome']); + } + + const invocationId = readExactIdentifier(readOwnProperty(reportRecord, 'invocationId')); + if (invocationId === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ + 'report.invocationId', + ]); + } + + const index = indexOfInvocation(snapshot, invocationId); + const tracked = index === -1 ? undefined : snapshot.invocations[index]; + if (tracked === undefined) { + return rejected(original, TRANSITION_REJECTION.UNKNOWN_INVOCATION, [ + 'report.invocationId', + ]); + } + if (tracked.state !== INVOCATION_STATE.REQUESTED) { + return rejected(original, TRANSITION_REJECTION.INVOCATION_ALREADY_REPORTED, [ + 'report.invocationId', + ]); + } + + // A report binds to its own invocation's commit, not to the workflow's + // current one. A report arriving after HEAD moved is a true historical fact + // and is recorded; it admits no evidence, because admission is keyed on the + // current revision and runs through a different event entirely. + const repositoryId = readExactIdentifier(readOwnProperty(reportRecord, 'repositoryId')); + const targetCommitSha = readExactIdentifier( + readOwnProperty(reportRecord, 'targetCommitSha'), + ); + const pullRequestId = readBoundPullRequest(reportRecord, 'pullRequestId'); + + const mismatched: string[] = []; + if (repositoryId === null || repositoryId !== snapshot.repositoryId) { + append(mismatched, 'report.repositoryId'); + } + if (targetCommitSha === null || targetCommitSha !== tracked.targetCommitSha) { + append(mismatched, 'report.targetCommitSha'); + } + if (pullRequestRejects(snapshot, pullRequestId)) { + append(mismatched, 'report.pullRequestId'); + } + if (mismatched.length > 0) { + return rejected(original, TRANSITION_REJECTION.BINDING_MISMATCH, mismatched); + } + + if (sequenceExhausted(snapshot)) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.sequence']); + } + + // Carried verbatim from PR 006, re-narrowed defensively and failing closed to + // `unknown`. It is recorded, never branched on: `reported-complete` and + // `reported-failed` produce indistinguishable transitions. + const rawReportedStatus = readOwnProperty(reportRecord, 'reportedStatus'); + const reportedStatus = isVocabularyMember( + AGENT_REPORT_STATUSES, + rawReportedStatus, + ) + ? rawReportedStatus + : 'unknown'; + + const sequence = snapshot.sequence + 1; + const next: TrackedInvocation = objectFreeze({ + invocationId: tracked.invocationId, + targetCommitSha: tracked.targetCommitSha, + purpose: tracked.purpose, + providerId: tracked.providerId, + agentId: tracked.agentId, + requestedAtRevision: tracked.requestedAtRevision, + requestedAtSequence: tracked.requestedAtSequence, + state: INVOCATION_STATE.REPORTED, + reportedStatus, + reportedAtRevision: snapshot.revision, + reportedAtSequence: sequence, + }); + + return applied( + freezeState({ + ...snapshot, + sequence, + invocations: replaceAt(snapshot.invocations, index, next), + }), + ); +} + +function applyReviewAdmitted( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const reviewRecord = asRecord(readOwnProperty(eventRecord, 'review')); + if (reviewRecord === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, ['event.review']); + } + + if (readOwnProperty(reviewRecord, 'outcome') !== INGESTION_OUTCOME.INGESTED) { + return rejected(original, TRANSITION_REJECTION.INPUT_NOT_INGESTED, ['review.outcome']); + } + + // PR 005 makes `reviewId` optional; this layer requires it, because an + // admission nobody can attribute is not worth recording. + const reviewId = readExactIdentifier(readOwnProperty(reviewRecord, 'reviewId')); + if (reviewId === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, ['review.reviewId']); + } + + const repositoryId = readExactIdentifier(readOwnProperty(reviewRecord, 'repositoryId')); + const reviewedCommitSha = readExactIdentifier( + readOwnProperty(reviewRecord, 'reviewedCommitSha'), + ); + const pullRequestId = readBoundPullRequest(reviewRecord, 'pullRequestId'); + + const mismatched: string[] = []; + if (repositoryId === null || repositoryId !== snapshot.repositoryId) { + append(mismatched, 'review.repositoryId'); + } + // Old-commit evidence can never advance a current-HEAD workflow. + if (reviewedCommitSha === null || reviewedCommitSha !== snapshot.boundCommitSha) { + append(mismatched, 'review.reviewedCommitSha'); + } + if (pullRequestRejects(snapshot, pullRequestId)) { + append(mismatched, 'review.pullRequestId'); + } + if (mismatched.length > 0) { + return rejected(original, TRANSITION_REJECTION.BINDING_MISMATCH, mismatched); + } + + for (let index = 0; index < snapshot.reviews.length; index += 1) { + const admitted = snapshot.reviews[index]; + if ( + admitted !== undefined && + admitted.reviewId === reviewId && + admitted.admittedAtRevision === snapshot.revision + ) { + return rejected(original, TRANSITION_REJECTION.DUPLICATE_ADMISSION, ['review.reviewId']); + } + } + + if ( + snapshot.reviews.length >= WORKFLOW_BOUNDS.MAX_ADMITTED_REVIEWS || + sequenceExhausted(snapshot) + ) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.reviews']); + } + + // Findings are never read. No count, no severity, no text: a derived summary + // would be a second answer that can drift from PR 005's, and a severity + // reaching this layer would make findings look like policy. + // + // The review need not correspond to a tracked invocation, and admitting it + // transitions none: it implies nothing about having been requested, about + // sufficiency, about policy satisfaction, or about authority. + const sequence = snapshot.sequence + 1; + const admission: AdmittedReview = objectFreeze({ + reviewId, + admittedAtCommitSha: snapshot.boundCommitSha, + admittedAtRevision: snapshot.revision, + admittedAtSequence: sequence, + }); + + return applied( + freezeState({ + ...snapshot, + sequence, + reviews: appendTo(snapshot.reviews, admission), + }), + ); +} + +function applyEvidenceAdmitted( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const verdictRecord = asRecord(readOwnProperty(eventRecord, 'verdict')); + if (verdictRecord === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, ['event.verdict']); + } + + // Freshness is never re-derived here. PR 004 already answered the question, + // and its result carries the target it was answered against — so the only + // thing left to check is that the answer is about *this* workflow's binding. + // A caller cannot launder stale evidence by judging it against a convenient + // target and handing over the verdict. + const notCurrent: string[] = []; + if (readOwnProperty(verdictRecord, 'state') !== FRESHNESS.CURRENT) { + append(notCurrent, 'verdict.state'); + } + if (readOwnProperty(verdictRecord, 'reason') !== FRESHNESS_REASON.BOUND_TO_CURRENT_HEAD) { + append(notCurrent, 'verdict.reason'); + } + if (readOwnProperty(verdictRecord, 'targetRepositoryId') !== snapshot.repositoryId) { + append(notCurrent, 'verdict.targetRepositoryId'); + } + if (readOwnProperty(verdictRecord, 'targetHeadSha') !== snapshot.boundCommitSha) { + append(notCurrent, 'verdict.targetHeadSha'); + } + if (notCurrent.length > 0) { + return rejected(original, TRANSITION_REJECTION.EVIDENCE_NOT_CURRENT, notCurrent); + } + + const evidenceId = readExactIdentifier(readOwnProperty(verdictRecord, 'evidenceId')); + const rawKind = readOwnProperty(verdictRecord, 'kind'); + const invalid: string[] = []; + if (evidenceId === null) { + append(invalid, 'verdict.evidenceId'); + } + if (!isVocabularyMember(EVIDENCE_KINDS, rawKind)) { + append(invalid, 'verdict.kind'); + } + if (evidenceId === null || !isVocabularyMember(EVIDENCE_KINDS, rawKind)) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, invalid); + } + + for (let index = 0; index < snapshot.evidence.length; index += 1) { + const admitted = snapshot.evidence[index]; + if ( + admitted !== undefined && + admitted.evidenceId === evidenceId && + admitted.admittedAtRevision === snapshot.revision + ) { + return rejected(original, TRANSITION_REJECTION.DUPLICATE_ADMISSION, [ + 'verdict.evidenceId', + ]); + } + } + + if ( + snapshot.evidence.length >= WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE || + sequenceExhausted(snapshot) + ) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.evidence']); + } + + const sequence = snapshot.sequence + 1; + const admission: AdmittedEvidence = objectFreeze({ + evidenceId, + kind: rawKind, + admittedAtCommitSha: snapshot.boundCommitSha, + admittedAtRevision: snapshot.revision, + admittedAtSequence: sequence, + }); + + // A human decision clears an open gate. What the human decided is not read, + // and cannot be: `EvidenceFreshness` carries no verdict field. This records + // that a decision exists at the bound commit — never what it permits. + const gateOpen = snapshot.status === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION; + const clearing = gateOpen && rawKind === EVIDENCE_KIND.HUMAN_DECISION; + + return applied( + freezeState({ + ...snapshot, + sequence, + status: clearing ? WORKFLOW_STATUS.OPEN : snapshot.status, + humanGateOpenedAtRevision: clearing ? null : snapshot.humanGateOpenedAtRevision, + evidence: appendTo(snapshot.evidence, admission), + }), + ); +} + +function applyHeadObserved( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const observedCommitSha = readExactIdentifier( + readOwnProperty(eventRecord, 'observedCommitSha'), + ); + if (observedCommitSha === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ + 'event.observedCommitSha', + ]); + } + + if (observedCommitSha === snapshot.boundCommitSha) { + return rejected(original, TRANSITION_REJECTION.HEAD_UNCHANGED, [ + 'event.observedCommitSha', + ]); + } + + if ( + snapshot.revision >= WORKFLOW_BOUNDS.MAX_REVISION || + sequenceExhausted(snapshot) + ) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.revision']); + } + + // Commit ordering is never inferred. A SHA is opaque: there is no parent + // check, no ancestry test, and no "is this newer". A HEAD that returns to a + // previous value still advances the revision, so evidence admitted earlier + // cannot resurrect — revision, not SHA alone, is the admission key. + // + // Prior admissions and outstanding invocations are retained unchanged. They + // remain true at their own revision and commit; they simply stop counting. + // + // A human gate is commit-bound orchestration state, not authority, so a HEAD + // advance clears it. This grants nothing: a decision recorded against the + // superseded commit is subsequently rejected as not current, and PR 003 plus + // the human remain the only authority boundary. + return applied( + freezeState({ + ...snapshot, + boundCommitSha: observedCommitSha, + revision: snapshot.revision + 1, + sequence: snapshot.sequence + 1, + status: WORKFLOW_STATUS.OPEN, + humanGateOpenedAtRevision: null, + }), + ); +} + +function applyHumanGateOpened( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const atCommitSha = readExactIdentifier(readOwnProperty(eventRecord, 'atCommitSha')); + if (atCommitSha === null) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ + 'event.atCommitSha', + ]); + } + + if (snapshot.status === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION) { + return rejected(original, TRANSITION_REJECTION.HUMAN_GATE_ALREADY_OPEN, [ + 'workflow.status', + ]); + } + + if (atCommitSha !== snapshot.boundCommitSha) { + return rejected(original, TRANSITION_REJECTION.BINDING_MISMATCH, ['event.atCommitSha']); + } + + if (sequenceExhausted(snapshot)) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.sequence']); + } + + return applied( + freezeState({ + ...snapshot, + sequence: snapshot.sequence + 1, + status: WORKFLOW_STATUS.AWAITING_HUMAN_DECISION, + humanGateOpenedAtRevision: snapshot.revision, + }), + ); +} + +function applyCloseRequested( + original: WorkflowState, + snapshot: WorkflowSnapshot, + eventRecord: object, +): TransitionResult { + const rawClosureReason = readOwnProperty(eventRecord, 'closureReason'); + if (!isWorkflowClosure(rawClosureReason)) { + return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ + 'event.closureReason', + ]); + } + + if (sequenceExhausted(snapshot)) { + return rejected(original, TRANSITION_REJECTION.CAPACITY_EXCEEDED, ['workflow.sequence']); + } + + // `closureReason` is a trusted, inert label. `HUMAN_DECISION_RECORDED` is not + // verified against an admitted human-decision record: this layer asserts + // nothing about why a caller closed a workflow, and the label grants nothing. + // + // `humanGateOpenedAtRevision` is retained, so a state closed while a human + // was still deciding stays distinguishable from one closed cleanly. + return applied( + freezeState({ + ...snapshot, + sequence: snapshot.sequence + 1, + status: WORKFLOW_STATUS.CLOSED, + closureReason: rawClosureReason, + }), + ); +} diff --git a/src/domain/workflow.ts b/src/domain/workflow.ts new file mode 100644 index 0000000..107058f --- /dev/null +++ b/src/domain/workflow.ts @@ -0,0 +1,704 @@ +/** + * Autoflow workflow state: the commit-bound record of what has been requested + * and what has been independently established for one unit of work. + * + * trusted workflow binding + one already-normalized event + * -> immutable WorkflowState | rejection + * + * PR 007 scope: state and vocabulary only. This module declares the domain + * types; `workflow-transitions.ts` holds the two pure entry points. Nothing in + * either file invokes an agent, calls a forge, opens a socket, reads a clock, + * touches the filesystem, spawns a process, persists anything, verifies an + * artifact, detects integration, judges freshness, selects a provider, retries, + * schedules, or makes a merge decision. + * + * The layer answers exactly one question: + * + * Given everything recorded so far for this repository at this exact + * commit, is this event a legal thing to record, and what is the result? + * + * It does not answer what should happen next. Legality is domain; selection is + * policy, and policy is a later PR. There is deliberately no projection, + * recommendation, ranking, or next-action API. + * + * **PR 007 consumes only the outputs of PR 004, PR 005, and PR 006.** There is + * no signature here that accepts an `AgentReport`, a `ReviewSubmission`, or an + * `EvidenceRecord`, so a second normalizer is structurally impossible rather + * than merely discouraged. Frozen vocabulary constants are imported from those + * layers — redeclaring `FRESHNESS.CURRENT` would create a divergent second + * answer — but no reader, normalizer, or validator function is. + * + * Deliberately absent, and never to be added: credentials, tokens, secrets, + * prompt or instruction payloads, callbacks, streams, file handles, API + * clients, mutable service objects, clocks, and metadata bags. There is no + * field typed to accept one. + */ + +import type { EvidenceKind } from './evidence.js'; +import type { EvidenceFreshness } from './evidence-freshness.js'; +import type { ReviewResult } from './review.js'; +import type { + AgentInvocation, + AgentReportStatus, + InvocationPurpose, +} from './agent-invocation.js'; +import type { InvocationReportResult } from './agent-invocation-report.js'; + +/** + * Intrinsics captured at module load. + * + * Caller-supplied state and event payloads are read through getters and Proxy + * traps that execute *during* evaluation. Such a trap can repoint + * `String.prototype.trim`, `Object.freeze`, and the `Array.prototype` methods + * the evaluator would otherwise rely on afterwards — turning validation into + * attacker-controlled code. Capturing the intrinsics here, before any untrusted + * property access is possible, removes that lever. + * + * Everything downstream either uses one of these references or is written so it + * depends on no prototype method at all. Same pattern as `evidence.ts`, + * `review.ts`, and `agent-invocation.ts`. + */ +const objectFreeze = Object.freeze; +const objectDefineProperty = Object.defineProperty; +const objectHasOwn = Object.hasOwn; +const objectIs = Object.is; +const numberIsInteger = Number.isInteger; +const reflectApply = Reflect.apply; +// Captured unbound on purpose and invoked through `Reflect.apply`, so neither a +// poisoned prototype method nor a poisoned `Function.prototype.call` is on the +// path. `this` is supplied explicitly at every call site. +/* eslint-disable @typescript-eslint/unbound-method */ +const stringTrim = String.prototype.trim; +const stringSlice = String.prototype.slice; +/* eslint-enable @typescript-eslint/unbound-method */ + +/** + * Membership test that touches no prototype method. + * + * A plain indexed scan over a frozen list uses only `===` and own-property + * reads, so poisoning `Set.prototype.has`, `Array.prototype.includes`, + * `indexOf`, or the array iterator cannot influence vocabulary validation. + */ +function containsValue(list: readonly string[], value: unknown): boolean { + for (let index = 0; index < list.length; index += 1) { + if (list[index] === value) { + return true; + } + } + return false; +} + +/** Append by defining an own element, bypassing inherited index setters. */ +export function append(list: T[], value: T): void { + objectDefineProperty(list, list.length, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + +/** + * Where a unit of work stands. + * + * Three members, deliberately. Any additional member — `AWAITING_REVIEW`, + * `REPAIR_IN_PROGRESS`, `READY_FOR_MERGE` — would encode either routing + * (repository policy) or sufficiency (a later PR's termination policy). + * + * `AWAITING_HUMAN_DECISION` states only *that* a human was asked. It never + * states what they were asked, what they answered, or what an answer would + * permit: there is no field anywhere in this layer through which that could be + * expressed. PR 003's policy gate plus the human remain the only authority. + * + * Anything derivable is deliberately not a status. "Work outstanding" is + * `invocations.some(state === REQUESTED)` and is computed by the caller, never + * stored — two fields that can disagree are a defect waiting to happen. + */ +export const WORKFLOW_STATUS = objectFreeze({ + /** Bound to a commit; work may be initiated and facts recorded. */ + OPEN: 'OPEN', + /** A human decision has been requested and not yet recorded. */ + AWAITING_HUMAN_DECISION: 'AWAITING_HUMAN_DECISION', + /** Terminal. No transition is legal, ever. */ + CLOSED: 'CLOSED', +} as const); + +export type WorkflowStatus = (typeof WORKFLOW_STATUS)[keyof typeof WORKFLOW_STATUS]; + +/** Every member of the {@link WorkflowStatus} union. Frozen: validation reads it. */ +export const WORKFLOW_STATUSES: readonly WorkflowStatus[] = objectFreeze([ + WORKFLOW_STATUS.OPEN, + WORKFLOW_STATUS.AWAITING_HUMAN_DECISION, + WORKFLOW_STATUS.CLOSED, +]); + +/** + * Where one tracked invocation stands. + * + * Two members, deliberately. PR 006's `AgentReportStatus` is terminal-only and + * says so: "a non-terminal state requires something to transition it". This is + * that state, and nothing more. + * + * There is no `CANCELLED`, `TIMED_OUT`, `ABANDONED`, or `SUPERSEDED`. + * Cancellation and deadlines are termination policy, which belongs to a later + * PR; supersession is derivable from `targetCommitSha` against the workflow's + * bound commit, and deriving it here would be a second freshness answer. + */ +export const INVOCATION_STATE = objectFreeze({ + /** Registered against a workflow revision; no report applied. */ + REQUESTED: 'REQUESTED', + /** A PR 006 report with outcome `INGESTED` has been applied. */ + REPORTED: 'REPORTED', +} as const); + +export type InvocationState = (typeof INVOCATION_STATE)[keyof typeof INVOCATION_STATE]; + +/** Every member of the {@link InvocationState} union. */ +export const INVOCATION_STATES: readonly InvocationState[] = objectFreeze([ + INVOCATION_STATE.REQUESTED, + INVOCATION_STATE.REPORTED, +]); + +/** + * Why a workflow ended. + * + * Deliberately no `MERGED`, `COMPLETED`, `SUCCEEDED`, or `ABANDONED` member. A + * merge is an observation recorded as evidence, never a closure semantic, and + * "completed" would be a sufficiency claim this layer cannot make. + * + * Both members are trusted, inert labels. `HUMAN_DECISION_RECORDED` is *not* + * verified against an admitted human-decision record: this layer asserts + * nothing about why a caller closed a workflow, and the label grants nothing. + */ +export const WORKFLOW_CLOSURE = objectFreeze({ + HUMAN_DECISION_RECORDED: 'HUMAN_DECISION_RECORDED', + CALLER_CLOSED: 'CALLER_CLOSED', +} as const); + +export type WorkflowClosure = (typeof WORKFLOW_CLOSURE)[keyof typeof WORKFLOW_CLOSURE]; + +/** Every member of the {@link WorkflowClosure} union. */ +export const WORKFLOW_CLOSURES: readonly WorkflowClosure[] = objectFreeze([ + WORKFLOW_CLOSURE.HUMAN_DECISION_RECORDED, + WORKFLOW_CLOSURE.CALLER_CLOSED, +]); + +/** + * Outcome of one transition attempt. + * + * Binary on purpose. A three-valued outcome with a "benign no-op" member would + * invite a caller to treat some rejections as harmless; the nuance lives in the + * rejection reason instead, where it cannot be skipped. + */ +export const TRANSITION_OUTCOME = objectFreeze({ + /** Legal; a new frozen state is returned. */ + APPLIED: 'APPLIED', + /** Not legal; the identical prior state reference is returned. */ + REJECTED: 'REJECTED', +} as const); + +export type TransitionOutcome = (typeof TRANSITION_OUTCOME)[keyof typeof TRANSITION_OUTCOME]; + +/** Every member of the {@link TransitionOutcome} union. */ +export const TRANSITION_OUTCOMES: readonly TransitionOutcome[] = objectFreeze([ + TRANSITION_OUTCOME.APPLIED, + TRANSITION_OUTCOME.REJECTED, +]); + +/** + * The seven things that can be recorded. + * + * There is deliberately no `HUMAN_DECISION_RECORDED` event. A human decision is + * PR 004 evidence of kind `human-decision`, arriving through + * `EVIDENCE_ADMITTED`. `EvidenceFreshness` carries no verdict field, so this + * layer records *that* a human decided and is structurally unable to learn + * *what* they decided. + */ +export const WORKFLOW_EVENT_KIND = objectFreeze({ + INVOCATION_REQUESTED: 'INVOCATION_REQUESTED', + INVOCATION_REPORTED: 'INVOCATION_REPORTED', + REVIEW_ADMITTED: 'REVIEW_ADMITTED', + EVIDENCE_ADMITTED: 'EVIDENCE_ADMITTED', + HEAD_OBSERVED: 'HEAD_OBSERVED', + HUMAN_GATE_OPENED: 'HUMAN_GATE_OPENED', + CLOSE_REQUESTED: 'CLOSE_REQUESTED', +} as const); + +export type WorkflowEventKind = + (typeof WORKFLOW_EVENT_KIND)[keyof typeof WORKFLOW_EVENT_KIND]; + +/** Every member of the {@link WorkflowEventKind} union. */ +export const WORKFLOW_EVENT_KINDS: readonly WorkflowEventKind[] = objectFreeze([ + WORKFLOW_EVENT_KIND.INVOCATION_REQUESTED, + WORKFLOW_EVENT_KIND.INVOCATION_REPORTED, + WORKFLOW_EVENT_KIND.REVIEW_ADMITTED, + WORKFLOW_EVENT_KIND.EVIDENCE_ADMITTED, + WORKFLOW_EVENT_KIND.HEAD_OBSERVED, + WORKFLOW_EVENT_KIND.HUMAN_GATE_OPENED, + WORKFLOW_EVENT_KIND.CLOSE_REQUESTED, +]); + +/** + * Why a transition was refused. Every member fails closed. + * + * A rejection never partially applies: no counter moves, no list grows, no + * status changes, and the caller receives the identical prior state reference. + */ +export const TRANSITION_REJECTION = objectFreeze({ + /** State is not a readable, self-consistent workflow. */ + WORKFLOW_UNREADABLE: 'WORKFLOW_UNREADABLE', + /** Event is not an object, is an array, or a read threw. */ + EVENT_UNREADABLE: 'EVENT_UNREADABLE', + /** `kind` is absent or unrecognised. Never defaulted, never inferred. */ + EVENT_KIND_UNKNOWN: 'EVENT_KIND_UNKNOWN', + /** Kind is known but its payload is missing or malformed. */ + EVENT_PAYLOAD_INVALID: 'EVENT_PAYLOAD_INVALID', + /** The workflow is terminal. Every event, unconditionally. */ + WORKFLOW_CLOSED: 'WORKFLOW_CLOSED', + /** A work-initiating event while a human gate is open. */ + WORKFLOW_AWAITING_HUMAN: 'WORKFLOW_AWAITING_HUMAN', + /** A human gate was requested while one is already open. */ + HUMAN_GATE_ALREADY_OPEN: 'HUMAN_GATE_ALREADY_OPEN', + /** Repository, pull request, or commit binding failed exact comparison. */ + BINDING_MISMATCH: 'BINDING_MISMATCH', + /** A PR 005 or PR 006 result whose outcome was not `INGESTED`. */ + INPUT_NOT_INGESTED: 'INPUT_NOT_INGESTED', + /** A PR 004 verdict that was not CURRENT, or not judged against this binding. */ + EVIDENCE_NOT_CURRENT: 'EVIDENCE_NOT_CURRENT', + /** The invocation id is already tracked, at any revision. */ + DUPLICATE_INVOCATION_ID: 'DUPLICATE_INVOCATION_ID', + /** A report for an invocation this workflow never requested. */ + UNKNOWN_INVOCATION: 'UNKNOWN_INVOCATION', + /** Replay of a report against an already-reported invocation. */ + INVOCATION_ALREADY_REPORTED: 'INVOCATION_ALREADY_REPORTED', + /** Same evidence or review id already admitted at the current revision. */ + DUPLICATE_ADMISSION: 'DUPLICATE_ADMISSION', + /** Observed HEAD equals the bound commit. */ + HEAD_UNCHANGED: 'HEAD_UNCHANGED', + /** A bound would be exceeded. The fact is refused, never silently dropped. */ + CAPACITY_EXCEEDED: 'CAPACITY_EXCEEDED', +} as const); + +export type TransitionRejection = + (typeof TRANSITION_REJECTION)[keyof typeof TRANSITION_REJECTION]; + +/** Every member of the {@link TransitionRejection} union, in declaration order. */ +export const TRANSITION_REJECTIONS: readonly TransitionRejection[] = objectFreeze([ + TRANSITION_REJECTION.WORKFLOW_UNREADABLE, + TRANSITION_REJECTION.EVENT_UNREADABLE, + TRANSITION_REJECTION.EVENT_KIND_UNKNOWN, + TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, + TRANSITION_REJECTION.WORKFLOW_CLOSED, + TRANSITION_REJECTION.WORKFLOW_AWAITING_HUMAN, + TRANSITION_REJECTION.HUMAN_GATE_ALREADY_OPEN, + TRANSITION_REJECTION.BINDING_MISMATCH, + TRANSITION_REJECTION.INPUT_NOT_INGESTED, + TRANSITION_REJECTION.EVIDENCE_NOT_CURRENT, + TRANSITION_REJECTION.DUPLICATE_INVOCATION_ID, + TRANSITION_REJECTION.UNKNOWN_INVOCATION, + TRANSITION_REJECTION.INVOCATION_ALREADY_REPORTED, + TRANSITION_REJECTION.DUPLICATE_ADMISSION, + TRANSITION_REJECTION.HEAD_UNCHANGED, + TRANSITION_REJECTION.CAPACITY_EXCEEDED, +]); + +/** + * V1 bounds. + * + * Every unbounded dimension is capped before iteration. Exceeding a cap + * **rejects the transition**; nothing here truncates. + * + * This is a deliberate third convention. PR 004 collapses an over-length + * evidence set to zero; PR 005 and PR 006 truncate and flag. Both operate on + * elements of a single hostile payload. A transition instead carries **one + * discrete fact**, so refusing it visibly at the call site is the only outcome + * that loses nothing — silently dropping orchestration history would be the + * dangerous result. + * + * `MAX_IDENTIFIER_LENGTH` must equal PR 005's `REVIEW_BOUNDS` and PR 006's + * `INVOCATION_BOUNDS` identifier bound. The three boundaries share no code, so + * the invariant is pinned by a test rather than by an import. + */ +export const WORKFLOW_BOUNDS = objectFreeze({ + /** Characters permitted in any identifier-shaped field. Oversize rejects. */ + MAX_IDENTIFIER_LENGTH: 256, + /** Invocations tracked in one workflow, across all revisions. */ + MAX_TRACKED_INVOCATIONS: 256, + /** Evidence admissions retained, across all revisions. */ + MAX_ADMITTED_EVIDENCE: 1_024, + /** Review admissions retained, across all revisions. */ + MAX_ADMITTED_REVIEWS: 256, + /** Highest reachable revision. */ + MAX_REVISION: 1_000_000, + /** Highest reachable sequence. */ + MAX_SEQUENCE: 1_000_000, +} as const); + +/** + * Trusted binding, supplied by the caller. + * + * This is the **only** source of workflow identity, repository, pull request, + * and initial commit. `pullRequestId` is a string so every binding field + * validates uniformly; a caller holding a numeric pull-request number + * stringifies it. It is optional because a unit of work may precede any pull + * request. + */ +export interface WorkflowBinding { + /** Caller-minted identity. Exact; never generated here, never truncated. */ + readonly workflowId: string; + /** Repository this unit of work is about. */ + readonly repositoryId: string; + /** Pull request, where one exists. Absent is not a mismatch. */ + readonly pullRequestId?: string; + /** The commit the workflow is initially bound to. */ + readonly boundCommitSha: string; +} + +/** + * Every binding field, in declaration order. + * + * Invalid-field reporting walks this list, so the order of `invalidFields` is + * deterministic and stable. + */ +export const WORKFLOW_BINDING_FIELD_ORDER = objectFreeze([ + 'workflowId', + 'repositoryId', + 'pullRequestId', + 'boundCommitSha', +] as const); + +/** + * Binding fields that must always be present and valid. + * + * `pullRequestId` is absent because it is optional. When it *is* present it + * must still validate: trusted context is all-or-nothing, so there is no + * partially accepted binding and no field that degrades silently. + */ +export const REQUIRED_BINDING_FIELDS = objectFreeze([ + 'workflowId', + 'repositoryId', + 'boundCommitSha', +] as const); + +/** + * One invocation this workflow asked for. + * + * `purpose`, `providerId`, and `agentId` are recorded for audit and are + * **inert**: no transition's legality depends on any of them. Roles are not + * permanently assigned to providers, and no purpose grants authority. A test + * pins byte-identical behaviour across every provider label and purpose. + * + * `reportedStatus` is carried verbatim from PR 006 and is equally inert. + * `reported-complete` and `reported-failed` produce indistinguishable + * transitions; interpreting them is termination policy, not domain. + */ +export interface TrackedInvocation { + readonly invocationId: string; + /** From the trusted PR 006 binding. Permanent; never rewritten to a newer HEAD. */ + readonly targetCommitSha: string; + /** PR 006 label. Inert: never read for legality. */ + readonly purpose: InvocationPurpose; + /** Inert: never read for legality. */ + readonly providerId: string; + /** Inert: never read for legality. */ + readonly agentId: string; + readonly requestedAtRevision: number; + readonly requestedAtSequence: number; + readonly state: InvocationState; + /** Carried verbatim from PR 006. Inert. `null` while `REQUESTED`. */ + readonly reportedStatus: AgentReportStatus | null; + readonly reportedAtRevision: number | null; + readonly reportedAtSequence: number | null; +} + +/** + * A PR 004 observation admitted at one revision. + * + * A pointer plus its exact commit binding — never a copy of the record, never a + * verdict, never a summary. Admission at revision *n* is a permanent historical + * fact: it is retained when HEAD advances, and it stops counting, because + * admission is keyed on revision rather than on SHA alone. + * + * `admittedAtCommitSha` is retained so a persisted state is independently + * auditable without a companion history table. Past bound commits are not + * otherwise recoverable from the aggregate. + */ +export interface AdmittedEvidence { + readonly evidenceId: string; + /** Echoed from the PR 004 verdict. */ + readonly kind: EvidenceKind; + /** `boundCommitSha` at the moment of admission. Permanent. */ + readonly admittedAtCommitSha: string; + readonly admittedAtRevision: number; + readonly admittedAtSequence: number; +} + +/** + * A PR 005 review admitted at one revision. + * + * A stable admission pointer and nothing more. There is deliberately no finding + * count, severity breakdown, or finding text: a derived summary would be a + * second answer that can drift from PR 005's, and a severity reaching this + * layer would make findings look like policy. Findings remain evidence. + * + * An admitted review is not necessarily one AgentBridge requested, and carries + * no implication of sufficiency, policy satisfaction, or authority. + */ +export interface AdmittedReview { + readonly reviewId: string; + /** `boundCommitSha` at the moment of admission. Permanent. */ + readonly admittedAtCommitSha: string; + readonly admittedAtRevision: number; + readonly admittedAtSequence: number; +} + +/** + * The aggregate. + * + * Every field is a primitive, `null`, or a frozen list of objects whose fields + * are primitives or `null`, so the state is JSON-serializable and survives a + * round trip unchanged. + * + * There is deliberately no `exists`, `verified`, `observed`, `integrated`, + * `merged`, `applied`, `validated`, `authorized`, `mergeable`, `approved`, + * `ready`, `freshness`, `current`, `stale`, `nextAction`, `attempt`, `retries`, + * `budget`, `deadline`, `timeout`, `backoff`, `cost`, or `converged` field, and + * a test asserts none can appear even when every event payload plants them. + */ +export interface WorkflowState { + readonly workflowId: string; + readonly repositoryId: string; + readonly pullRequestId: string | null; + /** Changes only through `HEAD_OBSERVED`. */ + readonly boundCommitSha: string; + /** Monotonic. +1 per HEAD change. The admission key. */ + readonly revision: number; + /** Monotonic. +1 per applied transition. The ordering primitive. */ + readonly sequence: number; + readonly status: WorkflowStatus; + /** Non-null exactly when `status` is `CLOSED`. */ + readonly closureReason: WorkflowClosure | null; + /** + * Revision at which the current human gate was opened. + * + * Always `null` or exactly `revision`: a HEAD advance clears the gate, so a + * gate can never outlive the revision it was opened at. Its only + * non-derivable content is whether a gate was open at closure. The + * relationship is enforced when a state is read back and pinned by a test, so + * the two values cannot disagree. + */ + readonly humanGateOpenedAtRevision: number | null; + readonly invocations: readonly TrackedInvocation[]; + readonly evidence: readonly AdmittedEvidence[]; + readonly reviews: readonly AdmittedReview[]; +} + +/** Register an invocation this workflow is asking for. Work-initiating. */ +export interface InvocationRequestedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.INVOCATION_REQUESTED; + /** PR 006 trusted binding. Trusted for binding, inert as authority. */ + readonly invocation: AgentInvocation; +} + +/** Record that a provider reported. Fact-recording. */ +export interface InvocationReportedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.INVOCATION_REPORTED; + /** PR 006 output. Pre-normalized, re-validated, never re-normalized. */ + readonly report: InvocationReportResult; +} + +/** Record that a review exists at the bound commit. Fact-recording. */ +export interface ReviewAdmittedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.REVIEW_ADMITTED; + /** PR 005 output. Pre-normalized, re-validated, never re-normalized. */ + readonly review: ReviewResult; +} + +/** Record an independent observation at the bound commit. Fact-recording. */ +export interface EvidenceAdmittedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.EVIDENCE_ADMITTED; + /** PR 004 output. Pre-judged; freshness is never re-derived here. */ + readonly verdict: EvidenceFreshness; +} + +/** + * Rebind the workflow to a newly observed commit. + * + * `observedCommitSha` is **trusted** adapter input, exactly as PR 004's + * `EvidenceTarget.currentHeadSha` is. HEAD is supplied, never inferred, and + * never derived from agent-controlled data — there is no field on any payload + * through which an agent could try. + */ +export interface HeadObservedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.HEAD_OBSERVED; + readonly observedCommitSha: string; +} + +/** Record that a human decision has been requested at the bound commit. */ +export interface HumanGateOpenedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.HUMAN_GATE_OPENED; + readonly atCommitSha: string; +} + +/** End the workflow. Terminal; there is no reopen. */ +export interface CloseRequestedEvent { + readonly kind: typeof WORKFLOW_EVENT_KIND.CLOSE_REQUESTED; + /** Trusted, inert label. Grants nothing and is verified against nothing. */ + readonly closureReason: WorkflowClosure; +} + +/** Everything that can be recorded. */ +export type WorkflowEvent = + | InvocationRequestedEvent + | InvocationReportedEvent + | ReviewAdmittedEvent + | EvidenceAdmittedEvent + | HeadObservedEvent + | HumanGateOpenedEvent + | CloseRequestedEvent; + +/** + * The result of one `applyWorkflowEvent` call. + * + * On `REJECTED`, `state` is the **identical prior reference** — testable proof + * that no partial application occurred. + */ +export interface TransitionResult { + readonly outcome: TransitionOutcome; + readonly state: WorkflowState; + readonly rejection: TransitionRejection | null; + /** Offending field paths, in fixed declaration order. Empty on `APPLIED`. */ + readonly invalidFields: readonly string[]; +} + +/** + * The result of one `openWorkflow` call. + * + * Distinct from {@link TransitionResult} because there is no prior state to + * echo: `state` is `null` exactly when `outcome` is `REJECTED`. Keeping the two + * shapes apart is what lets `TransitionResult.state` stay non-nullable, which + * is what the identical-reference invariant needs. + */ +export interface WorkflowOpenResult { + readonly outcome: TransitionOutcome; + readonly state: WorkflowState | null; + readonly rejection: TransitionRejection | null; + readonly invalidFields: readonly string[]; +} + +/** + * Cut a string to `limit` characters using a captured `slice`. + * + * No identifier in this boundary is ever cut; this exists so the reader set + * matches the PR 005 and PR 006 copies exactly and can be pinned by the parity + * guard. + */ +export function clampText(value: string, limit: number): string { + if (value.length <= limit) { + return value; + } + const cut: unknown = reflectApply(stringSlice, value, [0, limit]); + return typeof cut === 'string' ? cut : ''; +} + +/** + * Bound an untrusted value, then narrow it to a non-blank string or `null`. + * + * The bound is applied before `trim`, so blankness checks never scan more than + * the field's advertised limit. The trimmed form is never returned — + * normalising a value before storing it would let `" abc"` and `"abc"` become + * the same string on a boundary where exactness matters. + */ +export function readText(value: unknown, limit: number): string | null { + if (typeof value !== 'string') { + return null; + } + const bounded = clampText(value, limit); + const trimmed: unknown = reflectApply(stringTrim, bounded, []); + return typeof trimmed === 'string' && trimmed.length > 0 ? bounded : null; +} + +/** + * Read an exact identifier, rejecting rather than aliasing an oversized value. + * + * **Identifiers reject; nothing here truncates.** A truncated identifier is + * worse than no identifier: git resolves commit prefixes, so a cut SHA can + * falsely match a real object, and a cut workflow or invocation id can collide + * with a different one. An oversized value becomes `null` and its prefix never + * reaches the output at all. + */ +export function readExactIdentifier(value: unknown): string | null { + return typeof value === 'string' && value.length <= WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH + ? readText(value, WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH) + : null; +} + +/** + * Read one **own** property of an untrusted object. + * + * Own-only on purpose: an inherited property — including one planted on + * `Object.prototype` via a `__proto__` payload — must never supply a value the + * caller did not actually send. Reads are guarded because an own getter or a + * Proxy trap may throw. + */ +export function readOwnProperty(target: object, key: string): unknown { + try { + if (!objectHasOwn(target, key)) { + return undefined; + } + return (target as Record)[key]; + } catch { + return undefined; + } +} + +/** + * Narrow an untrusted value to a non-negative integer within `limit`. + * + * `-0` is rejected explicitly: it compares equal to `0` but does not survive a + * JSON round trip as the same value, which would break the byte-identity + * guarantee this layer makes. + */ +export function readCount(value: unknown, limit: number): number | null { + if (typeof value !== 'number' || !numberIsInteger(value)) { + return null; + } + if (objectIs(value, -0)) { + return null; + } + return value >= 0 && value <= limit ? value : null; +} + +/** Type guard: is this untrusted value a supported workflow status? */ +export function isWorkflowStatus(value: unknown): value is WorkflowStatus { + return typeof value === 'string' && containsValue(WORKFLOW_STATUSES, value); +} + +/** Type guard: is this untrusted value a supported invocation state? */ +export function isInvocationState(value: unknown): value is InvocationState { + return typeof value === 'string' && containsValue(INVOCATION_STATES, value); +} + +/** Type guard: is this untrusted value a supported closure reason? */ +export function isWorkflowClosure(value: unknown): value is WorkflowClosure { + return typeof value === 'string' && containsValue(WORKFLOW_CLOSURES, value); +} + +/** Type guard: is this untrusted value a supported event kind? */ +export function isWorkflowEventKind(value: unknown): value is WorkflowEventKind { + return typeof value === 'string' && containsValue(WORKFLOW_EVENT_KINDS, value); +} + +/** + * Type guard for a value drawn from any frozen vocabulary list. + * + * Used for vocabularies this module imports rather than owns, so membership is + * always tested against the owning layer's frozen list and never against a + * redeclared copy that could drift. + */ +export function isVocabularyMember( + list: readonly T[], + value: unknown, +): value is T { + return typeof value === 'string' && containsValue(list, value); +} diff --git a/tests/domain/reader-parity.test.ts b/tests/domain/reader-parity.test.ts index 9aba69f..55db2b1 100644 --- a/tests/domain/reader-parity.test.ts +++ b/tests/domain/reader-parity.test.ts @@ -1,23 +1,26 @@ /** - * Differential parity between the PR 005 and PR 006 untrusted-input readers. + * Differential parity between the PR 005, PR 006, and PR 007 untrusted-input + * readers. * - * The two boundaries are deliberately independent: `agent-invocation.ts` - * imports nothing from `review.ts`, so a defect or refactor on one side cannot - * change the other's validation. That independence is worth more than - * deduplicating six small readers — but it does create a drift risk, because - * the shared readers are byte-equivalent today by convention rather than by - * construction. + * The three boundaries are deliberately independent: `agent-invocation.ts` + * imports nothing from `review.ts`, and `workflow.ts` imports no reader from + * either, so a defect or refactor on one side cannot change another's + * validation and each captures its own intrinsics at its own load time. That + * independence is worth more than deduplicating a handful of small readers — + * but it does create a drift risk, because the shared readers are + * byte-equivalent today by convention rather than by construction. * * This suite converts that convention into a build failure. It runs one shared - * hostile-input corpus through both copies of every reader whose contract + * hostile-input corpus through every copy of every reader whose contract * overlaps and asserts identical results, so a change to one side that is not - * mirrored on the other cannot merge silently. + * mirrored on the others cannot merge silently. * - * **This file is the only place the two modules meet.** It is a test, so it - * creates no production dependency; neither `src/domain/review*.ts` nor - * `src/domain/agent-invocation*.ts` references the other at runtime. + * **This file is the only place the three modules meet.** It is a test, so it + * creates no production dependency; none of `src/domain/review*.ts`, + * `src/domain/agent-invocation*.ts`, or `src/domain/workflow*.ts` references + * another's readers at runtime. * - * Where the two boundaries intentionally differ, the divergence is pinned + * Where the boundaries intentionally differ, the divergence is pinned * explicitly at the bottom of this file rather than omitted. */ @@ -36,6 +39,13 @@ import { readText as reviewReadText, } from '../../src/domain/review.js'; import { ingestReview } from '../../src/domain/review-ingestion.js'; +import { + clampText as workflowClampText, + readExactIdentifier as workflowReadExactIdentifier, + readOwnProperty as workflowReadOwnProperty, + readText as workflowReadText, + WORKFLOW_BOUNDS, +} from '../../src/domain/workflow.js'; import { buildInvocation, buildReport, label, oversized, PR_A, REPO_A, SHA_A } from './invocation-fixtures.js'; /** Limits that straddle both boundaries' bounds and their edges. */ @@ -170,8 +180,18 @@ function buildTargets(): readonly (readonly [string, object])[] { describe('the reader copies are genuinely independent', () => { it('are distinct function objects, so parity is a real assertion', () => { expect(invocationReadText).not.toBe(reviewReadText); + expect(workflowReadText).not.toBe(reviewReadText); + expect(workflowReadText).not.toBe(invocationReadText); + expect(invocationClampText).not.toBe(reviewClampText); + expect(workflowClampText).not.toBe(reviewClampText); + expect(workflowClampText).not.toBe(invocationClampText); + expect(invocationReadOwnProperty).not.toBe(reviewReadOwnProperty); + expect(workflowReadOwnProperty).not.toBe(reviewReadOwnProperty); + expect(workflowReadOwnProperty).not.toBe(invocationReadOwnProperty); + + expect(workflowReadExactIdentifier).not.toBe(readExactIdentifier); }); }); @@ -181,25 +201,29 @@ describe('readText parity', () => { for (const limit of LIMITS) { const review = reviewReadText(value, limit); const invocation = invocationReadText(value, limit); + const workflow = workflowReadText(value, limit); expect(invocation, `${label(value)} @ ${String(limit)}`).toBe(review); + expect(workflow, `${label(value)} @ ${String(limit)}`).toBe(review); } } }); it('agrees that a bounded-then-blank value is null', () => { - // The bound is applied before the blankness check on both sides, so a + // The bound is applied before the blankness check on every side, so a // string whose first `limit` characters are whitespace reads as blank. const value = `${' '.repeat(10)}text`; expect(invocationReadText(value, 5)).toBeNull(); expect(reviewReadText(value, 5)).toBeNull(); + expect(workflowReadText(value, 5)).toBeNull(); }); it('agrees that the returned value is never the trimmed form', () => { for (const value of [' abc', 'abc ', ' abc ']) { expect(invocationReadText(value, 256)).toBe(value); expect(reviewReadText(value, 256)).toBe(value); + expect(workflowReadText(value, 256)).toBe(value); } }); }); @@ -210,8 +234,10 @@ describe('clampText parity', () => { for (const limit of LIMITS) { const review = reviewClampText(value, limit); const invocation = invocationClampText(value, limit); + const workflow = workflowClampText(value, limit); expect(invocation, `${label(value)} @ ${String(limit)}`).toBe(review); + expect(workflow, `${label(value)} @ ${String(limit)}`).toBe(review); expect(invocation.length).toBeLessThanOrEqual(Math.min(value.length, limit)); } } @@ -219,7 +245,9 @@ describe('clampText parity', () => { it('agrees on splitting a surrogate pair at an odd boundary', () => { expect(invocationClampText('👍👍', 1)).toBe(reviewClampText('👍👍', 1)); + expect(workflowClampText('👍👍', 1)).toBe(reviewClampText('👍👍', 1)); expect(invocationClampText('👍👍', 3)).toBe(reviewClampText('👍👍', 3)); + expect(workflowClampText('👍👍', 3)).toBe(reviewClampText('👍👍', 3)); }); }); @@ -229,29 +257,47 @@ describe('readOwnProperty parity', () => { for (const key of KEY_CORPUS) { const review: unknown = reviewReadOwnProperty(target, key); const invocation: unknown = invocationReadOwnProperty(target, key); + const workflow: unknown = workflowReadOwnProperty(target, key); expect(invocation, `${targetLabel}.${key}`).toBe(review); + expect(workflow, `${targetLabel}.${key}`).toBe(review); } } }); - it('agrees that an inherited value is invisible to both', () => { + it('agrees that an inherited value is invisible to all three', () => { const inherited: object = Object.create({ reference: 'inherited' }) as object; expect(invocationReadOwnProperty(inherited, 'reference')).toBeUndefined(); expect(reviewReadOwnProperty(inherited, 'reference')).toBeUndefined(); + expect(workflowReadOwnProperty(inherited, 'reference')).toBeUndefined(); }); - it('agrees that neither throws for any target and key', () => { + it('agrees that none throws for any target and key', () => { for (const [targetLabel, target] of buildTargets()) { for (const key of KEY_CORPUS) { expect(() => reviewReadOwnProperty(target, key), `${targetLabel}.${key}`).not.toThrow(); expect(() => invocationReadOwnProperty(target, key), `${targetLabel}.${key}`).not.toThrow(); + expect(() => workflowReadOwnProperty(target, key), `${targetLabel}.${key}`).not.toThrow(); } } }); }); +describe('readExactIdentifier parity between PR 006 and PR 007', () => { + it('agrees on every corpus value', () => { + for (const value of VALUE_CORPUS) { + expect(workflowReadExactIdentifier(value), label(value)).toBe(readExactIdentifier(value)); + } + }); + + it('agrees that oversize rejects rather than shortens on both sides', () => { + expect(readExactIdentifier(oversized(257))).toBeNull(); + expect(workflowReadExactIdentifier(oversized(257))).toBeNull(); + expect(workflowReadExactIdentifier(oversized(256))).toBe(oversized(256)); + }); +}); + describe('exact-identifier parity through the public boundaries', () => { const identifierCases: readonly (readonly [string, string])[] = Object.freeze([ ['at the limit', oversized(256)], @@ -345,11 +391,26 @@ describe('intentional divergence is pinned, not hidden', () => { expect(invocation.providerId).toBeNull(); }); - it('pins the identifier bound as equal on both sides', () => { - // Shared by convention, not by import. If either side changes its bound, - // the id that one boundary accepts is no longer the id the other stores. + it('pins the identifier bound as equal on all three sides', () => { + // Shared by convention, not by import. If any side changes its bound, the + // id that one boundary accepts is no longer the id another stores. expect(readExactIdentifier(oversized(256))).not.toBeNull(); expect(readExactIdentifier(oversized(257))).toBeNull(); + expect(workflowReadExactIdentifier(oversized(256))).not.toBeNull(); + expect(workflowReadExactIdentifier(oversized(257))).toBeNull(); expect(reviewReadText(oversized(256), 256)).toBe(oversized(256)); + expect(WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH).toBe(256); + }); + + /** + * PR 007 has no truncating path at all: it stores no prose, so `clampText` + * and `readText` exist there only to keep the reader set byte-equivalent and + * pinnable. Every field it stores goes through `readExactIdentifier`. + */ + it('pins PR 007 as having no field that truncates', () => { + const workflow = ingestInvocationReport(buildInvocation(), buildReport([])); + + expect(workflow.outcome).toBe('INGESTED'); + expect(workflowReadExactIdentifier(oversized(257))).toBeNull(); }); }); diff --git a/tests/domain/workflow-fixtures.ts b/tests/domain/workflow-fixtures.ts new file mode 100644 index 0000000..e14238f --- /dev/null +++ b/tests/domain/workflow-fixtures.ts @@ -0,0 +1,464 @@ +/** + * Shared test inputs for the Autoflow state machine. + * + * Expected vocabulary values are written as bare string literals throughout the + * suite, **not** as `WORKFLOW_STATUS.*` and friends, so the tests cannot ratify + * a production mapping that has been changed incorrectly. Only types and the + * two entry points are imported from `src/`. + * + * The PR 004, PR 005, and PR 006 results here are built as literals rather than + * produced by their owning layers, so a hostile variant can differ in exactly + * one field. `tests/domain/workflow-transitions.test.ts` additionally drives the + * real `evaluateEvidenceFreshness`, `ingestReview`, and `ingestInvocationReport` + * through the state machine, so the literals are pinned against the genuine + * shapes rather than trusted on their own. + */ + +import { + applyWorkflowEvent, + openWorkflow, + type AgentInvocation, + type EvidenceFreshness, + type InvocationReportResult, + type ReviewResult, + type WorkflowBinding, + type WorkflowEvent, + type WorkflowState, +} from '../../src/domain/index.js'; + +export const WORKFLOW_A = 'wf-0001'; +export const REPO_A = 'repo-agentbridge'; +export const REPO_B = 'repo-other'; +export const PR_A = '42'; +export const PR_B = '99'; +export const SHA_A = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678'; +export const SHA_B = 'ffeeddccbbaa99887766554433221100aabbccdd'; +export const SHA_C = '0123456789abcdef0123456789abcdef01234567'; +export const INVOCATION_A = 'inv-0001'; +export const INVOCATION_B = 'inv-0002'; +export const REVIEW_A = 'rev-0001'; +export const REVIEW_B = 'rev-0002'; +export const EVIDENCE_A = 'ev-0001'; +export const EVIDENCE_B = 'ev-0002'; +export const REQUESTED_AT = '2026-01-01T00:00:00.000Z'; + +/** The bound every identifier in this boundary is measured against. */ +export const IDENTIFIER_LIMIT = 256; + +/** A string of exactly `length` `x` characters. */ +export function oversized(length: number): string { + return 'x'.repeat(length); +} + +/** + * A safe, stable label for an arbitrary runtime value. + * + * Test names must never stringify an unknown value directly: a symbol throws, + * and an object degrades to `[object Object]`. + */ +export function label(value: unknown): string { + if (typeof value === 'string') { + return value === '' ? "''" : `'${value}'`; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'bigint') { + return `${String(value)}n`; + } + if (typeof value === 'symbol') { + return `symbol(${value.description ?? ''})`; + } + if (value === null) { + return 'null'; + } + if (value === undefined) { + return 'undefined'; + } + if (Array.isArray(value)) { + return 'array'; + } + if (typeof value === 'function') { + return 'function'; + } + return 'object'; +} + +const BASE_BINDING: WorkflowBinding = { + workflowId: WORKFLOW_A, + repositoryId: REPO_A, + pullRequestId: PR_A, + boundCommitSha: SHA_A, +}; + +/** A well-formed trusted binding. */ +export function buildBinding(overrides: Partial = {}): WorkflowBinding { + return { ...BASE_BINDING, ...overrides }; +} + +/** A binding with no pull request. */ +export function buildBindingWithoutPullRequest(): WorkflowBinding { + const { pullRequestId: _omitted, ...rest } = BASE_BINDING; + void _omitted; + return rest; +} + +/** Replace one binding field with an arbitrary runtime value. */ +export function withRawBindingField(field: string, value: unknown): WorkflowBinding { + return { ...BASE_BINDING, [field]: value } as unknown as WorkflowBinding; +} + +/** Open a workflow, failing the fixture loudly if the binding is unusable. */ +export function openedWorkflow(binding: WorkflowBinding = buildBinding()): WorkflowState { + const result = openWorkflow(binding); + if (result.state === null) { + throw new Error('fixture binding must open'); + } + return result.state; +} + +/** Apply an event, failing the fixture loudly if it was refused. */ +export function applyOrThrow(state: WorkflowState, event: WorkflowEvent): WorkflowState { + const result = applyWorkflowEvent(state, event); + if (result.outcome !== 'APPLIED') { + throw new Error(`fixture event must apply, got ${String(result.rejection)}`); + } + return result.state; +} + +const BASE_INVOCATION: AgentInvocation = { + invocationId: INVOCATION_A, + repositoryId: REPO_A, + pullRequestId: PR_A, + targetCommitSha: SHA_A, + providerId: 'codex', + agentId: 'agent-1', + purpose: 'review', + requestedAt: REQUESTED_AT, +}; + +/** A well-formed PR 006 trusted invocation. */ +export function buildInvocation(overrides: Partial = {}): AgentInvocation { + return { ...BASE_INVOCATION, ...overrides }; +} + +/** An invocation with no pull request bound to it. */ +export function buildInvocationWithoutPullRequest(): AgentInvocation { + const { pullRequestId: _omitted, ...rest } = BASE_INVOCATION; + void _omitted; + return rest; +} + +/** Replace one invocation field with an arbitrary runtime value. */ +export function withRawInvocationField(field: string, value: unknown): AgentInvocation { + return { ...BASE_INVOCATION, [field]: value } as unknown as AgentInvocation; +} + +const BASE_REPORT: InvocationReportResult = { + outcome: 'INGESTED', + invocationId: INVOCATION_A, + repositoryId: REPO_A, + pullRequestId: PR_A, + targetCommitSha: SHA_A, + providerId: 'codex', + agentId: 'agent-1', + purpose: 'review', + reportedStatus: 'reported-complete', + reportedDetail: 'done', + claims: [], + rejectedClaims: [], + invalidInvocationFields: [], + truncated: false, +}; + +/** A well-formed PR 006 ingestion result. */ +export function buildReport( + overrides: Partial = {}, +): InvocationReportResult { + return { ...BASE_REPORT, ...overrides }; +} + +/** Replace one report field with an arbitrary runtime value. */ +export function withRawReportField(field: string, value: unknown): InvocationReportResult { + return { ...BASE_REPORT, [field]: value } as unknown as InvocationReportResult; +} + +const BASE_REVIEW: ReviewResult = { + outcome: 'INGESTED', + repositoryId: REPO_A, + pullRequestId: PR_A, + reviewedCommitSha: SHA_A, + reviewId: REVIEW_A, + provider: 'coderabbit', + reviewerId: 'reviewer-1', + findings: [], + rejected: [], + invalidContextFields: [], + truncated: false, +}; + +/** A well-formed PR 005 ingestion result. */ +export function buildReview(overrides: Partial = {}): ReviewResult { + return { ...BASE_REVIEW, ...overrides }; +} + +/** Replace one review field with an arbitrary runtime value. */ +export function withRawReviewField(field: string, value: unknown): ReviewResult { + return { ...BASE_REVIEW, [field]: value } as unknown as ReviewResult; +} + +const BASE_VERDICT: EvidenceFreshness = { + evidenceId: EVIDENCE_A, + repositoryId: REPO_A, + commitSha: SHA_A, + kind: 'ci-result', + source: 'github', + targetRepositoryId: REPO_A, + targetHeadSha: SHA_A, + state: 'CURRENT', + reason: 'BOUND_TO_CURRENT_HEAD', + invalidFields: [], +}; + +/** A well-formed PR 004 freshness verdict, CURRENT at the bound commit. */ +export function buildVerdict(overrides: Partial = {}): EvidenceFreshness { + return { ...BASE_VERDICT, ...overrides }; +} + +/** Replace one verdict field with an arbitrary runtime value. */ +export function withRawVerdictField(field: string, value: unknown): EvidenceFreshness { + return { ...BASE_VERDICT, [field]: value } as unknown as EvidenceFreshness; +} + +/** A human-decision verdict, which is what clears an open human gate. */ +export function buildHumanDecisionVerdict( + overrides: Partial = {}, +): EvidenceFreshness { + return buildVerdict({ kind: 'human-decision', source: 'human', ...overrides }); +} + +/* ------------------------------------------------------------------ events */ + +export function requestInvocation(invocation: AgentInvocation = buildInvocation()): WorkflowEvent { + return { kind: 'INVOCATION_REQUESTED', invocation }; +} + +export function reportInvocation( + report: InvocationReportResult = buildReport(), +): WorkflowEvent { + return { kind: 'INVOCATION_REPORTED', report }; +} + +export function admitReview(review: ReviewResult = buildReview()): WorkflowEvent { + return { kind: 'REVIEW_ADMITTED', review }; +} + +export function admitEvidence(verdict: EvidenceFreshness = buildVerdict()): WorkflowEvent { + return { kind: 'EVIDENCE_ADMITTED', verdict }; +} + +export function observeHead(observedCommitSha: string = SHA_B): WorkflowEvent { + return { kind: 'HEAD_OBSERVED', observedCommitSha }; +} + +export function openHumanGate(atCommitSha: string = SHA_A): WorkflowEvent { + return { kind: 'HUMAN_GATE_OPENED', atCommitSha }; +} + +export function closeWorkflow(closureReason = 'CALLER_CLOSED'): WorkflowEvent { + return { kind: 'CLOSE_REQUESTED', closureReason } as WorkflowEvent; +} + +/** Every event kind, as a valid instance bound to the default fixture state. */ +export function everyValidEvent(): readonly (readonly [string, WorkflowEvent])[] { + return Object.freeze([ + ['INVOCATION_REQUESTED', requestInvocation()], + ['INVOCATION_REPORTED', reportInvocation()], + ['REVIEW_ADMITTED', admitReview()], + ['EVIDENCE_ADMITTED', admitEvidence()], + ['HEAD_OBSERVED', observeHead()], + ['HUMAN_GATE_OPENED', openHumanGate()], + ['CLOSE_REQUESTED', closeWorkflow()], + ] as const); +} + +/* ----------------------------------------------------------------- hostile */ + +/** Values that are not usable identifiers. */ +export const MALFORMED_VALUES: readonly (readonly [string, unknown])[] = Object.freeze([ + ['undefined', undefined], + ['null', null], + ['a number', 42], + ['a zero', 0], + ['a boolean', true], + ['an object', {}], + ['an array', []], + ['a function', (): string => 'x'], + ['a symbol', Symbol('s')], + ['a bigint', 7n], + ['whitespace only', ' \t\n '], + ['an empty string', ''], +]); + +/** Top-level values that are not objects at all. */ +export const NON_OBJECTS: readonly (readonly [string, unknown])[] = Object.freeze([ + ['null', null], + ['undefined', undefined], + ['a string', 'workflow'], + ['a number', 42], + ['a boolean', true], + ['a function', (): string => 'x'], + ['a symbol', Symbol('s')], + ['a bigint', 3n], +]); + +/** Event kinds outside the vocabulary. None may be accepted or degrade. */ +export const UNSUPPORTED_EVENT_KINDS: readonly unknown[] = Object.freeze([ + 'invocation_requested', + 'INVOCATION_REQUESTED ', + ' INVOCATION_REQUESTED', + 'Invocation_Requested', + 'HUMAN_DECISION_RECORDED', + 'MERGE', + 'unknown', + '', + '__proto__', + 'constructor', + 'toString', + 42, + true, + null, + undefined, + {}, + [], + Symbol('INVOCATION_REQUESTED'), +]); + +/** Provider labels, including privileged-sounding ones. All must be inert. */ +export const PROVIDER_LABELS: readonly string[] = Object.freeze([ + 'claude', + 'openai', + 'gemini', + 'codex', + 'system', + 'root', + 'admin', + 'agentbridge-internal', +]); + +/** Every PR 006 purpose. All must be inert. */ +export const PURPOSES: readonly string[] = Object.freeze([ + 'review', + 'implement', + 'repair', + 'audit', +]); + +/** Every PR 006 reported status. All must be inert. */ +export const REPORT_STATUSES: readonly string[] = Object.freeze([ + 'reported-complete', + 'reported-failed', + 'reported-cancelled', + 'unknown', +]); + +/** Field names no serialized workflow state may ever contain as a key. */ +export const FORBIDDEN_STATE_KEYS: readonly string[] = Object.freeze([ + 'exists', + 'verified', + 'observed', + 'integrated', + 'merged', + 'applied', + 'validated', + 'authorized', + 'mergeable', + 'approved', + 'mayMerge', + 'mayExecute', + 'ready', + 'readyForMerge', + 'blocking', + 'findingCount', + 'freshness', + 'current', + 'stale', + 'nextAction', + 'nextInvocation', + 'attempt', + 'attempts', + 'retries', + 'maxAttempts', + 'budget', + 'deadline', + 'timeout', + 'expiresAt', + 'backoff', + 'cost', + 'tokens', + 'converged', + 'requested', + 'unsolicited', +]); + +/** Values no serialized workflow state may ever contain. */ +export const FORBIDDEN_STATE_VALUES: readonly string[] = Object.freeze([ + 'ALLOW', + 'DENY', + 'ESCALATE', + 'AUTONOMOUS', + 'CURRENT', + 'STALE', +]); + +/** An object whose named property throws when read. */ +export function withThrowingGetter(base: T, field: string): T { + const target = { ...base } as Record; + Object.defineProperty(target, field, { + get() { + throw new Error('hostile getter'); + }, + enumerable: true, + configurable: true, + }); + return target as T; +} + +/** An object whose named property returns a different value on each read. */ +export function withUnstableGetter( + base: T, + field: string, + values: readonly unknown[], +): T { + const target = { ...base } as Record; + let reads = 0; + Object.defineProperty(target, field, { + get() { + const value = values[reads] ?? values[values.length - 1]; + reads += 1; + return value; + }, + enumerable: true, + configurable: true, + }); + return target as T; +} + +/** A revoked Proxy: every trap, and `Array.isArray`, throws on it. */ +export function revokedProxy(): object { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; +} + +/** An array-like Proxy reporting an absurd length. */ +export function absurdLengthArray(): unknown { + return new Proxy([], { + get(target, key): unknown { + if (key === 'length') { + return Number.MAX_SAFE_INTEGER; + } + return Reflect.get(target, key); + }, + }); +} diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts new file mode 100644 index 0000000..ef89d88 --- /dev/null +++ b/tests/domain/workflow-invariants.test.ts @@ -0,0 +1,903 @@ +/** + * Security and structural invariants for the Autoflow state machine. + * + * These are the properties that must survive every future change: provider, + * purpose, and reported status never influence legality; a claim never becomes + * an observation; old-revision facts never advance the current revision; no + * state implies merge, deploy, or write authority; hostile input fails closed; + * and every transition is pure, deterministic, and immutable. + */ + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { + applyWorkflowEvent, + INVOCATION_BOUNDS, + openWorkflow, + REVIEW_BOUNDS, + TRANSITION_REJECTIONS, + WORKFLOW_BOUNDS, + WORKFLOW_EVENT_KINDS, + WORKFLOW_STATUSES, + type AdmittedEvidence, + type TrackedInvocation, + type WorkflowEvent, + type WorkflowState, +} from '../../src/domain/index.js'; +import { + admitEvidence, + admitReview, + applyOrThrow, + buildBinding, + buildHumanDecisionVerdict, + buildInvocation, + buildReport, + buildReview, + buildVerdict, + closeWorkflow, + EVIDENCE_A, + FORBIDDEN_STATE_KEYS, + FORBIDDEN_STATE_VALUES, + INVOCATION_A, + INVOCATION_B, + observeHead, + openedWorkflow, + openHumanGate, + oversized, + PROVIDER_LABELS, + PURPOSES, + REPO_A, + REPORT_STATUSES, + reportInvocation, + requestInvocation, + REVIEW_A, + revokedProxy, + SHA_A, + SHA_B, + withRawInvocationField, + withRawReportField, + withThrowingGetter, + withUnstableGetter, +} from './workflow-fixtures.js'; + +/** A workflow with one invocation already requested at the bound commit. */ +function requested(): WorkflowState { + return applyOrThrow(openedWorkflow(), requestInvocation()); +} + +/** Every event kind as a valid instance, for sweeps that must cover all seven. */ +function everyEvent(): readonly (readonly [string, WorkflowEvent])[] { + return [ + [ + 'INVOCATION_REQUESTED', + requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + ], + ['INVOCATION_REPORTED', reportInvocation()], + ['REVIEW_ADMITTED', admitReview()], + ['EVIDENCE_ADMITTED', admitEvidence()], + ['HEAD_OBSERVED', observeHead(SHA_B)], + ['HUMAN_GATE_OPENED', openHumanGate()], + ['CLOSE_REQUESTED', closeWorkflow()], + ]; +} + +/** + * Remove comments so a static scan inspects code rather than prose. + * + * The doc comments legitimately discuss clocks, processes, and Promises while + * explaining why none of them appear in the code. + */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +} + +/** Run `body` with one property replaced, restoring it whatever happens. */ +function withPoisoned(target: object, key: PropertyKey, value: unknown, body: () => void): void { + const original = Object.getOwnPropertyDescriptor(target, key); + try { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: false, + configurable: true, + }); + body(); + } finally { + if (original === undefined) { + Reflect.deleteProperty(target, key); + } else { + Object.defineProperty(target, key, original); + } + } +} + +describe('group H — provider, purpose, and reported status are inert', () => { + /** Replace the three recorded label fields, so only they may differ. */ + function withoutLabels(state: WorkflowState): unknown { + return { + ...state, + invocations: state.invocations.map((tracked) => ({ + ...tracked, + purpose: 'X', + providerId: 'X', + agentId: 'X', + reportedStatus: tracked.reportedStatus === null ? null : 'X', + })), + }; + } + + function run(providerId: string, purpose: string, status: string): WorkflowState { + const invocation = buildInvocation({ + providerId, + agentId: `${providerId}-agent`, + purpose: purpose as never, + }); + return applyOrThrow( + applyOrThrow(openedWorkflow(), requestInvocation(invocation)), + reportInvocation(buildReport({ providerId, purpose: purpose as never, reportedStatus: status as never })), + ); + } + + const canonical = withoutLabels(run('codex', 'review', 'reported-complete')); + + const combinations: readonly (readonly [string, string, string])[] = PROVIDER_LABELS.flatMap( + (providerId) => + PURPOSES.flatMap((purpose) => + REPORT_STATUSES.map((status) => [providerId, purpose, status] as const), + ), + ); + + it('covers every provider, purpose, and status combination', () => { + expect(combinations).toHaveLength(8 * 4 * 4); + }); + + it.each(combinations)( + 'provider %s, purpose %s, status %s produces an identical state', + (providerId, purpose, status) => { + expect(withoutLabels(run(providerId, purpose, status))).toEqual(canonical); + }, + ); + + it('records the labels verbatim without ever reading them', () => { + const state = run('agentbridge-internal', 'repair', 'reported-failed'); + + expect(state.invocations[0]?.providerId).toBe('agentbridge-internal'); + expect(state.invocations[0]?.purpose).toBe('repair'); + expect(state.invocations[0]?.reportedStatus).toBe('reported-failed'); + expect(state.status).toBe('OPEN'); + }); + + it('grants a repair invocation nothing a review invocation lacks', () => { + const repair = run('claude', 'repair', 'reported-complete'); + const review = run('claude', 'review', 'reported-complete'); + + expect(Object.keys(repair)).toEqual(Object.keys(review)); + expect(withoutLabels(repair)).toEqual(withoutLabels(review)); + }); + + it('does not let a privileged-sounding provider open or clear a gate', () => { + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + const result = applyWorkflowEvent( + gated, + requestInvocation(buildInvocation({ providerId: 'root', agentId: 'admin' })), + ); + + expect(result.rejection).toBe('WORKFLOW_AWAITING_HUMAN'); + }); +}); + +describe('group I — a claim never becomes an observation', () => { + it('records no evidence for a complete report full of claims', () => { + const claims = Array.from({ length: 64 }, (_value, index) => ({ + claimId: `c${String(index)}`, + ordinal: index, + invocationId: INVOCATION_A, + repositoryId: REPO_A, + artifactType: 'commit', + reference: `ref-${String(index)}`, + claimedCommitSha: SHA_A, + truncated: false, + })); + const state = applyOrThrow( + requested(), + reportInvocation(withRawReportField('claims', claims)), + ); + const serialized = JSON.stringify(state); + + expect(state.evidence).toEqual([]); + expect(state.reviews).toEqual([]); + expect(serialized).not.toContain('ref-0'); + expect(serialized).not.toContain('artifactType'); + expect(serialized).not.toContain('claimedCommitSha'); + }); + + it('has no code path from a report into an admission list', () => { + const source = readFileSync( + new URL('../../src/domain/workflow-transitions.ts', import.meta.url), + 'utf8', + ); + const reportHandler = source.slice( + source.indexOf('function applyInvocationReported'), + source.indexOf('function applyReviewAdmitted'), + ); + + expect(reportHandler.length).toBeGreaterThan(0); + expect(reportHandler).not.toContain('snapshot.evidence'); + expect(reportHandler).not.toContain('snapshot.reviews'); + expect(reportHandler).not.toContain('AdmittedEvidence'); + expect(reportHandler).not.toContain('AdmittedReview'); + }); + + it('refuses a claim supplied where any payload belongs', () => { + const claim = { + claimId: 'c0', + ordinal: 0, + invocationId: INVOCATION_A, + repositoryId: REPO_A, + artifactType: 'commit', + reference: 'abc', + claimedCommitSha: SHA_A, + truncated: false, + }; + const payloads: readonly WorkflowEvent[] = [ + { kind: 'INVOCATION_REQUESTED', invocation: claim } as never, + { kind: 'INVOCATION_REPORTED', report: claim } as never, + { kind: 'REVIEW_ADMITTED', review: claim } as never, + { kind: 'EVIDENCE_ADMITTED', verdict: claim } as never, + ]; + + for (const event of payloads) { + const result = applyWorkflowEvent(requested(), event); + expect(result.outcome).toBe('REJECTED'); + } + }); +}); + +describe('group J — hostile input fails closed', () => { + it.each(['workflowId', 'repositoryId', 'boundCommitSha', 'revision', 'sequence', 'status'])( + 'refuses a state whose %s getter throws', + (field) => { + const state = withThrowingGetter(openedWorkflow(), field); + const result = applyWorkflowEvent(state, admitEvidence()); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(state); + }, + ); + + it.each(['invocations', 'evidence', 'reviews'])( + 'refuses a state whose %s list getter throws', + (field) => { + const result = applyWorkflowEvent(withThrowingGetter(openedWorkflow(), field), admitEvidence()); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + }, + ); + + it('refuses a state whose list element read throws', () => { + const base = requested(); + const hostile: unknown[] = []; + Object.defineProperty(hostile, 0, { + get() { + throw new Error('hostile element'); + }, + enumerable: true, + configurable: true, + }); + const state = { ...base, invocations: hostile } as unknown as WorkflowState; + + expect(applyWorkflowEvent(state, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('refuses a revoked Proxy as state and as event', () => { + const proxy = revokedProxy() as WorkflowState; + + expect(applyWorkflowEvent(proxy, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + expect( + applyWorkflowEvent(openedWorkflow(), revokedProxy() as WorkflowEvent).rejection, + ).toBe('EVENT_UNREADABLE'); + }); + + it('refuses an event whose kind getter throws', () => { + const event = withThrowingGetter(admitEvidence(), 'kind'); + + expect(applyWorkflowEvent(openedWorkflow(), event).rejection).toBe('EVENT_KIND_UNKNOWN'); + }); + + it.each(['invocation', 'report', 'review', 'verdict'])( + 'refuses an event whose %s payload getter throws', + (slot) => { + const kinds: Record = { + invocation: 'INVOCATION_REQUESTED', + report: 'INVOCATION_REPORTED', + review: 'REVIEW_ADMITTED', + verdict: 'EVIDENCE_ADMITTED', + }; + const event = withThrowingGetter( + { kind: kinds[slot], [slot]: {} } as unknown as WorkflowEvent, + slot, + ); + + expect(applyWorkflowEvent(requested(), event).rejection).toBe('EVENT_PAYLOAD_INVALID'); + }, + ); + + it('cannot validate one value and store another through an unstable getter', () => { + const invocation = withUnstableGetter(buildInvocation(), 'targetCommitSha', [SHA_A, SHA_B, SHA_B]); + const result = applyWorkflowEvent(openedWorkflow(), requestInvocation(invocation)); + + if (result.outcome === 'APPLIED') { + expect(result.state.invocations[0]?.targetCommitSha).toBe(SHA_A); + } else { + expect(result.rejection).toBe('BINDING_MISMATCH'); + } + }); + + it('never lets an unstable evidence verdict be admitted under a different id', () => { + const verdict = withUnstableGetter(buildVerdict(), 'evidenceId', [EVIDENCE_A, 'ev-forged']); + const result = applyWorkflowEvent(openedWorkflow(), admitEvidence(verdict)); + + if (result.outcome === 'APPLIED') { + expect(result.state.evidence[0]?.evidenceId).toBe(EVIDENCE_A); + } else { + expect(result.outcome).toBe('REJECTED'); + } + }); + + it.each(['outcome', 'state', 'status', 'revision', 'sequence', 'kind', 'boundCommitSha'])( + 'ignores %s planted on Object.prototype', + (key) => { + const expected = applyWorkflowEvent(openedWorkflow(), admitReview(buildReview({ reviewId: REVIEW_A }))); + let observed: unknown; + + withPoisoned(Object.prototype, key, 'INGESTED', () => { + observed = applyWorkflowEvent( + openedWorkflow(), + admitReview(buildReview({ reviewId: REVIEW_A })), + ); + }); + + expect(observed).toEqual(expected); + }, + ); + + it('ignores a payload that inherits its outcome through __proto__', () => { + const review = JSON.parse( + `{"__proto__":{"outcome":"INGESTED"},"reviewId":"${REVIEW_A}","repositoryId":"${REPO_A}","reviewedCommitSha":"${SHA_A}"}`, + ) as never; + const result = applyWorkflowEvent(openedWorkflow(), admitReview(review)); + + expect(result.rejection).toBe('INPUT_NOT_INGESTED'); + }); + + it.each([ + ['Array.prototype.push', Array.prototype, 'push'], + ['Array.prototype.includes', Array.prototype, 'includes'], + ['Array.prototype.map', Array.prototype, 'map'], + ['Array.prototype.filter', Array.prototype, 'filter'], + ['String.prototype.trim', String.prototype, 'trim'], + ['String.prototype.slice', String.prototype, 'slice'], + ])('survives a poisoned %s', (_name, target, key) => { + const expected = applyWorkflowEvent(requested(), reportInvocation()); + let observed: unknown; + + withPoisoned( + target, + key, + () => { + throw new Error('poisoned'); + }, + () => { + observed = applyWorkflowEvent(requested(), reportInvocation()); + }, + ); + + expect(observed).toEqual(expected); + }); + + it('survives an includes that claims everything is a member', () => { + let observed: unknown; + + withPoisoned(Array.prototype, 'includes', () => true, () => { + observed = applyWorkflowEvent(openedWorkflow(), { kind: 'MERGE' } as never); + }); + + expect((observed as { rejection: string }).rejection).toBe('EVENT_KIND_UNKNOWN'); + }); + + it('survives a poisoned Set.prototype.has', () => { + const expected = applyWorkflowEvent(openedWorkflow(), admitEvidence()); + let observed: unknown; + + withPoisoned(Set.prototype, 'has', () => true, () => { + observed = applyWorkflowEvent(openedWorkflow(), admitEvidence()); + }); + + expect(observed).toEqual(expected); + }); + + it('still freezes results when Object.freeze is replaced after module load', () => { + let observed: WorkflowState | undefined; + + withPoisoned(Object, 'freeze', (value: unknown) => value, () => { + observed = applyWorkflowEvent(openedWorkflow(), admitEvidence()).state; + }); + + expect(observed).toBeDefined(); + expect(Object.isFrozen(observed)).toBe(true); + expect(Object.isFrozen(observed?.evidence)).toBe(true); + }); + + it('still reads own properties when Object.hasOwn is replaced after module load', () => { + const expected = applyWorkflowEvent(openedWorkflow(), admitEvidence()); + let observed: unknown; + + withPoisoned(Object, 'hasOwn', () => false, () => { + observed = applyWorkflowEvent(openedWorkflow(), admitEvidence()); + }); + + expect(observed).toEqual(expected); + }); + + it('bypasses an inherited numeric index setter', () => { + const expected = applyWorkflowEvent(openedWorkflow(), requestInvocation()); + let observed: unknown; + + const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, 0); + try { + Object.defineProperty(Array.prototype, 0, { + set() { + throw new Error('inherited index setter'); + }, + get() { + return undefined; + }, + configurable: true, + }); + observed = applyWorkflowEvent(openedWorkflow(), requestInvocation()); + } finally { + if (descriptor === undefined) { + Reflect.deleteProperty(Array.prototype, 0); + } else { + Object.defineProperty(Array.prototype, 0, descriptor); + } + } + + expect(observed).toEqual(expected); + }); + + it('refuses a self-inconsistent state rather than trusting it', () => { + const base = openedWorkflow(); + const inconsistent: readonly WorkflowState[] = [ + { ...base, closureReason: 'CALLER_CLOSED' } as WorkflowState, + { ...base, status: 'CLOSED', closureReason: null } as unknown as WorkflowState, + { ...base, humanGateOpenedAtRevision: 0 } as WorkflowState, + { ...base, status: 'AWAITING_HUMAN_DECISION' } as unknown as WorkflowState, + { ...base, revision: 5, humanGateOpenedAtRevision: 2 } as WorkflowState, + { ...base, revision: -1 } as WorkflowState, + { ...base, sequence: 1.5 } as WorkflowState, + { ...base, status: 'open' } as unknown as WorkflowState, + ]; + + for (const state of inconsistent) { + expect(applyWorkflowEvent(state, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + } + }); + + it('refuses a state whose tracked invocation contradicts its own reported trio', () => { + const base = requested(); + const tracked = base.invocations[0]; + expect(tracked).toBeDefined(); + + const forged = { + ...base, + invocations: [{ ...(tracked as TrackedInvocation), reportedStatus: 'reported-complete' }], + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(forged, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('refuses a state whose admission postdates the workflow itself', () => { + const base = applyOrThrow(openedWorkflow(), admitEvidence()); + const admission = base.evidence[0]; + expect(admission).toBeDefined(); + + const forged = { + ...base, + evidence: [{ ...(admission as AdmittedEvidence), admittedAtSequence: 99 }], + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(forged, admitReview()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); +}); + +describe('group K — bounds', () => { + it('pins the identifier bound to PR 005 and PR 006', () => { + expect(WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH).toBe(REVIEW_BOUNDS.MAX_IDENTIFIER_LENGTH); + expect(WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH).toBe(INVOCATION_BOUNDS.MAX_IDENTIFIER_LENGTH); + }); + + it('carries a maximum-length identifier through unchanged', () => { + const maxId = oversized(WORKFLOW_BOUNDS.MAX_IDENTIFIER_LENGTH); + const state = applyOrThrow( + openedWorkflow(), + requestInvocation(buildInvocation({ invocationId: maxId })), + ); + + expect(state.invocations[0]?.invocationId).toBe(maxId); + }); + + it.each([ + ['invocationId', 'invocation.invocationId'], + ['repositoryId', 'invocation.repositoryId'], + ['targetCommitSha', 'invocation.targetCommitSha'], + ['providerId', 'invocation.providerId'], + ['agentId', 'invocation.agentId'], + ['requestedAt', 'invocation.requestedAt'], + ])('rejects an oversized %s', (field, path) => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(withRawInvocationField(field, oversized(257))), + ); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toContain(path); + }); + + it('refuses a new invocation once the tracked bound is reached', () => { + let state = openedWorkflow(); + for (let index = 0; index < WORKFLOW_BOUNDS.MAX_TRACKED_INVOCATIONS; index += 1) { + state = applyOrThrow( + state, + requestInvocation(buildInvocation({ invocationId: `inv-${String(index)}` })), + ); + } + const result = applyWorkflowEvent( + state, + requestInvocation(buildInvocation({ invocationId: 'inv-overflow' })), + ); + + expect(state.invocations).toHaveLength(WORKFLOW_BOUNDS.MAX_TRACKED_INVOCATIONS); + expect(result.rejection).toBe('CAPACITY_EXCEEDED'); + expect(result.state).toBe(state); + }); + + /** A synthetic but structurally valid state with `count` evidence admissions. */ + function withEvidenceAdmissions(count: number): WorkflowState { + const evidence = Array.from({ length: count }, (_value, index) => ({ + evidenceId: `ev-${String(index)}`, + kind: 'ci-result' as const, + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: index + 1, + })); + return { ...openedWorkflow(), sequence: count, evidence } as WorkflowState; + } + + it('refuses a new admission once the evidence bound is reached', () => { + const state = withEvidenceAdmissions(WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE); + const result = applyWorkflowEvent(state, admitEvidence()); + + expect(result.rejection).toBe('CAPACITY_EXCEEDED'); + expect(result.state).toBe(state); + }); + + it('still admits one below the evidence bound', () => { + const state = withEvidenceAdmissions(WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE - 1); + + expect(applyWorkflowEvent(state, admitEvidence()).outcome).toBe('APPLIED'); + }); + + it('refuses a state whose list exceeds its own bound', () => { + const state = withEvidenceAdmissions(WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE + 1); + + expect(applyWorkflowEvent(state, admitReview()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('refuses a new admission once the review bound is reached', () => { + const reviews = Array.from({ length: WORKFLOW_BOUNDS.MAX_ADMITTED_REVIEWS }, (_v, index) => ({ + reviewId: `rev-${String(index)}`, + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: index + 1, + })); + const state = { + ...openedWorkflow(), + sequence: WORKFLOW_BOUNDS.MAX_ADMITTED_REVIEWS, + reviews, + } as WorkflowState; + + expect(applyWorkflowEvent(state, admitReview()).rejection).toBe('CAPACITY_EXCEEDED'); + }); + + it('refuses to advance past the sequence bound', () => { + const state = { ...openedWorkflow(), sequence: WORKFLOW_BOUNDS.MAX_SEQUENCE } as WorkflowState; + + for (const [, event] of everyEvent()) { + const result = applyWorkflowEvent(state, event); + expect(result.outcome).toBe('REJECTED'); + expect(result.state).toBe(state); + } + }); + + it('refuses to advance past the revision bound', () => { + const state = { + ...openedWorkflow(), + revision: WORKFLOW_BOUNDS.MAX_REVISION, + sequence: 1, + } as WorkflowState; + + expect(applyWorkflowEvent(state, observeHead(SHA_B)).rejection).toBe('CAPACITY_EXCEEDED'); + }); +}); + +describe('group L — purity, immutability, and determinism', () => { + it('returns the identical state reference for every rejection', () => { + const states: readonly WorkflowState[] = [ + requested(), + applyOrThrow(requested(), openHumanGate()), + applyOrThrow(requested(), closeWorkflow()), + ]; + + for (const state of states) { + for (const [, event] of everyEvent()) { + const result = applyWorkflowEvent(state, event); + if (result.outcome === 'REJECTED') { + expect(result.state).toBe(state); + } + } + expect(applyWorkflowEvent(state, { kind: 'NOPE' } as never).state).toBe(state); + } + }); + + it('never mutates the state or the event', () => { + for (const [, event] of everyEvent()) { + const state = requested(); + const stateBefore = JSON.stringify(state); + const eventBefore = JSON.stringify(event); + + applyWorkflowEvent(state, event); + + expect(JSON.stringify(state)).toBe(stateBefore); + expect(JSON.stringify(event)).toBe(eventBefore); + } + }); + + it('produces an identical result when applied twice to the same state', () => { + for (const [, event] of everyEvent()) { + const state = requested(); + + expect(applyWorkflowEvent(state, event)).toEqual(applyWorkflowEvent(state, event)); + } + }); + + it('round-trips through JSON unchanged', () => { + let state = requested(); + state = applyOrThrow(state, reportInvocation()); + state = applyOrThrow(state, admitEvidence()); + state = applyOrThrow(state, admitReview()); + state = applyOrThrow(state, observeHead(SHA_B)); + + expect(JSON.parse(JSON.stringify(state))).toEqual(state); + }); + + it('deeply freezes every applied state', () => { + const state = applyOrThrow( + applyOrThrow(applyOrThrow(requested(), reportInvocation()), admitEvidence()), + admitReview(), + ); + + expect(Object.isFrozen(state)).toBe(true); + expect(Object.isFrozen(state.invocations)).toBe(true); + expect(Object.isFrozen(state.evidence)).toBe(true); + expect(Object.isFrozen(state.reviews)).toBe(true); + expect(Object.isFrozen(state.invocations[0])).toBe(true); + expect(Object.isFrozen(state.evidence[0])).toBe(true); + expect(Object.isFrozen(state.reviews[0])).toBe(true); + }); + + it('freezes the transition result and its invalid-field list', () => { + const result = applyWorkflowEvent(openedWorkflow(), observeHead(SHA_A)); + + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.invalidFields)).toBe(true); + }); + + it('drops any extra property a caller attached to a state', () => { + const smuggled = { ...openedWorkflow(), mayMerge: true } as unknown as WorkflowState; + const result = applyWorkflowEvent(smuggled, admitEvidence()); + + expect(result.outcome).toBe('APPLIED'); + expect(Object.keys(result.state)).not.toContain('mayMerge'); + }); + + it('keeps humanGateOpenedAtRevision null or equal to revision at every step', () => { + const steps: readonly WorkflowEvent[] = [ + requestInvocation(), + openHumanGate(), + reportInvocation(), + observeHead(SHA_B), + openHumanGate(SHA_B), + admitEvidence(buildHumanDecisionVerdict({ targetHeadSha: SHA_B, commitSha: SHA_B })), + admitReview(buildReview({ reviewedCommitSha: SHA_B })), + openHumanGate(SHA_B), + closeWorkflow('HUMAN_DECISION_RECORDED'), + ]; + let state = openedWorkflow(); + + for (const event of steps) { + state = applyOrThrow(state, event); + expect( + state.humanGateOpenedAtRevision === null || + state.humanGateOpenedAtRevision === state.revision, + ).toBe(true); + } + + expect(state.status).toBe('CLOSED'); + }); + + it('advances the sequence by exactly one per applied transition', () => { + let state = openedWorkflow(); + let expected = 0; + + for (const event of [ + requestInvocation(), + reportInvocation(), + admitEvidence(), + admitReview(), + observeHead(SHA_B), + openHumanGate(SHA_B), + closeWorkflow(), + ]) { + state = applyOrThrow(state, event); + expected += 1; + expect(state.sequence).toBe(expected); + } + }); + + it('reads no clock, randomness, environment, or host API', () => { + for (const file of ['workflow.ts', 'workflow-transitions.ts']) { + const source = stripComments( + readFileSync(new URL(`../../src/domain/${file}`, import.meta.url), 'utf8'), + ); + + for (const forbidden of [ + 'Date', + 'Math.random', + 'process', + 'globalThis', + 'require(', + 'node:', + 'async ', + 'await ', + 'Promise', + 'setTimeout', + 'crypto', + 'randomUUID', + ]) { + expect(source).not.toContain(forbidden); + } + } + }); + + it('returns synchronously, never a thenable', () => { + const result: unknown = applyWorkflowEvent(openedWorkflow(), admitEvidence()); + + expect(result).not.toHaveProperty('then'); + expect(openWorkflow(buildBinding())).not.toHaveProperty('then'); + }); +}); + +describe('group M — forbidden vocabulary', () => { + /** Every event payload, with every banned field name planted on it. */ + function planted(base: T): T { + const target = { ...base } as Record; + for (const key of FORBIDDEN_STATE_KEYS) { + target[key] = true; + } + for (const value of FORBIDDEN_STATE_VALUES) { + target[`planted_${value}`] = value; + } + return target as T; + } + + it('never places a banned key in a serialized state', () => { + let state = openedWorkflow(); + state = applyOrThrow(state, requestInvocation(planted(buildInvocation()))); + state = applyOrThrow(state, reportInvocation(planted(buildReport()))); + state = applyOrThrow(state, admitEvidence(planted(buildVerdict()))); + state = applyOrThrow(state, admitReview(planted(buildReview()))); + + const keys = new Set(); + JSON.parse(JSON.stringify(state), function collect(this: unknown, key: string, value: unknown) { + if (key !== '') { + keys.add(key); + } + return value; + }); + + for (const banned of FORBIDDEN_STATE_KEYS) { + expect(keys.has(banned)).toBe(false); + } + }); + + it('never places a policy or freshness value in a serialized state', () => { + let state = openedWorkflow(); + state = applyOrThrow(state, requestInvocation(planted(buildInvocation()))); + state = applyOrThrow(state, admitEvidence(planted(buildVerdict()))); + const serialized = JSON.stringify(state); + + for (const banned of FORBIDDEN_STATE_VALUES) { + expect(serialized).not.toContain(banned); + } + }); + + it('exposes no boolean field anywhere in a state', () => { + const state = applyOrThrow( + applyOrThrow(requested(), reportInvocation()), + admitEvidence(), + ); + const booleans: string[] = []; + + JSON.parse(JSON.stringify(state), function collect(this: unknown, key: string, value: unknown) { + if (typeof value === 'boolean') { + booleans.push(key); + } + return value; + }); + + expect(booleans).toEqual([]); + }); + + it('declares no status, event, or rejection name that implies authority', () => { + const names = [...WORKFLOW_STATUSES, ...WORKFLOW_EVENT_KINDS, ...TRANSITION_REJECTIONS]; + + for (const name of names) { + expect(name).not.toMatch(/MERGE|DEPLOY|APPROV|AUTHORIZ|ALLOW|DENY|WRITE|MUTAT/i); + } + }); + + it('keeps the rejection vocabulary complete and in declaration order', () => { + expect(TRANSITION_REJECTIONS).toEqual([ + 'WORKFLOW_UNREADABLE', + 'EVENT_UNREADABLE', + 'EVENT_KIND_UNKNOWN', + 'EVENT_PAYLOAD_INVALID', + 'WORKFLOW_CLOSED', + 'WORKFLOW_AWAITING_HUMAN', + 'HUMAN_GATE_ALREADY_OPEN', + 'BINDING_MISMATCH', + 'INPUT_NOT_INGESTED', + 'EVIDENCE_NOT_CURRENT', + 'DUPLICATE_INVOCATION_ID', + 'UNKNOWN_INVOCATION', + 'INVOCATION_ALREADY_REPORTED', + 'DUPLICATE_ADMISSION', + 'HEAD_UNCHANGED', + 'CAPACITY_EXCEEDED', + ]); + }); + + it('keeps the status and event vocabularies minimal', () => { + expect(WORKFLOW_STATUSES).toEqual(['OPEN', 'AWAITING_HUMAN_DECISION', 'CLOSED']); + expect(WORKFLOW_EVENT_KINDS).toEqual([ + 'INVOCATION_REQUESTED', + 'INVOCATION_REPORTED', + 'REVIEW_ADMITTED', + 'EVIDENCE_ADMITTED', + 'HEAD_OBSERVED', + 'HUMAN_GATE_OPENED', + 'CLOSE_REQUESTED', + ]); + }); + + it('exports no projection, recommendation, or next-action API', async () => { + const domain: Record = await import('../../src/domain/index.js'); + + for (const name of Object.keys(domain)) { + expect(name).not.toMatch(/^(legalEventKinds|nextAction|recommend|select|route|plan)/); + } + }); +}); diff --git a/tests/domain/workflow-transitions.test.ts b/tests/domain/workflow-transitions.test.ts new file mode 100644 index 0000000..e7bfeae --- /dev/null +++ b/tests/domain/workflow-transitions.test.ts @@ -0,0 +1,1195 @@ +/** + * Behavioural tests for the Autoflow state machine. + * + * Expected vocabulary values are bare string literals so the suite cannot + * ratify a production mapping that has been changed incorrectly. + */ + +import { describe, expect, it } from 'vitest'; + +import { + applyWorkflowEvent, + evaluateEvidenceFreshness, + ingestInvocationReport, + ingestReview, + openWorkflow, + type AgentInvocation, + type EvidenceRecord, + type ReviewContext, + type WorkflowEvent, + type WorkflowState, +} from '../../src/domain/index.js'; +import { + admitEvidence, + admitReview, + applyOrThrow, + buildBinding, + buildBindingWithoutPullRequest, + buildHumanDecisionVerdict, + buildInvocation, + buildInvocationWithoutPullRequest, + buildReport, + buildReview, + buildVerdict, + closeWorkflow, + EVIDENCE_A, + EVIDENCE_B, + INVOCATION_A, + INVOCATION_B, + label, + MALFORMED_VALUES, + NON_OBJECTS, + observeHead, + openedWorkflow, + openHumanGate, + oversized, + PR_A, + PR_B, + REPO_A, + REPO_B, + reportInvocation, + REQUESTED_AT, + requestInvocation, + REVIEW_A, + REVIEW_B, + SHA_A, + SHA_B, + SHA_C, + UNSUPPORTED_EVENT_KINDS, + WORKFLOW_A, + withRawBindingField, + withRawReportField, + withRawReviewField, + withRawVerdictField, + withThrowingGetter, +} from './workflow-fixtures.js'; + +/** A workflow with one invocation already requested at the bound commit. */ +function withRequestedInvocation(): WorkflowState { + return applyOrThrow(openedWorkflow(), requestInvocation()); +} + +/** That workflow with a human gate open at the bound commit. */ +function awaitingHuman(): WorkflowState { + return applyOrThrow(withRequestedInvocation(), openHumanGate()); +} + +/** That workflow, closed. */ +function closed(): WorkflowState { + return applyOrThrow(withRequestedInvocation(), closeWorkflow()); +} + +describe('openWorkflow — group A, construction', () => { + it('opens at revision 0 and sequence 0 with empty lists', () => { + const result = openWorkflow(buildBinding()); + + expect(result.outcome).toBe('APPLIED'); + expect(result.rejection).toBeNull(); + expect(result.invalidFields).toEqual([]); + expect(result.state).toEqual({ + workflowId: WORKFLOW_A, + repositoryId: REPO_A, + pullRequestId: PR_A, + boundCommitSha: SHA_A, + revision: 0, + sequence: 0, + status: 'OPEN', + closureReason: null, + humanGateOpenedAtRevision: null, + invocations: [], + evidence: [], + reviews: [], + }); + }); + + it('records an absent pull request as null', () => { + const result = openWorkflow(buildBindingWithoutPullRequest()); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state?.pullRequestId).toBeNull(); + }); + + it('deeply freezes the opened state', () => { + const state = openedWorkflow(); + + expect(Object.isFrozen(state)).toBe(true); + expect(Object.isFrozen(state.invocations)).toBe(true); + expect(Object.isFrozen(state.evidence)).toBe(true); + expect(Object.isFrozen(state.reviews)).toBe(true); + }); + + it.each(['workflowId', 'repositoryId', 'boundCommitSha'])( + 'rejects a binding whose %s is missing', + (field) => { + const result = openWorkflow(withRawBindingField(field, undefined)); + + expect(result.outcome).toBe('REJECTED'); + expect(result.state).toBeNull(); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.invalidFields).toEqual([`binding.${field}`]); + }, + ); + + it.each(MALFORMED_VALUES)('rejects a workflowId that is %s', (_name, value) => { + const result = openWorkflow(withRawBindingField('workflowId', value)); + + expect(result.outcome).toBe('REJECTED'); + expect(result.invalidFields).toEqual(['binding.workflowId']); + }); + + it('rejects an oversized identifier rather than truncating it', () => { + const long = oversized(257); + const result = openWorkflow(withRawBindingField('boundCommitSha', long)); + + expect(result.outcome).toBe('REJECTED'); + expect(result.state).toBeNull(); + expect(JSON.stringify(result)).not.toContain(long.slice(0, 32)); + }); + + it('accepts an identifier of exactly the bound', () => { + const result = openWorkflow(withRawBindingField('workflowId', oversized(256))); + + expect(result.outcome).toBe('APPLIED'); + }); + + it('rejects a present but invalid pullRequestId — binding is all-or-nothing', () => { + const result = openWorkflow(withRawBindingField('pullRequestId', 42)); + + expect(result.outcome).toBe('REJECTED'); + expect(result.invalidFields).toEqual(['binding.pullRequestId']); + }); + + it.each(NON_OBJECTS)('rejects a binding that is %s', (_name, value) => { + const result = openWorkflow(value as never); + + expect(result.outcome).toBe('REJECTED'); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.invalidFields).toEqual([ + 'binding.workflowId', + 'binding.repositoryId', + 'binding.boundCommitSha', + ]); + }); + + it('reports every invalid field in declaration order', () => { + const result = openWorkflow({ + workflowId: '', + repositoryId: '', + pullRequestId: 7, + boundCommitSha: '', + } as never); + + expect(result.invalidFields).toEqual([ + 'binding.workflowId', + 'binding.repositoryId', + 'binding.pullRequestId', + 'binding.boundCommitSha', + ]); + }); +}); + +describe('applyWorkflowEvent — group B, transition matrix', () => { + const cases: readonly (readonly [string, () => WorkflowEvent, string, string | null])[] = [ + [ + 'INVOCATION_REQUESTED', + () => requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + 'OPEN', + null, + ], + ['INVOCATION_REPORTED', () => reportInvocation(), 'OPEN', null], + ['REVIEW_ADMITTED', () => admitReview(), 'OPEN', null], + ['EVIDENCE_ADMITTED', () => admitEvidence(), 'OPEN', null], + [ + 'EVIDENCE_ADMITTED human-decision', + () => admitEvidence(buildHumanDecisionVerdict()), + 'OPEN', + null, + ], + ['HEAD_OBSERVED different', () => observeHead(SHA_B), 'OPEN', null], + ['HUMAN_GATE_OPENED', () => openHumanGate(), 'AWAITING_HUMAN_DECISION', null], + ['CLOSE_REQUESTED', () => closeWorkflow(), 'CLOSED', null], + ]; + + it.each(cases)('OPEN accepts %s', (_name, build, expectedStatus) => { + const result = applyWorkflowEvent(withRequestedInvocation(), build()); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state.status).toBe(expectedStatus); + expect(result.rejection).toBeNull(); + }); + + it('OPEN refuses HEAD_OBSERVED at the same commit', () => { + const result = applyWorkflowEvent(withRequestedInvocation(), observeHead(SHA_A)); + + expect(result.outcome).toBe('REJECTED'); + expect(result.rejection).toBe('HEAD_UNCHANGED'); + }); + + const awaitingCases: readonly (readonly [string, () => WorkflowEvent, string | null])[] = [ + [ + 'INVOCATION_REQUESTED', + () => requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + 'WORKFLOW_AWAITING_HUMAN', + ], + ['INVOCATION_REPORTED', () => reportInvocation(), null], + ['REVIEW_ADMITTED', () => admitReview(), null], + ['EVIDENCE_ADMITTED', () => admitEvidence(), null], + [ + 'EVIDENCE_ADMITTED human-decision', + () => admitEvidence(buildHumanDecisionVerdict()), + null, + ], + ['HEAD_OBSERVED different', () => observeHead(SHA_B), null], + ['HEAD_OBSERVED same', () => observeHead(SHA_A), 'HEAD_UNCHANGED'], + ['HUMAN_GATE_OPENED', () => openHumanGate(), 'HUMAN_GATE_ALREADY_OPEN'], + ['CLOSE_REQUESTED', () => closeWorkflow(), null], + ]; + + it.each(awaitingCases)( + 'AWAITING_HUMAN_DECISION handles %s', + (_name, build, expectedRejection) => { + const state = awaitingHuman(); + const result = applyWorkflowEvent(state, build()); + + if (expectedRejection === null) { + expect(result.outcome).toBe('APPLIED'); + } else { + expect(result.outcome).toBe('REJECTED'); + expect(result.rejection).toBe(expectedRejection); + expect(result.state).toBe(state); + } + }, + ); + + const everyEvent: readonly (readonly [string, () => WorkflowEvent])[] = [ + [ + 'INVOCATION_REQUESTED', + () => requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + ], + ['INVOCATION_REPORTED', () => reportInvocation()], + ['REVIEW_ADMITTED', () => admitReview()], + ['EVIDENCE_ADMITTED', () => admitEvidence()], + ['EVIDENCE_ADMITTED human-decision', () => admitEvidence(buildHumanDecisionVerdict())], + ['HEAD_OBSERVED different', () => observeHead(SHA_B)], + ['HEAD_OBSERVED same', () => observeHead(SHA_A)], + ['HUMAN_GATE_OPENED', () => openHumanGate()], + ['CLOSE_REQUESTED', () => closeWorkflow()], + ]; + + it.each(everyEvent)('CLOSED refuses %s', (_name, build) => { + const state = closed(); + const result = applyWorkflowEvent(state, build()); + + expect(result.outcome).toBe('REJECTED'); + expect(result.rejection).toBe('WORKFLOW_CLOSED'); + expect(result.invalidFields).toEqual(['workflow.status']); + expect(result.state).toBe(state); + }); + + it('CLOSED is terminal: there is no reopen', () => { + const state = closed(); + + expect(state.status).toBe('CLOSED'); + expect(state.closureReason).toBe('CALLER_CLOSED'); + expect(applyWorkflowEvent(state, openHumanGate()).state).toBe(state); + }); +}); + +describe('applyWorkflowEvent — group C, invocation lifecycle', () => { + it('tracks a requested invocation with its own commit and inert labels', () => { + const state = withRequestedInvocation(); + + expect(state.sequence).toBe(1); + expect(state.invocations).toHaveLength(1); + expect(state.invocations[0]).toEqual({ + invocationId: INVOCATION_A, + targetCommitSha: SHA_A, + purpose: 'review', + providerId: 'codex', + agentId: 'agent-1', + requestedAtRevision: 0, + requestedAtSequence: 1, + state: 'REQUESTED', + reportedStatus: null, + reportedAtRevision: null, + reportedAtSequence: null, + }); + }); + + it('does not store the caller-supplied requestedAt timestamp', () => { + expect(JSON.stringify(withRequestedInvocation())).not.toContain(REQUESTED_AT); + }); + + it('moves a requested invocation to REPORTED in place', () => { + const state = applyOrThrow( + applyOrThrow(withRequestedInvocation(), requestInvocation( + buildInvocation({ invocationId: INVOCATION_B }), + )), + reportInvocation(), + ); + + expect(state.invocations[0]?.invocationId).toBe(INVOCATION_A); + expect(state.invocations[0]?.state).toBe('REPORTED'); + expect(state.invocations[0]?.reportedStatus).toBe('reported-complete'); + expect(state.invocations[0]?.reportedAtSequence).toBe(3); + expect(state.invocations[1]?.invocationId).toBe(INVOCATION_B); + expect(state.invocations[1]?.state).toBe('REQUESTED'); + }); + + it('refuses a duplicate invocation id', () => { + const state = withRequestedInvocation(); + const result = applyWorkflowEvent(state, requestInvocation()); + + expect(result.rejection).toBe('DUPLICATE_INVOCATION_ID'); + expect(result.state).toBe(state); + }); + + it('refuses a duplicate invocation id at a later revision too', () => { + const moved = applyOrThrow(withRequestedInvocation(), observeHead(SHA_B)); + const result = applyWorkflowEvent( + moved, + requestInvocation(buildInvocation({ targetCommitSha: SHA_B })), + ); + + expect(result.rejection).toBe('DUPLICATE_INVOCATION_ID'); + }); + + it('refuses a report for an invocation it never requested', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + reportInvocation(buildReport({ invocationId: INVOCATION_B })), + ); + + expect(result.rejection).toBe('UNKNOWN_INVOCATION'); + }); + + it('refuses a replayed report', () => { + const reported = applyOrThrow(withRequestedInvocation(), reportInvocation()); + const result = applyWorkflowEvent(reported, reportInvocation()); + + expect(result.rejection).toBe('INVOCATION_ALREADY_REPORTED'); + expect(result.state).toBe(reported); + }); + + it('refuses a report whose upstream ingestion failed', () => { + const result = applyWorkflowEvent( + withRequestedInvocation(), + reportInvocation(buildReport({ outcome: 'INVOCATION_INVALID' })), + ); + + expect(result.rejection).toBe('INPUT_NOT_INGESTED'); + expect(result.invalidFields).toEqual(['report.outcome']); + }); + + it('refuses a report bound to a different repository', () => { + const result = applyWorkflowEvent( + withRequestedInvocation(), + reportInvocation(buildReport({ repositoryId: REPO_B })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['report.repositoryId']); + }); + + it('refuses a report bound to a different commit than its invocation', () => { + const result = applyWorkflowEvent( + withRequestedInvocation(), + reportInvocation(buildReport({ targetCommitSha: SHA_C })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['report.targetCommitSha']); + }); + + it('records a report that arrives after HEAD moved, bound to its own commit', () => { + const moved = applyOrThrow(withRequestedInvocation(), observeHead(SHA_B)); + const result = applyWorkflowEvent(moved, reportInvocation()); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state.boundCommitSha).toBe(SHA_B); + expect(result.state.invocations[0]?.targetCommitSha).toBe(SHA_A); + expect(result.state.invocations[0]?.state).toBe('REPORTED'); + expect(result.state.invocations[0]?.reportedAtRevision).toBe(1); + expect(result.state.evidence).toEqual([]); + }); + + it('accepts a report while a human gate is open', () => { + const result = applyWorkflowEvent(awaitingHuman(), reportInvocation()); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + }); + + it.each(['reported-complete', 'reported-failed', 'reported-cancelled', 'unknown'])( + 'carries reportedStatus %s verbatim', + (status) => { + const state = applyOrThrow( + withRequestedInvocation(), + reportInvocation(buildReport({ reportedStatus: status as never })), + ); + + expect(state.invocations[0]?.reportedStatus).toBe(status); + }, + ); + + it('fails an unrecognised reported status closed to unknown', () => { + const state = applyOrThrow( + withRequestedInvocation(), + reportInvocation(withRawReportField('reportedStatus', 'COMPLETE')), + ); + + expect(state.invocations[0]?.reportedStatus).toBe('unknown'); + }); +}); + +describe('applyWorkflowEvent — group D, HEAD, revision, and the A1 gate clear', () => { + it('rebinds and advances the revision, leaving history in place', () => { + const state = applyOrThrow( + applyOrThrow(withRequestedInvocation(), admitEvidence()), + observeHead(SHA_B), + ); + + expect(state.boundCommitSha).toBe(SHA_B); + expect(state.revision).toBe(1); + expect(state.sequence).toBe(3); + expect(state.evidence).toHaveLength(1); + expect(state.evidence[0]?.admittedAtRevision).toBe(0); + expect(state.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(state.invocations[0]?.state).toBe('REQUESTED'); + expect(state.invocations[0]?.targetCommitSha).toBe(SHA_A); + }); + + it.each(MALFORMED_VALUES)('refuses an observed head that is %s', (_name, value) => { + const result = applyWorkflowEvent(openedWorkflow(), { + kind: 'HEAD_OBSERVED', + observedCommitSha: value, + } as never); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toEqual(['event.observedCommitSha']); + }); + + it('refuses an oversized observed head', () => { + const result = applyWorkflowEvent(openedWorkflow(), observeHead(oversized(257))); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + }); + + it('does not increment the sequence on a refused HEAD_OBSERVED', () => { + const state = openedWorkflow(); + const result = applyWorkflowEvent(state, observeHead(SHA_A)); + + expect(result.state.sequence).toBe(0); + expect(result.state.revision).toBe(0); + }); + + it('advances the revision even when HEAD returns to a previous commit', () => { + const first = applyOrThrow(openedWorkflow(), admitEvidence()); + const moved = applyOrThrow(first, observeHead(SHA_B)); + const back = applyOrThrow(moved, observeHead(SHA_A)); + + expect(back.revision).toBe(2); + expect(back.boundCommitSha).toBe(SHA_A); + expect(back.evidence).toHaveLength(1); + expect(back.evidence[0]?.admittedAtRevision).toBe(0); + }); + + it('re-admits the same evidence id at a later revision as a fresh admission', () => { + const first = applyOrThrow(openedWorkflow(), admitEvidence()); + const moved = applyOrThrow(first, observeHead(SHA_B)); + const back = applyOrThrow(moved, observeHead(SHA_A)); + const readmitted = applyOrThrow(back, admitEvidence()); + + expect(readmitted.evidence).toHaveLength(2); + expect(readmitted.evidence[0]?.admittedAtRevision).toBe(0); + expect(readmitted.evidence[1]?.admittedAtRevision).toBe(2); + expect(readmitted.evidence[1]?.evidenceId).toBe(EVIDENCE_A); + }); + + it('A1: a HEAD advance clears an open human gate', () => { + const state = applyOrThrow(awaitingHuman(), observeHead(SHA_B)); + + expect(state.status).toBe('OPEN'); + expect(state.humanGateOpenedAtRevision).toBeNull(); + expect(state.revision).toBe(1); + }); + + it('A1: a HEAD advance from OPEN leaves the gate field null', () => { + const state = applyOrThrow(openedWorkflow(), observeHead(SHA_B)); + + expect(state.status).toBe('OPEN'); + expect(state.humanGateOpenedAtRevision).toBeNull(); + }); + + it('A1: work may be requested immediately after the clearing HEAD advance', () => { + const cleared = applyOrThrow(awaitingHuman(), observeHead(SHA_B)); + const result = applyWorkflowEvent( + cleared, + requestInvocation(buildInvocation({ invocationId: INVOCATION_B, targetCommitSha: SHA_B })), + ); + + expect(result.outcome).toBe('APPLIED'); + }); + + it('A1: a decision bound to the superseded commit cannot retroactively unblock', () => { + const cleared = applyOrThrow(awaitingHuman(), observeHead(SHA_B)); + const reopened = applyOrThrow(cleared, openHumanGate(SHA_B)); + const result = applyWorkflowEvent(reopened, admitEvidence(buildHumanDecisionVerdict())); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + }); + + it('A1: the gate can be re-opened at the new revision', () => { + const cleared = applyOrThrow(awaitingHuman(), observeHead(SHA_B)); + const reopened = applyOrThrow(cleared, openHumanGate(SHA_B)); + + expect(reopened.status).toBe('AWAITING_HUMAN_DECISION'); + expect(reopened.humanGateOpenedAtRevision).toBe(1); + }); +}); + +describe('applyWorkflowEvent — group E, evidence admission and A2', () => { + it('admits a CURRENT verdict judged against this binding', () => { + const state = applyOrThrow(openedWorkflow(), admitEvidence()); + + expect(state.evidence).toEqual([ + { + evidenceId: EVIDENCE_A, + kind: 'ci-result', + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: 1, + }, + ]); + }); + + it.each(['STALE', 'INVALID'])('refuses a %s verdict', (verdictState) => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(withRawVerdictField('state', verdictState)), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(['verdict.state']); + }); + + it('refuses a forged CURRENT verdict whose reason does not agree', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(buildVerdict({ reason: 'COMMIT_SHA_MISMATCH' })), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(['verdict.reason']); + }); + + it('refuses a verdict judged against a different head', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(buildVerdict({ targetHeadSha: SHA_B })), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(['verdict.targetHeadSha']); + }); + + it('refuses a verdict judged against a different repository', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(buildVerdict({ targetRepositoryId: REPO_B })), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(['verdict.targetRepositoryId']); + }); + + it.each(MALFORMED_VALUES)('refuses a verdict whose evidenceId is %s', (_name, value) => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(withRawVerdictField('evidenceId', value)), + ); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toContain('verdict.evidenceId'); + }); + + it('refuses a verdict whose kind is outside the PR 004 vocabulary', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(withRawVerdictField('kind', 'merge-approval')), + ); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toEqual(['verdict.kind']); + }); + + it('refuses a duplicate admission at the same revision', () => { + const state = applyOrThrow(openedWorkflow(), admitEvidence()); + const result = applyWorkflowEvent(state, admitEvidence()); + + expect(result.rejection).toBe('DUPLICATE_ADMISSION'); + expect(result.state).toBe(state); + }); + + it('admits a different evidence id at the same revision', () => { + const state = applyOrThrow( + applyOrThrow(openedWorkflow(), admitEvidence()), + admitEvidence(buildVerdict({ evidenceId: EVIDENCE_B })), + ); + + expect(state.evidence).toHaveLength(2); + }); + + it('clears an open gate on a human-decision admission', () => { + const state = applyOrThrow(awaitingHuman(), admitEvidence(buildHumanDecisionVerdict())); + + expect(state.status).toBe('OPEN'); + expect(state.humanGateOpenedAtRevision).toBeNull(); + expect(state.evidence[0]?.kind).toBe('human-decision'); + }); + + it('leaves an OPEN workflow open on a human-decision admission', () => { + const state = applyOrThrow(openedWorkflow(), admitEvidence(buildHumanDecisionVerdict())); + + expect(state.status).toBe('OPEN'); + }); + + it.each(['ci-result', 'code-review', 'security-review', 'test-result', 'repository-state'])( + 'does not let a %s admission clear a human gate', + (kind) => { + const state = applyOrThrow( + awaitingHuman(), + admitEvidence(buildVerdict({ kind: kind as never })), + ); + + expect(state.status).toBe('AWAITING_HUMAN_DECISION'); + expect(state.humanGateOpenedAtRevision).toBe(0); + }, + ); + + it('A2: an admission keeps its commit binding after HEAD moves', () => { + const admitted = applyOrThrow(openedWorkflow(), admitEvidence()); + const moved = applyOrThrow(admitted, observeHead(SHA_B)); + const later = applyOrThrow( + moved, + admitEvidence(buildVerdict({ evidenceId: EVIDENCE_B, targetHeadSha: SHA_B })), + ); + + expect(later.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(later.evidence[1]?.admittedAtCommitSha).toBe(SHA_B); + }); +}); + +describe('applyWorkflowEvent — group F, review admission and A3', () => { + it('admits a review bound to the current commit', () => { + const state = applyOrThrow(openedWorkflow(), admitReview()); + + expect(state.reviews).toEqual([ + { + reviewId: REVIEW_A, + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: 1, + }, + ]); + }); + + it('refuses a review whose ingestion failed', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(buildReview({ outcome: 'CONTEXT_INVALID' })), + ); + + expect(result.rejection).toBe('INPUT_NOT_INGESTED'); + }); + + it('refuses a review bound to a superseded commit', () => { + const moved = applyOrThrow(openedWorkflow(), observeHead(SHA_B)); + const result = applyWorkflowEvent(moved, admitReview()); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['review.reviewedCommitSha']); + }); + + it('refuses a cross-repository review', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(buildReview({ repositoryId: REPO_B })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + }); + + it('refuses a review for a different pull request', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(buildReview({ pullRequestId: PR_B })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['review.pullRequestId']); + }); + + it('ignores a pull request when the workflow has none', () => { + const result = applyWorkflowEvent( + openedWorkflow(buildBindingWithoutPullRequest()), + admitReview(buildReview({ pullRequestId: PR_B })), + ); + + expect(result.outcome).toBe('APPLIED'); + }); + + it('refuses a review whose pull request is present but unreadable', () => { + for (const value of [oversized(257), '', 42, {}, []]) { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(withRawReviewField('pullRequestId', value)), + ); + + expect(result.rejection, label(value)).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['review.pullRequestId']); + } + }); + + it('refuses a review whose pull request getter throws', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(withThrowingGetter(buildReview(), 'pullRequestId')), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['review.pullRequestId']); + }); + + it('refuses a report whose pull request is present but unreadable', () => { + const result = applyWorkflowEvent( + withRequestedInvocation(), + reportInvocation(withRawReportField('pullRequestId', oversized(257))), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['report.pullRequestId']); + }); + + it('still ignores an unreadable pull request when the workflow has none', () => { + const result = applyWorkflowEvent( + openedWorkflow(buildBindingWithoutPullRequest()), + admitReview(withRawReviewField('pullRequestId', oversized(257))), + ); + + expect(result.outcome).toBe('APPLIED'); + }); + + it('refuses an unattributable review', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitReview(buildReview({ reviewId: null })), + ); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toEqual(['review.reviewId']); + }); + + it('refuses a duplicate admission at the same revision and allows one later', () => { + const once = applyOrThrow(openedWorkflow(), admitReview()); + expect(applyWorkflowEvent(once, admitReview()).rejection).toBe('DUPLICATE_ADMISSION'); + + const moved = applyOrThrow(once, observeHead(SHA_B)); + const again = applyOrThrow( + moved, + admitReview(buildReview({ reviewedCommitSha: SHA_B })), + ); + + expect(again.reviews).toHaveLength(2); + expect(again.reviews[1]?.admittedAtRevision).toBe(1); + }); + + it('stores no finding content, count, or severity', () => { + const findings = Array.from({ length: 25 }, (_value, index) => ({ + findingId: `f${String(index)}`, + ordinal: index, + repositoryId: REPO_A, + pullRequestId: PR_A, + reviewedCommitSha: SHA_A, + reviewId: REVIEW_A, + provider: 'coderabbit', + reviewerId: 'reviewer-1', + severity: 'blocking', + classification: 'security', + status: 'open', + title: 'SENTINEL-TITLE', + message: 'SENTINEL-MESSAGE', + filePath: null, + startLine: null, + endLine: null, + sourceId: null, + providerFindingId: null, + truncated: false, + })); + const state = applyOrThrow( + openedWorkflow(), + admitReview(withRawReviewField('findings', findings)), + ); + const serialized = JSON.stringify(state); + + expect(state.reviews).toHaveLength(1); + expect(serialized).not.toContain('SENTINEL-TITLE'); + expect(serialized).not.toContain('SENTINEL-MESSAGE'); + expect(serialized).not.toContain('blocking'); + expect(serialized).not.toContain('findingCount'); + expect(serialized).not.toContain('25'); + }); + + it('A3: admits a review that matches no tracked invocation', () => { + const state = applyOrThrow( + openedWorkflow(), + admitReview(buildReview({ reviewId: 'unsolicited-reviewer-7' })), + ); + + expect(state.reviews).toHaveLength(1); + expect(state.invocations).toEqual([]); + }); + + it('A3: admitting a review transitions no invocation', () => { + const before = withRequestedInvocation(); + const after = applyOrThrow( + before, + admitReview(buildReview({ reviewId: INVOCATION_A })), + ); + + expect(after.invocations).toEqual(before.invocations); + expect(after.invocations[0]?.state).toBe('REQUESTED'); + }); + + it('A3: records nothing distinguishing a requested review from an unsolicited one', () => { + const base = withRequestedInvocation(); + const solicited = applyOrThrow(base, admitReview(buildReview({ reviewId: INVOCATION_A }))); + const unsolicited = applyOrThrow( + base, + admitReview(buildReview({ reviewId: 'forge-auto-review' })), + ); + const solicitedAdmission = solicited.reviews[0]; + const unsolicitedAdmission = unsolicited.reviews[0]; + + expect(solicitedAdmission).toBeDefined(); + expect(unsolicitedAdmission).toBeDefined(); + expect({ ...solicitedAdmission, reviewId: 'x' }).toEqual({ + ...unsolicitedAdmission, + reviewId: 'x', + }); + expect(solicited.invocations).toEqual(unsolicited.invocations); + }); +}); + +describe('applyWorkflowEvent — group G, binding integrity', () => { + it('refuses a cross-repository invocation', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocation({ repositoryId: REPO_B })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['invocation.repositoryId']); + }); + + it('refuses an invocation targeting a commit the workflow is not bound to', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocation({ targetCommitSha: SHA_C })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['invocation.targetCommitSha']); + }); + + it('refuses a cross-pull-request invocation', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocation({ pullRequestId: PR_B })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['invocation.pullRequestId']); + }); + + it('accepts an invocation with no pull request against a pull-request workflow', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocationWithoutPullRequest()), + ); + + expect(result.outcome).toBe('APPLIED'); + }); + + it('treats a case-differing commit as a different commit', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocation({ targetCommitSha: SHA_A.toUpperCase() })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + }); + + it('treats a padded commit as a different commit', () => { + const result = applyWorkflowEvent( + openedWorkflow(), + requestInvocation(buildInvocation({ targetCommitSha: ` ${SHA_A}` })), + ); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + }); + + it('reports every invalid invocation field in declaration order', () => { + const result = applyWorkflowEvent(openedWorkflow(), { + kind: 'INVOCATION_REQUESTED', + invocation: { + invocationId: '', + repositoryId: '', + pullRequestId: 7, + targetCommitSha: '', + providerId: '', + agentId: '', + purpose: 'merge', + requestedAt: '', + }, + } as never); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toEqual([ + 'invocation.invocationId', + 'invocation.repositoryId', + 'invocation.pullRequestId', + 'invocation.targetCommitSha', + 'invocation.providerId', + 'invocation.agentId', + 'invocation.purpose', + 'invocation.requestedAt', + ]); + }); + + it.each(UNSUPPORTED_EVENT_KINDS)('refuses the event kind %s', (kind) => { + const result = applyWorkflowEvent(openedWorkflow(), { kind } as never); + + expect(result.rejection).toBe('EVENT_KIND_UNKNOWN'); + expect(result.invalidFields).toEqual(['event.kind']); + }); + + it.each(NON_OBJECTS)('refuses an event that is %s', (_name, value) => { + const state = openedWorkflow(); + const result = applyWorkflowEvent(state, value as never); + + expect(result.rejection).toBe('EVENT_UNREADABLE'); + expect(result.state).toBe(state); + }); + + it('refuses an event that is an array', () => { + expect(applyWorkflowEvent(openedWorkflow(), [] as never).rejection).toBe( + 'EVENT_UNREADABLE', + ); + }); + + it('refuses a closure reason outside the vocabulary', () => { + const result = applyWorkflowEvent(openedWorkflow(), closeWorkflow('MERGED')); + + expect(result.rejection).toBe('EVENT_PAYLOAD_INVALID'); + expect(result.invalidFields).toEqual(['event.closureReason']); + }); + + it('refuses a human gate opened at a commit the workflow is not bound to', () => { + const result = applyWorkflowEvent(openedWorkflow(), openHumanGate(SHA_B)); + + expect(result.rejection).toBe('BINDING_MISMATCH'); + expect(result.invalidFields).toEqual(['event.atCommitSha']); + }); + + it('retains the gate revision when a workflow closes while awaiting a human', () => { + const state = applyOrThrow(awaitingHuman(), closeWorkflow('HUMAN_DECISION_RECORDED')); + + expect(state.status).toBe('CLOSED'); + expect(state.closureReason).toBe('HUMAN_DECISION_RECORDED'); + expect(state.humanGateOpenedAtRevision).toBe(0); + }); +}); + +describe('applyWorkflowEvent — group N, end-to-end lifecycle replay', () => { + const EVIDENCE_TARGET_A = { repositoryId: REPO_A, currentHeadSha: SHA_A }; + const EVIDENCE_TARGET_B = { repositoryId: REPO_A, currentHeadSha: SHA_B }; + + function ciEvidence(evidenceId: string, commitSha: string): EvidenceRecord { + return { + evidenceId, + repositoryId: REPO_A, + commitSha, + kind: 'ci-result', + source: 'github', + reference: 'check-run-1', + observedAt: REQUESTED_AT, + }; + } + + function humanEvidence(evidenceId: string, commitSha: string): EvidenceRecord { + return { + evidenceId, + repositoryId: REPO_A, + commitSha, + kind: 'human-decision', + source: 'human', + reference: 'decision-1', + observedAt: REQUESTED_AT, + }; + } + + function reviewContext(reviewId: string, commitSha: string): ReviewContext { + return { + repositoryId: REPO_A, + pullRequestId: PR_A, + reviewedCommitSha: commitSha, + provider: 'coderabbit', + reviewerId: 'reviewer-1', + reviewId, + }; + } + + function invocation( + invocationId: string, + purpose: AgentInvocation['purpose'], + targetCommitSha: string, + ): AgentInvocation { + return buildInvocation({ invocationId, purpose, targetCommitSha }); + } + + it('replays the real PR 005/006 lifecycle through the genuine upstream layers', () => { + let state = openedWorkflow(); + + // 1. implementation requested and reported. + const implement = invocation('inv-implement', 'implement', SHA_A); + state = applyOrThrow(state, requestInvocation(implement)); + state = applyOrThrow( + state, + reportInvocation( + ingestInvocationReport(implement, { + status: 'reported-complete', + detail: 'opened a change request', + artifacts: [{ artifactType: 'change-request', reference: 'pr-1234', commitSha: SHA_B }], + }), + ), + ); + expect(state.invocations[0]?.state).toBe('REPORTED'); + expect(state.evidence).toEqual([]); + + // 2. an independent CI observation at the bound commit. + state = applyOrThrow( + state, + admitEvidence(evaluateEvidenceFreshness(ciEvidence('ev-ci-a', SHA_A), EVIDENCE_TARGET_A)), + ); + expect(state.evidence).toHaveLength(1); + + // 3. review requested, and its findings ingested by PR 005. + const review = invocation('inv-review', 'review', SHA_A); + state = applyOrThrow(state, requestInvocation(review)); + state = applyOrThrow( + state, + admitReview( + ingestReview(reviewContext('inv-review', SHA_A), { + findings: [{ title: 'unsafe read', message: 'validate before use', severity: 'blocking' }], + }), + ), + ); + expect(state.reviews).toHaveLength(1); + + // 4. a human gate blocks new work but not fact recording. + state = applyOrThrow(state, openHumanGate(SHA_A)); + expect( + applyWorkflowEvent(state, requestInvocation(invocation('inv-x', 'repair', SHA_A))).rejection, + ).toBe('WORKFLOW_AWAITING_HUMAN'); + state = applyOrThrow( + state, + reportInvocation(ingestInvocationReport(review, { status: 'reported-complete' })), + ); + + // 5. HEAD moves. A1 clears the gate; old-commit evidence stops applying. + state = applyOrThrow(state, observeHead(SHA_B)); + expect(state.status).toBe('OPEN'); + expect(state.humanGateOpenedAtRevision).toBeNull(); + expect(state.revision).toBe(1); + expect( + applyWorkflowEvent( + state, + admitReview(ingestReview(reviewContext('inv-review', SHA_A), { findings: [] })), + ).rejection, + ).toBe('BINDING_MISMATCH'); + expect( + applyWorkflowEvent( + state, + admitEvidence(evaluateEvidenceFreshness(ciEvidence('ev-ci-a', SHA_A), EVIDENCE_TARGET_B)), + ).rejection, + ).toBe('EVIDENCE_NOT_CURRENT'); + + // 6. a fresh review at the new commit, plus an unsolicited one (A3). + const fresh = invocation('inv-review-2', 'review', SHA_B); + state = applyOrThrow(state, requestInvocation(fresh)); + state = applyOrThrow( + state, + admitReview(ingestReview(reviewContext('inv-review-2', SHA_B), { findings: [] })), + ); + state = applyOrThrow( + state, + admitReview(ingestReview(reviewContext('forge-auto-review', SHA_B), { findings: [] })), + ); + expect(state.reviews).toHaveLength(3); + expect(state.invocations).toHaveLength(3); + + // 7. a human decides at the current commit, then an audit runs, then close. + state = applyOrThrow(state, openHumanGate(SHA_B)); + state = applyOrThrow( + state, + admitEvidence( + evaluateEvidenceFreshness(humanEvidence('ev-human-1', SHA_B), EVIDENCE_TARGET_B), + ), + ); + expect(state.status).toBe('OPEN'); + + const audit = invocation('inv-audit', 'audit', SHA_B); + state = applyOrThrow(state, requestInvocation(audit)); + state = applyOrThrow( + state, + reportInvocation(ingestInvocationReport(audit, { status: 'reported-complete' })), + ); + state = applyOrThrow( + state, + admitEvidence(evaluateEvidenceFreshness(ciEvidence('ev-ci-b', SHA_B), EVIDENCE_TARGET_B)), + ); + state = applyOrThrow(state, closeWorkflow('HUMAN_DECISION_RECORDED')); + + expect(state.status).toBe('CLOSED'); + expect(state.revision).toBe(1); + expect(state.invocations).toHaveLength(4); + expect(state.evidence).toHaveLength(3); + expect(state.reviews).toHaveLength(3); + expect(state.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(state.evidence[1]?.admittedAtCommitSha).toBe(SHA_B); + expect(state.reviews[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(state.reviews[2]?.admittedAtCommitSha).toBe(SHA_B); + expect(JSON.parse(JSON.stringify(state))).toEqual(state); + }); + + it('rejects a claim passed where an observation belongs', () => { + const claimed = ingestInvocationReport(buildInvocation(), { + status: 'reported-complete', + artifacts: [{ artifactType: 'commit', reference: 'abc', commitSha: SHA_A }], + }); + const claim = claimed.claims[0]; + + expect(claim).toBeDefined(); + expect( + applyWorkflowEvent(openedWorkflow(), { kind: 'EVIDENCE_ADMITTED', verdict: claim } as never) + .rejection, + ).toBe('EVIDENCE_NOT_CURRENT'); + }); + + it('labels arbitrary values without throwing', () => { + expect(label(Symbol('x'))).toContain('symbol'); + expect(label(REVIEW_B)).toContain(REVIEW_B); + }); +}); From d4dd226f5f3d263de9df4bc1a99cb47ae12e2572 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 13:00:38 +0200 Subject: [PATCH 02/20] Fix workflow trust-boundary validation --- .../007-autoflow-state-machine.md | 2 + src/domain/workflow-transitions.ts | 23 +++++-- tests/domain/workflow-transitions.test.ts | 61 ++++++++++++++++++- 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/docs/architecture/007-autoflow-state-machine.md b/docs/architecture/007-autoflow-state-machine.md index 787ccb8..109bd28 100644 --- a/docs/architecture/007-autoflow-state-machine.md +++ b/docs/architecture/007-autoflow-state-machine.md @@ -238,6 +238,8 @@ result carries the target it was answered against. The only thing left to check is that the answer is about *this* workflow's binding: - `state` is `CURRENT` and `reason` is `BOUND_TO_CURRENT_HEAD`; +- the evidence's own `repositoryId` equals the workflow's repository; +- the evidence's own `commitSha` equals the bound commit; - `targetRepositoryId` equals the workflow's repository; - `targetHeadSha` equals the bound commit. diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 4556ad6..741cd83 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -473,6 +473,11 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { if (tracked === null) { return null; } + for (let priorIndex = 0; priorIndex < invocations.length; priorIndex += 1) { + if (invocations[priorIndex]?.invocationId === tracked.invocationId) { + return null; + } + } append(invocations, tracked); } @@ -1135,6 +1140,12 @@ function applyEvidenceAdmitted( if (readOwnProperty(verdictRecord, 'reason') !== FRESHNESS_REASON.BOUND_TO_CURRENT_HEAD) { append(notCurrent, 'verdict.reason'); } + if (readOwnProperty(verdictRecord, 'repositoryId') !== snapshot.repositoryId) { + append(notCurrent, 'verdict.repositoryId'); + } + if (readOwnProperty(verdictRecord, 'commitSha') !== snapshot.boundCommitSha) { + append(notCurrent, 'verdict.commitSha'); + } if (readOwnProperty(verdictRecord, 'targetRepositoryId') !== snapshot.repositoryId) { append(notCurrent, 'verdict.targetRepositoryId'); } @@ -1260,6 +1271,12 @@ function applyHumanGateOpened( snapshot: WorkflowSnapshot, eventRecord: object, ): TransitionResult { + if (snapshot.status === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION) { + return rejected(original, TRANSITION_REJECTION.HUMAN_GATE_ALREADY_OPEN, [ + 'workflow.status', + ]); + } + const atCommitSha = readExactIdentifier(readOwnProperty(eventRecord, 'atCommitSha')); if (atCommitSha === null) { return rejected(original, TRANSITION_REJECTION.EVENT_PAYLOAD_INVALID, [ @@ -1267,12 +1284,6 @@ function applyHumanGateOpened( ]); } - if (snapshot.status === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION) { - return rejected(original, TRANSITION_REJECTION.HUMAN_GATE_ALREADY_OPEN, [ - 'workflow.status', - ]); - } - if (atCommitSha !== snapshot.boundCommitSha) { return rejected(original, TRANSITION_REJECTION.BINDING_MISMATCH, ['event.atCommitSha']); } diff --git a/tests/domain/workflow-transitions.test.ts b/tests/domain/workflow-transitions.test.ts index e7bfeae..4660702 100644 --- a/tests/domain/workflow-transitions.test.ts +++ b/tests/domain/workflow-transitions.test.ts @@ -354,6 +354,18 @@ describe('applyWorkflowEvent — group C, invocation lifecycle', () => { expect(result.rejection).toBe('DUPLICATE_INVOCATION_ID'); }); + it('rejects a deserialized state with duplicate tracked invocation ids', () => { + const state = withRequestedInvocation(); + const duplicateState = { + ...state, + invocations: [state.invocations[0], state.invocations[0]], + } as WorkflowState; + const result = applyWorkflowEvent(duplicateState, reportInvocation()); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(duplicateState); + }); + it('refuses a report for an invocation it never requested', () => { const result = applyWorkflowEvent( openedWorkflow(), @@ -604,6 +616,36 @@ describe('applyWorkflowEvent — group E, evidence admission and A2', () => { expect(result.invalidFields).toEqual(['verdict.targetRepositoryId']); }); + it.each([ + ['repository', { repositoryId: REPO_B }, ['verdict.repositoryId']], + ['commit', { commitSha: SHA_B }, ['verdict.commitSha']], + [ + 'repository and commit', + { repositoryId: REPO_B, commitSha: SHA_B }, + ['verdict.repositoryId', 'verdict.commitSha'], + ], + ] as const)('refuses CURRENT evidence whose own %s binding is stale', (_name, overrides, fields) => { + const result = applyWorkflowEvent( + openedWorkflow(), + admitEvidence(buildVerdict(overrides)), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(fields); + }); + + it('does not let a cross-repository human decision clear an open gate', () => { + const state = awaitingHuman(); + const result = applyWorkflowEvent( + state, + admitEvidence(buildHumanDecisionVerdict({ repositoryId: REPO_B })), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.state).toBe(state); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + }); + it.each(MALFORMED_VALUES)('refuses a verdict whose evidenceId is %s', (_name, value) => { const result = applyWorkflowEvent( openedWorkflow(), @@ -673,7 +715,9 @@ describe('applyWorkflowEvent — group E, evidence admission and A2', () => { const moved = applyOrThrow(admitted, observeHead(SHA_B)); const later = applyOrThrow( moved, - admitEvidence(buildVerdict({ evidenceId: EVIDENCE_B, targetHeadSha: SHA_B })), + admitEvidence( + buildVerdict({ evidenceId: EVIDENCE_B, commitSha: SHA_B, targetHeadSha: SHA_B }), + ), ); expect(later.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); @@ -1003,6 +1047,21 @@ describe('applyWorkflowEvent — group G, binding integrity', () => { expect(result.invalidFields).toEqual(['event.atCommitSha']); }); + it('rejects an already-open human gate before reading atCommitSha', () => { + const event = { kind: 'HUMAN_GATE_OPENED' } as Record; + Object.defineProperty(event, 'atCommitSha', { + enumerable: true, + get() { + throw new Error('must not be read'); + }, + }); + + const result = applyWorkflowEvent(awaitingHuman(), event as never); + + expect(result.rejection).toBe('HUMAN_GATE_ALREADY_OPEN'); + expect(result.invalidFields).toEqual(['workflow.status']); + }); + it('retains the gate revision when a workflow closes while awaiting a human', () => { const state = applyOrThrow(awaitingHuman(), closeWorkflow('HUMAN_DECISION_RECORDED')); From a22eb520b96615f832e17ba5eb68d3ed326ee416 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 13:36:39 +0200 Subject: [PATCH 03/20] docs: clarify Autoflow rejection precedence Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/007-autoflow-state-machine.md | 14 +++++++++++--- src/domain/workflow-transitions.ts | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/architecture/007-autoflow-state-machine.md b/docs/architecture/007-autoflow-state-machine.md index 109bd28..d3bc0c2 100644 --- a/docs/architecture/007-autoflow-state-machine.md +++ b/docs/architecture/007-autoflow-state-machine.md @@ -159,9 +159,17 @@ varies: 11. capacity — `CAPACITY_EXCEEDED` 12. apply -`INVOCATION_REPORTED` is the one event whose binding check follows its identity -check, because the commit it compares against comes from the tracked invocation -rather than from the workflow. +Two events depart from that order, deliberately: + +- `INVOCATION_REPORTED` checks binding **after** identity, because the commit it + compares against comes from the tracked invocation rather than from the + workflow. +- `HUMAN_GATE_OPENED` checks status posture **before** its payload, so an + already-open gate returns `HUMAN_GATE_ALREADY_OPEN` without `atCommitSha` being + read at all. A request to open a gate that is already open is answered by the + gate, never by the shape of the payload accompanying it. + +Each order is fixed per event kind, so rejection reasons stay deterministic. ## Revision and sequence diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 741cd83..e5c1daa 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -42,9 +42,17 @@ * 11 capacity CAPACITY_EXCEEDED * 12 apply * - * `INVOCATION_REPORTED` is the one event whose binding check follows its - * identity check, because the SHA it compares against comes from the tracked - * invocation rather than from the workflow. + * Two events depart from that order, deliberately: + * + * - `INVOCATION_REPORTED` checks binding *after* identity, because the SHA it + * compares against comes from the tracked invocation rather than from the + * workflow. + * - `HUMAN_GATE_OPENED` checks status posture *before* its payload, so an + * already-open gate returns `HUMAN_GATE_ALREADY_OPEN` without `atCommitSha` + * being read at all. A request to open a gate that is already open is + * answered by the gate, never by the shape of the payload accompanying it. + * + * Each order is fixed per event kind, so rejection reasons stay deterministic. */ import { EVIDENCE_KIND, EVIDENCE_KINDS } from './evidence.js'; From 56adfa972eb999460d889053a1bed48552a80146 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 14:19:08 +0200 Subject: [PATCH 04/20] Harden workflow state admission readers --- src/domain/workflow-transitions.ts | 15 ++++- tests/domain/workflow-invariants.test.ts | 84 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index e5c1daa..6a524b7 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -206,6 +206,9 @@ function readList(value: unknown, limit: number): readonly unknown[] | null { for (let index = 0; index < length; index += 1) { let element: unknown; try { + if (!objectHasOwn(elements, index)) { + return null; + } element = elements[index]; } catch { return null; @@ -492,7 +495,11 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const evidence: AdmittedEvidence[] = []; for (let index = 0; index < evidenceCandidates.length; index += 1) { const admitted = readAdmittedEvidence(evidenceCandidates[index], revision, sequence); - if (admitted === null) { + if ( + admitted === null || + (admitted.admittedAtRevision === revision && + admitted.admittedAtCommitSha !== boundCommitSha) + ) { return null; } append(evidence, admitted); @@ -501,7 +508,11 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const reviews: AdmittedReview[] = []; for (let index = 0; index < reviewCandidates.length; index += 1) { const admitted = readAdmittedReview(reviewCandidates[index], revision, sequence); - if (admitted === null) { + if ( + admitted === null || + (admitted.admittedAtRevision === revision && + admitted.admittedAtCommitSha !== boundCommitSha) + ) { return null; } append(reviews, admitted); diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index ef89d88..e8db98f 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -511,6 +511,90 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(forged, admitReview()).rejection).toBe('WORKFLOW_UNREADABLE'); }); + + it.each(['invocations', 'evidence', 'reviews'] as const)( + 'rejects a prototype-planted numeric entry in %s without making it durable', + (listName) => { + const invocationState = requested(); + const evidenceState = applyOrThrow(openedWorkflow(), admitEvidence()); + const reviewState = applyOrThrow(openedWorkflow(), admitReview()); + const planted = + listName === 'invocations' + ? invocationState.invocations[0] + : listName === 'evidence' + ? evidenceState.evidence[0] + : reviewState.reviews[0]; + expect(planted).toBeDefined(); + + const sparse = new Array(1); + const forged = { + ...openedWorkflow(), + sequence: 1, + [listName]: sparse, + } as unknown as WorkflowState; + const event = + listName === 'invocations' + ? reportInvocation() + : requestInvocation(buildInvocation({ invocationId: INVOCATION_B })); + + withPoisoned(Array.prototype, 0, planted, () => { + const result = applyWorkflowEvent(forged, event); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(forged); + expect(Object.hasOwn(sparse, 0)).toBe(false); + }); + }, + ); + + it.each(['evidence', 'reviews'] as const)( + 'requires a current %s admission commit to match while retaining historical commits', + (listName) => { + const admitted = + listName === 'evidence' + ? applyOrThrow(openedWorkflow(), admitEvidence()) + : applyOrThrow(openedWorkflow(), admitReview()); + const admission = listName === 'evidence' ? admitted.evidence[0] : admitted.reviews[0]; + expect(admission).toBeDefined(); + + const mismatched = { + ...admitted, + [listName]: [{ ...admission, admittedAtCommitSha: SHA_B }], + } as unknown as WorkflowState; + const laterEvent = + listName === 'evidence' + ? admitEvidence(buildVerdict({ evidenceId: EVIDENCE_A })) + : admitReview(buildReview({ reviewId: REVIEW_A })); + expect(applyWorkflowEvent(mismatched, laterEvent).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + + const matching = { + ...admitted, + [listName]: [{ ...admission, admittedAtCommitSha: SHA_A }], + } as unknown as WorkflowState; + expect( + applyWorkflowEvent( + matching, + requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + ).outcome, + ).toBe('APPLIED'); + + const historical = applyOrThrow(admitted, observeHead(SHA_B)); + expect( + applyWorkflowEvent( + historical, + requestInvocation(buildInvocation({ + invocationId: INVOCATION_B, + targetCommitSha: SHA_B, + })), + ).outcome, + ).toBe('APPLIED'); + expect( + (listName === 'evidence' ? historical.evidence[0] : historical.reviews[0]) + ?.admittedAtCommitSha, + ).toBe(SHA_A); + }, + ); }); describe('group K — bounds', () => { From f570ec67e2fc96519125c1a63b495737c8313dbd Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 14:56:01 +0200 Subject: [PATCH 05/20] fix: enforce current invocation commit binding Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 14 ++- tests/domain/workflow-invariants.test.ts | 125 +++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 6a524b7..d727e47 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -400,7 +400,13 @@ function readAdmittedReview( * `revision` — a HEAD advance clears the gate, so a gate can never outlive * the revision it was opened at; * - `AWAITING_HUMAN_DECISION` always carries an open gate; - * - every recorded revision and sequence is within the workflow's own. + * - every recorded revision and sequence is within the workflow's own; + * - **everything stamped at the current revision is bound to the current + * commit**, symmetrically across all three collections: a tracked invocation + * whose `requestedAtRevision` equals `revision` targets `boundCommitSha`, and + * an evidence or review admission whose `admittedAtRevision` equals + * `revision` was admitted at `boundCommitSha`. Entries from earlier revisions + * keep their own historical commit and are never rewritten. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); @@ -481,7 +487,11 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const invocations: TrackedInvocation[] = []; for (let index = 0; index < invocationCandidates.length; index += 1) { const tracked = readTrackedInvocation(invocationCandidates[index], revision, sequence); - if (tracked === null) { + if ( + tracked === null || + (tracked.requestedAtRevision === revision && + tracked.targetCommitSha !== boundCommitSha) + ) { return null; } for (let priorIndex = 0; priorIndex < invocations.length; priorIndex += 1) { diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index e8db98f..768932d 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -56,6 +56,7 @@ import { revokedProxy, SHA_A, SHA_B, + SHA_C, withRawInvocationField, withRawReportField, withThrowingGetter, @@ -595,6 +596,130 @@ describe('group J — hostile input fails closed', () => { ).toBe(SHA_A); }, ); + + /** + * A tracked invocation stamped at the current revision must target the + * current bound commit, symmetrically with the two admission collections. + * + * Every state this layer produces satisfies it by construction — + * `INVOCATION_REQUESTED` requires `targetCommitSha === boundCommitSha`, and + * `revision` only moves through `HEAD_OBSERVED`. A deserialized or forged + * aggregate that violates it would let `INVOCATION_REPORTED` record a report + * against a commit the workflow was never bound to at that revision, because + * that comparison correctly uses the tracked invocation's own commit. + */ + const forgedCurrentInvocation = (): WorkflowState => { + const base = requested(); + const tracked = base.invocations[0]; + return { + ...base, + invocations: [{ ...tracked, targetCommitSha: SHA_C }], + } as unknown as WorkflowState; + }; + + it('refuses a current-revision invocation that targets a foreign commit', () => { + const forged = forgedCurrentInvocation(); + const tracked = forged.invocations[0]; + + expect(tracked?.requestedAtRevision).toBe(forged.revision); + expect(tracked?.targetCommitSha).not.toBe(forged.boundCommitSha); + expect(applyWorkflowEvent(forged, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('refuses that forged state for every event kind, failing closed each time', () => { + const forged = forgedCurrentInvocation(); + + for (const [name, event] of everyEvent()) { + const result = applyWorkflowEvent(forged, event); + + expect(result.rejection, name).toBe('WORKFLOW_UNREADABLE'); + expect(result.outcome, name).toBe('REJECTED'); + expect(result.state, name).toBe(forged); + } + expect(applyWorkflowEvent(forged, { kind: 'NOPE' } as never).state).toBe(forged); + }); + + it('refuses a report bound to the forged foreign commit', () => { + const forged = forgedCurrentInvocation(); + const result = applyWorkflowEvent( + forged, + reportInvocation(buildReport({ invocationId: INVOCATION_A, targetCommitSha: SHA_C })), + ); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(forged); + expect(result.state.invocations[0]?.state).toBe('REQUESTED'); + }); + + it('keeps a legitimate current-revision invocation valid', () => { + const legitimate = requested(); + + expect(legitimate.invocations[0]?.requestedAtRevision).toBe(legitimate.revision); + expect(legitimate.invocations[0]?.targetCommitSha).toBe(legitimate.boundCommitSha); + expect(applyWorkflowEvent(legitimate, reportInvocation()).outcome).toBe('APPLIED'); + expect(applyWorkflowEvent(legitimate, admitEvidence()).outcome).toBe('APPLIED'); + }); + + it('lets a historical invocation keep its own commit after HEAD advances', () => { + const moved = applyOrThrow(requested(), observeHead(SHA_B)); + const tracked = moved.invocations[0]; + + // Not rewritten to the new bound commit, and still below the new revision. + expect(tracked?.targetCommitSha).toBe(SHA_A); + expect(tracked?.requestedAtRevision).toBe(0); + expect(tracked?.state).toBe('REQUESTED'); + expect(moved.boundCommitSha).toBe(SHA_B); + expect(moved.revision).toBe(1); + + // The aggregate stays readable, and the historical report still applies + // against the invocation's own commit rather than the new HEAD. + expect(applyWorkflowEvent(moved, reportInvocation()).outcome).toBe('APPLIED'); + expect( + applyWorkflowEvent( + moved, + requestInvocation( + buildInvocation({ invocationId: INVOCATION_B, targetCommitSha: SHA_B }), + ), + ).outcome, + ).toBe('APPLIED'); + expect(applyOrThrow(moved, reportInvocation()).invocations[0]?.targetCommitSha).toBe(SHA_A); + }); + + it('binds all three collections to the current commit symmetrically', () => { + const base = requested(); + const withAdmissions = applyOrThrow(applyOrThrow(base, admitEvidence()), admitReview()); + const tracked = withAdmissions.invocations[0]; + const evidence = withAdmissions.evidence[0]; + const review = withAdmissions.reviews[0]; + + // The invariant holds for a legitimately built aggregate. + expect(tracked?.targetCommitSha).toBe(withAdmissions.boundCommitSha); + expect(evidence?.admittedAtCommitSha).toBe(withAdmissions.boundCommitSha); + expect(review?.admittedAtCommitSha).toBe(withAdmissions.boundCommitSha); + + // Breaking it in any one collection makes the whole aggregate unreadable. + const forgeries: readonly WorkflowState[] = [ + { + ...withAdmissions, + invocations: [{ ...tracked, targetCommitSha: SHA_C }], + } as unknown as WorkflowState, + { + ...withAdmissions, + evidence: [{ ...evidence, admittedAtCommitSha: SHA_C }], + } as unknown as WorkflowState, + { + ...withAdmissions, + reviews: [{ ...review, admittedAtCommitSha: SHA_C }], + } as unknown as WorkflowState, + ]; + + for (const forged of forgeries) { + const result = applyWorkflowEvent(forged, admitEvidence(buildVerdict({ evidenceId: 'ev-x' }))); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(forged); + } + }); }); describe('group K — bounds', () => { From e1d80f032dad42c91c0fecdb40e30f51481d01ee Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 18:04:01 +0200 Subject: [PATCH 06/20] fix: complete workflow snapshot and verdict validation Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../007-autoflow-state-machine.md | 20 +- src/domain/workflow-transitions.ts | 160 +++++++++- tests/domain/workflow-invariants.test.ts | 273 +++++++++++++++++- 4 files changed, 446 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 63dbc3b..ffb3e92 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ PR 005 adds review ingestion: untrusted reviewer output is normalized into commi PR 006 adds the provider-neutral agent invocation boundary: a commit-bound record of what AgentBridge asked which agent to do, and normalization of what that agent reported back. An agent's report is an untrusted **claim** — PR 006 records that an artifact was claimed, never that it exists remotely, is integrated, is validated, or is authorized. Provider identity is inert: it never implies a role or grants authority, and roles remain configurable. The layer invokes nothing, models no lifecycle transitions, and performs no I/O. See `docs/architecture/006-agent-invocation-boundary.md`. -PR 007 adds the Autoflow state machine: the smallest durable orchestration state model that binds a unit of work to an exact repository, pull request, and commit, and records — in order — what was requested and what was independently established. It consumes only the outputs of PR 004, 005, and 006; it never normalizes agent output, never judges freshness, and never grants authority. Provider identity, invocation purpose, and agent-reported status are inert: no transition's legality depends on any of them, and a claim can never become an observation. State is an immutable value produced by a pure, total transition function, and the layer performs no I/O. See `docs/architecture/007-autoflow-state-machine.md`. +PR 007 adds the Autoflow state machine: the smallest durable orchestration state model that binds a unit of work to an exact repository and commit, with an optional pull request, and records — in order — what was requested and what was independently established. It consumes only the outputs of PR 004, 005, and 006; it never normalizes agent output, never judges freshness, and never grants authority. Provider identity, invocation purpose, and agent-reported status are inert: no transition's legality depends on any of them, and a claim can never become an observation. State is an immutable value produced by a pure, total transition function, and the layer performs no I/O. See `docs/architecture/007-autoflow-state-machine.md`. ## Runtime diff --git a/docs/architecture/007-autoflow-state-machine.md b/docs/architecture/007-autoflow-state-machine.md index d3bc0c2..7918c2a 100644 --- a/docs/architecture/007-autoflow-state-machine.md +++ b/docs/architecture/007-autoflow-state-machine.md @@ -241,19 +241,29 @@ all-or-nothing, and `BINDING_MISMATCH` on the report and review paths. ### Evidence — the anti-duplication mechanism `EVIDENCE_ADMITTED` takes a PR 004 `EvidenceFreshness`, not an `EvidenceRecord`. -Freshness is never re-derived here; PR 004 already answered the question, and its -result carries the target it was answered against. The only thing left to check -is that the answer is about *this* workflow's binding: +**Freshness is never re-derived here**: PR 004 alone determines whether evidence +is current, and its result carries the target it was answered against. What is +left to this layer is defensive — verifying that the result it consumes is about +*this* workflow's binding, and that it has a shape PR 004 could legitimately +have produced. A verdict admitted as `CURRENT` must satisfy all of: - `state` is `CURRENT` and `reason` is `BOUND_TO_CURRENT_HEAD`; - the evidence's own `repositoryId` equals the workflow's repository; - the evidence's own `commitSha` equals the bound commit; - `targetRepositoryId` equals the workflow's repository; -- `targetHeadSha` equals the bound commit. +- `targetHeadSha` equals the bound commit; +- `source` is a member of PR 004's evidence-source vocabulary; +- `invalidFields` is a genuinely empty list. + +The last two are shape checks, not freshness judgments: PR 004 never emits a +`CURRENT` verdict with a missing source or a populated invalid-field list, so a +value carrying either is one PR 004 could not have produced. Checking them stops +a cast or hand-built object from reaching the human-gate path on the strength of +a few matching fields — trusting the *type* is not trusting the *value*. A caller therefore cannot launder stale evidence by judging it against a convenient target and handing over the verdict. **No change to PR 004 was -required**: its result shape already carries everything this check needs. +required**: its result shape already carries everything these checks need. Admissions are unique per `(id, revision)`. The same evidence can legitimately be current again at a later revision, and is then a fresh admission. diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index d727e47..3252f7a 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -55,7 +55,7 @@ * Each order is fixed per event kind, so rejection reasons stay deterministic. */ -import { EVIDENCE_KIND, EVIDENCE_KINDS } from './evidence.js'; +import { EVIDENCE_KIND, EVIDENCE_KINDS, EVIDENCE_SOURCES } from './evidence.js'; import { FRESHNESS, FRESHNESS_REASON } from './evidence-freshness.js'; import { INGESTION_OUTCOME } from './review.js'; import { @@ -171,6 +171,66 @@ function readOptionalOwn(target: object, key: string): { } } +/** + * Is this value a genuinely empty list? + * + * Used to check the `invalidFields` of a verdict claiming `CURRENT`. PR 004 + * only ever emits an empty list alongside `CURRENT`, so a populated, + * non-array, or unreadable value marks a verdict that PR 004 could not have + * produced. Every read is guarded, and the length is narrowed through + * {@link readCount}, so a Proxy reporting a hostile length fails closed. + */ +function isEmptyList(value: unknown): boolean { + let isArray = false; + try { + isArray = arrayIsArray(value); + } catch { + return false; + } + if (!isArray) { + return false; + } + let rawLength: unknown; + try { + rawLength = (value as readonly unknown[]).length; + } catch { + return false; + } + return readCount(rawLength, 0) === 0; +} + +/** + * Record one revision-to-commit binding, reporting a contradiction. + * + * No workflow history can bind a single revision to two commits: every entry + * stamped at revision R was created while `boundCommitSha` held one value. The + * mapping is derived **transiently** during validation from the per-entry + * commits the model already retains — nothing is stored, no field is added, + * and no revision-to-commit ledger is introduced. + * + * Parallel indexed arrays are used rather than a `Map`, so no prototype method + * is on the path, matching the accumulation strategy used elsewhere here. + * + * This says nothing about whether a historical commit is *genuine*; that stays + * recoverable only through external reconciliation against the retained + * per-entry commit. Only internal contradiction is detectable here. + */ +function bindRevisionCommit( + revisions: number[], + commits: string[], + revision: number, + commit: string, +): boolean { + for (let index = 0; index < revisions.length; index += 1) { + if (revisions[index] === revision) { + return commits[index] === commit; + } + } + append(revisions, revision); + append(commits, commit); + return true; +} + /** * Materialise an untrusted list with guarded reads and a hard length cap. * @@ -406,7 +466,15 @@ function readAdmittedReview( * whose `requestedAtRevision` equals `revision` targets `boundCommitSha`, and * an evidence or review admission whose `admittedAtRevision` equals * `revision` was admitted at `boundCommitSha`. Entries from earlier revisions - * keep their own historical commit and are never rewritten. + * keep their own historical commit and are never rewritten; + * - every **represented** revision maps to exactly one commit across all three + * collections, because every entry stamped at revision R was created while + * `boundCommitSha` held one value. Derived transiently here; nothing is + * stored. Whether a historical commit is *genuine* stays recoverable only by + * external reconciliation, and revisions with no entries are unconstrained; + * - `revision <= sequence`, since every revision advance is itself an applied + * transition; + * - no admission identity — the value pair (id, revision) — appears twice. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); @@ -438,6 +506,14 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { return null; } + // Both counters start at 0, every revision advance happens inside an applied + // `HEAD_OBSERVED` that also advances the sequence, and other events advance + // the sequence alone. `revision > sequence` is therefore unreachable, and an + // aggregate claiming it is not one this layer could have produced. + if (revision > sequence) { + return null; + } + const pullRequestId = rawPullRequestId === null ? null : readExactIdentifier(rawPullRequestId); if (rawPullRequestId !== null && pullRequestId === null) { @@ -512,6 +588,19 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { ) { return null; } + // Admission identity is the value pair (id, revision), exactly as the + // admission handlers enforce it. A duplicate already present in state + // would consume capacity and shadow a legitimate admission of the same id. + for (let priorIndex = 0; priorIndex < evidence.length; priorIndex += 1) { + const prior = evidence[priorIndex]; + if ( + prior !== undefined && + prior.evidenceId === admitted.evidenceId && + prior.admittedAtRevision === admitted.admittedAtRevision + ) { + return null; + } + } append(evidence, admitted); } @@ -525,9 +614,66 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { ) { return null; } + for (let priorIndex = 0; priorIndex < reviews.length; priorIndex += 1) { + const prior = reviews[priorIndex]; + if ( + prior !== undefined && + prior.reviewId === admitted.reviewId && + prior.admittedAtRevision === admitted.admittedAtRevision + ) { + return null; + } + } append(reviews, admitted); } + // Every represented revision must map to exactly one commit across all three + // collections. Derived transiently from entries already validated above. + const seenRevisions: number[] = []; + const seenCommits: string[] = []; + for (let index = 0; index < invocations.length; index += 1) { + const tracked = invocations[index]; + if ( + tracked === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + tracked.requestedAtRevision, + tracked.targetCommitSha, + ) + ) { + return null; + } + } + for (let index = 0; index < evidence.length; index += 1) { + const admitted = evidence[index]; + if ( + admitted === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + admitted.admittedAtRevision, + admitted.admittedAtCommitSha, + ) + ) { + return null; + } + } + for (let index = 0; index < reviews.length; index += 1) { + const admitted = reviews[index]; + if ( + admitted === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + admitted.admittedAtRevision, + admitted.admittedAtCommitSha, + ) + ) { + return null; + } + } + return { workflowId, repositoryId, @@ -1181,6 +1327,16 @@ function applyEvidenceAdmitted( if (readOwnProperty(verdictRecord, 'targetHeadSha') !== snapshot.boundCommitSha) { append(notCurrent, 'verdict.targetHeadSha'); } + // A verdict is trusted for its *type*, never for its *value*. PR 004 emits a + // valid source and an empty invalid-field list alongside every `CURRENT` + // verdict, so a value missing either is one PR 004 could not have produced — + // and an impossible verdict must never reach the human-gate path below. + if (!isVocabularyMember(EVIDENCE_SOURCES, readOwnProperty(verdictRecord, 'source'))) { + append(notCurrent, 'verdict.source'); + } + if (!isEmptyList(readOwnProperty(verdictRecord, 'invalidFields'))) { + append(notCurrent, 'verdict.invalidFields'); + } if (notCurrent.length > 0) { return rejected(original, TRANSITION_REJECTION.EVIDENCE_NOT_CURRENT, notCurrent); } diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 768932d..6d001de 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -14,6 +14,7 @@ import { describe, expect, it } from 'vitest'; import { applyWorkflowEvent, + evaluateEvidenceFreshness, INVOCATION_BOUNDS, openWorkflow, REVIEW_BOUNDS, @@ -38,6 +39,7 @@ import { buildVerdict, closeWorkflow, EVIDENCE_A, + EVIDENCE_B, FORBIDDEN_STATE_KEYS, FORBIDDEN_STATE_VALUES, INVOCATION_A, @@ -720,6 +722,272 @@ describe('group J — hostile input fails closed', () => { expect(result.state).toBe(forged); } }); + + /* ---- finding 1: a CURRENT verdict must carry a complete PR 004 shape ---- */ + + it.each([ + ['an absent source', { source: undefined }], + ['a null source', { source: null }], + ['a bogus source', { source: 'not-a-source' }], + ['a numeric source', { source: 7 }], + ['a non-empty invalidFields', { invalidFields: ['commitSha'] }], + ['a non-array invalidFields', { invalidFields: 'none' }], + ['a null invalidFields', { invalidFields: null }], + ['an absent invalidFields', { invalidFields: undefined }], + ])('refuses a CURRENT human-decision verdict with %s', (_name, overrides) => { + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + const result = applyWorkflowEvent( + gated, + admitEvidence(buildHumanDecisionVerdict(overrides as never)), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.state).toBe(gated); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + expect(result.state.humanGateOpenedAtRevision).toBe(0); + }); + + it('refuses a CURRENT verdict whose invalidFields is prototype-backed or hostile', () => { + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + const inherited = Object.create([]) as unknown[]; + const throwingLength = new Proxy([], { + get(target, key): unknown { + if (key === 'length') { + throw new Error('hostile length'); + } + return Reflect.get(target, key); + }, + }); + const lyingLength = new Proxy([], { + get(target, key): unknown { + if (key === 'length') { + return Number.MAX_SAFE_INTEGER; + } + return Reflect.get(target, key); + }, + }); + + for (const invalidFields of [inherited, throwingLength, lyingLength, revokedProxy()]) { + const result = applyWorkflowEvent( + gated, + admitEvidence(buildHumanDecisionVerdict({ invalidFields } as never)), + ); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + } + }); + + it('still admits a genuine PR 004 verdict and still clears the gate', () => { + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + const genuine = evaluateEvidenceFreshness( + { + evidenceId: EVIDENCE_A, + repositoryId: REPO_A, + commitSha: SHA_A, + kind: 'human-decision', + source: 'human', + reference: 'decision-1', + observedAt: '2026-01-01T00:00:00.000Z', + }, + { repositoryId: REPO_A, currentHeadSha: SHA_A }, + ); + + expect(genuine.state).toBe('CURRENT'); + expect(genuine.source).toBe('human'); + expect(genuine.invalidFields).toEqual([]); + + const result = applyWorkflowEvent(gated, admitEvidence(genuine)); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state.status).toBe('OPEN'); + expect(result.state.humanGateOpenedAtRevision).toBeNull(); + }); + + /* ---- finding 2: one revision maps to exactly one commit ---- */ + + /** A workflow with an invocation, an evidence and a review all at revision 0. */ + function historicalAggregate(): WorkflowState { + const populated = applyOrThrow( + applyOrThrow(applyOrThrow(openedWorkflow(), requestInvocation()), admitEvidence()), + admitReview(), + ); + return applyOrThrow(populated, observeHead(SHA_B)); + } + + it.each([ + ['within invocations', (s: WorkflowState) => ({ + ...s, + invocations: [ + { ...s.invocations[0], targetCommitSha: SHA_C }, + { ...s.invocations[0], invocationId: INVOCATION_B, targetCommitSha: SHA_A }, + ], + })], + ['within evidence', (s: WorkflowState) => ({ + ...s, + evidence: [ + s.evidence[0], + { ...s.evidence[0], evidenceId: 'ev-other', admittedAtCommitSha: SHA_C }, + ], + })], + ['within reviews', (s: WorkflowState) => ({ + ...s, + reviews: [ + s.reviews[0], + { ...s.reviews[0], reviewId: 'rv-other', admittedAtCommitSha: SHA_C }, + ], + })], + ['across invocations and evidence', (s: WorkflowState) => ({ + ...s, + evidence: [{ ...s.evidence[0], admittedAtCommitSha: SHA_C }], + })], + ['across evidence and reviews', (s: WorkflowState) => ({ + ...s, + reviews: [{ ...s.reviews[0], admittedAtCommitSha: SHA_C }], + })], + ])('refuses a revision bound to two commits %s', (_name, forge) => { + const forged = forge(historicalAggregate()) as unknown as WorkflowState; + + for (const [, event] of everyEvent()) { + const result = applyWorkflowEvent(forged, event); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(forged); + } + }); + + it('accepts a legitimate multi-revision history and preserves its commits', () => { + const historical = historicalAggregate(); + + expect(historical.invocations[0]?.targetCommitSha).toBe(SHA_A); + expect(historical.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(historical.reviews[0]?.admittedAtCommitSha).toBe(SHA_A); + + const next = applyOrThrow( + historical, + admitEvidence(buildVerdict({ evidenceId: 'ev-next', commitSha: SHA_B, targetHeadSha: SHA_B })), + ); + + // Revision 0 keeps commit A; revision 1 records commit B. Different + // revisions may of course differ. + expect(next.evidence[0]?.admittedAtCommitSha).toBe(SHA_A); + expect(next.evidence[1]?.admittedAtCommitSha).toBe(SHA_B); + expect(next.invocations[0]?.targetCommitSha).toBe(SHA_A); + }); + + it('accepts many entries sharing one revision and one commit', () => { + const state = applyOrThrow( + applyOrThrow(applyOrThrow(openedWorkflow(), requestInvocation()), admitEvidence()), + admitReview(), + ); + + expect(applyWorkflowEvent(state, observeHead(SHA_B)).outcome).toBe('APPLIED'); + }); + + /* ---- finding 3: revision may never exceed sequence ---- */ + + it.each([ + [2, 0], + [1, 0], + [5, 4], + [WORKFLOW_BOUNDS.MAX_REVISION, 0], + ])('refuses a state with revision %i and sequence %i', (revision, sequence) => { + const forged = { ...openedWorkflow(), revision, sequence } as WorkflowState; + + for (const [, event] of everyEvent()) { + expect(applyWorkflowEvent(forged, event).rejection).toBe('WORKFLOW_UNREADABLE'); + } + }); + + it.each([ + [0, 0], + [1, 1], + [1, 5], + ])('accepts an otherwise valid state with revision %i and sequence %i', (revision, sequence) => { + const forged = { ...openedWorkflow(), revision, sequence } as WorkflowState; + + expect(applyWorkflowEvent(forged, admitEvidence(buildVerdict())).outcome).toBe('APPLIED'); + }); + + it('leaves legitimate histories unaffected by the counter invariant', () => { + let state = openedWorkflow(); + for (const event of [requestInvocation(), admitEvidence(), admitReview(), observeHead(SHA_B)]) { + state = applyOrThrow(state, event); + expect(state.revision).toBeLessThanOrEqual(state.sequence); + } + }); + + /* ---- finding 4: duplicate admission identities in deserialized state ---- */ + + it('refuses a duplicate evidence admission identity already in state', () => { + const admitted = applyOrThrow(openedWorkflow(), admitEvidence()); + const entry = admitted.evidence[0]; + const forged = { + ...admitted, + sequence: 2, + // A copied object with the same value identity — not the same reference. + evidence: [entry, { ...entry, admittedAtSequence: 2 }], + } as unknown as WorkflowState; + + for (const [, event] of everyEvent()) { + expect(applyWorkflowEvent(forged, event).rejection).toBe('WORKFLOW_UNREADABLE'); + } + expect(applyWorkflowEvent(forged, admitEvidence()).state).toBe(forged); + }); + + it('refuses a duplicate review admission identity already in state', () => { + const admitted = applyOrThrow(openedWorkflow(), admitReview()); + const entry = admitted.reviews[0]; + const forged = { + ...admitted, + sequence: 2, + reviews: [entry, { ...entry, admittedAtSequence: 2 }], + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(forged, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts distinct admission ids sharing one revision', () => { + const state = applyOrThrow( + applyOrThrow(openedWorkflow(), admitEvidence()), + admitEvidence(buildVerdict({ evidenceId: EVIDENCE_B })), + ); + + expect(state.evidence).toHaveLength(2); + expect(applyWorkflowEvent(state, admitReview()).outcome).toBe('APPLIED'); + }); + + it('still allows the same admission id at a different revision', () => { + const admitted = applyOrThrow(openedWorkflow(), admitEvidence()); + const moved = applyOrThrow(admitted, observeHead(SHA_B)); + const readmitted = applyOrThrow( + moved, + admitEvidence(buildVerdict({ commitSha: SHA_B, targetHeadSha: SHA_B })), + ); + + expect(readmitted.evidence).toHaveLength(2); + expect(readmitted.evidence[0]?.admittedAtRevision).toBe(0); + expect(readmitted.evidence[1]?.admittedAtRevision).toBe(1); + expect(readmitted.evidence[0]?.evidenceId).toBe(readmitted.evidence[1]?.evidenceId); + }); + + it('cannot have duplicate detection bypassed through a hostile prototype', () => { + const admitted = applyOrThrow(openedWorkflow(), admitEvidence()); + const entry = admitted.evidence[0]; + const forged = { + ...admitted, + sequence: 2, + evidence: [entry, { ...entry, admittedAtSequence: 2 }], + } as unknown as WorkflowState; + let observed: unknown; + + withPoisoned(Array.prototype, 'includes', () => false, () => { + withPoisoned(Array.prototype, 'indexOf', () => -1, () => { + observed = applyWorkflowEvent(forged, admitReview()).rejection; + }); + }); + + expect(observed).toBe('WORKFLOW_UNREADABLE'); + }); }); describe('group K — bounds', () => { @@ -832,10 +1100,13 @@ describe('group K — bounds', () => { }); it('refuses to advance past the revision bound', () => { + // `sequence` must be at least `revision`: every revision advance is itself + // an applied transition, so a state at the revision bound has advanced the + // sequence at least as far. const state = { ...openedWorkflow(), revision: WORKFLOW_BOUNDS.MAX_REVISION, - sequence: 1, + sequence: WORKFLOW_BOUNDS.MAX_REVISION, } as WorkflowState; expect(applyWorkflowEvent(state, observeHead(SHA_B)).rejection).toBe('CAPACITY_EXCEEDED'); From 2bd3191ecaf5d91fb89d16da1a0a907b2f92c438 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 22:07:58 +0200 Subject: [PATCH 07/20] fix: harden workflow cardinality and temporal invariants Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 125 +++++- tests/domain/workflow-fixtures.ts | 5 +- tests/domain/workflow-invariants.test.ts | 487 ++++++++++++++++++++++- 3 files changed, 594 insertions(+), 23 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 3252f7a..d042a11 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -104,6 +104,8 @@ import { const objectFreeze = Object.freeze; const objectHasOwn = Object.hasOwn; const arrayIsArray = Array.isArray; +const reflectIsExtensible = Reflect.isExtensible; +const reflectOwnKeys = Reflect.ownKeys; /** Shared frozen empty list, so an empty result is byte-identical every time. */ const NO_FIELDS: readonly string[] = objectFreeze([]); @@ -172,15 +174,33 @@ function readOptionalOwn(target: object, key: string): { } /** - * Is this value a genuinely empty list? + * Is this value **provably** an empty list under the hostile-runtime model? * * Used to check the `invalidFields` of a verdict claiming `CURRENT`. PR 004 * only ever emits an empty list alongside `CURRENT`, so a populated, - * non-array, or unreadable value marks a verdict that PR 004 could not have - * produced. Every read is guarded, and the length is narrowed through - * {@link readCount}, so a Proxy reporting a hostile length fails closed. + * non-array, or unprovable value marks a verdict PR 004 could not have + * produced. + * + * **Reading `length` is not proof.** A Proxy over an *extensible* array can + * report `length` as `0` while the target holds entries, and can lie just as + * consistently through `ownKeys`, `getOwnPropertyDescriptor`, and `hasOwn` — + * no amount of reflection can contradict it. Emptiness is therefore proved the + * only way the engine underwrites: + * + * 1. it is an array (`Array.isArray` pierces a Proxy to its target); + * 2. it is **non-extensible** — the `isExtensible` trap is required to agree + * with the target, so this cannot be faked; + * 3. its own keys are exactly `['length']` — for a non-extensible target the + * `ownKeys` trap must return exactly the target's own keys, so an element + * cannot be hidden; + * 4. `length` still reads as `0`, as a redundant cross-check. + * + * Step 2 is what turns steps 3 and 4 from assertions into proof. PR 004 freezes + * every result list, so a genuine verdict satisfies this; anything that cannot + * prove it fails closed. Every read is guarded, so a throwing or revoked value + * is rejected rather than raised. */ -function isEmptyList(value: unknown): boolean { +function isProvablyEmptyList(value: unknown): boolean { let isArray = false; try { isArray = arrayIsArray(value); @@ -190,6 +210,27 @@ function isEmptyList(value: unknown): boolean { if (!isArray) { return false; } + + let extensible = true; + try { + extensible = reflectIsExtensible(value as object); + } catch { + return false; + } + if (extensible) { + return false; + } + + let keys: readonly (string | symbol)[]; + try { + keys = reflectOwnKeys(value as object); + } catch { + return false; + } + if (keys.length !== 1 || keys[0] !== 'length') { + return false; + } + let rawLength: unknown; try { rawLength = (value as readonly unknown[]).length; @@ -199,6 +240,30 @@ function isEmptyList(value: unknown): boolean { return readCount(rawLength, 0) === 0; } +/** + * Claim one transition sequence stamp, reporting a collision. + * + * Every applied transition advances `sequence` exactly once, and stamps at most + * one record with the new value: `INVOCATION_REQUESTED` stamps a request, + * `INVOCATION_REPORTED` a report, and the two admission events an admission. + * `HEAD_OBSERVED`, `HUMAN_GATE_OPENED`, and `CLOSE_REQUESTED` advance the + * sequence without stamping anything. **No two retained stamps can therefore + * share a value**, across every collection — some sequence values simply have + * no stamp at all. + * + * Derived transiently during validation; no timeline is stored. Uses an indexed + * scan rather than a `Set`, so no prototype method is on the path. + */ +function claimSequence(seen: number[], stamp: number): boolean { + for (let index = 0; index < seen.length; index += 1) { + if (seen[index] === stamp) { + return false; + } + } + append(seen, stamp); + return true; +} + /** * Record one revision-to-commit binding, reporting a contradiction. * @@ -262,6 +327,27 @@ function readList(value: unknown, limit: number): readonly unknown[] | null { return null; } + // A reported length is not, on its own, a statement about what the list + // holds: a Proxy can report a *smaller* length than the own indices actually + // present, and iterating to that length would silently drop the excess — + // precisely the shortening of orchestration history this layer refuses. + // + // The own-key structure is therefore required to agree with the reported + // cardinality. A genuine array of `n` elements has exactly `n + 1` own keys: + // one per index, plus `length`. Any surplus key — a hidden element past the + // claimed range, a stray property, a symbol — breaks the equality, and any + // missing index is caught by the per-index ownership check below. Together + // they pin the structure exactly rather than trusting either signal alone. + let keys: readonly (string | symbol)[]; + try { + keys = reflectOwnKeys(elements); + } catch { + return null; + } + if (keys.length !== length + 1) { + return null; + } + const materialised: unknown[] = []; for (let index = 0; index < length; index += 1) { let element: unknown; @@ -474,7 +560,9 @@ function readAdmittedReview( * external reconciliation, and revisions with no entries are unconstrained; * - `revision <= sequence`, since every revision advance is itself an applied * transition; - * - no admission identity — the value pair (id, revision) — appears twice. + * - no admission identity — the value pair (id, revision) — appears twice; + * - no two retained transition sequence stamps share a value, since every + * applied transition advances `sequence` once and stamps at most one record. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); @@ -627,10 +715,18 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { append(reviews, admitted); } - // Every represented revision must map to exactly one commit across all three - // collections. Derived transiently from entries already validated above. + // Two aggregate-wide invariants, derived transiently from entries already + // validated above. Nothing is stored. + // + // 1. Every represented revision maps to exactly one commit across all three + // collections, because every entry stamped at revision R was created while + // `boundCommitSha` held one value. + // 2. No two retained transition sequence stamps share a value, because every + // applied transition advances `sequence` once and stamps at most one + // record with the new value. const seenRevisions: number[] = []; const seenCommits: string[] = []; + const seenSequences: number[] = []; for (let index = 0; index < invocations.length; index += 1) { const tracked = invocations[index]; if ( @@ -640,7 +736,10 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { seenCommits, tracked.requestedAtRevision, tracked.targetCommitSha, - ) + ) || + !claimSequence(seenSequences, tracked.requestedAtSequence) || + (tracked.reportedAtSequence !== null && + !claimSequence(seenSequences, tracked.reportedAtSequence)) ) { return null; } @@ -654,7 +753,8 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { seenCommits, admitted.admittedAtRevision, admitted.admittedAtCommitSha, - ) + ) || + !claimSequence(seenSequences, admitted.admittedAtSequence) ) { return null; } @@ -668,7 +768,8 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { seenCommits, admitted.admittedAtRevision, admitted.admittedAtCommitSha, - ) + ) || + !claimSequence(seenSequences, admitted.admittedAtSequence) ) { return null; } @@ -1334,7 +1435,7 @@ function applyEvidenceAdmitted( if (!isVocabularyMember(EVIDENCE_SOURCES, readOwnProperty(verdictRecord, 'source'))) { append(notCurrent, 'verdict.source'); } - if (!isEmptyList(readOwnProperty(verdictRecord, 'invalidFields'))) { + if (!isProvablyEmptyList(readOwnProperty(verdictRecord, 'invalidFields'))) { append(notCurrent, 'verdict.invalidFields'); } if (notCurrent.length > 0) { diff --git a/tests/domain/workflow-fixtures.ts b/tests/domain/workflow-fixtures.ts index e14238f..e502e31 100644 --- a/tests/domain/workflow-fixtures.ts +++ b/tests/domain/workflow-fixtures.ts @@ -217,7 +217,10 @@ const BASE_VERDICT: EvidenceFreshness = { targetHeadSha: SHA_A, state: 'CURRENT', reason: 'BOUND_TO_CURRENT_HEAD', - invalidFields: [], + // Frozen, exactly as PR 004 emits it: `evaluateEvidenceFreshness` freezes + // every result list, and emptiness is only provable for a non-extensible + // value. A plain `[]` here would not be faithful to the real producer. + invalidFields: Object.freeze([]), }; /** A well-formed PR 004 freshness verdict, CURRENT at the bound commit. */ diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 6d001de..c5b2914 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -331,22 +331,26 @@ describe('group J — hostile input fails closed', () => { const invocation = withUnstableGetter(buildInvocation(), 'targetCommitSha', [SHA_A, SHA_B, SHA_B]); const result = applyWorkflowEvent(openedWorkflow(), requestInvocation(invocation)); - if (result.outcome === 'APPLIED') { - expect(result.state.invocations[0]?.targetCommitSha).toBe(SHA_A); - } else { - expect(result.rejection).toBe('BINDING_MISMATCH'); - } + // Each field is read exactly once, so the first observed value is the one + // validated *and* stored. The swapped value must never reach the state — + // asserted unconditionally, so an unrelated rejection cannot satisfy it. + expect(JSON.stringify(result.state)).not.toContain(SHA_B); + expect(result.outcome).toBe('APPLIED'); + expect(result.state.invocations).toHaveLength(1); + expect(result.state.invocations[0]?.targetCommitSha).toBe(SHA_A); }); it('never lets an unstable evidence verdict be admitted under a different id', () => { const verdict = withUnstableGetter(buildVerdict(), 'evidenceId', [EVIDENCE_A, 'ev-forged']); const result = applyWorkflowEvent(openedWorkflow(), admitEvidence(verdict)); - if (result.outcome === 'APPLIED') { - expect(result.state.evidence[0]?.evidenceId).toBe(EVIDENCE_A); - } else { - expect(result.outcome).toBe('REJECTED'); - } + // The forged second value must never appear anywhere in the resulting + // state. Asserted unconditionally: a rejection for an unrelated reason + // cannot satisfy this the way a bare `outcome === 'REJECTED'` would. + expect(JSON.stringify(result.state)).not.toContain('ev-forged'); + expect(result.outcome).toBe('APPLIED'); + expect(result.state.evidence).toHaveLength(1); + expect(result.state.evidence[0]?.evidenceId).toBe(EVIDENCE_A); }); it.each(['outcome', 'state', 'status', 'revision', 'sequence', 'kind', 'boundCommitSha'])( @@ -970,6 +974,469 @@ describe('group J — hostile input fails closed', () => { expect(readmitted.evidence[0]?.evidenceId).toBe(readmitted.evidence[1]?.evidenceId); }); + /* ---- readList: observable cardinality must match own-index structure ---- */ + + /** + * A workflow carrying two entries in each collection, so an under-reported + * length has something to hide. + */ + function populatedAggregate(): WorkflowState { + let state = applyOrThrow(openedWorkflow(), requestInvocation()); + state = applyOrThrow( + state, + requestInvocation(buildInvocation({ invocationId: INVOCATION_B })), + ); + state = applyOrThrow(state, admitEvidence()); + state = applyOrThrow(state, admitEvidence(buildVerdict({ evidenceId: EVIDENCE_B }))); + state = applyOrThrow(state, admitReview()); + return applyOrThrow(state, admitReview(buildReview({ reviewId: 'rv-second' }))); + } + + const COLLECTIONS = ['invocations', 'evidence', 'reviews'] as const; + + /** Swap one collection for a hostile list and apply an unrelated event. */ + function withList( + state: WorkflowState, + collection: (typeof COLLECTIONS)[number], + list: unknown, + ): ReturnType { + const forged = { ...state, [collection]: list } as unknown as WorkflowState; + return applyWorkflowEvent(forged, closeWorkflow()); + } + + it.each(COLLECTIONS)('refuses %s whose proxy under-reports its length', (collection) => { + const state = populatedAggregate(); + const real = [...state[collection]]; + const under = new Proxy(real, { + get: (target, key): unknown => (key === 'length' ? 1 : Reflect.get(target, key)), + }); + + expect(real).toHaveLength(2); + expect(withList(state, collection, under).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s reporting length 0 over a populated list', (collection) => { + const state = populatedAggregate(); + const zero = new Proxy([...state[collection]], { + get: (target, key): unknown => (key === 'length' ? 0 : Reflect.get(target, key)), + }); + + expect(withList(state, collection, zero).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s with own indices beyond the reported length', (collection) => { + const state = populatedAggregate(); + const real = [...state[collection], state[collection][0]]; + + // A genuine Array cannot hold an own index past its own length — lowering + // `length` makes the engine delete the surplus — so the only way to present + // this shape is a Proxy that under-reports while the target keeps them. + const truncated = [...real]; + truncated.length = 1; + expect(Object.hasOwn(truncated, 1)).toBe(false); + + const hiding = new Proxy(real, { + get: (target, key): unknown => (key === 'length' ? 1 : Reflect.get(target, key)), + }); + + expect(real).toHaveLength(3); + expect(Object.hasOwn(hiding, 1)).toBe(true); + expect(Object.hasOwn(hiding, 2)).toBe(true); + expect(withList(state, collection, hiding).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s carrying a stray non-index own property', (collection) => { + const state = populatedAggregate(); + const strayed = [...state[collection]] as unknown[] & { smuggled?: unknown }; + strayed.smuggled = state[collection][0]; + + expect(withList(state, collection, strayed).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s that over-reports its length', (collection) => { + const state = populatedAggregate(); + const over = new Proxy([...state[collection]], { + get: (target, key): unknown => (key === 'length' ? 5 : Reflect.get(target, key)), + }); + + expect(withList(state, collection, over).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s with an unstable length', (collection) => { + const state = populatedAggregate(); + let reads = 0; + const unstable = new Proxy([...state[collection]], { + get: (target, key): unknown => { + if (key === 'length') { + reads += 1; + return reads === 1 ? 2 : 0; + } + return Reflect.get(target, key); + }, + }); + const result = withList(state, collection, unstable); + + // Either the structure check catches the disagreement, or the first read + // stands and every entry is kept — never a silent shortening. + if (result.outcome === 'APPLIED') { + expect(result.state[collection]).toHaveLength(2); + } else { + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + } + }); + + it.each(COLLECTIONS)('refuses %s with a throwing length', (collection) => { + const state = populatedAggregate(); + const throwing = new Proxy([...state[collection]], { + get: (target, key): unknown => { + if (key === 'length') { + throw new Error('hostile length'); + } + return Reflect.get(target, key); + }, + }); + + expect(withList(state, collection, throwing).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s that is a revoked proxy', (collection) => { + expect(withList(populatedAggregate(), collection, revokedProxy()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + }); + + it.each(COLLECTIONS)('refuses %s that is sparse with an inherited numeric entry', (collection) => { + const state = populatedAggregate(); + const planted = state[collection][0]; + const holed: unknown[] = []; + holed.length = 2; + let observed: string | null = null; + + withPoisoned(Array.prototype, 0, planted, () => { + withPoisoned(Array.prototype, 1, planted, () => { + const result = withList(state, collection, holed); + observed = result.rejection; + // The inherited value must never become a durable own entry. + expect(Object.hasOwn(holed, 0)).toBe(false); + expect(JSON.stringify(result.state[collection])).not.toBe('[]'); + }); + }); + + expect(observed).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('accepts %s as an ordinary dense list with an accurate length', (collection) => { + const state = populatedAggregate(); + const dense = [...state[collection]]; + const result = withList(state, collection, dense); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state[collection]).toHaveLength(2); + }); + + it('accepts genuinely empty collections', () => { + const empty = openedWorkflow(); + + expect(empty.invocations).toEqual([]); + expect(applyWorkflowEvent(empty, admitEvidence()).outcome).toBe('APPLIED'); + expect( + applyWorkflowEvent({ ...empty, evidence: [], reviews: [] } as WorkflowState, admitReview()) + .outcome, + ).toBe('APPLIED'); + }); + + it('accepts a JSON round-tripped aggregate, whose lists are plain and dense', () => { + const state = populatedAggregate(); + const restored = JSON.parse(JSON.stringify(state)) as WorkflowState; + + expect(applyWorkflowEvent(restored, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('keeps capacity and duplicate scans working after the cardinality check', () => { + const state = populatedAggregate(); + + // Duplicate detection still fires on a structurally sound list. + const duplicated = { + ...state, + evidence: [state.evidence[0], { ...state.evidence[0], admittedAtSequence: 9 }], + sequence: 20, + } as unknown as WorkflowState; + expect(applyWorkflowEvent(duplicated, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + + // Capacity still rejects rather than truncating. + const full = Array.from({ length: WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE }, (_v, index) => ({ + evidenceId: `ev-${String(index)}`, + kind: 'ci-result' as const, + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: index + 1, + })); + const saturated = { + ...openedWorkflow(), + sequence: WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE, + evidence: full, + } as WorkflowState; + + expect(applyWorkflowEvent(saturated, admitEvidence()).rejection).toBe('CAPACITY_EXCEEDED'); + }); + + /* ---- P1: emptiness of invalidFields must be provable, not reported ---- */ + + /** A gated workflow plus a forged human-decision verdict carrying `invalidFields`. */ + function gateAttack(invalidFields: unknown): { + readonly state: WorkflowState; + readonly result: ReturnType; + } { + const state = applyOrThrow(openedWorkflow(), openHumanGate()); + return { + state, + result: applyWorkflowEvent( + state, + admitEvidence(buildHumanDecisionVerdict({ invalidFields } as never)), + ), + }; + } + + it('refuses a list proxy reporting length 0 over a populated target', () => { + const lying = new Proxy(['commitSha'], { + get: (target, key): unknown => (key === 'length' ? 0 : Reflect.get(target, key)), + }); + const { state, result } = gateAttack(lying); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.invalidFields).toEqual(['verdict.invalidFields']); + expect(result.state).toBe(state); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + }); + + it.each([ + [ + 'a length lie over three elements', + (): unknown => + new Proxy(['a', 'b', 'c'], { + get: (target, key): unknown => (key === 'length' ? 0 : Reflect.get(target, key)), + }), + ], + [ + 'a length lie larger than the target', + (): unknown => + new Proxy([], { + get: (target, key): unknown => (key === 'length' ? 5 : Reflect.get(target, key)), + }), + ], + [ + 'an unstable length', + (): unknown => { + let reads = 0; + return new Proxy(['x'], { + get: (target, key): unknown => { + if (key === 'length') { + reads += 1; + return reads === 1 ? 0 : 9; + } + return Reflect.get(target, key); + }, + }); + }, + ], + [ + 'a throwing length', + (): unknown => + new Proxy(['x'], { + get: (target, key): unknown => { + if (key === 'length') { + throw new Error('hostile length'); + } + return Reflect.get(target, key); + }, + }), + ], + [ + 'an ownKeys trap hiding the element', + (): unknown => + new Proxy(['x'], { + get: (target, key): unknown => (key === 'length' ? 0 : Reflect.get(target, key)), + ownKeys: (): ArrayLike => ['length'], + getOwnPropertyDescriptor: (target, key): PropertyDescriptor | undefined => + key === 'length' + ? { value: 0, writable: true, enumerable: false, configurable: false } + : undefined, + }), + ], + ['an ordinary non-empty list', (): unknown => ['commitSha']], + [ + 'a sparse single-hole list', + (): unknown => { + const holed: unknown[] = []; + holed.length = 1; + return holed; + }, + ], + ['an object inheriting from an array', (): unknown => Object.create([]) as unknown], + ['an array-like plain object', (): unknown => ({ length: 0 })], + ['a revoked proxy', (): unknown => revokedProxy()], + ['an extensible empty array', (): unknown => []], + ])('refuses a CURRENT verdict whose invalidFields is %s', (_name, build) => { + const { state, result } = gateAttack(build()); + + expect(result.rejection).toBe('EVIDENCE_NOT_CURRENT'); + expect(result.state).toBe(state); + expect(result.state.status).toBe('AWAITING_HUMAN_DECISION'); + expect(result.state.evidence).toEqual([]); + }); + + it('accepts a frozen empty list, exactly as PR 004 emits it', () => { + const { result } = gateAttack(Object.freeze([])); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state.status).toBe('OPEN'); + }); + + it('accepts the genuine list a real PR 004 verdict carries', () => { + const genuine = evaluateEvidenceFreshness( + { + evidenceId: EVIDENCE_A, + repositoryId: REPO_A, + commitSha: SHA_A, + kind: 'human-decision', + source: 'human', + reference: 'd1', + observedAt: '2026-01-01T00:00:00.000Z', + }, + { repositoryId: REPO_A, currentHeadSha: SHA_A }, + ); + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + + expect(Object.isFrozen(genuine.invalidFields)).toBe(true); + expect(applyWorkflowEvent(gated, admitEvidence(genuine)).state.status).toBe('OPEN'); + }); + + /* ---- P2: every retained transition sequence stamp is unique ---- */ + + /** A workflow carrying one of every sequence-stamped record. */ + function stampedAggregate(): WorkflowState { + let state = applyOrThrow(openedWorkflow(), requestInvocation()); + state = applyOrThrow(state, admitEvidence()); + state = applyOrThrow(state, admitReview()); + return applyOrThrow(state, reportInvocation()); + } + + it('stamps each applied transition with a distinct sequence', () => { + const state = stampedAggregate(); + + expect(state.sequence).toBe(4); + expect(state.invocations[0]?.requestedAtSequence).toBe(1); + expect(state.evidence[0]?.admittedAtSequence).toBe(2); + expect(state.reviews[0]?.admittedAtSequence).toBe(3); + expect(state.invocations[0]?.reportedAtSequence).toBe(4); + }); + + it.each([ + [ + 'evidence reusing the request stamp', + (s: WorkflowState) => ({ ...s, evidence: [{ ...s.evidence[0], admittedAtSequence: 1 }] }), + ], + [ + 'review reusing the request stamp', + (s: WorkflowState) => ({ ...s, reviews: [{ ...s.reviews[0], admittedAtSequence: 1 }] }), + ], + [ + 'review reusing the evidence stamp', + (s: WorkflowState) => ({ ...s, reviews: [{ ...s.reviews[0], admittedAtSequence: 2 }] }), + ], + [ + 'report reusing the evidence stamp', + (s: WorkflowState) => ({ + ...s, + invocations: [{ ...s.invocations[0], reportedAtSequence: 2 }], + }), + ], + [ + 'report reusing the review stamp', + (s: WorkflowState) => ({ + ...s, + invocations: [{ ...s.invocations[0], reportedAtSequence: 3 }], + }), + ], + [ + 'two evidence admissions sharing a stamp', + (s: WorkflowState) => ({ + ...s, + evidence: [s.evidence[0], { ...s.evidence[0], evidenceId: EVIDENCE_B }], + }), + ], + [ + 'two reviews sharing a stamp', + (s: WorkflowState) => ({ + ...s, + reviews: [s.reviews[0], { ...s.reviews[0], reviewId: 'rv-other' }], + }), + ], + ])('refuses a state where %s', (_name, forge) => { + const forged = forge(stampedAggregate()) as unknown as WorkflowState; + + for (const [, event] of everyEvent()) { + const result = applyWorkflowEvent(forged, event); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.state).toBe(forged); + } + }); + + it('refuses two invocations sharing a request stamp', () => { + const base = stampedAggregate(); + const tracked = base.invocations[0]; + const forged = { + ...base, + invocations: [ + tracked, + { + ...tracked, + invocationId: INVOCATION_B, + state: 'REQUESTED', + reportedStatus: null, + reportedAtRevision: null, + reportedAtSequence: null, + }, + ], + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(forged, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts a legitimate history whose stamps are all distinct', () => { + const state = stampedAggregate(); + const moved = applyOrThrow(state, observeHead(SHA_B)); + + // HEAD_OBSERVED advances the sequence while stamping nothing, so the gap it + // leaves must not be mistaken for a violation. + expect(moved.sequence).toBe(5); + expect(moved.invocations[0]?.requestedAtSequence).toBe(1); + expect(moved.evidence[0]?.admittedAtSequence).toBe(2); + expect( + applyWorkflowEvent( + moved, + admitEvidence( + buildVerdict({ evidenceId: EVIDENCE_B, commitSha: SHA_B, targetHeadSha: SHA_B }), + ), + ).outcome, + ).toBe('APPLIED'); + }); + + it('keeps historical stamps intact across a HEAD advance', () => { + const moved = applyOrThrow(stampedAggregate(), observeHead(SHA_B)); + const later = applyOrThrow( + moved, + admitEvidence( + buildVerdict({ evidenceId: EVIDENCE_B, commitSha: SHA_B, targetHeadSha: SHA_B }), + ), + ); + + expect(later.evidence[0]?.admittedAtSequence).toBe(2); + expect(later.evidence[1]?.admittedAtSequence).toBe(6); + expect(later.invocations[0]?.requestedAtSequence).toBe(1); + expect(later.invocations[0]?.reportedAtSequence).toBe(4); + }); + it('cannot have duplicate detection bypassed through a hostile prototype', () => { const admitted = applyOrThrow(openedWorkflow(), admitEvidence()); const entry = admitted.evidence[0]; From b2c27ec371fce2fce99a7b8790bd004c7b73bec2 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 23:14:15 +0200 Subject: [PATCH 08/20] fix: harden workflow list and temporal invariants Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 47 +++- tests/domain/workflow-invariants.test.ts | 341 ++++++++++++++++++++++- 2 files changed, 374 insertions(+), 14 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index d042a11..2798f42 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -316,6 +316,34 @@ function readList(value: unknown, limit: number): readonly unknown[] | null { return null; } + // **Every content signal a Proxy exposes is one the same Proxy controls.** + // Over an *extensible* target it may report a short `length`, return a + // matching `ownKeys`, and deny the hidden indices through + // `getOwnPropertyDescriptor` — all consistently. Corroborating one trapped + // channel with another proves nothing when one adversary owns both, so a real + // own record could be hidden and then silently deleted from the durable + // snapshot by the next applied transition. + // + // Non-extensibility is the one property the engine refuses to let a Proxy + // fake: the `isExtensible` trap must agree with its target, and once the + // target is non-extensible the `ownKeys` trap must return *exactly* the + // target's own keys. Only then do the structural checks below become proof + // rather than assertion. + // + // Every list this layer emits is frozen by `freezeState`, so a state produced + // here always satisfies this. A caller that rebuilds a state from JSON must + // freeze the three collections before handing it back; an extensible list is + // refused rather than partially trusted. + let extensible = true; + try { + extensible = reflectIsExtensible(elements); + } catch { + return null; + } + if (extensible) { + return null; + } + let rawLength: unknown; try { rawLength = elements.length; @@ -407,6 +435,10 @@ function readTrackedInvocation( requestedAtRevision === null || requestedAtSequence === null || requestedAtSequence < 1 || + // Reaching revision R costs R applied HEAD_OBSERVED transitions, each + // consuming a distinct sequence slot, so the R-th advance sat at slot >= R + // and any record stamped at revision R was created later still. + requestedAtSequence <= requestedAtRevision || !isVocabularyMember(INVOCATION_PURPOSES, rawPurpose) || !isVocabularyMember(INVOCATION_STATES, rawState) ) { @@ -428,7 +460,8 @@ function readTrackedInvocation( reportedAtRevision === null || reportedAtSequence === null || reportedAtRevision < requestedAtRevision || - reportedAtSequence <= requestedAtSequence + reportedAtSequence <= requestedAtSequence || + reportedAtSequence <= reportedAtRevision ) { return null; } @@ -480,6 +513,7 @@ function readAdmittedEvidence( admittedAtRevision === null || admittedAtSequence === null || admittedAtSequence < 1 || + admittedAtSequence <= admittedAtRevision || !isVocabularyMember(EVIDENCE_KINDS, rawKind) ) { return null; @@ -517,7 +551,8 @@ function readAdmittedReview( admittedAtCommitSha === null || admittedAtRevision === null || admittedAtSequence === null || - admittedAtSequence < 1 + admittedAtSequence < 1 || + admittedAtSequence <= admittedAtRevision ) { return null; } @@ -562,7 +597,13 @@ function readAdmittedReview( * transition; * - no admission identity — the value pair (id, revision) — appears twice; * - no two retained transition sequence stamps share a value, since every - * applied transition advances `sequence` once and stamps at most one record. + * applied transition advances `sequence` once and stamps at most one record; + * - every stamped record satisfies `recordedSequence > recordedRevision`: + * reaching revision R costs R applied `HEAD_OBSERVED` transitions, each + * consuming a distinct sequence slot, so the R-th advance sat at slot >= R + * and the record stamped at revision R was created by a later transition + * still. The bound is tight — open, one HEAD advance at slot 1, then a + * request at slot 2 stamps revision 1 with sequence 2. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index c5b2914..f29891d 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -96,6 +96,17 @@ function stripComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); } +/** + * Freeze a collection the way `freezeState` always does. + * + * Every list this layer emits is frozen, and emptiness/cardinality is only + * provable for a non-extensible list, so a synthetic state must be frozen to be + * a faithful stand-in for one the layer produced. + */ +function stored(list: readonly T[]): readonly T[] { + return Object.freeze([...list]); +} + /** Run `body` with one property replaced, restoring it whatever happens. */ function withPoisoned(target: object, key: PropertyKey, value: unknown, body: () => void): void { const original = Object.getOwnPropertyDescriptor(target, key); @@ -565,7 +576,7 @@ describe('group J — hostile input fails closed', () => { const mismatched = { ...admitted, - [listName]: [{ ...admission, admittedAtCommitSha: SHA_B }], + [listName]: stored([{ ...admission, admittedAtCommitSha: SHA_B }]), } as unknown as WorkflowState; const laterEvent = listName === 'evidence' @@ -577,7 +588,7 @@ describe('group J — hostile input fails closed', () => { const matching = { ...admitted, - [listName]: [{ ...admission, admittedAtCommitSha: SHA_A }], + [listName]: stored([{ ...admission, admittedAtCommitSha: SHA_A }]), } as unknown as WorkflowState; expect( applyWorkflowEvent( @@ -1127,7 +1138,7 @@ describe('group J — hostile input fails closed', () => { it.each(COLLECTIONS)('accepts %s as an ordinary dense list with an accurate length', (collection) => { const state = populatedAggregate(); - const dense = [...state[collection]]; + const dense = stored([...state[collection]]); const result = withList(state, collection, dense); expect(result.outcome).toBe('APPLIED'); @@ -1140,16 +1151,33 @@ describe('group J — hostile input fails closed', () => { expect(empty.invocations).toEqual([]); expect(applyWorkflowEvent(empty, admitEvidence()).outcome).toBe('APPLIED'); expect( - applyWorkflowEvent({ ...empty, evidence: [], reviews: [] } as WorkflowState, admitReview()) - .outcome, + applyWorkflowEvent( + { ...empty, evidence: stored([]), reviews: stored([]) } as WorkflowState, + admitReview(), + ).outcome, ).toBe('APPLIED'); }); - it('accepts a JSON round-tripped aggregate, whose lists are plain and dense', () => { + it('requires a JSON round-tripped aggregate to have its collections re-frozen', () => { const state = populatedAggregate(); const restored = JSON.parse(JSON.stringify(state)) as WorkflowState; - expect(applyWorkflowEvent(restored, closeWorkflow()).outcome).toBe('APPLIED'); + // `JSON.parse` yields extensible arrays, and an extensible list cannot be + // proven complete under the hostile-runtime model, so it is refused rather + // than partially trusted. A caller restoring persisted state re-freezes the + // three collections — which is the shape this layer itself always emits. + expect(Object.isFrozen(restored.invocations)).toBe(false); + expect(applyWorkflowEvent(restored, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + + const refrozen = { + ...restored, + invocations: stored(restored.invocations), + evidence: stored(restored.evidence), + reviews: stored(restored.reviews), + } as WorkflowState; + + expect(applyWorkflowEvent(refrozen, closeWorkflow()).outcome).toBe('APPLIED'); + expect(applyWorkflowEvent(refrozen, closeWorkflow()).state.invocations).toHaveLength(2); }); it('keeps capacity and duplicate scans working after the cardinality check', () => { @@ -1158,7 +1186,7 @@ describe('group J — hostile input fails closed', () => { // Duplicate detection still fires on a structurally sound list. const duplicated = { ...state, - evidence: [state.evidence[0], { ...state.evidence[0], admittedAtSequence: 9 }], + evidence: stored([state.evidence[0], { ...state.evidence[0], admittedAtSequence: 9 }]), sequence: 20, } as unknown as WorkflowState; expect(applyWorkflowEvent(duplicated, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); @@ -1174,12 +1202,303 @@ describe('group J — hostile input fails closed', () => { const saturated = { ...openedWorkflow(), sequence: WORKFLOW_BOUNDS.MAX_ADMITTED_EVIDENCE, - evidence: full, + evidence: stored(full), } as WorkflowState; expect(applyWorkflowEvent(saturated, admitEvidence()).rejection).toBe('CAPACITY_EXCEEDED'); }); + /* ---- a hostile list view may never hide a real own record ---- */ + + /** + * A Proxy that lies *consistently* about length, ownKeys, and descriptors, + * exposing only a prefix of a larger target. Every channel agrees, so no + * amount of cross-checking can contradict it — which is why acceptance rests + * on non-extensibility instead. + */ + function hidingView(target: readonly unknown[], visible: number): unknown { + const shown: string[] = []; + for (let index = 0; index < visible; index += 1) { + shown.push(String(index)); + } + shown.push('length'); + return new Proxy(target, { + get: (t, key): unknown => (key === 'length' ? visible : Reflect.get(t, key)), + ownKeys: (): ArrayLike => shown, + getOwnPropertyDescriptor: (t, key): PropertyDescriptor | undefined => { + if (key === 'length') { + return { value: visible, writable: true, enumerable: false, configurable: false }; + } + if (typeof key === 'string' && Number(key) < visible) { + return Reflect.getOwnPropertyDescriptor(t, key); + } + return undefined; + }, + has: (t, key): boolean => + typeof key === 'string' && Number(key) >= visible ? false : Reflect.has(t, key), + }); + } + + it.each(COLLECTIONS)('refuses %s whose view hides a trailing own record', (collection) => { + const state = populatedAggregate(); + const real = [...state[collection]]; + const result = withList(state, collection, hidingView(real, 1)); + + expect(real).toHaveLength(2); + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.outcome).toBe('REJECTED'); + }); + + it.each(COLLECTIONS)('refuses %s whose view hides a middle own record', (collection) => { + const state = populatedAggregate(); + const three = [...state[collection], state[collection][0]]; + const middle = new Proxy(three, { + get: (t, key): unknown => + key === 'length' ? 2 : Reflect.get(t, key === '1' ? '2' : key), + ownKeys: (): ArrayLike => ['0', '1', 'length'], + getOwnPropertyDescriptor: (t, key): PropertyDescriptor | undefined => { + if (key === 'length') { + return { value: 2, writable: true, enumerable: false, configurable: false }; + } + if (key === '0' || key === '1') { + return Reflect.getOwnPropertyDescriptor(t, '0'); + } + return undefined; + }, + }); + + expect(withList(state, collection, middle).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s presented as an extensible list', (collection) => { + const state = populatedAggregate(); + const extensible = [...state[collection]]; + + expect(Object.isExtensible(extensible)).toBe(true); + expect(withList(state, collection, extensible).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('accepts %s as a non-extensible list of the same records', (collection) => { + const state = populatedAggregate(); + + for (const seal of [Object.freeze, Object.seal, Object.preventExtensions]) { + const result = withList(state, collection, seal([...state[collection]])); + + expect(result.outcome).toBe('APPLIED'); + expect(result.state[collection]).toHaveLength(2); + } + }); + + it.each(COLLECTIONS)('refuses %s whose ownKeys trap throws', (collection) => { + const state = populatedAggregate(); + const throwing = new Proxy(Object.freeze([...state[collection]]), { + ownKeys: (): never => { + throw new Error('hostile ownKeys'); + }, + }); + + expect(withList(state, collection, throwing).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each(COLLECTIONS)('refuses %s whose key view is unstable', (collection) => { + const state = populatedAggregate(); + let reads = 0; + const unstable = new Proxy([...state[collection]], { + ownKeys: (t): ArrayLike => { + reads += 1; + return reads === 1 ? ['0', 'length'] : Reflect.ownKeys(t); + }, + }); + + expect(withList(state, collection, unstable).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('loses no record through a later legitimate transition', () => { + const state = populatedAggregate(); + const hidden = withList(state, 'invocations', hidingView([...state.invocations], 1)); + + expect(hidden.rejection).toBe('WORKFLOW_UNREADABLE'); + + // The genuine aggregate still carries both records after further work. + const advanced = applyOrThrow(state, observeHead(SHA_B)); + + expect(advanced.invocations).toHaveLength(2); + expect(advanced.evidence).toHaveLength(2); + expect(advanced.reviews).toHaveLength(2); + expect(Object.isFrozen(advanced.invocations)).toBe(true); + }); + + /* ---- a stamped record may not precede the transition that created it ---- */ + + /** + * Reaching revision R costs R applied `HEAD_OBSERVED` transitions, each + * consuming a distinct sequence slot, so the R-th advance sat at slot >= R + * and a record stamped at revision R was created by a later transition still: + * + * recordedSequence > recordedRevision + * + * The bound is tight — open, one HEAD advance at slot 1, then a request at + * slot 2 legitimately stamps revision 1 with sequence 2. + */ + function stampedState(overrides: Record): WorkflowState { + return { + ...openedWorkflow(), + boundCommitSha: SHA_B, + ...overrides, + } as unknown as WorkflowState; + } + + function trackedAt(revision: number, sequence: number): unknown { + return { + invocationId: INVOCATION_A, + targetCommitSha: SHA_B, + purpose: 'review', + providerId: 'codex', + agentId: 'agent-1', + requestedAtRevision: revision, + requestedAtSequence: sequence, + state: 'REQUESTED', + reportedStatus: null, + reportedAtRevision: null, + reportedAtSequence: null, + }; + } + + const admittedAt = (revision: number, sequence: number): unknown => ({ + evidenceId: EVIDENCE_A, + kind: 'ci-result', + admittedAtCommitSha: SHA_B, + admittedAtRevision: revision, + admittedAtSequence: sequence, + }); + + const reviewedAt = (revision: number, sequence: number): unknown => ({ + reviewId: REVIEW_A, + admittedAtCommitSha: SHA_B, + admittedAtRevision: revision, + admittedAtSequence: sequence, + }); + + it.each([ + ['an invocation', (r: number, q: number) => ({ invocations: stored([trackedAt(r, q)]) })], + ['an evidence admission', (r: number, q: number) => ({ evidence: stored([admittedAt(r, q)]) })], + ['a review admission', (r: number, q: number) => ({ reviews: stored([reviewedAt(r, q)]) })], + ])('refuses %s stamped at revision 1 with sequence 1', (_name, build) => { + const forged = stampedState({ revision: 1, sequence: 2, ...build(1, 1) }); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it.each([ + ['an invocation', (r: number, q: number) => ({ invocations: stored([trackedAt(r, q)]) })], + ['an evidence admission', (r: number, q: number) => ({ evidence: stored([admittedAt(r, q)]) })], + ['a review admission', (r: number, q: number) => ({ reviews: stored([reviewedAt(r, q)]) })], + ])('accepts %s stamped at revision 1 with sequence 2', (_name, build) => { + const legal = stampedState({ revision: 1, sequence: 2, ...build(1, 2) }); + + expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it.each([ + ['an invocation', (r: number, q: number) => ({ invocations: stored([trackedAt(r, q)]) })], + ['an evidence admission', (r: number, q: number) => ({ evidence: stored([admittedAt(r, q)]) })], + ['a review admission', (r: number, q: number) => ({ reviews: stored([reviewedAt(r, q)]) })], + ])('accepts %s stamped at revision 0 with sequence 1', (_name, build) => { + // Bound to SHA_B so the revision-0 entries match their own commit binding. + const legal = stampedState({ sequence: 1, ...build(0, 1) }); + + expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it.each([ + [3, 2], + [3, 3], + [5, 1], + [2, 2], + ])('refuses a record stamped at revision %i with sequence %i', (revision, sequence) => { + const forged = stampedState({ + revision, + sequence: revision + 2, + evidence: stored([admittedAt(revision, sequence)]), + }); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('refuses a report stamp that precedes its own revision', () => { + const forged = stampedState({ + revision: 2, + sequence: 6, + invocations: stored([ + { + ...(trackedAt(2, 3) as Record), + state: 'REPORTED', + reportedStatus: 'reported-complete', + reportedAtRevision: 2, + reportedAtSequence: 2, + }, + ]), + }); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts a report whose stamps follow both its request and its revision', () => { + const legal = stampedState({ + revision: 2, + sequence: 6, + invocations: stored([ + { + ...(trackedAt(2, 3) as Record), + state: 'REPORTED', + reportedStatus: 'reported-complete', + reportedAtRevision: 2, + reportedAtSequence: 4, + }, + ]), + }); + + expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('keeps a real multi-HEAD history valid and its stamps ahead of their revisions', () => { + let state = applyOrThrow(openedWorkflow(), requestInvocation()); + state = applyOrThrow(state, observeHead(SHA_B)); + state = applyOrThrow( + state, + requestInvocation(buildInvocation({ invocationId: INVOCATION_B, targetCommitSha: SHA_B })), + ); + state = applyOrThrow( + state, + admitEvidence(buildVerdict({ commitSha: SHA_B, targetHeadSha: SHA_B })), + ); + state = applyOrThrow(state, observeHead(SHA_C)); + state = applyOrThrow( + state, + admitReview(buildReview({ reviewId: 'rv-late', reviewedCommitSha: SHA_C })), + ); + + for (const tracked of state.invocations) { + expect(tracked.requestedAtSequence).toBeGreaterThan(tracked.requestedAtRevision); + } + for (const admission of [...state.evidence, ...state.reviews]) { + expect(admission.admittedAtSequence).toBeGreaterThan(admission.admittedAtRevision); + } + expect(state.revision).toBe(2); + expect(applyWorkflowEvent(state, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('still enforces global stamp uniqueness alongside the revision relation', () => { + const forged = stampedState({ + revision: 1, + sequence: 4, + evidence: stored([admittedAt(1, 3)]), + reviews: stored([reviewedAt(1, 3)]), + }); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + /* ---- P1: emptiness of invalidFields must be provable, not reported ---- */ /** A gated workflow plus a forged human-decision verdict carrying `invalidFields`. */ @@ -1517,7 +1836,7 @@ describe('group K — bounds', () => { admittedAtRevision: 0, admittedAtSequence: index + 1, })); - return { ...openedWorkflow(), sequence: count, evidence } as WorkflowState; + return { ...openedWorkflow(), sequence: count, evidence: stored(evidence) } as WorkflowState; } it('refuses a new admission once the evidence bound is reached', () => { @@ -1550,7 +1869,7 @@ describe('group K — bounds', () => { const state = { ...openedWorkflow(), sequence: WORKFLOW_BOUNDS.MAX_ADMITTED_REVIEWS, - reviews, + reviews: stored(reviews), } as WorkflowState; expect(applyWorkflowEvent(state, admitReview()).rejection).toBe('CAPACITY_EXCEEDED'); From 8800805e9f125e9d9d7afa8b4e755ee7d17bb99c Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Tue, 11 Aug 2026 23:53:16 +0200 Subject: [PATCH 09/20] fix: close PR 007 final Codex workflow invariant findings Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 156 ++++++++++++++++++++--- tests/domain/workflow-invariants.test.ts | 109 ++++++++++++---- 2 files changed, 224 insertions(+), 41 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 2798f42..e383b7c 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -104,6 +104,8 @@ import { const objectFreeze = Object.freeze; const objectHasOwn = Object.hasOwn; const arrayIsArray = Array.isArray; +const objectDefineProperty = Object.defineProperty; +const objectIsFrozen = Object.isFrozen; const reflectIsExtensible = Reflect.isExtensible; const reflectOwnKeys = Reflect.ownKeys; @@ -296,6 +298,83 @@ function bindRevisionCommit( return true; } +/** + * Note that revision `revision` covers sequence slot `sequence`. + * + * Revision never decreases, so ordering every stamped record by sequence must + * produce non-decreasing revisions. Tracking the lowest and highest slot each + * revision covers reduces that to a comparison between revision *bands*, which + * the caller checks once at the end. Derived transiently; nothing is stored. + */ +function noteRevisionSpan( + revisions: number[], + lowest: number[], + highest: number[], + revision: number, + sequence: number, +): void { + for (let index = 0; index < revisions.length; index += 1) { + if (revisions[index] === revision) { + const low = lowest[index]; + const high = highest[index]; + if (low !== undefined && sequence < low) { + objectDefineProperty(lowest, index, { + value: sequence, + writable: true, + enumerable: true, + configurable: true, + }); + } + if (high !== undefined && sequence > high) { + objectDefineProperty(highest, index, { + value: sequence, + writable: true, + enumerable: true, + configurable: true, + }); + } + return; + } + } + append(revisions, revision); + append(lowest, sequence); + append(highest, sequence); +} + +/** + * Do the recorded revision bands respect chronology? + * + * An earlier revision may not hold a slot at or after a later revision's + * earliest slot: the HEAD transition that produced the later revision consumed + * a slot between them. + */ +function revisionBandsOrdered( + revisions: readonly number[], + lowest: readonly number[], + highest: readonly number[], +): boolean { + for (let a = 0; a < revisions.length; a += 1) { + for (let b = 0; b < revisions.length; b += 1) { + const earlier = revisions[a]; + const later = revisions[b]; + const earlierHigh = highest[a]; + const laterLow = lowest[b]; + if ( + earlier === undefined || + later === undefined || + earlierHigh === undefined || + laterLow === undefined + ) { + return false; + } + if (earlier < later && earlierHigh >= laterLow) { + return false; + } + } + } + return true; +} + /** * Materialise an untrusted list with guarded reads and a hard length cap. * @@ -318,29 +397,33 @@ function readList(value: unknown, limit: number): readonly unknown[] | null { // **Every content signal a Proxy exposes is one the same Proxy controls.** // Over an *extensible* target it may report a short `length`, return a - // matching `ownKeys`, and deny the hidden indices through - // `getOwnPropertyDescriptor` — all consistently. Corroborating one trapped - // channel with another proves nothing when one adversary owns both, so a real - // own record could be hidden and then silently deleted from the durable - // snapshot by the next applied transition. + // matching `ownKeys`, and deny the hidden indices — all consistently. + // Corroborating one trapped channel with another proves nothing when one + // adversary owns both, so a real own record could be hidden and then silently + // deleted from the durable snapshot by the next applied transition. + // + // Non-extensibility alone is not enough. A *sealed* array's elements stay + // writable, and the `get` invariant binds a Proxy only for a non-configurable + // **and non-writable** data property — so a sealed view can keep `length`, + // `ownKeys`, and `hasOwn` perfectly compliant while substituting an arbitrary + // record for an index, erasing the real entry and freeing its identity. // - // Non-extensibility is the one property the engine refuses to let a Proxy - // fake: the `isExtensible` trap must agree with its target, and once the - // target is non-extensible the `ownKeys` trap must return *exactly* the - // target's own keys. Only then do the structural checks below become proof - // rather than assertion. + // Frozen-ness is what the engine underwrites end to end: `isExtensible` must + // agree with the target, `ownKeys` must then return exactly the target's own + // keys, and every element — now non-configurable and non-writable — must read + // back as its true value. `Object.isFrozen` cannot be faked either, because a + // non-configurable writable property may not be reported as non-writable. // // Every list this layer emits is frozen by `freezeState`, so a state produced // here always satisfies this. A caller that rebuilds a state from JSON must - // freeze the three collections before handing it back; an extensible list is - // refused rather than partially trusted. - let extensible = true; + // freeze the three collections before handing it back. + let frozen = false; try { - extensible = reflectIsExtensible(elements); + frozen = objectIsFrozen(elements); } catch { return null; } - if (extensible) { + if (!frozen) { return null; } @@ -603,7 +686,9 @@ function readAdmittedReview( * consuming a distinct sequence slot, so the R-th advance sat at slot >= R * and the record stamped at revision R was created by a later transition * still. The bound is tight — open, one HEAD advance at slot 1, then a - * request at slot 2 stamps revision 1 with sequence 2. + * request at slot 2 stamps revision 1 with sequence 2; + * - ordering every stamped record by sequence yields non-decreasing revisions, + * since a revision never decreases once a HEAD transition advances it. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); @@ -768,6 +853,9 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const seenRevisions: number[] = []; const seenCommits: string[] = []; const seenSequences: number[] = []; + const spanRevisions: number[] = []; + const spanLowest: number[] = []; + const spanHighest: number[] = []; for (let index = 0; index < invocations.length; index += 1) { const tracked = invocations[index]; if ( @@ -784,6 +872,22 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { ) { return null; } + noteRevisionSpan( + spanRevisions, + spanLowest, + spanHighest, + tracked.requestedAtRevision, + tracked.requestedAtSequence, + ); + if (tracked.reportedAtRevision !== null && tracked.reportedAtSequence !== null) { + noteRevisionSpan( + spanRevisions, + spanLowest, + spanHighest, + tracked.reportedAtRevision, + tracked.reportedAtSequence, + ); + } } for (let index = 0; index < evidence.length; index += 1) { const admitted = evidence[index]; @@ -799,6 +903,13 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { ) { return null; } + noteRevisionSpan( + spanRevisions, + spanLowest, + spanHighest, + admitted.admittedAtRevision, + admitted.admittedAtSequence, + ); } for (let index = 0; index < reviews.length; index += 1) { const admitted = reviews[index]; @@ -814,6 +925,19 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { ) { return null; } + noteRevisionSpan( + spanRevisions, + spanLowest, + spanHighest, + admitted.admittedAtRevision, + admitted.admittedAtSequence, + ); + } + + // Revision never decreases, so ordering every stamped record by sequence must + // yield non-decreasing revisions. + if (!revisionBandsOrdered(spanRevisions, spanLowest, spanHighest)) { + return null; } return { diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index f29891d..7b2c4aa 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1278,17 +1278,62 @@ describe('group J — hostile input fails closed', () => { expect(withList(state, collection, extensible).rejection).toBe('WORKFLOW_UNREADABLE'); }); - it.each(COLLECTIONS)('accepts %s as a non-extensible list of the same records', (collection) => { + it.each(COLLECTIONS)('accepts %s as a frozen list of the same records', (collection) => { const state = populatedAggregate(); + const result = withList(state, collection, Object.freeze([...state[collection]])); - for (const seal of [Object.freeze, Object.seal, Object.preventExtensions]) { - const result = withList(state, collection, seal([...state[collection]])); + expect(result.outcome).toBe('APPLIED'); + expect(result.state[collection]).toHaveLength(2); + }); - expect(result.outcome).toBe('APPLIED'); - expect(result.state[collection]).toHaveLength(2); + it.each(COLLECTIONS)('refuses %s that is merely sealed, not frozen', (collection) => { + const state = populatedAggregate(); + + // A sealed array keeps its elements writable, and the `get` invariant binds + // a Proxy only for a non-configurable *and non-writable* property — so a + // sealed view can substitute a record while every other channel stays + // compliant. Non-extensibility alone is therefore not sufficient. + for (const weaken of [Object.seal, Object.preventExtensions]) { + const weakened = weaken([...state[collection]]); + + expect(Object.isExtensible(weakened)).toBe(false); + expect(Object.isFrozen(weakened)).toBe(false); + expect(withList(state, collection, weakened).rejection).toBe('WORKFLOW_UNREADABLE'); } }); + it('refuses a sealed view that substitutes a record for a real entry', () => { + const state = populatedAggregate(); + const real = [...state.invocations]; + const decoy = { ...real[0], invocationId: 'i-substituted', providerId: 'attacker' }; + const substituting = new Proxy(Object.seal([...real]), { + get: (target, key): unknown => (key === '0' ? decoy : Reflect.get(target, key)), + }); + const result = withList(state, 'invocations', substituting); + + expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); + expect(result.outcome).toBe('REJECTED'); + + // The real entry is never displaced, and the decoy never becomes durable. + const ids = state.invocations.map((tracked) => tracked.invocationId); + expect(ids).toContain(INVOCATION_A); + expect(ids).not.toContain('i-substituted'); + }); + + it('cannot have an element substituted once the list is frozen', () => { + const state = populatedAggregate(); + const real = [...state.invocations]; + const decoy = { ...real[0], invocationId: 'i-substituted' }; + const overFrozen = new Proxy(Object.freeze([...real]), { + get: (target, key): unknown => (key === '0' ? decoy : Reflect.get(target, key)), + }); + + // The engine itself refuses the lie for a non-writable, non-configurable + // element, so the substitution cannot even be observed. + expect(() => overFrozen[0]).toThrow(TypeError); + expect(withList(state, 'invocations', overFrozen).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + it.each(COLLECTIONS)('refuses %s whose ownKeys trap throws', (collection) => { const state = populatedAggregate(); const throwing = new Proxy(Object.freeze([...state[collection]]), { @@ -1426,21 +1471,35 @@ describe('group J — hostile input fails closed', () => { }); it('refuses a report stamp that precedes its own revision', () => { - const forged = stampedState({ + // The request sits at revision 0 / sequence 1 so the report at sequence 2 + // clears the pre-existing `reportedAtSequence > requestedAtSequence` rule. + // The only rule it breaks is `reportedAtSequence > reportedAtRevision`. + const request = trackedAt(0, 1) as Record; + const reported = { + ...request, + state: 'REPORTED', + reportedStatus: 'reported-complete', + reportedAtRevision: 2, + reportedAtSequence: 2, + }; + + expect(reported.reportedAtSequence).toBeGreaterThan(request.requestedAtSequence as number); + expect(reported.reportedAtRevision).toBeGreaterThanOrEqual( + request.requestedAtRevision as number, + ); + + const forged = stampedState({ revision: 2, sequence: 6, invocations: stored([reported]) }); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + + // Moving only the report stamp past its own revision makes it legal again. + const legal = stampedState({ revision: 2, sequence: 6, - invocations: stored([ - { - ...(trackedAt(2, 3) as Record), - state: 'REPORTED', - reportedStatus: 'reported-complete', - reportedAtRevision: 2, - reportedAtSequence: 2, - }, - ]), + invocations: stored([{ ...reported, reportedAtSequence: 3 }]), }); - expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); }); it('accepts a report whose stamps follow both its request and its revision', () => { @@ -1653,42 +1712,42 @@ describe('group J — hostile input fails closed', () => { it.each([ [ 'evidence reusing the request stamp', - (s: WorkflowState) => ({ ...s, evidence: [{ ...s.evidence[0], admittedAtSequence: 1 }] }), + (s: WorkflowState) => ({ ...s, evidence: stored([{ ...s.evidence[0], admittedAtSequence: 1 }]) }), ], [ 'review reusing the request stamp', - (s: WorkflowState) => ({ ...s, reviews: [{ ...s.reviews[0], admittedAtSequence: 1 }] }), + (s: WorkflowState) => ({ ...s, reviews: stored([{ ...s.reviews[0], admittedAtSequence: 1 }]) }), ], [ 'review reusing the evidence stamp', - (s: WorkflowState) => ({ ...s, reviews: [{ ...s.reviews[0], admittedAtSequence: 2 }] }), + (s: WorkflowState) => ({ ...s, reviews: stored([{ ...s.reviews[0], admittedAtSequence: 2 }]) }), ], [ 'report reusing the evidence stamp', (s: WorkflowState) => ({ ...s, - invocations: [{ ...s.invocations[0], reportedAtSequence: 2 }], + invocations: stored([{ ...s.invocations[0], reportedAtSequence: 2 }]), }), ], [ 'report reusing the review stamp', (s: WorkflowState) => ({ ...s, - invocations: [{ ...s.invocations[0], reportedAtSequence: 3 }], + invocations: stored([{ ...s.invocations[0], reportedAtSequence: 3 }]), }), ], [ 'two evidence admissions sharing a stamp', (s: WorkflowState) => ({ ...s, - evidence: [s.evidence[0], { ...s.evidence[0], evidenceId: EVIDENCE_B }], + evidence: stored([s.evidence[0], { ...s.evidence[0], evidenceId: EVIDENCE_B }]), }), ], [ 'two reviews sharing a stamp', (s: WorkflowState) => ({ ...s, - reviews: [s.reviews[0], { ...s.reviews[0], reviewId: 'rv-other' }], + reviews: stored([s.reviews[0], { ...s.reviews[0], reviewId: 'rv-other' }]), }), ], ])('refuses a state where %s', (_name, forge) => { @@ -1706,7 +1765,7 @@ describe('group J — hostile input fails closed', () => { const tracked = base.invocations[0]; const forged = { ...base, - invocations: [ + invocations: stored([ tracked, { ...tracked, @@ -1716,7 +1775,7 @@ describe('group J — hostile input fails closed', () => { reportedAtRevision: null, reportedAtSequence: null, }, - ], + ]), } as unknown as WorkflowState; expect(applyWorkflowEvent(forged, admitEvidence()).rejection).toBe('WORKFLOW_UNREADABLE'); From a03c0eff3be0b7611cdefdbd32062d40bda2b34c Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 00:25:59 +0200 Subject: [PATCH 10/20] test: reach frozen-list-gated assertions Co-Authored-By: Claude Opus 5 (1M context) --- tests/domain/workflow-transitions.test.ts | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/domain/workflow-transitions.test.ts b/tests/domain/workflow-transitions.test.ts index 4660702..8b47a1e 100644 --- a/tests/domain/workflow-transitions.test.ts +++ b/tests/domain/workflow-transitions.test.ts @@ -356,14 +356,27 @@ describe('applyWorkflowEvent — group C, invocation lifecycle', () => { it('rejects a deserialized state with duplicate tracked invocation ids', () => { const state = withRequestedInvocation(); + // Frozen, as every collection this layer emits is: an unfrozen list is + // refused at the frozen-list gate before the duplicate-id scan ever runs, + // which would leave this assertion green for the wrong reason. const duplicateState = { ...state, - invocations: [state.invocations[0], state.invocations[0]], + invocations: Object.freeze([state.invocations[0], state.invocations[0]]), } as WorkflowState; const result = applyWorkflowEvent(duplicateState, reportInvocation()); + expect(result.outcome).toBe('REJECTED'); expect(result.rejection).toBe('WORKFLOW_UNREADABLE'); expect(result.state).toBe(duplicateState); + + // The same frozen shape without the duplicate is accepted, so the rejection + // above is caused by the duplicate identity rather than the freeze gate. + const uniqueState = { + ...state, + invocations: Object.freeze([state.invocations[0]]), + } as WorkflowState; + + expect(applyWorkflowEvent(uniqueState, reportInvocation()).outcome).toBe('APPLIED'); }); it('refuses a report for an invocation it never requested', () => { @@ -1231,6 +1244,24 @@ describe('applyWorkflowEvent — group N, end-to-end lifecycle replay', () => { expect(state.reviews[0]?.admittedAtCommitSha).toBe(SHA_A); expect(state.reviews[2]?.admittedAtCommitSha).toBe(SHA_B); expect(JSON.parse(JSON.stringify(state))).toEqual(state); + + // Serialization preserves the data, but `JSON.parse` does not restore the + // frozen-collection runtime invariant — that is a caller obligation. The + // raw parse is refused as unreadable; a re-frozen copy is readable again + // and reaches the terminal-status rule this closed workflow expects. + const restored = JSON.parse(JSON.stringify(state)) as WorkflowState; + + expect(Object.isFrozen(restored.invocations)).toBe(false); + expect(applyWorkflowEvent(restored, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + + const refrozen = { + ...restored, + invocations: Object.freeze([...restored.invocations]), + evidence: Object.freeze([...restored.evidence]), + reviews: Object.freeze([...restored.reviews]), + } as WorkflowState; + + expect(applyWorkflowEvent(refrozen, closeWorkflow()).rejection).toBe('WORKFLOW_CLOSED'); }); it('rejects a claim passed where an observation belongs', () => { From ad14bbc815478005490480b8f05bf2ed7e1ffe2e Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 01:00:42 +0200 Subject: [PATCH 11/20] fix: enforce sequence capacity across revision gaps Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 54 ++++++++++---- tests/domain/workflow-invariants.test.ts | 94 +++++++++++++++++++++++- 2 files changed, 132 insertions(+), 16 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index e383b7c..6a7d1d9 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -344,30 +344,54 @@ function noteRevisionSpan( /** * Do the recorded revision bands respect chronology? * - * An earlier revision may not hold a slot at or after a later revision's - * earliest slot: the HEAD transition that produced the later revision consumed - * a slot between them. + * Each applied transition consumes exactly one sequence slot, and advancing + * from revision `r1` to `r2` costs `r2 - r1` `HEAD_OBSERVED` transitions that + * stamp nothing. Those transitions need slots of their own, so two stamped + * records cannot merely be ordered — they must leave room between them: + * + * later stamp at (r2, s2) after an earlier stamp at (r1, s1), r1 < r2 + * => s2 - s1 > r2 - r1 + * + * The intervening HEAD transitions occupy distinct slots strictly between `s1` + * and `s2`, of which there are `s2 - s1 - 1`. Only the highest slot of the + * earlier revision against the lowest slot of the later one has to be checked; + * every other cross pair leaves a wider gap. This subsumes the plain ordering + * rule, because the gap is always at least two slots when revisions differ. + * + * The same accounting applies after the last stamp: reaching the aggregate's + * `revision` from a band's revision costs that many further HEAD transitions, + * each needing a slot up to and including the aggregate's `sequence`: + * + * band at (r, high) => sequence - high >= revision - r + * + * That bound is inclusive, because the aggregate's own final slot may itself be + * one of those HEAD transitions. The origin needs no separate case: measured + * against the opening `(0, 0)`, the pairwise rule reduces to + * `recordedSequence > recordedRevision`, which is already enforced per record. */ function revisionBandsOrdered( revisions: readonly number[], lowest: readonly number[], highest: readonly number[], + revision: number, + sequence: number, ): boolean { for (let a = 0; a < revisions.length; a += 1) { + const earlier = revisions[a]; + const earlierHigh = highest[a]; + if (earlier === undefined || earlierHigh === undefined) { + return false; + } + if (sequence - earlierHigh < revision - earlier) { + return false; + } for (let b = 0; b < revisions.length; b += 1) { - const earlier = revisions[a]; const later = revisions[b]; - const earlierHigh = highest[a]; const laterLow = lowest[b]; - if ( - earlier === undefined || - later === undefined || - earlierHigh === undefined || - laterLow === undefined - ) { + if (later === undefined || laterLow === undefined) { return false; } - if (earlier < later && earlierHigh >= laterLow) { + if (earlier < later && laterLow - earlierHigh <= later - earlier) { return false; } } @@ -688,7 +712,9 @@ function readAdmittedReview( * still. The bound is tight — open, one HEAD advance at slot 1, then a * request at slot 2 stamps revision 1 with sequence 2; * - ordering every stamped record by sequence yields non-decreasing revisions, - * since a revision never decreases once a HEAD transition advances it. + * since a revision never decreases once a HEAD transition advances it, and + * consecutive stamps leave room for the `HEAD_OBSERVED` transitions between + * their revisions — each of which consumes a sequence slot of its own. */ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { const record = asRecord(state); @@ -936,7 +962,7 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { // Revision never decreases, so ordering every stamped record by sequence must // yield non-decreasing revisions. - if (!revisionBandsOrdered(spanRevisions, spanLowest, spanHighest)) { + if (!revisionBandsOrdered(spanRevisions, spanLowest, spanHighest, revision, sequence)) { return null; } diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 7b2c4aa..6f8ec1c 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1492,16 +1492,106 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); - // Moving only the report stamp past its own revision makes it legal again. + // Moving the report stamp past its own revision makes it legal again — but + // only once it also clears the two HEAD transitions that revision 2 costs, + // which occupy slots 2 and 3. Slot 4 is the earliest reachable one. const legal = stampedState({ revision: 2, sequence: 6, - invocations: stored([{ ...reported, reportedAtSequence: 3 }]), + invocations: stored([{ ...reported, reportedAtSequence: 4 }]), }); expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); }); + /* ---- intervening HEAD transitions must have sequence slots of their own ---- */ + + /** One invocation whose request and report straddle a revision advance. */ + function straddling( + requestRevision: number, + requestSequence: number, + reportRevision: number, + reportSequence: number, + sequence: number, + ): WorkflowState { + return stampedState({ + revision: reportRevision, + sequence, + invocations: stored([ + { + ...(trackedAt(requestRevision, requestSequence) as Record), + state: 'REPORTED', + reportedStatus: 'reported-complete', + reportedAtRevision: reportRevision, + reportedAtSequence: reportSequence, + }, + ]), + }); + } + + it('refuses a two-revision jump that leaves no slot for the HEAD transitions', () => { + // Reaching revision 2 from 0 costs two HEAD_OBSERVED transitions, which take + // slots 2 and 3, so the report cannot also occupy slot 3. + const forged = straddling(0, 1, 2, 3, 4); + + expect(applyWorkflowEvent(forged, closeWorkflow()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the nearest reachable two-revision jump', () => { + const legal = straddling(0, 1, 2, 4, 4); + + expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('pins the boundary for a single intervening HEAD transition', () => { + // Slot 2 is the HEAD transition itself, so the report cannot sit there. + expect(applyWorkflowEvent(straddling(0, 1, 1, 2, 3), closeWorkflow()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + expect(applyWorkflowEvent(straddling(0, 1, 1, 3, 3), closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('leaves stamps within one revision unaffected', () => { + expect(applyWorkflowEvent(straddling(0, 1, 0, 2, 2), closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it.each([ + [2, 2, 'WORKFLOW_UNREADABLE'], + [2, 3, null], + [1, 2, null], + ])( + 'reserves slots between the final stamp and aggregate revision %i / sequence %i', + (revision, sequence, rejection) => { + // The last stamp sits at revision 0 / sequence 1; reaching the aggregate's + // revision costs that many further HEAD transitions, each needing a slot + // up to and including the aggregate's own sequence. + const state = stampedState({ + revision, + sequence, + invocations: stored([trackedAt(0, 1)]), + }); + const result = applyWorkflowEvent(state, closeWorkflow()); + + if (rejection === null) { + expect(result.outcome).toBe('APPLIED'); + } else { + expect(result.rejection).toBe(rejection); + } + }, + ); + + it('keeps a genuinely replayed two-HEAD history valid', () => { + let state = applyOrThrow(openedWorkflow(), requestInvocation()); + state = applyOrThrow(state, observeHead(SHA_B)); + state = applyOrThrow(state, observeHead(SHA_C)); + state = applyOrThrow(state, reportInvocation()); + + expect(state.invocations[0]?.requestedAtSequence).toBe(1); + expect(state.invocations[0]?.reportedAtRevision).toBe(2); + expect(state.invocations[0]?.reportedAtSequence).toBe(4); + expect(applyWorkflowEvent(state, closeWorkflow()).outcome).toBe('APPLIED'); + }); + it('accepts a report whose stamps follow both its request and its revision', () => { const legal = stampedState({ revision: 2, From 977d854ba77ac49d24caf3406e8be627412fed50 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 01:31:21 +0200 Subject: [PATCH 12/20] fix: reserve sequence slots for status transitions Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 13 ++++ tests/domain/workflow-invariants.test.ts | 86 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 6a7d1d9..f6d6adb 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -966,6 +966,19 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { return null; } + // A status that only a transition can produce means that transition already + // ran, and it stamps no record — so it needs a sequence slot of its own, + // distinct from every retained stamp. `AWAITING_HUMAN_DECISION` comes from + // `HUMAN_GATE_OPENED` and `CLOSED` from `CLOSE_REQUESTED`; `OPEN` is the + // opening state and requires nothing. + if ( + (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION || + rawStatus === WORKFLOW_STATUS.CLOSED) && + sequence <= seenSequences.length + ) { + return null; + } + return { workflowId, repositoryId, diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 6f8ec1c..8090d02 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1504,6 +1504,92 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(legal, closeWorkflow()).outcome).toBe('APPLIED'); }); + /* ---- a status-producing transition needs an unstamped slot of its own ---- */ + + it('refuses AWAITING_HUMAN_DECISION with no slot for the gate transition', () => { + const forged = { + ...openedWorkflow(), + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 0, + sequence: 0, + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(forged, observeHead(SHA_B)).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the nearest valid awaiting-human state', () => { + const legal = { + ...openedWorkflow(), + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 0, + sequence: 1, + } as unknown as WorkflowState; + + expect(applyWorkflowEvent(legal, observeHead(SHA_B)).outcome).toBe('APPLIED'); + }); + + it('requires the gate slot to be unstamped', () => { + // The single slot is already claimed by the request stamp, leaving none for + // the gate opening; one more slot makes it reachable. + const requested = applyOrThrow(openedWorkflow(), requestInvocation()); + const contended = { + ...requested, + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 0, + } as unknown as WorkflowState; + + expect(requested.sequence).toBe(1); + expect(applyWorkflowEvent(contended, observeHead(SHA_B)).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + expect( + applyWorkflowEvent({ ...contended, sequence: 2 } as WorkflowState, observeHead(SHA_B)) + .outcome, + ).toBe('APPLIED'); + }); + + it('refuses CLOSED with no slot for the closing transition', () => { + const forged = { + ...openedWorkflow(), + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + sequence: 0, + } as unknown as WorkflowState; + + // Unreadable, rather than merely refused by the terminal-status gate. + expect(applyWorkflowEvent(forged, observeHead(SHA_B)).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the nearest valid closed state', () => { + const legal = { + ...openedWorkflow(), + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + sequence: 1, + } as unknown as WorkflowState; + + // Readable now, so the terminal-status rule is what refuses the event. + expect(applyWorkflowEvent(legal, observeHead(SHA_B)).rejection).toBe('WORKFLOW_CLOSED'); + }); + + it('leaves OPEN states that never made either transition unaffected', () => { + const fresh = openedWorkflow(); + + expect(fresh.sequence).toBe(0); + expect(applyWorkflowEvent(fresh, observeHead(SHA_B)).outcome).toBe('APPLIED'); + expect(applyOrThrow(fresh, requestInvocation()).sequence).toBe(1); + }); + + it('keeps genuinely produced gate and closure states valid', () => { + const gated = applyOrThrow(openedWorkflow(), openHumanGate()); + const closed = applyOrThrow(openedWorkflow(), closeWorkflow()); + + expect(gated.sequence).toBe(1); + expect(closed.sequence).toBe(1); + expect(applyWorkflowEvent(gated, observeHead(SHA_B)).outcome).toBe('APPLIED'); + expect(applyWorkflowEvent(closed, observeHead(SHA_B)).rejection).toBe('WORKFLOW_CLOSED'); + }); + /* ---- intervening HEAD transitions must have sequence slots of their own ---- */ /** One invocation whose request and report straddle a revision advance. */ From 4aae023c20ad6597d31313a3be7db4a9f652c075 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 02:13:49 +0200 Subject: [PATCH 13/20] fix: account for HEAD slots in status chronology Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 16 +++-- tests/domain/workflow-invariants.test.ts | 92 ++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index f6d6adb..12ade38 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -967,14 +967,20 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { } // A status that only a transition can produce means that transition already - // ran, and it stamps no record — so it needs a sequence slot of its own, - // distinct from every retained stamp. `AWAITING_HUMAN_DECISION` comes from - // `HUMAN_GATE_OPENED` and `CLOSED` from `CLOSE_REQUESTED`; `OPEN` is the - // opening state and requires nothing. + // ran, and it stamps no record — so it needs a sequence slot of its own. + // `AWAITING_HUMAN_DECISION` comes from `HUMAN_GATE_OPENED` and `CLOSED` from + // `CLOSE_REQUESTED`; `OPEN` is the opening state and requires nothing. + // + // Counting retained stamps alone under-counts the occupied slots: reaching + // `revision` also cost that many `HEAD_OBSERVED` transitions, and those stamp + // nothing, so they never appear among the retained stamps. All three groups + // occupy distinct slots in `[1, sequence]`, hence + // + // sequence >= revision + retained stamps + 1 if ( (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION || rawStatus === WORKFLOW_STATUS.CLOSED) && - sequence <= seenSequences.length + sequence <= revision + seenSequences.length ) { return null; } diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 8090d02..8135dcd 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1590,6 +1590,98 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(closed, observeHead(SHA_B)).rejection).toBe('WORKFLOW_CLOSED'); }); + /* ---- HEAD slots count toward the status transition's reservation ---- */ + + /** Awaiting a human at revision 1, with one current-revision admission. */ + function awaitingAfterHead(sequence: number, admissionSequence: number): WorkflowState { + return { + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 1, + evidence: stored([ + { + evidenceId: EVIDENCE_A, + kind: 'ci-result', + admittedAtCommitSha: SHA_B, + admittedAtRevision: 1, + admittedAtSequence: admissionSequence, + }, + ]), + } as unknown as WorkflowState; + } + + const admitAtB = (): WorkflowEvent => + admitEvidence( + buildVerdict({ evidenceId: EVIDENCE_B, commitSha: SHA_B, targetHeadSha: SHA_B }), + ); + + it('refuses an awaiting-human state whose HEAD slot leaves no room for the gate', () => { + // Slot 1 is the HEAD advance to revision 1 and slot 2 is the admission, so + // nothing remains for HUMAN_GATE_OPENED even though only one stamp exists. + const forged = awaitingAfterHead(2, 2); + + expect(applyWorkflowEvent(forged, admitAtB()).rejection).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the same history with one more slot for the gate', () => { + expect(applyWorkflowEvent(awaitingAfterHead(3, 2), admitAtB()).outcome).toBe('APPLIED'); + }); + + it('counts a HEAD transition that leaves no retained stamp behind', () => { + // No admissions at all: the single HEAD advance still occupies a slot, so + // the gate needs a second one. + const withoutStamps = (sequence: number): WorkflowState => + ({ + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 1, + }) as unknown as WorkflowState; + + expect(applyWorkflowEvent(withoutStamps(1), admitAtB()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + expect(applyWorkflowEvent(withoutStamps(2), admitAtB()).outcome).toBe('APPLIED'); + }); + + it('keeps a genuinely replayed HEAD, admission and gate history valid', () => { + let state = applyOrThrow(openedWorkflow(), observeHead(SHA_B)); + state = applyOrThrow( + state, + admitEvidence(buildVerdict({ commitSha: SHA_B, targetHeadSha: SHA_B })), + ); + state = applyOrThrow(state, openHumanGate(SHA_B)); + + expect(state.revision).toBe(1); + expect(state.sequence).toBe(3); + expect(state.status).toBe('AWAITING_HUMAN_DECISION'); + expect(applyWorkflowEvent(state, admitAtB()).outcome).toBe('APPLIED'); + }); + + it('applies the same reservation to a closed state after a HEAD advance', () => { + const closedAfterHead = (sequence: number): WorkflowState => + ({ + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + }) as unknown as WorkflowState; + + // Unreadable while the HEAD slot leaves no room for the closing transition. + expect(applyWorkflowEvent(closedAfterHead(1), admitAtB()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + // Readable once it does, so the terminal-status rule is what refuses. + expect(applyWorkflowEvent(closedAfterHead(2), admitAtB()).rejection).toBe('WORKFLOW_CLOSED'); + }); + /* ---- intervening HEAD transitions must have sequence slots of their own ---- */ /** One invocation whose request and report straddle a revision advance. */ From 808e8e9f366b6ba7b96a1349fc0be2a275621e08 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 03:15:47 +0200 Subject: [PATCH 14/20] fix: enforce bounded workflow chronology invariants Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 39 +++++++ tests/domain/workflow-invariants.test.ts | 124 +++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 12ade38..286234e 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -985,6 +985,45 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { return null; } + // Counting is not enough on its own: the gate slot must also sit *after* the + // final `HEAD_OBSERVED` that reached the current revision. That HEAD follows + // every stamp recorded at an earlier revision, and the gate follows the HEAD, + // so the gate's slot is at least two past the latest earlier-revision stamp. + // + // Only an open gate at revision >= 1 is constrained. A gate that was already + // cleared — by a HEAD advance or by a human decision — leaves the workflow + // `OPEN`, and those histories are deliberately left alone. + if (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION && revision > 0) { + let latestEarlier = 0; + for (let index = 0; index < spanRevisions.length; index += 1) { + const bandRevision = spanRevisions[index]; + const bandHighest = spanHighest[index]; + if (bandRevision === undefined || bandHighest === undefined) { + return null; + } + if (bandRevision < revision && bandHighest > latestEarlier) { + latestEarlier = bandHighest; + } + } + if (sequence < latestEarlier + 2) { + return null; + } + } + + // At revision 0 with no retained stamp and no status-producing transition, + // nothing could have consumed a sequence slot: every event either stamps a + // record, advances the revision, opens the gate, or closes the workflow. + // Deliberately narrow — no general upper bound is claimed here, because a + // cleared gate legitimately consumes a slot it leaves no trace of. + if ( + revision === 0 && + rawStatus === WORKFLOW_STATUS.OPEN && + seenSequences.length === 0 && + sequence > 0 + ) { + return null; + } + return { workflowId, repositoryId, diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 8135dcd..57e7819 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1682,6 +1682,130 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(closedAfterHead(2), admitAtB()).rejection).toBe('WORKFLOW_CLOSED'); }); + /* ---- the gate slot must follow the HEAD that reached the revision ---- */ + + /** Awaiting a human at revision 1, with one historical admission at revision 0. */ + function awaitingWithHistoricalAdmission( + sequence: number, + admissionSequence: number, + ): WorkflowState { + return { + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + status: 'AWAITING_HUMAN_DECISION', + humanGateOpenedAtRevision: 1, + evidence: stored([ + { + evidenceId: EVIDENCE_A, + kind: 'ci-result', + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: admissionSequence, + }, + ]), + } as unknown as WorkflowState; + } + + it('refuses an open gate with no slot after the revision-advancing HEAD', () => { + // The admission holds slot 2, so the HEAD reaching revision 1 must hold + // slot 3, leaving nothing for HUMAN_GATE_OPENED. + expect( + applyWorkflowEvent(awaitingWithHistoricalAdmission(3, 2), closeWorkflow()).rejection, + ).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the same history once a slot exists after that HEAD', () => { + expect( + applyWorkflowEvent(awaitingWithHistoricalAdmission(4, 2), closeWorkflow()).outcome, + ).toBe('APPLIED'); + }); + + it('accepts a genuinely replayed admission, HEAD and gate history', () => { + let state = applyOrThrow(openedWorkflow(), admitEvidence()); + state = applyOrThrow(state, observeHead(SHA_B)); + state = applyOrThrow(state, openHumanGate(SHA_B)); + + expect(state.evidence[0]?.admittedAtSequence).toBe(1); + expect(state.revision).toBe(1); + expect(state.sequence).toBe(3); + expect(applyWorkflowEvent(state, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('leaves an A1 gate-clearing history readable', () => { + // Gate opened, then cleared by a HEAD advance: the workflow is OPEN, so the + // gate rule does not apply and the slot the cleared gate consumed is not + // required to be explainable. + const cleared = applyOrThrow( + applyOrThrow(openedWorkflow(), openHumanGate()), + observeHead(SHA_B), + ); + + expect(cleared.status).toBe('OPEN'); + expect(cleared.revision).toBe(1); + expect(cleared.sequence).toBe(2); + expect(cleared.invocations).toEqual([]); + expect(applyWorkflowEvent(cleared, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('leaves a human-decision gate-clearing history readable', () => { + const cleared = applyOrThrow( + applyOrThrow(openedWorkflow(), openHumanGate()), + admitEvidence(buildHumanDecisionVerdict()), + ); + + expect(cleared.status).toBe('OPEN'); + expect(cleared.revision).toBe(0); + expect(cleared.sequence).toBe(2); + expect(applyWorkflowEvent(cleared, closeWorkflow()).outcome).toBe('APPLIED'); + }); + + it('leaves a gate re-opened after a clear readable', () => { + let state = applyOrThrow(openedWorkflow(), openHumanGate()); + state = applyOrThrow(state, observeHead(SHA_B)); + state = applyOrThrow(state, openHumanGate(SHA_B)); + + expect(state.status).toBe('AWAITING_HUMAN_DECISION'); + expect(state.sequence).toBe(3); + expect(applyWorkflowEvent(state, observeHead(SHA_C)).outcome).toBe('APPLIED'); + }); + + /* ---- revision 0, OPEN, no stamps: only sequence 0 is reachable ---- */ + + it('refuses an untouched workflow claiming a consumed sequence slot', () => { + for (const sequence of [1, 2]) { + const forged = { ...openedWorkflow(), sequence } as WorkflowState; + + expect(forged.invocations).toEqual([]); + expect(applyWorkflowEvent(forged, observeHead(SHA_B)).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + } + }); + + it('accepts a freshly opened workflow at sequence 0', () => { + const fresh = openedWorkflow(); + + expect(fresh.revision).toBe(0); + expect(fresh.status).toBe('OPEN'); + expect(fresh.sequence).toBe(0); + expect(applyWorkflowEvent(fresh, observeHead(SHA_B)).outcome).toBe('APPLIED'); + }); + + it('does not apply the untouched-workflow rule once any history exists', () => { + // A retained stamp explains the slot. + expect(applyOrThrow(openedWorkflow(), requestInvocation()).sequence).toBe(1); + // A HEAD advance explains it, at revision 1 rather than 0. + expect(applyOrThrow(openedWorkflow(), observeHead(SHA_B)).sequence).toBe(1); + // A cleared gate leaves no stamp, and the rule must not reach it. + const cleared = applyOrThrow( + applyOrThrow(openedWorkflow(), openHumanGate()), + admitEvidence(buildHumanDecisionVerdict()), + ); + expect(applyWorkflowEvent(cleared, closeWorkflow()).outcome).toBe('APPLIED'); + }); + /* ---- intervening HEAD transitions must have sequence slots of their own ---- */ /** One invocation whose request and report straddle a revision advance. */ From c18569b4fbb8ef7605eca522f64eea75cb99036a Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 03:42:53 +0200 Subject: [PATCH 15/20] fix: reserve final sequence slot for closed workflows Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 22 +++++++---- tests/domain/workflow-invariants.test.ts | 50 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 286234e..6a06394 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -985,15 +985,21 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { return null; } - // Counting is not enough on its own: the gate slot must also sit *after* the - // final `HEAD_OBSERVED` that reached the current revision. That HEAD follows - // every stamp recorded at an earlier revision, and the gate follows the HEAD, - // so the gate's slot is at least two past the latest earlier-revision stamp. + // Counting is not enough on its own: the slot belonging to the transition + // that produced the current status must also sit *after* the final + // `HEAD_OBSERVED` that reached the current revision. That HEAD follows every + // stamp recorded at an earlier revision, and the status transition follows + // the HEAD, so its slot is at least two past the latest earlier-revision + // stamp. This holds for `HUMAN_GATE_OPENED` and for `CLOSE_REQUESTED` alike. // - // Only an open gate at revision >= 1 is constrained. A gate that was already - // cleared — by a HEAD advance or by a human decision — leaves the workflow - // `OPEN`, and those histories are deliberately left alone. - if (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION && revision > 0) { + // Only an open gate or a closure at revision >= 1 is constrained. A gate that + // was already cleared — by a HEAD advance or by a human decision — leaves the + // workflow `OPEN`, and those histories are deliberately left alone. + if ( + (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION || + rawStatus === WORKFLOW_STATUS.CLOSED) && + revision > 0 + ) { let latestEarlier = 0; for (let index = 0; index < spanRevisions.length; index += 1) { const bandRevision = spanRevisions[index]; diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 57e7819..c622e0d 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1771,6 +1771,56 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(state, observeHead(SHA_C)).outcome).toBe('APPLIED'); }); + /* ---- the closing slot must follow the HEAD that reached the revision ---- */ + + /** Closed at revision 1, with the decision that cleared a gate at revision 0. */ + function closedAfterClearedGate(sequence: number): WorkflowState { + return { + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + evidence: stored([ + { + evidenceId: EVIDENCE_A, + kind: 'human-decision', + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: 2, + }, + ]), + } as unknown as WorkflowState; + } + + it('refuses a closed state with no slot after the revision-advancing HEAD', () => { + // The gate took slot 1 and the clearing decision slot 2, so the HEAD that + // reached revision 1 took slot 3 and CLOSE_REQUESTED cannot share it. + expect(applyWorkflowEvent(closedAfterClearedGate(3), admitAtB()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + }); + + it('accepts the same closed history once a slot exists after that HEAD', () => { + // Readable now, so the terminal-status rule is what refuses the event. + expect(applyWorkflowEvent(closedAfterClearedGate(4), admitAtB()).rejection).toBe( + 'WORKFLOW_CLOSED', + ); + }); + + it('accepts a genuinely replayed gate, decision, HEAD and closure history', () => { + let state = applyOrThrow(openedWorkflow(), openHumanGate()); + state = applyOrThrow(state, admitEvidence(buildHumanDecisionVerdict())); + state = applyOrThrow(state, observeHead(SHA_B)); + state = applyOrThrow(state, closeWorkflow()); + + expect(state.status).toBe('CLOSED'); + expect(state.revision).toBe(1); + expect(state.sequence).toBe(4); + expect(applyWorkflowEvent(state, admitAtB()).rejection).toBe('WORKFLOW_CLOSED'); + }); + /* ---- revision 0, OPEN, no stamps: only sequence 0 is reachable ---- */ it('refuses an untouched workflow claiming a consumed sequence slot', () => { From a01f54d58ebe88d682fd02e8b69e2da62b8b14a7 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 04:05:03 +0200 Subject: [PATCH 16/20] fix: account for terminal and gate slots in chronology Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 63 +++++++++--- tests/domain/workflow-invariants.test.ts | 117 +++++++++++++++++++++++ 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 6a06394..b75fb7d 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -971,20 +971,41 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { // `AWAITING_HUMAN_DECISION` comes from `HUMAN_GATE_OPENED` and `CLOSED` from // `CLOSE_REQUESTED`; `OPEN` is the opening state and requires nothing. // + // A `CLOSED` aggregate still carrying a gate posture needs *two* such slots. + // Closing retains `humanGateOpenedAtRevision` untouched, so that gate was + // opened by a `HUMAN_GATE_OPENED` of its own and the closure came later; both + // stamp nothing. Under `AWAITING_HUMAN_DECISION` the gate *is* the status + // transition and is already counted once. + const closedGateSlot = + rawStatus === WORKFLOW_STATUS.CLOSED && humanGateOpenedAtRevision !== null ? 1 : 0; + // Counting retained stamps alone under-counts the occupied slots: reaching // `revision` also cost that many `HEAD_OBSERVED` transitions, and those stamp - // nothing, so they never appear among the retained stamps. All three groups + // nothing, so they never appear among the retained stamps. All these groups // occupy distinct slots in `[1, sequence]`, hence // - // sequence >= revision + retained stamps + 1 + // sequence >= revision + retained stamps + 1 + retained gate slot if ( (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION || rawStatus === WORKFLOW_STATUS.CLOSED) && - sequence <= revision + seenSequences.length + sequence <= revision + seenSequences.length + closedGateSlot ) { return null; } + // `CLOSE_REQUESTED` is terminal — nothing can follow it — so it is the last + // transition the aggregate ran and owns the slot its counter names. A + // retained stamp sitting on that same slot would have to be the closing + // transition itself, and closing stamps nothing. + if (rawStatus === WORKFLOW_STATUS.CLOSED) { + for (let index = 0; index < seenSequences.length; index += 1) { + const stamp = seenSequences[index]; + if (stamp === undefined || stamp >= sequence) { + return null; + } + } + } + // Counting is not enough on its own: the slot belonging to the transition // that produced the current status must also sit *after* the final // `HEAD_OBSERVED` that reached the current revision. That HEAD follows every @@ -1016,18 +1037,30 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { } } - // At revision 0 with no retained stamp and no status-producing transition, - // nothing could have consumed a sequence slot: every event either stamps a - // record, advances the revision, opens the gate, or closes the workflow. - // Deliberately narrow — no general upper bound is claimed here, because a - // cleared gate legitimately consumes a slot it leaves no trace of. - if ( - revision === 0 && - rawStatus === WORKFLOW_STATUS.OPEN && - seenSequences.length === 0 && - sequence > 0 - ) { - return null; + // At revision 0 an `OPEN` workflow's unstamped transitions are knowable, so + // the slots can be bounded from above too. No `HEAD_OBSERVED` has been + // applied, and a `CLOSE_REQUESTED` would have left the workflow `CLOSED`, so + // the only unstamped transition it can have run is `HUMAN_GATE_OPENED` — and + // with no HEAD advance available, the sole way back to `OPEN` is admitting a + // `human-decision`, which is retained with a stamp of its own. Every gate + // opened here is therefore paid for by a retained human decision: + // + // sequence <= retained stamps + retained human decisions + // + // Deliberately bounded to revision 0 and `OPEN`. Once a HEAD advance is in + // play it clears a gate while leaving nothing behind, and no upper bound is + // claimed there. With no stamps at all this reduces to the untouched + // workflow: only sequence 0 is reachable. + if (revision === 0 && rawStatus === WORKFLOW_STATUS.OPEN) { + let humanDecisions = 0; + for (let index = 0; index < evidence.length; index += 1) { + if (evidence[index]?.kind === EVIDENCE_KIND.HUMAN_DECISION) { + humanDecisions += 1; + } + } + if (sequence > seenSequences.length + humanDecisions) { + return null; + } } return { diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index c622e0d..471f0d3 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1821,6 +1821,123 @@ describe('group J — hostile input fails closed', () => { expect(applyWorkflowEvent(state, admitAtB()).rejection).toBe('WORKFLOW_CLOSED'); }); + /* ---- the closing transition owns the aggregate's final slot ---- */ + + /** Closed at revision 0, with one admission stamped at `admissionSequence`. */ + function closedWithAdmission(sequence: number, admissionSequence: number): WorkflowState { + return { + ...openedWorkflow(), + sequence, + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + evidence: stored([ + { + evidenceId: EVIDENCE_A, + kind: 'ci-result', + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: admissionSequence, + }, + ]), + } as unknown as WorkflowState; + } + + it('refuses a closed state whose final slot is held by a retained stamp', () => { + // CLOSE_REQUESTED is terminal, so slot 2 cannot be both the admission and + // the closure. + expect(applyWorkflowEvent(closedWithAdmission(2, 2), admitAtB()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + }); + + it('accepts the same closed history with a slot of its own for the closure', () => { + // Readable now, so the terminal-status rule is what refuses the event. + expect(applyWorkflowEvent(closedWithAdmission(3, 2), admitAtB()).rejection).toBe( + 'WORKFLOW_CLOSED', + ); + }); + + /* ---- a closed aggregate that kept its gate posture paid for both ---- */ + + /** Closed at revision 0 while a human was still deciding. */ + const closedWhileGated = (sequence: number): WorkflowState => + ({ + ...openedWorkflow(), + sequence, + status: 'CLOSED', + closureReason: 'CALLER_CLOSED', + humanGateOpenedAtRevision: 0, + }) as unknown as WorkflowState; + + it('refuses a closed gated state with room for only one of the two slots', () => { + expect(applyWorkflowEvent(closedWhileGated(1), admitAtB()).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + }); + + it('accepts a closed gated state with room for the gate and the closure', () => { + expect(applyWorkflowEvent(closedWhileGated(2), admitAtB()).rejection).toBe( + 'WORKFLOW_CLOSED', + ); + }); + + it('keeps a genuinely replayed gate-then-close history valid', () => { + const closed = applyOrThrow( + applyOrThrow(openedWorkflow(), openHumanGate()), + closeWorkflow(), + ); + + expect(closed.status).toBe('CLOSED'); + expect(closed.humanGateOpenedAtRevision).toBe(0); + expect(closed.sequence).toBe(2); + expect(applyWorkflowEvent(closed, admitAtB()).rejection).toBe('WORKFLOW_CLOSED'); + }); + + /* ---- revision 0, OPEN: only a human decision explains an extra slot ---- */ + + /** Open at revision 0 with one admission of `kind` at `admissionSequence`. */ + function openWithAdmission( + sequence: number, + kind: string, + admissionSequence: number, + ): WorkflowState { + return { + ...openedWorkflow(), + sequence, + evidence: stored([ + { + evidenceId: EVIDENCE_A, + kind, + admittedAtCommitSha: SHA_A, + admittedAtRevision: 0, + admittedAtSequence: admissionSequence, + }, + ]), + } as unknown as WorkflowState; + } + + /** Probe an OPEN state: APPLIED when readable. */ + const probeOpen = (state: WorkflowState): string | null => + applyWorkflowEvent(state, observeHead(SHA_B)).rejection; + + it('refuses a revision-0 open state whose extra slot nothing explains', () => { + // Slot 1 is the ci-result admission. No HEAD advance has happened, a + // closure would not leave the workflow OPEN, and only a human decision + // could have cleared a gate — so slot 2 has no explanation. + expect(probeOpen(openWithAdmission(2, 'ci-result', 1))).toBe('WORKFLOW_UNREADABLE'); + }); + + it('accepts the same admission with no unexplained slot', () => { + expect(probeOpen(openWithAdmission(1, 'ci-result', 1))).toBe(null); + }); + + it('lets a retained human decision explain the gate slot it cleared', () => { + // Gate at slot 1, cleared by the human decision stamped at slot 2. + expect(probeOpen(openWithAdmission(2, 'human-decision', 2))).toBe(null); + // One retained decision explains one gate, not two. + expect(probeOpen(openWithAdmission(3, 'human-decision', 2))).toBe('WORKFLOW_UNREADABLE'); + }); + /* ---- revision 0, OPEN, no stamps: only sequence 0 is reachable ---- */ it('refuses an untouched workflow claiming a consumed sequence slot', () => { From d6a73afcb58f8744695e25f9c02bffb86e01afc8 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 05:16:27 +0200 Subject: [PATCH 17/20] fix: enforce revision sequence upper bound Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 33 ++++++++++++++---------- tests/domain/workflow-invariants.test.ts | 31 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index b75fb7d..5d0af3f 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -1037,28 +1037,33 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { } } - // At revision 0 an `OPEN` workflow's unstamped transitions are knowable, so - // the slots can be bounded from above too. No `HEAD_OBSERVED` has been - // applied, and a `CLOSE_REQUESTED` would have left the workflow `CLOSED`, so - // the only unstamped transition it can have run is `HUMAN_GATE_OPENED` — and - // with no HEAD advance available, the sole way back to `OPEN` is admitting a - // `human-decision`, which is retained with a stamp of its own. Every gate - // opened here is therefore paid for by a retained human decision: + // An `OPEN` workflow's unstamped transitions are knowable, so its slots can + // be bounded from above too. A `CLOSE_REQUESTED` would have left the workflow + // `CLOSED`, so every slot is a retained stamp, one of the `revision` + // `HEAD_OBSERVED` advances, or a `HUMAN_GATE_OPENED`: // - // sequence <= retained stamps + retained human decisions + // sequence = retained stamps + revision + gates opened // - // Deliberately bounded to revision 0 and `OPEN`. Once a HEAD advance is in - // play it clears a gate while leaving nothing behind, and no upper bound is - // claimed there. With no stamps at all this reduces to the untouched - // workflow: only sequence 0 is reachable. - if (revision === 0 && rawStatus === WORKFLOW_STATUS.OPEN) { + // No gate is open now, so every gate that was opened was cleared, and a clear + // costs either one of those same HEAD advances or one admitted + // `human-decision` — each of which clears at most one gate. Hence + // + // gates opened <= revision + retained human decisions + // + // Nothing here reconstructs which slot held what; only the totals are read. + // At revision 0 this reduces to stamps plus retained decisions, and with no + // stamps at all to the untouched workflow, where only sequence 0 is + // reachable. Deliberately confined to `OPEN`: a retained gate or closure + // posture is accounted for by the lower bounds above. + if (rawStatus === WORKFLOW_STATUS.OPEN) { let humanDecisions = 0; for (let index = 0; index < evidence.length; index += 1) { if (evidence[index]?.kind === EVIDENCE_KIND.HUMAN_DECISION) { humanDecisions += 1; } } - if (sequence > seenSequences.length + humanDecisions) { + const gatesOpened = revision + humanDecisions; + if (sequence > seenSequences.length + revision + gatesOpened) { return null; } } diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 471f0d3..5f6f94b 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -913,10 +913,13 @@ describe('group J — hostile input fails closed', () => { } }); + // The sequence may run ahead of the revision, but only as far as the + // transitions the state still accounts for: at revision 1 with nothing + // retained, the HEAD advance and the gate it cleared reach slot 2. it.each([ [0, 0], [1, 1], - [1, 5], + [1, 2], ])('accepts an otherwise valid state with revision %i and sequence %i', (revision, sequence) => { const forged = { ...openedWorkflow(), revision, sequence } as WorkflowState; @@ -1938,6 +1941,32 @@ describe('group J — hostile input fails closed', () => { expect(probeOpen(openWithAdmission(3, 'human-decision', 2))).toBe('WORKFLOW_UNREADABLE'); }); + /* ---- the open upper bound follows the revision past zero ---- */ + + /** Open at revision 1 with nothing retained at all. */ + const openAfterHead = (sequence: number): WorkflowState => + ({ + ...openedWorkflow(), + boundCommitSha: SHA_B, + revision: 1, + sequence, + }) as unknown as WorkflowState; + + it('refuses a revision-1 open state whose extra slots nothing explains', () => { + // One HEAD advance reached revision 1 and one gate could have been opened + // for it to clear, so slot 3 has no explanation: nothing is retained, so no + // human decision cleared a second gate. + expect(applyWorkflowEvent(openAfterHead(3), observeHead(SHA_C)).rejection).toBe( + 'WORKFLOW_UNREADABLE', + ); + }); + + it('accepts revision-1 open states the HEAD advance alone explains', () => { + // The bare advance, and the advance that also cleared a gate. + expect(applyWorkflowEvent(openAfterHead(1), observeHead(SHA_C)).outcome).toBe('APPLIED'); + expect(applyWorkflowEvent(openAfterHead(2), observeHead(SHA_C)).outcome).toBe('APPLIED'); + }); + /* ---- revision 0, OPEN, no stamps: only sequence 0 is reachable ---- */ it('refuses an untouched workflow claiming a consumed sequence slot', () => { From 7587ad91f8930aac797e8b8fa292d90e53ecdbe8 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Wed, 12 Aug 2026 05:37:36 +0200 Subject: [PATCH 18/20] fix: count only human decisions that can clear a gate Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/workflow-transitions.ts | 27 ++++++++++++++++++------ tests/domain/workflow-invariants.test.ts | 8 +++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 5d0af3f..6de0b3b 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -1048,21 +1048,36 @@ function snapshotWorkflow(state: WorkflowState): WorkflowSnapshot | null { // costs either one of those same HEAD advances or one admitted // `human-decision` — each of which clears at most one gate. Hence // - // gates opened <= revision + retained human decisions + // gates opened <= revision + clearing human decisions + // + // A decision only counts as clearing when a gate could have preceded it: the + // `HUMAN_GATE_OPENED` it cleared has to sit at a lower slot, and that slot + // cannot be one a retained stamp already holds. A decision stamped at slot 1 + // clears nothing, because nothing ran before it. // // Nothing here reconstructs which slot held what; only the totals are read. - // At revision 0 this reduces to stamps plus retained decisions, and with no + // At revision 0 this reduces to stamps plus clearing decisions, and with no // stamps at all to the untouched workflow, where only sequence 0 is // reachable. Deliberately confined to `OPEN`: a retained gate or closure // posture is accounted for by the lower bounds above. if (rawStatus === WORKFLOW_STATUS.OPEN) { - let humanDecisions = 0; + let clearingDecisions = 0; for (let index = 0; index < evidence.length; index += 1) { - if (evidence[index]?.kind === EVIDENCE_KIND.HUMAN_DECISION) { - humanDecisions += 1; + const admitted = evidence[index]; + if (admitted !== undefined && admitted.kind === EVIDENCE_KIND.HUMAN_DECISION) { + let stampsBelow = 0; + for (let other = 0; other < seenSequences.length; other += 1) { + const stamp = seenSequences[other]; + if (stamp !== undefined && stamp < admitted.admittedAtSequence) { + stampsBelow += 1; + } + } + if (admitted.admittedAtSequence - 1 > stampsBelow) { + clearingDecisions += 1; + } } } - const gatesOpened = revision + humanDecisions; + const gatesOpened = revision + clearingDecisions; if (sequence > seenSequences.length + revision + gatesOpened) { return null; } diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index 5f6f94b..d76c55f 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -1941,6 +1941,14 @@ describe('group J — hostile input fails closed', () => { expect(probeOpen(openWithAdmission(3, 'human-decision', 2))).toBe('WORKFLOW_UNREADABLE'); }); + it('refuses a decision with no earlier slot for the gate it would clear', () => { + // The decision itself holds slot 1, so no HUMAN_GATE_OPENED could have run + // before it and nothing explains slot 2. + expect(probeOpen(openWithAdmission(2, 'human-decision', 1))).toBe('WORKFLOW_UNREADABLE'); + // With the decision one slot later the gate fits below it again. + expect(probeOpen(openWithAdmission(2, 'human-decision', 2))).toBe(null); + }); + /* ---- the open upper bound follows the revision past zero ---- */ /** Open at revision 1 with nothing retained at all. */ From 87223760daee4873c6458a4762a336f5edb2d7c1 Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 24 Aug 2026 09:12:10 +0200 Subject: [PATCH 19/20] fix: insulate workflow descriptor objects PR9-WF-F1: descriptor construction in workflow.ts::append and the inline objectDefineProperty sites in workflow-transitions.ts::noteRevisionSpan used ordinary prototype-inheriting PropertyDescriptor literals. Under a poisoned inherited Object.prototype.get/set, ToPropertyDescriptor observes the inherited accessor fields and throws TypeError, so workflow evaluation could throw instead of returning the intended deterministic applied state or rejection. Capture Object.setPrototypeOf at module load beside the existing intrinsics and null-prototype each descriptor before the captured Object.defineProperty consumes it. Descriptor flags, index semantics, and revision/sequence ordering are unchanged; the only behavioural change is that prototype-poison-induced TypeError becomes the already-intended fail-closed result. Co-Authored-By: Claude Opus 4.8 --- src/domain/workflow-transitions.ts | 17 +- src/domain/workflow.ts | 13 +- tests/domain/workflow-invariants.test.ts | 189 ++++++++++++++++++++++ tests/domain/workflow-transitions.test.ts | 131 +++++++++++++++ 4 files changed, 344 insertions(+), 6 deletions(-) diff --git a/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts index 6de0b3b..803c73c 100644 --- a/src/domain/workflow-transitions.ts +++ b/src/domain/workflow-transitions.ts @@ -105,6 +105,7 @@ const objectFreeze = Object.freeze; const objectHasOwn = Object.hasOwn; const arrayIsArray = Array.isArray; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectIsFrozen = Object.isFrozen; const reflectIsExtensible = Reflect.isExtensible; const reflectOwnKeys = Reflect.ownKeys; @@ -318,20 +319,28 @@ function noteRevisionSpan( const low = lowest[index]; const high = highest[index]; if (low !== undefined && sequence < low) { - objectDefineProperty(lowest, index, { + // Null-prototype the descriptor before `defineProperty` reads it, so a + // poisoned inherited `get`/`set` cannot be observed by + // `ToPropertyDescriptor` and turn this stamp into a thrown `TypeError`. + // Same insulation as `workflow.ts::append`, kept inline here on purpose. + const descriptor: PropertyDescriptor = { value: sequence, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(lowest, index, descriptor); } if (high !== undefined && sequence > high) { - objectDefineProperty(highest, index, { + const descriptor: PropertyDescriptor = { value: sequence, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(highest, index, descriptor); } return; } diff --git a/src/domain/workflow.ts b/src/domain/workflow.ts index 107058f..bcc4949 100644 --- a/src/domain/workflow.ts +++ b/src/domain/workflow.ts @@ -60,6 +60,7 @@ import type { InvocationReportResult } from './agent-invocation-report.js'; */ const objectFreeze = Object.freeze; const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; const objectHasOwn = Object.hasOwn; const objectIs = Object.is; const numberIsInteger = Number.isInteger; @@ -90,12 +91,20 @@ function containsValue(list: readonly string[], value: unknown): boolean { /** Append by defining an own element, bypassing inherited index setters. */ export function append(list: T[], value: T): void { - objectDefineProperty(list, list.length, { + // The descriptor is null-prototyped before `defineProperty` consumes it. + // `ToPropertyDescriptor` tests `get`/`set` with `HasProperty`, which walks the + // prototype chain: an ordinary literal inherits from `Object.prototype`, so a + // poisoned inherited `get`/`set` would be observed and throw `TypeError` + // instead of appending. Severing the prototype leaves only the own data + // fields visible, so conversion sees exactly what is written here. + const descriptor: PropertyDescriptor = { value, writable: true, enumerable: true, configurable: true, - }); + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(list, list.length, descriptor); } /** diff --git a/tests/domain/workflow-invariants.test.ts b/tests/domain/workflow-invariants.test.ts index d76c55f..f352f90 100644 --- a/tests/domain/workflow-invariants.test.ts +++ b/tests/domain/workflow-invariants.test.ts @@ -27,6 +27,7 @@ import { type WorkflowEvent, type WorkflowState, } from '../../src/domain/index.js'; +import { append } from '../../src/domain/workflow.js'; import { admitEvidence, admitReview, @@ -127,6 +128,46 @@ function withPoisoned(target: object, key: PropertyKey, value: unknown, body: () } } +/** + * Plant inherited accessor fields on `Object.prototype` and restore them exactly. + * + * A poisoned `get`/`set` is what makes `ToPropertyDescriptor` throw on any + * ordinary descriptor literal: it sees an inherited, callable accessor sitting + * beside the literal's own `value`/`writable`, the one combination the intrinsic + * refuses. The installing descriptor is itself null-prototyped, so poisoning + * `set` while `get` is already poisoned does not disrupt the very + * `defineProperty` call that installs it — the harness stays valid under the + * exact condition it exercises, and cannot mask the defect with its own throw. + */ +function withAccessorPoison(keys: readonly PropertyKey[], body: () => void): void { + const proto = Object.prototype; + const saved = keys.map((key) => Object.getOwnPropertyDescriptor(proto, key)); + const poison: PropertyDescriptor = { + value(): unknown { + return undefined; + }, + writable: true, + enumerable: false, + configurable: true, + }; + Object.setPrototypeOf(poison, null); + try { + for (const key of keys) { + Object.defineProperty(proto, key, poison); + } + body(); + } finally { + keys.forEach((key, index) => { + const original = saved[index]; + if (original === undefined) { + Reflect.deleteProperty(proto, key); + } else { + Object.defineProperty(proto, key, original); + } + }); + } +} + describe('group H — provider, purpose, and reported status are inert', () => { /** Replace the three recorded label fields, so only they may differ. */ function withoutLabels(state: WorkflowState): unknown { @@ -2822,3 +2863,151 @@ describe('group M — forbidden vocabulary', () => { } }); }); + +describe('group N — PR9-WF-F1: descriptor objects survive prototype poisoning', () => { + // `append` builds a PropertyDescriptor and hands it to `Object.defineProperty`. + // `ToPropertyDescriptor` probes `get`/`set` with `HasProperty`, which walks the + // prototype chain, so an inherited poison on `Object.prototype` was observed by + // the conversion and threw `TypeError` — turning the layer's intended + // deterministic result into an unexpected throw. Every case here must complete + // without throwing and must leave the realm exactly as it found it. + + // The realm is always restored before any assertion runs: a matcher such as + // `toEqual` builds descriptor objects of its own, which would themselves throw + // under the poison and mask what is being tested (Section 14). Every case + // captures plain values inside the poisoned block and asserts once outside it. + + it('appends under a poisoned Object.prototype.get without throwing', () => { + const list: number[] = []; + withAccessorPoison(['get'], () => { + append(list, 7); + }); + expect(list).toHaveLength(1); + expect(list[0]).toBe(7); + }); + + it('appends under a poisoned Object.prototype.set without throwing', () => { + const list: string[] = []; + withAccessorPoison(['set'], () => { + append(list, 'x'); + }); + expect(list).toEqual(['x']); + }); + + it('appends under a poisoned get *and* set without throwing', () => { + const list: number[] = []; + withAccessorPoison(['get', 'set'], () => { + append(list, 1); + append(list, 2); + }); + expect(list).toEqual([1, 2]); + }); + + it('preserves append index and flag semantics under poison', () => { + const list: number[] = []; + let descriptor: PropertyDescriptor | undefined; + withAccessorPoison(['get', 'set'], () => { + append(list, 42); + descriptor = Object.getOwnPropertyDescriptor(list, 0); + }); + expect(descriptor).toEqual({ + value: 42, + writable: true, + enumerable: true, + configurable: true, + }); + }); + + it.each([['get'], ['set'], ['get', 'set']] as const)( + 'reaches append through applyWorkflowEvent under %s poison and still applies', + (...keys) => { + // `requested()` carries one invocation, so validating it re-runs the + // snapshot append path before the report is applied. Compare against the + // clean evaluation: same input semantics must yield the same output. + const clean = applyWorkflowEvent(requested(), reportInvocation()); + let poisoned: unknown; + + withAccessorPoison([...keys], () => { + poisoned = applyWorkflowEvent(requested(), reportInvocation()); + }); + + expect(poisoned).toEqual(clean); + expect((poisoned as typeof clean).outcome).toBe('APPLIED'); + expect((poisoned as typeof clean).state.invocations[0]?.state).toBe('REPORTED'); + }, + ); + + it('returns the identical prior state on a rejection reached under poison', () => { + const prior = requested(); + let result: ReturnType | undefined; + + withAccessorPoison(['get', 'set'], () => { + // A duplicate invocation id rejects, and the snapshot of `prior` reaches + // `append` on the way to that rejection. + result = applyWorkflowEvent(prior, requestInvocation()); + }); + + expect(result?.outcome).toBe('REJECTED'); + expect(result?.rejection).toBe('DUPLICATE_INVOCATION_ID'); + expect(result?.state).toBe(prior); + }); + + it.each([['get'], ['set']] as const)( + 'survives an Object.prototype.%s poison installed mid-evaluation before a later append', + (key) => { + const proto = Object.prototype; + const saved = Object.getOwnPropertyDescriptor(proto, key); + const base = requested(); + const hostile = { ...base } as Record; + const poison: PropertyDescriptor = { + value(): unknown { + return undefined; + }, + writable: true, + enumerable: false, + configurable: true, + }; + Object.setPrototypeOf(poison, null); + // The bound commit is read early in the snapshot; arming the poison from + // its getter guarantees the poison is live before the invocation list's + // `append` calls run later in the same evaluation. + Object.defineProperty(hostile, 'boundCommitSha', { + get(): string { + Object.defineProperty(proto, key, poison); + return base.boundCommitSha; + }, + enumerable: true, + configurable: true, + }); + + let outcome: string | undefined; + let invocationState: string | undefined; + try { + const result = applyWorkflowEvent( + hostile as unknown as WorkflowState, + reportInvocation(), + ); + outcome = result.outcome; + invocationState = result.state.invocations[0]?.state; + } finally { + if (saved === undefined) { + Reflect.deleteProperty(proto, key); + } else { + Object.defineProperty(proto, key, saved); + } + } + + // Assert only after the realm is restored, so the matcher itself runs + // against a clean `Object.prototype`. + expect(outcome).toBe('APPLIED'); + expect(invocationState).toBe('REPORTED'); + }, + ); + + it('leaves Object.prototype.get and Object.prototype.set untouched afterwards', () => { + // Every case above restores in `finally`; this pins that the realm is clean + // once the group has run, so no later test inherits a poisoned prototype. + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); + }); +}); diff --git a/tests/domain/workflow-transitions.test.ts b/tests/domain/workflow-transitions.test.ts index 8b47a1e..5a4c9df 100644 --- a/tests/domain/workflow-transitions.test.ts +++ b/tests/domain/workflow-transitions.test.ts @@ -1283,3 +1283,134 @@ describe('applyWorkflowEvent — group N, end-to-end lifecycle replay', () => { expect(label(REVIEW_B)).toContain(REVIEW_B); }); }); + +/** + * Plant inherited accessor fields on `Object.prototype`, restoring them exactly. + * + * The installing descriptor is null-prototyped so that poisoning `set` while + * `get` is already poisoned does not disrupt the very `defineProperty` that + * installs it — the harness stays valid under the same condition it exercises. + */ +function withAccessorPoison(keys: readonly PropertyKey[], body: () => void): void { + const proto = Object.prototype; + const saved = keys.map((key) => Object.getOwnPropertyDescriptor(proto, key)); + const poison: PropertyDescriptor = { + value(): unknown { + return undefined; + }, + writable: true, + enumerable: false, + configurable: true, + }; + Object.setPrototypeOf(poison, null); + try { + for (const key of keys) { + Object.defineProperty(proto, key, poison); + } + body(); + } finally { + keys.forEach((key, index) => { + const original = saved[index]; + if (original === undefined) { + Reflect.deleteProperty(proto, key); + } else { + Object.defineProperty(proto, key, original); + } + }); + } +} + +describe('PR9-WF-F1: noteRevisionSpan inline descriptors survive prototype poisoning', () => { + // `noteRevisionSpan` stamps its lowest/highest slots with `Object.defineProperty` + // over an inline descriptor. Those calls are on the public evaluation path: + // `applyWorkflowEvent` -> `snapshotWorkflow` -> `noteRevisionSpan`. An inherited + // `get`/`set` poison made `ToPropertyDescriptor` throw there, so a hostile realm + // turned an intended apply/rejection into an unexpected `TypeError`. + + /** One invocation requested then reported at the same revision. */ + function reportedInvocation(): WorkflowState { + return applyOrThrow(withRequestedInvocation(), reportInvocation()); + } + + /** + * A state whose two same-revision invocation records are ordered so the + * second-listed carries the lower sequence — driving `noteRevisionSpan` + * through its lowest-slot inline descriptor. Built from real transitions, + * then reordered; lists are refrozen to stay faithful to a produced state. + */ + function reachesLowestSpanSite(): WorkflowState { + let state = openedWorkflow(); + state = applyOrThrow(state, requestInvocation(buildInvocation({ invocationId: INVOCATION_A }))); + state = applyOrThrow(state, requestInvocation(buildInvocation({ invocationId: INVOCATION_B }))); + const invocations = Object.freeze([ + Object.freeze({ ...state.invocations[0], requestedAtSequence: 2 }), + Object.freeze({ ...state.invocations[1], requestedAtSequence: 1 }), + ]); + return Object.freeze({ ...state, invocations }) as WorkflowState; + } + + const deeplyFrozen = (state: WorkflowState): boolean => + Object.isFrozen(state) && + Object.isFrozen(state.invocations) && + Object.isFrozen(state.evidence) && + Object.isFrozen(state.reviews); + + it.each([['get'], ['set'], ['get', 'set']] as const)( + 'reaches the highest-slot inline descriptor under %s poison and applies unchanged', + (...keys) => { + const prior = reportedInvocation(); + const clean = applyWorkflowEvent(prior, admitEvidence()); + let poisoned: ReturnType | undefined; + + withAccessorPoison([...keys], () => { + poisoned = applyWorkflowEvent(prior, admitEvidence()); + }); + + expect(poisoned).toEqual(clean); + expect(poisoned?.outcome).toBe('APPLIED'); + // Chronology, revision, and sequence accounting are all unchanged. + expect(poisoned?.state.revision).toBe(clean.state.revision); + expect(poisoned?.state.sequence).toBe(clean.state.sequence); + expect(poisoned ? deeplyFrozen(poisoned.state) : false).toBe(true); + }, + ); + + it.each([['get'], ['set'], ['get', 'set']] as const)( + 'reaches the lowest-slot inline descriptor under %s poison with identical outcome', + (...keys) => { + const prior = reachesLowestSpanSite(); + const clean = applyWorkflowEvent(prior, admitEvidence()); + let poisoned: ReturnType | undefined; + + withAccessorPoison([...keys], () => { + poisoned = applyWorkflowEvent(prior, admitEvidence()); + }); + + // Whether the reordered state reads as applicable or as a deterministic + // rejection, the poisoned run must reproduce the clean run exactly. + expect(poisoned).toEqual(clean); + expect(poisoned?.outcome).toBe(clean.outcome); + expect(poisoned?.rejection).toBe(clean.rejection); + }, + ); + + it('preserves prior-state identity on a rejection reached through noteRevisionSpan', () => { + const prior = reportedInvocation(); + let poisoned: ReturnType | undefined; + + withAccessorPoison(['get', 'set'], () => { + // A duplicate invocation id rejects, but the snapshot of `prior` reaches + // `noteRevisionSpan` first. + poisoned = applyWorkflowEvent(prior, requestInvocation()); + }); + + expect(poisoned?.outcome).toBe('REJECTED'); + expect(poisoned?.rejection).toBe('DUPLICATE_INVOCATION_ID'); + expect(poisoned?.state).toBe(prior); + }); + + it('leaves the realm clean after exercising the inline descriptors', () => { + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'get')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(Object.prototype, 'set')).toBeUndefined(); + }); +}); From 15a35cc1f528e3c3a33d963fbe6cc590b90c356f Mon Sep 17 00:00:00 2001 From: LogicDuke Date: Mon, 24 Aug 2026 12:53:23 +0200 Subject: [PATCH 20/20] fix: fail closed on unreadable push force flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repair.push request whose own `force` operand is present but unreadable — an own getter or Proxy trap that throws, a getter that returns `undefined`, an own data property of `undefined`, or a presence check that throws — normalized to `force: false` and received ALLOW_ONCE, because the shared guarded reader reports both absence and a throwing read as `undefined` and the value-only force parser treated `undefined` as non-forced. Absence and present-but- unreadable collapsed into the same non-forced default, contradicting the documented invariant that only an absent or literally `false` force is non-forced. Replace the value-only parser with a presence-aware, fail-closed force reader local to job-operation.ts: an absent own `force` stays non-forced, a present own `force` is read at most once and is non-forced only when it reads as literally `false`, and any unreadable presence check or read fails closed to forced. `Object.hasOwn` is captured at module load. The shared `readOwnProperty` contract is unchanged; every other operand already fails closed. C1-JO-F1. Co-Authored-By: Claude Opus 4.8 --- src/domain/job-operation.ts | 58 ++++++- .../job-authorization-invariants.test.ts | 147 ++++++++++++++++++ 2 files changed, 197 insertions(+), 8 deletions(-) diff --git a/src/domain/job-operation.ts b/src/domain/job-operation.ts index abe5b5c..1326e98 100644 --- a/src/domain/job-operation.ts +++ b/src/domain/job-operation.ts @@ -36,6 +36,10 @@ import { } from './repair-job.js'; const objectFreeze = Object.freeze; +// Captured at module load, before any untrusted request is evaluated, so a +// property planted on a global after this module initializes cannot substitute +// the own-property test the force reader fails closed on. +const objectHasOwn = Object.hasOwn; /** * Operations a repair job may be authorized to perform. @@ -320,15 +324,53 @@ const UNREADABLE_OPERATION: NormalizedJobOperation = objectFreeze({ }); /** - * Read a force flag, failing closed. + * Read a push force flag, presence-aware and failing closed. * - * Absent or literally `false` is not a force. **Everything else is**, including - * `0`, `''`, `null`, `'false'`, and an object — a value that cannot be read as - * "definitely not forced" is treated as forced, and forced pushes are denied - * unconditionally. + * Only two shapes are "definitely not forced": an **absent** own `force`, and + * an own `force` whose value reads as literally `false`. **Everything else is + * forced**, and forced pushes are denied unconditionally. + * + * The distinction absence-versus-unreadable is the whole point. The shared + * {@link readOwnProperty} reader reports both an absent property and a getter + * that threw as `undefined`, so a value-only parser cannot tell "the caller + * sent nothing" from "the caller sent something this process could not read". + * Collapsing the second into the first is a fail-*open*: a present-but- + * unreadable force operand would normalize to non-forced and a push it could + * not establish as unforced would be authorized. So this reader looks at + * presence itself, not only a read value: + * + * - The own-property test is guarded; if it throws, presence is undecidable and + * the flag fails closed to forced. + * - An absent own `force` is not forced — the ordinary unforced push. + * - A present own `force` is read exactly once, own-only. If that read throws — + * an own getter or a Proxy `get` trap — it is unreadable and fails closed. + * - Only a read value of literally `false` is unforced. Any other value — + * `undefined` included, so a present-but-`undefined` own property is forced + * rather than mistaken for absence — is forced, as are `0`, `''`, `null`, + * `'false'`, and an object. No truthiness coercion is applied; `=== false` + * is the only unforced value. + * + * The own value is read at most once, so an accessor's getter runs at most once + * per {@link readJobOperation} call and the single-read snapshot discipline is + * preserved. Pure, total, deterministic, and never throws. */ -function readForceFlag(value: unknown): boolean { - return !(value === undefined || value === false); +function readForceFlag(record: object): boolean { + let present: boolean; + try { + present = objectHasOwn(record, 'force'); + } catch { + return true; + } + if (!present) { + return false; + } + let value: unknown; + try { + value = (record as Record).force; + } catch { + return true; + } + return value !== false; } /** @@ -376,7 +418,7 @@ export function readJobOperation(request: JobOperationRequest): NormalizedJobOpe sourceRefMalformed: sourceRef === null && rawSourceRef !== undefined, targetRef, targetRefMalformed: targetRef === null && rawTargetRef !== undefined, - force: readForceFlag(readOwnProperty(record, 'force')), + force: readForceFlag(record), }); } diff --git a/tests/domain/job-authorization-invariants.test.ts b/tests/domain/job-authorization-invariants.test.ts index 854c435..8fb9abc 100644 --- a/tests/domain/job-authorization-invariants.test.ts +++ b/tests/domain/job-authorization-invariants.test.ts @@ -722,6 +722,153 @@ describe('a value read twice cannot differ between validation and use', () => { }); }); +describe('C1-JO-F1: an unreadable push force operand fails closed', () => { + // The invariant, stated once: only an ABSENT own `force` or an own `force` + // that reads as literally `false` is "definitely not forced". A present but + // unreadable operand — an own data `undefined`, a getter that returns + // `undefined`, a getter or Proxy trap that throws, or a presence check that + // throws — must not collapse into the same non-forced default that absence + // has. It is forced, and a forced push is denied with no permit. + + function pushWithForceAccessor(descriptor: PropertyDescriptor): JobOperationRequest { + const request = { ...buildPush() }; + Object.defineProperty(request, 'force', { configurable: true, enumerable: true, ...descriptor }); + return request as unknown as JobOperationRequest; + } + + function expectForcedDenied(request: JobOperationRequest): void { + const decision = authorizeJobOperation(buildJob(), request); + expect(decision.decision).toBe(JOB_AUTHORIZATION.DENY); + expect(decision.reason).toBe(JOB_AUTHORIZATION_REASON.FORCE_PUSH_FORBIDDEN); + expect(decision.mayExecuteOnce).toBe(false); + expect(decision.permit).toBeNull(); + } + + it('preserves absent force as an unforced, authorized push', () => { + const request = buildPush(); + delete (request as { force?: unknown }).force; + expect(Object.hasOwn(request, 'force')).toBe(false); + expect(readJobOperation(request).force).toBe(false); + + const decision = authorizeJobOperation(buildJob(), request); + expect(decision.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.mayExecuteOnce).toBe(true); + expect(decision.permit?.operands.force).toBe(false); + }); + + it('preserves literally-false force as an unforced, authorized push', () => { + const request = buildPush({ force: false }); + expect(readJobOperation(request).force).toBe(false); + + const decision = authorizeJobOperation(buildJob(), request); + expect(decision.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.permit?.operands.force).toBe(false); + }); + + it('denies an honestly forced push', () => { + const request = buildPush({ force: true }); + expect(readJobOperation(request).force).toBe(true); + expectForcedDenied(request); + }); + + it('denies a present own force of undefined (absence and present-undefined do not collapse)', () => { + const request = pushWithForceAccessor({ value: undefined, writable: true }); + expect(Object.hasOwn(request, 'force')).toBe(true); + expect(readJobOperation(request).force).toBe(true); + expectForcedDenied(request); + }); + + it('denies an own getter that returns undefined, reading it exactly once', () => { + const getter = vi.fn(() => undefined); + const request = pushWithForceAccessor({ get: getter }); + expect(readJobOperation(request).force).toBe(true); + expect(getter).toHaveBeenCalledTimes(1); + expectForcedDenied(request); + }); + + it('still authorizes an own getter that returns literally false, reading it exactly once', () => { + const getter = vi.fn(() => false); + const request = pushWithForceAccessor({ get: getter }); + expect(readJobOperation(request).force).toBe(false); + expect(getter).toHaveBeenCalledTimes(1); + + const decision = authorizeJobOperation(buildJob(), request); + expect(decision.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE); + expect(decision.permit?.operands.force).toBe(false); + }); + + it('denies an own throwing force getter without throwing, reading it at most once', () => { + const getter = vi.fn(() => { + throw new Error('hostile force getter'); + }); + const request = pushWithForceAccessor({ get: getter }); + expect(Object.hasOwn(request, 'force')).toBe(true); + + const normalized = readJobOperation(request); + expect(normalized.readable).toBe(true); + expect(normalized.force).toBe(true); + expect(getter).toHaveBeenCalledTimes(1); + expectForcedDenied(request); + }); + + it('denies a Proxy whose force get trap throws, without throwing', () => { + const request = new Proxy( + { ...buildPush() }, + { + get(target, key, receiver): unknown { + if (key === 'force') { + throw new Error('hostile force get trap'); + } + return Reflect.get(target, key, receiver); + }, + }, + ) as unknown as JobOperationRequest; + expect(readJobOperation(request).force).toBe(true); + expectForcedDenied(request); + }); + + it('denies a Proxy whose own-property detection for force throws, without throwing', () => { + const request = new Proxy( + { ...buildPush() }, + { + getOwnPropertyDescriptor(target, key) { + if (key === 'force') { + throw new Error('hostile force own-property trap'); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ) as unknown as JobOperationRequest; + expect(readJobOperation(request).force).toBe(true); + expectForcedDenied(request); + }); + + it('denies every present non-false force value', () => { + const forcedValues: readonly unknown[] = [undefined, null, 0, '', 'false', {}, []]; + for (const value of forcedValues) { + const request = buildPush({ force: value as never }); + expect(readJobOperation(request).force).toBe(true); + expectForcedDenied(request); + } + }); + + it('does not turn a force-only read failure into an unreadable whole request', () => { + // The rest of the request is honest; only `force` throws. The snapshot must + // stay readable, so the operation is denied for being forced, not for being + // unreadable — every other operand is still available to the evaluator. + const request = pushWithForceAccessor({ + get() { + throw new Error('hostile force getter'); + }, + }); + const normalized = readJobOperation(request); + expect(normalized.readable).toBe(true); + expect(normalized.operation).toBe(JOB_OPERATION.REPAIR_PUSH); + expect(normalized.ref).toBe(REPAIR_BRANCH); + expect(normalized.requestId).not.toBeNull(); + }); +}); + describe('prototype pollution and inherited properties create no authority', () => { it('ignores authorization fields planted on Object.prototype', () => { const baseline = authorizeJobOperation(buildJob(), buildEdit({ path: UNAUTHORIZED_PATH }));