Factory docs, home
Page navigation

Records each registered Product's core values (0–6 ordered values, each a statement and an application rule) as immutable, digest-bound revisions in the Command journal, and resolves pins to exactly the revision they name.

Status: "Accepted local domain and browser interface" (build review row "Project guidance / Attention", build review). Receipt: disposition accepted-local, scope "Local project guidance, source-derived attention and blocker resolution views", base 8ada02a. Local development only: no planner consumes guidance, no delivery Authority is added, and all 27 epics remain open. Long-form contract: project guidance; HTTP and editor: dashboard.

  • Source: src/project-guidance.ts; browser editor apps/dashboard/src/guidance-draft.ts and apps/dashboard/src/views/ValuesPage.tsx (route /projects/:id/values)
  • Tests: test/project-guidance.test.ts, test/dashboard-guidance-attention.test.ts, test/dashboard-editor.test.ts; import rules in test/dashboard-boundaries.test.ts; child helper test/helpers/guidance-child.ts

Intelligence: optional — project-guidance/summarise via frontier/low

What it hides

  • Storage. One journal aggregate per Product, guidance:product:<productId>, written only through CommandJournal.execute; aggregate version = revision; one guidance.values-set event per revision; state schema product-guidance-state/1.
  • Integrity. Every read of recorded guidance re-parses every event up to the aggregate version captured at the start of the read, recomputes each digest and checks the aggregate state equals the last revision. Callers get frozen snapshots or CORRUPT, never a half-checked value. Revision 0 and a Product with no aggregate are computed without reading history.
  • Idempotency and concurrency. Command receipts, replay binding and optimistic revisions are mapped to one error type with six codes.
  • Registration. Product existence comes from the Portfolio registry; the checkout is never read.

Public interface

Constants (src/project-guidance.ts:40-82):

NameValue
GUIDANCE_PROTOCOL"product-guidance/1" (inside every digest)
GUIDANCE_LIMITS (frozen)maxValues: 6, maxTextLength: 280, maxRequestBytes: 16_384 (canonical {productId, values, provenance}), maxRevisions: 500, maxHistoryPage: 20
DIRECT_ENTRY (frozen){ origin: "direct-entry", note: null }

Input rules (not exported, :51-61): command ID /^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,127}$/; Product ID /^[a-z0-9][a-z0-9-]{0,62}$/; value ID /^[a-z0-9][a-z0-9-]{0,39}$/, unique within a revision; each text field (statement, application, note) is 1–280 UTF-16 code units, well-formed, no outer whitespace, and no control, U+2028/2029, U+202A–202E, U+2066–2069 or U+FEFF characters (UNSAFE_TEXT). A pin digest is 64 lower-case hex characters (DIGEST); a stored recordedAt must be YYYY-MM-DDTHH:MM:SS.mmmZ (INSTANT) and round-trip through Date (instantOf, :412), and the clock's toISOString() must match INSTANT (:241); revisions are non-negative safe integers. A setValues or resolvePin input must have exactly its fields (accessors, extra or missing keys are INVALID); those inputs are first canonicalised whole, so JSON_LIMITS (1 MiB, depth 64) also apply as INVALID. Stored state, events and command results are held to the same exact shapes, but a failure there is wrapped as CORRUPT (stored, :442). Constructor options and the history page object are read field by field, not shape-checked. Internal names: EVENT, STATE_SCHEMA, HISTORY_PAGE = 500 (the readHistory page size).

class ProductGuidance (:158)

  • constructor(journal: CommandJournal, options?: ProductGuidanceOptions); ProductGuidanceOptions { now?: () => Date } supplies recordedAt. A non-CommandJournal is INVALID.
  • Read:
    • current(productId: string): GuidanceSnapshot (revision 0 when unset)
    • revision(productId: string, revision: number): GuidanceSnapshot
    • history(productId: string, page?: { before?: number; limit?: number }): GuidanceHistory: newest first, limit 1–20 (default 10), before a positive integer
    • resolvePin(pin: GuidancePin): GuidanceSnapshot (:209)
  • Write: setValues(input: SetValuesInput): SetValuesResult (:222)

Function: guidanceDigest(productId: string, values: readonly GuidanceValue[], provenance: GuidanceProvenance | null = DIRECT_ENTRY): string (:342): SHA-256 hex of canonical {protocol, productId, values, provenance}; revision 0 uses ([], null). It is a hashing helper, not a guidance validator: it serialises through JSON.stringify (getters and toJSON run), then applies only canonicalJson's own checks (well-formed strings, JSON_LIMITS), which escape as InvalidInputError, not GuidanceError; native serialisation errors also propagate. None of the value, provenance or Product rules above are applied, so validate guidance inputs first.

