User stories
Work-ledger has seven personas, zero of whom are a person clicking a screen except at the very edges (Robert reads Grafana/SQL; Tomas reads a disposition table). Five personas are software: fleet agents, the harness, the triage service, the fleet monitor, and (post-MVP) Temporal’s projection stream. Stories are written from each caller’s perspective against the API-as-UX surface 01-initial-recommendation.md §“Recommended interaction model” specifies — there is no other UX to design for.
Scope correction from research (see shared digests, temporalCase + repoRecon): v1 has no Temporal-modelled fleet units yet — one toy workflow exists in the whole monorepo (repoRecon §3). Every v1 story below therefore routes through the external-claim path (architect addendum #2), not a Temporal projection. Stories that assume a Temporal-backed unit are explicitly marked post-MVP. Component-level disposition (~26 repos, ADR-tier) and unit-level disposition (~1000 rows, table-tier) are two different things — temporalCase digest’s steelman is the first place this repo’s research draws that line explicitly; US-17 depends on it.
Legend
| Field | Meaning |
|---|---|
| Priority | P0 = critical path, ships or MVP doesn’t work. P1 = in MVP, not launch-blocking. P2 = valuable, sequenced after MVP. |
| Classification | MVP / Post-MVP / Rejected, per 01-initial-recommendation.md §“What should NOT be in the MVP” |
Schema names (state, disposition, claim_lease_expires_at, …) |
Hypothesis, for acceptance-criteria concreteness only — canonical schema is 10-architecture.md’s to define, not this doc’s |
| Numeric thresholds (lease duration, ramp gates) | Assumption, mechanism is confirmed by research, exact values are operator-tuned and intentionally left TBD rather than fabricated |
Critical path
US-00 → US-01 → US-02 → US-04 → US-09 → US-16 → US-17. Claim, transition, block, and stale-claim takeover are the mechanism that makes 64-concurrent safe at all (priorArt digest: locks-alone reproduces the highest redundant-work rate of any tested condition — arXiv:2606.19616, June 2026 — so claim without a durable completion/takeover record does not solve claim-before-work). US-16 is the v1 reality (no Temporal projection exists yet). US-17 is why the product is worth owning at all (01-initial-recommendation.md: “dispositions alone justify it”).
Activation story: US-00 — the first unit that goes claim → work → verified end-to-end, proving the loop before ramping past a pilot concurrency.
Retention-equivalent: US-25 — work-ledger has no users to retain in the SaaS sense; the trust-retention signal is the weekly reconciliation audit passing (ledger state vs. Temporal/harness ground truth never disagreeing), because the failure mode that kills an internal system of record is silent drift, not churn.
Summary
| ID | Persona | Story | Priority | Class |
|---|---|---|---|---|
| US-00 | Fleet agent | Activation: first unit claimed→verified end-to-end | P0 | MVP |
| US-01 | Fleet agent | Claim a unit atomically | P0 | MVP |
| US-02 | Fleet agent | Transition unit state | P0 | MVP |
| US-03 | Fleet agent | Report cost per attempt | P1 | MVP |
| US-04 | Fleet agent | Block unit with a blocking question | P0 | MVP |
| US-05 | Fleet agent | Record terminal disposition with evidence | P0 | MVP |
| US-06 | Fleet agent | Crashed mid-unit — claim lease expiry | P0 | MVP |
| US-07 | Fleet agent | Double-claim attempt rejected | P0 | MVP |
| US-08 | Fleet agent | Illegal transition rejected | P1 | MVP |
| US-09 | Fleet agent | Stale claim takeover by a peer agent | P0 | MVP |
| US-10 | Robert (operator) | Queue depth view | P1 | MVP |
| US-11 | Robert (operator) | Stall detection | P1 | MVP |
| US-12 | Robert (operator) | Cost per merged unit | P1 | MVP |
| US-13 | Robert (operator) | Rework rate | P1 | MVP |
| US-14 | Robert (operator) | Ramp gating 10→50→64 | P1 | MVP |
| US-15 | Robert (integrator) | Wire claim into the fleet loop | P0 | MVP |
| US-16 | Robert (integrator) | External-claim auth | P0 | MVP |
| US-17 | Tomas | Kill-gate disposition table with evidence links | P0 | MVP |
| US-18 | Tomas | Zero-effort trust | P1 | MVP |
| US-19 | Harness | Writes parity-result rows | P1 | MVP |
| US-20 | Triage service | Consumes blocking questions | P1 | MVP |
| US-21 | Fleet monitor | Reads queue-depth/cost views | P1 | MVP |
| US-22 | Trust & safety | Projection-only invariant | P0 | MVP (guard) / Post-MVP (projection itself) |
| US-23 | Trust & safety | Append-only history | P0 | MVP |
| US-24 | Trust & safety | PII-free evidence links | P0 | MVP |
| US-25 | Trust & safety | Weekly reconciliation audit (retention-equivalent) | P1 | MVP |
Stage 1 — Activation
US-00 — End-to-end proof gate
- Story: As a fleet agent, I want to claim a real unit, do the work, and transition it to
verifiedwith evidence, so that the ledger is proven to hold under real use before Robert ramps concurrency past a pilot. - JTBD: Prove the four-endpoint loop (claim → work → report cost → transition) is trustworthy with one unit before trusting it with a thousand — this doubles as the PRD Phase-1/2 release gate (
12-prd.md§23), not the Activation metric itself: Activation is the earlier, first successfulclaim()call (09-north-star.md§8), which this story’s step 1 subsumes. - Preconditions: Schema migrated; at least one seeded unit in
UNCLAIMED; one authenticated agent identity (US-16). - Main flow: 1) agent calls
claim(unit), gets full unit state back. 2) agent does the audit/migration work in its worktree. 3) agent reports cost (US-03) at least once. 4) agent callstransition(unit, VERIFIED, evidence_url=...). 5) Robert confirms the row in the queue-depth/cost views (US-10/US-12). - Edge cases: Agent claims but the seeded unit has malformed preconditions (no title/component ref) — claim still succeeds, work reveals the gap, unit gets blocked (US-04) instead of verified; that is itself a valid activation outcome, not a failure.
- Acceptance criteria:
- Given a clean schema and one seeded unit, when the four-call sequence runs, then the unit’s final row shows
state=VERIFIED, non-nullcost, non-nullevidence_url, and exactly oneattemptrow. - Given the same sequence run twice against the same unit id, then the second
claim()fails with the double-claim error (US-07), not a silent no-op. - This is literally TDD step 1 in
00-SYNTHESIS.md(“First implementation steps”) — the activation test and the first production increment are the same artifact.
- Given a clean schema and one seeded unit, when the four-call sequence runs, then the unit’s final row shows
Stage 2 — Fleet agent: core loop
US-01 — Claim a unit atomically
- Story: As a fleet agent, I want to claim a unit of work so that no other agent in the fleet can start the same unit while I hold it.
- JTBD: Get exclusive, durable ownership of one unit without a coordination meeting or a side-channel convention.
- Preconditions: Agent authenticated (US-16); target unit
state=UNCLAIMED(or its lease has expired, US-09). - Main flow: 1) agent requests a claim, optionally by unit id or “give me the next unit” (queue-order pop). 2) DB claims via
FOR UPDATE SKIP LOCKED(chosen mechanism,ossdigest: “cheaper to write than to integrate a dependency for” — Que/Oban/pg-boss/graphile-worker precedent, mature). 3) response returns full unit state + a lease expiry timestamp + anattempt_id. - Edge cases: Empty queue (no claimable units) → explicit empty response, not an error. Agent requests a specific unit id already claimed by someone else → US-07.
- Acceptance criteria:
- Given 2 concurrent claim calls for the same unit, when both race, then exactly one succeeds and the other gets a structured conflict response, proven under Docker PG with 2 concurrent claimers (
00-SYNTHESIS.mdTDD step 1;repoRecondigest flags this as new infra — no house vitest+Docker-PG pattern exists yet, unlike claimed in earlier drafts). - Given a successful claim, when the response returns, then it includes the full unit row (Norman “feedback” — every call returns full resulting state, per
01-initial-recommendation.md). - Claim latency stays p99 < 200ms, measured under the AC2 race test (12-prd NF2).
- Given 2 concurrent claim calls for the same unit, when both race, then exactly one succeeds and the other gets a structured conflict response, proven under Docker PG with 2 concurrent claimers (
US-02 — Transition unit state
- Story: As a fleet agent, I want to move a claimed unit through its lifecycle (e.g.
CLAIMED → VERIFIED,CLAIMED → BLOCKED) so that the ledger reflects real progress and nobody else can enter an invalid state on my behalf. - JTBD: Record state changes I’m allowed to make; be stopped by the database, not by convention, from making ones I’m not.
- Preconditions: Unit currently claimed by the calling agent’s active attempt.
- Main flow: 1) agent calls
transition(unit, target_state, evidence?). 2) enum-constrained state machine + CHECK constraint validates the transition. 3) success returns full new state; theattemptrow is stamped with the transition. - Edge cases: Transition attempted by an agent that doesn’t hold the current claim (stale local state) → rejected, same family as US-08.
- Acceptance criteria:
- Given the state machine table, when any transition not in the allowed-edges set is attempted, then it is rejected by a DB constraint, not application code (Norman: “affordances at the schema layer,” corroborated independently by the dual-write/race-condition literature
competitivedigest cites — arize.com/galileo.ai/getmaxim.ai 2025-2026 aggregate). - Given a valid transition, when it succeeds, then it is visible in the queue-depth view within the same read (no async lag to the human-facing surface).
- Given the state machine table, when any transition not in the allowed-edges set is attempted, then it is rejected by a DB constraint, not application code (Norman: “affordances at the schema layer,” corroborated independently by the dual-write/race-condition literature
US-03 — Report cost per attempt
- Story: As a fleet agent, I want to record the token/dollar cost of my attempt at a unit so that Robert can compute cost-per-merged-unit and detect runaway attempts.
- JTBD: Attribute spend to the specific unit that generated it, not to an undifferentiated session total.
- Preconditions: Active claim/attempt.
- Main flow: 1) agent (or its harness wrapper) reports cost incrementally or once at attempt close. 2) cost accrues on the
attemptrow, never overwrites — a unit reworked 3× carries 3 cost rows, summed for reporting. - Edge cases: Cost reported after the attempt’s claim lease already expired (US-06) — still accepted and attributed to that attempt, so abandoned work isn’t cost-invisible.
- Acceptance criteria:
- Given 3 attempts on one unit (2 abandoned, 1 verified), when cost/merged-unit is computed (US-12), then it sums all 3 attempts’ cost, not just the winning one — this is the number that makes rework expensive and visible, not free.
- Cost field never accepts a negative or non-numeric value (CHECK constraint, not app-layer validation).
US-04 — Block unit with a blocking question
- Story: As a fleet agent, I want to park a unit I can’t finish without a human/domain answer, and hand the question off, so that I don’t burn tokens guessing and the unit isn’t silently stuck.
- JTBD: Escalate cleanly and keep working elsewhere; get un-parked automatically when the answer lands.
- Preconditions: Active claim.
- Main flow: 1) agent calls
block(unit, question). 2) unit →BLOCKED, claim released back to the queue (or held — TBD,10-architecture.md), ablocking_questionsrow is created and exposed for triage (US-20) to poll — v1’s blocking-question loop stays entirely inside work-ledger’s own table; notasksrow is created in v1 (no exposed endpoint exists yet for minting anINFORMATION_REQUESTfrom outside Core —10-architecture.mdD7,05-competitive-analysis.md§4.1 B4). 3) when triage resolves the question and callsunblockwith the answer, the unit is unblocked (re-enters the queue or auto-resumes for the same agent) with the answer attached as evidence. (Post-MVP: routing the question into Tomas’stasksservice — see US-20 Post-MVP section.) - Edge cases: Same normalized question asked twice across different units → triage dedup (US-20) should resolve the second instantly, not double-page a human. Question never answered → unit sits
BLOCKEDindefinitely; this is visible in the queue-depth view (aBLOCKEDcount that only grows is itself a signal, not a silent failure). - Acceptance criteria:
- Given a block call, when it succeeds, then
state=BLOCKED,questionis non-null, and ablocking_questionsrow exists linking the unit to its question (v1 —10-architecture.mdD7; notasksrow exists yet). - Given an answered question, when triage writes the resolution back via
unblock, then the unit’s state changes exactly once (idempotent — a duplicate resolution signal is a no-op, not a double-transition).
- Given a block call, when it succeeds, then
US-05 — Record terminal disposition with evidence
- Story: As a fleet agent, I want to record why a unit ended the way it did —
MIGRATED,DROPPED,DEFERRED, orDEAD— with a link to the evidence, so that the reason survives longer than my session and longer than Temporal’s retention window. - JTBD: Make the disposition and its evidence durable and queryable, because Temporal purges closed-workflow data at 72h (
temporalCasedigest, B1: “every requirement containing ‘surviving retention’ is ~0% on Temporal-native” — this is the single fact that forces an external durable store) and kill gates are M4/M8/M12, i.e. months later. - Preconditions: Unit has reached a terminal decision — either
VERIFIED(parity confirmed, disposition =MIGRATED) or an audit finding that migration isn’t warranted (disposition =DROPPED/DEFERRED/DEADwithout ever reachingVERIFIED). - Main flow: 1) agent (or the harness, on the strength of a parity result — US-19) sets
disposition+evidence_urlon the unit. 2) row becomes immutable for that field going forward (append a new attempt to change a disposition, never edit in place — same discipline as the decision log’s supersede-never-edit rule,research/12§1 item 4). - Edge cases: Component-level vs. unit-level disposition are not the same thing — this research pass (
temporalCasedigest steelman) draws a line the earlier plan-001 framing blurred: ~26 legacy components’ go/no-go decisions are architecture-tier and belong in the append-only ADR/decision log; work-ledger’sdispositionfield is per-work-unit (~1000 rows) and feeds a component rollup, it doesn’t replace the ADR. A unit’s disposition alone should never be read as “this whole repo is decided” without checking whether all sibling units under that component agree. - Acceptance criteria:
- Given a terminal disposition set, when queried later (even past Temporal’s 72h retention), then the row is still present with its evidence link — this is the entire reason the table exists rather than a Temporal search attribute.
- Given a
DROPPED/DEADdisposition with no priorVERIFIEDstate, then that path is a first-class valid transition (dead code doesn’t need to pass through “verified” to be correctly retired).
Stage 3 — Fleet agent: error recovery
Framing evidence for this whole stage: a June 2026 preprint (priorArt digest, arXiv:2606.19616, hypothesis-grade — single unreviewed source, high relevance) found that an advisory-lease-only condition produced the highest redundant-rediscovery count of any tested concurrency condition, and that file-based last-writer-wins trackers silently drop one agent’s concurrent field write entirely. Both findings argue directly for what these four stories specify: a durable completion record, not just a lock, and DB-enforced state, not last-writer-wins.
US-06 — Crashed mid-unit: claim lease expiry
- Story: As the ledger (on behalf of the fleet), I want a claim that nobody renews to expire on its own, so that a crashed agent doesn’t leave a unit permanently locked.
- JTBD: Bound the blast radius of a crashed agent to a lease window, not to “until someone notices.”
- Preconditions: Unit claimed; claiming agent’s process dies (OOM, host reclaim, network partition — Claude Code’s own known failure mode of losing work silently under lock contention,
anthropic/claude-code#55724, cited inresearch/12§1 item 5, is exactly the class of crash this guards against) without releasing or heartbeating. - Main flow: 1) claim carries
claim_lease_expires_at. 2) agent optionally heartbeats to renew. 3) if the lease lapses with no terminal transition, the unit becomes claimable again (US-09) and the dead attempt is markedabandoned/timed_out, not silently deleted. - Edge cases: Agent comes back to life after its lease already expired and someone else claimed the unit — its late writes must be rejected (same guard as US-07), not merged.
- Acceptance criteria:
- Given a claim with no heartbeat past its lease, when the lease window elapses, then the unit’s
statereturns to claimable and the originalattemptis closed with a non-verified/non-disposition terminal marker — never left open indefinitely. - Given a crashed agent’s late write after reclaim, when it arrives, then it is rejected with an error naming the invariant (“claim no longer held”), not applied.
- Lease duration is an operator-tunable value, not hardcoded (assumption: default TBD in
10-architecture.md, no evidence-based number exists yet — deliberately not fabricated here).
- Given a claim with no heartbeat past its lease, when the lease window elapses, then the unit’s
US-07 — Double-claim attempt rejected
- Story: As a fleet agent, when I try to claim a unit someone else already holds, I want a clean, immediate rejection so that I don’t waste an attempt discovering the collision after doing the work.
- JTBD: Fail fast and cheaply on a race, not after burning tokens.
- Preconditions: Unit already
CLAIMEDwith an unexpired lease. - Main flow: 1) losing claimer’s request hits the
SKIP LOCKEDclaim query. 2) query returns no row for that unit (it’s locked by the winner). 3) API returns a structured “already claimed” response, not a generic 500 or a silent empty queue. - Edge cases: Two agents both request “next unit off the queue” (not a specific id) at the same instant — this is the common case, not the edge case, and must never return the same unit to both (this is what
SKIP LOCKEDis for, distinct from the specific-unit-id race above). - Acceptance criteria:
- Given 2 concurrent claimers requesting the same specific unit, then exactly one gets the unit and the other gets a typed conflict error, deterministically, at any concurrency up to 64 (ramp ceiling, US-14).
- Given 64 concurrent “next unit” pops against a queue of 64 distinct claimable units, then every agent gets a distinct unit — zero duplicates (this is the load-bearing test for the whole 64-concurrent claim;
competitivedigest: GitHub’s own 2026 rate-limit crisis under agent load is cited as evidence against routing this through any external SaaS API — reinforces keeping it in Postgres, which is exactly what this test proves out).
US-08 — Illegal transition rejected
- Story: As a fleet agent, when I attempt a transition that isn’t valid from the unit’s current state, I want a rejection that names the violated rule so that I (or the model generating my next call) can self-correct.
- JTBD: Get an error message that’s useful prompt input, not a generic 400.
- Preconditions: Unit in some state
S; agent requests a transition not inS’s allowed-edges set. - Main flow: 1) DB CHECK constraint / enum guard rejects the write. 2) API surfaces the specific violated invariant (e.g. “cannot transition BLOCKED → VERIFIED directly, must UNBLOCK first”).
- Edge cases: Agent retries the same illegal transition after a partial local misunderstanding of state (e.g. thinks it still holds a claim that expired, US-06) — the error should distinguish “wrong state” from “you don’t hold the claim” so recovery logic can branch correctly.
- Acceptance criteria:
- Given every edge not in the allowed-transition table, when attempted, then all are rejected — enumerated by a test that walks the full transition matrix, not spot-checked.
- Error responses are typed/structured (error code + violated invariant name), not free text a caller has to regex (Norman: “the error message is prompt engineering,”
01-initial-recommendation.md).
US-09 — Stale claim takeover by a peer agent
- Story: As a fleet agent, I want to be able to claim a unit whose lease has expired (even though another agent claimed it first and never released it), so that a crashed peer doesn’t permanently remove a unit from the queue.
- JTBD: Recover fleet-wide throughput from an individual agent’s failure without human intervention.
- Preconditions: Unit’s
claim_lease_expires_atis in the past; original claimant never transitioned it to a terminal state. - Main flow: 1) new agent’s claim query includes expired-lease units as claimable (same
SKIP LOCKEDquery, widerWHERE). 2) takeover creates a newattemptrow; the original attempt stays on record as abandoned, not deleted (US-23). 3) new agent starts fresh (or, post-MVP, sees the prior attempt’s partial evidence — v1 keeps it simple: fresh attempt, prior attempt visible for audit only). - Edge cases: Original agent’s process was actually alive but network-partitioned, and it comes back and tries to write after losing the race to a takeover — must be rejected (same guard as US-06’s late-write case), otherwise two agents produce conflicting terminal states for one unit and “only the winner survives in git history” (
priorArtdigest’s exact failure mode for race-to-close). - Acceptance criteria:
- Given an expired lease, when a second agent claims the same unit, then the takeover succeeds exactly once even under concurrent takeover attempts from multiple peers (same atomicity guarantee as US-01, applied to the expired-lease branch of the claim query).
- Given a takeover followed by the original (late) agent’s write, then the late write is rejected and the rejection is itself a durable record (not silently dropped) — this is the specific gap
priorArtdigest flags in git-native file trackers (“keeps one agent’s write and silently discards the other’s entirely”) that a DB-enforced claim closes.
Stage 4 — Robert as operator
US-10 — Queue depth view
- Story: As Robert, I want a live count of units by state (unclaimed / claimed / blocked / verified / terminal-dispositioned) so that I can see whether the fleet is starved, saturated, or stuck.
- JTBD: Answer “is there enough work queued, and is it moving” without asking anyone.
- Preconditions: Units exist in the ledger across multiple states.
- Main flow: SQL view aggregates
COUNT(*) GROUP BY state, surfaced via Grafana panel (01-initial-recommendation.md: “two SQL views… one Grafana panel”). - Edge cases:
BLOCKEDcount climbing with flatVERIFIEDcount over time is the specific pathology this view exists to catch (a triage bottleneck, not a claim bottleneck). - Acceptance criteria:
- Given any mix of unit states, when the view is queried, then counts match a direct
SELECT COUNT(*) FROM units GROUP BY state— no caching lag beyond normal Grafana refresh interval. - View is one of the two MVP views named in
01-initial-recommendation.md; not a bespoke dashboard.
- Given any mix of unit states, when the view is queried, then counts match a direct
US-11 — Stall detection
- Story: As Robert, I want to be alerted when a claimed unit has had no state change or heartbeat for an abnormal window so that I catch stuck agents before they silently occupy a slot until lease expiry.
- JTBD: Shrink the detection latency between “an agent is stuck” and “Robert knows.”
- Preconditions: Claim lease mechanism (US-06) exists; fleet monitor consumes the queue-depth view (US-21) or a dedicated stall query.
- Main flow: query surfaces claimed units whose
updated_at/heartbeat is older than N minutes but lease hasn’t yet expired (the pre-crash warning window, distinct from post-expiry US-06/US-09). - Edge cases: A unit that’s legitimately slow (large legacy component, long characterization run) vs. a genuinely stuck agent — v1 doesn’t try to distinguish these automatically; it surfaces both and lets Robert judge (no false-negative tolerance, some false-positive tolerance is acceptable for v1).
- Acceptance criteria:
- Given a claimed unit with no activity for longer than the stall threshold, when the fleet monitor’s stall panel queries work-ledger, then that unit appears — this is the mental-model tie-in from
research/12§2: Anthropic’s own Agent View sorts “needs input”/stuck sessions to the top; work-ledger is that panel’s data source for units, not sessions.
- Given a claimed unit with no activity for longer than the stall threshold, when the fleet monitor’s stall panel queries work-ledger, then that unit appears — this is the mental-model tie-in from
US-12 — Cost per merged unit
- Story: As Robert, I want to know the total cost (summed across all attempts, including rework) to get one unit to a terminal disposition, so that I can see whether the fleet is getting more or less expensive per unit of real progress as it scales.
- JTBD: Turn raw token spend into a unit-economics number that’s comparable across ramp steps.
- Preconditions: Units with ≥1 terminal disposition and ≥1 cost-bearing attempt (US-03).
- Main flow: SQL view sums
costacross all attempts per unit, grouped by disposition and (optionally) component. - Edge cases: A unit disposed
DROPPEDafter one cheap audit-only attempt has a very different cost profile than oneMIGRATEDafter 3 reworked attempts — the view should not conflate them silently (group by disposition, don’t just report one blended average). - Acceptance criteria:
- Given 3 attempts on one unit with costs $2/$5/$8 and a terminal
MIGRATEDdisposition, when the view is queried, then it reports $15 for that unit, not $8 (the winning attempt only). - This is the second of the two MVP SQL views named in
01-initial-recommendation.md.
- Given 3 attempts on one unit with costs $2/$5/$8 and a terminal
US-13 — Rework rate
- Story: As Robert, I want to know what fraction of units required more than one attempt before reaching a terminal state, so that I can tell whether raising concurrency is causing collisions/rework rather than throughput.
- JTBD: Get the leading indicator for “the fleet is too big for its coordination” before it shows up as a queue-depth stall.
- Preconditions: Attempts table with ≥2 rows for at least one unit.
- Main flow: derived from
attempts per unitgrouped by concurrency level at time of claim (requires tagging attempts with the fleet size at claim time, or joining against the fleet monitor’s concurrency metric — cross-service dependency, flag explicitly). - Edge cases: Rework caused by a legitimate blocked→answered→resumed cycle (US-04) is not the same signal as rework caused by two agents colliding on the same unit (US-07/US-09) — the view should be able to distinguish attempt-count-due-to-block from attempt-count-due-to-collision, or it will falsely blame concurrency for triage latency.
- Acceptance criteria:
- Given a batch of units at a known concurrency level, when rework rate is computed, then it correctly separates collision-driven rework (US-09 takeovers) from block-driven re-attempts (US-04 resumptions).
- This is the metric named in
00-SYNTHESIS.md’s pre-mortem #5 detection signal (“rework rate vs. concurrency curve”) — it exists specifically to catch the risk that fleet sizing outran the evidence.
US-14 — Ramp gating 10→50→64
- Story: As Robert, I want a metric-gated go/no-go before each concurrency ramp step, so that I don’t scale the fleet past what the collision/rework data supports.
- JTBD: Make “safe to ramp” a query result, not a guess.
- Preconditions: US-13 (rework rate) and US-10 (queue depth) both populated at the current concurrency level for a representative window of units.
- Main flow: before advancing 10→50 or 50→64, Robert checks rework rate and stall count at the current step; ramps only if both stay within an acceptable band.
- Edge cases: Not enough units processed yet at the current step to have a statistically meaningful rework rate — the gate should refuse to bless a ramp on too small a sample, not silently pass it.
- Acceptance criteria:
- Given fewer than N completed units at the current concurrency (N TBD, assumption — no evidence-based minimum sample size exists yet, deliberately not fabricated), then the ramp-gate query flags “insufficient data,” not a false green light.
- Given rework rate rising materially between two ramp steps, then that is visible in the same view used for the go/no-go call — this operationalizes
00-SYNTHESIS.md’s risk-register mitigation (“Ramp 10→50→64 gated on ledger metrics”) as a literal query, not a policy statement. - Exact numeric thresholds are explicitly out of this doc’s scope — they’re an operating parameter, not a user story;
14-validation-plan.mdis where they get tested.
Stage 5 — Robert as integrator
US-15 — Wire claim into the fleet loop
- Story: As Robert (building the fleet’s dispatch loop), I want a documented, typed claim contract so that spinning up a new Claude Code session includes “ask work-ledger for a unit” as a first-class step in the dispatch script, not a bolt-on.
- JTBD: Make claiming a unit as cheap to wire into the fleet loop as starting a session at all.
- Preconditions: US-01/US-16 exist and are documented (Encore auto-generates a typed client —
repoRecon§1 confirms this is the house pattern for internal→internal calls, and for external callers the same endpoint is reachable over the singleAuthorization: Bearerheader,repoRecon§6). - Main flow: dispatch script calls claim() before starting a session; session naming ties 1:1 to the claimed unit id (
research/12§2: “force human-legible unit names 1:1 with ledger rows” — the fleet monitor’s session list and work-ledger’s unit list must be the same identifier, not two naming schemes that need manual reconciliation). - Edge cases: Dispatch loop starts a session but the claim call fails (queue empty, auth expired) — loop must handle “no work available” as a normal outcome (back off / idle), not a crash.
- Acceptance criteria:
- Given the fleet dispatch script, when a new session starts, then its first action is a claim call and its session name is derived from the claimed unit id.
- Given an empty queue, when dispatch runs, then it degrades gracefully (idle/backoff), not an error loop.
US-16 — External-claim auth
- Story: As Robert (integrator), I want non-Temporal Claude CLI agents to authenticate to work-ledger the same way any other internal-plane caller does, so that v1 doesn’t need a bespoke auth mechanism.
- JTBD: Reuse the existing, current auth path rather than inventing one — this is the v1 reality named in the task brief, not a future nice-to-have.
- Preconditions: Agent has a
grpn_<prefix>_<random>API token minted via Core’stokenCreate(or the internal_tokenCreateForUserMCP-OAuth path). - Main flow: 1) agent sends
Authorization: Bearer grpn_<prefix>_<random>— no separate header (repoRecon§6: same-day commiteada68c, 2026-07-18, “One credential header” — collapsed both planes onto a single header; theg-api-keyheader and dev-bypass header were both removed that day). 2) work-ledger’s endpoint validates via theapi-tokensservice, identical to any other internal-plane endpoint. 3) claim/transition/block calls proceed under that identity. - Edge cases: A skill doc (
repoRecon§6 flags.claude/skills/encore-service-registration/SKILL.mdline 40) still describes the oldg-api-keymechanism — stale, do not follow it; work-ledger’s own docs must cite the code (auth-resolver.ts), not that skill doc, or a future integrator will wire the wrong header. - Acceptance criteria:
- Given a valid
grpn_token, when an external (non-Encore) agent calls claim/transition/block, then it authenticates identically to an in-cluster service-to-service call — same header, same validation path. - Given an invalid or expired token, then the call is rejected with a standard auth error, not a partial/ambiguous state change.
- No new auth mechanism is built for work-ledger — this is a zero-build story by design; the only work is documentation + wiring, not new infrastructure.
- Given a valid
Stage 6 — Tomas
US-17 — Kill-gate disposition table with evidence links
- Story: As Tomas, at each kill gate (M4/M8/M12), I want to query (or have Robert hand me) the disposition table for the units/components in scope, each with a link to its evidence, so that I can sign off without a meeting and without trusting anyone’s memory.
- JTBD: Get the program’s evidence of record for “what happened to this capability” without re-deriving it from Slack history or asking around.
- Preconditions: Units under the gated scope have reached a terminal disposition (US-05); component-level rollups exist where Tomas needs the coarser view.
- Main flow: 1) Tomas (or Robert on his behalf) runs a SQL query / Grafana panel filtered to the gate’s scope. 2) each row shows unit id, disposition, evidence link, component. 3) for component-level questions (“is this whole repo done”), Tomas cross-checks against the ADR/decision log, not just the unit table (US-05 edge case — the two tiers are deliberately not merged).
- Edge cases: A component with some units
MIGRATEDand others stillBLOCKED/UNCLAIMEDat gate time — the table must make partial completion visible, not hide it behind a single rolled-up status. - Acceptance criteria:
- Given a gate’s scope, when queried, then every unit shows a non-null disposition or an explicit “not yet terminal” state — no unit is silently absent from the answer.
- Given a
MIGRATEDdisposition, then its evidence link resolves to something real (parity result, PR, or audit doc) — not a dangling reference. - This table must still answer correctly after the corresponding Temporal workflow (if any) has been purged at 72h retention (
temporalCasedigest, B1) — the entire reason this data lives in Postgres, not in Temporal search attributes, is that a kill gate at M4 is months past any unit’s execution window.
US-18 — Zero-effort trust
- Story: As Tomas, I want to trust the disposition table without needing a meeting, a dashboard walkthrough, or Robert’s explanation, so that reading it costs me minutes, not an afternoon.
- JTBD: Verify the program’s claims cheaply enough that I actually do it, rather than rubber-stamping.
- Preconditions: US-17’s table exists; US-22/US-23/US-24 (trust & safety invariants) hold.
- Main flow: Tomas runs one query (or opens one Grafana panel), reads disposition + evidence link, done.
- Edge cases: If Tomas ever finds a disposition that contradicts what he independently knows (a unit says
MIGRATEDbut he knows the legacy path is still live), that single incident is the trust-collapse failure mode00-SYNTHESIS.mdpre-mortem #3 names — “the first disagreement during an incident kills trust in both.” - Acceptance criteria:
- Given the disposition table, when Tomas reads it cold (no prior context), then he can answer “is capability X migrated, and what’s the proof” without asking Robert.
- No UI, login flow, or training is required — SQL/Grafana access is the entire onboarding cost (Rams: “no interface at all where SQL suffices,”
01-initial-recommendation.md).
Stage 7 — Harness
US-19 — Harness writes parity-result rows
- Story: As the dual-run/parity harness, I want to write a parity-result (pass/fail + diff evidence) directly against a unit’s ledger row, so that verification outcomes land in the same system of record as everything else, without the fleet agent having to relay them by hand.
- JTBD: Close the loop from “diffed old vs. new behavior” to “the ledger reflects it” without a human or agent as a manual relay step.
- Preconditions: Unit claimed and in-progress; harness has run its oracle diff against recorded/shadow traffic (
00-SYNTHESIS.mdcritical-flow sequence diagram — harness talks to work-ledger directly, not through the agent). - Main flow: 1) harness runs its diff (3-oracle pattern generalization). 2) if parity ≥ threshold: harness calls work-ledger to transition the unit to
VERIFIEDwith the diff evidence link. 3) if parity < threshold: harness calls work-ledger toBLOCKthe unit with the diff as evidence, routing toward triage exactly like an agent-raised question. - Edge cases: Harness runs outside Encore entirely (architect addendum #4: “workspace package + CLI/CI runner outside Encore — Cloud Run can’t run pinned EOL images or tap MBUS”) — it authenticates the same way an external fleet agent does (US-16), it is not a privileged internal caller by virtue of being Robert’s own tooling.
- Acceptance criteria:
- Given a passing parity result, when the harness calls work-ledger, then the unit reaches
VERIFIEDwith the diff evidence link attached — same transition path US-02 defines, same invariants enforced. - Given a failing parity result, then the unit reaches
BLOCKEDwith the diff as the “question” evidence, and it enters the same triage path as an agent-raised question (US-04/US-20) — no separate harness-only escalation channel. - Legacy fixture data used in the diff carries PII/PCI risk (
00-SYNTHESIS.mdarchitect addendum: “recorded legacy traffic = PII/PCI → security-architect sign-off on fixture retention/masking”) — the evidence link the harness writes must point to a masked/sanctioned artifact, never raw legacy payloads (ties to US-24).
- Given a passing parity result, when the harness calls work-ledger, then the unit reaches
Stage 8 — Triage service
US-20 — Triage consumes blocking questions
- Story: As the triage service, I want to read units in
BLOCKEDstate with their questions, dedup against previously-answered questions, and either auto-resolve or forward to a human queue, so that Robert and Tomas aren’t paged for the same question twice. - JTBD: Reduce questions-that-need-a-human without inventing new infrastructure (triage v1 = zero PRs against Tomas’s
tasksservice, per architect addendum #3). - Preconditions: Units exist with
state=BLOCKEDand a non-null question (US-04). - Main flow (v1): 1) triage polls/queries work-ledger’s
blocking_questionstable (status='open', US-04). 2) normalizes and fingerprints each question (exact-match first —00-SYNTHESIS.mdpre-mortem #4: “naive-first ordering is in v1 by decision, not preference”). 3) if fingerprint matches a prior answered question, auto-resolves the unit immediately: callsunblockwith the prior answer as evidence. 4) otherwise, the open question surfaces to a human through the sameblocking_questionspolling surface (Grafana/SQL) — v1 mints notasksrow (10-architecture.mdD7; no exposed endpoint exists yet for creating anINFORMATION_REQUESTfrom outside Core,05-competitive-analysis.md§4.1 B4). 5) once answered, triage writes the resolution back to work-ledger viaunblock, unblocking the unit (US-04). - Edge cases: Same question fingerprint arriving from 5 different units simultaneously — one human answer should resolve all 5, not require 5 separate answers (this is the entire point of dedup, not an edge case to defer).
- Acceptance criteria (v1):
- Given the same normalized question fingerprint asked twice, when the second instance arrives, then it auto-resolves from the first’s answered
blocking_questionsrow viaunblock, without ever routing to a human a second time (contract test named explicitly in00-SYNTHESIS.md“First implementation steps” #4). - Given an answered
blocking_questionsrow (written viaunblock), when the resolution lands, then every work-ledger unit blocked on that fingerprint transitions out ofBLOCKEDexactly once each (idempotent per unit). - Triage never writes to work-ledger’s Temporal-owned fields (US-22) — it only ever writes
BLOCKED↔resumed transitions and evidence, the same surface an agent has.
- Given the same normalized question fingerprint asked twice, when the second instance arrives, then it auto-resolves from the first’s answered
Post-MVP — tasks-service handoff: v1 stops at “record the durable question, expose it for polling” (10-architecture.md §7 note); minting an actual tasks INFORMATION_REQUEST and the cross-plane answer bridge back are deferred (D7: “tasks-svc minting is triage’s v2 job”; decision #36: “no cross-plane signal bridge exists”). Deferred main-flow steps: for a non-duplicate open question, creates/reuses a tasks INFORMATION_REQUEST with assigneeGroupSlug="agent-questions" (assigneeGroupSlug is confirmed to be a free-form varchar — zero schema change needed, repoRecon §2; this is the only part of the tasks-integration confirmed today, not a “zero-PR path” claim for the integration as a whole, which needs the new endpoint noted in B4); when a human answers via the tokenized public link or Admin UI, the tasks completion signal flows back and triage writes the resolution to work-ledger, unblocking the unit (US-04). Deferred acceptance criteria:
- Given the same normalized question fingerprint asked twice, when the second instance arrives, then it auto-resolves from the first’s answer without creating a second
tasksrow. - Given an answered
tasksrow, when its completion signal fires, then every work-ledger unit blocked on that fingerprint transitions out ofBLOCKEDexactly once each (idempotent per unit).
Stage 9 — Fleet monitor
US-21 — Fleet monitor reads queue-depth/cost views
- Story: As the fleet monitor, I want read-only access to work-ledger’s queue-depth and cost SQL views so that I can fold unit-level metrics (queue depth, stall count, cost/merged-unit, rework rate) into the same Grafana surface as session-level OTel metrics, without work-ledger having to know the monitor exists.
- JTBD: Consume, don’t duplicate — the monitor’s thin custom layer (
research/12§2: “hook-event ingest computing what nothing off-the-shelf tracks: task success rate, review-rejection rate, rework rate, retry-depth, cost/merged-unit, queue depth”) gets its unit-level numbers from work-ledger’s views rather than re-deriving them from raw session logs. - Preconditions: US-10/US-12/US-13 views exist and are stable (view contract, not internal table schema — the monitor should never query work-ledger’s base tables directly).
- Main flow: monitor’s Grafana data source points at work-ledger’s Postgres read replica (or the views directly, given both are Postgres-first per
01-deep-dive.md[E10]); panels join unit-level views against session-level OTel metrics on the shared unit-id-as-session-name convention (US-15). - Edge cases: View schema changes in work-ledger silently breaking the monitor’s panels — views are the stable contract precisely so base-table refactors don’t ripple; this is a design constraint on work-ledger, not a monitor-side story, but it’s the acceptance criterion that keeps this relationship one-way.
- Acceptance criteria:
- Given the two named SQL views (US-10, US-12) plus rework rate (US-13), when the fleet monitor’s Grafana instance queries them, then it gets consistent, current data without any custom work-ledger-specific ingestion code — SQL is the entire integration surface.
- Fleet monitor never writes to work-ledger — read-only, one direction, matching the same one-way-projection discipline work-ledger itself follows relative to Temporal (US-22).
Stage 10 — Trust & safety
US-22 — Projection-only invariant
- Story: As the system (enforced by schema + code review, not by any one caller), I want it to be structurally impossible for work-ledger’s own write path to overwrite a field that a future Temporal projection owns, so that when Temporal-modelled units eventually exist (post-MVP), the two sources of truth can never quietly disagree.
- JTBD: Prevent the single failure mode named as pre-mortem #3 in
00-SYNTHESIS.md: “ledger and Temporal disagreed during an incident; trust in both collapsed” — and prevent it before it’s possible, not after the first incident. - Preconditions: None yet in v1 — no Temporal-modelled unit exists (
repoRecon§3: “only ONE example internal Temporal worker exists… a toy hello-internal workflow”). This story is preparatory, not reactive. - Main flow: the schema physically separates “Temporal-owned execution fields” (state, timestamps — future) from “ledger-owned business fields” (disposition, cost, evidence, blocking-question ref — always ledger’s, per architect addendum #2). A code-review rule blocks any PR that adds a write path from work-ledger’s own endpoints into a Temporal-owned column once those columns exist.
- Edge cases: v1’s external-claim units have no Temporal counterpart at all, so v1’s
statefield IS ledger-owned (there’s nothing to conflict with yet) — the invariant is about the future seam, not a v1 restriction on itself. Do not over-apply this to v1 by making state read-only when nothing else can write it. - Acceptance criteria:
- Given the future architecture doc’s field ownership table (
10-architecture.md), when reviewed, then every field has exactly one designated writer (work-ledger’s own endpoints, or a future Temporal projection — never both). - Given a weekly reconciliation query (US-25), when it runs, then it is the mechanism that would detect drift if this invariant were ever violated — belt and suspenders, not just a code-review rule.
- Given the future architecture doc’s field ownership table (
US-23 — Append-only history
- Story: As anyone auditing the ledger later (Tomas at a gate, Robert debugging a rework spike, a future incident review), I want every claim/attempt/transition to remain on record even after a unit reaches a terminal state, so that the full history — including abandoned and reworked attempts — is reconstructable.
- JTBD: Make “what actually happened to this unit” answerable months later, not just “what’s true right now.”
- Preconditions: Attempts table (US-01/US-06/US-09) is the append target; nothing about a closed attempt is ever deleted or overwritten, only superseded by a new attempt row.
- Main flow: every claim creates a new
attemptrow; every transition stamps the current attempt; abandoned/expired/superseded attempts are marked, not removed (mirrors the decision log’s supersede-never-edit discipline,research/12§1 item 4, and the auditlog service’s existing append-only + keyset-pagination pattern this table should copy —repoRecon§4). - Edge cases: A unit taken over 3 times before finally verifying (US-09 chain) has 4 attempt rows on record — this is a feature (rework-rate signal, US-13), not clutter to be pruned.
- Acceptance criteria:
- Given any unit’s full history, when queried, then every attempt (including abandoned/superseded ones) is present with its outcome and cost — nothing is deleted on takeover or re-attempt.
- Table follows the
auditlogprecedent exactly: explicit columns (not thedefaultTableColumnstrio),mode:"string"timestamps where append-only demands it, dedicated(created_at desc, id desc)index, hand-rolled keyset pagination — notgetPaginated(repoRecon§4).
US-24 — PII-free evidence links
- Story: As anyone with read access to the ledger (including, eventually, broader internal audiences), I want evidence fields to be pointers (URLs) to sanctioned artifacts, never raw payloads, so that legacy production data (which may carry PII/PCI) never lands inside a Postgres table with broad internal read access.
- JTBD: Keep the ledger safe to read widely without a data-classification review on every row.
- Preconditions:
evidence_url(andquestionfree-text) are the only free-text surfaces in the schema (01-initial-recommendation.md: “free text is confined toquestionandevidence_url”). - Main flow: harness/agent writes a URL pointing at the harness’s own evidence store (fixture archive, PR, parity-result artifact) — never the raw legacy request/response body inline in the ledger row.
- Edge cases: A well-meaning agent pastes a raw diff snippet (containing a legacy customer record) directly into
questionorevidence_urlinstead of linking out — this is exactly the failure mode00-SYNTHESIS.md’s architect addendum flags for the harness’s fixture data (“recorded legacy traffic = PII/PCI → security-architect sign-off on fixture retention/masking”) and it applies equally to any free-text field an agent can write. - Acceptance criteria:
- Given the schema, when reviewed, then
evidence_urlandquestionare the only unstructured text fields, and both are documented as pointer-only, never payload — every other field is enum/numeric/FK. - Given a security-architect sign-off requirement on legacy fixture retention/masking (already flagged upstream in
00-SYNTHESIS.md), then work-ledger’s evidence links inherit that masking by construction — it points at an already-sanctioned artifact, it never duplicates the raw data itself.
- Given the schema, when reviewed, then
Stage 11 — Retention-equivalent
US-25 — Weekly reconciliation audit passing = trust retention
- Story: As the system (a scheduled query, not a person), I want to run a weekly reconciliation comparing ledger state against Temporal/harness ground truth for units where both exist, so that drift is caught within a week, not discovered during an incident.
- JTBD: Give work-ledger an equivalent of a SaaS product’s “retention metric” — since there are no repeat human visits to retain, the signal that the product is still trusted is that this audit keeps passing, week over week, with nobody having to think about it.
- Preconditions: Applies fully once Temporal-modelled units exist (post-MVP); in v1, the audit’s scope is narrower — reconciling ledger attempt/claim history against the harness’s own parity-result log, and checking for orphaned claims (leases expired but never taken over — a stuck-queue symptom, not a drift symptom, but the same query family).
- Main flow: scheduled job compares ledger rows against the independent source of truth for the same units; any mismatch (state, disposition, cost) is surfaced immediately, not silently absorbed.
- Edge cases: Zero mismatches for many consecutive weeks is success, not evidence the check is unnecessary — this is explicitly the mitigation named for pre-mortem #3 in
00-SYNTHESIS.md(“projection-only invariant enforced by code review rule + weekly audit query”). - Acceptance criteria:
- Given a scheduled reconciliation run, when it finds zero mismatches, then that is logged as a positive signal (a metric Robert can point to), not just silence.
- Given any mismatch, when found, then it is surfaced with enough detail (unit id, field, both values) to diagnose within the same session — no separate investigation tooling needed.
- This audit is the closest work-ledger has to a retention metric: the product is “sticky” precisely to the extent nobody ever needs to stop trusting it.
Rejected stories
Each of these was considered and explicitly cut — not overlooked. Reasoning is one line each, all traceable to 01-initial-recommendation.md §“What to remove from the initial concept” and reinforced by the competitive digest’s finding that every commercial product which shipped this shape (Vibe Kanban/Bloop, Terragon) is now defunct as a business, for reasons unrelated to the underlying technical need.
| Rejected story | One-line reason |
|---|---|
| “As Robert, I want a kanban/board UI to see units visually.” | Grafana + SQL cover every read need named; a board is a worse Linear two people use (01-initial-recommendation.md §“temptation to resist”). |
| “As an agent, I want to comment on a unit to leave context for the next agent.” | Comments/chat threads belong nowhere in an accounting system — questions go to triage, decisions go to the ADR log, not a comment stream. |
| “As Robert, I want to set priority/sprint on units.” | No priority algorithm, sprint, or iteration concept — queue order + claim is enough at this scale; nothing in 2026 market evidence (competitive digest) validates this feature paying for itself. |
| “As Robert, I want push notifications when a unit blocks.” | The monitor alerts; the ledger records — notification delivery is explicitly the monitor’s job, not the ledger’s (01-initial-recommendation.md). |
| “As an operator, I want assignee routing rules (route unit X to agent Y).” | Claim-before-work is opportunistic by design; assignee routing reintroduces the exact race/ambiguity the claim mechanism exists to remove. |
| “As triage, I want work-ledger to run embedding-based question similarity.” | Embeddings are triage’s own R&D bet (plan 001 verdict #2), explicitly out of the ledger’s scope; naive exact-match dedup ships first by decision. |
| “As an org admin, I want multi-tenant roles/permissions beyond the core/internal-plane boundary.” | Two humans total use this system; role management is solving a problem that doesn’t exist yet, and none does in 2026 market evidence either. |
Feeds into: 10-architecture.md (canonical schema/endpoint contracts these stories assume), 12-prd.md (MVP scope authority — this doc’s MVP/Post-MVP tags are a proposal to it, not a substitute for it), 13-pre-mortem.md (US-22/US-23/US-25 operationalize pre-mortem #3’s mitigation as literal stories), 14-validation-plan.md (every numeric TBD flagged above — lease duration, ramp thresholds, minimum sample size — is a candidate for the cheapest-disproving-test list).