Factory docs, home
Page navigation

One journal aggregate per Product (gate:product:<productId>) that binds a Factory-owned store; holds grants, revocations, a stop intent and the one dispatch owner; freezes Slices with their allowances; and admits productions, checks and dispatches as the linearisation point. Beside it, the delivery records module keeps create-once records in the journal and derives an Assessment purely.

Status: Accepted as step 1, increment 2, of the step-1 core (handover), reviewed together with increment 1 (Repository Assurance). Independent implementation review by Astra (gpt-6-astra, high effort): rounds 1–5 FAIL (6, 4, 3, 2 and 1 findings), round 6 PASS on 29 September 2026 (<local evidence archive>). At that review the increment tests passed 73/73 and the full npm run check 428/428, none skipped, on the pre-merge base f5a8991. No receipt exists yet, and this reference has no separate reference-review report. Not composed: the only src/ importers of either module are each other and the Repository collector, which takes the SnapshotManifest type from the delivery records (test "nothing imports a producer, and no other src module consumes the delivery core yet"); the collector (step 1, increment 3) is accepted but not composed, the repository guard seam (increment 3b) is accepted but not composed, and delivery.ts, the delivery CLI and the live Slice (increments 4–8) are not built. Local development only: no delivery, release or customer claim, and all 27 epics remain open. Human page: guide.

  • Source: src/product-gate.ts, src/delivery-records.ts
  • Tests: test/product-gate.test.ts (17), test/product-gate-ordering.test.ts (5, real processes), test/delivery-records.test.ts (4), test/delivery-adversarial.test.ts (27), test/delivery-boundaries.test.ts (7, import boundaries of all three core modules and the producer boundary, read from the compiler's syntax tree)
  • Helpers: test/helpers/gate.ts, test/helpers/gate-child.ts, test/helpers/adversarial.ts, test/helpers/repository-proof.ts

Intelligence: none — Authority, admission, integration, create-once delivery records and Assessments are pure decisions over durable facts; a model would be a second authority.

What it hides

  • One aggregate, one decision. Every gate command is one CommandJournal.execute whose pure decision re-parses the stored state (parseGateState; a defect is CORRUPT, never repaired), applies its rules in order (the first failure refuses and nothing is recorded), checks the state and transition invariants and the byte budget, and emits one event. atVersion is context.version + 1; clocks (now, frozenAt) travel in the payload. No I/O happens in a decision.
  • Command IDs. gate:<p>: followed by bind, grant:<g>, revoke:<g>, stop:<n>, resume:<n>, own:<token>, release:<token>, freeze:<s>:<r>, produce:<hex48>, produced:<hex48>, check:<hex48>, dispatch:<id32>, outcome:<id32>:<status or kind> or close:<s>:<r>. stop:<n> must be the next stop (STALE otherwise) and resume:<n> the current one. A changed payload under an ID is CONFLICT; an exact repeat replays.
  • Proof re-derivation. admitDispatch(integrate-local) runs judgeRepository inside the decision over the caller's trusted manifest, base listing and store-read Evidence. The result must canonically equal the supplied Verdict, which must be Verified. trusted and verdict are decision inputs and are never journaled; the payload keeps the prepared intent and its proof digest.
  • Ledger keys. delivery:binding:<p>, delivery:slice:<p>/<s>/<r>, delivery:snapshot:<hex>, delivery:verdict:<hex>, delivery:integration:<prepared hex> and delivery:receipt:<prepared hex>, each written once by command record:<key> at expected version 0. Reads re-validate each record against its key.

Public interface

src/product-gate.ts. Runtime imports: parseFenceIdentity from ./attempt-fence.ts, canonicalJson and JSON_LIMITS from ./canonical-json.ts, parsePreparedIntegration and parseProductBinding from ./delivery-records.ts, the journal class and its errors from ./journal.ts, and acceptanceDigest, environmentDigest, judgeRepository, parseRepositoryVerdict, parseStoreIdentity, recipeDigest and verdictDigest from ./repository-assurance.ts. Types only from ./assurance.ts and ./repository.ts.

  • Constants: GATE_PROTOCOL = "sf-product-gate/1". GATE_LIMITS (frozen): maxSliceIdLength 24, maxGrantIdLength 32, maxRevision 999 999, maxOccurrence 99, maxOccurrenceGap 16, maxGrantScopeEntries 64, maxSliceScopeEntries 16, maxTextLength 280, maxGrants 32, maxSlices 64, maxRunsPerSlice 20, maxAttemptsPerCheck 20, maxStops 1 000, maxElapsedMs 86 400 000, maxStateBytes 786 432. Patterns PRODUCT_ID (1–63 characters), SLICE_ID (1–24), GRANT_ID (1–32), DISPATCH_ID (32 hex). Free texts are 1–280 printable ASCII.
  • class ProductGate(journal: CommandJournal):
    • read(productId): Gate | undefined validates the stored state (CORRUPT on any defect) and reads with readAggregate only.
    • bindStore, grant, revoke, requestStop, clearStop, takeOwnership, releaseOwnership, freezeSlice, admitProduction, recordProduction, admitCheck, recordOutcome and closeSlice return GateResult {gate, replayed} or throw GateError.
    • admitDispatch(input: AdmitDispatchInput): AdmitDispatchResult returns {admitted: true, dispatch}, a refusal {admitted: false, reason, detail, dispatch: null}, or, for an exact replay, {admitted: false, reason: "REPLAYED", dispatch}: history, never a permit.
  • Helpers: gateAggregateId, sliceRefOf (<s>/<r>), ownerIdOf (delivery/<token>), actionIdOf (integrate-local:<p>:<sliceRef>), productionKeyOf and checkKeyOf (produce: or check: plus the first 48 hex of the SHA-256 of canonical {productId, sliceRef, proposal | candidate}), verificationRunId (ver-<hex48>, the Evidence runId), storeDigestOf, parseGateState, openDispatch (a dispatch whose outcome is null) and integrationBlocked (an integrate-local dispatch of any Slice whose outcome is null or unknown).
  • GateErrorCode: INVALID CORRUPT STALE CONFLICT LIMIT NOT_BOUND ALREADY_BOUND NOT_REGISTERED GRANT_EXISTS NO_GRANT REVOKED STOPPED NOT_STOPPED OWNERSHIP_PROOF NOT_OWNER OUT_OF_SCOPE SLICE_EXISTS SLICE_UNKNOWN SLICE_CLOSED DEADLINE ALLOWANCE PRODUCTION_OPEN PRODUCTION_UNKNOWN OUTCOME_FINAL DISPATCH_UNKNOWN DISPATCH_OPEN OCCURRENCE PROOF_MISMATCH CANDIDATE_MISMATCH NOT_VERIFIED. GateError {code, productId, currentVersion}.
  • Types: Operation (read | produce | check | integrate-local), ProductBinding, GrantLimits {produceRuns, checkRuns, attemptsPerCheck, integrations, elapsedMs}, Grant, StopIntent, DispatchOwner, OwnershipProof (fenced {generation, fenceNonce, alreadyFenced} or released {generation}), OwnerRef, SliceEntry, SliceDisposition, Admission, ProductionOutcome, CheckOutcome, IntegrationOutcome, Dispatch (check, integrate-local, or a reserved review that no command creates and stored state refuses), GateState, Gate, GateCommand, GateResult, and one input type per command.

src/delivery-records.ts. Runtime imports: canonicalJson from ./canonical-json.ts, the journal class and its conflict errors from ./journal.ts, and parsers, digests and rejudgeRepository from ./repository-assurance.ts. Types only from ./product-gate.ts, ./repository.ts and ./assurance.ts.

  • RECORD_LIMITS (frozen): maxNeedLength 2 000, maxBriefLength 16 384, maxMeasurements 16, maxPopulationLength 500, maxSnapshotsPerSlice 16, maxMetricLength 56.
  • class DeliveryLedger(journal): recordBinding / readBinding, recordSlice (returns {sliceDigest, replayed}) / readSlice, recordSnapshotManifest / readSnapshotManifest, recordVerdict (returns the proof digest) / readVerdict, recordPrepared / readPrepared, recordReceipt / readReceipt. An absent record reads as undefined. LedgerError codes: invalid-input, conflict, corrupt and missing (declared; no path throws it today).
  • deriveAssessment(slice, verdict, gathered): Assessment (pure), briefDigest, sliceDigestOf (the gate's sliceDigest), and the parsers shared with the gate: parseProductBinding, parsePreparedIntegration (digest recomputed from the canonical payload, at most 4096 bytes), parseIntegrationObservation, parseSliceRecord and parseSnapshotManifest.
  • Types: SliceNeed, SliceBrief (an optional guidance pin of the same Product), MeasurementSource (evidence {check, quantity: stdoutBytes | passed} or declared {value}), SliceRecord, SnapshotManifest, Assessment {measurements, conclusion: supported | inconclusive | rejected}, IntegrationRecord, ReceiptRecord, LedgerErrorCode.

Invariants and guarantees

  1. Common admission rules, in order (admissible): bound (NOT_BOUND); the Slice exists and is open (SLICE_UNKNOWN, SLICE_CLOSED); its grant is unrevoked (REVOKED); grant and Slice both include the operation (OUT_OF_SCOPE); no stop (STOPPED); the current, unreleased owner (NOT_OWNER); now is at or before the Slice deadline (DEADLINE). Then per command: a production checks its key is new (CONFLICT), its allowance (ALLOWANCE) and that no production is pending (PRODUCTION_OPEN); a check checks its key, its allowance and that this Slice sealed that Candidate (PRODUCTION_UNKNOWN). Tests: "admissions check authority…", and the A1 rows.
  2. Linearisation with revocation. Revoke and admit are commands on one aggregate. A revoke committed first makes a stale admission STALE and a current one REVOKED; an admission committed first stands, and the later revoke succeeds. Four revokers and four admitters racing in real processes give one history, one command per version (product-gate-ordering, A1).
  3. One in flight. At most one production with outcome null and one dispatch with outcome null per Product, whatever the Slice (PRODUCTION_OPEN, DISPATCH_OPEN). An integrate-local outcome that is null or unknown blocks every later integration of the Product, across closure and new Slices, until it is confirmed.
  4. Check dispatch, in order: bound (NOT_BOUND); an existing check admission (DISPATCH_UNKNOWN); then the common rules (admissible) on that admission's Slice, so DISPATCH_UNKNOWN precedes the Slice, revocation, scope, stop, owner and deadline checks; then a new dispatch id (CONFLICT), no open dispatch (DISPATCH_OPEN), no earlier epoch already observed (OUTCOME_FINAL), and an epoch that is the next one and within attemptsPerCheck (ALLOWANCE).
  5. Integration admission, after the common rules, in order: a new dispatch id; no open dispatch; the integration allowance; the prepared intent names this Product, store and target ref (OUT_OF_SCOPE) and this Slice, base and action occurrence (INVALID) and this owner (NOT_OWNER); the occurrence is above the Slice's highest and within maxOccurrenceGap (OCCURRENCE); the Verdict parses and digests to the intent's proof (PROOF_MISMATCH); a check admission of this Slice matches the intent's Candidate and commit and the proof's run (CANDIDATE_MISMATCH); that check's dispatch at pins.epoch recorded observed with this proof, and the pins are this Product, store, Candidate, base, commit, Slice and its three frozen digests (PROOF_MISMATCH); re-derivation succeeds, equals the Verdict and is Verified (NOT_VERIFIED); the authenticated Candidate was sealed under exactly the Slice's scope, and every edit and every file it adds, changes or removes against the authenticated base listing lies at or below a scope entry, or it is refused when its edits cannot be read (OUT_OF_SCOPE); finally the Product-wide unknown block (DISPATCH_OPEN). The prepared intent's digest is recomputed when the input is parsed. The scope rule came from review round 5; the step-1 spec's §4.1 list does not state it yet.
  6. Ownership by proof only. Generation 1 takes no proof. A later generation needs fenced with the current generation and the current owner's fence nonce, or released of the current generation after that owner released (OWNERSHIP_PROOF). Time never passes ownership. The owner's fence must be {runId: productId, epoch: 1, worker: delivery/<token>}, and the owner record is at most 2 KiB. Tests H: eight racers, a dead dispatcher, 6,000 racing takeovers and 70 dead successors, and 6,000 successive idle takeovers, with real createFence and tryFence.
  7. Outcomes. A check goes from null to observed or not-started by its admitted sender, or to unresolved by the current owner of a later generation. An integration goes from null to any outcome, from unknown to confirmed, and from a reconciler's not-dispatched to unknown or confirmed; a sender's not-dispatched and confirmed are final. confirmed.target must be the Candidate commit. An outcome from an inspection needs an unrevoked grant that includes read; one from a receipt does not. Outcome recording never checks deadline, stop or closure. recordProduction takes the admitting or the current owner (only the current owner may abandon), and a sealed Candidate must be on the Slice's base. closeSlice takes no owner; integrated needs a confirmed integration of the Slice, and exactly postcondition-observed names an observation id.
  8. Counters and state. used.* equals the admissions and dispatches it counts, never decreases and never exceeds the limits. Frozen fields, grants, closures and revocations are set once. Stored state must also be a history the rules could have made: each admission and dispatch under the grant, Slice, stop and owner tenure in force at its version, no integration after an unknown, check epochs 1..n, and each integration on an observed check with its proof. State equals the fold of its events (test "the gate state equals the fold of its events").
  9. Byte budget. Growing commands (bind, grant, freeze and the admissions) refuse with LIMIT above 768 KiB; recording commands may use the journal's 1 MiB, so a successor settles every obligation from the soft limit (test "byte budget…").
  10. Product isolation. Keys, IDs and pins carry the Product; Product A cannot integrate with B's Candidate, proof or store (test "two Products in one journal…").
  11. Ledger. Create-once: identical content replays, and different content is conflict and changes nothing. A replay or a lost race re-reads the stored record with the same checks as a read, so a corrupt stored copy is corrupt, never "replayed". A record planted under another key is corrupt on read. Limits and text rules match the gate's on write and on read.
  12. Assessment is Verdict-first. deriveAssessment refuses (TypeError) a Verdict for another Product, Slice, store, base or frozen input. It judges the supplied records again in full (rejudgeRepository) and refuses a Verdict whose judgement of a check covers all of that check's records but disagrees with what they give. Failed gives rejected and Inconclusive gives inconclusive. Evidence measurements come only from a Verdict its supplied records reproduce exactly (the first judged record's facts, or passed as 1 or 0), declared values come as frozen, and anything else is null with a reason. A Verified Verdict the records do not reproduce is inconclusive; otherwise the conclusion follows the frozen predicates, with any unknown value giving inconclusive. No conclusion is stored.

Failure semantics

  • Input defects are INVALID before any journal work. Rule failures throw their code and record nothing. VersionConflictError becomes STALE (with currentVersion), CommandConflictError becomes CONFLICT ("already recorded; re-read"; never re-issue under a new clock), and stored defects are CORRUPT.
  • admitDispatch returns every GateError except CORRUPT as a refusal result (a malformed productId still throws INVALID). Journal storage errors pass through.
  • Ledger: invalid-input before writing, including a record larger than one canonical journal document; conflict for different content under a key; corrupt for a stored record that fails its parser or key, or a recorded command with no record.

Trust scope

  • Established locally (macOS arm64, Node 26.8.1, real SQLite journals and Evidence stores in temporary directories): the rule order, linearisation against revocation in real processes, fence-proved ownership chains, refusal of forged and mutated proofs inside the decision, the scope rule, outcome transitions, separate allowance counters, the byte budget and refusal of corrupt state.
  • Import boundaries: test/delivery-boundaries.test.ts reads imports from the pinned TypeScript 7.0.2's unstable JS API (typescript/unstable/sync to open the project in the compiler's server, typescript/unstable/ast to walk each file's syntax tree), not from source text or diagnostics. It visits every import and export declaration with a specifier, import x = require(…), import(…) call and import('…') type, every triple-slash file reference and every identifier named require or module; specifiers are the literals' decoded text and the checker resolves each one, so comments, strings, escapes, line terminators and @ts-ignore/@ts-nocheck change nothing, and the walk must match the imports the compiler itself collected. The core modules' import tables name the export each import takes, and those modules rename no import; only an import type declaration counts as type-only, because Node's type stripping keeps every other import declaration (including import {}, import { type X } and a side-effect import) and so loads its module. Outside src/producer.ts, src/claude-producer.ts and src/openai-producer.ts, no file may reach a producer, import node:module (or module, the static source of createRequire), call import() with anything but one string literal, name require or module, or carry a /// <reference path> or types directive (the compiler adds the named file outside the import list; src/ has none); every source file must be in the program as an ES module and every import must resolve. Loaders obtained at run time (for example process.getBuiltinModule('module')) and paths handed to workers or child processes are not visible to it: it guards against accidental coupling, not deliberately hostile source. The API is unstable, so the test asserts the pinned version.
  • Not established: any composition (no bind from the Portfolio, no collector, no Git, no delivery). SQLite and Git never commit together, so a revoke can land between the final read and Git's move. The gate cannot see a fence: a fenced proof is the caller's report of tryFence, checked only against the current generation and fence nonce. Authentication is re-derivation over trusted local files, not cryptography. No compaction, rebind or operator override.

Composition

  • Depends on: Command journal, Attempt fence (parseFenceIdentity only), Repository Assurance, and Repository types; all unchanged.
  • Used by: nothing outside the two modules themselves, except that the Repository collector takes the SnapshotManifest type from the delivery records.
  • Intended caller contract (step-1 spec §5; increments 4–5, not built): check the Portfolio registry, create the store, record the complete binding draft (recordBinding), then bindStore; take ownership with a fence proof the caller obtained from tryFence; freeze the Slice, then record the Slice whose digest the freeze names (recordSlice); admit each production and check before its effect; retain the Verdict (recordVerdict) and record the prepared intent (recordPrepared) before admitDispatch; call invokeOnce only for an admitted dispatch, passing a guard that repeats the final read, and record a withheld result as not-dispatched with its claim; record the sender's receipt (recordReceipt) before its outcome; a later generation reconciles from the receipt first, then from an inspection.

Changing it safely

  • Run node --test test/product-gate.test.ts test/product-gate-ordering.test.ts test/delivery-records.test.ts test/delivery-adversarial.test.ts test/delivery-boundaries.test.ts, then npm run check.
  • Any change to a rule's order, a command ID, a key formula or a stored shape changes the recorded history: stored gates must still parse, or add a versioned reader. Keep trusted and verdict out of the journaled payload and keep re-derivation inside the decision.
  • Reviewers check: no I/O in a decision; refusals record nothing; REPLAYED is never a permit; every new obligation is bounded so that recording fits under the hard limit.

Source: docs/agents/product-gate.md