Types

  • GuidanceValue { id, statement, application }
  • GuidanceProvenance { origin: "direct-entry" | "document-summary"; note: string | null } (note required for document-summary)
  • GuidanceSnapshot { protocol, productId, revision, digest, recordedAt: string | null, provenance: GuidanceProvenance | null, values }, deep-frozen
  • GuidancePin { productId, revision, digest }
  • SetValuesInput { commandId, productId, expectedRevision, values, provenance? } (defaults to DIRECT_ENTRY)
  • SetValuesResult { productId, revision, digest, recordedAt, replayed }
  • GuidanceHistory { productId, revision, revisions, nextBefore: number | null }
  • Errors: GuidanceErrorCode = "INVALID" | "NOT_FOUND" | "STALE" | "CONFLICT" | "LIMIT" | "CORRUPT"; class GuidanceError extends Error { readonly code; readonly currentRevision: number | null } (:141)

Browser editor (guidance-draft.ts, pure, run under Node by dashboard-editor.test.ts; its header comment names dashboard-format.test.ts, which does not import it): draftOf, typed, payloadOf, sameValues, isDirty, problemsOf, changesBetween, changeText, provenanceText, newValueId (v- + 1–10 [a-z0-9]), move, operationFor, failureOf, and types Draft, FieldProblem, ChangeSummary, Operation, SaveFailure = "stale" | "uncertain" | "refused". ValuesPage.tsx exports ValuesPage({ productId: string; period: PeriodId }), titled "Core values" and reached from the project page as "Add values", "Edit values" or, with edits disabled, "View values", and ValueList({ values: readonly GuidanceValueDto[] }), which ProjectPage.tsx also renders on the project overview, so a presentation change there shows in both places. Its history disclosure requests ?limit=10 (api.ts) and shows only that newest page, saying older revisions are kept; there is no paging control, so older revisions are reachable through the API's before parameter only.

Invariants and guarantees

  1. One revision per accepted command. Revision 0 is unset (recordedAt, provenance null, no values). Each accepted setValues, including one that clears the list, adds exactly one revision. Identical content under a new command ID is still a new revision. Tests: "revisions survive a real restart…clearing is a revision"; "…stale and writes nothing" (re-save under a new ID).
  2. History is append-only. revision(p, n) returns exactly what was recorded. The module has no delete, rewrite or retention path. Value IDs are caller-supplied: the domain checks only their syntax and uniqueness within a revision and does not enforce continuity between revisions, so a caller keeps an ID when rewording or reordering (the editor does; the restart test does so in its inputs).
  3. Digest binding. The digest covers protocol, Product, ordered values and provenance, not revision or recordedAt, so two revisions can share a digest; a pin carries both. The same values with different provenance have different digests (test "provenance is part of each revision").
  4. Pins never move. resolvePin returns the pinned snapshot whatever is edited later; a digest mismatch is CONFLICT, never upgraded. A pin moved to another Product is NOT_FOUND when that Product has no such revision (the tested case) and otherwise CONFLICT, because the digest names the Product (test "a pinned revision resolves to exactly what was pinned").
  5. Validation before writing. Malformed, oversized or unregistered input, and a bad clock value, are refused before execute; the 501st revision is refused inside the decision, which also writes nothing. The journal footprint is unchanged (test "invalid, oversized, unregistered or cross-Product input writes nothing"). The invalid-clock and 500/501-revision cases are verified from source (:240-241, :322) only; no permanent test exercises them.
  6. Idempotent by command ID. The journal compares aggregate, expectedRevision and canonical payload. An exact repeat returns the recorded result, original recordedAt included, with replayed: true, writes nothing and works after later revisions; any difference is CONFLICT (test "an exact command replays…").
  7. Optimistic concurrency. A non-current expectedRevision is STALE with currentRevision; nothing is merged, rebased or retried. Two real processes at one revision: exactly one wins, three rounds (test "two real controllers…").
  8. Reconcile before trusting. current, history, setValues, and revision and resolvePin for n ≥ 1, run #reconciled (:283); decideSet (:315) re-validates the previous state inside the transaction. After execute, every result must carry the outcome's version and this request's digest, and a replayed one must also match the recorded revision's digest and recordedAt. Any disagreement is CORRUPT for that Product only; other Products still read and no edit writes over it (test "stored guidance that disagrees…"). Exceptions: revision 0 (via revision or resolvePin) is the computed unset snapshot, and current of a Product with no aggregate is the same, both returned without reading history.
  9. Isolation. Guidance never reads the checkout, runs Git, writes the registry or records Product facts; productView(...).deliveryAuthority stays "absent" (test "an unavailable checkout does not block guidance…"). Values are never copied between Products.
  10. Text is data. Values are stored verbatim (hostile HTML round-trips unchanged); the dashboard escapes <, >, & in every JSON body.
  11. Import boundary. Imports are exactly ./canonical-json.ts, ./journal.ts, ./portfolio.ts, node:buffer, node:crypto; the only src consumers are dashboard-server.ts and dashboard-view.ts (dashboard-boundaries.test.ts).

