ADR 0226: One tracker tool surface
Status: accepted (James, 2026-10-05). Tracks VUH-1665.
Amends the tool vocabulary, priority and disconnected behavior of
ADR 0191.
Amended by ADR 0243:
linear_graphql reaches the rest of Linear's API beyond this subset.
A proposed
ADR 0181 amendment
(VUH-1904) grows the built-in default tracker from the durable local backend and
makes Linear an optional connection; these tools stay the agent vocabulary.
A proposed amendment
(VUH-1916) adds actors, exactly-once writes, preconditions and an audit log to
the built-in tracker. A second proposed
amendment
(VUH-1917) adds the item event stream, derived state, delivery stages and owner
wakes. A third proposed amendment
(VUH-1930) makes releases records whose items come from landed commits, and a
fourth amendment (VUH-1931) adds
per-project cycles with automatic rollover, and a fifth
amendment
(VUH-1918) adds runs, leases, the ready queue and drift. A sixth
amendment
(VUH-1919) adds evidence bundles, linked owner asks and run gates. An
amendment
(VUH-1991, VUH-1997) keys evidence by the tracker's own identifiers. The
cutover amendment
(VUH-1987) moves one World project from Linear onto the built-in tracker and back;
its VUH-2829 item routing
sends any caller's call for a cut-over project's item to the built-in tracker.
The LOCAL-VUH-42 amendment
makes a built-in write cost its change rather than the whole store.
Context
Clankie and his workers already know the Linear tools and writing skills.
Separate work_items tools force them to learn another vocabulary, and losing
the Linear connection removes that familiar surface. James requested “a mirror
of linear tools locally, for fallback if we're not using Linear.”
Decision
Expose the same linear_* names and input shapes through the existing service
tool directory to Clankie and workers. The supported subset covers issue
reads, lists and search; issue upserts and atomic description patches; state,
priority, labels, parents and relations; comments and replies; projects and
project status updates. Identity, team, state and label discovery support the
same writing and orientation skills. Unsupported provider features must fail
explicitly, rather than silently discard input.
Choose the owner-connected Linear backend when connected, retaining its
credential, account, lane and dispatch fences. Otherwise use durable local
service storage. A failed connected call never switches backend or replays a
mutation. clankie doctor identifies the active backend and why it was chosen.
Local identity is visibly local; it is not a fabricated verified Linear account.
Fleet admission and its kill switch continue to apply to both backends.
Repository conventions still select GitHub or Markdown storage. Their adapters
project that storage onto the same tools; repository selection is explicit.
The existing clankie work, device API and receipt-backed narrow writes remain
compatibility callers of the common contract. They retain repository admission,
fresh-read merges and uncertain-write handling. Ancillary local records belong
to the same repository scope, rather than a second task queue.
Priority is 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low) on every
backend. Open-work lists sort urgent through low, then unprioritized, before
pagination or limits. Markdown stores priority as front matter; GitHub uses
reserved priority labels; Linear retains its native field.
Connected issue lists collect provider pages once into an account/configuration- bound sorted snapshot. Identical concurrent reads and public cursor pages share that scan for 60 seconds; failures have a 30-second retry floor. The host retains at most 32 listings. Cursors name the snapshot and require a restarted listing after expiry or invalidation, preventing mixed pages. Each caller still passes its own lane, grant, account and revocation checks, including after a shared read. Writes invalidate at dispatch and settlement (also after uncertain failures), and verified webhooks invalidate before self-echo suppression. A connection change or shutdown retires snapshots. No mutation is cached or retried. Host call logs include the actual provider-page count, including zero for cache hits.
Local records have immutable UUIDs and stable human identifiers. Writes lock the durable store and replace it atomically. Patch anchors must match current content; a failing operation aborts the entire mutation. Labels replace only when explicitly supplied; relations append as in Linear. A local record remains local when an account is connected; no automatic migration or synchronization is implied.
Export and import
A future explicit export is a versioned bundle containing source store UUID
(the local store's persisted team UUID),
record UUIDs and identifiers, full content, priorities, state and label metadata,
projects, comments and replies, relations, and timestamps. Import requires an
owner-selected connected account, team and project mapping. It creates projects
and issues first, records a durable (store UUID, record UUID) → Linear UUID
mapping, then resolves hierarchy, relations, status updates and comment threads.
Each mutation records intent and its provider receipt before continuing. A
rerun reconciles the mapping and uncertain receipts instead of duplicating
records; unmapped labels or states require a decision. Export/import execution
and automatic synchronization are outside this change.
Amendment: actors, receipts and audit (2026-10-09, VUH-1916)
Status: proposed. Tracks VUH-1916. Applies to the built-in tracker (the durable local backend); connected Linear, the Linear API tracker and the GitHub/Markdown adapters are unchanged.
Actors. Every write records who made it. The host stamps the actor from an
authenticated Clankie identity and passes it beside the call, never in tool
arguments. There are three types: human (the owner), agent-worker (Clankie
himself or a hired worker) and app (an enrolled device). Each actor carries
its on-behalf-of chain, outermost first, and the model when the host knows it.
| Caller | Actor | Chain |
|---|---|---|
Fleet or granted worker (clankie_call) | agent-worker, its grant principal | owner → Clankie |
| Clankie's operator tools | agent-worker clankie, turn model | owner |
| Clankie in a social room | agent-worker clankie, turn model | none |
clankie work on the operator credential | agent-worker clankie | owner |
Owner's explicit clankie work owner | human owner | none |
| Owner's verify/gate answer (ADR 0245) | human owner, via: owner_ask | none |
| Owner write from an app device | app device:<id> | owner |
| Owner app verdict (ADR 0299) events | human owner, via: owner_app | none |
| Standalone use with no host | the store's visibly local user | none |
The operator credential proves a caller on this machine (the lead seat, a hire's
shell or the owner's terminal), not the person at the keyboard, so its
clankie work writes are Clankie's for the owner (LOCAL-VUH-1, 2026-10-10).
Before that, the operator credential wrote as human owner and the CLI's
/v1/work verbs as the store's local user. The lead's own bundles then counted
as the owner's work, so the owner's verify answer was refused as a self-check.
A fleet worker is named by the persona bound to the exact seat and session its
hire recorded, such as Lark. With no bound persona it is named by that seat. Its
pane principal stays the actor id. The records it creates or changes carry
createdByActor and updatedByActor.
The Linear-shaped person fields (author and user on comments, createdBy
on issues, user on status updates) name the record's agent-worker or
external writer when it has one: a hired worker by its name, Clankie, or an
outside member such as a Discord forum poster. They carry isLocal: false; an
external write is untrusted and never reads as the owner. Owner, device and
standalone writes, and records written before actors, keep the store's local
user.
Amended 2026-10-28 (LOCAL-VUH-59).
Before this, workers were named by their Herdr terminal ID (term_…), and every
built-in record's author read "Local owner". A client showing author then made
an agent's comment look like the owner's. Historical records keep the names
they were stamped with.
Exactly-once writes. Write tools accept an optional idempotencyKey. The
store keeps one receipt per (actor, key): the request fingerprint, applied or
refused, and the original result or reason. Receipts are committed in the same
atomic replacement as the effect, so a crash leaves both or neither. A retry
with the same key and arguments returns the original result without writing
again. The same key with different arguments is refused (idempotency_conflict).
get_write_receipt answers applied, refused or unknown. unknown means
nothing was applied or refused under that key by that actor, so sending again is
safe. This is the evidence store's receipt pattern
(ADR 0258) applied to the tracker.
The worker channel's own call receipts stay a transport journal above it.
Patch-safe updates. Field and label deltas already exist (omitted fields are
unchanged; addLabels/removeLabels; description patch). Updates now accept
ifUpdatedAt. A record changed since then is refused with
precondition_failed, and nothing changes. Each write gets a strictly
increasing timestamp, so updatedAt works as a version even under a coarse or
fixed clock.
Audit log. Every applied or refused write appends an event with its tool,
actor, key, field names (not values), touched records or target, and refusal
reason. Events are hash-chained. The store refuses to open, and so to extend, a
log whose entries were edited, removed or reordered. list_audit_events reads
it newest first, optionally for one record.
Where it lives. Receipts and the audit log live inside the store file
(tracker.json), beside the records they describe, so one atomic replacement
covers effect, receipt and audit. Both grow without bound until a retention
decision. The hosted Postgres backend replaces this layout without changing the
tools.
Other backends. idempotencyKey, ifUpdatedAt, get_write_receipt and
list_audit_events fail explicitly on every other backend rather than being
dropped. linear-writes.json and linear-attribution.json already record only
connected-Linear activity; the built-in tracker never needed them, and they stay
unchanged for connected Linear.
Amendment: typed events, delivery stages and owner wakes (2026-10-09, VUH-1917)
Status: proposed. Tracks VUH-1917. Built-in tracker only, on top of the VUH-1916 actors, receipts and audit log.
Event stream. Each item has typed events in one store-wide stream.
Workers report ack, plan, action, blocked, ask, result and error
with post_issue_event. The tracker records created, comment, state,
priority, stage, verified and reopened from writes, including hand-set
ones. Every event carries its actor and a selfEcho flag. Owner activity (a
human actor, or an app acting for the owner) is not self-echo; everything
Clankie and his workers write is. Events are hash-linked like the audit log,
committed in the same atomic replacement as their write, and the store refuses
to open a broken chain. list_issue_events reads them oldest first and resumes
from the returned cursor (the event seq). In process, the store pushes newly
committed events to subscribers and replays from a cursor on subscribe.
Derived state. ack moves a reported item to accepted. blocked, ask and
error set flags that the next ack, plan, action or result clears.
A stage on the event advances delivery: reported → accepted → landed →
delivered → owner-verified. Each stage maps to a status: the one bound to it, or
otherwise the first status of its category. Status categories stay backlog,
unstarted, started, completed and canceled. save_issue_status creates or
renames statuses and binds them to stages, so custom names map onto Linear's
categories during the mirror. New stores add a Delivered status.
Owner-only verification. Only a human actor sets owner-verified, completes
an item by hand, or moves an item back. Agents report landed or delivered and
the owner checks it. Reaching landed raises a "check it works" owner ask: the
existing ADR 0245 ask with a new purpose, verify, so it appears in
input_list and the World mailbox. Answering "It works" records owner-verified
as the owner. Any other answer sends the item back to accepted with the
owner's text. These events are marked via: owner_ask. An ask's issue
reference may now use the built-in clankie-work:// record URL.
Owner wakes. An in-process loop reads the stream from a durable cursor. It
needs no port, tunnel or webhook secret. Owner comments, verifications,
reopens, state and priority changes wake the item's project lead chat (the
VUH-1927 projectChats routes), otherwise global-default; nonproject items
wake the default chat. The same wake-target rules as Linear activity apply,
but not its follow switch: linearWebhook.following can only be turned on with
a configured Linear webhook, and the built-in tracker has none. Self-echo never
wakes. Answers to asks don't wake a second time, because ADR 0245 already
wakes the source chat. The owner's own writes reach the built-in tracker
through POST /v1/tracker/owner/call, authenticated as the operator (a human
owner) or a terminalControl device (app).
Optional outbound webhooks and feeding Linear's webhooks into this stream during the mirror are not part of this change.
Amendment: releases (2026-10-09, VUH-1930)
Status: proposed. Tracks VUH-1930. Built-in tracker only, on top of the VUH-1917 event stream and stages. James, 2026-10-09: releases are in v1 because they are core to Clankie. Cycles and initiatives stay out.
Record. A release is a shipped version of one repository lane: version,
tag, commit, lane, date with its date kind (annotated tag, lightweight tag's
commit, or publication), and its items. Its id is repository:lane:version, with
the repository named by its origin remote (github.com/owner/name). Clones
and worktrees therefore record one release. Releases live in tracker.json
beside the items they ship.
Membership is derived, never typed. A sync reads the repository's v*
versions through the same tag source as clankie work project. It then reads
the commits between each version and the previous one in version order. The
first version takes its whole history. The items are the keys those commit
messages name: built-in keys (LOCAL-…) and the connected tracker's team key
from the repository's convention (VUH-…). Other key-shaped text (UTF-8,
SHA-256) is not work. No tool writes a release or its items. The sync is a
host operation that the owner starts with clankie work releases sync
(POST /v1/tracker/releases/sync), and the release skill runs it after
pushing a tag.
Delivered on ship. A built-in item reaches delivered on the first release
that contains it, on any lane. That move is a stage event written as Clankie
(agent-worker clankie for the owner), via: release, with the version in its
body. It is self-echo, so it never wakes anyone. The owner still checks
everything that ships: a stage move that passes landed without stopping there
raises the same "check it works" verify ask that landing does, once per item.
Items already at delivered or
owner-verified stay put, and canceled or duplicate items are not delivered.
Imported items without a recorded delivery stage retain their release membership
without a stage move. Delivery still requires an independently checked evidence
bundle. An individual refusal does not abort release recording or other items'
delivery; the sync returns skipped entries with identifier, version, reason and
detail for each built-in item that stays put (VUH-2086).
Keys from a connected tracker are listed by key only and have no stage effects
until the mirror (VUH-1907). The sync is deterministic: run again, it upserts
the same releases and moves nothing.
Reads. list_releases and get_release follow Linear's tool names and
input shapes. When Linear is connected, they reach Linear's own releases. On
the built-in tracker, the pipeline is the repository lane, the stage is
Shipped, query also matches an item key, and release notes fail explicitly.
get_issue with includeReleases lists the releases that shipped an item,
oldest first. GitHub and Markdown repository trackers refuse release tools.
Release notes, planned releases and lanes beyond one per repository convention
are outside this change. So is a release that does not ship as a v* tag.
Amendment: cycles (2026-10-09, VUH-1931)
Status: proposed. Tracks VUH-1931.
Built-in tracker only. James's choice of cycle length and scope is still open,
so this uses the issue's default: one week, per project. The length is a stored
project setting (save_project cycleDays, clankie work cycle length), not a
constant, so changing it later is a setting change.
Cycles. A cycle is a numbered time box of one project, with a start and an
end. A project's first cycle starts at UTC midnight on the day its first item
joins a cycle. After that, each cycle starts where the previous one ended. A
length change applies from the next cycle, and the current cycle keeps its end.
Cycles are records in tracker.json.
Membership. An item joins or leaves a cycle through save_issue cycle
(an id, a number, current or next, or null), Linear's own field. Only the
owner, the owner's app or Clankie as lead may set it; a hired worker is refused
(cycle_planning_reserved). An item needs a project, and leaves its cycle if
it moves to another project. An ended cycle takes no new items. Every change is
a cycle event, with from and to set to cycle ids.
Rollover. Rollover needs no timer. The first tracker access after a cycle
ends performs it as Clankie's own write (via: rollover, self-echo). That write
creates each elapsed cycle and moves every unfinished item from an ended cycle
into the current one. Unfinished means a status that is not completed, canceled
or duplicate, so a delivered item that the owner has not yet verified rolls
over. Several elapsed cycles roll straight into the current one.
The view comes from the event stream. Each cycle's summary is read from its
cycle events and completions:
- planned: joined before the cycle started;
- added: joined after it started;
- rolled in;
- removed;
- finished: completed while in the cycle;
- rolled over;
- in flight: unfinished members of the current cycle.
There is no burn-down. list_cycles keeps Linear's
type: current|previous|next and adds project. get_cycle and
list_issues cycle read one cycle and its items. The current cycle is
what the ready queue (VUH-1918) will prefer.
Other backends refuse cycle and the cycle tools explicitly. Connected Linear
keeps its own team cycles, and the Linear import (VUH-1907) will map them.
Amendment: runs, leases, the ready queue and drift (2026-10-09, VUH-1918)
Status: proposed. Tracks VUH-1918.
Built-in tracker only. Linear has no equivalent, so these are built-in tools
(save_run, list_runs, save_lease, list_ready_issues, list_drift), and
other backends refuse them.
Runs are attempts at an issue's work. Each has an attempt number per issue,
an optional parent run (a retry or sub-run), its status (active, then
succeeded, failed or canceled), worktree, branch, and reported tokens and
dollars. The tracker stores only the run's host-stamped actor, never a copy of
seat state. On every read the host resolves the live link from its own hire
records (ADR 0207): a fleet worker's principal names its fleet and pane, and
the hire record names its seat, harness and hire revision. A pane the host
could not verify links to its fleet only. Only the run's own runner, the owner
or the lead update a run. Starting and finishing write run events. An item's
get_issue carries work, the rollup of its runs: attempts, active runs,
time, tokens and cost.
Leases default to 30 minutes and expire on their own, with no timer. While a
lease is live, nobody else can lease the item or start a run on it. A holder
renews its own lease quietly. Taking or releasing a lease, and taking over an
expired one, writes lease events. The owner and the lead may release anyone's
lease.
The ready queue lists open items not yet landed that are unblocked (no open
blocker and no blocked flag), unleased and not being run. The project's
current cycle comes first, then priority (urgent to low, none last), then the
oldest.
Drift is read, not stored. It lists:
- leases that expired without a release;
- runs still active on closed items (the builders who keep working on closed issues);
- open items with no update in N days (default 30).
Cycle rollover is bookkeeping and does not count as an update, so an item that only rolls over still reads as idle.
Amendment: evidence bundles, owner asks and run gates (2026-10-09, VUH-1919)
Status: proposed. Tracks VUH-1919. Built-in tracker only, on top of actors, events and runs. The existing World mailbox remains the owner surface (ADR 0245).
Bundles. save_evidence_bundle attaches an immutable bundle to an item,
optionally to one of its runs. It contains store record IDs, sha256 and matching
clankie://evidence/sha256/… links, typed log, screenshot, video, diff,
eval or other, and an explicit gaps list. The host resolves each record ID
against the evidence store before accepting it; a missing store or mismatched
hash refuses the write. A record may be reused across items. The tracker stores
references, never bytes. get_evidence_bundle, item reads and run reads expose
the current bundle. New bundles supersede the previous bundle and its check.
Item completion uses the item's bundle; run bundles describe their own attempts.
An item past landed must be reopened by the owner before its completion bundle
can be replaced, preserving its checked-proof invariant.
Completion. Moving past landed requires the current item bundle and an
independent check, recorded as bundle_checked with its bundle ID and actor.
post_bundle_check refuses a checker who published the bundle, ran the work,
or reported action/result/landing on the item, and refuses a superseded bundle.
The owner's verification through the existing verify ask counts as a check.
The owner (a human actor) is never refused: independence keeps agents from
attesting their own work, and he alone verifies (LOCAL-VUH-1).
The same gate applies to typed stage moves, hand-set completion and automatic
release delivery; a release sync refuses atomically if an item it would advance
lacks a checked bundle. Creating an item already completed is refused: create,
attach proof, then verify. Listed gaps remain visible for the checker to assess;
there is no automatic judgment of their severity.
Asks. post_issue_ask records the request in the item event stream. The host
raises a source-bound ADR 0245 decision ask and links its requestId from the
item. No second question/answer store exists. An item and a run hold only mailbox
references. Verification asks raised on landing use the same links.
The project's lead chat receives the ask; the default chat is the fallback.
Gates. A request with purpose gate names a run and one of plan, spend,
destructive_action, merge or external_write. It blocks the run immediately,
including before the mailbox link is available. The host raises the ADR 0245
ask with purpose gate and explicit Approve/Decline choices. Only an authenticated
owner answer choosing Approve clears that gate; free text, decline and cancellation
leave it blocked. Multiple pending gates must all be approved. Public tracker
arguments cannot approve, resolve or replace an ask. The resolution is a gate
event attributed to the owner, via: owner_ask; lead controls retain the lead's
actor and are never reported as owner approvals.
Run controls. Only the owner, the owner's app or Clankie as lead can call
post_run_control to steer, pause, resume or stop a run. Each writes its named
event with runId and optional instruction text. Pause blocks run updates;
resume clears the pause but does not clear gates; stop records cancellation and
end time. Cancellation is still allowed while blocked. Child runs cannot bypass
a parent's pause or pending gates. These are durable execution-state actions;
they do not type into a terminal, terminate a process or imply native delivery.
Recovery. The item event is the durable request. Host ask publication uses that event ID as its identity, reconciling the same retained mailbox record even if already answered. The loop keeps its cursor behind failed publication, retries the same identity and applies retained settled answers on restart. Linking and resolution use the tracker's atomic journal; resolution is idempotent by request ID. Owner-answer events do not wake the lead twice: ADR 0245 already wakes the source conversation. Mailbox retention bounds still apply.
Interfaces. Additive built-in tools share the existing host catalog and
refuse on connected Linear, GitHub and Markdown backends. The owner CLI uses
clankie work bundle set|show|check, work ask, work gate ask and
work run steer|pause|resume|stop through the authenticated tracker owner route.
The existing input_list/input_answer API and clankie conversations questions|answer commands answer
asks, including gates; there is no tracker approval shortcut.
Amendment: built-in tracker sync journal (2026-10-09, VUH-1962)
Status: proposed. Tracks VUH-1962, under VUH-1920. Built-in tracker only. The research baseline supplies the bootstrap/delta shape, not provider internals.
One ordering. Each committed write appends a hash-linked sync commit with
one increasing safe-integer syncId. Its model deltas, item events, effects,
audit and VUH-1916 receipt share the existing atomic file replacement. Clients
apply a whole commit before saving its cursor: a transaction's side effects
cannot be split by a resume. Refused writes advance the cursor with no model
effects; receipt replays advance nothing. Each commit carries the authenticated
actor, tool and optional idempotency key; (actor.type, actor.id, key) is the
existing receipt identity. The log is retained without pruning, like the item
and audit streams. Broken, missing or reordered sync entries refuse store open.
Pre-sync stores start at zero; existing objects enter through bootstrap.
Models and groups. Wire objects are normalized stored fields and UUID
references, rather than expanded Linear-shaped views. Supported models are
issues, comments, projects, cycles, releases, runs, leases and bundles, plus
item events, status updates and shared team/user/status/label metadata.
Milestone uses the reserved milestone model name; imported documents use
document. Both preserve their imported fields. An issue's project is its sync group;
unprojected is the explicit group for items without a project. Associated
comments, events, runs, leases, bundles and release membership follow the issue.
A move includes previous and current groups and complete data, so clients can
remove a departing object and populate an arriving one. Ordinary updates carry
changed fields and removedFields; inserts are complete. Group filtering keeps
every commit, even with empty deltas, preserving the global cursor.
Workspace discovery (VUH-1964). The reserved workspace group carries
project create, rename and archive records and project-level milestone, cycle
and release metadata. Every client always subscribes to it; the server includes
it in subscriptions even when omitted. Project and cycle records also keep their
project UUID groups; release records keep their derived membership groups.
Imported milestones, documents and project status updates also join this group;
status updates retain the reserved status_update model name.
A full bootstrap with projects: ["*"] expands to every tracker project UUID
(including archived projects), plus unprojected and workspace. The metadata
syncGroups contains the exact expanded IDs. Partial bootstrap retains exactly
the named groups. On a new project delta, the client partially bootstraps that
UUID, preserves other groups, and adds the UUID to its subscription; it reconciles
the snapshot cursor with buffered deltas before resuming. Settings-to-tracker
bindings use explicit UUIDs only; unmatched projects stay unbound. The binding is
an optional trackerProjectId on the host's project settings (VUH-1969), not in
the committed .clankie/tracking.json, because tracker UUIDs belong to one store.
project update sets or clears it; the host refuses a UUID the built-in tracker
does not hold and a second project bound to the same UUID, and work_repos
returns it on the project's repo. Sync group
lookup accepts UUIDs and reserved groups, never names. Discovery uses deltas,
never polling.
Bootstrap and hydration. Full bootstrap replaces the selected project's
local pool; partial bootstrap replaces only named groups. Both snapshot under
the journal lock and return JSON lines, one model per line, ending in metadata:
schemaVersion, storeId (persisted team UUID), lastSyncId, syncGroups,
type and modelCount. Shared metadata accompanies both. Default lazy bootstrap
omits comment body/quoted text, event bodies and run summary/worktree/branch, listing
unhydratedFields; lazy:false returns everything. Batch reads return complete
selected objects and their snapshot lastSyncId. Clients merge a batch only if
no newer delta for that object has arrived; hydration never rolls back newer
server data. Both bootstrap types are per-project snapshots, not pagination.
Live path. tracker_sync is an additive op on the existing authenticated
POST /operator/v1/dispatch. Reads need the device's current chat grant;
transactions need current terminalControl/owner authority. The relay forwards
the original signed device identity for both, including hosted devices, and
rechecks identity, scope and grants before emitting. Bootstrap at the relay
returns raw application/x-ndjson; the service/CLI envelope holds those lines
in ndjson. subscribe resumes from (storeId,lastSyncId) and waits on
in-process commit publication for up to 20 seconds, rather than repeatedly
reading the tracker. Replay snapshot and listener registration close the
read/subscribe race. Disconnect cancels the wait. The existing relay
POST /operator/v1/tail streams kind:tracker_sync pages continuously; empty
pages are heartbeat checkpoints. No new inbound port or gateway route is needed.
A changed store or cursor ahead of the journal returns rebootstrap with the
requested groups. The client replaces those groups with a partial bootstrap.
The retained log otherwise has no cursor expiry. Sync payloads retain exact
model fields; transcript redaction must not alter object identities or content.
Transactions. A transaction contains 1–50 existing write tool invocations
and one required idempotency key. All operations validate before dispatch and
run under one lock and one atomic replacement, reusing the VUH-1916 receipt.
Operations retain distinct increasing write times so version preconditions and
independent checks see earlier operations in the same transaction.
A failure rolls back every operation and retains the original refusal. Nested
keys and read tools are refused. Receipt lookup remains get_write_receipt.
Omitted fields stay unchanged, so the latest committed write wins only fields
it names. ifUpdatedAt refuses stale edits; a retry requires fresh state and
new intent/key. Transactions retain all actor, bundle, completion and gate
constraints of the underlying tools. They cannot resolve owner asks.
Store fence (VUH-1971). A transaction may carry an optional
expectedStoreId, the storeId from the client's bootstrap. The server compares
it with the open store inside the same lock and atomic replacement as the write,
so a store swapped after the client's own check cannot receive a queued edit. A
mismatch is refused with reason store_replaced; no operation applies, and the
keyed receipt, audit entry and model-free sync commit record the refusal like any
other. A present expectedStoreId joins the receipt fingerprint, so an omitted
one keeps existing fingerprints. Clients send it on every queued replay.
Interfaces and proof. clankie work sync --json COMMAND reaches this same
operator API. The app object pool and offline queue are Sync 2 (VUH-1964).
Integration proof exercises real pairing, signed sessions, host and relay HTTP,
bootstrap/lazy hydration, atomic refusal, keyed replay and disconnect/resume.
apps/clankie/scripts/tracker-sync-roundtrip.ts is a separate disposable scratch
round trip with two paired clients observing each other's writes in order.
Proposed amendment: read-only Linear mirror import (2026-10-09, VUH-1963)
Status: proposed. Linear stays authoritative until the explicit ADR 0181 cutover.
The host imports a single-team Linear project into a named scratch built-in
store with clankie work import linear --project UUID --scratch NAME. Scratch
stores live under tracker-imports, separate from the live tracker; this command
neither selects a backend nor deploys anything. Multi-team mappings and team-wide
imports require a later explicit mapping decision and are refused for now.
Every imported record keeps its Linear UUID, issue identifier, timestamps, archive marker and source metadata. Projects, issues, comments/replies, state spans and actor-bearing history, labels, relations, documents, milestones, project updates and team cycles are paginated. Out-of-project relation endpoints remain explicit references. Reruns compare provider records by UUID and content; unchanged records do not write, append history, or advance sync IDs. Upstream changes update existing records. Disappearance alone never deletes local data. Linear statuses are historical observations, not an assertion that the owner verified a built-in delivery. Imports never raise asks, run completion gates or roll Linear cycles over. A mirror store suppresses local cycle rollover until cutover; cutover policy belongs to Mirror 3.
Missing foundations. Milestones and documents become built-in records.
Milestones retain id, projectId, name, description, targetDate,
sortOrder and timestamps; issues retain milestoneId and their original
projectMilestone. Documents retain id, projectId, optional issueId, title,
content, slug, sort order, timestamps and author. The sync names are milestone
(the VUH-1962 reservation) and document; existing bootstrap, batch and delta
paths carry them. Imported changes enter the same atomic sync journal and audit
log; a record event marks the source observation. Native read tools expose both.
Project status updates reuse the existing built-in records.
Identity. Actor mapping is keyed by Linear ID. Owner IDs/emails come from
owner-authored Linear wake settings, the connected app actor maps to clankie,
and explicit bot-persona authors map to agent workers. Unknown authors retain
the original provider identity. Names merely mentioned in prose are not evidence
of authorship. Original author fields remain alongside built-in provenance.
Bytes and authority. Only the broker-connected Clankie app is used. Queries
and authenticated upload downloads share the service's background request
budget, and budget refusal stops the import with its retry time. No provider
mutation is available in the importer. App bearers go only to HTTPS
uploads.linear.app and public.linear.app, with redirects disabled. External
attachment links are retained and reported rather than receiving a bearer.
Downloads are bounded to 128 MiB; a failure leaves the prior tracker snapshot
intact. Evidence uses ADR 0258's existing content-addressed upload/receipt path.
Imported links become clankie://evidence; original links remain in source
metadata. A durable scratch cache avoids downloading unchanged URLs again.
The response reports counts, created/updated/unchanged records, skipped download
reasons, request count and five issue keys for inspection.
Proposed amendment: Linear webhooks keep the mirror current (2026-10-09, VUH-1965)
Status: proposed. Linear stays authoritative until the explicit ADR 0181 cutover.
The owner enables a webhook mirror per scratch import with clankie work mirror linear --scratch NAME --project UUID enable|disable|status; nothing mirrors by
default, and only a store imported from that project can be bound. Bindings and
their counters live in tracker-imports/mirrors.json.
One mapper. The webhook route hands every authenticated data-change event,
including his own writes' echoes, to the mirror after the wake decision; mirror
work runs in order per store and its failures never change the response or a
wake. Each event becomes a partial import snapshot, merged onto the stored
provider record, and importLinear applies it. Issue create/update/remove
(state, priority, labels, milestone, assignee, parent, cycle), comments, labels,
the project, project updates, documents, cycles and issue attachments are
covered. Linear's webhook models have no relation or milestone resources: a
project event refreshes milestones, and relations update when an issue is next
re-read. Removed comments leave the native list; other removals keep the record
with an archive marker.
Exactly once and attributed. The Linear event id is the hash of the signed
body without its send timestamp, so a redelivery or replay is recognized; the
store keeps the last 5,000 applied ids and a replay writes nothing. Item events
(created, state, priority, comment) and record events carry the original
Linear actor, mapped as the import maps authors, with via: linear_mirror; the
sync journal and audit record mirror_linear, and subscribers receive the
change as it commits.
Read-only copy. A store holding Linear mirror records refuses every built-in
tool and sync write with mirror_read_only; changes are made in Linear.
Drift. An event that names an unknown issue, label, workflow state, milestone or comment parent is drift. The service re-reads only those records through the connected account with the import's field selections (at most five issues per event, read-only, shared background budget), applies them with the import mapper, then applies the event. Records found outside the project are not imported. Each repair is reported with its references, outcome and request count.
Amendment: evidence keys are the tracker's own identifiers (2026-10-09, VUH-1991, VUH-1997)
Status: accepted for the Clankie Work pilot cutover (VUH-1987).
Evidence records are keyed by the identifier the tracker gives an item, and the
device reads (evidence_records, the issue sheet's tiles, "Proven recently")
look them up by that same identifier. No alias or mapping table joins them.
An imported item keeps its Linear VUH-n, so records keyed VUH-n match it.
A natively created built-in item keeps its LOCAL-n key, or LOCAL-VUH-n once
its store holds an imported team. That whole string is one key. Its VUH-n
tail names a different item, so key extraction never shortens it. The
LOCAL- prefix stays: it keeps native numbering apart from a team whose
numbers Linear still assigns during the mirror.
Inferred keys (push without --issue, and backfill) follow the release rule
above. They are built-in LOCAL-… keys, or keys whose prefix is the team key
in the repo's .clankie/tracking.json. Other key-shaped text (SHA-256,
UTF-8, GPT-6) is not work. In a README, Linear issue URLs, headings and
Tracks:/Issue: lines win over keys found elsewhere in the text. When
nothing qualifies, the record stays unkeyed. An explicit --issue is taken as
written. Records pushed before this rule under the shortened VUH-n keep that
key; nothing rewrites them.
Proposed amendment: cutover and switch-back (2026-10-09, VUH-1987)
Status: proposed. This is the ADR 0181 cutover step: the built-in tracker mirrors Linear first, then the owner cuts one World project over. Linear is read during cutover and never written.
One command, dry run first. clankie work cutover linear --world-project ID --scratch NAME --project UUID [--dry-run] (POST /v1/tracker/cutover/linear,
operator only) cuts the named World project over from the Linear project imported
into scratch store NAME. --dry-run reads only, including the Linear capture.
It prints everything the cutover would change and the exact switch-back command:
- counts per type against Linear;
- the mirror's state, then disabled;
- the live store file, its storeId and counts, and the backup path;
- the settings binding from and to;
- the convention file from and to.
The real run prints the same plan with the steps it applied, or refuses with the same plan and HTTP 409. Cutovers run one at a time per service.
Refusals. Cutover refuses:
- when the copy has drifted: a fresh read-only capture of the Linear project
(the import's own collector, through the connected account and the shared
background budget) has a record the copy lacks, or a newer Linear
updatedAtthan the copy holds (actors are compared by id only); - when the mirror recorded failed events or unrepaired drift reports;
- when the copy holds writes that did not come from Linear;
- when the import came from another workspace or another Linear project than the convention scopes;
- when the convention is not
linear; - when a cutover is already active;
- when the tracker UUID is bound to another World project;
- when the live built-in store holds any record.
Cutover never merges two stores; a live store with records is a separate
decision. Records the copy keeps after Linear removed them are reported as
extra, but they do not block. Rerunning clankie work import linear repairs
missing or stale records before a retry.
Steps. The service first writes the intent, tracker/cutover/active.json,
and backs up the convention. It then applies these steps, recording each one:
- Mirror. It disables the scratch store's webhook mirror.
- Store. Under both store locks, the live
tracker/tracker.json's exact bytes go totracker/cutover/<time>/before.json. The copy then replaces the live store, markedlinearMirror.cutover. The marked store is writable and authoritative. Its Linear records stay as provenance, and it refuses further imports. The copy keeps its own storeId, so clients rebootstrap (store_replaced, VUH-1971). - Binding. It sets the World project's
trackerProjectIdto the imported project UUID (VUH-1969), which is Linear's project UUID. - Convention. It switches the project's
.clankie/tracking.jsonfrombackend: lineartobackend: builtin. Thelinearblock stays unchanged, so switch-back restores it, and the note records how to switch back.
The builtin convention. builtin joins the repository backends. A
builtin repo reads and writes the service's own built-in store, scoped to the
project's bound trackerProjectId. Lists, searches and new items take that
project and its team. A delegated write to an issue outside it, or a project
argument naming another project, is refused. The scope uses the UUID, never the
name. Workers keep the linear_* tools: a call with that project's repo, or
clankie work --repo, reaches the built-in tracker with the host-stamped worker
actor. A worker hired for the project reaches it without repo as well (see
Routing by the hire's project below). Project details read the bound project by UUID. Release key matching keeps
the VUH prefix. An unbound builtin repo is unavailable and names the missing
binding. It never falls back to Linear.
Owner loop. Imported and mirrored history (via: linear_import or
linear_mirror) already woke chats in Linear's time. It advances the owner
loop's cursor without wakes or asks. The cursor belongs to one storeId: when a
cutover or switch-back replaces the store, the loop restarts its cursor and its
ask links for the new store. Imported Linear team cycles (no project) are
history and never roll over; project cycles roll over once the store is cut
over.
Switch-back. clankie work cutover linear --world-project ID --switch-back [--dry-run] reverses the recorded steps. It prints the convention, binding and
store changes. It also prints pilotWrites: the built-in writes made since
cutover, which Linear never saw and which must be carried over by hand. The
switch-back then:
- restores the convention's exact pre-cutover bytes;
- restores the previous
trackerProjectId, or none; - keeps the pilot store as
tracker/cutover/<time>/pilot-<time>.json; - restores the live store's exact pre-cutover bytes.
It refuses if the live store is not the one the cutover promoted. The mirror
stays disabled. To cut over again, run a fresh import linear into a new scratch
name, then cut over again.
Routing by the hire's project (VUH-2014). Calls without repo still
reached connected Linear, so a worker on a cut-over project could keep writing
there. Now, when a delegated principal names no repo, its hire decides. That
is a verified fleet pane's live project hire, or a project:<id> grant. The host
reads the hire's registered project and its saved convention:
builtin, or any other non-Linear convention: the call goes to the project's own repo, with the same scope as an explicitrepo. Writes land in the built-in store with the worker as the actor. Writes outside the project are refused, and so islinear_graphql, because it would reach Linear directly.linear: the call reaches Linear unchanged. This amendment narrows no Linear project's existing scope. A project without atrackerRefcounts aslinearwhen every saved convention in its local workspaces islinear, as with today'sclankieproject.- If the project has no saved convention, is gone, or its tracker cannot be read on this machine, the call is refused and names the fix. The host never guesses.
An explicit repo always wins. Operator-lane calls with no hire, such as
Clankie's own seat, behave as before. A bearer-linked worker without a verified
pane, or a pane that is not a project hire, has no project context. Its calls
behave as before. The pane's hire record supplies the project, never a tool
argument. Only a confirmed hire routes. A hire that is not yet confirmed, a
legacy hire whose process was never proved, or a finished (gone) hire keeps
the old routing. A worker in that state passes repo for a cut-over project.
Routing by the item's project (VUH-2829). VUH-2014 routes by the caller's
hire, but a cut-over project's work is often done from another project's repo.
Clankie Work is worked from the clankie and clankie-app checkouts, whose
convention is linear, and the lead has no hire at all. Their calls for Clankie
Work items still reached the frozen Linear copy: after the 2026-10-10 cutover,
eight Clankie Work issues diverged between Linear and the built-in tracker.
Decision: route by the item's project. We did not choose refusing and
pointing to the built-in. A tracker call that names a cut-over project's
record goes to that project's built-in tracker, whoever calls: a worker hired
into any project, the lead's operator tools, an unhired operator call, or the
Linear backend behind clankie work --repo on a Linear repo. A cut-over
project is a registered project bound to a built-in tracker project
(trackerProjectId) whose saved convention is not linear. An unreadable
convention counts as cut over, so nothing falls back to Linear. A call names a
record by an imported TEAM-n, a LOCAL-TEAM-n, a record UUID (issue,
comment, status update, document, milestone), or the project's own UUID, name
or slug, in any reference argument (id, issueId, parentId, project,
relation lists and the rest; free text never counts). The route reaches the
project's repo as an explicit repo does. Writes record the host-stamped actor
and stay scoped to the project.
Why route rather than refuse:
- The intent is unambiguous: the item has one home, and the built-in tracker holds it under the same key and UUID.
- A refusal asks every caller to retry with
repo. That manual rule was already documented, and nobody followed it. - Routing grants nothing new. Any operator-lane caller could already pass the
project's
repo, and the same project scope applies.
Precedence: an explicit repo wins, then the item's project, then the hire's
project (VUH-2014), then the old default. The route fails closed. These cases
are refused before any provider call, and never treated as "no cut-over item":
- a call naming records of two cut-over projects;
- a reference another project shares with a cut-over one, such as a duplicate project name, which must then be named by UUID;
- a built-in store that is missing, or that the tracker's own store validation rejects, while any cut-over project is registered.
What cannot be routed is refused with a pointer to the built-in tracker. It never reaches Linear:
linear_graphqlmutations whose document or variables carry such a reference;- Linear-only writes, such as
create_worker_commentor a connected Linear MCP tool outside the tracker vocabulary; - routed calls from a non-operator lane.
clankie work show|activity|update|close|attach ID takes the VUH-2805
--project path for a cut-over item, whatever --repo it runs from, and
clankie work write from a Linear repo binds its receipt to the item's project
repo. Both matter because the Linear backend mixes item calls with team calls
(statuses, users) that only Linear answers.
History reads stay possible and marked. A read tool with linearHistory: true reaches connected Linear without any routing. Its result is wrapped as
{ "linearHistory": "Linear history: …", "result": … }. A write, a
repo call or linear_graphql with linearHistory is refused, and so is a
history read when Linear is not connected. Unrouted Linear reads that name a
cut-over record, such as linear_graphql queries and connected Linear MCP read
tools outside the vocabulary, return the same marked envelope. The 2026-10-10
drift check and the mirror read Linear through their own GraphQL session, so
this routing does not change them.
Interfaces and proof. Before the live run, the device protocol's
WorkBackendKind gains builtin, so app builds must include it before they read
a builtin repo. apps/clankie/test/linear-cutover.integration.test.ts covers
these cases over the HTTP route:
- dry run;
- promote;
- switch, with a fleet worker's
clankie_callwrites landing in the built-in store; - an out-of-project write refused;
- one "check it works" ask on landing;
- no wakes from imported history;
- exact switch-back;
- refusal on drift, with nothing changed;
- VUH-2014: a hired worker on the
builtinproject writes withoutrepointo the built-in store as itself; a cross-project write is refused; a worker on alinearproject, bound or unbound, reaches the Linear path (the local fallback in tests); a worker whose project has no saved convention is refused; and an explicitrepostill wins. - VUH-2829, with Linear connected through a recording MCP boundary: a hire in a
linearproject and the lead comment on and move an imported Clankie Work item by key and UUID, and aLOCAL-VUH-none; the lead'sclankie work updatefrom a Linear repo moves it as well. Every write lands in the built-in store under its actor.linear_graphqlmutations naming it, inline or in variables, are refused before any request.linearHistoryreads reach Linear and come back marked. The provider sees no write at all. A duplicate project name in either store order is refused while the UUID still routes. A missing,{}, invalid-JSON, wrong-version or store-shaped but incomplete store holds the write before the provider, and the route returns once the store is restored. Without a cut-over project the store is never consulted.
Amendment: owner tracker settings (2026-10-20, VUH-2532)
The owner chooses a default for new projects and an explicit per-project
backend/target through one owner service API. Existing .clankie/tracking.json
conventions remain authoritative. A new-project default never rewrites an
existing project's convention. Project changes require a preview-bound explicit
confirmation: items stay in their original stores, with no migration or deletion.
Switching back reopens those items; built-in project bindings are retained.
Linear and GitHub reuse connected accounts. Built-in needs none. The Discord
forum slot remains unavailable pending VUH-2531: it will be a two-way view of
the built-in source of truth, not another store. CLI/API shapes are in
the CLI reference.
Amendment: external actors (2026-10-21, VUH-2531)
The Discord forum view lets people outside the owner's authority, such as server
members, file items and comments. The tracker records them as a fourth actor
type, external. An external actor is never the owner (human) and its events
are never selfEcho, so consumers must check actor.type before treating a
non-echo event as the owner's. The owner loop does not wake on external
activity. The host stamps the actor from the forum event, never from tool
arguments, and the text carries a provenance line that marks it untrusted.
Owner-only moves stay with human. The design and its trust rules are in
the forum view proposal.
Amendment: agents follow changes by cursor (2026-10-27, LOCAL-VUH-43)
Agents could re-list or read list_issue_events, not the sync journal, so
"what changed since I looked" meant polling lists. Two built-in additions close
that, and other backends refuse both:
list_changes {project, since, storeId, waitMs?, limit?}reads the existingsyncsubscribe path for one project, so its cursor,waitMslong poll on the in-process commit publication, andrebootstrap(store replaced or cursor ahead) apply unchanged. Commits from other projects are skipped but advance the cursor. Payloads are changed fields and ids, with bodies left out by the bootstrap's lazy rules; entering the project reads as insert, leaving it as delete, and shared metadata (labels, users, statuses) is not included. A hire's default project comes from its routing (VUH-2014). This is not a read fence: naming another built-in project's id routes the call to that project for any caller (VUH-2829), aslist_issuesdoes. Every built-in project here belongs to one owner, so Sync 5 keeps that routing.list_issuesandlist_ready_issuestakesnapshot: true. The first page fixes the whole result in service memory and returns itssyncIdandstoreId; later pages read that result. Snapshots are process-local and bounded (16, 32 MiB, five idle minutes), so a restart, eviction or replaced store reports the snapshot expired instead of mixing states. Reconstructing a past state from the journal was not chosen: it would bind list reads to the journal's storage, which Sync 4 (LOCAL-VUH-42) is changing.
Amendment: writes cost the change, not the store (2026-10-27, LOCAL-VUH-42)
Status: accepted (lead-approved scope, 2026-10-27). Tracks LOCAL-VUH-42 in Clankie Work, under VUH-1920 (milestone 3). Built-in tracker only. Tools, receipts, the audit and event streams, the sync journal and the store fence keep their contracts.
Why. Each write cost the whole store, several times over: it re-parsed and
re-verified every hash chain after the previous write, made two whole-store
copies, diffed every sync model and rewrote and fsynced the whole file. On
generated stores at fleet volume (600 sync commits a day, other records in the
live store's proportions), a single save_issue took 118 ms at one day of
volume and 1,036 ms at ten. Serializing and fsyncing were only about a fifth of
that. A single JSON string also stops working: at 60 days of that volume the
first write fails with V8's Invalid string length. Method and numbers:
the measurement record.
Copy-on-write.
- Every backend instance on a store file in the process shares the last committed store, deep-frozen; reads use it directly. Production opens a fresh scoped instance per hired worker call, and none re-parses another instance's commit.
- A write works on a draft. The draft copies only the top level and each collection
array. A record is cloned only when the write changes it (
own); finding an issue or project inside a write does this. - A refused write discards its draft. The committed store is the before-image the audit, receipts and sync diff compare against.
- A missed
owncannot corrupt committed state. The frozen record throws, and the write is refused withinvalid_request, naming the code site. - An integration test drives every write tool, an update of each and a sync transaction through the frozen store.
- Values a caller passes in (tool arguments, transaction operations, a Linear snapshot) are cloned before they can become committed records.
Other writers. Before reusing its snapshot, each backend checks the store
file's identity, size and timestamps under the store lock. Any commit by another
process or another backend instance changes them, and the next call reads the
store again. Today the service is the only process that writes
~/.clankie/tracker, through several backend instances: the CLI and the relay
reach it through the service API. A two-process integration test proves no
write is lost either way. After its own commit, a backend applies exactly what
opening those bytes would apply (stage status binding), so it never has to read
them back.
Incremental sync diff.
- A sync commit's deltas come only from the records whose identity changed.
- It also covers what derives from them: an issue's lease and owner asks, and the groups of comments, events, runs, bundles, documents and releases that follow a moved issue, document or status update.
- This is a superset of the real changes, and each candidate is still compared as before, so the deltas are the same.
- Under Vitest, every commit also computes the full diff and fails on any difference. A deliberately dropped dependency fails the suite.
Copy-on-write alone brought a write to 31 ms at one day and 220 ms at ten. What remained was mostly the whole-file rewrite, so the second half stores writes as a journal.
Journal.
- Format: JSONL segments.
tracker.jsonstays the store's file and becomes its checkpoint. A store under 2 MiB keeps the single legacy file, rewritten per commit; at that size the whole-file write costs about as much as an append. - Above 2 MiB, writes go to
tracker.journal/NNNNNNNNNNNN.jsonl, named by first sync id. A commit appends one line and fsyncs once. The line holds the sync commit and the store's record changes: appended tails, id-keyed upserts and removals, or a whole replaced collection when order alone changed. - The checkpoint holds every record but no sync log. Its
journalheader names the sync id and hash it covers and seals every earlier segment by size and SHA-256. A build without the journal refuses a checkpoint rather than open a partial store. - Open is checkpoint plus tail. The records after the checkpoint must chain from its sync hash with consecutive ids. The events, audit and sync chains are proven over what they replay. Sealed segments must match their digests, which are checked once per process. A torn final line, an interrupted append, is left out, and the next append cuts it off. Anything else refuses the open, as a broken chain always has. (LOCAL-VUH-47 also skips a final line whose bytes are not a whole record at its stated length; see the follow-up below.)
- Checkpoints run in the background once the live journal reaches 4 MiB or an
eighth of the checkpoint, in three steps:
- Under the lock: seal the live segments and start a new one.
- With no lock held: stream the frozen snapshot to a temp file. No single string is built, so a checkpoint is never limited by V8's string length.
- Under the lock: rename it in, if nothing replaced the store meanwhile. Commits keep appending throughout. A crash at any step reopens from the previous checkpoint and replays every segment after it.
- A segment is always created new, never reopened. If a checkpoint stopped before its rename, the empty segment it started stays the live one. The next checkpoint reuses it rather than sealing it.
- The store directory is fsynced after every rename that switches it: the move onto the journal, a checkpoint, and setting a journal aside. Writes loop until every byte is written, so a short write is never acknowledged.
- An append checks that it still holds the store lock before it touches the segment.
- Segments are never pruned: they are the sync history, and a subscribe cursor older than the checkpoint reads them.
- The checkpoint does not grow with the sync log. It does grow with events, audit and receipts, by about 4.4 MB a day at fleet volume (267 MB at 60 days, projected 400 MB at 90).
- Another process's commit changes the checkpoint file or the newest segment, so the next call replays that tail onto its snapshot, or reopens.
Receipts. Each receipt records its sync id. Once a checkpoint covers it, an
applied result over 1 KiB stays only in its journal record. get_write_receipt
and keyed replay read it back from there. Guided-review receipts stay whole,
because retirement reads them.
Readers.
- Host readers (routing, record-project lookup, cutover summaries) read the checkpoint plus its tail.
- The route fence includes the journal's version, because a commit may touch only the journal.
- Long-polling subscribers re-subscribe after every page. While the committed snapshot is current and the page is in memory, a subscriber reads it without the store lock: reading it and registering the listener happen in one synchronous step. Ten waiters therefore no longer queue ahead of the next write.
Migration and rollback.
- At start the service checkpoints its store, which moves a large single file onto the journal. A commit would move it otherwise.
- The exact bytes stay beside it as
tracker.pre-journal-<ms>.json. Its sync log becomes the first sealed segment. The checkpoint rename is the switch: until then the single file is authoritative, and a journal left by an interrupted move is set aside. - Cutover and switch-back keep exact whole-store bytes: the backup is the full store as one file, and the replaced journal is set aside, never deleted.
- To roll back to a build without the journal, stop the service and write
exportTrackerStore(directory, out). It writes the whole store, sync history and receipt results included, as one legacy file. Movetracker.journal/aside and put that file in place astracker.json. - Restoring
tracker.pre-journal-<ms>.jsoninstead loses every write since the move. - Either single file fails before 60 days of fleet volume, the old format's own limit.
Measured (generated stores, migrated by the real path; method and every run in the measurement record):
- Single write, p50/p95, at 1, 10, 30 and 60 days: 5.0–5.3/5.3–11.8, 5.2–8.8/6.1–11.9, 10.3–11.2/11.4–14.4 and 11.7–21.7/14.3–39.8 ms. The machine was shared, with load averages of 8 to 22 on 18 cores.
- Before this amendment: 113, 1,008 and 3,466 ms p50 at 1, 10 and 30 days. At
60 days the first write fails with V8's
Invalid string length. - At 60 days: 17 sequential creates take 0.41–1.01 s and a cold open takes 1.1–1.5 s. A hired worker's write, through a fresh scoped instance, takes p95 21.5–33.5 ms.
- Moving the 60-day store onto the journal took 4.0 s. A copy of the live store (16.7 MB) took 140 ms, and every record, sync commit and receipt was preserved.
- Ten long-polling subscribers add about 0.5 ms (10 days) to 1.5 ms (60 days) to the write p50. Before they skipped the lock, they added 11 and 21 ms.
- Not met:
- The issue also asked for p95 at 60 days within 2× of one day. It is about 3–4×, and the lead accepted the 25 ms bar instead.
- That 25 ms p95 bar holds at 60 days for the reused instance at moderate load, but not for the hired-worker path or the most loaded run.
- Both have the same cause: per-write passes over whole collections (37k events, 36k audit entries and 25k receipts at 60 days). They are identity diffs for the journal record, settling the snapshot and the sync candidates, plus receipt and bundle lookups by key, plus GC. LOCAL-VUH-47 makes those passes cost the change.
Rejected: node:sqlite. One WAL row per commit with synchronous=FULL, on the
live store's real records (mean 6.6 KB):
- With
fullfsync, the same durability as Node'sfsyncon macOS (F_FULLFSYNC): p50 5.0 to 6.9 ms per commit, against JSONL's 4.0 to 5.1 ms. Replay took the same time, and the file was 14% larger. - Without
fullfsyncit took 0.1 ms, but a commit then survives a process crash, not a power loss. - It would not win at equal durability, so it is not worth a second storage engine.
Follow-up: flat at 60 days (2026-10-27, LOCAL-VUH-47)
Status: accepted (lead-approved scope, 2026-10-27).
- A write costs what it changed. A draft remembers the committed array each
collection came from, and the records it replaced through
ownorreplaceRecord. Settling, the journal record and the sync candidates read only those records and the appended tail. Collections the write left alone stay the committed arrays. - Lookups by key use indexes. These are receipts, current bundles, owner asks and
sync models by id. One index serves a line of committed arrays, and each commit
extends it.
list_issuesviews only the page it returns. - Under Vitest, the tracked journal record and the incremental sync diff are compared with whole-store ones, and settled snapshots must hold only frozen records.
- Measured at the same load (2.3–3.1 on 18 cores), before and after interleaved:
- 60-day p95 is 7.0–7.7 ms plain and 6.7–7.1 ms through a fresh hired instance, both within 1.5× of one day. Before, it was 11.5–19.1 and 16.4–20.8 ms.
- The service's
list_issuesafter a hired write went from about 130 ms to 8 ms. - Details are in the measurement record.
- Journal lines name their length. A final line whose bytes are not a whole record
at that length is an unacknowledged tail: a power cut kept its size but not its
pages. Open skips it.
- The same damage earlier is refused.
- A process that acknowledged the record refuses to lose it.
- A final line that parses but breaks a chain is refused.
- A checksum was rejected because it would make a tampered, well-formed final line skippable.
- A journal build from before framing, after a rollback, appends unframed lines after framed ones. Such a line reads as a record when it parses, so the rollback and the return keep every write.
- An append proves what it cuts (LOCAL-VUH-54). Bytes past the size the appending backend last saw are cut only when they hold no whole parseable record, framed or unframed, at any line start. Otherwise they are an acknowledged commit read from a stale base, and the append refuses before dispatch, cutting and writing nothing. This is independent of LOCAL-VUH-51's under-lock generation guard, which keeps a stale base from reaching the append at all. A torn tail is one interrupted append, so the tail is read only up to 64 MiB. A larger one is refused unread, and recovering from it (an interrupted append of a record over 64 MiB) is the manual recovery in the work-items README.
clankie doctorreports the checkpoint against the single-string limit. Open still parses the checkpoint under that limit (536,870,888 characters). Doctor warns at half. Retention or a streamed open is LOCAL-VUH-49.
Amendment: streamed checkpoint open (2026-10-28, LOCAL-VUH-49)
Choose streamed open over retention. Dropping events or audit would lose provenance; expiring receipts would change keyed-write reconciliation. None is necessary to remove the demonstrated whole-checkpoint string ceiling. The existing JSON layout, journal framing, hash chains and Vitest full-diff cross-checks remain unchanged. Large opens assemble top-level arrays one record at a time, hash the exact bytes being decoded and run the same store validation before publishing. Small files (up to 8 MiB) keep the native JSON parser. Host snapshot/routing reads share the same decoder. Checkpoint writers already emit records in chunks.
The pre-build synthetic benchmark at 600 commits/day measured 300,403,245 bytes / 1,084.8 ms at 60 days, 450,219,292 / 1,623.4 ms at 90, and 900,484,451 bytes / string-limit refusal at 180. The synthetic replacement for the deleted original seed uses its commit/issue/comment/receipt proportions, but different payloads; it is about 12.5% larger than LOCAL-VUH-47's 60-day checkpoint. Decision, after measurements and rollback evidence are in the measurement record.
Previous journal builds read/write the unchanged layout below their string ceiling; above it they refuse before writing. Doctor retains the old limit and half-limit warning as rollback headroom and points to the storage guide. Whole-store memory, individual record size and legacy export/cutover string ceilings remain. The unisolated stale-client FastJsonStringifier OOM in LOCAL-VUH-51 is not proven fixed.
LOCAL-VUH-49 review follow-up: preserve digest-before-decode on external journal appends. Hash the checkpoint Buffer first; a matching digest continues from the committed snapshot using only new journal records. Decode on mismatch or failed continuation. On the same 126.44 MB synthetic checkpoint, three external-append reads fell from 976/946/797 ms to 86/93/71 ms (higher load after). A real-process regression observes zero checkpoint decodes on that continuation, and a fresh large-open guard rejects whole-buffer string conversion. The measurement record retains the full results and the original 900 MB proof's scope.
