Skip to content
GRPNR.

10 — Product architecture

Scope: the technical design of work-ledger — one thin Encore.ts service in the internal plane of monorepo-development, Postgres SSOT for unit 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 HEAD 92d2ea0, 2026-07-18) and the plan-001 architect addendum (00-SYNTHESIS.md lines 141-150). Evidence IDs [E1]-[E10] are defined in plans/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:

  1. 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_attempts row (attempt_no = attemptCount) and append a claimed event, same tx. Drizzle expresses this via .for("update", { skipLocked: true }) or a raw sql fragment through the repository’s transaction().

  2. 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 the UPDATE); 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-op ON 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_executions via the generated client (never direct DB — the core/internal boundary, root CLAUDE.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-null workflow_id, compare work_units.state against Core workflow_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-proxy ask — §5).
  • Temporal workflow-id uniqueness as the claim primitive for workflow-modelled units (addendum #2).
  • tasks INFORMATION_REQUEST minting + cross-plane answer bridge (triage’s lane).
  • cost_entries breakdown table (only if Grafana needs per-model/per-phase splits — D8).
  • Old-estate 1→N split as new unit row types (00-SYNTHESIS.md evolution 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.