Architect design, 2026-10-01, base 1dfc87103. Design only. Flag: LOKI_CONTROL=1 until acceptance (slice CP-17).
loki control, replaces dashboard/, legacy-ui/ and engine10/dashboard. Zero config locally; deployed once for hundreds of runs.| Source | Where | Status |
|—|—|—|
| Run event log, append-only JSONL, envelope {v,seq,ts,run,type,stage,data} | engine10/events.ts:11-23 (validate), :54-89 (single writer), types.ts:26-43 (26 types) | REAL. The spool. |
| Folded run state: verdict, stages, cost (usd null when any session unpriced) | events.ts:106-140 fold(), :143-152 partialCost() | REAL. Reuse as is. |
| Run start data: task_source, issue_ref, provider, model, deep, cap_s, branch, origin_repo | supervisor.ts:194, cli data at supervisor.ts:331-334 | REAL |
| PR URL | pr.opened {url,draft,existing} supervisor.ts:233; run.completed.pr_url supervisor.ts:252 | REAL (opened only) |
| PR merged, issue closed | nowhere | MISSING. No event records merge or close. |
| Cost per session: usd, tokens, cache tokens, model, source | cost event session.ts:127-130 | REAL |
| Receipt and NOT PROVEN | receipt.json, schema types.ts:139-181; receipt.sealed {path,receipt_sha256,signed,verdict,not_proven} stages/seal.ts:292; run.completed.not_proven supervisor.ts:252 | REAL |
| Backlog queue (queued, running, BUDGET_STOP) | autonomy/lib/backlog.py:194-257, in memory only | MISSING from any durable store |
| Workspace group state | ~/.loki/workspaces/<ws>/<group>/group.json, docs/v10/D51-PHASE-B.md | NOT BUILT (design only) |
| engine10 dashboard | engine10/dashboard/server.ts:30-57 (fold-based), routes :116-125 | REAL, but one repo, one process, 127.0.0.1 |
| Python dashboard, 168 routes | dashboard/server.py (13,171 lines); cost reads .loki/metrics/efficiency server.py:7869 | REAL but legacy: reads run.sh state that v10 runs never write |
| Pricing table | dashboard/server.py:7748, :8582 (“Unverified placeholder rate”) | INVENTED |
| legacy-ui /api/v2 activity, agents/leaderboard, cost/breakdown, memory/graph, pipeline/status, providers/health | legacy-ui/index.js:100-105, issue #203 | INVENTED (no server route) |
| Secret redaction | util/redact.ts:4-14 redactSecrets; seal.ts:30 sanitizeReason; output.ts:79 Reason line | REAL. Reuse. |
sources(id PK, host_hash, repo_path_hash, origin_repo, first_seen, last_seen). id = sha256(hostname + “\0” + realpath(repo)) truncated to 16 hex, computed by the shipper. No raw paths or hostnames stored.events(source_id, run_id, seq, PK(source_id, run_id, seq), ts, type, stage, data JSON, line_sha256, received_at). Index (run_id, seq), (type, ts).runs(source_id, run_id, PK(source_id, run_id), origin_repo, issue_ref, task_source, provider, model, started_at, ended_at, verdict, outcome, pr_url, pr_draft, cost_usd NULL, partial_usd, measured_sessions, total_sessions, input_tokens, output_tokens, wall_s, last_seq, last_event_at, tampered, conflict). A projection, rebuilt from events by fold() on every ingest batch for the touched runs; never written any other way.receipts(source_id, run_id, PK, receipt_sha256, verdict, not_proven JSON, body JSON).work_items(origin_repo, kind 'issue'|'pr', number, PK(origin_repo, kind, number), state, url, observed_at, run_id NULL). Fed only by work.observed events (CP-12).source_id:run_id:seq. Same id and same line_sha256 is a no-op (idempotent retry). Same id with a different hash returns 409, sets runs.conflict = 1 and never overwrites.loki verify on local files: the shipper redacts, so stored bytes may differ from events.jsonl.POST /v1/ingest {source, run_id, events[]}: at most 500 events or 1 MB, each validated with validateEnvelope() (events.ts:11), redacted again server-side with redactSecrets. Returns {accepted, duplicate, conflict, last_seq}.PUT /v1/runs/:source/:run/receipt: receipt.json; 400 unless its receipt_sha256 matches a received receipt.sealed event.GET /v1/runs?repo=&verdict=&provider=&since=&cursor=, /v1/runs/:source/:run (summary + timeline), /v1/runs/:source/:run/events?after_seq=, /v1/stats, /v1/costs?group=day|repo|model, /v1/compare?ids=, /v1/work, /v1/backlog.GET /health (process up; {service:"loki-control", version, install_path, pid}, the P0-DASH-STATIC identity); GET /ready (DB reachable and migrations at head, else 503).src/server/auth.ts): binds 127.0.0.1 by default. With LOKI_CONTROL_TOKEN set, every /v1/* call (ingest, reads, answer) needs Authorization: Bearer <token>, compared in constant time; missing or wrong gives 401. /health and /ready need no token. A non-loopback LOKI_CONTROL_HOST with no token exits 2 naming LOKI_CONTROL_TOKEN before binding; LOKI_CONTROL_ALLOW_INSECURE_BIND=1 overrides. On a loopback bind a request whose Host is not 127.0.0.1, localhost or [::1] (any port) gets 403, which blocks DNS rebinding. The UI takes #token=<token> once, keeps it in sessionStorage and sends it on every call. Ingest and read share the token in v1..loki/runs/<id>/ship.json {acked_seq, url}, written atomically.LOKI_CONTROL=0; the url comes from LOKI_CONTROL_URL or local discovery, section 6), it uses tail() (events.ts:155), batches up to 200 events or 1 s, and POSTs. Backoff 1, 2, 4 … 60 s with jitter. It never blocks or fails the run: on supervisor exit it flushes for at most 5 s, then leaves the rest for replay.data goes through redactSecrets before sending.loki control prune --repo OWNER/NAME and/or --before ISO_DATE (--dry-run previews) deletes matching runs, their events and orphaned sources in one transaction, direct on the SQLite file (WAL, 5 s busy timeout), so it works with the server up or down. DELETE /v1/runs/:source/:run (JSON content type, same-host Origin, bearer when a token is set) removes one run and writes an audit row first; the UI Run detail page has a Remove button with a confirm step.loki control backfill [--repo DIR] and every loki start ship each run whose ship.json acked_seq is below its last seq. Backfill of old .loki/runs uses the same code path.LOKI_CONTROL defaults on; LOKI_CONTROL=0 makes loki control print one “off” line and exit 0, and disables discovery.loki control serve reads the child’s “listening on” line and writes ~/.loki/control/instance.json {pid, port, url, version, install_path, db} (mode 0600), removed when the CLI exits.LOKI_CONTROL_URL is set, or when instance.json names a live pid (kill -0) whose /health answers service=loki-control within 300 ms (packages/control-plane/src/shipper/discover.ts). A run never starts a server and never kills a process it did not record.~/.loki/control/control.db. Tests always pass a temp HOME, an ephemeral port and LOKI_NO_BROWSER=1, and stop by recorded PID.oven/bun base, the service plus built UI assets, bun packages/control-plane/dist/server.js. Probes: liveness /health, readiness /ready.deploy/helm/loki-control: replicas 1 with SQLite on a PVC (single writer, documented); replicas N only with DATABASE_URL. Migrations run on boot under a Postgres advisory lock. No in-process pub/sub: the UI polls with a cursor, so any replica serves any request.LOKI_CONTROL_URL and LOKI_CONTROL_TOKEN; containers and Helm runs of loki start ship the same way.loki dashboard becomes an alias of loki control, and the dashboard tests are removed (CP-18).CP-00 corpus, then CP-01, CP-02 and CP-03 in parallel, then CP-04 to wire them: SQLite service with ingest, the shipper with backfill, and a UI with the Runs list and detail, all behind the flag.
Rules for every card: file sets do not overlap; only CP-02 touches supervisor.ts and registry.ts, only CP-04 touches autonomy/loki and root package.json. Every Wall check feeds the CP-00 corpus through the real ingest path and asserts exact counts, starting the service on an ephemeral port with a temp DB under LOKI_RUN_TMP. Any new tests/*.sh needs runner registration, a shard row, timeout -k and shellcheck.
| ID | Scope and file set | Wall check (fixture in, counts out) | Tier | Deps |
|---|---|---|---|---|
| CP-00 | Corpus: events.jsonl and receipts written by the real supervisor under the engine10 e2e fake sessions (VERIFIED, PARTIAL, FAILED, BLOCKED, cap.hit, tampered, unpriced cost, resumed). packages/control-plane/test/fixtures/runs/ + EXPECTED.json |
EXPECTED.json counts equal a direct fold() over each file |
LOW | none |
| CP-01 | Parallel A. Service + DB: src/server/{app,ingest,runs}.ts, src/db/{schema,migrate}.ts, drizzle/, package.json |
ingest corpus twice: run count, verdict counts, cost_usd (null where expected), duplicate=all on 2nd pass; changed line gives 409 | MEDIUM | CP-00 |
| CP-02 | Parallel B. Shipper + backfill: src/shipper/{ship,backfill}.ts, hook loki-ts/src/e10ext/ship_hook.ts, one line each in supervisor.ts and registry.ts |
backfill corpus into a CP-01 stub server; kill mid-batch, rerun: every event exactly once; redacted token never appears in the posted body; ship.json advances | MEDIUM | CP-00 |
| CP-03 | Parallel C. UI: ui/ (Vite, React, Tailwind), Runs list with filters, run detail timeline, receipt and NOT PROVEN panel; empty states |
build, render against section 4 JSON captured from the corpus: row count, verdict badges and NOT PROVEN items match EXPECTED.json; empty DB shows “no runs ingested” | MEDIUM | CP-00 |
| CP-04 | Wire: service serves ui/dist; loki control [serve,backfill,status] in autonomy/loki + loki-ts cli; bundle into the npm package (files[]) |
npm pack tarball contains server and UI; loki control serve + backfill corpus: /v1/runs count = corpus |
MEDIUM | 01,02,03 |
| CP-05 | Local lifecycle: src/local/lifecycle.ts (instance.json, reuse rule, restart, replay) |
stale-version instance not reused; dead pid restarted and replayed; never touches unrecorded pid | MEDIUM | CP-04 |
| CP-06 | Auth and bind: src/server/auth.ts |
non-loopback bind without token exits non-zero; wrong token 401 on ingest and read; loopback no token 200 | HIGH | CP-01 |
| CP-07 | Postgres: src/db/pg.ts, advisory-lock migrations |
with DATABASE_URL: two instances ingest corpus concurrently, counts equal SQLite run | MEDIUM | CP-01 |
| CP-08 | Receipts: src/server/receipts.ts + ui/src/receipt/ |
receipt PUT accepted only with matching sealed hash; NOT PROVEN items equal receipt.json | MEDIUM | 01,03 |
| CP-09 | Overview: src/server/stats.ts, ui/src/overview/ |
totals, verdict split, in-progress and stale counts equal EXPECTED.json | LOW | 01,03 |
| CP-10 | Costs: src/server/costs.ts, ui/src/costs/ |
per-day and per-model usd; unpriced run shows partial “N of M sessions”, never 0 | MEDIUM | 01,03 |
| CP-11 | Compare: src/server/compare.ts, ui/src/compare/ |
two runs: stage times, cost, verdict, not_proven diff match fixtures | LOW | 01,03 |
| CP-12 | Work: issues to PRs; loki control sync turns gh pr view/gh issue view on the client into work.observed events (no token on the server). src/sync/, src/server/work.ts, ui/src/work/ |
open, merged, blocked, pending counts from fixture gh JSON; without sync the panel says “merge state not ingested” | MEDIUM | 01,03 |
| CP-13 | Backlog: backlog.py writes envelope events (backlog.queued, backlog.dispatched, backlog.stopped) to .loki/backlog/<batch>/events.jsonl; backfill ships it. autonomy/lib/backlog.py, src/server/backlog.ts, ui/src/backlog/ |
5-issue fixture batch: queued, running, done and BUDGET_STOP counts | MEDIUM | 02,03 |
| CP-14 | Live: cursor polling ui/src/live/, ?after_seq= |
append 3 events to a fixture run while open: detail shows them within 2 polls | LOW | 01,03 |
| CP-15 | Deploy: packages/control-plane/Dockerfile, deploy/helm/loki-control/ |
image boots, /ready 200 after migrations; helm template renders probes; replicas>1 without DATABASE_URL fails render |
MEDIUM | CP-07 |
| CP-16 | Self-heal: /ready gating, stale-run derivation, replay on boot | DB file removed while up: /ready 503, recovers; stale run flagged after heartbeat gap | LOW | 01,05 |
| CP-17 | Acceptance + flag flip | full corpus and one real first-run demo run on SQLite and Postgres: every view’s counts equal EXPECTED.json | HIGH | all above |
| CP-18 | Delete dashboard/, legacy-ui/, engine10/dashboard/, their tests and references | rg finds no imports; local-ci fast tier green; first-run gate opens the control UI |
HIGH | CP-17 + 1 release |
Dependencies to add (none present today; web-app/package.json pins react 19, vite 6, tailwind 3, so align): hono, drizzle-orm, drizzle-kit (dev), react, react-dom, vite, @vitejs/plugin-react, tailwindcss. SQLite uses built-in bun:sqlite. Postgres uses drizzle’s bun-sql driver if the pinned drizzle-orm exports it, otherwise postgres is the one extra. No hard blocker found: Bun is already the runtime (bin/loki:94).
loki control sync (chosen default, no server secret) versus a GitHub webhook or App into the service. Webhooks need a public endpoint and a secret.LOKI_ENGINE=legacy) write no events.jsonl. Proposed: not shown. Confirm.heartbeat after N days?A run blocked on a spec conflict shows its question in the run view with an answer box. The answer is POSTed to /v1/runs/<source>/<run>/answer (application/json, answer up to 4000 chars, the run must be blocked, ids are validated, the server binds 127.0.0.1) and written to $LOKI_CONTROL_ANSWER_DIR (default ~/.loki/control/answers) as <source>/<run>.answer.txt, mode 0600. The engine has no in-place resume reader, so resuming starts a fresh run with the answer as task context: loki answer <run>.
loki answer [<run-id>] [--text "..."] reads the run’s task and question from .loki/runs/<run>/events.jsonl and the answer from --text or the answer file above, then launches bin/loki "<task>\n\nClarification answering \"<question>\": <answer>" and prints the new run id. The default run is the newest BLOCKED run in the current repo. The child env drops every SLACK_* variable and LOKI_CONTROL_TOKEN and sets LOKI_NO_BROWSER=1. A run that is not BLOCKED, or has no answer, exits 2 with the reason.
LOKI_CONTROL_DEFAULT=1 (off by default) makes loki dashboard open the Control Plane.
Ingest runs a pure verifier (src/server/integrity.ts) over every stored run and persists tampered, attested, sig_checked and integrity_reasons. Only the sealed prefix (events through log.sealed) supplies the verdict; a second run.completed, or any verdict-bearing event after log.sealed, is TAMPERED. The log.sealed kid must equal the signed receipt kid, and with keys configured an unknown kid is TAMPERED.
Display verdicts (effective_verdict, mirrored by ui/src/api.ts): TAMPERED; UNVERIFIED (a VERIFIED claim that is not attested); “VERIFIED (signature not checked)” (attested, but no key in LOKI_CP_RECEIPT_PUBKEYS checked the seal signature); VERIFIED (attested and signature-checked). With no keys configured nothing can be plain VERIFIED. Choice for the list filter: ?verdict=VERIFIED lists signature-checked runs only; ?verdict=VERIFIED (signature not checked) lists the rest. ALREADY_SATISFIED is a success outcome and needs a seal exactly like VERIFIED (same UNVERIFIED and signature-not-checked rules). Any other verdict on an unattested run shows <verdict> (unattested), so an edited or redacted FAILED or PARTIAL never reads as plain. The Overview VERIFIED tile counts only plain VERIFIED; a separate tile counts signature-not-checked runs. Redaction (Q17-05b): the engine redacts event data with redactEnvelope (loki-ts/src/util/redact.ts) before it hashes the log and signs the seal; the shipper and ingest apply the same function, which is a fixpoint, so on an engine-written line both are no-ops. Seal crypto fields of the exact engine shape are exempt. A log whose bytes no longer match its seal, including one rewritten by redaction after sealing, is TAMPERED; there is no UNVERIFIED escape for redaction. Rows are recomputed at boot in chunks (one transaction each) when attested is NULL or the stored key-set fingerprint (integrity_key_fp) differs from the configured one; rows whose events are gone are marked unattested once and not retried.