docs/adr/0226-one-tracker-tool-surface.md

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.

CallerActorChain
Fleet or granted worker (clankie_call)agent-worker, its grant principalowner → Clankie
Clankie's operator toolsagent-worker clankie, turn modelowner
Clankie in a social roomagent-worker clankie, turn modelnone
clankie work on the operator credentialagent-worker clankieowner
Owner's explicit clankie work ownerhuman ownernone
Owner's verify/gate answer (ADR 0245)human owner, via: owner_asknone
Owner write from an app deviceapp device:<id>owner
Owner app verdict (ADR 0299) eventshuman owner, via: owner_appnone
Standalone use with no hostthe store's visibly local usernone

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 updatedAt than 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:

  1. Mirror. It disables the scratch store's webhook mirror.
  2. Store. Under both store locks, the live tracker/tracker.json's exact bytes go to tracker/cutover/<time>/before.json. The copy then replaces the live store, marked linearMirror.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).
  3. Binding. It sets the World project's trackerProjectId to the imported project UUID (VUH-1969), which is Linear's project UUID.
  4. Convention. It switches the project's .clankie/tracking.json from backend: linear to backend: builtin. The linear block 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:

  1. restores the convention's exact pre-cutover bytes;
  2. restores the previous trackerProjectId, or none;
  3. keeps the pilot store as tracker/cutover/<time>/pilot-<time>.json;
  4. 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 explicit repo. Writes land in the built-in store with the worker as the actor. Writes outside the project are refused, and so is linear_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 a trackerRef counts as linear when every saved convention in its local workspaces is linear, as with today's clankie project.
  • 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_graphql mutations whose document or variables carry such a reference;
  • Linear-only writes, such as create_worker_comment or 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_call writes 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 builtin project writes without repo into the built-in store as itself; a cross-project write is refused; a worker on a linear project, bound or unbound, reaches the Linear path (the local fallback in tests); a worker whose project has no saved convention is refused; and an explicit repo still wins.
  • VUH-2829, with Linear connected through a recording MCP boundary: a hire in a linear project and the lead comment on and move an imported Clankie Work item by key and UUID, and a LOCAL-VUH-n one; the lead's clankie work update from a Linear repo moves it as well. Every write lands in the built-in store under its actor. linear_graphql mutations naming it, inline or in variables, are refused before any request. linearHistory reads 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 existing sync subscribe path for one project, so its cursor, waitMs long poll on the in-process commit publication, and rebootstrap (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), as list_issues does. Every built-in project here belongs to one owner, so Sync 5 keeps that routing.
  • list_issues and list_ready_issues take snapshot: true. The first page fixes the whole result in service memory and returns its syncId and storeId; 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 own cannot corrupt committed state. The frozen record throws, and the write is refused with invalid_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.json stays 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 journal header 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:
    1. Under the lock: seal the live segments and start a new one.
    2. 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.
    3. 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. Move tracker.journal/ aside and put that file in place as tracker.json.
  • Restoring tracker.pre-journal-<ms>.json instead 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's fsync on 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 fullfsync it 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 own or replaceRecord. 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_issues views 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_issues after 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 doctor reports 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.