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 editorapps/dashboard/src/guidance-draft.tsandapps/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 intest/dashboard-boundaries.test.ts; child helpertest/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 throughCommandJournal.execute; aggregate version = revision; oneguidance.values-setevent per revision; state schemaproduct-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):
| Name | Value |
|---|---|
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 }suppliesrecordedAt. A non-CommandJournalisINVALID.- Read:
current(productId: string): GuidanceSnapshot(revision 0 when unset)revision(productId: string, revision: number): GuidanceSnapshothistory(productId: string, page?: { before?: number; limit?: number }): GuidanceHistory: newest first,limit1–20 (default 10),beforea positive integerresolvePin(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 }(noterequired fordocument-summary)GuidanceSnapshot { protocol, productId, revision, digest, recordedAt: string | null, provenance: GuidanceProvenance | null, values }, deep-frozenGuidancePin { productId, revision, digest }SetValuesInput { commandId, productId, expectedRevision, values, provenance? }(defaults toDIRECT_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
- One revision per accepted command. Revision 0 is unset (
recordedAt,provenancenull, no values). Each acceptedsetValues, 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). - 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). - 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"). - Pins never move.
resolvePinreturns the pinned snapshot whatever is edited later; a digest mismatch isCONFLICT, never upgraded. A pin moved to another Product isNOT_FOUNDwhen that Product has no such revision (the tested case) and otherwiseCONFLICT, because the digest names the Product (test "a pinned revision resolves to exactly what was pinned"). - 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. - Idempotent by command ID. The journal compares aggregate,
expectedRevisionand canonical payload. An exact repeat returns the recorded result, originalrecordedAtincluded, withreplayed: true, writes nothing and works after later revisions; any difference isCONFLICT(test "an exact command replays…"). - Optimistic concurrency. A non-current
expectedRevisionisSTALEwithcurrentRevision; nothing is merged, rebased or retried. Two real processes at one revision: exactly one wins, three rounds (test "two real controllers…"). - Reconcile before trusting.
current,history,setValues, andrevisionandresolvePinfor n ≥ 1, run#reconciled(:283);decideSet(:315) re-validates the previous state inside the transaction. Afterexecute, 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 andrecordedAt. Any disagreement isCORRUPTfor that Product only; other Products still read and no edit writes over it (test "stored guidance that disagrees…"). Exceptions: revision 0 (viarevisionorresolvePin) is the computed unset snapshot, andcurrentof a Product with no aggregate is the same, both returned without reading history. - Isolation. Guidance never reads the checkout, runs Git, writes the registry or records Product facts;
productView(...).deliveryAuthoritystays"absent"(test "an unavailable checkout does not block guidance…"). Values are never copied between Products. - Text is data. Values are stored verbatim (hostile HTML round-trips unchanged); the dashboard escapes
<,>,&in every JSON body. - Import boundary. Imports are exactly
./canonical-json.ts,./journal.ts,./portfolio.ts,node:buffer,node:crypto; the onlysrcconsumers aredashboard-server.tsanddashboard-view.ts(dashboard-boundaries.test.ts).
Failure semantics
GuidanceError.code | Raised when | Written? | HTTP (toHttpError) |
|---|---|---|---|
INVALID | Malformed input, bad pin shape, a revision that is not a non-negative safe integer, clock outside INSTANT | No | 400 BAD_REQUEST |
NOT_FOUND | Unregistered Product, or no such revision | No | 404 |
STALE | expectedRevision not current (currentRevision set) | No | 409 STALE |
CONFLICT | Command ID reused with other input; pin digest mismatch | No | 409 CONFLICT |
LIMIT | Over 6 values, over 16 384 payload bytes, or a 501st revision | No | 409 CONFLICT |
CORRUPT | Stored state, history or receipt disagree | No (refused before writing, or on a replay); the post-execute result check would report a defective fresh commit as CORRUPT after the write | 500 CORRUPT |
- Other errors pass through unchanged: journal errors (busy, read-only, closed),
PortfolioErrorfrom the registry read, and anything thenowcallback throws. Native errors are not wrapped: a clock that returns an invalidDatethrowsRangeErrorfromtoISOString()before anything is written (:240), and a storedrecordedAtthat matchesINSTANTbut is not a real date throwsRangeErrorfrominstantOf(:412) rather thanCORRUPT. The dashboard serves these as 500INTERNAL. A journal error duringexecuteleaves 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
STALEif 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.
failureOfmaps status 0, ≥ 500,BUSYandINCOMPATIBLEtouncertain; the person then chooses "Retry save", andoperationForreuses the sameguidance-<id>operation while revision and trimmed values are unchanged; a draft changed since sends a new ID at the sameexpectedRevision, so a first save that did commit surfaces asSTALErather than being overwritten. The pending operation and draft live in component state only: a reload, navigation or "Discard changes" loses them.STALEkeeps the draft and shows both sides when the follow-up read of the current revision succeeds; if that read fails it becomesrefusedwith the draft kept. "Keep my draft" rebases explicitly. Anything else isrefused. - Retries: none inside the module. Repair: none;
CORRUPTpersists 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-summaryvalues at revision 1; other Products are unset.
Not established:
- Any consumer. No planner, brief builder or provider reads guidance, and
resolvePinhas no caller insrc/. 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-summaryis 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, andVersionConflictError→STALE,CommandConflictError→CONFLICT,DecisionError(unwrapped to itsGuidanceErrorcause),InvalidInputError;src/canonical-json.ts:jsonObjectEntriesfor exact-shape reads;src/portfolio.ts(Portfolio):new Portfolio(journal).listProducts()for registration; aPortfolioErrorfrom that read passes through unchanged.
- Used by (Dashboard):
src/dashboard-server.ts:guidanceResponse→current;guidanceHistoryResponse→history(before1–501,limit1–20);guidanceRevisionResponse→revision(route…/guidance/revisions/:n,n1–6 digits);guidanceCommand→setValueswithprovenance: DIRECT_ENTRY. Reads open the journal read-only; the command usesCommandJournal.openExisting. The body is at most 24 576 bytes (DASHBOARD_LIMITS.maxGuidanceBodyBytes) and must be exactly{commandId, expectedRevision, values}, so aprovenancefield is refused.--no-guidance-editsmakes 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}fromcurrent(), presents the applicableapplicationrules, explains trade-offs against them, and resolves withresolvePin.
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 checkbefore acceptance;- for
ValuesPage.tsxchanges,npm --prefix apps/dashboard run buildand a real desktop and mobile browser check.
- focused:
- 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).
#reconciledre-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 ProductCORRUPTand 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 pinsCONFLICT;- 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; EVENTorSTATE_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 isNOT_FOUND. - A served-shape change needs a
DASHBOARD_PROTOCOLbump (dashboard-contract.ts:9).
- Keep in step: the editor's
INVISIBLEset withUNSAFE_TEXT; editor limits come from the servedlimits. - Reviewers check:
- validation and reconciliation still run before
execute; - no content shortcut bypasses command receipts;
STALEis 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.
- validation and reconciliation still run before