Failure semantics

GuidanceError.codeRaised whenWritten?HTTP (toHttpError)
INVALIDMalformed input, bad pin shape, a revision that is not a non-negative safe integer, clock outside INSTANTNo400 BAD_REQUEST
NOT_FOUNDUnregistered Product, or no such revisionNo404
STALEexpectedRevision not current (currentRevision set)No409 STALE
CONFLICTCommand ID reused with other input; pin digest mismatchNo409 CONFLICT
LIMITOver 6 values, over 16 384 payload bytes, or a 501st revisionNo409 CONFLICT
CORRUPTStored state, history or receipt disagreeNo (refused before writing, or on a replay); the post-execute result check would report a defective fresh commit as CORRUPT after the write500 CORRUPT
  • Other errors pass through unchanged: journal errors (busy, read-only, closed), PortfolioError from the registry read, and anything the now callback throws. Native errors are not wrapped: a clock that returns an invalid Date throws RangeError from toISOString() before anything is written (:240), and a stored recordedAt that matches INSTANT but is not a real date throws RangeError from instantOf (:412) rather than CORRUPT. The dashboard serves these as 500 INTERNAL. A journal error during execute leaves the outcome unknown to the caller.
  • Unknown is not failed. After an unknown outcome, retry the same command ID with identical input: a committed write replays; an uncommitted one records now, or returns STALE if another revision landed. Never re-issue the change under a new ID until the first is resolved.
  • The editor follows this within one mounted page. failureOf maps status 0, ≥ 500, BUSY and INCOMPATIBLE to uncertain; the person then chooses "Retry save", and operationFor reuses the same guidance-<id> operation while revision and trimmed values are unchanged; a draft changed since sends a new ID at the same expectedRevision, so a first save that did commit surfaces as STALE rather than being overwritten. The pending operation and draft live in component state only: a reload, navigation or "Discard changes" loses them. STALE keeps the draft and shows both sides when the follow-up read of the current revision succeeds; if that read fails it becomes refused with the draft kept. "Keep my draft" rebases explicitly. Anything else is refused.
  • Retries: none inside the module. Repair: none; CORRUPT persists until the store is repaired by other means.

Trust scope

Established locally (receipt, base 8ada02a: independent Astra xhigh review, 0 open must-fix, 11 independent tests; 350/350 tests, check exit 0):

  • Revisioning, exact replay, stale refusal, two-process races, restart survival, pin stability, provenance binding and tamper detection of inconsistent state, history or receipts. All run over real SQLite journals in disposable directories.
  • The HTTP command: guards, bounds, restart and replay, forced direct entry, edits with a missing checkout and no Git.
  • Browser checks in one browser (Codex in-app) on synthetic fixtures at 1536×1024 and 390×844: save with history, disabled controls while saving, stale draft kept, explicit rebase, keyboard reorder, retire and restore. Live Factory shows six document-summary values at revision 1; other Products are unset.

Not established:

  • Any consumer. No planner, brief builder or provider reads guidance, and resolvePin has no caller in src/. Any effect of values on decisions is unproven.
  • Authority. Guidance grants no delivery Authority and changes no acceptance, Budget or safety constraint. Empty guidance is never a gate or an attention item.
  • Identity. Provenance records where words came from, not who typed them. document-summary is accepted from any in-process caller and is not calibrated doctrine. The dashboard token guards the browser session; it does not name a person.
  • Tamper resistance. Digests are unkeyed SHA-256. Anyone with write access to the journal can rewrite event, state and receipt consistently and reads will accept it. An existing pin detects a change to the digest-covered content of its revision (Product, ordered values, provenance) but not to its recordedAt, which the digest excludes.
  • Scale and durability. Every read of recorded guidance re-validates its full history (up to 500 revisions); no latency is measured. There is no rollover past 500, no migration, and no durability beyond the Command journal's own.
  • Value. Any Verdict, release or customer-value claim.

