Console record delivery
Delivery class is a property of the record class, declared once. It is not a per-script choice, and it is not decided by which transport a caller happened to reach for.
The two shapes, and why they want opposite guarantees
Console’s ingest already models both, and stores them differently:
- Events append (
events/<producer>/<scope>.jsonl; one row per record id). - Projections upsert (
projections/<producer>/<scope>.jsonlocally; hosted,on conflict (tenant_id, record_id) do update setguarded so a late snapshot cannot clobber a newer one).
That difference is the whole decision:
A projection is a fold of current state, so it is self-healing. Any successful emission repairs every prior miss. Dropping one costs freshness, never correctness — the board may lag, it cannot be wrong.
An event is an independent fact, so a miss is a hole — and an invisible one: nothing distinguishes “no verdict was recorded” from “the verdict was lost.”
Decision
Board state — “where is this work, is it done” — is derived from a fold, never
from event replay. It is emitted as a projection over durable local state
(trust.bundle + state.json), upserted per scope. Best-effort delivery is
correct here because the next emission repairs the last miss. A read surface
must be derivable from state that can be re-read, not from a stream that had to
be caught.
Receipts — gate claims, critiques, verdicts, releases — are events, delivered
at-least-once, with a local outbox (append, detached flush, mark-sent, re-flush
unsent on the next run) and gap detection. /records’ idempotent upsert makes
at-least-once safe. Only this class justifies the cost.
At-least-once without gap detection is theatre. If a class cannot answer “which records never acked,” it has retries, not durability. Gap detection is therefore required for the receipt class and pointless for the others.
Class assignment
| Class | Examples | Posture | Why |
|---|---|---|---|
| Receipts | gate claims, critiques, verdicts, releases | at-least-once + gap detection | loss is silent and material |
| Board / workflow state | flow position, completion | projection fold, upserted, best-effort | self-healing; loss costs freshness only |
| High-frequency observation | per-tool-use hooks, heartbeats | best-effort, fire-and-forget | statistical; an outbox here puts I/O on the hot path of every tool call |
| Periodic aggregates | kit economics per run | replay on next run | re-derivable from local state; per-run, not per-event |
Best-effort for hook telemetry and liveness is the correct answer, not a compromise. The test is whether loss is both silent and material; if loss is detectable or immaterial, durability buys nothing and costs latency.
Consequences
Filesystem scraping is retired, not extended. Today trust facts are written
to gitignored, worktree-local projection files and later scraped by a bridge bin
(kontour-process-bridge and a proposed kontour-trust-bridge). That model
loses receipts whenever a worktree is pruned, and makes Console’s contents a
function of when a scraper last ran. Flow Agents emits instead — the shape
scripts/liveness/relay.sh already uses, posting through the shared
console_post_json core to POST /records after the durable local write.
A kontour-trust-bridge bin should not be built; it would entrench the
scrape path.
Console renders core primitives; kits contribute their own outcome views. Console represents Flow and Flow Agents — runs, claims, trust bundles, attestation, provenance tier. Those are generic. A kit’s outcome is not: “merge-ready” belongs to Builder Kit, and another kit composes the same evidence into something else entirely. So Console should not hardcode a kit’s vocabulary as a first-class view; it should expose an extension surface a kit contributes to, the way Station’s manifest-driven plugin UI lets a plugin contribute layouts. Ideally the same contract rather than a second parallel one — a kit that already describes its surfaces should not have to describe them twice in a different shape. Baking “Merge Readiness” into core Console would put a Builder Kit concept in the product every other kit also has to live with.
Records carry the provenance tier established at delivery. A receipt is not just a fact that
something happened; it is a fact with a provenance, and Console must never render
independently-verified and self-asserted identically — if it does, the signing is decorative
and Console is only as trustworthy as its weakest accepted producer. The tier is defined once, in
trust-reconcile; Console
displays it rather than deciding it, and a kit’s view interprets it rather than redefining it.
Local-first is unchanged. The durable local write happens first and remains authoritative; Console is the tenant’s durable mirror, not the enforcement point.
Liveness stays advisory. A missed release degrades a lane to reclaimable
on TTL — safe, self-correcting. A missed claim is the dangerous direction:
Console would show free while the lane is held. That is tolerable only because
the authoritative liveness write is local and precedes the relay. Any capability
that ever enforces on the Console fleet view needs the projection treatment,
not best-effort events.
One core, two modes. The single shared transport keeps its
“one core, never forked” rule and grows an at-least-once path behind it, selected
by declared class. Today the guarantee diverges by implementation language
instead: the bash console_post_json is curl --connect-timeout 2 --max-time 5,
detached, no retry, quiet exit 0; the JS ApiSink carries maxAttempts,
retryBackoffMs, and a sentIds dedup set. Nobody chose that split.