Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7cfbfc9
feat: add commit-bound Autoflow state machine
LogicDuke Aug 11, 2026
d4dd226
Fix workflow trust-boundary validation
LogicDuke Aug 11, 2026
a22eb52
docs: clarify Autoflow rejection precedence
LogicDuke Aug 11, 2026
56adfa9
Harden workflow state admission readers
LogicDuke Aug 11, 2026
f570ec6
fix: enforce current invocation commit binding
LogicDuke Aug 11, 2026
e1d80f0
fix: complete workflow snapshot and verdict validation
LogicDuke Aug 11, 2026
2bd3191
fix: harden workflow cardinality and temporal invariants
LogicDuke Aug 11, 2026
b2c27ec
fix: harden workflow list and temporal invariants
LogicDuke Aug 11, 2026
8800805
fix: close PR 007 final Codex workflow invariant findings
LogicDuke Aug 11, 2026
a03c0ef
test: reach frozen-list-gated assertions
LogicDuke Aug 11, 2026
ad14bbc
fix: enforce sequence capacity across revision gaps
LogicDuke Aug 11, 2026
977d854
fix: reserve sequence slots for status transitions
LogicDuke Aug 11, 2026
4aae023
fix: account for HEAD slots in status chronology
LogicDuke Aug 12, 2026
808e8e9
fix: enforce bounded workflow chronology invariants
LogicDuke Aug 12, 2026
c18569b
fix: reserve final sequence slot for closed workflows
LogicDuke Aug 12, 2026
a01f54d
fix: account for terminal and gate slots in chronology
LogicDuke Aug 12, 2026
d6a73af
fix: enforce revision sequence upper bound
LogicDuke Aug 12, 2026
7587ad9
fix: count only human decisions that can clear a gate
LogicDuke Aug 12, 2026
8722376
fix: insulate workflow descriptor objects
LogicDuke Aug 24, 2026
7131f4c
Merge pull request #52 from LogicDuke/repair/pr009-workflow-descripto…
LogicDuke Aug 24, 2026
5accbca
Merge main 556144b into PR 007 reconciliation
LogicDuke Aug 24, 2026
15a35cc
fix: fail closed on unreadable push force flag
LogicDuke Aug 24, 2026
89ca7ba
Merge pull request #54 from LogicDuke/repair/pr053-job-operation-forc…
LogicDuke Aug 24, 2026
2db44b0
Merge pull request #53 from LogicDuke/reconcile/pr009-main-556144b
LogicDuke Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
490 changes: 490 additions & 0 deletions docs/architecture/007-autoflow-state-machine.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
58 changes: 50 additions & 8 deletions src/domain/job-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, unknown>).force;
} catch {
return true;
}
return value !== false;
}

/**
Expand Down Expand Up @@ -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),
});
}

Expand Down
Loading
Loading