Skip to content

v3.0: separate delivery cursor from merge resolver, add workspace scoping - #1

Merged
pasichDev merged 18 commits into
mainfrom
v3.0
Sep 5, 2026
Merged

v3.0: separate delivery cursor from merge resolver, add workspace scoping#1
pasichDev merged 18 commits into
mainfrom
v3.0

Conversation

@pasichDev

Copy link
Copy Markdown
Owner

What this is

Three silent data-loss bugs in the sync layer, one latent lock-corruption bug, and the feature the tool was missing: workspaces.

Everything here is uncommitted work from one pass, split into two commits — code, then docs. It is deliberately not four stage-shaped commits: index.ts, web/api.ts and repository.ts each carry changes from several stages at once, so a file-granularity split would produce three commits that don't compile, and git bisect on a commit that doesn't build tells you nothing.

The bug this release is named after

updatedAt was doing two different jobs — resolving merge conflicts, and acting as the delivery cursor. mergeTodoFields copies the author's updatedAt onto the local record, so an item that reached B second-hand landed in B's store already timestamped in A's past, underneath A's cursor for B.

With A↔B and B↔C paired, and A↔C not, an edit made on C never reached A. No error, no log line.

Format v8 separates the two jobs. localSeq is a per-device counter stamped on every local write — including accepting a peer's change, which is the actual fix.

Bugs found and fixed

Bug How it was found
1 Transitive propagation: a third device's edit is lost in transit Failing test written first
2 First sync of a store >1 page silently drops the remainder Failing test written first
3 Two processes can hold the file lock simultaneously Failing test written first (ENTER b / ENTER a — both inside)
4 A lagging clock stamps edits before the version they replace, so every peer discards them Convergence property test
5 Winning a merge never tells the loser — both sides keep their own value forever Convergence property test
6 The tie-break isn't total: same deviceId on both copies means neither adopts Convergence property test
7 docket restore strands every peer's cursor Review
8 A newer deletion of a resurrected item is never sequenced, so it never propagates Review — this is a hole in the spec, not just the code
9 sourceLinkHtml renders a clickable javascript: href Render-escaping test
10 A malformed request body returns 500 — "my fault, retry" for something no retry fixes API route test
11 CLI errors print a stack trace and "failed to start" for a command that started fine CLI smoke test

Bugs 4–6 were found by a property test over random topologies, operation orders and clock skew, run across 500 seeds. Two of them lived in lines the suite already covered — coverage measures which lines ran, not whether anything was checked.

The feature

Items file themselves under the project they were captured in, resolved from the git remote — so the same repo cloned to different paths on two machines is one workspace, which only matters because sync exists. todo_list defaults to the current project plus unfiled items, compact, one line each.

An empty scoped result now says what it is not showing (0 open in acme/backend — 47 open across 3 other workspaces). That isn't a bug fix; it's the feature's scariest failure mode. A host with an unexpected cwd resolves elsewhere, the list comes back empty, and "my data is gone" is the honest conclusion from where the user sits.

Plus a live session registry, a one-line routing hint, and a single Claude Code SessionStart hook that fails open in every failure mode.

Upgrading — read before merging

3.0 migrates the store v7 → v8 on first run. The migration is safe; downgrading afterwards is not. 2.3.1 cannot read v8 but will happily write it, stripping localSeq, workspace and seqCounter from every item. It is published and cannot be patched.

So the upgrade copies the pre-migration store to ~/.docket/todos.v7-pre-upgrade.enc, once, never overwriting it, and prints the path. To go back: docket restore --from-v7, then install 2.3.1 — in that order.

Testing

192 → 408 tests. Every bug above has a regression test named after the failure, and I verified each one fails with its fix reverted.

  • 500-seed convergence property test over random topologies, clock skew and partial syncs
  • Sequence invariant driven from the exported surface, so a mutator added later fails it by default
  • Hostile input from a peer: prototype pollution, localSeq of NaN/-1/Infinity, 100k history entries, javascript: URLs, forged tombstones
  • Corrupt files on disk: truncated, bit-flipped, wrong key, wrong format version
  • Two real processes contending on a lock; a suspended writer whose lock is reaped mid-operation
  • The dashboard's inline script executed in a sandbox, so views.ts is actually tested rather than only compiled

Mutation testing on sync.ts and mutations.ts — no surviving mutants in the merge logic; the ones that remain are equivalent or need a clock injected into production code purely so a test can hold it still, and each is documented in place.

Not done

  • Never run on real accumulated data. The rehearsal used 412 synthetic items (0 fields lost, 26 ms) — that proves the mechanics, not anyone's actual store.
  • Never run on a second OS.
  • No demo-workspaces.cast. I won't fabricate one; the README's central claim needs a real recording.
  • storage.ts and filelock.ts haven't been mutation-tested.

Recommendation: publish 3.0.0-rc.1 under --tag next first. The three convergence fixes changed merge and timestamp semantics late; they're backed by 500 seeds and regression tests, but they're new. An RC costs nothing and means the first stranger to install doesn't get a migration that has only ever run on fixtures.

…aths

