loadCollections already drops a response a newer load superseded
(collectionsFlight's isLatest), which is BUG-2144's fix, but nothing tested that
binding: with the drop removed, every store test still passed. The existing
superseded-load leg resolves every request with the same list, so it could not
see which one committed. This one answers each workspace with its own list and
resolves them out of order.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
The editor parsed `---\ntitle: x\ndate: y\n---` as a horizontal rule followed
by a setext heading, and serialized it back as `---\n\n## title: x date: y`,
a fixed point. Four blog posts are stored that way, and the blog-publish
contract reads the block.
A markdown-it block rule now turns a strictly recognised leading block into
the existing codeBlock node with language `frontmatter`: no new node type, so
the persisted Y.Doc shape and SCHEMA_VERSION are unchanged. It fires only for
a `---` at byte 0, a later `---` line, and only key: value / key: lines,
indented continuations or blank lines between, so a body that opens with a
horizontal rule stays a rule. The block serializes back as `---` only when it
is the first block and still passes the same check; otherwise as a
frontmatter fence, so an edit never re-flattens it. Every other language keeps
tiptap-markdown's default codeBlock spec.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
ImportWorkspace and RemapAttachmentReferencesInWorkspace each ran a
post-commit rebuildFTSForWorkspace: one autocommit INSERT INTO items_fts per
item. items_fts is an external-content FTS5 table kept by the items_fts_*
triggers, which had already indexed those rows inside the transaction. So the
pass double-indexed every imported item (FTS5 integrity-check reports the
table malformed), and it turned an N-item import into N write transactions
after the commit, which stalled every other writer on the database. Under
e2e load that stall outlasted the 30 s busy_timeout, which is why
version-restore-collab and workspace-bundle-roundtrip failed together.
The pass and both call sites are removed; the triggers are the index.
Migration 095 (SQLite chain only) drops and recreates items_fts and its three
triggers with 005's definitions, then runs FTS5 'rebuild', so it repairs
existing databases whatever state they are in. slowSQLiteMigrations logs a
line before it starts.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(store): NUL Layer B covers columns added after 084 through a later generated trigger file (BUG-3108)
The trigger generator had one hardwired target, 084, which runs before every
later migration's columns exist, so every post-084 column could only be
baselined. The trigger files are now an ordered list; each protected column is
assigned to one, 084 by default. The generator writes every file and the pin
test compares every file. The startup re-assertion wants and re-executes only
the files already applied, because it runs after every migration, including the
ones before a later column exists.
094 carries the three post-084 columns that hold caller text: items.lease_holder
(request body, verbatim), item_relation_links.source_field_key (a schema field
key) and item_decisions.model (the admin model pin). 084 is byte-identical.
TestNULTriggerFilesFollowTheirColumns walks the migration chain one file at a
time and fails when a column's trigger file does not run after the migration
that introduced it, naming the fix. The next post-084 column therefore fails
there instead of going quietly into the baseline.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(store): a NUL trigger restore does not re-execute a trigger file that is not yet applied (BUG-3108)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(store): item_relation_links.source_field_key stays out of Layer B on migration 088's derivation, now recorded as an exclusion (BUG-3108)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(store): ScanNUL's cost comment counts the two post-084 protected columns (BUG-3108)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(store): later trigger files get their own name prefix so an older binary cannot drop them; the ordering instrument takes a column's latest appearance (BUG-3108)
An older binary's restore reads triggers by GLOB 'pad_nul_*' and drops any it
does not expect. Had 094's triggers used that prefix, a binary predating 094
started with --force would have removed them all. Each later file now uses
pad_nul<NNN>_, and the restore reads one GLOB per file it knows.
columnIntroductions records the last time a column appeared after being
absent, so a column dropped and re-added, or a table renamed and replaced,
counts as introduced again. The instrument is parameterised so a synthetic
chain can show that rule going red.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(store): the source_field_key exclusion names the check that covers it, a decoded value (BUG-3108)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
Two parts, per the lead's ruling on Unit B.
1. Progress has one writer. ItemDetail recomputed computedOverrides.progress
from ChildItems' children list, racing the load's GET /progress, and the
two disagree. Measured on a private instance: children in a collection
whose done field is `stage` (board_group_by), one shipped and one not,
gave server 2/1/50% and client 2/0/0%, because the client read `status`.
Two of three plain tasks done gave 66% vs 67%. Whichever landed last won.
The client no longer computes it. A change in the children (ids or
statuses) within a load re-reads /progress, and the load's first report
does not, since the load already asked; if the load's own read failed,
that first report re-asks once. /progress reads are ordered by a ticket
and high-water mark, so an older answer landing last never wins.
ChildItems' header takes the same numbers through a new `progress` prop,
with NO local fallback: without them it shows the child count alone and
no bar.
2. A tab loaded hidden (session restore, middle-click, several at once)
mounts decisions, attachments, the comments timeline, children,
backlinks and relation backlinks only when it is first shown.
visibility.seenVisible flips once and never back, so nothing is ever
unmounted, and PLAN-2290's never-unmount rule stands. A visible load is
unchanged.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(scripts): install-refresh replaces the server listening on its port, by pid, and leaves other pad servers running (BUG-3194)
The script counted every `pad server start` on the box and refused on
more than one, so a sibling seat's e2e server (a worktree binary on its
own port) made the scripted refresh unusable whenever anyone ran e2e.
Its stop was a system-wide `pkill -x`, which would also have killed
that server and any `pad` CLI call in flight.
The target is now the `server start` process listening on the port the
server itself would resolve without a flag (PAD_PORT, config, 7777),
read with ss or lsof. It is stopped by pid, after its argv is re-read
and matched. Still refused: the port held by something that is not a
captured server, several servers on it, or several servers and none on
it. One server with nothing on the port is still that server.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(make): the install caution names npm ci, the hazard that remains; the stop is no longer system-wide (BUG-3194)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(scripts): refuse a foreign or unreadable listener on the port, re-check before each signal, keep empty trailing arguments (BUG-3194, codex round 1)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(scripts): the one-server fallback needs an explicit --port, so a lone sibling server is never replaced (BUG-3194, codex round 2)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(make): reflow the install comment (BUG-3194)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
Two tabs opened together each spend an item page's worth of requests from
one per-user burst. The refused GETs all carry the same Retry-After, so the
client's single fixed retry sent them back together, the herd was refused
again, and the tab showed errors it never recovered from without a reload.
GET/HEAD now retry up to MAX_RATE_LIMIT_RETRIES (3) times. Each delay is
drawn uniformly from [floor, min(2 x floor, 5 s)]. The floor is the
UNCLAMPED Retry-After, or 750 ms doubled per attempt with no header, so a
retry is never earlier than the server asked. A floor above the 5 s cap is
not clamped down, which would retry early into a bucket that said wait:
that request surfaces rate_limited at once. Writes still never auto-retry,
and the global cooldown is unchanged.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
A cold item page sent 32-33 API requests (measured). Two defects accounted
for six of them:
- The load $effect ran loadData() in its tracking scope, so the effect
depended on everything loadData read before its first await. That
included localIndex.bootstrap()'s bootstrapState, which bootstrap
writes twice ('cold' -> 'loading', then 'loading' -> 'ready'), so the
item and its collection were fetched three times. The effect now reads
wsSlug/collSlug/itemSlug explicitly and runs loadData under untrack.
- localIndex.reconcile started its own /items-changes read per ask, even
while the bootstrap's /items-index snapshot was in flight at cursor 0.
The layout's first sync result sent since=0, a full delta duplicating
the snapshot. Each ask is now answered by ONE read issued after it:
a bootstrap still hydrating, or an already-queued pass, is joined. A
read already in flight gets one shared pass queued behind it, reading
from the snapshot's cursor. The wait is bounded at 15 s, and reset()
releases the queue.
Measured with the checkpoint-1 fetch instrument on a cold task page:
origin/main 32, 32, 33 requests; this branch 26, 26, 27.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(import): a panic after a bundle import's mint takes the keep door, then re-panics (BUG-3191)
A panic after importBundle minted its workspace left retErr nil and
unwound past the handler's keep arm, the only place a failed import's
owner row is written, so the workspace stayed live, ownerless and
unnamed by chi's 500. importBundle's defer now recovers, attaches the
importer through addOwnerOrCompensate (or removes the workspace when
that fails), logs the outcome, and re-panics so the recovery middleware
answers as before.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(import): the keep door logs the original panic stack; panic(nil) pinned; comments say what the helper does (BUG-3191, codex round 1)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
A stored attachment name keeps < > : " | ? * (legal and common on Linux
and macOS: "Meeting: notes.pdf"). On Windows those are refused, and a
colon is worse: "a.svg:x.txt" names an NTFS alternate data stream, so the
bytes land in a stream on an empty "a.svg".
- attachments.WindowsPathComponent, beside WindowsDeviceName: reserved
characters and C0 controls become "_", trailing dots and spaces are
trimmed, and a device stem gets the "_" prefix. It is for the boundary
where a name becomes a path, never for the stored name.
- The CLI's safeLocalFilename applies it only when it RUNS on Windows
(safeLocalFilenameFor(runtime.GOOS, ...), so it is testable on any host).
Each saver applies its own filesystem's rules, as browsers do.
The accepted edge is written down there: a Linux CLI writing onto an NTFS
mount keeps the colon.
Not changed, by lead ruling on the trail: the Content-Disposition name
(it stays faithful; the server cannot know the client filesystem) and the
bundle tar path. Import recomputes that path from the manifest and skips
unmatched blobs silently, so changing it would make older servers drop
bytes. A cross-version contract comment on bundleAttachmentPath says so.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
The Nix-built binary stayed on go 1.26.5 because the locked nixpkgs
shipped it and its Go builder pins GOTOOLCHAIN=local. nixos-26.05 now
ships go 1.26.7 (pkgs/development/compilers/go/1.26.nix at 1bc55b9d),
so the 8 stdlib advisories accepted for that lag are removed. The one
entry left, GO-2026-5932, is a deprecation advisory on x/crypto/openpgp
that can never clear: pad requires the module for bcrypt and does not
import openpgp. Its reason is corrected to say so.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap; the web provider jitters its reconnects (BUG-1308)
A collab dial drew on the general per-user API bucket (burst 60), so 100
dials from one user got 60 through, and a user's own dials and REST
calls refused each other. Dials now draw on a CollabDial bucket (5/s,
burst 50), and how many sockets a principal may hold is bounded
separately by PAD_COLLAB_MAX_PER_USER (default 50), refused with 429
collab_limit_exceeded and Retry-After. Both defaults are sized from the
reconnect-storm measurement on BUG-1308's trail (checkpoint 2).
A browser cannot read a refused handshake's status, so the provider's
backoff is what spaces a refused herd out: each step is now jittered to
between half and all of itself.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(collab): PAD_COLLAB_MAX_PER_USER and the collab dial bucket, in the deployment docs, samples and load-test usage (BUG-1308)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(collab): only a WebSocket upgrade is a dial; per-user cap pinned within one workspace; dial-bucket keying stated correctly (BUG-1308, codex round 1)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(collab): a PAD_COLLAB_MAX_PER_USER below 1 means unlimited, as the SSE knobs do (BUG-1308, codex round 2)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(collab): the struct field agrees, 0 or less is unlimited (BUG-1308, codex round 3)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
Measured 2026-09-24 on 5 green CI runs: E2E wall time 9.3-11.7 min
(median 10.9) against a 15-min cap, versus 7m54s when #1158 set it. One
flaky retry (~+2m) still fits, two may not, and a cap-end cancels a job
whose tests passed.
The growth is new specs: tests present in both the baseline (#1158 merge
run) and today's run went 606s -> 566s in total, while 83 added tests
account for 257 test-seconds (test phase 5.5m -> 7.3m).
Raising the cap treats the symptom. The tripwire is IDEA-3189: shard or
trim when the median of the last 10 green E2E jobs passes 14m.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(import): every post-mint bundle failure goes through the keep door, and the body read gets a per-Read deadline (BUG-3184)
`read tar entry` was the one importBundle error return that dropped the
workspace it had minted, so any tr.Next error after pad-export.json (a
stalled or dropped body, a corrupt gzip, the body cap) left a live
workspace with no member row, invisible to its importer, that the 400
never mentioned. importBundle now hands back the minted workspace on
every error return, so the handler's keep and rollback arms see it.
Whether a mid-stream partial should be kept at all is TASK-896's open
question and is not changed here.
The server-wide 15s ReadTimeout also counted the server's own time
between body reads, and the import reads while it writes, so any import
still reading at 15s failed. Once the caller is authorised to mint, the
import route arms the read deadline before each Read (60s idle, 1h
ceiling), so only time spent waiting on the client counts.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(server): account for the import body pass-through in the body-reader inventory (BUG-3184)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(import): per-Read restart, JSON body shape and keep-alive legs for the import deadline (BUG-3184, codex round 1)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(import): the one-door comment covers error returns, not panics (BUG-3184, codex round 2)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* docs(import): the ceiling counts server work too; only the idle window excludes it (BUG-3184, codex round 3)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
`pad item export` saves to "<slug>.pad.md", so an item slugged nul, con,
aux, prn, com1-9 or lpt1-9 named a Windows device, and the CLI on Windows
wrote the artifact nowhere.
- attachments.WindowsDeviceName is exported (a rename, no behaviour
change): the one device rule from BUG-2822 (#1493), where the stem ends
at the first "." or ":". Its doc says not to write a second one.
- artifactExportFilename prefixes "_" to a device name, so the
Content-Disposition both clients save under is safe.
- The CLI applies the same rule to the path it will write
(defaultExportPath): it is the process writing the file, an older server
still suggests the device name, and the "<ref>.pad.md" fallback never
passed through the server.
Population (every Content-Disposition and client default name): the
bundle export ("<slug>-export.tar.gz", whose stem is never bare), the account
export (a fixed name) and the edit temp/recovery files (the "pad-edit-"
prefix) are safe; attachments are #1493. Import has no mirror: nothing it
creates is named from content.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3152): bug-2602's move waits until the page's event stream is subscribed
The move is observable only through this page's event stream. A fresh
EventSource has no Last-Event-ID, so a move committed before the page
subscribed was never delivered, and the refetch oracle timed out at 15s.
Under full-suite load the stream's headers took 532ms, and the move landed
inside that window (full run 3 on c49ee768).
Deterministic: with /events held 1.5s by route interception, the old spec
failed 3/3 with the identical timeout; with this wait it passes 3/3 under
the same delay, and 3/3 unmodified.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3152): the collab editor's first mount waits EDITOR_MOUNT_TIMEOUT (20s), not the 5s default
version-restore-collab:209 (2/8) and collab-persistence:41 (1/8) failed in
local full runs at the first editor-visible wait after a goto or a reload:
one request in the mount chain stalled 1.4-2.2s under box contention (load
17-23 on 8 cores), and the socket was still connecting at 5s.
Measured as NOT a product defect: in isolation, under 40 concurrent writers
(94s of cumulative pool waits), the same reads stayed under 90ms. So mount
latency under load is not what these specs test.
expectEditorMounted() in collab-helpers waits the suite's existing
SYNC_TIMEOUT figure (20s) for the FIRST mount only. The nine default-timeout
first-mount waits (the population: every editor-visible expect in web/e2e)
use it. No content or ordering assertion changed.
Deterministic: with collections/docs held 6s, the old wait failed 2/2 at the
mount step; with the helper it passed 2/2.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3152): SYNC_TIMEOUT has one definition, in collab-helpers; EDITOR_MOUNT_TIMEOUT is it
Six specs each declared const SYNC_TIMEOUT = 20_000. The value is
unchanged; they now import it, and the first-mount budget is defined as
that constant rather than a repeated 20_000 (lead ruling on #1494).
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3152): bug-2602's comment says why the oracle needs the subscription, not that the change is lost
A move before the subscription still reaches the pane, through the connect
catch-up (adopted from the /changes payload, no item GET); only this spec's
refetch oracle misses it. The earlier comment claimed the change was never
delivered, which a probe refuted (BUG-3187, closed).
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(attachments): a Windows device name is stored with a "_" prefix (BUG-2822)
Windows opens "nul.txt", "CON" or "lpt1.png" as a device whatever directory
it sits in, so a download saved under such a stored name is lost or
refused. NormalizeFilename now prefixes the name with "_", as Chromium's
download sanitiser does, which keeps it readable and keeps its extension.
The match reads the name as Windows does: stem before the first dot,
trailing spaces ignored, case folded. ServedFilename runs the same
normaliser, so legacy rows are served renamed.
Trailing dots and spaces, the item's other half, were already trimmed by
BUG-2818.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(attachments): the upload and download doors apply the device-name prefix (BUG-2822)
Door tests for a new row and a legacy row, each shown to fail against
main's normaliser, and the two comments that enumerate NormalizeFilename's
rules now name the prefix.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(attachments): a device stem also ends at a colon (BUG-2822, codex round 1)
"NUL:stream.txt" reached the device on Windows, since a colon opens the
stream syntax and ends the stem as a dot does.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
TestImportRoutingIgnoresSoftDeletedCollections installed a capturing handler
as the process-global slog default while marked t.Parallel(). Its siblings
TestImportDeduplicatesAConflictingArchive and ...AfterCanonicalInference, also
parallel, legitimately emit the same "declares one artifact kind on two
collections" warning for their own imports, and those records landed in its
capture. It failed 13 times in 40 with them alongside and 0 in 40 alone. The
empty "(collections=)" in the observed failure is simply that the warning
carries no such attribute.
The same shape was in two more tests (TestDedupeReportsAnArbitraryTieAsArbitrary,
TestStartupInvariants_LogsOnMissingTrigger), whose "must contain" assertions
it could turn into false passes or, when two capture at once, false fails. All
three drop t.Parallel(), since non-parallel tests never overlap parallel ones.
The shared handler's comment claimed "tests are single-threaded"; it now states
the rule, and TestSlogCaptureTestsAreNotParallel enforces it for every test
that calls slog.SetDefault. Test-only; no product change.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
Create added a freshly minted workspace to the calling OAuth connection's
allow-list (maybeAutoAddCreatorConnection). Neither import door did, so a
connection with an explicit allow-list got a 201 for an imported workspace it
could not then see. Latent today: no catalog action reaches the import doors.
BUG-2793 gave the mint doors a shared front half, beginWorkspaceMint (consent
and the plan limit). This adds the back half, finishWorkspaceMint, which runs
the post-creation side effects. All three doors call it on their success path
only, after the owner is attached and before the 201. The bundle door's
workspace commits before its items, so a failed bundle import can leave a
partial workspace; the helper sits after that error return, so a partial
workspace never joins the allow-list.
Tests drive both import doors with an OAuth identity, plus a failed bundle
import whose partial workspace must stay off the list, and an AST pin that
maybeAutoAddCreatorConnection has exactly one caller, finishWorkspaceMint.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(relations): an unjudged wrong_collection fails closed with no second lookup (BUG-3012)
Both server relation passes settled a `wrong_collection` issue the resolver
had not judged by RE-RESOLVING its value and checking visibility on what came
back. That second lookup was racy: a delete between the two turned a visible
caller's specific message into `not_found`. Since the day-64 ruling moved
visibility into the store resolver, both sites that build this reason stamp
VisibilityChecked, and every server feeder passes a non-nil
relationVisibility, so on main nothing reached the re-resolve.
It is replaced with a fail-closed guard: an unjudged `wrong_collection`
collapses to `not_found` with no lookup, and logs. A future feeder that hands
the resolver a nil visibility func therefore discloses less, and cannot quietly
reopen the window. store.ResolveRelationTarget had no callers left and is
removed, and the stale VisibilityChecked and door-test comments are rewritten.
Tests: the unjudged issue fails closed even for a live, VISIBLE target, which
the old re-resolve would have kept as wrong_collection. An AST guard derives
every store function that takes a RelationVisibilityFunc and requires each
server call, and the copy's RelationVisibility field, to be fed
relationVisibility(...), directly or through a local bound from it.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(relations): a carried wrong_collection reason is judged in the store, on the row resolved (BUG-3012)
The first commit's premise was incomplete, and the full server suite caught
it: TestRelationDoors_PreflightCarriedDropDoesNotDiscloseExistence. Every
server feeder passes a visibility func, but MigrateRelationReferentsQ's
carried branch deliberately resolves with nil, because survival must not
depend on who moves the item. Its wrong_collection issues therefore arrived
unjudged, and the copy preflight renders their reason. The removed server
re-resolve was what judged it, so the fail-closed guard told the OWNER
not_found for a live item they can see.
The resolver now records the matched item on a wrong_collection issue, and
the carried branch judges only the REPORTED REASON against the requester, on
that exact row, and stamps it judged. The disposition (dropped for every
mover) is unchanged, no value is looked up twice, and nothing reaches the
server's fail-closed pass: a full server test run logs it zero times, against
three times with this judgment removed.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(relations): a carried title into the wrong collection is judged per requester (BUG-3012)
The title rung of the carried path was the one the removed server re-resolve
could never judge (a UUID-or-ref ladder cannot speak titles), and it was
untested. The new preflight test has both legs: the owner, who can see the
matched item, is told wrong_collection; a requester who cannot is told the same
reason as a title naming nothing, and the response carries no hidden id.
Dropping `matched` on the title rung fails only this test, and judging every
requester as able to see fails it together with the ref-rung preflight test.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(BUG-3112): a mermaid diagram rendered in dark mode prints light
Printing always uses the light palette (app.css pins it for every
theme), but mermaid bakes its palette into the SVG at render time, so a
diagram drawn in dark mode printed dark on white. Printing an item is
browser-native and beforeprint is synchronous, so a re-render cannot
land in the printout; the flip is CSS, applied at print paint.
Each diagram now records the theme it was rendered on
(data-mermaid-theme), and an @media print rule applies
filter: invert(1) hue-rotate(180deg) to dark-rendered diagrams only.
It is an approximation, ruled on the trail: mermaid's dark palette
inverted, with author classDef colours keeping roughly their hue and
flipping lightness. The exact alternative (a light twin rendered per
diagram) was declined as doubling mermaid work for every dark-mode
reader.
e2e (print emulation, painted pixel inside a node): dark mode is dark on
screen and light in print; light mode stays light in print and is not
inverted.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3112): sample the node's shape fill, not the g.node box that holds the label (codex round 1)
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(BUG-3013): a relation id that does not resolve is "Unavailable item", never a claim that its target is gone
A restricted member's local index leaves out items in collections they
cannot see (the visibility filter in handlers_changes.go), so a live
target misses exactly like a dangling id. Every surface rendered that
miss as "Unresolved reference", with the hover text "This value does not
match any item in this workspace.", which asserts non-existence. The
server's not_found collapse exists to withhold that assertion.
The label and hover text are now one pair of shared constants in
relationGroups.ts (UNRESOLVED_LABEL / UNRESOLVED_TITLE). They are used by
the FieldEditor chip, the table cell and the activity value, and by the
board/list lanes and the filter chip through relationChipFor.
"(deleted)" is unchanged: it is shown only when the index holds the row
and the row carries deleted_at, which is positive evidence. BUG-3014's
text-value branch in FieldEditor is unchanged.
A shared test helper (src/test/existenceClaim.ts) checks rendered text
AND title/aria-label, root included, against the claim words. Guards
cover the constants and the three render sites.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* test(BUG-3013): the claim pattern covers the curly apostrophe and 'point at nothing', and tests itself (codex round 1)
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
A failed links refresh keeps the last-good rows (BUG-2871) and the
full-refresh path still advances the sync cursor, so a link added or
removed elsewhere, which emits no item event, could stay stale
indefinitely.
Withholding the pane's markSynced() (the filed preference) cannot fix
it: markSynced is one shared lastSyncTime = Date.now(), and the
workspace layout advances it on its own after every clean reconcile.
So the pane remembers the failure itself.
- web/src/lib/items/linksRetry.ts: one pending target per pane, retried
at 2s / 10s / 60s, restarted by kick() or a new failure, cleared by a
success, cancelled on destroy.
- ItemDetail: the preserving helper and the initial load record
failure / success; every sync result kicks the retry before the
caught_up return (a gap holding only link changes reports caught_up);
retryLinks is the attempt. It commits only if nothing wrote the links
since (itemGen, which the identity listener's loadData bumps, and the
itemLinks identity), and a newer write answers "not fresh" so the
retry is kept rather than dropped.
- The identity-fence gate tables retryLinks and allows the helper's two
record calls; the rows whose hash moved were re-read and hold.
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(cli): pad server stop keeps a legacy bare-pid file instead of deleting a live server's record (BUG-2970)
On Unix, pidFileOwner read a free lock as proof that a record was stale, and
removed it. That holds for records this build writes, because their server
holds the lock for its whole life. It does not hold for a bare-pid file written
before BUG-2969: no build that wrote that form took a lock, so a free lock says
nothing about its server, which may be live. The day-19 refresh deleted exactly
such a record.
A record without a fingerprint (zero StartedAt, which is what a bare pid
parses to) is now unprovable and left in place, the answer Windows already
gave for the same shape. Fingerprinted records are unchanged. Three existing
fixtures modelled fingerprinted records without a StartedAt, which no build
writes; they now carry one.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(cli): a legacy PID file naming a dead process is stale and removed; start is never blocked by a kept one (BUG-2970)
The kept, unprovable state now applies only while the legacy record's pid
exists. A pid with no process behind it cannot be anyone's server, so that
record is stale by the same proof as an unheld fingerprinted one, and is
removed under the lock. Stop then says "server not running" instead of
refusing forever.
Tests pin both halves of the way out. Start is never blocked: EnsureServer
probes the port, and the new server's ClaimPIDFile takes over a kept legacy
file and writes a fingerprinted record stop can address. And the kept
refusal names its manual remedy (remove <path>).
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(relations): a relation value stored as text reads as text, not as an unavailable target (BUG-3014, ToolSurfaceVersion 0.50)
HydrateRelationTargetsQ treated every stored relation value as an id. A value
that is not UUID-shaped (a title a bundle import carries verbatim, which is that
door's contract; or legacy free text) resolved to nothing and hydrated as an
id-only entry, which means "the target is gone or not visible". So an imported
workspace showed relations as broken when the title might name a live item.
Such an entry now carries an additive, omitempty `stored_as_text: true`. The
predicate is syntactic ("not UUID-shaped"), decided from the stored bytes alone
and never from a lookup, so the flag discloses nothing about any item, and it is
deliberately not coupled to the resolver's ladder (lead ruling). A UUID-shaped
value, resolved or not, marshals byte-identically to 0.49, and a UUID that fails
to resolve is never marked.
The CLI renders `"<text>" (text, not a reference)`. The web chip, which resolves
locally from its index rather than from relation_targets, uses the same
syntactic predicate and says the same. The MCP instructions tell agents to
render a marked entry as text.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(relations): one shared vector table pins the Go and web stored_as_text predicates together (BUG-3014)
The web carries its own copy of the not-UUID predicate, because it resolves
relation chips from its local index rather than from relation_targets. Two
implementations of one contract rule drift. Both now read
internal/store/testdata/relation_stored_as_text_cases.json (21 cases: upper
and mixed case, braces, no dashes, URN form, wrong dash positions, length off
by one, titles, refs, blanks, and padding), and their KEEP IN SYNC comments
point at each other.
The table found a real divergence: the web trimmed with String.prototype.trim,
which disagrees with Go's strings.TrimSpace on U+0085 (Go only) and U+FEFF (JS
only). The web now trims with serverTrimmedTitle, the Go-parity helper the title
limit already uses. Swapping JS trim back in fails exactly those two vectors,
and dropping Go's TrimSpace fails the five padded ones.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(store): two concurrent updates by one writer start one activity row, not two (BUG-2777)
CreateActivityDebounced inserted a fresh "updated" row whenever its candidate
read found nothing. Two calls of one writer that both missed both inserted,
so the run the debounce exists to coalesce started as two rows. Measured on
the unfixed code, 4 concurrent calls did so in 29 of 30 bursts on both
SQLite and Postgres.
A miss now goes through insertDebouncedOnMiss: a transaction holding a lock
keyed on the document (a Postgres advisory xact lock; SQLite's BEGIN
IMMEDIATE), which re-reads the candidate and inserts only if it is still
absent. A row that appeared meanwhile is merged into through the existing
compare-and-set path.
Tests: a seam in the unlocked miss window (re-read), a Postgres test that
observes the competitor waiting on the advisory lock (lock), and a no-seam
burst on both backends that fails on any duplicate. Every faithful mutant is
killed on both backends except "no advisory lock" on SQLite, where that lock
does not exist.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(store): the debounce lock uses the pad: key namespace, and its order is stated (BUG-2777)
The advisory key follows the repo convention, hashtext('pad:activity-debounce:' || $1).
The lock-order comment states the debounce lock is a leaf: taken first in its own
transaction, holding nothing else, never nested inside one that holds a lock. The
single caller runs outside any transaction, and no *sql.Tx precheck in
internal/server reaches it (a call-graph walk, shown able to go red with a planted
call).
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
cmd_server closes the event buses before srv.Shutdown drains handlers, so a
request already inside the SSE handler could subscribe after Close. MemoryBus
admitted it on all three doors and returned a channel nothing would ever close,
so the handler ranged over it until Shutdown's 30s deadline. RedisBus already
refused the same call with SubscribeFailed, as measured on main.
MemoryBus now answers SubscribeFailed, with a nil channel and no registration,
on Subscribe, SubscribeIfAllowed and SubscribeAndReplaySince once closed. The
resume door clears its resume-gap report first, as RedisBus does. The SSE
handler already maps SubscribeFailed to a 503 with Retry-After, which is the
right shutdown answer, and the two buses now answer a late subscriber alike.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
cascadeTitleRename writes linking items' content without a compare-and-set.
Its comment justified that with UpdateItem's workspace seq lock, and named
RemapAttachmentReferencesInWorkspace as the one writer outside the lock,
tracked as BUG-2795. BUG-2797 (#1328, pinned by #1446) made the remap take
that lock before its scan, so every writer of items.content now serializes
on it, and the comment's "missing a guard outright" is false on main.
The comment now states the condition the unconditional write depends on:
every items.content writer takes the lock before it reads. A new writer
that does not must bring its own compare-and-set.
Comment-only; no behaviour change.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
store.ListItems (and its FTS copy) split every ItemListParams.Fields value
containing a comma into an IN list, for every caller. A value containing a
comma could therefore never be matched as itself, and in-process callers
that pass stored or schema values got an OR they never asked for:
checkUniqueFields looked up "a,b" as IN ('a','b'), so it raised a false
conflict when "a" existed and missed a real "a,b" duplicate.
Fields now match exactly, and ItemListParams.FieldsAnyOf is the explicit OR.
Both store copies share one builder. parseItemListParams is the one place a
comma becomes an OR: `?status=open,done` lowers to FieldsAnyOf, so the
documented external contract (`pad item list --status a,b`, the MCP list
filter) is unchanged. resolveParentFilter rejoins a comma parent value, so
it is refused as before.
Documented limit (lead ruling): over the query string, a literal value
containing a comma still cannot be filtered for exactly. A repeated-param
form was declined because older servers silently narrow it. Census: 0
comma-containing option values among 2,025 select/multi_select options
across 27 workspaces, and 0 among 81 declared bootstrap filter entries.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(BUG-2872): an activity change on a relation field renders the target (REF · title / deleted / unresolved), never the item ID it stores
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
* fix(BUG-2872): timeline leaves import no stores; the owning page hands down a ChangeContext (the collection store's module-scope identity listener broke seven timeline suites)
Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
Both copies of the completed-work listing (the server's
listTerminalItemsSince and the CLI's listCompletedWorkSince) already resolve
each collection's done field per collection when querying. The rows
rendering those items read the literal "status", so an item from a
collection whose done field is something else (board_group_by: stage) was
included with a blank Status. The resolved field now travels beside the
items as a collection-id map, and the four row builders (server and CLI,
standup and changelog) read it through completedWorkValue, falling back to
status. The JSON key stays "status"; only the value it reads moves.
Dashboard and item-list views that read the literal status to decide
active, in-progress and stalled ask a different question, and are left
alone.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(BUG-2410): a default report leaves out system collections; Insights' picker stops offering them; ToolSurfaceVersion 0.49
* fix(BUG-2410): a saved layout naming a system collection no longer filters the report by a selection the picker cannot show (codex r1)
* fix(BUG-2410): with no collection list the report goes unfiltered, and a failed load never erases the saved layout (codex r2)
* fix(BUG-2410): a collections response for a workspace the user left is ignored (codex r3)
listTerminalItemsSince passed the caller's full item-grant list into every
per-(field, value) completed-work query. ListItems ORs ItemIDs past the
CollectionIDs restriction, which is right for visibility, but here
CollectionIDs also means 'the collections for which this value is completed
work'. So a granted item was counted under another collection's terminal
value, and one matching two groups' keys was listed twice. Each granted
item's collection is now resolved once, and each group receives only the
granted items that live in one of its collections (the group's full list,
so item-grant-only collections still contribute).
Both copies (the server's and the CLI's listCompletedWorkSince) now also
dedup by id. That is not only defensive: the list endpoint splits a filter
value containing a comma into an IN list, so a collection whose
completed-work values include both 'x' and 'x,y' matched one item twice,
for members too.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(BUG-2182): reproduce pane Back returning to the top of the previous item
* fix(BUG-2182): the split pane's Back returns to the previous item at the position it was read at
* test(BUG-2182): the drill-after-Back race with a scrolled A, the case the saved position turns into a shallow entry
* test(BUG-2182): drop the scrolled race variant; the race it copies is flaky on main (filed separately)
Item CREATE was the last door that stored a hand-written reserved metadata
key (implementation_notes, decision_log, github_pr, convention). A `--field`
or MCP `field` value landed as a string no extractor reads. For notes and
decisions, that also made `note` / `decide` refuse on the item until the row
was repaired.
Create's `fields` now refuses all four keys with 400 validation_error. The
check runs on the caller's map. Library activation, the only system writer that
used this door, moves to a new typed `convention` create member, which is
validated (an empty object is refused), normalized and lowered into the blob.
Every convention writer switches to it: the CLI and remote MCP library
activate, the web library activate, the web Conventions page create, and the
e2e seed helper. The CLI checks the STORED `convention` key to confirm the
member landed. It cannot use item.Convention for that, because that value is
derived from the sibling keys and is non-nil even when an older server has
dropped the member.
A FULL `fields` update may CARRY stored reserved values unchanged. A differing
value, or an OMITTED stored key (which the whole-blob replace would delete),
is refused with 400. The comparison uses decoded JSON, never bytes, and runs
in the update transaction against the row re-read under the write lock
(composed into the precheck, like the BUG-3133 guard). A note appended
concurrently therefore cannot be erased by a stale blob. A stored blob that is
not a JSON object has no readable reserved metadata, so a full write can
still repair it.
Artifact import shares createItemChecked but needs no gate, because
artifact.Decode fills Fields from a fixed frontmatter key list. Existing test
fixtures that put reserved keys in through the two closed doors now seed at
the store level. The stdio refusal texts are pinned to the classifier's
validation_failed table.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
A `--field github_pr=...` / MCP `field` setter stored the PR as a string on
every transport, so no link rendered, and `=null` stored the string "null".
BUG-2627 had exempted github_pr from the reserved-key refusal as remote MCP's
only writer; that writer never worked.
ItemUpdate gains a typed `github_pr` PR object (number > 0 and an absolute
http(s) url required) plus `clear_github_pr`, lowered by the handler into the
field patch after the caller's own fields_patch is refused every reserved key.
`pad github link` / `unlink` / `project reconcile` use it and verify from the
response that the change landed (an older server ignores the member and
answers 200). Not exposed on the MCP catalog; a remote PR-link action is
deferred to the catalog-trim decision. The point is validity, not sender
identity. Create (still accepts hand-written reserved keys) is BUG-3163.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(BUG-3160): reproduce the open timeline missing a merged activity change
* fix(BUG-3160): the item timeline re-reads its head on its item's item_updated, throttled to one per 10s
* fix(BUG-3160): the workspace activity page re-reads its head on item events, same throttle
* test(BUG-3160): drop an unobservable hidden guard; pin that unmount clears the throttle timer
* fix(BUG-3160): an item event during a load is owed a head re-read when it settles (codex r1)
* feat(web): the schema editor marks a terminal option as abandoned, and saving no longer strips abandoned_options (BUG-2347)
Each terminal option in the collection schema editor gets a "counts as
abandoned" toggle (the lead's added scope): abandoned values are left out of
the changelog, standup and report throughput.
It also closes a hole #1464 opened: the Edit modal rebuilds every FieldDef
from its EditableField on save, and that type had no abandoned list, so
saving a collection in the web UI silently stripped abandoned_options. The
field state now carries it, hydrates it, and saves only values that are
still terminal (the server refuses any other).
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(web): an option's terminal and abandoned marks follow its rename, and each toggle names its option (BUG-2347, codex round 1 on #1468)
Markings are keyed by option value, and the option input was a plain bind,
so renaming a marked option left its marks on the old value, which the save
then filtered out: a renamed "cancelled" silently stopped being terminal and
abandoned. Terminal marks had the same pre-existing gap. The input now
carries both marks to the new value (unless another row still holds the old
one). The Done and Abandoned toggles' accessible labels name their option.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(BUG-3159): reproduce touch drag on the roles board and the child list
* fix(BUG-3159): the roles board and the child list withhold drag on a touch device
* test(BUG-3159): register each scratch resource's cleanup when it is created (codex r1)
* test(BUG-3159): cleanup never throws, and one failure does not stop the rest (codex r2)
* fix(project): a collection's own abandoned_options decide what the changelog, standup and reports count as shipped (BUG-2347)
The completed-work views excluded abandoned closes by a global name list
(NegativeTerminals). A collection's own abandon word was invisible to it:
live, `pad project changelog` listed PLAN-2326 (plans status `overturned`)
as shipped, along with archived marketing posts and docs.
A select field gains optional `abandoned_options`, validated as a subset of
its terminal_options on collection create/update. One resolver
(PositiveTerminalValuesForDoneField) serves the changelog, standup, the CLI
copy and report throughput: completed = terminal - abandoned, with the global
names as the fallback when a field declares none. Templates seed it.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(store): workspace import refuses abandoned_options that are not terminal, like the collection doors (BUG-2347, codex round 1 on #1464)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(store): the import test edits the parsed schema, not its text, so it holds on Postgres JSONB (BUG-2347)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(BUG-3158): board and list drag are off on a coarse pointer, not only on a narrow viewport
* docs(BUG-3158): state and pin the primary-pointer trade-off (codex r1)
* test(BUG-3158): each leg owns its collection and proves its gesture can land (CI caught a population-dependent precondition)
* fix(mcp): bulk-update refuses an undeclared status/priority per item on stdio and remote too (BUG-3156, ToolSurfaceVersion 0.46)
Over WebMCP, pad_item bulk-update has refused (since 0.44) a status or
priority change on an item whose collection declares no such field; stdio
and remote went through the single-item PATCH, which accepted it with a
warning and stored an orphan field.
The item update door gains a general, additive strict opt-in,
`refuse_undeclared_fields`: with it, the write is refused with 400
validation_error for exactly the keys warnings.undeclared_fields would have
named, and nothing is written. Both bulk-update transports set it. A write
without it is unchanged; a server that predates it ignores it, which is the
old accept-and-warn.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(server): a refused write also leaves the title and body alone (BUG-3156, codex round 1 on #1463)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* fix(mcp): remote bulk-update patches only status/priority, so it no longer reverts a concurrent field write (BUG-3156)
dispatchItemBulkUpdate (remote /mcp) still did BUG-3049's read-modify-write:
GET the item, set status/priority on the decoded blob, PATCH the whole blob
back, so any field another client wrote between the two was reverted. The
CLI (stdio) has sent fields_patch since BUG-3049; remote was missed.
It now sends one PATCH per row with fields_patch holding only the keys the
call sets, and no prefetch. Tool surface unchanged, no version bump.
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
* test(mcp): the per-item failure test describes the PATCH-only row, not a GET (BUG-3156, codex round 1)
Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N