UX / UI — work-ledger
Answers brief section 9. Commits to
01-initial-recommendation.md’s anti-UI position — API-first, Grafana-read, zero custom web app — and specifies it to implementation-ready detail. Nothing in the research digests overturns this position; if anything it hardens it (Symphony shipped zero UI and got 500% more PRs landed; GitHub’s own Agent HQ treats the audit log, not a screen, as the product surface — competitive digest §(b),(d)).
Scope note (N/A — internal): no onboarding funnel, no purchase flow, no marketing site, no visual-design system, no brand exploration. Two human readers (Robert, Tomas) and an agent fleet is not an audience that needs a design language. Where a standard UX-spec section (funnel, personas-as-marketing-segments, conversion copy) would normally go, it is skipped, not stubbed.
State model used below (supported interpretation, reconciling 01-initial-recommendation.md’s “transitions to verified/blocked” journey with the temporalCase digest’s unit-level-disposition framing — final column list is 10-architecture.md’s call, not this doc’s):
- state (agent-owned, execution lifecycle):
ready → claimed → in_progress → {blocked, verified, abandoned};claimed → ready(release / lease expiry);blocked → in_progress(unblock);verified → done(dispose(MIGRATED)only). - disposition (business field, set once terminal, immutable):
MIGRATED | DROPPED | DEFERRED | DEAD— assigned only viadispose(), fromin_progress(DROPPED/DEAD→abandoned),blocked(DEFERRED/DEAD→abandoned), orverified(MIGRATEDonly →done). The tiers are coupled by construction: a unit cut before merge is dropped fromin_progress— it never reachesverifiedin the first place; a technically-verifiedunit can only resolve todone/MIGRATED. Disposition rolls up per unit; component-tier architecture decisions (the ~dozens, not ~1000, per-repo calls) stay inreasons-and-decisions/ADRs per the temporalCase digest’s concession — the two are cross-linked, not merged.
stateDiagram-v2 [*] --> ready: register ready --> claimed: claim (SKIP LOCKED) claimed --> in_progress: start claimed --> ready: release / lease expiry in_progress --> blocked: block(question) blocked --> in_progress: unblock(answer) in_progress --> verified: transition(evidence) verified --> done: dispose(MIGRATED) in_progress --> abandoned: dispose(DROPPED/DEAD) blocked --> abandoned: dispose(DEFERRED/DEAD) claimed --> claimed: stale_reclaim (attemptNo++) done --> [*] abandoned --> [*]
Lenses, applied to this doc specifically
| Lens | Where it lands here |
|---|---|
| Rams — weniger aber besser, honest | Exactly 5 Grafana panels, no more (§4). Zero custom pages in v1. No decorative empty states — an empty queue is labeled “clear,” not rendered as a broken chart. |
| Norman — affordances, feedback, error prevention, signifiers | Every response carries full unit state (§3.2). Illegal transitions are DB-rejected, not app-conditional (§3.3). Error strings are signifiers — they name the constraint AND the door out (§3.4). |
| Ive — coherence through naming | One vocabulary end to end: table names, column names, view names, panel titles, and error strings all say unit / claim / disposition / attempt — never task, ticket, issue. Schema is the information architecture (§5). |
1. UX vision
Trust in numbers, not trust in a screen. The product succeeds if a human reading a SQL view or a Grafana panel believes the numbers without needing to go verify them against Temporal or against the agents themselves. That trust is earned by invariants the schema enforces (no ambiguous states, no silent double-claims, no orphaned disposition), not by visual polish — there is no visual layer to polish.
Time-to-first-value: first claimed unit in under 1 minute of integration effort. Defined precisely: an agent (or the human standing up a new fleet member) with nothing but a base URL and a grpn_... bearer token (repoRecon §6, same header on both planes) should be able to go from zero to holding a claimed unit in one HTTP call, without reading documentation beyond the error string of a failed first attempt. This is the API-as-UX version of “does the user reach value before they give up” — it is measurable (§7 prototype success criteria) and it is a hard requirement, not an aspiration: if a fresh integration takes longer than that, the interface has failed its one job.
2. What the “interface” actually is
flowchart LR A[Claude agent] -- claim / transition / block --> API[work-ledger API<br/>Encore, internal plane] API --> PG[(Postgres<br/>unit table + attempt log)] PG --> V[SQL views] V --> GF[Grafana panels] V --> KG[kill-gate query<br/>Robert / Tomas, ad hoc SQL] GF -.-> R[Robert - operator] KG -.-> T[Tomas - stakeholder]
No box in this diagram is a web app. The API is the agent’s UX; the views are the humans’ UX. Everything between “unit claimed” and “number on a dashboard” is a SELECT.
3. The API as interface
3.1 Endpoint surface (v1)
| Verb + path | Purpose | Idempotency |
|---|---|---|
POST /units |
Register units in batch (backlog import) | Idempotent by unique external_ref — re-register is a no-op |
POST /units/claim |
Pull-next: atomically claim the next available unit (FOR UPDATE SKIP LOCKED), optionally filtered by domain |
Idempotent per agent within the claim TTL: calling it again before releasing the current claim returns the same held unit, never grants a second — an agent cannot accidentally hold two units by retrying |
POST /units/{id}/claim |
Named claim: agent claims a specific unit id (v1 external-session path — no workflow-id to claim on) | Conditional UPDATE ... RETURNING; 409 if live-held by another agent |
POST /attempts/{id}/heartbeat |
Extend the claim lease | Naturally idempotent |
POST /units/{id}/transition |
Move a claimed unit through the state machine (e.g. to verified) with evidence; DB-enforced against the state diagram above |
Requires client-generated Idempotency-Key header; server dedupes replayed POSTs against it (standard idempotency-key pattern) |
POST /units/{id}/block |
Park a unit with a question, move it to blocked |
Same Idempotency-Key requirement |
POST /units/{id}/unblock |
Write an answer back, resume the unit into in_progress |
Same Idempotency-Key requirement |
POST /attempts/{id}/cost |
Record cost against the active attempt | Additive by source_event_id |
POST /units/{id}/dispose |
Set terminal disposition (MIGRATED/DROPPED/DEFERRED/DEAD) + evidence |
Same Idempotency-Key requirement |
GET /units |
Keyset list of units | n/a — read |
GET /units/{id} |
Full current-state read (poll after a claim, verify before retrying) | n/a — read |
Small, typed, and every write still returns full state (§3.2) — the surface above is 10-architecture.md §6’s v1 API, canon. No GET /queue browse endpoint beyond the two reads above (agents don’t pick units by hand; claim() picks for them), no comments, no priority field to set. Auth is the existing grpn_... bearer token, same header on both planes, no new mechanism (repoRecon §6).
3.2 Full-state-in-every-response (Norman: feedback)
Every write endpoint returns the complete current row, never a bare {ok:true}:
{
"unit_id": "orders-svc-42",
"state": "claimed",
"disposition": null,
"claimed_by": "fleet-a7",
"claimed_at": "2026-07-18T14:02:00Z",
"claim_expires_at": "2026-07-18T15:02:00Z",
"attempts": 1,
"cost_cents": 340,
"blocking_question_ref": null,
"evidence_url": null,
"updated_at": "2026-07-18T14:02:00Z"
}
An agent never has to make a second call to find out what just happened, and it never has to trust its own memory of state across a retry — the response is the state. This is also what makes the error-string design in §3.4 sufficient documentation: the agent’s context window accumulates real request/response pairs instead of a separately-maintained mental model.
3.3 DB-enforced transitions (Norman: error prevention)
Legality of a transition is enforced by an allow-map-guarded UPDATE ... WHERE state = $expected (enum-typed values; transition allow-map in the service — 10-architecture.md §3), not by a bare if scattered through application code. This is not a style preference — it is the direct fix for the failure mode the priorArt digest quantifies: locks-only claim queues reproduce the highest redundant-work rate because a lock stops two agents colliding but does nothing to stop one agent re-doing already-finished work after a state read goes stale. A transition that isn’t legal from the row’s current state fails the UPDATE (0 rows affected → app returns the error in §3.4), not “usually correct, occasionally racy.”
3.4 Error messages as prompt engineering
Design rule: every error names (a) the invariant that was violated, and (b) the legal next action(s), in the same sentence the agent will feed straight into its own reasoning. An LLM caller does not read a docs site; the error string is the docs site, read once, per failure, in-context. Five concrete examples:
| # | Trigger | Message |
|---|---|---|
| 1 | Claim conflict | unit 'orders-svc-42' is already claimed by agent 'fleet-a7' until 2026-07-18T15:02:00Z. You cannot claim a held unit. Call POST /units/claim with no unit_id for the next unclaimed unit, or retry this one after the expiry above. |
| 2 | Illegal transition | cannot transition unit 'orders-svc-42' from 'blocked' to 'verified': direct blocked→verified is not legal. Legal next states from 'blocked' are ['in_progress' (via POST /units/{id}/unblock, once the question is answered), 'abandoned' (via POST /units/{id}/dispose)]. Unblock the unit, resume work, then transition to verified once evidence_url is ready. |
| 3 | Transition without active claim | agent 'fleet-b3' does not hold the active claim on unit 'orders-svc-42' (held by 'fleet-a7', or none). You cannot transition or block a unit you have not claimed. Call POST /units/claim first. |
| 4 | Missing required evidence | transition to state 'verified' requires evidence_url (dual-run parity report). Received: none. Retry with evidence_url set, or call POST /units/{id}/block with a question if parity cannot yet be established. |
| 5 | Terminal / immutable | unit 'orders-svc-42' is 'abandoned' with disposition 'DROPPED', set via dispose() at 2026-07-10T09:00:00Z. Dispositions are immutable once set — this is a ledger, not a scratchpad. If this was wrong, that is a new decision: record a superseding ADR in reasons-and-decisions/ and open a new unit row; do not attempt to overwrite this one. |
Each maps to an Encore APIError code already in house use (repoRecon §1: APIError.unavailable-style rethrow pattern) — alreadyExists (#1), failedPrecondition (#2, #5), permissionDenied (#3), invalidArgument (#4).
4. Information architecture = schema legibility
There is no navigation to design because the table and column names are the navigation — \d work_unit in psql, or GET /units/{id}’s JSON keys, should teach the whole product in one screenful. Naming carries the entire IA burden:
| Artifact | Convention | Why it’s the IA |
|---|---|---|
work_unit table |
one row per work unit; columns = state, disposition, attempts, cost_cents, claimed_by, claim_expires_at, blocking_question_ref, evidence_url |
reading the column list answers “what does this product track” completely — no separate data dictionary needed |
work_unit_attempt table (append-only) |
explicit columns, no updated_at (immutable rows), (created_at desc, id desc) keyset index — copies the auditlog precedent exactly (repoRecon §4) |
per-attempt cost/duration history without mutating the unit row; the naming (attempt, not event or log) says what it is for |
Views: v_queue_depth, v_stall_list, v_cost_per_unit, v_rework, v_disposition_summary |
v_<panel_name>, 1:1 with the Grafana panels in §5 |
a human grep-ing views finds the exact list of things the product claims to answer — no orphaned dashboards querying ad hoc SQL nobody can find again |
| Vocabulary discipline | unit, claim, attempt, disposition — never task/ticket/issue |
collision-avoidance with Zaruba’s tasks service is structural, enforced by the naming, not by a comment |
5. Interaction design for agents
| Concern | Decision | Why |
|---|---|---|
| Claim polling | Plain poll with jitter (e.g. 2–5s interval), not long-poll/websocket | At 10→64 concurrent agents, polling load on Postgres is trivial; SKIP LOCKED already makes concurrent claim attempts safe and cheap. Long-poll buys nothing at this concurrency and is infra the fleet doesn’t need — add only if agent count moves to the hundreds. |
| Claim TTL (visibility timeout) | Configurable, defaulting to an estimate of typical attempt duration (hypothesis, needs calibration — flag as an open tuning knob, not a fixed constant) | Too short → premature reclaim while the agent is still working → the exact “locks-only reproduces the highest redundant-work rate” failure the priorArt digest documents. Too long → a dead agent’s claim sits as a zombie, invisible until the stall-list panel (§6) catches it. This is the single most consequential number in the whole design and it is not knowable from first principles — set an initial value, watch the stall-list panel, adjust. |
| Retries | Standard exponential backoff on 5xx/network error; Idempotency-Key header on every transition/block POST |
A retried write must not double-count cost or double-append an attempt row — this is the dual-write/duplicate-write hazard the priorArt digest’s arXiv finding calls out directly for file-based trackers; Postgres + idempotency key closes it here. |
claim() idempotency |
Same agent calling claim() again while already holding a unit returns that same unit |
Prevents an agent from silently accumulating claims through retry loops — see §3.1. |
6. The Grafana surface (v1, exactly 5 panels)
Rams: as few elements as possible, each one earning its place. No panel is added “since we’re building a dashboard anyway.”
| # | Panel | Objective | Data (view) | Success state | Empty state | Error / warning state |
|---|---|---|---|---|---|---|
| 1 | Queue depth | Is unclaimed work piling up faster than the fleet claims it? | v_queue_depth — count by state, by component |
flat or falling ready count |
ready = 0 labeled “clear queue” — genuinely good, not rendered as a broken empty chart |
ready climbing over N samples while claimed stays flat → fleet under-claiming, escalate to Robert |
| 2 | Stall list | Which claimed units have gone quiet (dead agent, stuck loop)? | v_stall_list — units state='claimed' with updated_at stale vs. median attempt duration, or past claim_expires_at |
empty — nobody stalled | empty is the success state, labeled explicitly | any row past claim_expires_at should already have been auto-released by the claim-guard; a row here past expiry is an operational bug, not a business signal — see UX risk table (§8) |
| 3 | Cost / merged unit | Is the marginal cost of finishing a unit rising or falling? | v_cost_per_unit — sum(cost_cents)/count(disposition='MIGRATED'), 7-day rolling |
flat or declining trend | no MIGRATED units yet in window → labeled “no completions yet,” not zero |
steadily rising cost/unit → fleet efficiency regressing, escalate |
| 4 | Rework rate | What fraction of closed units needed >1 attempt? | v_rework — count(attempts>1)/count(*) over closed units, windowed |
low and flat | no closed units yet → “too early to measure” | rising rework is the direct canary for the priorArt digest’s documented locks-only failure mode — if this climbs, the claim TTL (§5) is miscalibrated first, not the fleet |
| 5 | Disposition summary | Tomas’s kill-gate rollup: counts by disposition, by component | v_disposition_summary — count(*) group by component, disposition |
MIGRATED share rising toward the gate target |
nothing closed yet (early program) — labeled, not blank | n/a — a rollup query failing is an infra error, not a business state; treat as a Grafana datasource alert, not a panel state |
7. SQL views as “screens”
The view is the screen; Grafana is chrome around it. One worked example (the rest follow the identical shape — exact DDL is 10-architecture.md’s job, this is the pattern):
create view v_queue_depth as
select component, state, count(*) as n
from work_unit
group by component, state;
Anyone with psql access gets the same “screen” Grafana renders, with zero deploy step. This is the concrete meaning of “API-first, Grafana-read” from 01-initial-recommendation.md: the product’s UI layer is a CREATE VIEW statement, reused by two different renderers (Grafana panel, ad hoc SELECT *).
8. The kill-gate disposition table — the one high-stakes human “screen”
Tomas’s read at M4/M8/M12 is row-level, not aggregate: unit_id, component, disposition, evidence_url, cost_cents, disposed_at, filterable and sortable — the disposition-summary panel (§6 #5) is the rollup; this is the drill-down underneath it. In v1 it is a saved SQL query / a Grafana table panel with a link column — nothing server-rendered, nothing with its own auth model.
Trigger to build an actual page (single server-rendered, read-only, no writes, no auth beyond viewer role): the third human who needs interactive access without SQL. Today that’s 2 (Robert, Tomas) — under threshold. The moment a third stakeholder (a second domain lead, a FinOps/audit reader) needs this table and can’t run SQL, build the page — one route, one query, no framework decision to relitigate. Before that point, building it is exactly the “worse Linear two people use” trap 01-initial-recommendation.md warns against.
9. Accessibility
N/A for v1 — there is no UI to make accessible; the interface is a typed API and a query language. Note for the record, not a requirement: Grafana ships reasonable default contrast and keyboard navigation out of the box, inherited for free. If the kill-gate page in §8 ever gets built, its two accessibility non-negotiables are named now so they aren’t relitigated later: semantic HTML table markup (not div-grids), and disposition shown as a text label always, never color-alone (MIGRATED/DROPPED/DEFERRED/DEAD carries real stakes for a colorblind reader).
10. UX risks
| Risk | Why it matters | Mitigation |
|---|---|---|
| State-model complexity (2-tier state + disposition, §0) | An agent that conflates “verified” with “MIGRATED” will send a malformed request and get error #4/#5 (§3.4) in a loop instead of understanding the model | Error strings are the teaching mechanism (§3.4) by design — no separate docs to fall out of sync; if agents still conflate the two tiers after the prototype (§11), collapse them into one, don’t add a doc |
| Two-truths drift (ledger vs. Temporal execution state) | First disagreement during an incident kills trust in both surfaces at once (plan 001 pre-mortem #3) | Structural, not a UX fix: Temporal-owned fields are projection-only by construction (00-SYNTHESIS architect addendum #2); work-ledger’s state/disposition/cost columns have no Temporal equivalent to drift against in v1, because v1 has no Temporal-modelled units yet (repoRecon: only one toy internal workflow exists) |
| Free-text junk | question and evidence_url are the only unconstrained fields; an agent could dump reasoning into them, degrading Tomas’s kill-gate read into prose to parse |
Everything enumerable is an enum with a CHECK constraint (§3.3); the two free-text fields are load-bearing on purpose (a question needs to be asked in words, evidence needs a URL) and nowhere else accepts free text |
| Silent claim-expiry surprises | An agent whose claim silently expired mid-work keeps writing as if it still owns the unit; its next transition call fails with error #3, but tokens already spent on work now attributed to nobody |
The stall-list panel (§6 #2) is built specifically to surface this before it becomes invisible; claim-expiry error responses (design extension of #3) should explicitly say “your prior transitions before expiry are preserved as a separate attempt row” so the agent knows the work wasn’t silently discarded, only the ownership was |
11. Prototype — the walking skeleton
Per 01-initial-recommendation.md §“Fastest prototype” and the plan-001 TDD step 1, unchanged and confirmed here as the UX-validating artifact, not a separate exercise:
Scope: work_unit schema (Drizzle migration) + claim() as the atomic guard + v_queue_depth + one Grafana panel reading it. Nothing else.
Fidelity: real Docker Postgres for the concurrency assertion specifically — repoRecon’s correction stands: there is no house vitest+Docker-PG pattern to copy (all existing service tests mock the repository), so this is new test infrastructure, not a reuse of an existing harness. Everything else (transition/block error-message logic) can stay a mocked-repository unit test per house convention; only the claim-atomicity assertion needs a real database.
Tasks:
work_unitmigration (defaultTableColumnstrio for the main row; explicit-columns/no-updated_at/keyset-index pattern forwork_unit_attempt, copyingauditlog).claim()SQL guard (FOR UPDATE SKIP LOCKED) + Docker-PG vitest: 2 concurrent claimers against one seeded unit, assert exactly 1 succeeds and 1 receives error string #1 verbatim.transition()/block()with enumCHECKconstraints + mocked-repository unit tests asserting error strings #2–#5 verbatim (not just the error code — the exact message, since the message is the interface per §3.4).v_queue_depth+ Grafana panel, exercised the house way (pnpm backend:core+ MCPquery_database, per repoRecon §5 — no Docker-PG needed here, this is a read path).
Success criteria:
- Two concurrent
claim()calls on the same unit: exactly one returns the claimed row, the other returns error #1 verbatim. - An illegal transition attempt returns error #2 verbatim, not a generic 500.
- The Grafana panel renders nonzero
readyafter seeding, zero after claiming all seeded units — both states observed and screenshotted. - The vision metric itself, self-tested: a fresh agent given only a base URL, a token, and nothing else reaches a claimed unit in under 1 minute, guided by nothing but the first error string it hits (or none, if it gets the call right first try).