diff --git a/README.md b/README.md index 2e56f25..ffb3e92 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 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 - 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..7918c2a --- /dev/null +++ b/docs/architecture/007-autoflow-state-machine.md @@ -0,0 +1,490 @@ +# 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 + +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 + +`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 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; +- `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 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. + +### 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 aaf8a35..6196b59 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -209,3 +209,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/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/src/domain/workflow-transitions.ts b/src/domain/workflow-transitions.ts new file mode 100644 index 0000000..803c73c --- /dev/null +++ b/src/domain/workflow-transitions.ts @@ -0,0 +1,1938 @@ +/** + * 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 + * + * 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, EVIDENCE_SOURCES } 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; +const objectDefineProperty = Object.defineProperty; +const objectSetPrototypeOf = Object.setPrototypeOf; +const objectIsFrozen = Object.isFrozen; +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([]); + +/** + * 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 }; + } +} + +/** + * 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 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 isProvablyEmptyList(value: unknown): boolean { + let isArray = false; + try { + isArray = arrayIsArray(value); + } catch { + return false; + } + 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; + } catch { + return false; + } + 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. + * + * 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; +} + +/** + * 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) { + // 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) { + const descriptor: PropertyDescriptor = { + value: sequence, + writable: true, + enumerable: true, + configurable: true, + }; + objectSetPrototypeOf(descriptor, null); + objectDefineProperty(highest, index, descriptor); + } + return; + } + } + append(revisions, revision); + append(lowest, sequence); + append(highest, sequence); +} + +/** + * Do the recorded revision bands respect chronology? + * + * 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 later = revisions[b]; + const laterLow = lowest[b]; + if (later === undefined || laterLow === undefined) { + return false; + } + if (earlier < later && laterLow - earlierHigh <= later - earlier) { + return false; + } + } + } + return 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; + } + + // **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 — 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. + // + // 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. + let frozen = false; + try { + frozen = objectIsFrozen(elements); + } catch { + return null; + } + if (!frozen) { + return null; + } + + let rawLength: unknown; + try { + rawLength = elements.length; + } catch { + return null; + } + const length = readCount(rawLength, limit); + if (length === 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; + try { + if (!objectHasOwn(elements, index)) { + return null; + } + 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 || + // 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) + ) { + 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 || + reportedAtSequence <= reportedAtRevision + ) { + 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 || + admittedAtSequence <= admittedAtRevision || + !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 || + admittedAtSequence <= admittedAtRevision + ) { + 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; + * - **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; + * - 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; + * - no two retained transition sequence stamps share a value, since every + * 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; + * - ordering every stamped record by sequence yields non-decreasing revisions, + * 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); + 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; + } + + // 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) { + 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 || + (tracked.requestedAtRevision === revision && + tracked.targetCommitSha !== boundCommitSha) + ) { + return null; + } + for (let priorIndex = 0; priorIndex < invocations.length; priorIndex += 1) { + if (invocations[priorIndex]?.invocationId === tracked.invocationId) { + 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 || + (admitted.admittedAtRevision === revision && + admitted.admittedAtCommitSha !== boundCommitSha) + ) { + 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); + } + + const reviews: AdmittedReview[] = []; + for (let index = 0; index < reviewCandidates.length; index += 1) { + const admitted = readAdmittedReview(reviewCandidates[index], revision, sequence); + if ( + admitted === null || + (admitted.admittedAtRevision === revision && + admitted.admittedAtCommitSha !== boundCommitSha) + ) { + 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); + } + + // 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[] = []; + const spanRevisions: number[] = []; + const spanLowest: number[] = []; + const spanHighest: number[] = []; + for (let index = 0; index < invocations.length; index += 1) { + const tracked = invocations[index]; + if ( + tracked === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + tracked.requestedAtRevision, + tracked.targetCommitSha, + ) || + !claimSequence(seenSequences, tracked.requestedAtSequence) || + (tracked.reportedAtSequence !== null && + !claimSequence(seenSequences, tracked.reportedAtSequence)) + ) { + 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]; + if ( + admitted === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + admitted.admittedAtRevision, + admitted.admittedAtCommitSha, + ) || + !claimSequence(seenSequences, admitted.admittedAtSequence) + ) { + return null; + } + noteRevisionSpan( + spanRevisions, + spanLowest, + spanHighest, + admitted.admittedAtRevision, + admitted.admittedAtSequence, + ); + } + for (let index = 0; index < reviews.length; index += 1) { + const admitted = reviews[index]; + if ( + admitted === undefined || + !bindRevisionCommit( + seenRevisions, + seenCommits, + admitted.admittedAtRevision, + admitted.admittedAtCommitSha, + ) || + !claimSequence(seenSequences, admitted.admittedAtSequence) + ) { + 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, revision, sequence)) { + 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. + // `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 these groups + // occupy distinct slots in `[1, sequence]`, hence + // + // sequence >= revision + retained stamps + 1 + retained gate slot + if ( + (rawStatus === WORKFLOW_STATUS.AWAITING_HUMAN_DECISION || + rawStatus === WORKFLOW_STATUS.CLOSED) && + 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 + // 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 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]; + const bandHighest = spanHighest[index]; + if (bandRevision === undefined || bandHighest === undefined) { + return null; + } + if (bandRevision < revision && bandHighest > latestEarlier) { + latestEarlier = bandHighest; + } + } + if (sequence < latestEarlier + 2) { + return null; + } + } + + // 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 + revision + gates opened + // + // 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 + 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 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 clearingDecisions = 0; + for (let index = 0; index < evidence.length; index += 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 + clearingDecisions; + if (sequence > seenSequences.length + revision + gatesOpened) { + return null; + } + } + + 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, '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'); + } + 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 (!isProvablyEmptyList(readOwnProperty(verdictRecord, 'invalidFields'))) { + append(notCurrent, 'verdict.invalidFields'); + } + 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 { + 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, [ + 'event.atCommitSha', + ]); + } + + 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..bcc4949 --- /dev/null +++ b/src/domain/workflow.ts @@ -0,0 +1,713 @@ +/** + * 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 objectSetPrototypeOf = Object.setPrototypeOf; +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 { + // 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); +} + +/** + * 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/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 })); 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..e502e31 --- /dev/null +++ b/tests/domain/workflow-fixtures.ts @@ -0,0 +1,467 @@ +/** + * 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', + // 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. */ +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..f352f90 --- /dev/null +++ b/tests/domain/workflow-invariants.test.ts @@ -0,0 +1,3013 @@ +/** + * 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, + evaluateEvidenceFreshness, + 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 { append } from '../../src/domain/workflow.js'; +import { + admitEvidence, + admitReview, + applyOrThrow, + buildBinding, + buildHumanDecisionVerdict, + buildInvocation, + buildReport, + buildReview, + buildVerdict, + closeWorkflow, + EVIDENCE_A, + EVIDENCE_B, + 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, + SHA_C, + 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, ''); +} + +/** + * 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); + 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); + } + } +} + +/** + * 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 { + 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)); + + // 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)); + + // 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'])( + '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'); + }); + + 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]: stored([{ ...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]: stored([{ ...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); + }, + ); + + /** + * 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); + } + }); + + /* ---- 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'); + } + }); + + // 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, 2], + ])('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); + }); + + /* ---- 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 = stored([...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: stored([]), reviews: stored([]) } as WorkflowState, + admitReview(), + ).outcome, + ).toBe('APPLIED'); + }); + + 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; + + // `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', () => { + const state = populatedAggregate(); + + // Duplicate detection still fires on a structurally sound list. + const duplicated = { + ...state, + evidence: stored([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: 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 frozen list of the same records', (collection) => { + const state = populatedAggregate(); + const result = withList(state, collection, Object.freeze([...state[collection]])); + + 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]]), { + 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', () => { + // 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 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: 4 }]), + }); + + 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'); + }); + + /* ---- 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'); + }); + + /* ---- 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'); + }); + + /* ---- 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'); + }); + + /* ---- 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'); + }); + + 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. */ + 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', () => { + 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. */ + 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, + 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`. */ + 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: stored([{ ...s.evidence[0], admittedAtSequence: 1 }]) }), + ], + [ + 'review reusing the request stamp', + (s: WorkflowState) => ({ ...s, reviews: stored([{ ...s.reviews[0], admittedAtSequence: 1 }]) }), + ], + [ + 'review reusing the evidence stamp', + (s: WorkflowState) => ({ ...s, reviews: stored([{ ...s.reviews[0], admittedAtSequence: 2 }]) }), + ], + [ + 'report reusing the evidence stamp', + (s: WorkflowState) => ({ + ...s, + invocations: stored([{ ...s.invocations[0], reportedAtSequence: 2 }]), + }), + ], + [ + 'report reusing the review stamp', + (s: WorkflowState) => ({ + ...s, + invocations: stored([{ ...s.invocations[0], reportedAtSequence: 3 }]), + }), + ], + [ + 'two evidence admissions sharing a stamp', + (s: WorkflowState) => ({ + ...s, + evidence: stored([s.evidence[0], { ...s.evidence[0], evidenceId: EVIDENCE_B }]), + }), + ], + [ + 'two reviews sharing a stamp', + (s: WorkflowState) => ({ + ...s, + reviews: stored([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: stored([ + 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]; + 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', () => { + 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: stored(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: stored(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', () => { + // `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: WORKFLOW_BOUNDS.MAX_REVISION, + } 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)/); + } + }); +}); + +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 new file mode 100644 index 0000000..5a4c9df --- /dev/null +++ b/tests/domain/workflow-transitions.test.ts @@ -0,0 +1,1416 @@ +/** + * 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('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: 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', () => { + 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([ + ['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(), + 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, commitSha: SHA_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('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')); + + 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); + + // 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', () => { + 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); + }); +}); + +/** + * 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(); + }); +});