Skip to content
GRPNR.

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 via dispose(), from in_progress (DROPPED/DEADabandoned), blocked (DEFERRED/DEADabandoned), or verified (MIGRATED only → done). The tiers are coupled by construction: a unit cut before merge is dropped from in_progress — it never reaches verified in the first place; a technically-verified unit can only resolve to done/MIGRATED. Disposition rolls up per unit; component-tier architecture decisions (the ~dozens, not ~1000, per-repo calls) stay in reasons-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_unitsum(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_reworkcount(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_summarycount(*) 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:

  1. work_unit migration (defaultTableColumns trio for the main row; explicit-columns/no-updated_at/keyset-index pattern for work_unit_attempt, copying auditlog).
  2. 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.
  3. transition()/block() with enum CHECK constraints + 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).
  4. v_queue_depth + Grafana panel, exercised the house way (pnpm backend:core + MCP query_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 ready after 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).