12 — PRD: work-ledger
Brief section 15 (
pm:write-prd). Requirements, MVP scope, acceptance criteria, kill criteria for work-ledger — one thin Encore.ts service in the internal plane ofmonorepo-development. This PRD assumes the BUILD-THIN decision (00-SYNTHESIS.mdverdict #1,11-build-vs-buy.mdscore 4.53) and specifies what to build and how we’ll know it worked — not whether. Evidence tiers and the advisory-board challenges that shaped scope live in02-research.md; ground-truth IDs [E1]–[E10] inplans/001-factory-apps-validation/01-deep-dive.md.Altitude: requirements, not schema. The canonical data model, enum values, and endpoint contracts are
10-architecture.md’s call; where this PRD names a state or column it defers to that doc. Confidence tags per house convention: confirmed / supported / hypothesis / assumption / unknown.MVP definition used here: the smallest credible product that puts the riskiest assumptions under real fleet traffic — not a shrunk copy of the full vision. The riskiest assumptions are agent-usability of the claim API unassisted, and Tomas’s trust in the disposition read (both untested), not claim atomicity (feasibility — over-proven across four research passes).
1. Executive summary
work-ledger is the durable system-of-record for Robert’s legacy-rebuild fleet: one Postgres table set behind a typed Encore service where every unit of rebuild work has, at all times, exactly one claimant, one execution state, one permanent disposition, and one attributed cost. Agents are the primary users and reach it only by API; two humans (Robert operates, Tomas reads at kill gates) read it via Grafana/SQL. There is no UI, ever.
It exists because the failure mode is already in the estate — two half-finished stranglers rotting in code at 1-developer scale [E6] — and becomes fatal at 10→64 concurrent agents: double-spent tokens, merge collisions, silently half-done work, and un-attributable cost. A June-2026 arXiv preprint quantifies that a bare lock queue reproduces the highest redundant-rework rate of any tested condition and that git-native file trackers silently drop concurrent field writes [priorArt digest §2/§4] — so the fix is not “a lock,” it is a DB-enforced state machine + durable attempt/disposition record. No 2026 product covers the requirement bundle (atomic claim at 64-concurrent + disposition taxonomy + per-unit cost + one-way Temporal projection); the closest analog, OpenAI’s Symphony, ships without all three and its authors declined to productize it [competitive digest §b].
Scope discipline is the whole product. The durable table gets built regardless — the fleet monitor (Robert’s committed, first-to-ship deliverable) needs the identical per-unit store on day one [temporalCase digest; research/12 §2]. Product-wise, work-ledger ships as the data layer of the fleet-monitor deliverable — same lane, same sprint, no separate roadmap; “work-ledger” names the schema + claim endpoints, not a fourth product. One Encore internal-plane service exists underneath it as a technical necessity (endpoints require registerInternalService), not as a separately-branded product boundary. Promotion to an independently-roadmapped standalone product happens only on a named trigger — a second fleet (Tomas’s) claims through it, or the v2 Temporal projection lands (§20 Q1, §25). This PRD specifies v1 to be days of work, local-first, zero core-plane PRs.
2. Problem statement + evidence
Problem: at fleet scale, unclaimed-and-untracked work destroys trust in the whole rebuild program, and the record needed to defend a kill-gate decision does not survive where execution truth lives.
| # | Sub-problem | Evidence | Tier |
|---|---|---|---|
| PB1 | Two concurrent agents claim the same unit → double token spend + merge collision | arXiv:2606.19616 (advisory-lease-only = highest redundant-rediscovery, 180 instances); race-to-close leaves no git trace | confirmed source, hypothesis-grade finding (different system, untested here) [priorArt §2] |
| PB2 | A locks-only queue does not solve claim-before-work — it stops collision, not re-doing finished work | same preprint; closes open question #1 of 01-initial-recommendation.md |
confirmed source, hypothesis-grade finding (different system, untested here) [priorArt §4] |
| PB3 | Disposition + cost + evidence must be readable months after a unit closes; 72h is the operate-skill’s namespace-create example — nothing is provisioned yet and retention is an explicit tuning knob, but the structural argument stands at any realistic setting: closed workflows are purged from persistence and visibility, and Archival keeps replay-only blobs, not a queryable table | temporal-operate SKILL; Temporal docs |
confirmed [temporalCase B1] |
| PB4 | Abandoned migrations become invisible; the estate already carries two rotting half-stranglers (VIS3→legacy VIS; identity-service abandoned 2022) | 26-repo / 1.05M-LOC legacy sample | confirmed [E6] |
| PB5 | No off-the-shelf product covers the bundle; external SaaS trackers are a documented 2026 anti-pattern as a 64-concurrent claim substrate (GitHub 5k req/hr, 8.4× overrun) | four research passes converge | confirmed [competitive §b; oss; priorArt §3] |
| PB6 | Cost accrues with no per-unit attribution once the session ends | OTel carries live per-worktree cost but nothing durable past 72h | supported [research/12 §2; temporalCase] |
Steelman against building (carried, not buried): the durable core is ~1 table + ~150 LOC + 2 views that the monitor needs anyway, so a separate named service may be redundant packaging [Prosecutor case; advisory board — Jobs/Christensen]. Resolution: 11-build-vs-buy.md — agents need the SKIP LOCKED guard wrapped in a typed, full-state-returning endpoint (07-ux-ui.md §3), which in Encore requires registerInternalService; the divergence from the “no service” camp is naming/packaging, not build/no-build. The fold-into-monitor path stays a first-class kill/fold trigger (§25), not a dismissed objection.
3. Goals / Non-goals
Goals
| # | Goal | Measured by |
|---|---|---|
| G1 | No unit of work is ever in an ambiguous state (unclaimed-but-being-worked, double-claimed, closed-without-disposition) | Zero double-claims (hard invariant); anti-metric “units claimed never transitioned” (§15) |
| G2 | Every terminal unit carries a disposition + cost + evidence link that survives Temporal retention and is readable at M4/M8/M12 | North Star: verified throughput (§15); weekly reconciliation audit pass rate |
| G3 | An agent goes from base-URL + token to a claimed unit in one HTTP call, guided only by error strings (no docs site) | Time-to-first-value < 1 min, self-tested in the prototype (07-ux-ui.md §11) |
| G4 | Ships in days, local-first, zero core-plane PRs, no billing-gate dependency | Walking skeleton on encore run :4001 [10-arch §11] |
Non-goals (aggressive — each is a standing decision, not a backlog item)
| # | Non-goal | Why | Escape hatch |
|---|---|---|---|
| N1 | No UI / kanban / board / comments in v1 | Grafana + SQL is the entire human surface; a screen is the “worse Linear two people use” trap | One server-rendered read-only table only when a 3rd human needs interactive access without SQL (07-ux-ui.md §8) |
| N2 | No second tenant required | Tomas’s fleet claiming through it is upside, not a v1 dependency (open question #3, untested by design) | Revisit if his fleet starts claiming domain-migration units |
| N3 | No priorities / sprints / assignee-routing / notifications | claim() picks; the monitor alerts; the ledger records. Every commercial product of this shape (Vibe Kanban/Bloop, Terragon) is defunct |
Never — category-error risk, not a roadmap item |
| N4 | No core-plane PRs in v1 | External-claim endpoint + internal-plane-only tables; zero negotiated ask of Tomas | Any future ask (tasks TaskType) stays a single explicit PR — never implicit coupling |
| N5 | Never execution truth | Temporal is the only execution truth, permanently; work-ledger is a one-way projection + the business fields Temporal purges — not a parallel state machine that could disagree | Never — this is the load-bearing invariant (pre-mortem #3; temporalCase) |
| N6 | No evidence-file storage | Links only; artifacts live in the harness/R2 | Never — PII/PCI stays out of work-ledger by construction (§15) |
| N7 | No embeddings / dedup / rate-limit | That is triage’s R&D lane, not the ledger’s | Belongs to the triage sibling |
4. Product principles
Carried from 10-architecture.md §1 (P1–P7), compressed:
- Accounting system, not an app — value is the data model + invariants, not screens.
- Temporal is the only execution truth — one-way projection; never writes back.
- Affordances at the schema layer — states that cannot be misused (enum + CHECK), claims that cannot race (
SKIP LOCKED), errors that name the violated invariant. - One vocabulary —
unit/claim/attempt/disposition. Neverticket/task(collides with Core’staskssvc) /issue. - Survive retention — every field Temporal purges at 72h lives durably here, readable at M12.
- Thinner than plan 001 — reuse Temporal +
workflow_executions+ workflow-id uniqueness where units are workflows; build only what Temporal purges + the external-claim path. - Zero core-plane PRs in v1 — local-first on
encore run.
5. Personas (agents are first-class)
Full profiles in 03-journey.md. Summary:
| Persona | Role | Path | Frequency | Stakes |
|---|---|---|---|---|
| Fleet agent (10→64 concurrent Claude sessions) | Claims units, works, transitions, records cost, blocks with questions | Write (primary user) — via external-claim API + grpn_ bearer token |
Constant (sub-second at 64-concurrent) | Must never double-claim, must recover from crash mid-unit |
| Robert | Operator: watches queue depth, stall, cost/merged-unit, rework; owns the concurrency ramp | Read (Grafana/SQL) + occasional operator writes (register backlog, force-dispose) | Daily | Fleet legibility; his committed lane |
| Tomas | Read-only stakeholder at kill gates: disposition table with evidence | Read (SQL/Grafana table) | ~3×/year (M4/M8/M12) | Highest-stakes single read; his trust is the moat |
| Harness / triage / monitor (sibling services) | Integrators: write parity results, poll questions, read views | Service-account auth (triage/monitor); harness is external, grpn_ bearer token like a fleet agent (§13) |
Continuous | Correct integration without coupling |
Commercial personas (marketing segments, buyers) — N/A, internal.
6. Jobs-to-be-done
| Job | Hirer | Frequency |
|---|---|---|
| “When I ask for work, give me exactly one unit nobody else is doing, atomically” | Fleet agent | Constant |
| “When I crash mid-unit, make my unit reclaimable and don’t silently lose my attempt” | Fleet agent | On failure |
| “When I can’t establish parity, park the unit with my question and free the claim” | Fleet agent / harness | Per block |
| “Show me if unclaimed work is piling up faster than the fleet finishes it” | Robert | Daily |
| “At the kill gate, show me what happened to each component — MIGRATED/DROPPED/DEFERRED/DEAD, with evidence — and let me trust it without re-verifying” | Tomas | Per gate |
7. Journey summary
Full maps in 03-journey.md (8-stage internal-adoption journey + 5 failure paths). The load-bearing “purchase” analog is the A1 lane negotiation (Tomas grants a CODEOWNERS-partitioned lane) — the actual go/no-go on placement, distinct from Tomas’s separate read-only role at gates. Steady-state loop:
flowchart LR REG[register backlog] --> READY[unit: ready] READY -->|claim SKIP LOCKED| WORK[agent works in worktree<br/>heartbeat extends lease] WORK -->|parity miss| BLOCK[blocked + question → triage] BLOCK -->|answer written back| WORK WORK -->|parity pass| VERIFIED[verified + evidence link] VERIFIED -->|dispose| DONE[MIGRATED / DROPPED / DEFERRED / DEAD + cost] WORK -->|agent dies, lease expires| READY DONE --> VIEW[(SQL views → Grafana / Tomas kill-gate read)]
8. User-stories summary
Full set in 04-user-stories.md (26 stories US-00…US-25 + 7 rejected). Critical path: US-00 → US-01 → US-02 → US-04 → US-09 → US-16 → US-17 (claim → transition → block → stale-takeover → external-auth → kill-gate disposition table). All v1 stories route through the external-claim path — no Temporal-modelled units exist yet (repoRecon: only one toy workflow on the internal-plane substrate the stream work-ledger would project from; Core’s merchant-contract-signing workflow runs on Core’s own workers and is irrelevant to the projection point); projection stories are post-MVP.
P0 (ships or MVP doesn’t work): US-00, 01, 02, 04, 05, 06, 07, 09, 15, 16, 17, 22, 23, 24. Rejected outright (traceable to non-goals §3): kanban UI, comments, priorities, notifications, assignee routing, in-ledger embedding dedup, multi-tenant roles.
9. Functional requirements
Each requirement’s acceptance criteria are testable and concrete. Endpoint/enum names defer to 10-architecture.md §3/§6.
F1 — Unit registry (backlog import)
POST /units (batch) registers work units keyed by a unique external_ref (legacy component/capability key). Idempotent register.
- AC1.1 Registering the same
external_reftwice inserts one row; the second call is a no-op returning the existing row (proven by unique-index test onexternal_ref). - AC1.2 A newly registered unit is
state='ready',disposition=null,claimed_by=null.
F2 — Atomic claim with lease + heartbeat + stale takeover
POST /units/claim (pull-next, FOR UPDATE SKIP LOCKED) and POST /units/:id/claim (named, conditional UPDATE … RETURNING). Claim carries a lease (lease_expires_at, default ~15 min — calibration knob, not a hardcoded constant); POST /attempts/:id/heartbeat extends it. Pull-next also matches state='claimed' AND lease_expires_at < now() (stale reclaim, attempt_no++, stale_reclaimed event).
- AC2.1 Two concurrent claims on one ready unit: exactly one 200 (claimed row returned), exactly one 409 with error string #1 verbatim — proven by a vitest race test against real Docker Postgres (new infra; no house pattern to copy — repoRecon §5).
- AC2.2 N concurrent claimers vs M<N ready units → exactly M distinct units claimed, none twice (same test, parameterized).
- AC2.3 Same agent calling
claim()again while holding a unit returns its same held unit, never a second (idempotent-per-agent). - AC2.4 A claim whose
lease_expires_atis in the past is reclaimable by pull-next; reclaim bumpsattempt_noand appends astale_reclaimedevent (the dead agent’s work leaves a trace) [US-06, US-09]. - AC2.5 A named claim on a live-held unit returns 0 rows → 409 with error string #1 (US-07).
F3 — Transitions with DB-enforced state machine
POST /units/:id/transition moves a unit per the enum-guarded allow-map (10-architecture.md §3 state diagram). Illegality is rejected by the DB (UPDATE … WHERE state=$expected → 0 rows), not an app if.
- AC3.1 An illegal transition (e.g.
blocked → verifieddirectly) returns error string #2 verbatim naming the violated invariant + legal next states — not a generic 500 (US-08). - AC3.2 A transition by an agent not holding the active claim returns error string #3 (
permissionDenied) (US-02 edge). - AC3.3
transition → verifiedwithoutevidence_urlreturns error string #4 (invalidArgument). - AC3.4 Every write returns the complete current row (full-state feedback), never
{ok:true}(07-ux-ui.md§3.2).
F4 — Block with question
POST /units/:id/block parks a claimed unit → blocked, creates a blocking_questions row (status='open', free-form assigneeGroupSlug default agent-questions), releases the active claim’s work-hold, appends a blocked event.
- AC4.1 After
block, the unit isstate='blocked'and oneblocking_questionsrow exists withstatus='open'and the suppliedquestiontext (US-04). - AC4.2 The open-question set is pollable by the triage sibling via
blocking_questions WHERE status='open'(index-backed) — zero core-plane PR (US-20). - AC4.3
unblockwrites the answer back →in_progress, setsstatus='answered', appendsunblockedevent.
F5 — Dispositions (absorbs the decommission ledger)
Terminal disposition enum MIGRATED | DROPPED | DEFERRED | DEAD set once, immutable, on dispose. Rolls up per unit (~1000 rows); component-tier decisions (~26 repos) stay in reasons-and-decisions/ ADRs, cross-linked not merged [temporalCase concession].
- AC5.1
disposesetsdisposition+ terminal state (done/abandoned) + an evidence link; adisposedevent is appended (US-05). - AC5.2 Attempting to overwrite a set disposition returns error string #5 verbatim (“dispositions are immutable … record a superseding ADR”) —
failedPrecondition(US-05 edge). - AC5.3 All four dispositions are equally terminal and equally count toward verified throughput — a correctly-diagnosed DEAD unit is as much progress as MIGRATED (
09-north-star.md§7).
F6 — Cost entries
cost_cents recorded per attempt (POST /attempts/:id/cost, additive by source_event_id, or folded into transition-to-terminal), rolled to work_units.cost_cents.
- AC6.1 Recording cost twice with the same
source_event_id/Idempotency-Keycounts once (no double-count on retry) (US-03). - AC6.2
work_units.cost_centsequals the sum of its attempts’cost_centsafter any sequence of recordings (roll-up invariant test). - AC6.3 Per-model/per-phase cost breakdown is out of MVP — a single number per attempt satisfies cost/unit + rework views (D8; §20).
F7 — SQL read views (the human “screens”)
Exactly the MVP set, 1:1 with monitor panels: v_queue_depth, v_cost_per_unit (per unit + per merged unit), v_disposition_summary (counts + evidence links — Tomas’s kill-gate table), v_rework (attempt_count > 1).
- AC7.1 Each view returns correct counts against a seeded fixture (view-level assertion).
- AC7.2 An empty queue renders as “clear,” not a broken chart; no
MIGRATEDyet renders “no completions,” not zero (07-ux-ui.md§6 empty-states). - AC7.3
v_disposition_summaryis drillable to row-level (unit_id, component, disposition, evidence_url, cost_cents, disposed_at) via a saved SQL query / Grafana table — no server-rendered page in v1 (US-17).
F8 — Idempotency + append-only audit
Every write carries an Idempotency-Key header → source_event_id; a replay returns the prior result via the events unique constraint. work_unit_events is append-only (auditlog pattern, no updated_at, keyset index).
- AC8.1 A replayed
transition/block/disposePOST with the sameIdempotency-Keyproduces the same result and no duplicate event row (US-23). - AC8.2 No
UPDATE/DELETEpath exists againstwork_unit_events(append-only enforced by code-review rule + absence of a repository mutate method).
F9 — Projection from Temporal (POST-MVP, spec’d as the contract)
When fleet units run as internal-plane Temporal workflows, work-ledger projects execution events one-way (event-id dedup via work_unit_events.source_event_id, read-only generated client).
- AC9.1 (v2) Replaying the same Temporal event stream twice produces transitions once (
source_event_idunique blocks the dupe) [T2]. - AC9.2 (v2) work-ledger never writes execution state back to Temporal or Core
workflow_executions(one-way invariant, code-review-enforced + weekly reconciliation query) (US-22). - Not in v1 — the interceptor hook is a hardcoded file in Tomas-owned
workflow-proxywith only one toy workflow flowing through it; wiring it is a negotiated substrate touch, not a free read (repoRecon §3;10-architecture.md§5).
10. Non-functional requirements
| # | NFR | Target | Test |
|---|---|---|---|
| NF1 | Zero double-claims, ever | Hard invariant, not an SLO — one occurrence stops the ramp (§24) | AC2.1/AC2.2 race test; double-claim counter alert |
| NF2 | Claim latency | p99 < 200 ms local (single SKIP LOCKED statement + one attempt insert + one event insert, one tx) |
measured against Encore dev PG under the race test load |
| NF3 | Append-only audit | work_unit_events never mutated/deleted |
AC8.2 |
| NF4 | Restore-from-backup drill | A pg_dump → fresh-DB restore reproduces every unit’s current state + full attempt/event history with zero loss; drill run once before first real Orders unit and documented |
operational drill (§17) |
| NF5 | Concurrency ceiling | Correct at 10→64 concurrent; the SKIP LOCKED vacuum/MultiXact pressure point (thousands of workers) is irrelevant here — 1000 = queued units, never concurrent editors [E9] |
AC2.2 at N=64 |
| NF6 | Local-first / no billing gate | Full v1 runs on encore run :4001 against Docker PG; cross-plane clients stay lazy (app-ids empty) |
walking-skeleton deploy |
| NF7 | Simplicity budget | v1 = 4 tables + 5 enums + ~150–200 LOC service + 4 views; if a field takes >1 sentence to explain, cut it | code review |
11. Data requirements
| # | Requirement | Detail |
|---|---|---|
| D-1 | Retention: the ledger outlives Temporal retention — that is its reason to exist | Rows are permanent for the life of the program (through M12+). No TTL, no purge. 72h is the operate-skill’s namespace-create example, not a provisioned value — retention is a tuning knob, but at any realistic setting Temporal purges execution truth; work-ledger’s disposition/cost/evidence must be readable months later [PB3; temporalCase B1] |
| D-2 | One row per unit, forever | work_units mutable registry + live claim + current state; unique external_ref |
| D-3 | One row per attempt | work_unit_attempts append-friendly; cost + evidence per attempt; attempt_no 1-based; rework = attempt_count > 1 |
| D-4 | Append-only event log | work_unit_events (auditlog precedent — explicit columns, mode:"string" timestamps, (created_at desc, id desc) keyset index); also the exactly-once projection dedup home |
| D-5 | Free text confined to 3 columns | question, answer, evidence_url (+ detail jsonb on events). Everything enumerable is an enum with CHECK |
| D-6 | Evidence = links only | evidence_urls array of R2/harness URLs, never payloads (§15) |
| D-7 | Backlog cardinality | ~1000 unit rows (Orders beachhead first), ~26 component-tier decisions stay in ADRs |
12. Integrations
| Integrator | Mechanism | Direction | v1? |
|---|---|---|---|
| Dual-run harness (CLI/CI, outside Encore — addendum #4) | transition(verified, evidence=<parity-report-url>) on parity pass, block(question) on miss; report artifact lives in R2, ledger stores the link |
harness → work-ledger | Yes (US-19) |
| Triage | Polls blocking_questions WHERE status='open', dedups/rate-limits, routes to human tasks INFORMATION_REQUEST (assigneeGroupSlug='agent-questions'), writes answer back via unblock |
work-ledger ↔ triage ↔ Core tasks | v1 poll surface yes; tasks-minting v2 (US-20) |
| Fleet monitor | Grafana SQL datasource reads the 4 views directly; OTel carries live per-call cost, work-ledger carries the durable per-unit roll-up — no overlap | monitor → work-ledger views | Yes (US-21) |
| workflow-proxy stream | One-way projection of internal-plane workflow events (negotiated substrate touch) | workflow-proxy → work-ledger | Post-MVP (F9) |
13. Permissions
| Caller | Mechanism | Source |
|---|---|---|
| Non-Encore Claude agents (the v1 reality) | Authorization: Bearer grpn_<prefix>_<random>, minted via Core api-tokens (HMAC-SHA256 hash stored, token shown once); validated identically on both planes since commit eada68c “One credential header” — no new mechanism to build |
repoRecon §6; confirmed |
| In-cluster services (triage, monitor) | Service-account bearer via libs/service-account.ts (internal:<svc> in-memory token), same Authorization header |
repoRecon §6 |
| Harness (dual-run/parity, runs outside Encore) | External caller, same Authorization: Bearer grpn_<prefix>_<random> path as fleet agents — not a privileged in-cluster caller by virtue of being Robert’s own tooling |
plan 001 architect addendum #4; 04-user-stories.md US-19 |
| Authorization granularity | Role/permission check in the controller only (.claude/rules/encore-ts.md), not deeper; claim-holder identity checked on transition/block/dispose (error string #3 on mismatch) |
10-arch §9 |
| No new auth surface | v1 adds zero auth code — reuses the existing single-header path | non-goal N4 |
14. Analytics & instrumentation
The metric set is 09-north-star.md’s taxonomy; instrumentation is what the schema + views must emit.
| Category | Metric | Instrumented from |
|---|---|---|
| North Star | Verified throughput = units/week reaching a terminal disposition with both cost and evidence_url populated (rate, not stock) |
v_disposition_summary + cost/evidence non-null filter |
| Activation | First agent-claimed unit (fleet + per-agent) | first successful claim() — not US-00 (04-user-stories.md’s “Activation story”), which is the first unit going claim→work→verified end-to-end: the end-to-end proof gate, a different, later event |
| Time-to-value | Median cycle time disposed_at − claimed_at |
events |
| Engagement | Daily claiming agents (distinct IDs, ≥1 claim()/day) |
claim events |
| Retention-equivalent | Weekly reconciliation audit pass rate | reconciliation cron (§17) — this is the trust metric |
| Quality | Rework rate (attempt_count > 1) |
v_rework |
| Reliability | Claim p99 + zero double-claims (hard invariant) | latency histogram + double-claim counter |
| Guardrails | Claim→verified conversion; stale-claim rate; ledger coverage % of fleet work | events + stall view |
| Anti-metrics | Units claimed never transitioned (“ledger theater”); row growth without dispositions | claim-without-follow-up query |
Reporting rule: disposition mix is reported alongside verified throughput (stacked, not blended) — DEFERRED is the cheapest disposition to assign, so a rising DEFERRED share is a smell test on the headline number (09-north-star.md §7.1).
15. Security & privacy
| Concern | Design | Sign-off |
|---|---|---|
| PII / PCI | Evidence is LINKS only — R2/harness URLs, never payloads. Recorded legacy traffic behind those links is PII/PCI | — |
| Security-architect sign-off (blocking item) | ⚠ REQUIRED before any evidence link may point at raw recorded legacy traffic. Ship the link column; do not populate it with raw-traffic URLs until sign-off. Fixture retention/masking is out of work-ledger’s scope but gates the harness integration | open — §17/§20 |
| Prompt leakage | OTEL_LOG_USER_PROMPTS stays OFF |
research/12 §2 |
| Least privilege | Controller-only authz; claim-holder check on mutating calls | 10-arch §9 |
| Free-text junk | Confined to 3 columns; everything else enum+CHECK; risk that an agent dumps reasoning into question/evidence_url is mitigated structurally, monitored via Grafana anomaly |
UX risk table 07-ux-ui.md §10 |
Commercial privacy surfaces (consent flows, data-subject requests, retention policy pages) — N/A, internal.
16. Operational requirements
| # | Requirement | Detail |
|---|---|---|
| O1 | Local-first run | pnpm backend:internal → encore run :4001 against Encore’s Docker-backed Postgres; no GCP billing gate [E5] |
| O2 | Migrations | Generated only via pnpm drizzle (never hand-edited); pnpm drizzle --check proves sync; per-service db/migrations/* committed verbatim (repoRecon §4) |
| O3 | Validate gate | pnpm validate (biome + type-check + test) before every commit; no coverage threshold exists in the repo — do not invent one (repoRecon §5) |
| O4 | Weekly reconciliation audit job | *.cron.ts: for units with a workflow_id, compare work_units.state vs Core workflow_executions.status (generated client). Any mismatch is an alert, not an auto-fix — recovery is idempotent read-before-write, never an out-of-band manual edit. This is the detection signal for the two-sources-of-truth failure (pre-mortem #3). In v1 (no workflow-modelled units) the job’s scope is the empty set — it lands with projection (F9) but the audit discipline (sampled cost/evidence/disposition spot-check) runs from day one as the retention-equivalent metric |
| O5 | Restore drill | NF4 — run once before the first real Orders unit, documented in the decision log |
| O6 | Decision log | Append-only reasons-and-decisions/07-decision-log.md, supersede-never-edit; every work-ledger design decision logged (root CLAUDE.md) |
| O7 | LLM run evidence | Any manual/batch/offline LLM run on the fleet’s behalf logged per the run-evidence rule — but note work-ledger itself makes no model calls (deterministic service, 10-arch §8) |
17. Dependencies (works under both A1 and A-lite)
| Dep | Status | If unmet |
|---|---|---|
| A1 — Tomas grants a CODEOWNERS-partitioned lane in the internal plane | assumption, untested (assumption A1, plans/001-factory-apps-validation/01-deep-dive.md §Assumption log); likelihood Medium |
A-lite fallback: Robert-owned repo, API-only integration — same schema, only the deploy target changes (D5). The PRD is placement-agnostic by construction; nothing in v1 requires the lane to exist |
| Encore + Drizzle + Docker PG | confirmed available (repo runs) | — |
grpn_ bearer-token auth |
confirmed current (commit eada68c) |
— |
| Grafana (local-first, docker-compose) | fleet-monitor deliverable; mandated | Ships with the monitor; work-ledger only exposes views |
| Security-architect sign-off | open (§15) | Evidence-link column ships empty of raw-traffic URLs until granted |
| Temporal projection substrate | not needed v1 | Post-MVP; external-claim path is standalone and complete |
This PRD is valid whether or not A1 resolves. Placement is a deploy-target decision, not a requirements decision.
18. Constraints
- Postgres-first, TDD seams, DDD, simplicity — Robert’s constitution [E10].
- House conventions non-negotiable: layered controller/service/repository,
BaseRepositoryextension, keyset pagination for the append-only table (auditlog precedent),registerInternalService, no relative import climbing >1 level, English-only, no em-dash, every number carries source/window/confidence. - No Docker-PG vitest pattern exists in the repo — the atomic-claim concurrency test (AC2.1) is new infrastructure, not a reuse; budget for it (repoRecon §5).
- Review capacity is the real ceiling — AI-authored PRs wait 4.6× longer, 32.7% acceptance [E8;
research/12§1.7]. work-ledger supplies the verified-throughput numerator; it does not own the review-capacity metric. - 64-concurrent hard ceiling — nothing published validates orchestration past 64 [E9].
19. Risks (top 5 — full 18-month set in 13-pre-mortem.md)
| # | Risk | Likelihood | Detection | Mitigation |
|---|---|---|---|---|
| R1 | The lane never existed — A1 drags, Robert builds nothing for a quarter | Medium | No OWNERSHIP.md within 2 weeks of the Tomas conversation (research/14) |
Trigger A-lite immediately — portfolio is placement-agnostic (§17) |
| R2 | Two sources of truth — ledger and Temporal disagree during an incident, trust in both collapses | Low–Medium | First weekly reconciliation mismatch | One-way projection by construction; projection is v2, so v1 cannot drift at all (D4, F9) |
| R3 | Ledger theater — agents claim but the API is too awkward to use unassisted, so real work flows around it | Medium | <50% of fleet work through claims after 4 weeks; anti-metric climbing | Error-strings-as-docs (§9 F3); walking-skeleton usability self-test (§23); kill/fold trigger (§25) |
| R4 | Tomas never reads the disposition table — the audit half is built for an assumed reader | Medium | No kill-gate read at M4; open question #3 stays untested | Get his explicit written yes/no before the disposition schema is treated as final (§20 Q3); the claim half stands alone if he says no |
| R5 | Blind scale past 64 — fleet ramps on the calendar, rework explodes | High if ignored [E9] | Rework rate vs concurrency curve (v_rework) |
Ramp 10→50→64 gated on ledger metrics (§24); hard cap until data says otherwise |
20. Open questions
| # | Question | Owner | Resolve by |
|---|---|---|---|
| Q1 | Service vs. fold-into-monitor — does the durable table ship as a standalone, independently-roadmapped work-ledger product or as the data layer of the fleet-monitor deliverable? (The build-vs-buy verdict, 11-build-vs-buy.md Bottom line + 02-research.md §5.3: fold-into-monitor by default — one Encore service exists because endpoints require registerInternalService, but that’s packaging, not product boundary. Promotion to standalone gated on a named trigger: a second fleet claims through it, or the v2 Temporal projection lands) |
Robert | Before v1 deploy; re-evaluated at the 4-week kill gate (§25) |
| Q2 | A1 lane — granted, or A-lite? | Tomas / Robert | 2 weeks post-research/14 |
| Q3 | Does Tomas actually read a disposition table (vs. the ADR log he already reads)? Untested prior — one message, not a doc | Tomas | Before disposition schema is final |
| Q4 | DEFERRED vs DROPPED — is the distinction operationally real at a kill gate, or should the 4-way enum collapse? (Advisory challenge — Jobs/Ive) | Robert / Tomas | From first 4 weeks of real disposition data, not decided a priori |
| Q5 | State granularity — 7-state enum (10-arch) vs 5-state (07-ux-ui). If agents conflate tiers after the prototype, collapse — don’t add a doc | Robert | After walking-skeleton agent test |
| Q6 | Claim TTL / lease duration — not knowable from first principles; set an initial value, watch the stall-list panel, tune | Robert | Continuous calibration knob |
| Q7 | Has a real collision actually happened at current (~10-agent) scale, or is the 64-concurrent failure mode entirely modeled? | Robert | Instrument from claim #1; report at 4-week gate |
21. MVP scope
MVP = the walking skeleton grown to a complete claim+disposition loop for one domain (Orders), local-first. Not the full vision; not a claim-only spike either — because the disposition/cost half is the reason to own it at all (open question #3) and the monitor forces the same table on day one, so deferring it saves nothing (temporalCase crossover).
| In v1 | Reason | Out of v1 | Reason |
|---|---|---|---|
| 4 tables + 5 enums (10-arch §3) | Irreducible core surviving retention | Temporal projection (F9) | No workflow-modelled units exist; negotiated substrate touch |
| Claim (pull-next + named) w/ lease/heartbeat/stale takeover | The mechanism that makes 64-concurrent safe | Temporal workflow-id claim | v2 — for workflow-modelled units only |
| DB-enforced transitions + 5 error strings verbatim | Error strings are the agent’s only docs | tasks INFORMATION_REQUEST minting + cross-plane bridge |
Triage’s lane; no cross-plane signal bridge exists |
| Block-with-question + pollable open set | Parks units without core-plane PR | cost_entries per-model/per-phase breakdown |
One number/attempt suffices; add only if Grafana asks (D8) |
| 4-way disposition (immutable) + evidence links | The audit half; absorbs decommission ledger | Kanban / UI / comments / priorities / notifications | Non-goals N1–N3 |
cost_cents per attempt → rolled to unit |
Cost/unit + rework views | Second tenant / cross-fleet claim | Non-goal N2; open question #3 |
| 4 SQL views + kill-gate drill-down query | Human read surface | Server-rendered disposition page | Only at 3rd human without SQL (N1 escape hatch) |
External-claim grpn_ auth |
v1 fleet reality | Cloud deploy | After billing gate |
| Idempotency keys + append-only events | No double-count, full audit trail | Coverage-threshold gate | None exists in repo; don’t invent |
| Atomic-claim concurrency test (new infra) | Proves NF1 | — | — |
22. Post-MVP roadmap
flowchart LR V1["v1: 4 tables + claim/transition/block/dispose/cost<br/>+ 4 views + external-claim auth (local-first)"] V2["v2: Temporal projection (workflow-proxy fan-out)<br/>+ workflow-id claim + tasks minting + cloud deploy"] V3["v3: cost breakdown table + old-estate 1→N unit types<br/>+ second-fleet onboarding (if open Q3 flips)"] V1 -->|"real workflow units exist<br/>AND/OR billing gate clears"| V2 V2 -->|"Grafana needs per-model splits<br/>OR ledger >500 units"| V3
| v2 item | Trigger |
|---|---|
| Projection from internal-plane workflow stream (F9) | Fleet units become internal-plane Temporal workflows + negotiated workflow-proxy ask |
| Workflow-id claim primitive | Same |
tasks INFORMATION_REQUEST minting + answer bridge |
Triage graduates; single negotiated PR |
| Cloud deploy (GKE, Cloud SQL) | Billing gate clears [E5] |
| v3 item | Trigger |
|---|---|
cost_entries breakdown table |
Grafana needs per-model/per-phase splits (D8) |
| Old-estate 1→N split as new unit row types | Ledger >500 units (00-SYNTHESIS.md evolution path) |
| Second-fleet (Tomas’s) onboarding | Open question #3 flips — his fleet starts claiming |
23. Release strategy
Three phases, each with a gate. Phase 1 is literally TDD step 1–2 of 00-SYNTHESIS.md.
| Phase | Scope | Success gate |
|---|---|---|
| 1. Walking skeleton (days) | work_units migration + claim() atomic guard + v_queue_depth + one Grafana panel + the concurrency test. Nothing else |
AC2.1 (2 concurrent claims → exactly one 200, one 409 verbatim) passes on real Docker PG; a fresh agent reaches a claimed unit in <1 min guided only by error strings (§23 self-test, 07-ux-ui.md §11) |
| 2. First real unit on Orders | Full v1 surface (transition/block/dispose/cost + 4 views + external auth), one real Orders unit driven claim→verified→MIGRATED with cost + evidence | One Orders unit reaches a terminal disposition with cost + evidence populated; Tomas reads the v_disposition_summary drill-down once (closes Q3 with a real read, not an assertion) |
| 3. Ramp gates | Fleet ramps 10 → 50 → 64, each unlock metric-gated | Each step’s 4 conditions hold (§24) before adding agents |
Restore-from-backup drill (NF4) runs between phase 1 and 2.
24. Ramp gating
Concurrency is unlocked by metrics, not the calendar (09-north-star.md §9):
| Step | Unlock condition (all must hold) | If violated |
|---|---|---|
| 10 → 50 | Verified throughput trending up ≥2 wks at 10; rework rate flat/falling; zero double-claims; claim→verified conversion ≥ floor (set from first 4 weeks) | Hold at 10; fix the broken guardrail before adding agents |
| 50 → 64 | Same four sustained ≥2 wks at 50, plus stale-claim rate not rising with concurrency | Hold at 50 |
| Past 64 | Not attempted — nothing published past 64 [E9]; 1000 = queued units, never concurrent editors | Hard ceiling by evidence |
25. Success metrics, pivot criteria, kill criteria
Success (v1 declared successful when)
- Verified throughput is a non-zero, reported weekly rate with disposition mix.
- Zero double-claims across the whole ramp (hard invariant held).
- Tomas has read
v_disposition_summaryat ≥1 real kill gate and the reconciliation audit found no discrepancy (the moat exists). - ≥50% of fleet work flows through claims (ledger coverage floor).
Pivot criteria (change shape, don’t kill)
| Trigger | Pivot |
|---|---|
| Agents conflate the 2-tier state model repeatedly after the prototype | Collapse the state enum (Q5) — don’t add docs |
| Rising DEFERRED share games verified throughput | Report mix stacked; add a DEFERRED-share guardrail threshold from real data |
| Rework rate climbs with concurrency | Recalibrate claim TTL first (Q6), then hold the ramp — not the fleet’s fault first |
Kill / fold criteria (concrete)
| Trigger | Action |
|---|---|
| After 4 weeks of fleet operation, <50% of fleet work flows through claims | Kill or fold into the fleet monitor’s schema — the ledger isn’t earning its service boundary; the table survives inside the monitor (Q1) |
| Tomas confirms (Q3) he will read the ADR log, not a disposition table, at gates, and no collision has occurred at current scale (Q7) | Fold the disposition half into ADRs; keep only claim + the monitor’s cost/queue table — the standalone service is not justified |
| A1 rejected and A-lite also blocked | The problem is placement, not product — escalate; do not build homeless |
| First reconciliation discrepancy that erodes Tomas’s trust and cannot be root-caused to a fixable projection bug | Stop the ramp; the moat is trust and it’s spent (pre-mortem #3) |
The honest through-line: the durable table gets built regardless (11-build-vs-buy.md, temporalCase). It ships as the data layer of the fleet-monitor deliverable by default — same lane, same sprint — with one Encore internal-plane service underneath it out of technical necessity (registerInternalService), not as a fourth product. Promotion to a standalone, independently-roadmapped service happens only on a named trigger (a second fleet claims through it, or the v2 Temporal projection lands); the fold-into-monitor kill trigger below is that same default made concrete at the 4-week gate, not a fallback.
Cross-references
| Doc | What it owns that this PRD doesn’t |
|---|---|
02-research.md |
Evidence tiers, advisory board, steelman both ways |
03-journey.md |
Full stakeholder + journey maps, 5 failure paths |
04-user-stories.md |
Full US-00…US-25 + rejected stories |
07-ux-ui.md |
Endpoint surface detail, 5 error strings verbatim, Grafana panel specs |
09-north-star.md |
Full metric taxonomy + NSM defense |
10-architecture.md |
Canonical schema, enums, claim algorithm, projection contract, decision table |
11-build-vs-buy.md |
The 8-option sourcing decision + the service-vs-fold resolution |
13-pre-mortem.md |
Full 18-month failure modes behind §19 |
14-validation-plan.md |
Riskiest assumptions → cheapest disproving tests |