Composition

  • Depends on:
    • src/journal.ts (Command journal): CommandJournal.execute, readAggregate, readHistory, canonicalJson, and VersionConflictError → STALE, CommandConflictError → CONFLICT, DecisionError (unwrapped to its GuidanceError cause), InvalidInputError;
    • src/canonical-json.ts: jsonObjectEntries for exact-shape reads;
    • src/portfolio.ts (Portfolio): new Portfolio(journal).listProducts() for registration; a PortfolioError from that read passes through unchanged.
  • Used by (Dashboard):
    • src/dashboard-server.ts: guidanceResponse → current; guidanceHistoryResponse → history (before 1–501, limit 1–20); guidanceRevisionResponse → revision (route …/guidance/revisions/:n, n 1–6 digits); guidanceCommand → setValues with provenance: DIRECT_ENTRY. Reads open the journal read-only; the command uses CommandJournal.openExisting. The body is at most 24 576 bytes (DASHBOARD_LIMITS.maxGuidanceBodyBytes) and must be exactly {commandId, expectedRevision, values}, so a provenance field is refused. --no-guidance-edits makes POST 403 while reads still work.
    • src/dashboard-view.ts: guidanceDto(snapshot), a type-only import.
    • The browser reaches guidance only through HTTP DTOs (GuidanceDto, GuidanceSaveRequestDto, …, DASHBOARD_PROTOCOL = "factory-dashboard/3").
  • Not linked: Required actions and blockers (src/attention.ts) cannot import guidance, and empty guidance is never an item.
  • Intended future caller: a brief pins {productId, revision, digest} from current(), presents the applicable application rules, explains trade-offs against them, and resolves with resolvePin.

Changing it safely

  • Run:
    • focused: node --test test/project-guidance.test.ts test/dashboard-guidance-attention.test.ts test/dashboard-editor.test.ts test/dashboard-boundaries.test.ts;
    • npm run typecheck;
    • npm run check before acceptance;
    • for ValuesPage.tsx changes, npm --prefix apps/dashboard run build and a real desktop and mobile browser check.
  • Which tests prove what:
    • project-guidance.test.ts: domain invariants 1–9 (restart, replay, conflict, stale, races, bad input, missing checkout, pins, provenance, corruption), except the invalid-clock and 500/501-revision cases, which are source-verified only;
    • dashboard-guidance-attention.test.ts: HTTP guards, bounds, restart, replay, STALE/CONFLICT, direct entry, missing checkout;
    • dashboard-editor.test.ts: operation reuse, failure classes, draft rules, change summaries;
    • dashboard-boundaries.test.ts: the import boundary and consumer list.
  • Receipt: the receipt hashes evidence files under <local evidence directory> (gitignored, not in this repository), not source, so a source change does not invalidate it mechanically. Treat any behaviour change as outside the accepted scope until a fresh independent review and new receipt. Update project guidance, dashboard and the build-review row.
  • Storage-affecting changes (assess each separately; no versioned reader or migration exists, so adding one is part of any such change). #reconciled re-parses a Product's whole history on every read except revision 0, so one stored record it can no longer validate makes every such read of that Product CORRUPT and breaks its pins:
    • GUIDANCE_PROTOCOL, the digest inputs or their canonical encoding: every recorded digest is recomputed and compared (snapshotOf); revision 0's computed digest can change too, making existing revision-0 pins CONFLICT;
    • the value or provenance shapes: every recorded revision holds a provenance record and every non-empty one holds value records (snapshotOf), so only a Product whose revisions are all empty survives a value-shape change;
    • EVENT or STATE_SCHEMA: every recorded revision fails (#reconciled, parseState); revision-0 pins are unaffected, as revision 0 never reads storage;
    • the aggregate ID format (aggregateOf): existing guidance reads as unset (revision 0) with empty history, and a pin to a recorded revision is NOT_FOUND.
    • A served-shape change needs a DASHBOARD_PROTOCOL bump (dashboard-contract.ts:9).
  • Keep in step: the editor's INVISIBLE set with UNSAFE_TEXT; editor limits come from the served limits.
  • Reviewers check:
    • validation and reconciliation still run before execute;
    • no content shortcut bypasses command receipts;
    • STALE is never merged or retried;
    • replay still binds request and recorded revision;
    • the HTTP path still forces DIRECT_ENTRY;
    • the import boundary holds;
    • any new consumer pins a revision instead of reading current() implicitly.

Source: docs/agents/project-guidance.md