`updatedAt` was doing two different jobs: resolving merge conflicts ("when did
the author change this?") and acting as the delivery cursor ("what have I not
seen yet?"). Merging copies the author's `updatedAt` onto the local record, so
an item reaching B second-hand landed in B's store already timestamped in A's
past, underneath A's cursor for B. With A<->B<->C paired and A<->C not, an edit
made on C never reached A at all.

Format v8 separates the two. `localSeq` is a per-device counter stamped on
every local write — including accepting a peer's change, which is the actual
fix. `updatedAt` keeps its merge-resolution job unchanged.

Also fixed, each with a regression test that fails without it:

- A first sync of a store larger than one page silently dropped the remainder:
  the payload was clamped and the cursor advanced past the gap anyway. Sync now
  pages, and the cursor advances only to what was actually merged.
- Two processes could hold the file lock at once — both judged it stale, both
  removed it, and the second removed the first's brand-new lock. Reaping is now
  an atomic rename with a compare-and-swap on the holder record.
- A lock held longer than the staleness window was reaped out from under its
  holder. Held locks now heartbeat, and a writer whose lock was reaped mid-
  operation detects it before committing and retries instead of clobbering.

A property test over random topologies, operation orders and clock skew found
three more, all of which lost data and none of which review would have caught:

- A device with a lagging clock stamped edits and deletions BEFORE the version
  they replaced, so every peer discarded them and the originating device was
  the only one that saw its own change.
- Winning a merge conflict never told the loser: our cursor had already passed
  their record, so both sides kept their own value forever.
- The tie-break was not total — two copies with the same deviceId each refused
  to adopt the other.

Other changes on the write path: history moves to history.json.enc, batched so
an actively-worked item does not rewrite the whole log on every edit; claim
renewals no longer write a history entry, which was the main source of growth.
Upgrading copies the pre-migration store to todos.v7-pre-upgrade.enc, because
2.3.1 cannot read v8 but will happily write over it, stripping the new fields
from every item.
The README led with "one shared workspace for your AI coding agents", which
described the mechanism rather than the reason anyone would want it. The
product is the layer underneath Notion, GitLab and Obsidian: work lands here at
the speed an agent can type, from any tool, in any project, before it has
earned a ticket. That space between vendors is the part no platform release can
take away, because no vendor has an incentive to build it.

- Lead with that sentence, then the three-project workspace picture.
- Promote sourceUrl from a field bullet to its own section. It answers "why not
  just use GitHub Issues", which the README never did.
- New docs/workspaces.md: resolution order, the .docket.json override, the
  monorepo and same-basename cases.
- docs/security.md gains two sections: the audit log's write ordering and what
  it can lose, and what sessions.json holds (absolute project paths, in
  plaintext, never synced) and why that tradeoff.
- Add an "Upgrading from 2.x" section, prominently. Downgrading after the
  migration needs `docket restore --from-v7` first; without it 2.3.1's first
  write strips the new fields from every item.

Honesty fixes that had been outstanding: the deployment-mode table now says
claims are advisory in Local/P2P and atomic in Self-hosted, and p2p-sync.md
carries a deprecation notice with the real reason — a 15-second pull interval
cannot deliver an atomic guarantee.

The skill is renamed from docket-claim to docket, since it is no longer about
claiming, and split so the always-loaded part is one sentence instead of ~1200
tokens of field reference.
@pasichDev

Copy link
Copy Markdown
Owner Author

Ran the v7→v8 migration against a real, accumulated production store (not a fixture) — 99 live todos + 4 tombstones, built up over normal day-to-day use.

Result: clean.

  • Pre-migration store diffed field-by-field against the post-migration store (title/description/category/priority/list/done/workingAgent/createdAt, plus every uuid).
  • 0 uuids lost, 0 added, 0 field mismatches.
  • Every record got a non-zero localSeq; seqCounter landed at exactly todos + tombstones (103), matching the backfill design.
  • todos.v7-pre-upgrade.enc was written once, before the first v8 write, and matches the pre-migration bytes.
  • A stale already-running docket web process (compiled before the upgrade) correctly refused to read the v8 store instead of guessing — confirms the fileVersion > CURRENT_FORMAT_VERSION guard behaves as intended in a real "forgot to restart everything" scenario, not just in tests.

No task content or counts beyond what's above are included here, since the store is a real personal/work list.

This addresses the "never run on real accumulated data" item under Not done — closing that gap with a real result rather than another synthetic fixture. +1 for shipping 3.0.0-rc.1 under --tag next first regardless, per the PR description's own recommendation.

@pasichDev

Copy link
Copy Markdown
Owner Author

Went through the PR in more detail. Overall the direction looks really solid, especially separating localSeq from updatedAt, paginated sync, store epochs after restore, and workspaces. There are a few things I’d still fix before the RC though.

  1. We’re basically trusting payload.maxSeq from the peer right now. If a buggy or compromised peer sends a huge or invalid value, we can advance the cursor too far and silently stop receiving future records. I’d add strict sync envelope validation before merging or advancing the cursor.

  2. updatedAt, createdAt, and fieldTimestamps coming from peers aren’t validated enough. Something like not-a-date can make it into the store and then a normal edit/delete can blow up on Date.parse(...).toISOString(). We should either reject those records or normalize timestamps before persisting them.

  3. There’s still a race between history.json.enc and the optimistic store retry. flushOverflowHistory() runs before the final store stamp check in saveStore(). If the lock gets reaped and the write retries, the history sidecar may already have been modified. This is especially risky for pruning after a delete.

  4. Restoring an older backup that doesn’t contain history.json.enc leaves the current history sidecar on disk. That gives us an old todo store with newer history. On restore I’d also move aside/reset known state files that aren’t present in the backup.

  5. Workspace slugs derived from git remotes only use the last two path segments and ignore the host. That can collide pretty easily with GitLab/self-hosted repos that happen to share the same group/repo.

One smaller thing: if we’re actually going with 3.0.0-rc.1, package.json is already set to 3.0.0, and the updater’s version comparison doesn’t really handle prerelease semver.

I’d close these out, add regression tests for each case, and then publish 3.0.0-rc.1 under next. Other than that, the PR looks very solid.

@pasichDev pasichDev self-assigned this Sep 4, 2026
…y hid in

Six issues from PR review, three sync bugs found by running real servers against
each other, and the decomposition that makes the next one easier to find.

## Sync correctness

- A peer's `maxSeq` is no longer taken on trust. The delivery cursor is the one
  piece of sync state where a wrong value is both silent and permanent: advance
  it past records that were never sent and this device stops asking for that
  range forever. A page that carried records can no longer promise more than its
  highest record, and a maxSeq that is not a sequence number is rejected.

- Peer timestamps are validated before they enter the store. Shape alone was not
  enough: "2026-13-45T99:99:99Z" matches the ISO pattern and still parses to NaN,
  which made `new Date(Date.parse(x) + 1).toISOString()` in mutations.ts throw
  RangeError on the next ordinary edit — long after the sync that accepted it.

- A restored peer no longer costs a whole sync tick. The epoch check compared
  against the value read off disk when the tick began, which nothing updated as
  it ran, so every page after the first looked like another fresh restore and
  reset the cursor to zero again. The tick ping-ponged 0 -> PAGE_SIZE -> 0 until
  it ran out of pages, and anything above the first page waited for the next one.

- A pull that fails partway keeps credit for the pages that landed. `lastSeq` was
  only persisted on success, so a tick that merged four pages and then lost the
  connection re-fetched and re-merged them next time — a peer that fails late
  every time would never converge.

The last two were found by two real servers on a loopback network, stepped by
hand, not by reading the code.

## Storage and workspaces

- History pruning moved after the store commit. withStore's write is optimistic
  and retries; a prune done before the commit could delete the audit log of an
  item the winning write had kept alive. Appending still happens before, which is
  what buys the crash-safety ordering.

- Restoring a backup without history.json.enc no longer leaves the current one in
  place. Only that file is swept aside — peers.json.enc is independent state, and
  clearing it would silently unpair every device.

- Workspace slugs now include the git host. "owner/repo" alone collided whenever
  two forges shared a namespace. This changes existing slugs: acme/backend becomes
  gitlab.com/acme/backend.

- compareVersions implements SemVer §11. It split on "." and ran Number() over the
  parts, so "0-rc" became NaN and every comparison against it fell through to the
  "greater" branch: 3.0.0-rc.1 compared as newer than 3.0.0-rc.2, which would have
  offered an RC user a downgrade as an update.

- The MCP server no longer spawns a dashboard per session when DOCKET_WEB_PORT is
  not a fixed port. Port 0 means "any free port", which the probe can never find
  again, so every session spawned another detached one. Nineteen accumulated on
  one machine before it was noticed.

## Dashboard

Cards lead with the title; the meta row moved below it and stopped repeating what
the filter row already says. Descriptions render markdown, previewed at 300
characters with the full item in a modal; editing moved into a modal too, with a
formatting bar and a preview. Clicking an id copies it. The project switcher is a
select in the toolbar rather than a second row of pills that opened with its own
"All". The header shows a real spinner while a device sync is in flight, driven by
a server-sent event rather than guessed from the dashboard's own polling. An
address typed without a port now assumes 8787 instead of failing with a 502 from
port 80.

## Decomposition

One commit, because the pieces do not separate: web/routes imports sync/, and
server/routes imports web/http, so any smaller split leaves commits that do not
compile — and a commit that does not compile tells `git bisect` nothing.

- sync.ts (892 lines, five concerns) -> sync/{peering,auth,payload,sanitize,merge,client}.
  No barrel: every importer names the submodule it needs, or the coupling survives
  under a new name.
- handleApiRoute (792 lines, 28 sequential if-blocks whose numbered comments were
  doing a module boundary's job) -> web/routes/*, with a dispatch table.
- views.ts (2,588 lines, one template literal no tool could see into) -> web/client/*.
  This does not make the client type-checked — it is still text until a browser
  parses it — but a stray backtick now breaks one ~200-line file instead of the
  page, and views.backtick.test.ts names the line.
- Two different things called "pairing" are now peering (device-to-device) and
  enrolment (device-to-server); the code alphabet they share moved to short-code.ts.

Extracting web/http.ts fixed a real seam: server/routes.ts was importing json()
from the dashboard's route module, pulling in the whole table to get a helper.

## Tests

192 -> 449. New: sync envelope validation, hostile peer timestamps, the epoch
regression, v1-peer fallback, a response cut off mid-body, a peer that dies
partway through a backlog, markdown rendering and its escaping, the two card
de-duplication rules, SemVer precedence, and a guard for backticks inside the
page template. Each fix was verified to fail with the fix reverted.
Publish with `npm publish --tag next`, so `latest` keeps pointing at 2.3.1 until
this has run on machines that are not the author's.

The version is a pre-release for a reason: three convergence fixes and three
cursor fixes changed merge and delivery semantics late, and while each is backed
by a regression test and 500 property-test seeds, none of them has yet been wrong
in a way anyone would notice quickly — the failure mode of all six is silence.

CHANGELOG covers what changed since the branch was cut, including the one entry
that can cost someone visible state: workspace slugs now carry the git host, so
items filed under the old name stay under it and show up as a separate project.
The client was 1,676 lines of JavaScript inside a template literal, which no
compiler, linter or editor could see into. That is where this release's UI bugs
came from, every one of them found by loading the page rather than by a tool: a
backtick in a comment closing the string (four separate times, once inside the
comment explaining the hazard), bare `ul`/`li` selectors silently restyling the
lists inside rendered markdown, a min-height lost to an equal-specificity rule
sixty lines further down.

It is now 13 modules in src/web/client/app, compiled against the DOM lib and
loaded by the browser as native ES modules. No bundler and no new dependency —
`tsc` twice and one route that serves the output.

## Three tsconfigs, deliberately

  tsconfig.json             node, no DOM   — server code cannot touch `document`
  tsconfig.client.json      DOM, types: [] — client code cannot touch `process`
  tsconfig.client-test.json both           — a test drives DOM code under node

Splitting them is the point: each half now fails to compile if it reaches for
the other's globals, which a single shared config would have allowed silently.

## What the compiler found immediately

118 errors on the first pass, and they were not ceremony: unchecked
`querySelector` results, `e.target` used as an element without narrowing, lookup
tables indexed by an arbitrary string. Every one was a place the old string
could have thrown at runtime and nothing would have said so until a user clicked
it.

## A real bug, found by loading the page

Saving an edit did not refresh the list. The PATCH went out and the server
stored it, but no GET followed, so the card kept showing the old title until the
next 15-second poll. The refresh hung off the dialog's `close` event; it is now
explicit on the save path, with the close handler keeping its own refresh for
Cancel and Escape — those genuinely need it, since refreshes are suppressed for
as long as the dialog holds the screen.

## Structure

The pure modules — util, markdown, cards, types, state — touch no DOM at module
scope, which is why render.escaping.test.ts now simply imports them. It used to
extract the page's inline script with a regex and run it in a `vm` sandbox
behind a Proxy-based fake DOM: a test harness reimplementing a module loader,
and the clearest evidence that the client needed to stop being a string.

main.ts is the only module with side effects at import; everything else exports
an `init…()` it calls.

api.ts declares what the dashboard's own endpoints answer with. Those were
untyped literals straight out of `await res.json()` — exactly where a rename on
the server becomes a blank panel in the browser with nothing in the console.

views.ts is 2,588 lines shorter (142 now) and holds what no compiler can check
in any arrangement: the stylesheet and the markup. The page is 124KB -> 53KB.

## Serving it

/client/*.js is matched by an allowlist pattern rather than normalised, so there
is no traversal to reason about on any platform; `../`, a subdirectory and a
capitalised name all 404. Cached as no-cache rather than immutable: the
filenames are stable across versions, so a cached copy would otherwise survive
an upgrade and pair a new page with an old module.

## Also

- `build` now clears dist first. A stale compiled test left behind by a rename
  ran against deleted code and failed in a way that pointed nowhere.
- views.backtick.test.ts is scoped to the two files still written as template
  literals. The client's own code no longer needs it: an unbalanced backtick
  there is now an ordinary syntax error with an ordinary position.

Verified beyond the suite: 22 interactions across the real dashboard with no
console errors, and create/edit/toolbar/preview/copy/delete/undo on a scratch
instance. Not verified: the Cancel and Escape paths out of the edit dialog —
`close` events are not delivered at all in this automation context, including to
a freshly created dialog, so that one needs a human click.
`docket devices pair|approve|revoke` is backed by /api/v1/admin/devices, which
was gated on the request arriving from 127.0.0.1. The reasoning was that the
operator on the server machine is the trust boundary — sound, right up until
something else on that machine forwards requests to it.

Which is exactly what the docs tell people to do. docs/headless.md recommends a
reverse proxy for HTTPS and gives a worked example:

    todo.home.example { reverse_proxy 127.0.0.1:8788 }

Every request Caddy forwards reaches docket from 127.0.0.1. So anyone on the
internet could mint a pairing code, approve their own request, and walk away
with a fully authorised device against the authoritative store — read and write
on the whole shared workspace. The check was not wrong about loopback; it was
wrong to treat a network property as an identity.

The admin routes now require a secret written to `admin-token` in the data
directory at mode 0600. `docket devices …` reads it because it runs as the same
user on the same machine; a request that merely arrives through a proxy cannot.
The loopback check stays as defence in depth rather than as the boundary, and
X-Forwarded-For is deliberately not consulted — it is set by whoever spoke to
the proxy, so trusting it would hand the decision straight back to the caller.

The regression drives all six admin routes three ways: no token, a forged token,
and a spoofed forwarded-for header, all 403; then the real credential, 200, so
this is a lock rather than a wall.

docs/headless.md now says plainly what a proxy does to source addresses, and not
to forward /api/v1/admin/ or put the token in a proxy config.
All four share a shape: something is refused, dropped or reordered, and nothing
downstream notices. That is the class this release exists to close.

**The delivery cursor stepped over records the sanitiser refused.** A record
rejected for a malformed timestamp still occupies a position in the peer's
delivery order, and the cursor was computed from the raw page — so the next
request started above it and it was never asked for again. Permanent, silent,
and precisely what localSeq was introduced to prevent.

The merge now reports the lowest sequence it refused, and the cursor stops below
it. Everything under that still lands and still counts; the bad record is
re-requested each tick and named on the peer record, which is loud rather than
lost. Rejecting the whole page would have been simpler and would have thrown
away the valid records underneath the bad one.

**A tombstone's `deletedAt` was any string at all.** Deletions are resolved by
string ordering, so `"zzzz"` sorted above every real ISO timestamp and produced
a deletion that no later edit from any device could beat — an item that could
not be brought back.

**Timestamps at the ISO boundary were accepted.** mutations.ts keeps timestamps
monotonic with `Date.parse(x) + 1`, and one millisecond past
9999-12-31T23:59:59.999Z is year 10000, which JS serialises as
`+010000-01-01T…`. No Docket accepts that shape, so this device could edit a
record into something the next device would refuse — and a refusal that the
cursor mishandled was the first bug above. The test is now the invariant itself:
a timestamp is acceptable only if stepping it forward still produces one.

**Releasing a file lock read the holder record, then unlinked.** A process
suspended between those two steps — a closed laptop lid — wakes to find its lock
long since reaped and a new holder in place, and then deletes that holder's
lock. Release now claims the file by rename first: atomic, single-winner, so
whatever it moves aside is the file it is entitled to judge. The window is gone
rather than narrowed, and the regression pauses exactly where it used to open.

Two more, same family:

- A server backup did not include `devices.json.enc`. docs/headless.md tells
  operators to take that backup before an upgrade and restore it if the upgrade
  goes wrong — and doing so produced a server with the right todos, the right
  identity, and no memory of which devices were allowed to talk to it.

- Workspace slugs kept only the last two path segments, so
  `team-a/platform/backend` and `team-b/platform/backend` collapsed to one slug
  and two teams' lists merged. The whole repository path is the identity now;
  this changes existing slugs again, for nested groups only.

And one that was loud in the wrong direction: a protocol-v1 peer with more
records than a single merge can accept can never finish, because v1 does not
page and the legacy cursor correctly refuses to advance past a clamped payload.
It reported success on every tick forever. It now reports the incompatibility
and says which version fixes it.
**CI was green over a directory it never executed.** The workflow duplicated
`npm test`'s glob by hand, and the copy stopped matching the browser-client
tests the moment those moved into dist/web/client/app during the TypeScript
refactor. Nothing failed, because nothing ran. It calls `npm test` now, which is
the only version that cannot drift.

**`docket check-update` could not see the next RC.** A release candidate is
published under `next` so that `latest` keeps pointing at the last stable build
— which meant someone on 3.0.0-rc.1 was told 2.3.1 was the newest thing
available, and never heard about rc.2. The channel that most needs update
checking was the one channel without it.

It now follows the channel its own version came from, and asks about both when
that channel is `next`: a missing `next` tag (nothing published yet) must not
break the check, and `next` lags behind `latest` once a release ships, so an RC
should also hear about the stable build that supersedes it.

Also:

- The sync incompatibility error told users to `npm install -g docket@latest`.
  The package is `@pasichdev/docket`; that command installs someone else's.
- `qs` is pinned past a moderate advisory. It reaches us only through the MCP
  SDK's `express` dependency, and Docket loads the stdio transport only — so it
  is not reachable here. Pinned anyway: a package that ships with a red audit
  teaches its users to stop reading audits. Upgrading the SDK to 1.30.0 does not
  help; the fix is upstream in express.
The new reverse-proxy regression killed its server and removed the data
directory in the same breath, which on a slower machine is ENOTEMPTY: the
process is still on its way out, and `docket serve` writes admin-token at
startup, so there is more in that directory than the test put there.

This file already has cleanup() for exactly this — wait for the child to exit,
then remove with retries. The new test simply did not use it.

Found by CI on the first run that actually executed this test, which is the
run where CI started calling `npm test` instead of a hand-copied glob.
The release audit's five foundation blockers, all of them variants of one
thing: a write that reports success while losing state.

B05 — "temp file + rename" is an ATOMICITY guarantee and was being relied on
as a DURABILITY one. Rename means no reader sees a half-written file; it says
nothing about whether the data or the directory entry reached the medium
before the power did. New src/fs-atomic.ts fsyncs the file before the rename
and the directory after it, and every persistent write now goes through it —
including three that had no temp file at all: the user's Claude settings.json,
each MCP host's config, and the backup bundle itself, where "it printed
success" and "it is on the disk" must not be able to disagree.

B04 — first-run secrets were read-then-write, so two processes starting
against an empty data directory (an MCP session and the dashboard it spawns:
the ordinary case) each minted their own. For the at-rest key that is not
cosmetic — the loser encrypts with a key that is not on disk, and everything
it writes is unreadable by anyone, itself included, after a restart.
atomicCreateOrRead settles the winner with an exclusive create and hands the
losers the value that actually landed; the at-rest key, the store epoch and
the admin token all use it.

B02 — the advisory lock cannot stop a suspended process from being reaped: a
laptop that sleeps mid-hold wakes still inside its critical section. storage.ts
detected that with its content stamp; peers, viewers, server devices, remote
credentials and this device's own identity simply wrote. withFileLock now
hands every callback a Lease, and src/registry.ts gives the registries the
same two guards the store has — identity, then content hash — with a bounded
retry against freshly loaded state.

B03 — that stamp was (mtime, size), which is almost always right, and the
guard exists precisely for the rare case where almost right is wrong. Two
ciphertexts of one store very often share a LENGTH, and mtime resolution is one
second on several filesystems. It is a SHA-256 of the bytes now.

B01 — releaseLock restored a claimed record with rename, which silently
replaces. Between the claim and the restore the lock path is free, so a third
process can legitimately acquire it and have its brand-new lock deleted. link()
refuses to clobber, which is the whole difference.

Tests: 12 new ones across filelock.race, storage.race and fs-atomic, each
verified to FAIL against the pre-fix mechanism — including a three-process
restore, a same-size same-mtime interfering write, a torn-read detector, and
twelve real processes racing on an empty data directory behind a wall-clock
barrier. 469 pass.
B06 — createBackup read the data directory's files one after another with
nothing held, so a bundle could pair a todos.json.enc from before a sync with
a peers.json.enc from after it. Each file is individually valid and nothing
detects the mixture; it surfaces much later as a peer whose cursor points past
records the restored store never had. It now reads under every relevant lock,
in canonical (sorted) order, and records a per-file SHA-256 manifest so a
restore can tell "this bundle is intact" from "this bundle decrypted".

B07 — restore replaced `key` and then each ciphertext file in turn. A crash in
the middle left the NEW key beside SOME of the OLD ciphertext: unreadable, and
unreadable in a way no later run could diagnose or undo. It is now
stage → journal → commit → clean. Nothing is touched until every file has been
validated against the manifest and staged; the journal names the whole set
before the first move; and the commit is idempotent — a staged file that is
still there has not been applied, one that is gone has — so an interrupted
restore is finished on the next start. Every entry point (MCP, web, serve)
calls the recovery hook before its first read.

B08 — every long-running process caches what it can never re-derive: the
at-rest key, this device's identity, the store epoch, the admin token. All four
are silently wrong the moment a restore replaces the directory underneath, and
one more write from a `docket serve` still holding the old key encrypts part of
the store under a key that is no longer on disk. The directory now carries a
generation id; a process pins it at startup and every commit in storage.ts and
registry.ts re-checks it, alongside the lock lease and the content stamp. It is
deliberately not retried — there is no fresher state to retry against, the
process itself is stale. `docket restore` also names what is still running and
refuses without --force; the probe compares generations rather than trusting a
port, so a second install's dashboard is not mistaken for a holder.

B09 — history overflow was appended to the sidecar BEFORE the store commit, so
an attempt that lost its optimistic-concurrency check re-ran the mutation and
left the failed attempt's entries in the log for ever. A permanent record of an
edit that never happened, in the one file whose entire job is to be trustworthy
about what happened. The store now commits first; the sidecar append and the
inline trim follow under the same lock, as a second commit, and nothing past
the first commit may throw into the retry loop.

Tests: 8 new, verified to fail against the pre-fix code — a coherence check
across twelve backups taken under a concurrent coupled writer, an exhaustive
sweep of every commit boundary an interrupted restore can stop at, a real
child process that pins a generation and is then refused, and a phantom-event
check that forces a lost race after an overflowing mutation. 477 pass.
…10–B18)

B10 — `copyTodos` re-created every item through the ordinary create()/complete()
calls and called the result a migration. What arrived on the far side was a set
of NEW items: new uuids, so every paired device saw the workspace deleted and a
different one appear; today's timestamps, so the chronology went; no history;
and, once v3 made project structure the centre of the product, no workspace
either, so everything landed in Unfiled. All of it silent. New src/snapshot.ts
defines a versioned WorkspaceSnapshot carrying uuid, workspace, content,
chronology, completion, revision, provenance, per-field timestamps, full
history and tombstones. Claims are cleared as an explicit policy — a claim is a
statement about a running process, and moving a store does not move the process.

B11 — a migration is a network operation and networks fail halfway. The old
per-item loop left both sides populated and the retry refused to continue,
telling the user to repair it by hand. A snapshot carries a migration id; the
destination records what it has applied (src/server/migrations.ts) and is
idempotent by uuid besides, so "run it again" is correct at every failure point.
Two new endpoints, GET and POST /api/v1/snapshot, authenticated as any other
paired-device call.

B12 — `backend localize` renamed todos.json.enc aside and rebuilt the sequence
space from 1, leaving three things pointing at a store that no longer existed:
the uuid-keyed history sidecar, the store epoch (so every peer's cursor sat
above the new high-water mark and that device was never heard from again), and
this device's own cursors into its peers. replaceStoreSnapshot() in storage.ts
now owns all of it, including a new resetPeerCursors().

B13 — the dashboard is spawned detached and runs a sync tick on a timer, so
switching to remote left it serving and syncing into a store that was no longer
the source of truth. Both transition paths stop it and verify it exited, and
say so plainly when they cannot.

B14 — `list`, `stats`, `workspaces` and `export` read local storage in remote
mode, and `import` WROTE to it: the CLI in a terminal showed an empty list while
the editor showed the real one, and an import reported success for items that
existed nowhere the user could reach. Reads now go through the mode-aware
service; import parses into a scratch store and sends a snapshot.

B15 — setup baked DOCKET_MODE into each host's config, and the resolver's
priority is env > config, so `backend localize` could update the central config,
report success, and leave every agent talking to the server. Setup writes no
deployment environment at all now; the central config is the source of truth and
the env override remains for a container or a single command.

B16 — a custom dataDir lived only in host configs and a shell rc, so `docket
backup` in a plain terminal backed up an empty ~/.docket and reported success.
dataDir is a config field now, resolved identically everywhere, and `docket
status` says which source is in force.

B17 — `setup --remote` wrote remote mode without ever looking at the local
store: a user with a year of todos saw success and an empty workspace, data
still on disk but no longer part of the product. It now offers upload / keep /
cancel, and refuses to guess non-interactively.

B18 — generated host invocations were unpinned, so an RC's own setup configured
hosts to launch whatever `latest` resolved to: v2 code against a v3 data
directory. Every generated invocation pins the exact running version.

Also: writeDeploymentConfig rebuilt the config from a fixed key list, silently
dropping fields a newer docket had written. It is a real read-modify-write now.

Tests: 8 new, including a full e2e migration against a real `docket serve` over
HTTP that checks identity, project, chronology, completion and audit log on the
far side, a retry after a mid-transfer failure converging on exactly one copy,
and a spawned CLI in remote mode proving no local store is created. 492 pass.
B24 — findTodoByAnyId returned the FIRST short-id match. The short id is six
characters over a 31-character alphabet, so on a shared list that lives for
years a collision is not a thought experiment, and the consequence was not a
display bug: `todo_complete T-7K2F9A` would silently complete somebody else's
task, leaving an audit entry on an item nobody touched. It now collects every
match and throws AmbiguousTodoIdError naming both uuids; both HTTP surfaces map
that to 409 rather than 500, since the request was well-formed and the fix is
the caller's.

B25 — setup wrapped its config parse in a bare catch labelled "new file", so a
trailing comma (or a permissions error) in ~/.cursor/mcp.json meant every other
MCP server the user had configured was replaced by a fresh object containing
only docket. A config that exists but cannot be read is never an absent config
now: it is left byte-for-byte alone and reported. Configs that CAN be read keep
their unknown fields and get a .docket-backup beside them, and the CLI-managed
hosts (codex, claude) capture the existing entry before the remove that `mcp
add` requires, so a failed re-add says what to run rather than leaving no docket
entry at all.

B26 — after an upgrade the old detached dashboard is still on the port, and
auto-start accepted any 200 as "already running". /api/version now reports
product, packageVersion and pid; a dashboard from a different build is stopped
and replaced, one that is not docket at all is left alone, and a version match
is adopted as before. Also adds `docket --version`, which the release gate needs
to assert the packed artifact is the version that was packed.

B21 — the Dockerfile copied only tsconfig.json while `npm run build` runs three
compilers. The documented build path could not have worked; nothing in CI ever
ran it. B22 — docs tell operators to run `docker compose exec docket docket
devices pair`, and the runtime image had no `docket` on PATH at all. B23 — the
default bind mount to ./data is created host-owned at 0755, which the image's
unprivileged user cannot write; the compose file uses a named volume, and the
host port is overridable for a machine already running `docket serve`. All three
verified on a real Docker host: build, the exact documented pairing command
inside the container, non-root write, and data surviving a restart.

B19 — `npm publish` with no --tag makes npm's default dist-tag `latest`, so
publishing 3.0.0-rc.1 would have pointed every unpinned install and npx in the
world at a release candidate, the update checker included. The tag is now
derived from the version and verified after publishing.

B20 — the release job built and published. A tag on any commit on any branch
became a release, gated only on tsc succeeding. Publishing now needs a verify
job that proves the tag is an ancestor of main, that it matches package.json,
and that the full suite, npm audit, a packed-artifact smoke test in an isolated
HOME, the server metadata version and a Docker build-and-pair all pass on the
exact tagged tree — then a protected `release` environment for the credentials.

B27 — the ">=18" engines claim was evidenced by one moving lts/* on Ubuntu. CI
now runs Node 18/20/22/24 on Linux plus a macOS job, and adds the pack smoke and
the Docker build. Verified locally in containers: all four versions pass.

Also: createBackup took its five locks but never re-checked that it still held
them, so a process starved past the staleness window could assemble a bundle
from two moments and report success. It asserts every lease after the read and
retries, which is the same guard every other commit path in this codebase has.

Tests: 15 new across short-id collisions (found with real colliding uuids, not
stubs), installer safety, and daemon replacement. 507 pass on Node 18, 20, 22
and 24.
…posed

The screenshots still showed the pre-3.0 card layout — the one where the id
badge wrapped and the title sat on a second row behind it — and none of them
showed anything v3 actually added. Four now, all of the current build against a
seeded workspace: the list in both themes, one item opened with its Markdown
description rendered and its history showing who claimed it, and the same item
in the editor with Write/Preview.

docs/assets/demo-seed.mjs builds that workspace, so the shots can be regenerated
after any UI change without anyone inventing plausible-looking content again.
Two projects, an unfiled thought, a claimed item, completed items, categories,
priorities, due dates and a description long enough to clip — because every one
of those is something the card layout has to handle, and a picture of six
identical one-line items proves none of it.

Running it is what found the real bug in this commit. The script deleted its
scratch data directory and restarted while the previous run's dashboard was
still listening; that process kept its old at-rest key, wrote the store into the
recreated directory, and the new key beside it could not decrypt a thing. The
generation guard from B08 should have stopped exactly that, and did not, for two
reasons:

  - it was gated on "has this process already pinned a generation?", and nothing
    in an MCP session or a `docket serve` ever asks for one — so it was a no-op
    for precisely the long-running writers it exists to stop. Only the dashboard
    pinned one, because it reports the generation on /api/version. The first
    call now pins instead of checking, which puts the baseline at the moment a
    process first commits;
  - an absent generation was treated as a pass, to cover data directories
    predating the file. That case is covered by the pin itself, which mints it.
    A generation that existed and is now gone means the directory was replaced
    underneath a process still holding its key, and that is a mismatch.

Two regression tests, both failing against the previous guard: a writer that
never mentions a generation, and a directory wiped underneath a running process.

Also: demo-setup.sh's --clean ran `pkill -f dist/web.js`, which kills the
dashboard serving the user's real data directory along with the demo's. It now
asks the demo's own port which process to stop. 509 tests pass.
The docker job failed with exit code 141 and no explanation of what was
actually wrong, because nothing was wrong: 141 is 128 + SIGPIPE.

`docker exec … docket devices pair | head -1 | grep -qE …` — `head -1` closes
the pipe after the first of that command's four lines, the writer gets SIGPIPE,
and `set -o pipefail` reports the pipeline as failed even though the assertion
it was making had already passed. `grep -q` does the same thing, exiting the
moment it finds a match; those pipelines only survived because their output was
small enough to fit in the pipe buffer, which is luck rather than correctness.

Every one of these now captures the output into a variable and matches it with a
here-string, so nothing closes a pipe on a process that is still writing.
Verified locally against a real built image and a real packed-artifact run.
It sat in the loop that runs every read-only CLI command and asserts exit 0. It
passed for a year on machines that could reach npm, then failed on a macOS
runner that could not — reporting an unreachable registry, exactly as designed.
The run after it passed again, which is worse: the test is a coin toss on
network reachability, and the coin would have come up tails during a release.

Weakening the command to keep it green would be the wrong repair. "I could not
check whether you are up to date" is not success, and a script acting on the
answer has to be able to tell those apart. So the test now accepts both
outcomes and checks what must hold either way: that it says something
intelligible, and that a network failure is a sentence rather than a stack
trace. Verified in a container with no network, where it reproduces the CI
failure exactly against the old test.
…again

The convergence property test had been failing intermittently on CI for a while
and I had twice written it off as a flake, because it never reproduced. That was
the actual problem: the test drew its topology, its operations and its clock skew
from a seed, and then read the REAL clock for timestamps. Whether two operations
landed in the same millisecond therefore depended on how fast the machine was, so
a seed that failed on a macOS runner passed everywhere else. A property test whose
failures cannot be reproduced is worse than no property test — it trains everyone
to re-run the job.

The clock is part of the seed now, and the sweep is widenable from the
environment. With that, 20,000 seeds at three clock granularities found real
non-convergence, and it turned out to be two separate faults:

A GENUINE BUG, in the merge. When a tombstone loses to a newer local edit the
item correctly survives — and the peer that deleted it was never told. Our copy
keeps the sequence number it had when that peer last saw it, which is below their
cursor, so it is never sent again. One device shows the item, the other shows it
deleted, both report a successful sync, and nothing further can repair it because
there is nothing left to send. The todo loop already had exactly this rule for a
refused edit ("winning a merge is when the other side most needs to hear from
us"); the tombstone loop did not. It is two devices and one slow clock away, not
exotic.

Proven by a deterministic two-device test rather than by the random sweep, which
does not happen to generate the ordering: A edits, B receives the edit, B then
deletes on a clock running five seconds behind, A keeps its newer edit — and B
must get it back. That test fails against the previous merge.

A MODELLING ERROR, in the test. `withSkew` moved the record's `updatedAt` onto
the device's clock but left `createdAt` on the unskewed one, which describes a
machine that cannot exist and put creation times up to 90 seconds AHEAD of the
record's own edits. A field with no per-field timestamp falls back to `createdAt`,
so an untouched field became unbeatable and a genuinely newer edit lost every
comparison for ever. The test was reporting a failure the code could not produce.

After both: 20,000 seeds × three clock granularities, all converging. 510 pass.
The CHANGELOG has described this version as unreleased since the second review
pass; the audit remediation and the sync fix land in it too. package.json,
package-lock.json and server.json now agree, which the release gate checks
before it will publish anything.

Publishes to the `next` dist-tag, so `latest` keeps pointing at 2.3.1 until
this has run on machines that are not the author's.
@pasichDev
pasichDev merged commit 35eb7eb into main Sep 5, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant