10 — Product architecture
Scope: the technical design of work-ledger — one thin Encore.ts service in the internal plane of
monorepo-development, Postgres SSOT forunit x state x attempts x cost x blocking-question x disposition. Grounds every claim in repo ground truth (file paths from the repo-recon digest, verified against HEAD92d2ea0, 2026-07-18) and the plan-001 architect addendum (00-SYNTHESIS.mdlines 141-150). Evidence IDs [E1]-[E10] are defined inplans/001-factory-apps-validation/01-deep-dive.md.Confidence tags per repo convention: confirmed (read from HEAD), supported (reasoned from confirmed facts), hypothesis (plausible, untested), assumption (depends on an external decision), unknown.
1. Principles
| # | Principle | Consequence | Source |
|---|---|---|---|
| P1 | Accounting system, not an app. The value is the data model + invariants, not screens. | No UI, no notifications, no priorities. Ever. | 01-initial-recommendation.md §1; competitive digest (Paul Gross ledger-as-primitive) |
| P2 | Temporal is the only execution truth. work-ledger owns only durable business fields. | One-way projection; work-ledger never writes back to Temporal or to Core’s workflow_executions. |
addendum #1-2; temporalCase digest |
| P3 | Affordances at the schema layer. States that cannot be misused, claims that cannot race, errors that name the violated invariant. | Enum + CHECK constraints, DB-enforced transitions, atomic claim as a DB primitive not an app convention. | 01-initial-recommendation.md (Norman lens); priorArt digest (locks-only insufficient) |
| P4 | One vocabulary: unit, claim, attempt, disposition. Never ticket/task/issue. |
task collides with Core’s tasks service; ledger alone collides with merchant-accounting. |
addendum note; house style |
| P5 | Survive retention. Every field Temporal purges at 72h (temporal-operate SKILL) must live durably here, readable at M12. |
Disposition, cost, attempts, evidence links, blocking-question refs are the irreducible core. | temporalCase digest B1 |
| P6 | Thinner than plan 001. Reuse workflow_executions projection + Temporal workflow-id uniqueness for workflow-modelled units; build only what Temporal purges + the external-claim path. |
v1 = 4 tables, ~150-200 LOC service, 4-5 views. Days, not weeks. | temporalCase digest (verdict); addendum #2 |
| P7 | Zero core-plane PRs in v1. Runs local-first on encore run, no billing-gate dependency, no negotiated ask of Tomas. |
Projection-from-Temporal and tasks-service minting are explicit v2 asks, not v1. | addendum #1,#3; 01-initial-recommendation.md §“NOT in MVP” |
2. Context & boundaries
flowchart LR
subgraph FLEET["Robert's fleet (10 to 64 concurrent Claude agents)"]
EA["external Claude sessions<br/>(v1 reality: not workflows)"]
WU["internal-plane unit workflows<br/>(v2: run on Zaruba's worker)"]
end
subgraph IP["internal plane — monorepo-development"]
WL["work-ledger svc<br/>own Postgres SSOT"]
WP["workflow-proxy<br/>(Tomas-owned)"]
end
subgraph CP["core plane — Tomas DRI"]
WFE[("workflow_executions")]
TASKS["tasks svc<br/>INFORMATION_REQUEST"]
end
subgraph SIB["Robert-owned siblings"]
TRI["triage"]; MON["fleet monitor<br/>Grafana"]; HAR["dual-run harness<br/>(CLI, outside Encore)"]
end
R2[("R2 / harness<br/>artifacts")]
EA -- "Bearer grpn_ token, external-claim API" --> WL
WU -. "v2: workflow-id claim + projected events" .-> WP
WP -. "v2 projection (negotiated)" .-> WL
WL -- "generated client (read only)" --> WFE
WL -- "evidence = LINKS only" --> R2
TRI -- "polls open questions, writes answers back" --> WL
MON -- "reads SQL views" --> WL
HAR -- "transition(verified, evidence) / block(question)" --> WL
Inside work-ledger (owns): unit registry, atomic claim, state transitions, dispositions, per-unit/per-attempt cost roll-up, evidence links, and the SQL read views. Confirmed the internal plane has no work-ledger-shaped artifact today (repo-recon §8: exhaustive grep, zero hits).
Outside (consumes / is consumed):
| Concern | Owner | work-ledger’s relationship |
|---|---|---|
| Execution truth (live workflow state) | Temporal + Core workflow_executions (apps/backend-encore-core-ts/services/workflow-management/schemas/workflowExecution.schema.ts) |
Reads via generated client only; never writes back (P2). Projects state in (v2). |
| Blocking-question routing to a human | triage sibling + Core tasks svc |
work-ledger records the durable question; triage dedups/rate-limits and mints the tasks INFORMATION_REQUEST (assigneeGroupSlug “agent-questions”). |
| Dashboards / operator surface | fleet monitor (Grafana, research/12 §2) |
Exposes SQL views; monitor scrapes them. work-ledger ships one panel-worthy view set, no dashboards of its own. |
| Artifact storage (parity reports, fixtures, diffs) | harness / R2 | Stores links only (P5-adjacent, PII/PCI §9). |
Never in work-ledger: custom UI, kanban, comments/chat, priorities, sprint/iteration, assignee-routing algorithms, notifications, embeddings, multi-tenancy. (01-initial-recommendation.md §“What to remove”; every 2026 vendor that shipped this shape is defunct — competitive digest.)
3. Component design — data model
The brief names six concepts (units, attempts, transitions_log, dispositions, blocking_questions, cost_entries). Decision (supported): collapse them onto 4 tables + 5 enums — the fewest that keep the claim hot-path fast, the audit trail append-only, and the open-question set queryable. Mapping:
| Brief concept | Where it lives |
|---|---|
| units | work_units (mutable registry + live claim + current state) |
| attempts | work_unit_attempts (one row per claim; cost + evidence per attempt) |
| transitions_log | work_unit_events (append-only, auditlog pattern; also the projection dedup home) |
| dispositions | work_units.disposition enum column + the disposed event row |
| cost_entries | work_unit_attempts.cost_cents, rolled to work_units.cost_cents (a separate table only post-MVP — §11) |
| blocking_questions | blocking_questions (small, status-indexed; triage polls it) |
Schema sketch (Drizzle, following repo conventions: mutable tables spread defaultTableColumns from apps/backend-encore-core-ts/libs/drizzle/defaults.ts; the append-only table keeps columns explicit with mode: "string" timestamps + a keyset index, exactly the auditlog precedent at apps/backend-encore-core-ts/services/auditlog/schemas/schema.ts):
// --- enums (CHECK-constrained at the DB, P3) ---
export const workStateEnum = pgEnum("work_state",
["ready", "claimed", "in_progress", "blocked", "verified", "done", "abandoned"]);
export const dispositionEnum = pgEnum("work_disposition",
["MIGRATED", "DROPPED", "DEFERRED", "DEAD"]); // absorbs the decommission ledger [verdict #8]
export const attemptOutcomeEnum = pgEnum("attempt_outcome",
["verified", "blocked", "failed", "abandoned", "superseded"]);
export const eventTypeEnum = pgEnum("work_event_type",
["registered", "claimed", "heartbeat", "transitioned", "blocked",
"unblocked", "cost_recorded", "disposed", "released", "stale_reclaimed"]);
export const questionStatusEnum = pgEnum("question_status", ["open", "answered", "cancelled"]);
// --- 1. work_units: registry + live claim + current state. One row per unit, forever. ---
export const workUnitsTable = pgTable("work_units", {
...defaultTableColumns, // id uuid pk, created_at, updated_at
externalRef: text("external_ref").notNull(), // legacy component/capability key
fleet: text("fleet").notNull(), // 'robert' | 'tomas' (free-form; not an enum yet — YAGNI)
domain: text("domain"), // 'orders', 'deal-factory', ...
state: workStateEnum("state").notNull().default("ready"),
disposition: dispositionEnum("disposition"), // null until terminal
disposedAt: timestamp("disposed_at", { withTimezone: true }), // nullable; set exactly once by dispose(); denormalizes the `disposed` event's timestamp for kill-gate queries (09-north-star NSM formula; 07-ux-ui §8)
workflowId: text("workflow_id"), // set iff the unit runs as an internal-plane workflow (v2 projection key)
// --- live claim (denormalized onto the unit for a single-table claim hot path) ---
claimedBy: text("claimed_by"), // agent id holding the lease, null if free
leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }),
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
// --- rolled-up business fields (survive Temporal's 72h retention, P5) ---
attemptCount: integer("attempt_count").notNull().default(0),
costCents: bigint("cost_cents", { mode: "number" }).notNull().default(0),
evidenceUrls: text("evidence_urls").array(), // LINKS only, never payloads (§9)
}, (t) => [
uniqueIndex("work_units_external_ref_uq").on(t.externalRef), // idempotent register + dedup claim
// Partial index: only the rows a claimant can pull. Keeps the SKIP LOCKED scan tiny at 64-concurrent.
index("work_units_ready_idx").on(t.createdAt).where(sql`state = 'ready'`),
index("work_units_stale_idx").on(t.leaseExpiresAt).where(sql`state = 'claimed'`), // stale-lease reclaim path
index("work_units_disposition_idx").on(t.disposition),
]);
// --- 2. work_unit_attempts: one row per claim. Cost + evidence per attempt; rework = attemptCount > 1. ---
export const workUnitAttemptsTable = pgTable("work_unit_attempts", {
...defaultTableColumns,
unitId: uuid("unit_id").notNull().references(() => workUnitsTable.id),
attemptNo: integer("attempt_no").notNull(), // 1-based per unit
agentId: text("agent_id").notNull(),
outcome: attemptOutcomeEnum("outcome"), // null while running
costCents: bigint("cost_cents", { mode: "number" }).notNull().default(0),
evidenceUrl: text("evidence_url"),
endedAt: timestamp("ended_at", { withTimezone: true }),
}, (t) => [
uniqueIndex("work_unit_attempts_unit_no_uq").on(t.unitId, t.attemptNo),
index("work_unit_attempts_unit_idx").on(t.unitId),
]);
// --- 3. work_unit_events: append-only transition log + exactly-once projection dedup. ---
// Does NOT spread defaultTableColumns: append-only (no updated_at), createdAt is mode:"string" so reads
// serialize straight to JSON, dedicated keyset index. Same shape as auditlog/schemas/schema.ts.
export const workUnitEventsTable = pgTable("work_unit_events", {
id: uuid("id").primaryKey().defaultRandom(),
unitId: uuid("unit_id").notNull().references(() => workUnitsTable.id),
attemptId: uuid("attempt_id").references(() => workUnitAttemptsTable.id),
eventType: eventTypeEnum("event_type").notNull(),
fromState: workStateEnum("from_state"),
toState: workStateEnum("to_state"),
agentId: text("agent_id"),
costDeltaCents: bigint("cost_delta_cents", { mode: "number" }),
detail: jsonb("detail").$type<Record<string, unknown>>(), // question, evidence_url, reason — the only free text
// Exactly-once key: caller Idempotency-Key for external writes; Temporal event id for projected events (v2).
sourceEventId: text("source_event_id"),
createdAt: timestamp("created_at", { mode: "string", withTimezone: true }).notNull().defaultNow(),
}, (t) => [
index("work_unit_events_created_at_id_idx").on(t.createdAt.desc(), t.id.desc()), // keyset (auditlog precedent)
index("work_unit_events_unit_idx").on(t.unitId, t.createdAt),
uniqueIndex("work_unit_events_source_event_uq").on(t.sourceEventId)
.where(sql`source_event_id IS NOT NULL`), // dedup; NULLs (internal events) unconstrained
]);
// --- 4. blocking_questions: durable, agent-pollable, triage-consumable. ---
export const blockingQuestionsTable = pgTable("blocking_questions", {
...defaultTableColumns,
unitId: uuid("unit_id").notNull().references(() => workUnitsTable.id),
attemptId: uuid("attempt_id").references(() => workUnitAttemptsTable.id),
question: text("question").notNull(),
status: questionStatusEnum("status").notNull().default("open"),
answer: text("answer"),
// Routing key into the human tasks queue. Free-form varchar(128) there (tasks/schemas/schema.ts) — zero schema change.
assigneeGroupSlug: text("assignee_group_slug").notNull().default("agent-questions"),
externalTaskId: text("external_task_id"), // set iff minted as a tasks INFORMATION_REQUEST (v2)
}, (t) => [
index("blocking_questions_status_idx").on(t.status), // triage polls the open set
index("blocking_questions_unit_idx").on(t.unitId),
]);
Layering (confirmed convention, .claude/rules/encore-ts.md): controller (validate + authorize) → service (claim algorithm, transition guard) → repository extending BaseRepository<typeof table, Row> (apps/backend-encore-core-ts/libs/drizzle/repository.ts — supplies create/findOne/getPaginated/transaction). The append-only work_unit_events list is a hand-rolled keyset query, not getPaginated, exactly as auditEntryRepository.list() does (offset would force a COUNT(*) over an unbounded table).
State machine (DB-enforced, P3):
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 --> [*]
Illegal transitions are rejected in the service by a static allow-map keyed on (fromState, toState); the error names the violated invariant (Norman lens: the error message is prompt engineering for the LLM caller). Terminal disposition is set only on done/abandoned.
4. Claim algorithm
The core primitive. Requirement: at 10→64 concurrent agents, two agents must never hold the same unit; a dead agent’s unit must become reclaimable; every claim/attempt is recorded (priorArt digest: locks-only reproduces the highest redundant-work rate — you need the durable attempt record too, not just mutual exclusion).
Two claim paths:
-
Pull-next (
POST /units/claim) — agent asks for any ready unit. One statement, in a transaction:WITH picked AS ( SELECT id FROM work_units WHERE state = 'ready' AND ($1::text IS NULL OR domain = $1) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 ) UPDATE work_units u SET state='claimed', claimed_by=$2, lease_expires_at=now()+$3::interval, last_heartbeat_at=now(), attempt_count=attempt_count+1, updated_at=now() FROM picked WHERE u.id = picked.id RETURNING u.*;Then insert the
work_unit_attemptsrow (attempt_no = attemptCount) and append aclaimedevent, same tx. Drizzle expresses this via.for("update", { skipLocked: true })or a rawsqlfragment through the repository’stransaction(). -
Named claim (
POST /units/:id/claim) — external agent names its unit (the v1 external-session reality: units are not workflows, so there is no workflow-id to claim on — addendum #2, temporalCase B3). A conditional update is already atomic (the row lock is held for theUPDATE); no separate lock needed:UPDATE work_units SET state='claimed', claimed_by=$2, lease_expires_at=now()+$3, ... WHERE id=$1 AND (state='ready' OR (state='claimed' AND lease_expires_at < now())) RETURNING *; -- zero rows returned => already held by a live claimant => 409
Lease + heartbeat + stale takeover. A claim carries a lease (lease_expires_at, default e.g. 15 min — calibration knob, tune from measured attempt duration; do not hardcode as if the fleet ran at a fixed rate). POST /attempts/:id/heartbeat extends it. The pull-next query is widened to also match state='claimed' AND lease_expires_at < now() via the work_units_stale_idx partial index; reclaiming a stale unit bumps attempt_no and appends a stale_reclaimed event, so the dead agent’s work leaves a trace (priorArt: race-to-close is otherwise invisible in git history).
sequenceDiagram participant A1 as agent A participant A2 as agent B participant DB as work_units (PG) Note over A1,A2: two agents pull-next at once A1->>DB: SELECT ... state='ready' FOR UPDATE SKIP LOCKED LIMIT 1 A2->>DB: SELECT ... state='ready' FOR UPDATE SKIP LOCKED LIMIT 1 DB-->>A1: row U (locked for A1) DB-->>A2: row V (U skipped — held by A1) A1->>DB: UPDATE U -> claimed, lease=now()+ttl, attempt_count++ ; INSERT attempt ; INSERT event(claimed) A2->>DB: UPDATE V -> claimed, ... (same tx) Note over A1: works in worktree, heartbeats to extend lease A1-->>DB: (agent A dies — no heartbeat) A2->>DB: pull-next later matches U via stale_idx (lease expired) DB-->>A2: row U reclaimed, attempt_no=2, INSERT event(stale_reclaimed)
Why SKIP LOCKED, not advisory locks or pgmq (decision D1, §10):
| Option | Verdict | Reason |
|---|---|---|
FOR UPDATE SKIP LOCKED |
chosen | Battle-tested multi-consumer claim (Oban/graphile-worker/pg-boss). Each claimant grabs a distinct ready row without blocking. The lock only guards row selection; state flips to claimed in the same statement. SKIP LOCKED covers pull-next; a conditional UPDATE ... RETURNING covers named claim. One clause, zero dependencies. (oss digest: “cheaper to write than to integrate a dependency for.”) |
pg_advisory_xact_lock |
rejected | Keyed on a specific id — solves “claim THIS unit,” which the conditional UPDATE already does atomically. Adds nothing for pull-next and leaks across pooled connections if not xact-scoped. |
pgmq / graphile-worker / river |
rejected | Model messages, not durable unit x state x disposition x cost rows you query with SQL. You would still build work_units alongside. Redundant with a single SKIP LOCKED clause (oss digest). |
Scale note (supported): the known SKIP LOCKED pressure point (vacuum / MultiXact SLRU under thousands of workers hammering one table) is irrelevant at 10→64 (priorArt digest). 1000 is queued units, never concurrent editors ([E9]).
5. Projection design (v2 — spec’d now as the contract)
Confirmed constraint: projection is not v1. The interceptor hook is a single hardcoded file in Tomas-owned workflow-proxy (apps/backend-encore-internal-ts/services/workflow-proxy/worker-core/services/temporal-worker.ts loads execution.interceptor.ts by absolute path, not a pluggable array — repo-recon §3), and only one toy example workflow runs on that substrate today. There is nothing to project from until fleet units actually become internal-plane Temporal workflows. Wiring work-ledger to it means editing shared substrate code — a negotiated workflow-proxy ask, not the “zero-cost passive read” the addendum framing implied (repo-recon correction). So: v1 external-claim path is standalone; projection lands in v2 when workflow-modelled units exist.
When it lands, the design is:
sequenceDiagram
participant T as Temporal worker
participant IC as execution.interceptor (workflow-proxy)
participant WL as work-ledger.project()
participant DB as work_unit_events
Note over IC: v2 fan-out added alongside the existing<br/>_ensureInProgress / _progressUpdate / _statusUpdate forwards
T->>IC: workflow execute (unit workflow)
IC->>WL: project({ workflowId, event, temporalEventId })
WL->>DB: INSERT ... ON CONFLICT (source_event_id) DO NOTHING
alt first time (row inserted)
WL->>DB: apply transition to work_units
else replay (conflict, 0 rows)
WL-->>IC: ack, no-op (exactly-once)
end
- Exactly-once:
work_unit_events.source_event_id= the Temporal event id; the partial unique index makes a replayed event a no-opON CONFLICT DO NOTHING. (priorArt digest: this matches Netflix Conductor’s worker-listener + Vitess topology-aggregation patterns — industry-validated, not novel.) - One-way invariant (P2): work-ledger reads Core
workflow_executionsvia the generated client (never direct DB — the core/internal boundary, rootCLAUDE.md), and never writes execution state back. Enforced by a code-review rule + the reconciliation query below. - Weekly reconciliation (
*.cron.ts): for every unit with a non-nullworkflow_id, comparework_units.stateagainst Coreworkflow_executions.status(via generated client). Any mismatch is an alert, not an auto-fix (priorArt: recover by idempotent read-before-write, never an out-of-band manual edit). This is the detection signal for pre-mortem #3 (00-SYNTHESIS.md).
6. API surface
Typed Encore endpoints (Request/Response interfaces defined in the controller file per .claude/rules/encore-ts.md). Every write carries an Idempotency-Key header → source_event_id; a replay returns the prior result via the events unique constraint.
| Endpoint | Auth | Idempotency | Purpose |
|---|---|---|---|
POST /units (register-units, batch) |
service or operator | unique external_ref |
Absorb the backlog. Re-register is a no-op. |
POST /units/claim |
fleet agent | Idempotency-Key; re-claim by same agent returns its active attempt |
Pull-next (SKIP LOCKED). |
POST /units/:id/claim |
fleet agent | conditional UPDATE (409 if live-held) | Named claim (v1 external sessions). |
POST /attempts/:id/heartbeat |
claim holder | naturally idempotent | Extend lease. |
POST /units/:id/transition |
claim holder | Idempotency-Key |
Enum-guarded state change (+ evidence link). |
POST /units/:id/block |
claim holder | Idempotency-Key |
Park unit → blocked; create blocking_questions row; emit toward triage. |
POST /units/:id/unblock |
triage or operator | Idempotency-Key |
Write answer back → in_progress. |
POST /attempts/:id/cost |
claim holder | additive by source_event_id |
Record cost (or fold into transition-to-terminal). |
POST /units/:id/dispose |
claim holder or operator | Idempotency-Key |
Terminal disposition (+ evidence). |
GET /units, GET /units/:id |
any authed | — | Keyset list (auditlog pattern) + detail. |
Human reads are SQL views, not endpoints (P1 — humans read via Grafana/SQL, 01-initial-recommendation.md §“interaction model”). Canonical MVP view set:
| View | Feeds |
|---|---|
v_queue_depth |
ready / claimed / blocked counts by domain — monitor queue-depth panel |
v_stall_list |
claimed/in_progress units whose lease heartbeat is older than the stall threshold — monitor stall alerts; kill-gate stall detection (04-user-stories US-11; 13-pre-mortem F2) |
v_cost_per_unit |
SUM(cost_cents) per unit and per merged unit — monitor cost/merged-unit |
v_rework |
units with attempt_count > 1 — monitor rework rate |
v_disposition_summary |
MIGRATED/DROPPED/DEFERRED/DEAD counts + evidence links — Tomas’s kill-gate table |
7. Integration architecture
| Integrator | Mechanism | Direction |
|---|---|---|
| Dual-run harness (CLI/CI, outside Encore — addendum #4) | Calls transition(verified, evidence=<parity-report-url>) on parity pass, or block(question) on parity miss. Parity result becomes a transition + an evidence link; the report artifact lives in R2/harness. |
harness → work-ledger |
| Triage | Polls blocking_questions WHERE status='open'; dedups/rate-limits; routes into the human tasks INFORMATION_REQUEST queue (assigneeGroupSlug='agent-questions', free-form varchar — zero schema change, addendum #3); writes the answer back via unblock. |
work-ledger ↔ triage ↔ Core tasks |
| Fleet monitor | Grafana SQL datasource reads the five views directly; OTel carries live per-call cost (research/12 §2), work-ledger carries the durable per-unit roll-up that survives 72h retention. No overlap. |
monitor → work-ledger views |
Note (supported): the v1 blocking-question loop stays entirely inside work-ledger’s own table + the free-form assigneeGroupSlug. Minting an actual tasks INFORMATION_REQUEST (and the cross-plane answer bridge — decision #36: “no cross-plane signal bridge”) is triage’s concern, deferred to v2. work-ledger’s boundary is: record the durable question, expose it for polling. This keeps v1 at zero core-plane PRs (P7).
Data flow (a unit’s whole life):
flowchart TD
REG["register (backlog import)"] --> READY["work_units: ready"]
READY -->|"claim (SKIP LOCKED)"| CLAIM["+ attempt row + claimed event"]
CLAIM --> WORK["agent works in worktree<br/>heartbeat extends lease"]
WORK -->|"cost recorded"| COST["attempts.cost_cents -> units.cost_cents"]
WORK -->|"parity miss"| BLOCK["blocking_questions: open<br/>+ blocked event"]
BLOCK -->|"triage routes to human tasks queue"| ANSWER["answer written back<br/>+ unblocked event"]
ANSWER --> WORK
WORK -->|"parity pass"| VERIFIED["verified + evidence LINK"]
VERIFIED -->|"dispose"| DONE["done, disposition=MIGRATED"]
WORK -->|"dispose"| DROP["abandoned<br/>DROPPED / DEFERRED / DEAD"]
DONE --> VIEWS[("SQL views:<br/>queue_depth / cost_per_unit /<br/>disposition_summary / rework")]
DROP --> VIEWS
VIEWS --> GRAF["Grafana + Tomas kill-gate read"]
8. AI architecture
N/A — deterministic service. No model call on any code path; the only intelligence is the schema’s invariants. (Per brief’s own rule for a deterministic component.)
9. Security
| Concern | Design | Source |
|---|---|---|
| External-claim auth (non-Encore Claude agents — the v1 reality) | Authorization: Bearer grpn_<prefix>_<random>, minted via Core api-tokens (apps/backend-encore-core-ts/services/api-tokens/utils/token.utils.ts — HMAC-SHA256 hash stored, token shown once). Confirmed validated identically on both planes since commit eada68c “One credential header” (repo-recon §6): a grpn_-prefixed token is a static API token, anything else a session JWT; the removed g-api-key / X-Skip-Encore-Auth paths are gone. No new auth mechanism to build. |
|
| In-cluster callers (triage, monitor as services) | Service-account bearer via @libs/service-registry / libs/service-account.ts (in-memory internal:<svc> token). Same Authorization header, different token shape. |
repo-recon §6 |
| Dual-run harness auth (CLI, outside Encore — not in-cluster) | Same path as fleet agents: Authorization: Bearer grpn_<prefix>_<random>, minted via Core api-tokens. The harness runs outside the Encore process, so it authenticates as an external caller, not via service-account. |
plan-001 architect addendum #4; 04-user-stories US-19 |
| PII / PCI | Evidence is LINKS only — R2/harness URLs, never payloads. Free text confined to question, answer, evidence_url (P3). Recorded legacy traffic behind those links is PII/PCI. |
addendum; 01-initial-recommendation.md §“biggest risk” |
| Security-architect sign-off flag | ⚠ REQUIRED before any evidence link may point at raw recorded legacy traffic. Fixture retention/masking is out of work-ledger’s scope but gates the harness integration. Ship the link column; do not populate it with raw-traffic URLs until sign-off. | addendum (“recorded legacy traffic = PII/PCI → security-architect sign-off”) |
| Least privilege | Role/permission check in the controller only (.claude/rules/encore-ts.md), not deeper. OTEL_LOG_USER_PROMPTS stays OFF (research/12 §2). |
|
| Multi-host reachability (v1 scope gap) | v1 scope = fleet co-located on the operator host (local Docker PG via encore run); Postgres and endpoints bind to localhost only. Accepted single-host scope for v1 — before ramping the fleet past one host, Postgres/endpoints must be bound beyond localhost with token auth. Decide before ramp, not after. |
03-journey Failure path E |
10. Architecture-decision table
| # | Decision | Chosen | Rejected | Why |
|---|---|---|---|---|
| D1 | Claim primitive | FOR UPDATE SKIP LOCKED + conditional UPDATE ... RETURNING |
advisory locks; pgmq/graphile-worker/river | Battle-tested, zero-dependency, correct at 64-concurrent; alternatives are redundant with a single clause (oss digest) or model messages not durable rows. |
| D2 | Claim durability | Lease + heartbeat + stale takeover (attemptNo++) | permanent claim | A dead agent must free its unit; a bare lock without a durable attempt record reproduces the highest redundant-work rate (priorArt digest). |
| D3 | Audit trail | Append-only work_unit_events (auditlog pattern) alongside mutable current-state on work_units |
mutable-state-only | Kill gates need the history (every claim/attempt/transition), not just the terminal row; race-to-close is otherwise invisible (priorArt). Denormalized current-state keeps the claim hot-path single-table. |
| D4 | Temporal relationship | One-way projection (event-id dedup, read-only client) | dual-write / bidirectional sync | Dual-write is the documented anti-pattern; one-way matches Netflix Conductor + Vitess (priorArt). Prevents pre-mortem #3 (two sources of truth). |
| D5 | Placement | Encore internal service in monorepo-development |
standalone Robert-owned repo (A-lite) | One convention set, typed Encore+Drizzle+PG, structural CODEOWNERS partition [E2]. Fallback A-lite (own repo, API-only) is named if assumption A1 fails — the design is placement-agnostic (only the deploy target changes). |
| D6 | Data access | Drizzle + BaseRepository (raw sql only for the SKIP LOCKED claim) |
raw SQL throughout | Repo convention (.claude/rules/encore-ts.md); migrations generated by pnpm drizzle only (never hand-edited). ADR-0004 (Drizzle-vs-raw, research/12 §1) resolves to Drizzle; the one justified raw fragment is the claim CTE. |
| D7 | Blocking-question home | Own blocking_questions table (v1) |
mint tasks INFORMATION_REQUEST directly (v1) |
v1 zero-PR (P7); no cross-plane signal bridge exists (decision #36). Durable + agent-pollable now; tasks-svc minting is triage’s v2 job. |
| D8 | Cost model | cost_cents column on attempts, rolled to units |
separate cost_entries table |
One number per attempt satisfies cost/unit + rework views; per-model/per-phase breakdown is post-MVP (§11). OTel already carries the live stream. |
D5 note — contested third option (fold into fleet-monitor’s service): a third placement was raised alongside the two scored above — fold the work-ledger schema into the fleet-monitor service instead of standing up a separate Encore service. Resolution (canon per 11-build-vs-buy + 02-research §5.3): work-ledger exists as its own service technically (as designed above), but ships product-wise as the fleet-monitor deliverable’s data layer — same lane, no separate roadmap item. Standalone-product framing (D5’s “own repo” fallback) only applies once a second fleet claims through it or the v2 Temporal projection lands. Tracked as the open decision at 12-prd §20 Q1.
11. MVP vs post-MVP; buy / build / postpone / avoid
MVP architecture (v1 — days): 4 tables + 5 enums + the service (claim / heartbeat / transition / block / unblock / dispose / register / cost) + 5 SQL views + external-claim auth. Runs on encore run (pnpm backend:internal, port 4001) against Encore’s Docker-backed Postgres (SQLDatabase from encore.dev/storage/sqldb) — no billing-gate dependency (P7, addendum). This is exactly TDD step 1-2 of 00-SYNTHESIS.md.
flowchart LR
subgraph V1["v1 — local-first (no billing gate)"]
direction TB
FA["fleet agents<br/>(external Claude, grpn_ token)"] --> ENC["encore run :4001<br/>work-ledger svc"]
ENC --> PG[("Encore dev Postgres<br/>(Docker daemon)")]
ENC -. "lazy client, app-id empty" .-> CORE["Core plane (unlinked)"]
GRAF["local Grafana<br/>(docker-compose)"] --> PG
end
subgraph V2["v2 — cloud (after billing gate)"]
direction TB
ENC2["Encore Cloud -> GKE"] --> CSQL[("Cloud SQL")]
ENC2 --> TMP["Temporal projection<br/>(workflow-proxy fan-out)"]
ENC2 --> CORE2["Core client (linked)"]
end
V1 -- "billing gate clears" --> V2
Cross-plane clients stay lazy (app ids empty until billing/linking, addendum) so v1 never blocks on Core being reachable.
Post-MVP (v2+, each independently droppable):
- Projection from the internal-plane workflow stream (arrives with real unit workflows; a negotiated
workflow-proxyask — §5). - Temporal workflow-id uniqueness as the claim primitive for workflow-modelled units (addendum #2).
tasksINFORMATION_REQUEST minting + cross-plane answer bridge (triage’s lane).cost_entriesbreakdown table (only if Grafana needs per-model/per-phase splits — D8).- Old-estate 1→N split as new unit row types (
00-SYNTHESIS.mdevolution path).
| Lane | Items |
|---|---|
| Build | The 4-table schema; the claim algorithm; the 6-7 write endpoints + 5 read views; the atomic-claim concurrency test (new infra — no Docker-PG vitest pattern exists in the repo, repo-recon §5). |
| Buy / adopt | Nothing — no 2026 product covers the requirement bundle (competitive + oss + priorArt digests all confirm). Reuse patterns only: SKIP LOCKED, the auditlog keyset schema, api-tokens auth, registerInternalService. |
| Postpone | Temporal projection; workflow-id claim; tasks minting; cost breakdown; cloud deploy. All gated on real workflow units and/or the billing gate. |
| Avoid | Any UI/kanban/board; notifications; priorities; comments; embeddings; advisory locks; pgmq; a proposal-review write flow (repo-recon §1: that pattern has zero code — use direct calls if a Core write ever appears). |
12. Testing strategy
The two named TDD tests from 00-SYNTHESIS.md §“First implementation steps” are the acceptance anchors:
| # | Test | Asserts | Note |
|---|---|---|---|
| T1 | Atomic claim under concurrency | 2 claimers race for 1 ready unit → exactly one wins, the other gets “no unit”. N claimers vs M<N ready units → exactly M distinct units claimed, none twice. | New test infrastructure (repo-recon §5: no Docker-PG vitest pattern exists — all repo tests mock repositories via vi.hoisted()). Spin 2+ real pg connections in one vitest against Encore’s dev PG, or fall back to a mocked-repo unit test for the SQL logic + prove atomicity at the pnpm backend:internal manual-exercise layer. This test is the walking skeleton. |
| T2 | Exactly-once projection | Replaying the same Temporal event stream twice produces transitions once (source_event_id unique blocks the dupe). |
v2; spec now as the projection contract. |
Plus DB-level invariant tests (cheap, high value): illegal transition rejected; stale-lease reclaim bumps attempt_no; heartbeat extends the lease; register idempotent by external_ref. pnpm validate (biome + type-check + test) is the gate; there is no coverage threshold in the repo (repo-recon §5) — do not invent one.
Top technical risks
| Risk | Likelihood | Detection | Mitigation |
|---|---|---|---|
| Two sources of truth (ledger vs Temporal disagree) | Low-Medium | First weekly reconciliation mismatch (§5) | One-way projection by construction (D4); projection is v2, so v1 cannot drift at all. |
| Projection wiring is a negotiated substrate touch, not a free read | Medium | workflow-proxy PR unmerged / no real unit workflows exist |
Keep projection out of v1 (P7); external-claim path is standalone and complete without it. |
| Claim test has no house pattern to copy | Medium (schedule) | T1 needs new concurrency-test infra | Accept it as new infra; the SQL logic is one CTE — low surface. Manual pnpm backend:internal exercise is the fallback proof. |
| Free-text junk from LLM agents | Medium | Grafana anomaly on question/evidence_url |
Everything enumerable is an enum + CHECK (P3); free text confined to 3 columns; evidence is links only. |
| Fleet scaled past the 64 ceiling before metrics say so | High if ignored [E9] | Rework rate rises with concurrency (v_rework) |
Ramp 10→50→64 gated on ledger metrics; hard cap until data says otherwise. |
| A1 fails — no partitioned lane (assumption) | Medium | No OWNERSHIP.md within 2 weeks | Fallback A-lite (D5): own repo, API-only integration — same schema, only the deploy target changes. |