1792 Commits

Author SHA1 Message Date
xarmian dc70ff3d7f fix(server): write first, apply second on the collab applier path (TASK-2989 / BUG-2840 half A) (#1318)
* test(server): measure BUG-2840 half A's premise before designing a fix

Half A's plan makes step one an experiment, not a design: the claim that a
refused PATCH still lands its content was a reading of the snapshot branch
rather than an observation, and the shape of the fix depends on which half
actually bites.

Measured, on the applier path with a live room: a PATCH carrying content and a
stale expected_updated_at answers 409, leaves items.content untouched, and adds
an op-log row that outlives the request. The caller's refusal is true of the
row and false of the collaborative document.

The first version of this test was CIRCULAR and reported the premise confirmed.
It drove a ?source=collab-snapshot PATCH carrying the refused string, which
proves only that a snapshot write writes what it is given. The server cannot
close that loop at all: collab here is a dumb relay that persists opaque Yjs
updates and never parses them, so nothing server-side derives markdown from a
room's document — in production that markdown comes from a live tab's Y.Doc.
What IS observable server-side is durable collab state created by a request
that was refused, which is what this now measures.

Two details that make the harness faithful rather than convenient:

- The fake applier emits a binary op as well as the ack. A real applier is a
  browser tab that applies the markdown and broadcasts the resulting update;
  acking alone would leave no durable trace, so the experiment would have been
  measuring a peer that does not exist.
- Readiness is detected by the observable difference between the two paths — a
  succeeding probe PATCH that leaves items.content untouched proves the applier
  answered — because no exported accessor for electable connections exists and
  the manager's state is not reachable from this package.

The test asserts today's behaviour, defect included, so the fix has a baseline
to move. It skips with an explicit "premise NOT established" message if the
harness ever stops reproducing the applier writing durable state, rather than
passing vacuously.

Refs: BUG-2840

* feat(server): write first, apply second on the collab applier path (TASK-2989 / BUG-2840 half A)

PLAN-2975 decisions 2-4. A refused PATCH no longer changes the item.

The applier path used to push content into the live Y.Doc before the
row write, so any of the four typed refusals answered 4xx while the
collaborative document had already moved and the next collab-snapshot
flush carried the refused content into items.content. The reorder is
possible because TASK-2987's HasElectableApplier answers which path the
request is on without taking it.

routeContentUpdate owns the re-decision deliberately: the predecessor
retried ErrRoomActiveDuringPrune inside applyContentViaCollab and
re-called ApplyExternalContent, which could succeed through a freshly
joined applier and return nil, after which the row write still ran last
and reproduced the defect. Re-deciding before anything is written makes
that impossible rather than unlikely.

Two typed 409s join the structured family. content_not_applied answers
the hybrid the reorder creates - row write committed, content not in the
document - naming the landed fields and the new updated_at so a
content-only retry does not trip OCC. room_settling answers the standoff
where PruneAndApply blocks on any writer while election also demands
unfrozen and replay-done: the predecessor gave up after three tries and
wrote past the live peer, losing the write on its next flush.
applier_ambiguous is untouched; its outcome is unknown and a claim
either way would be false.

The measurement harness is inverted rather than deleted: it asserted the
defect and would have become a SKIP, which reads as a pass.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* refactor(server): retire the route-flipping helper chain the reorder replaced (TASK-2989)

applyContentViaCollab, applyContentViaCollabOnce, directWriteFn,
applyContentMaxRetries and isDeterministicWriteFailure are dead once the
router owns the decision, and golangci-lint said so. Removing them is
the point rather than tidying: that chain retried
ErrRoomActiveDuringPrune internally and re-called ApplyExternalContent,
which could succeed through a freshly joined applier and let the row
write run last after all.

Two things ported rather than dropped. isDeterministicWriteFailure's
closed-set warning moves onto writeTypedItemRefusal, which inherits the
job of recognising every typed permanent refusal. Its regression test is
ported too, unchanged in property: a refusal the handler does not
recognise is treated as recoverable and the request re-derives it by
another route, which BUG-2804 measured as a rename cascade run twice.

CONVE-23 sweep: my own comment on HasElectableApplier, merged four hours
ago, said the fallback could write content past live peers. This unit
made that false. It now states what the sentence was true of and what
replaced it, rather than being quietly deleted.

The structural guard needed teaching, not weakening: it counts the
handler's refusal blocks and failed closed when one moved into a shared
function. It now scans both files and says why three is still three.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(server): pin the settle budget itself, which every other test bypassed (TASK-2989)

Found by mutation: applierSettleBudget = 0 survived the whole suite. The
decision tests pass their own budget, so the constant had no coverage at
all — and a zero budget makes the retryable refusal the normal answer
for any room with a writer still anchoring.

The floor is the measurement the constant was sized from rather than a
number: 47ms, just above the 46.41ms worst anchoring time measured for
this deployment.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(server): bound the standoff subtest so a broken deadline fails instead of hanging (TASK-2989)

The only exit from the standoff branch is the deadline, so the mutant
that makes it unreachable spins and the failure arrives as a package
timeout with no --- FAIL line — which a mutation harness reads as 'the
package broke' rather than as a detection. Measured: that is exactly
what M5 produced.

Same shape as the waiter rule: a failure mode indistinguishable from the
waiting mode is not a signal.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* fix(server): content_not_applied must not assert a timeout did not land (TASK-2989)

Codex round 1, and the sharpest finding in it. ApplyExternalContent
returns ErrAllAppliersTimedOut only AFTER an applier_request has gone
out on the wire, so the elected peer may have applied the markdown and
persisted its ops while the ack was lost or merely late. Answering
content_landed:false there states as fact something the server cannot
know — the same overclaim the ruling avoided by leaving
applier_ambiguous alone, arriving one door over.

The discriminator already existed upstream and needed no new machinery:
electAndApply returns ErrNoApplierAvailable when anyWriteSucceeded is
false (nothing reached a peer) and ErrAllAppliersTimedOut when something
did. The envelope now carries content_outcome, and content_landed is
ABSENT rather than false when the outcome is unknown, because a caller
that reads false may act on a premise nothing supports.

Three smaller round-1 items. The settle budget's comment now says it
bounds how long the route keeps ASKING, not how long the request takes —
the deadline is only consulted between attempts and PruneAndApply can
block on the per-item lock. A comment on fullWriteHandled still named
applyContentViaCollab, which this unit deleted; my own sweep missed it.
The ported classifier test now inspects the recorder rather than only
the boolean, since a mutant could return true while writing the wrong
status.

Verified and NOT changed: nil-ing content on the row write does not
newly suppress version bracketing. main already set input.Content = nil
on the applier path before its row write, so that behaviour is identical
before and after the reorder.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* fix(collab,server): the not-applied claim was still false on two post-wire paths (TASK-2989)

Codex round 2, and it refuted the reasoning I gave in round 1's fix
rather than just finding another case. I said the discriminator already
existed upstream because electAndApply tracks anyWriteSucceeded. It
does — PER ELECTION — and two paths escape it:

  - a restore storm returns ErrNoApplierAvailable after up to
    applierMaxRestartsAfterRestore elections, each of which may have put
    an applier_request on the wire, with the per-election flag discarded
    at every restart;
  - a registerPendingAck failure on a retry attempt returns a raw error
    after an earlier attempt had already sent one.

Both would have answered content_landed:false about content that may
have landed. Same shape as the finding they follow: a reason that was
sufficient-sounding and one file short of true.

Fixed at the source where the source can know it — ApplyExternalContent
now carries sentAny across restarts, so ErrNoApplierAvailable means what
its callers read it to mean — and by construction everywhere else:
classifyApplyOutcome is a whitelist, so only the two sentinels that mean
nothing reached a peer may make the claim and every other error,
including ones nobody has written yet, degrades to unknown.

Cancellation: the re-decision wait is the only new blocking wait this
branch adds, and it now ends when the caller goes away. The rest of the
path was context-blind on main and stays that way; threading a context
into the store and the applier round-trip is a different change.

Not fixed here, deliberately: the ambiguous-commit double-write. Codex
confirmed against main that it has the identical shape there, so it is
pre-existing and gets filed rather than folded into this unit.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* docs(collab): sweep the prose my own round-2 fix falsified (TASK-2989)

Codex round 3, one P3, and it is CONVE-23 arriving for the third time in
this unit. Carrying sentAny across the restart loop changed which
sentinel a restore storm returns, and left two comments describing the
old behaviour: the cap's doc still said exhaustion falls back with
ErrNoApplierAvailable, and the sentinel's own doc still said every
attempt timed out.

Both now say what the sentinel MEANS rather than how it usually arises —
bytes reached a peer and the outcome is unknown — because that is the
half two callers depend on: the op-log prune stays suppressed, and the
PATCH handler reports the content outcome as unknown rather than
not-applied.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* fix(server): restore the UNIQUE-constraint 409 the applier path used to inherit (TASK-2989)

Codex round 5, and a regression rather than a gap. The ordinary error
block maps a UNIQUE-constraint race — two updates that both pass
checkUniqueFields and then both hit the partial unique index on
invocation_slug — to a 409. Before the reorder the applier path's row
write ran through that block and inherited the mapping. Routing it
through a helper built from 'the four typed refusals' dropped the arm and
turned a benign race into a 500 on that route alone.

The irony is the lesson, and it belongs on the record: writeTypedItemRefusal
exists BECAUSE this handler's refusal set has been under-counted three
times, and I under-counted it again while building the thing meant to
stop that — by taking the population from the errors that have a Go type
rather than from the block that actually answers them.

The new arm's first version panicked on a nil error, since it
dereferences where the typed arms use errors.As. The existing nil
control leg caught it immediately, which is the entire reason that leg
is there.

The structural guard now DERIVES its file set — every non-test file in
the package that calls UpdateItemWithParentLink — instead of listing two
names, so a future block in a third file cannot sit unmapped while the
test passes.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(server): the guard now requires the fifth arm, scoped to the block's own function (TASK-2989)

Codex round 6. Two gaps in the guard as it stood: it verified only the
four typed arms, so removing the UNIQUE-constraint mapping from either
ordinary block still passed; and its file set matched on the store call
text, so a file reaching the store through a wrapper would not be
scanned at all.

The file set is now the UNION of files calling UpdateItemWithParentLink
and files calling any of the arms — a refusal block lives where the arms
are called, whatever it calls the store through.

The fifth-arm check is scoped to the ENCLOSING FUNCTION, and that is the
part worth reading. The first version asked whether a UNIQUE literal
appeared between one block's start and the next block's start in
token.Pos. Those windows span whole files, so the gap between the last
block of one file and the first block of the next swallowed every
literal in between — two in handlers_items.go belonging to the create
and restore paths, one in handlers_items_bulk.go. All three mutation
controls survived it. It asserted nothing, and it passed, which is the
only reason I looked.

Committed BEFORE the controls run this time. The previous round's
controls used git checkout -- internal/ against uncommitted guard work
and deleted it; the tree read clean afterwards, which is the ambiguity —
clean means the mutation was reverted OR the mutation and my work both
were.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(server): scope the fifth-arm check to the block statement, not the function (TASK-2989)

Per-function was the second wrong containment and the controls said so:
handleUpdateItem holds TWO refusal blocks with a UNIQUE arm each, so
neutralising either hid behind the other and survived. Only the
writeTypedItemRefusal control was detected — the check covered one of
the three blocks it claimed to cover.

Innermost enclosing BlockStmt is the containment that matches what the
sentence means by 'the block's own arm'.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(server): the fifth-arm check reads if-conditions, not any literal in the block (TASK-2989)

Codex round 7. Scanning the whole BlockStmt for a matching string
literal let an unrelated nested closure — or a message string quoting
the phrase — satisfy the guard after the real mapping had been deleted.
That is the guard passing for a reason unrelated to what it asserts,
which is the failure this whole check exists to prevent one level down.

It now reads only IfStmt conditions, which is the shape the arm actually
has.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-10 08:54:41 -04:00
xarmian ab4607fed1 docs: CLAUDE.md described pad auth login as an email/password prompt; it is browser-based (#1317)
`pad auth login` opens the browser-based CLI auth flow. The email/password
prompt is behind `-i` — the flag's own help says so: "Use email/password prompt
instead of browser-based login" (`cmd/pad/cmd_auth.go`, where the RunE branches
on `interactive` and otherwise calls `doBrowserLogin`).

The line mattered because it is the one an agent reads before telling a
locked-out user what to run, and on a headless box the wrong answer wastes the
attempt. Found while writing TASK-2984's README paragraph, where I made the
same mistake in my own first draft and had to check the code to catch it.

Scope: one line. The neighbouring claim at CLAUDE.md:112 — that minting is
gated because "a token that can mint tokens outlives its own revocation" — was
also flagged in review as loose, since it is the tokens the mint PRODUCES that
outlive the parent's revocation. Left alone deliberately: it is imprecise
phrasing of a true fact, not a statement that is wrong about the code, and this
change is for the latter.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 03:50:22 -04:00
xarmian 4a4a912fa6 docs: pad token create needs a session, and one code comment said otherwise (TASK-2984) (#1316)
Since #1267 the server refuses a mint authenticated by an API token with HTTP
403 `session_required` — "Creating or rotating API tokens requires an
interactive session, not an API token" — because the tokens such a mint
produces outlive the revocation of the token that made them: each has its own
name and expiry, and nothing in `pad token list` records which token minted
which. `list` and `revoke` stay reachable by a PAT deliberately, since
revocation is the response to a compromised credential and should not need a
fresh login.

Verified against `handlers_tokens.go::requireInteractiveSession` before writing
it, as the item asked: the code and the message are quoted from there, and the
guard is `isAPITokenAuth`, which is false for a session cookie AND for a saved
`padsess_` CLI bearer — a CLI session IS an interactive session.

Three artifacts a reader consumes carried the now-false implication; two are
fixed here.

- The README's PAD_TOKEN section said the override authenticates "without `pad
  auth login`" and named `pad token create` as where tokens come from, which
  together read as "you can mint under the override". It now names the one
  exception and links to the paragraph.
- The token-management section gets that paragraph: which subcommands need a
  session, the exact code and message, why, and that `pad auth login -i` is the
  headless path.
- `internal/cli/client_tokens.go` claimed the PAD_TOKEN override was "usable
  end-to-end without a browser". True when written and false after the gate.
  Corrected in place with the reason and a pointer to the guard, rather than
  deleted — a comment that was true once is worth more as a dated correction
  than as a gap.

THREE CLAIMS I HAD WRONG, all caught before merge and all by checking rather
than by rereading:

- "needs a terminal but not a browser" — `pad auth login` DEFAULTS to
  browser-based auth; `-i` is the email/password prompt. The README and the
  comment name the flag now.
- "fails when `PAD_TOKEN` is set" — too broad. `PAD_TOKEN` accepts a `padsess_`
  session token as well as a `pad_` API token (`env_token.go:21-23`), and the
  session form mints normally. The gate is on the CREDENTIAL KIND, not on the
  variable.
- The history was compressed into "#879, before #1267". #879 added the override
  and left minting web-only; #1237 (f262449b) added `pad token` so the CLI could
  mint at all; #1267 (cdc5b301) then gated it. Both commits verified by
  `git log -S` rather than taken from the review that flagged it.

Deliberately not done: no help text added to `pad token create`. The 403's own
message already names the cause and the fix at the point of failure, and the
item scoped this to documentation. `CLAUDE.md:127` describes `pad auth login`
as an email/password prompt, which is wrong about today's code — that is a
separate one-line change, not this one.

Gates: go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean, vitest
153 files / 2342 tests passed (unaffected — the only code change is a comment).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 03:49:38 -04:00
xarmian 8f6c62ec27 fix(web): a failed links refresh keeps the links it has, so a click cannot be lost to it (BUG-2871) (#1315)
Three same-item links refreshes in ItemDetail swallowed a request failure into
an EMPTY array — `api.links.list(...).catch(() => [])` — throwing away rows
that were on screen and correct. A failed request says nothing about the links
it did not fetch. The initial load did the same on throw, unconditionally.

Two reasons that mattered beyond the lost data.

`{#if relationshipGroups.length > 0}` sits ABOVE both `{#each}` keys, so keying
the rows on `(group.label)` and `(entry.key)` protects nothing against an empty
list: the whole relationships section is destroyed and later rebuilt. A click
straddling that is swallowed entirely, because a click needs mousedown and
mouseup on ONE node — no navigation, no error. That is the defect BUG-2871
fixed for the Children pane, at the same altitude, through a different door,
and the Children fix had to cover its error branch as well as its loading one
for exactly this reason.

Second, silence on a failed same-item refresh is the RULED behaviour on that
trail — last-good rows are valid data. That ruling only holds if the rows
survive the failure, which is the half this closes.

It does NOT promise a retry, and the code no longer implies one. Turning the
error into a successful return leaves the full-refresh caller's
`syncService.markSynced()` advancing the cursor, so stale links can persist with
nothing scheduled to correct them — not introduced here, since `.catch(() =>
[])` returned successfully too, but this makes the staleness survivable rather
than visibly empty. Filed as BUG-2992 and cited from both comments rather than
left implied.

THE GATE IS WHOSE ROWS THEY ARE, not which function is running. That was wrong
in the first version of this change, which treated `loadData` as always a first
load or a switch and cleared there unconditionally; the edit-collection handler
calls it for a SAME-item reload after a schema change, so the defect survived
through that door (codex P1). The load now captures the item its links belong
to before it can replace `item`, and clears only when that differs — keeping
them across a same-item reload, dropping them on a real switch, where holding
them would render one item's relationships under another's title.

WHAT THIS DOES NOT CLAIM. It does not close the `:194` E2E flake. The failure
is located — `waitForEvent` timing out at the ctrl-click's popup wait with
`click()` itself resolving, so Playwright believed it clicked and nothing
navigated — and this makes that observation impossible via this route. But
there is no evidence from the failing run that a links request actually failed;
the job log carries the test's view, not the server's responses. What is
established is that the path is reachable (every API route sits behind a
600/min burst-60 limiter whose own comment cites cascading SSE refreshes) and
that two rival explanations are refuted: the `<a>`/`<span>` href flip (all four
`itemLinks` sites call the same `api.links.list`, so there is no leaner payload
to flip to) and interception on modifier clicks (`shouldOpenInPane` returns
false for `ctrlKey` before any `preventDefault`). If the flake recurs after
this, the cause is elsewhere and the trail should say so rather than reading a
green run as a diagnosis.

Tests: a source-level guard, following `itemDetailUsesPicker.test.ts` for the
same reason — the property is structural and mounting a 7,900-line component to
observe it costs more than it is worth. FIVE of its eight assertions fail
against pre-fix source, verified by running it against `git show origin/main:`
rather than counted by hand — the earlier claim of three in this message was
wrong, and review caught it.

Seven mutants, each killing exactly one leg: a helper spelled `catch { return
[]; }`; `catch { itemLinks = []; return itemLinks; }`, which satisfied a
return-value assertion while reintroducing the defect; the same via
`itemLinks.length = 0` and via `itemLinks.splice(0)`, which an
assignment-only check misses; restoring the unconditional clear; an
unconditional clear sitting BESIDE the gated one; and an unconditional
`splice(0)` beside it. That progression is why the assertions pin absence and a
COUNT rather than presence, why the mutation check enumerates in-place emptying
as well as assignment, and why the regions are matched by BALANCED BRACES
instead of fixed-length windows — the fixed windows were escapable past their
end, and the load one bled past its `catch` into unrelated code where an
ordinary edit could fail the count for the wrong reason.

Three review rounds each defeated the previous version of these assertions with
a mutant written against them, and the file now says where that stops: a source
guard checks spellings, not behaviour, and the remaining escapes need someone
writing deliberately around a test in the file they are editing.

Comments are stripped before asserting, which is load-bearing rather than tidy:
the new helper's doc comment quotes `.catch(() => [])` as the thing it replaced,
so a raw-source guard would pass on documentation. The strip control is anchored
on a long-standing comment, after a first version anchored on the new one
reported "the strip is broken" against pre-fix source, where the strip was fine
and only the fix was absent — a control that fails for the wrong reason is not a
control.

Two assertions pass on BOTH builds, deliberately: the load site still has a
clear to gate, and the rows are still keyed. The second is the premise the whole
fix rests on — if someone unkeys those rows, this fix stops being sufficient and
that test is what should say so.

A WEDGE I SHIPPED INTO CI AND HAD TO FIX. The first version captured the held
item id with a plain `item?.id` read at the top of `loadData`. `loadData` is
called from an `$effect` tracking wsSlug/collSlug/itemSlug, and it WRITES `item`
further down — so that read made the effect self-invalidating. Dev throws
`effect_update_depth_exceeded`; the PRODUCTION build silently wedges the global
effect scheduler, and the app stops re-rendering with no error anywhere. CI's
E2E job died on it: 77 failures across the attachment specs, nothing to do with
relationships, and the job hit its 15-minute timeout.

CONVE-1688 names exactly this, and I had loaded it before writing the change.
The read is now `untrack(() => item?.id ?? null)`, with the reason at the site
and a test pinning it — because every cheaper gate passed while the built app
rendered nothing: svelte-check, vitest and the unit suites are all blind to it.

The gap that let it through is that my local gates never BUILT the change. The
worktree's `web/build` was copied from the main checkout, so vitest and
svelte-check read my source while nothing exercised it compiled. Verified the
fix the way that gap demanded: `vite build` + `make build-go`, then the failing
spec against that binary — 11/11 fail before, 11/11 pass after, and 11/11 pass
on a main-built binary as the control.

Gates: svelte-check 0 errors (1087 files), vitest 154 files / 2351 tests passed,
go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean. Full local E2E
on the built tree: 214 passed, 198 skipped, 2 failed — `pane-content-link-anchors:194`
and one no-store HEAD counting test, both of which also fail on main (see the
BUG-2871 trail: the ctrl-click popup timeout reproduces locally at roughly 1 run
in 3, on the CHILDREN leg too, which the merged Children fix did not eliminate).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 03:46:25 -04:00
xarmian 6d77856447 fix(web): unknown membership is not a denial — stop dropping it on a repeat resolve (TASK-2988) (#1313)
`workspaceStore.setCurrent` cleared `currentMembership` and `membershipKnown`
on entry so the permission helpers could not answer with the PREVIOUS
workspace's grants. Correct for a switch, wrong for a REPEAT: re-resolving a
workspace the session had already answered dropped it back to "unknown", and
consumers cannot tell unknown from denied — that indistinguishability is the
whole reason BUG-2978 added the flag. So a permission-gated `{#if}` reading the
store directly unmounted for the length of the refetch, destroying the
in-progress state of any dialog inside one. Consumers holding a sticky copy
rode it out, which is why the four sites this was found at are the ones that
did not.

The reachable route is `recoverIfMissing`, which the workspace layout calls
from its sync callback on every sync result and which re-resolves whenever
`current` is null or names a different workspace — as it does after a `create`,
which points `current` at the new workspace while the previous route stays
mounted. Traced sequence: open a collection page, open its ShareDialog or
EditCollectionModal, create a workspace from the topbar, and the next sync
result unmounts the dialog mid-edit. The dashboard's collection-editor modal
already carried a comment about exactly this unmount and dodged it by mounting
unconditionally; four sites had not.

Fixed in the store, not per consumer. `setCurrent` remembers the answer it
settles and serves it while a refetch for the same workspace is in flight, so
`membershipKnown` never drops to false for one already seen. The entry clear
still happens for a workspace with no answer yet, which preserves the original
invariant: the cache is keyed by the slug being SET, so it can only ever serve
that workspace's own grants. A cached denial is served too — denied is an
answer, and a denied workspace flickering to unknown is the same defect with
the opposite sign.

TWO FENCES on a settle, for two different races, behind one `settleIfCurrent`
so a call site cannot forget either. `membershipSeq` is the navigation fence
that already existed. The identity fence is new: logout is an SPA navigation,
so the cache outlives a sign-out and a request issued as one user can settle
after another has signed in. Both `setCurrent` and `create` capture the
identity BEFORE their first await — `create` before its POST, since capturing
on success would key the first user's membership by the second user's id — and
the cache key carries that identity. The fence covers the MEMBERSHIP settle
only; `current`, `workspaces` and an already-published membership are not
identity-scoped, and a sign-out does not clear them, which is BUG-2991.

The fences dispose of a rejected settle DIFFERENTLY, which is the part review
had to find twice. A superseded call returns silently: a newer call is already
speaking. An identity mismatch CLEARS to unknown, because if the answer being
published belongs to a user who is no longer signed in then so does whatever is
published right now — including an answer replayed from the cache moments
earlier, which is how a stale owner read would otherwise survive a sign-out.
Unknown is the fail-safe reading, since the helpers treat it as no access.

The identity read is `untrack`ed. `setCurrent` reads it synchronously before
its first await and not every caller is inside `untrack` — the settings page
calls `load(wsSlug)` straight from an `$effect`, which would otherwise start
re-running on any session change.

What this does NOT bound: a served answer is normally corrected by the same
call's settle, but a failed workspace GET settles a denial without reaching
`/me`, a superseded call settles nothing (its successor does), and a request that never
answers leaves the served value standing. An identity mismatch is the one case
that DOES heal itself: it also drops `current`, which is `recoverIfMissing`'s
retry condition, and that runs on every sync result. The server stays the
enforcement boundary; what is at stake is what the UI shows. Reviewing this
also turned up BUG-2990, a pre-existing dashboard cache gated on
`currentMembership !== null` that never clears on a definitive denial — filed
separately rather than widening this PR, alongside BUG-2991 for the unfenced
`create` mutations and the sign-out that leaves live permission state behind.

Tests: ten legs in `workspaceRepeatResolve.svelte.test.ts`. Five fail against
the unfixed store, verified by reverting it via `git show origin/main:` with a
grep confirming the build under test lacked the fix. Each fence was
mutation-tested individually and each mutant killed exactly one leg: keying by
slug alone kills the cross-user leg; removing the identity check kills the
mid-flight leg; returning instead of clearing on a mismatch kills the
replayed-answer leg; capturing `create`'s identity after its POST kills the
create leg; leaving `current` in place on a mismatch kills the replayed-answer
leg's self-healing half. Two more legs guard the over-reach this could become (one
workspace's answer is never served for another; a later answer replaces the
cached one), and one is the counterfactual that must pass on both builds: a
workspace with no answer yet still clears.

Two existing tests asserted the old behaviour and were updated rather than
softened. `workspaceMembershipKnown` now resets modules per test: its cases all
resolve the same slug and one asserts the FIRST-resolution unknown window,
which a sibling's remembered answer would otherwise satisfy — the isolation was
accidental before. The settings flicker test's known → unknown → known
transition is no longer producible by a second `setCurrent`, so it asserts that
the window does not open, keeping its non-vacuity check; that is an accepted
coverage loss, stated at the test, and its denial sibling still exercises the
discriminating half of the page's sticky read.

Prose the change falsified, swept: the `membershipKnown` doc comment, the
settings test's header, and two dashboard comments that described a repeat
`setCurrent` as clearing membership. Four comments mark the owner-gated blocks
wrapping stateful dialogs as consumers relying on the store guarantee.

Gates: svelte-check 0 errors (1086 files), vitest 153 files / 2342 tests
passed, go test exit 0 with no FAIL, golangci-lint 0 issues, gofmt clean.

Assessment this came out of, and the correction to it: TASK-2982.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-10 01:55:52 -04:00
xarmian cece78a703 feat(collab): applier-availability pre-check on RoomManager (TASK-2987 / PLAN-2975 unit 1) (#1314)
* feat(collab): applier-availability pre-check on RoomManager (TASK-2987)

PLAN-2975 decision 1. HasElectableApplier answers "would an external
content update for this item route through a designated applier right
now?", so handleUpdateItem can learn it is on the applier path BEFORE
it writes the row — today the only way to learn that is to apply, and
by then the content is already in the live Y.Doc (BUG-2840 half A).

It is a hint, not a lock: it registers nothing, takes no admission, and
enters no gate, so a concurrent ApplyExternalContent neither blocks it
nor sees it. Eligibility is delegated to pickApplier rather than
restated, so the hint and the elector cannot drift.

The error direction is asymmetric on purpose. A false negative is the
defect (it sends the caller back to apply-then-write); a false positive
costs a typed partial answer, which PLAN-2975 decision 2 owes anyway.
So an in-progress version restore answers TRUE rather than consulting
the conns: ForceRefreshRoom freezes every conn and pickApplier skips
frozen conns, yet a restore ROLLBACK unfreezes them and elects an
applier — so a bare pickApplier check answers false for exactly the
window this is required to compose with.

Eight legs, including a negative control: the pickApplier-only
formulation must answer false during a restore, or the table would be
evidence about neither implementation.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(collab): pin the m.closed guard, which Close makes unreachable through the public API (TASK-2987)

The manager-closed leg passed with the guard deleted: Close sets
m.closed and empties m.rooms in one m.mu critical section, so the room
lookup already answers nil. Measured as a surviving mutant, not
assumed. The new leg constructs closed-with-rooms-populated — a state
Close does not produce — because that is the coupling the guard breaks.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* docs(collab): narrow two claims in HasElectableApplier's contract that the code does not support (TASK-2987)

Codex round 2, adversarial on the unit's own claims.

'A false positive costs a partial answer, not corruption' was a claim
about the whole path stated as if it were about this return value. The
fallback that exists today can write content past live peers: when
applyContentViaCollab exhausts its ErrRoomActiveDuringPrune retries the
handler falls through to a plain direct write while a live writer may
hold a Y.Doc that outvotes it. That predates this function; the comment
now says so rather than implying it cannot happen.

'The hint and the elector cannot drift' reads as 'cannot disagree'. They
cannot disagree about the RULE, since the predicate is shared. They can
disagree about the ANSWER, and the routes are now enumerated: a fresh
writer joins, an unanchored conn finishes replay, a view-only conn is
promoted by the periodic revalidation, a restore rolls back.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-10 00:55:22 -04:00
xarmian 793fad959c docs(mcp): the reason given for the protocol restriction was false — replace it with the true one (TASK-2977) (#1312)
TASK-2977 step 1 restricted the remote transport to the handshake era
and gave this reason: pad_set_workspace pins a session default workspace
that the stateless era has nowhere to keep, so pad "is not known to be
able to serve" that era. It is plausible and it is false for THIS
transport, and I wrote it reasoning from the tool's purpose rather than
from its remote behaviour.

cmd/pad builds the cloud dispatcher with a SHARED workspace state whose
ResolveDefault() returns "" by construction — BUG-1865, the cross-user
workspace bleed — so the pin is recorded and never consulted here.
Resolution on /mcp is the explicit workspace argument, else a default
derived per request from the caller's own OAuth identity and token
allow-list. Every input comes from the request. This transport has been
stateless with respect to workspace resolution since that bug was fixed,
and the fix for a cross-user bug turns out to be most of the work a
stateless era would need.

THE TRUE REASON IS BETTER AND WAS ONE FILE AWAY. The
mcp-active-sessions gauge is keyed on the Mcp-Session-Id header, and the
generate-only session-id manager at this transport's call site exists so
that header is always minted and the gauge stays observable (PR #400
round 1). SEP-2567 REMOVES session IDs in 2026-07-28 — a server serving
that revision never mints or echoes one — so in that era nothing pad
mints is available to key on.

Ruled day 62: that is an accepted cost, not a blocker, and it is
recorded where the key is CHOSEN rather than only where the era is
refused — middleware_mcp_session.go now carries the obligation on
whoever opens that era to re-key the gauge first, and says why the era's
arrival is exactly the moment a silently-flat gauge gets read as "no MCP
traffic" instead of "no measurement".

The superseded reason is kept in the comment as superseded, four lines
of it, because the false reason is the PLAUSIBLE one: the next person to
reason about the stateless era from pad_set_workspace's name will reach
for it, and the comment now meets them with the shared-state mechanism
instead.

TWO CODEX ROUNDS, TWO FINDINGS, both about this change's own prose and
both verified in the code before accepting:

  - The server-package comment named mcp.ServedProtocolVersions as a
    symbol. It is not reachable from there — internal/mcp imports
    internal/server and not the reverse, which is also why the transport
    reaches the router as a plain http.Handler. Named by path now, with
    the direction stated.

  - "Modern-era traffic would be invisible to the gauge" was
    OVERSTATED, which is this unit's own defect class arriving inside
    the fix for it. trackMCPSession resolves the id from the response
    header and FALLS BACK to the request header, so a modern-era client
    that volunteers an Mcp-Session-Id is still tracked. The accurate
    claim, now in both files: the gauge stops depending on anything pad
    mints and starts depending on whether clients keep sending a header
    the spec removed — under-counting by a margin nobody controls,
    rather than a flat zero.

Comment-only; no behaviour changes. The restriction, its four tests and
the derived version set are untouched.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 20:35:44 -04:00
xarmian 9df2da2c97 fix(web): a background children refresh keeps its row nodes, so clicks are not swallowed (BUG-2871) (#1311)
* fix(web): a background children refresh keeps its row nodes, so clicks are not swallowed (BUG-2871)

`loadChildren()` set `loading = true` before every fetch, and the template swaps
the whole list for a spinner while loading. Any `item_created` in the workspace
triggers that refresh through ChildItems' SSE subscription, so with other people
working it fired constantly — and each time every row node was destroyed and
rebuilt, even when the data came back byte-identical.

A click needs mousedown and mouseup on the SAME node. When a refresh landed
between them no click event fired at all: no navigation, no in-pane drill, and
no error anywhere. In CI that presented as the pane-content-link-anchors
ctrl-click popup timing out on `waitForEvent('page')`, intermittently, at three
different lines over three months. For a user it silently drops a click on a
child row — plain click included, which is why this stopped being a test-only
concern once the mechanism was reproduced.

Measured rather than argued, in a real browser:

- the row, its wrapper and its container were all disconnected by a refresh
  whose data was identical, while the zone element survived, with the loading
  spinner observed in between;
- replacing an anchor's node identity between mousedown and mouseup reproduces
  all three CI observations at once — no popup, no drill, no error;
- a control leg (ctrl-click, no interference) opens the popup, and a leg with a
  1px pointer move between down and up ALSO opens it, which refuted the first
  hypothesis: svelte-dnd-action swallowing the click. The rows do sit in a
  `dndzone`, so that story was plausible and wrong, and the experiment is what
  said so.

The fix shows the spinner only when there is nothing valid on screen for the
current item — a first load or an item switch. A same-item refresh now leaves
the rendered rows mounted, so an unchanged row keeps its DOM node and a click in
flight survives. The switch case still tears down deliberately: `children` is
not cleared when `itemSlug` changes, so without the spinner the previous item's
rows would sit there looking current until the new load lands.

The regression test asserts the PROPERTY (an unchanged row keeps its node across
a refresh) rather than the symptom, because the symptom is timing-dependent and
the property is not. It carries a non-vacuity guard that a children fetch
actually happened, since "the node survived" would otherwise pass trivially if
no refresh ever ran. Verified against the unfixed component with the binary
rebuilt: it fails with "the child row node was replaced by a refresh".

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a failed background refresh must not destroy the rows either (BUG-2871, codex round 1)

Two P2s, both mine.

**The error branch is the same defect through a different door.** My fix stopped
`loading` from tearing the list down on a same-item refresh, but a refresh that
FAILS sets `error`, and the template's `{:else if error}` replaces the child
list exactly as the spinner did. A transient refresh failure could therefore
still destroy the row node under a live pointer and swallow the click. It also
called `onChildrenChange?.([])` while `children` still held the rows, telling the
parent we had none.

A background refresh that fails now keeps the last good rows and stays quiet;
the next refresh retries, and they arrive constantly since any `item_created` in
the workspace triggers one. That failure is then invisible, which is a real
tradeoff and worth naming rather than glossing: the alternative in the code
today was destroying the list under the user's pointer, and the initial-load and
item-switch cases still surface errors normally.

**The regression test could pass on a broken build.** It polled request
initiation and asserted immediately, so it could observe the node still
connected before Svelte had processed the state change that replaces it — a
race-dependent false pass. It now counts RESPONSES, waits for the refresh to
settle, and gives the component two animation frames plus a settle before
asserting.

Verified the hardened test still discriminates — and the first attempt to check
that was WRONG in a way worth recording. I reverted with `git checkout --
ChildItems.svelte` and the counterfactual passed, which looked like the test had
stopped working. The file had been COMMITTED by then, so checkout restored the
fix rather than removing it: I had rebuilt and retested the fixed build and read
it as evidence about the unfixed one. `git show origin/main:<path>` gives the
real thing, and against that the test fails with "the child row node was
replaced by a refresh".

Found by Codex review round 1 (two P2s).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* ci: re-run after pre-existing :194 flake (BUG-2871, run 34413842449)

The E2E row on the previous run failed at `pane-content-link-anchors.spec.ts:194`
— the Relationships leg — while this branch fixes the Children one. Established
as unrelated three ways: the diff touches `ChildItems.svelte` only; a
node-identity probe shows the relationships list KEEPS its nodes across a
background refresh, refuting the obvious sibling hypothesis; and main's E2E
failed at that same `:194` on 2026-09-02 (run 33673588422), a week before this
change existed.

That evidence is not a licence to merge with a red row — the next case will look
just as strong and be wrong — so this re-triggers CI instead.
`gh run rerun --failed` is refused on the previous run ("cannot be rerun; its
workflow file may be broken"), which is why this is an empty commit rather than
a re-run.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 19:34:50 -04:00
xarmian d83632e258 feat(attachments): extension trust — the audio/video split, the text family, the CFB office trio and RTF (TASK-2976 / BUG-2963 PR B) (#1309)
* test(attachments): record the Void-beyond-window limit, and correct three comments that claimed more than their evidence (TASK-2976)

The first commit of BUG-2963 PR B, before any extension-trust work: round
6's three items, deliberately kept out of PR A so its verified tip would
not move, plus the nix terminator-comment correction folded in from
BUG-2974's trail.

1. TestMatroskaDocTypeBeyondTheWindowIsWebM records a LIMITATION. A Void
   element is legal anywhere in an EBML header and may be any size; make
   one larger than the 512 bytes this door reads and the DocType behind
   it is not in the input at all, so the parse finds nothing and the
   stdlib's video/webm stands. Nothing got worse — the same file was
   video/webm before the DocType read existed, and video/webm's own
   allowlist entry permits inline serving — and no larger window fixes
   it, since Void may be larger still. The fixture is the ordinary
   FFmpeg Matroska with a 560-byte Void spliced into its header and the
   header size widened to match, the same construction as
   matroska-void-padded.head512; the complete file reads as
   matroska,webm under ffprobe, and what is committed is its first 512
   bytes, so the DocType is absent by construction rather than by
   truncation accident.

2. TestTarWinsAPrefixCollision no longer calls the collision
   "asymmetric". That was the round-4 premise round 5 refuted with
   flac-ustar-in-comment.head512 — a FLAC's Vorbis COMMENT tags are
   arbitrary UTF-8, so real audio carries "ustar" at offset 257 as
   readily as a real tar carries an audio marker at offset zero. The
   implementation and the fixtures already said so; the stale word
   survived in the place a reader looks first. The comment now gives the
   real reason tar leads the default order, which is weaker: its magic
   sits at a fixed offset rather than at a prefix.

3. The seven-byte textual-AAC input's comment says which of the two
   things it is. It is a valid ADTS sync and layer signature that the
   stdlib reads as text — not a decodable AAC, and nothing in the test
   establishes that it is. What review established is the case it stands
   in for, and the comment now separates the two.

4. The nix loop-terminator comments named the wrong mechanism
   (BUG-2974, observed day 62). The heal push cannot loop because a push
   made with the default GITHUB_TOKEN creates no workflow runs at all —
   `gh run list --branch main` spans cbfc073e -> 31d11e76 -> 0b16be49
   and skips the heal commit 249a8f87 entirely. The corrected-tree
   argument the comments gave is true and UNEXERCISED; it is now written
   as the backstop, with the tripwire that it becomes load-bearing the
   moment that push uses a PAT or app token. The per-commit query is not
   the instrument for this and the comment says so: it also returned
   nothing for 0b16be49, which has runs.

Also swept: the fixture README's "the two EBML entries" was already
false at four, and nix.yml's "THAT DISTINCTION" lost its referent once
paragraphs landed above it. The new fixture joins the FuzzSniffMIME
seeds.

go test ./internal/attachments/ green; nix/heal-vendor-hash_test.sh 20
passed, 0 failed.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the ISO-BMFF audio/video split — .mov and .m4a stop being refused (TASK-2976, BUG-2963 F4)

Both iPhone-default containers were refused at upload while their types
sat on the allowlist. Measured on 06ccabdd before the change:

  clip.mov  (qt   brand) -> sniff application/octet-stream -> mime_not_allowed
  clip.m4a  (isom brand) -> sniff video/mp4 -> mime_extension_mismatch
  clip.m4a  (M4A  brand) -> sniff video/mp4 -> mime_extension_mismatch

Three legs, per the day-62 per-family ruling on BUG-2963:

1. Major brand "qt  " -> video/quicktime. The stdlib's mp4 matcher wants
   a brand beginning "mp4" and a QuickTime file carries none, so it had
   no opinion at all. This ADDS a detection, exactly as the still-image
   brands do.

2. Major brand "M4A " -> audio/mp4. This is the one place this package
   OVERRIDES a type the standard library named: an .m4a's compatible
   brands routinely include "mp41", so the stdlib answers video/mp4 from
   a compatible brand, having no way to weigh it against the major one.
   The major brand is the file's own statement about itself. The
   override is bounded — both types are allowlisted, both render inline,
   and the only thing that moves is the CATEGORY, which decides whether
   the UI offers an audio player or a video one.

   It is read from the MAJOR brand alone and BEFORE the mp4-yield, which
   is the whole mechanism: the yield would otherwise return "" on that
   same mp41 brand before the check could speak. Both facts are stated
   in the code.

3. isom-branded bytes named .m4a -> audio/mp4, in ValidateUpload beside
   the zip+document branch and as the same kind of trust one family
   over: the BYTES establish the container, the FILENAME chooses only
   the spelling within it. No track reads, per the ruling.

sniffISOBMFFImage is now sniffISOBMFF. It answers for audio and video
now, and a name saying Image while it returns audio/mp4 is the exact
defect class this bug keeps producing.

The controls are the test. An accept-only test is satisfied by a rule
that trusts .m4a outright, which is a much larger claim than the one
granted, so each leg carries what the STDLIB said about the same bytes
(the thing separating "adds a detection" from "overrides one"), and the
extension leg carries four: the same bytes named .mp4 stay video, the
same bytes with no extension stay video, a WebM named .m4a is still
refused (the trust does not reach outside the MP4 family), and PNG bytes
named .m4a are still refused (the extension introduces no type). A
synthetic ftyp box with major isom and "M4A " among its COMPATIBLE
brands is refused too — reading a category out of the compatible list is
a wider rule than the one granted.

Fixtures: quicktime.head512, m4a-brand.head512, m4a-isom.head512 — real
FFmpeg n7.1 output, provenance in the fixture README. Swept prose that
said this pre-check is still-images-only (SniffMIME's doc comment, the
mime_isobmff.go header).

go test ./internal/attachments/ green. Mutation matrix next, then F5.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* refactor(attachments): the .m4a branch's category guard was dead — assert the invariant it stood for instead (TASK-2976)

A mutation run says the guard survives removal, and it is right: the
branch answers for exactly .m4a, so extEntry.Category == CategoryAudio
can never differ from the ext test beside it. (The zip branch's
identical-looking guard IS load-bearing, because that branch answers for
many extensions at once.)

What the branch actually depends on is that .m4a maps to an allowlisted
audio entry, since it returns that entry. That is now asserted directly
in TestBUG2963F4M4AExtensionTrust, so a remap of .m4a fails a test
instead of quietly retyping MP4 bytes. Dead code replaced by a tested
invariant, rather than kept as defence in depth it cannot provide.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the text family — the extension chooses which text, category unchanged (TASK-2976, BUG-2963 F5)

text/plain is the stdlib saying "these bytes are text" and nothing more;
it has no signature separating Markdown from YAML from JavaScript
because at the byte level there is none to have. So when the sniff is
text/plain and the extension maps to an allowlisted TEXT entry, that
entry is what gets stored. Per the day-62 ruling.

Category-preserving is the whole safety argument: nothing becomes an
image, an archive or a document by being renamed, and a mapping that is
not an allowlisted text entry does not fire — .svg still reaches
extension_blocked rather than being quietly stored as text.

TestTextFamilyStillStoresAsPlain is now TestBUG2963F5TextFamily. It
recorded this boundary and said in its own failure message that F5
landing should move it; it kept the same three inputs and changed the
expectation, which is what "move it" meant.

THE SERVING BUCKET MOVES FOR EVERY TYPE F5 TOUCHES, NOT ONLY .js.
text/plain is the only member of the text family in inlineSafe, so
choosing any other text spelling takes the file out of inline serving
and into Content-Disposition: attachment. The ruling weighed that for
.js, where the move to RenderForceDownload is the point. It applies
equally to .md, .csv, .json, .yaml and .toml, where it is a side effect
— and it turns out to be the honest one: the client's own DR-5 already
says every text/* subtype but plain text "is downloaded or rendered
inconsistently across browsers", and those types only reached the
browser-preview affordance because the server was storing them as
text/plain. Whether any of the five belongs in inlineSafe is an
allowlist decision and is not taken here.

The in-app markdown preview is unaffected: it fetch()es the bytes and
renders them itself, which an attachment disposition does not impede,
and it now matches on the MIME rather than falling back to the
extension. Its prose in web/src/lib/attachments/display.ts said an
uploaded .md "is stored as text/plain" as a present-tense measured
fact; that is now history, and the fallback it justifies is what
PRE-EXISTING rows still depend on, so the comment says that instead
(CONVE-23, and its mirror in display.test.ts).

Controls, since an accept-only test is satisfied by "trust text
extensions outright": .txt / an unmapped extension / no extension at all
stay text/plain; PNG bytes named .md are still a mismatch; text bytes
named .mp3 are still a mismatch (an allowlisted mapping in another
category is the dangerous shape and the one the category guard is for);
.svg still fails extension_blocked; and the .js leg asserts RenderMode
== RenderForceDownload against the same bytes named .txt, not merely
that the type changed.

go test ./internal/attachments/ green. Mutation matrix next.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* feat(attachments): the legacy Office trio and RTF — two more families the door refused (TASK-2976, BUG-2963)

CFB. The container legacy Office wrote .doc/.xls/.ppt into has no stdlib
signature, so every one of those files sniffed application/octet-stream
and was refused while all three types sat on the allowlist. The branch
reads eight fixed bytes and says only "this is a CFB container", because
that is all eight bytes can say: .msi installers and Visio files wear
the same header, and telling them apart is a directory-stream walk this
door does not do. So the extension chooses which of the three REVIEWED
types is stored, exactly as the zip+document branch does one container
family over.

The extension list is written out rather than derived from extMIMEMap.
The question is not "does this extension map to a document" — .vsd and
.msi are CFB too — it is "is this one of the three the allowlist
reviewed", and that is a list, not a predicate. .docx is the sharp
control: a document extension whose type is allowlisted but whose
container is zip, refused on a CFB header.

RTF. Printable ASCII, so the stdlib answers text/plain — an OPINION, not
the absence of one — and a .rtf was refused as a mismatch against its
own type. This one needs no extension: five fixed bytes at offset zero,
one of them a version digit the spec fixes, is a stronger signature than
the three-byte "ID3" the stdlib uses for audio/mpeg. So it is a
refinement in SniffMIME rather than extension trust in ValidateUpload,
and the tests assert it under three filenames including .bin, which is
what says so.

text/plain therefore joins the refinement switch, and the comment there
says what makes that case narrower than the octet-stream one: it admits
exactly one signature, and the bar for a second is the argument at
validRTFStream rather than the list's existence.

One behaviour change beyond the refusals: an EXTENSIONLESS RTF used to
store as text/plain and serve inline; it now stores as application/rtf
and serves as an attachment. More conservative, and named here because
it is the one leg that changes a file that was previously accepted.

CONVE-23 sweep, three sites, all of them prose this change made false:
SniffMIME's "two refinements", and the F6 comment claiming
text/javascript "is not reachable EITHER" — F5 made it reachable, which
is the whole point of including .js.

The CFB fixture is hand-built and the test says why that is sound here
and not elsewhere: the branch reads eight fixed bytes, so eight bytes
exercise all of it, unlike the ISO-BMFF and EBML fixtures where the code
walks structure an encoder produces.

go test ./internal/attachments/ green.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* fix(attachments): gate the CFB branch on the stdlib verdict, not the refined one (TASK-2976, BUG-2963)

A surviving mutant found this. Dropping the octet-stream gate changed
no test, which said the gate was either dead or untested; it is
untested, and it was also gating on the wrong value.

A CFB file's 512-byte header is sector data, so one can carry "ustar" at
offset 257 and be refined to application/x-tar by this package's own
magic table. Gated on the REFINED verdict, that file is refused under
its own .doc name — the false refusal BUG-2963 exists to remove. Gated
on the stdlib's, the question is whether anything IDENTIFIED the bytes,
and nothing did.

This is the reasoning the .aac branch already carries, in the same
function, for the same collision from the other side. The test is a CFB
header with ustar spliced into its sector data, and it asserts the
premise (these bytes really do refine to x-tar) before asserting the
outcome, so it cannot pass by the collision quietly failing to happen.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 19:23:18 -04:00
xarmian a357b9f609 fix(mcp): restrict the remote transport to the protocol era pad can serve (TASK-2977) (#1310)
* fix(mcp): restrict the remote transport to the era pad can serve (TASK-2977)

mcp-go 1.0 implements the stateless protocol core from 2026-07-28 — no
handshake, no sessions, per-request identity in _meta — and its
Streamable HTTP transport advertises EVERY revision it implements by
default, serving both eras concurrently on one endpoint and deciding the
era per request. pad's construction site passed no version restriction,
so the library bump alone had main answering modern-era traffic through
server/discover while pad://_meta/version still published 2025-11-25 as
the maximum revision this server can negotiate.

NOT A PRODUCTION DEFECT, checked rather than assumed: app.getpad.dev and
mcp.getpad.dev both report commit 0e2cb06a built 2026-08-31, nine days
before the mcp-go 1.0 merge, and 0.58 has no 2026-07-28 constant at all.
The gap is in main, ahead of a hand deploy, so this lands before it can
become real.

Beyond the mismatch: pad_set_workspace pins a session default workspace
and the stateless era has no session for that pin to live in. So the
modern era is not something pad happens not to advertise, it is
something pad is not known to be able to serve. Establishing what it
would take is step 2 of this item; the honest advertisement meanwhile is
the era pad was built and tested against.

The set is DERIVED from mcp.LegacyProtocolVersions(), the SDK's own
answer to "which revisions use the handshake", so a future SDK adding a
legacy revision includes it and one adding a modern revision excludes
it, with no edit here. A hand-written list would silently mean the wrong
thing after either bump — the same shape as the defect being closed.

The option set moved into mcpserver.NewRemoteTransport so a test can
drive what cmd/pad actually constructs. An advertised set is only
correct if the option is PASSED, and a test building its own transport
would vouch for the option and not for the binding (CONVE-19).

Four tests, and the second is what makes the first mean anything:

- a well-formed 2026-07-28 server/discover against pad's transport is
  refused with code -32022, data.requested naming the version and
  data.supported carrying exactly the legacy four. Asserting "an error
  came back" would also pass on a transport that had simply broken.
- the identical bytes against an UNRESTRICTED mcp-go transport are
  SERVED, with 2026-07-28 among supportedVersions. Negative control: it
  is what says the refusal comes from pad's option rather than from a
  malformed request or a changed library default. Both requests carry
  the Mcp-Method header the modern era requires, so a refusal cannot be
  about headers.
- initialize still negotiates 2025-11-25 — the restriction must not
  break the era pad actually serves.
- AdvertisedMCPProtocolVersion equals the newest served revision, which
  is the half TestAdvertisedProtocolVersion structurally cannot see: it
  pins the literal to what the HANDSHAKE answers, and the modern era has
  no handshake.

Sweep: meta.go's two comments described the handshake cap as the whole
story. One construction site only — pad-cloud is an OAuth/billing layer
and builds no transport, so the pad binary in cloud mode is the single
place this is decided.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn

* test(mcp): the handshake test measured less than its comment claimed (TASK-2977)

A mutation found this in my own instrument. Restricting the advertised
list to a version that EXCLUDES 2025-11-25 leaves the legacy-handshake
test green, so that test cannot be evidence that the restriction
preserved the era pad serves — which is exactly what its comment said it
was.

The mechanism, read rather than inferred from the green: initialize is
answered by MCPServer through mcp.NegotiateLegacyVersion, which consults
LATEST_LEGACY_PROTOCOL_VERSION and never the transport's list. The two
are independent.

So the comment now says what the test measures (the legacy path works)
and what it does not (that the restriction preserved it), and the
independence is pinned as its own subtest: a transport advertising only
2025-06-18 still answers initialize with 2025-11-25. If that ever fails,
the handshake has become coupled to the advertised list and the
restriction has become able to refuse legacy clients — the moment this
file needs a different test.

Nothing about the fix changes; the claim about the evidence does.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 19:22:58 -04:00
dependabot[bot] d89d624c56 chore(deps)(deps-dev): bump vitest from 4.1.11 to 5.0.0 in /web (#1277)
* chore(deps)(deps-dev): bump vitest from 4.1.11 to 5.0.0 in /web

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: pin the repo's Node floor to 24 (TASK-2971)

vitest 5 declares `engines.node = "^22.12.0 || ^24.0.0 || >=26.0.0"`, so this
bump makes the Node version a thing the repo cares about rather than a detail of
whoever's box. Measured before Dave moved this machine's default: on Node 25.8.1
with `engine-strict=true`, `npm ci` against this branch failed outright —

    npm error notsup Required: {"node":"^22.12.0 || ^24.0.0 || >=26.0.0"}
    npm error notsup Actual:   {"node":"v25.8.1","npm":"11.11.0"}

— which takes out every make target whose chain reaches `npm ci`: web, build,
install, serve, web-check, check. That includes `make install`, the dev-server
refresh. CI was unaffected throughout, because `ci.yml` pins node-version 24;
the failure was only ever local, and it was invisible from CI by construction.

`24` matches what CI already pins, so the file documents the version the project
actually builds against rather than introducing a second opinion. It is not wired
into the workflows here — they name 24 explicitly and changing them to read this
file is a separate decision.

Verified against the MERGE RESULT of this branch and main, not the branch alone:
`npm ci` exits 0 under Node 24.21.0, and the suite is 152 files / 2332 tests
passing on vitest 5 — the same counts as main on vitest 4, which is the guard
against a suite that goes green by collecting less.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-09-09 18:12:06 -04:00
xarmian a6024600a9 fix(web): wrap the admin console tab strip instead of hiding its scrollbar (TASK-2979) (#1308)
* fix(web): wrap the admin console tab strip instead of hiding its scrollbar (TASK-2979)

The class sweep out of C82 (TASK-2245). The admin console used the same shape
the workspace settings strip did: below 640px it scrolled with
`scrollbar-width: none`, so the row ended after a tab with clean trailing
whitespace and the tabs past the fold were unadvertised rather than merely
awkward to reach.

Measured before, against two tab sets:

| viewport | self-host (4 tabs) | with the two cloudMode tabs |
|---|---|---|
| 320 | Settings 61% | MCP Audit 51.5%, Billing 0%, Settings 0% |
| 360 | clean | MCP Audit 96.6%, Billing 0%, Settings 0% |
| 390 | clean | Billing 36.1%, Settings 0% |
| 412 | clean | Billing 70.6%, Settings 0% |
| 430 | clean | Billing 98.9%, Settings 0% |

After: zero clipped at every width from 320 to 1280, on both tab sets, and
640/1280 stay one row at 35.9px — identical to the scrolling build, which is
the inertness of `flex-wrap` measured rather than assumed. `flex-shrink: 0` and
`white-space: nowrap` are hoisted out of the deleted mobile block so a wrapped
row still breaks between tabs and never inside a label.

The cloud tab set is a RECONSTRUCTION: this instance is self-host, so the two
`cloudMode` tabs were cloned into the live strip with their real labels. Same
CSS, same fonts, real boxes, but not a measurement taken on a cloud instance.

The deleted block's own comment claimed the scrollport made every tab
"reachable ... without wrapping or clipping". Half of that was true. The
replacement comment says which half.

The e2e leg runs at 320px rather than the mobile project's 412, because
self-host renders four tabs and they fit from 360 up — at 412 the leg would
pass on the broken build. Both legs carry a non-vacuity precondition, and the
320 one fails on the unfixed build in both projects while the desktop control
passes on both.

EditCollectionModal, the third site in the class, is deliberately NOT changed.
Measured: it clips only at 320 (Quick Actions 53.6%) and is clean at 390 and
430, and it already carries an edge-fade mask that advertises the overflow — so
the tab past the fold is signposted rather than hidden, which is the property
this class of bug is about. It is also a modal, where vertical space is the
constrained axis and wrapping costs more than it does on a page.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): the non-vacuity precondition asserts overflow, not tab identity (TASK-2979, codex round 1)

The comment said a renamed or dropped tab would trip the precondition. It would
not necessarily: `intrinsicWidth > clientWidth` is a claim about the row not
fitting, and the remaining tabs may well still overflow at 320px. What the
precondition actually rules out is the case that would make the leg vacuous — a
tab set that starts FITTING at this width, which would turn "nothing is clipped"
into a statement about a row that never needed to wrap.

Narrowed to what it proves, in both the docblock and the assertion comment. No
test behaviour changes.

Found by Codex review round 1 (nit; no P1/P2 findings).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 17:20:17 -04:00
xarmian 06ccabddb0 fix(web): settings permissions are sticky, so the owner-only tab survives the /me window (BUG-2978) (#1307)
* fix(web): settings permissions are sticky, so the owner-only tab survives the /me window (BUG-2978)

Deep-linking `/{user}/{ws}/settings#danger` landed on General for a workspace
OWNER — 0/10 loads, at both 390px and 1280px, while `#storage` and `#members`
were 10/10.

The hash-restoration effect was not the fault, which is where I looked first.
Instrumenting it showed the effect applying `danger` correctly at 219ms and
losing it at 244ms. `workspaceStore.setCurrent` clears `currentMembership` to
null before `/me` resolves and the permission helpers treat unknown as
no-access by design; this route calls `setCurrent` TWICE per load, once from
the workspace layout and once from the page's own `load()`. So
`canEditWorkspace` reads true -> false -> true, the owner-only tab drops out of
the tab set during the false window, the effect's snap-back branch moves
`activeTab` off the now-invalid `danger`, and `pendingHash` was already
consumed — nothing restores it when the permission returns. Only the owner-only
tab could hit this, which is exactly why `#storage` never did.

The page now reads its permissions through sticky state that updates only when
membership is definitively known, reset on a real workspace switch — the same
two-effect shape the dashboard already uses for its owner-gated CTA, and for
the same reason (CONVE-606). `isOwner` and `canExport` get the same treatment,
because they flicker identically and gate ~15 controls on this page: read
straight from the store, an ordinary owner load makes the Save buttons, the
invite form and the delete controls go readonly and then come back.

One reset effect owns all three. Two effects testing the same
`wsSlug !== lastPermSlug` could never both fire, since whichever ran first
would have already updated the marker — an error in my first draft of this fix.

Default stays false, so owner-only chrome still never flashes before `/me`
confirms; the server-side owner check remains the enforcement boundary.

Measured after the fix: 40/40 deep links land across `#danger`, `#storage`,
`#members` and `#collections` at both widths, against 0/10 for `#danger` on
main.

The regression spec settles and then asserts ONCE, rather than using an
auto-retrying assertion: the pre-fix failure is "correct, then reverted", so a
retrying matcher can observe the correct intermediate state and pass on a
broken build. The owner leg asserts again after a further wait so a later
revert is still caught, and carries a non-vacuity check that the fixture user
really is an owner.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): make the BUG-2978 guard the unit test, because the e2e leg does not discriminate

The e2e spec I wrote for this fix PASSES ON THE UNFIXED BUILD. I checked, which
is the only reason this is a commit and not a false green: on the e2e fixture
the layout's `setCurrent` and the page's own land inside a single unresolved
`/me` window, so membership never goes known -> unknown -> known and the flicker
the bug needs never occurs. The ordering is a property of a small fast fixture
workspace, not of the product — the real workspace produces it readily.

So the e2e spec is relabelled a smoke leg and says plainly, in its own docblock,
that it does not guard this bug and which test does.

The guard is a jsdom test that drives the two `/me` resolutions by hand, which
makes the sequence deterministic rather than dependent on fixture speed. Run
against the unfixed page it FAILS; against the fixed page it passes.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): tell "membership not fetched yet" apart from "no access" (BUG-2978, codex round 1)

The sticky permission cache from e6ed6fdd updated only when
`currentMembership !== null`, and null means BOTH "not fetched yet" and "no
access". So the cache held the last good answer forever once the answer became
a denial: an owner removed from the workspace, or a `/me` that 403s, kept the
Danger Zone tab, the Save buttons and the delete controls on screen
indefinitely. Server enforcement meant it was stale UI rather than an escalation
— which is why it is a behaviour regression I introduced and not a hole that was
already there.

The store now says which of the two a null is. `membershipKnown` is false while
a membership fetch is in flight and true once one has settled either way,
including the 403 path and the workspace-did-not-resolve path. The settings page
gates its cache on that instead, so the cache still covers exactly the in-flight
window it was written for and nothing else.

Second test leg, and it is the complement rather than a repeat: it drives `/me`
to REJECT and asserts the owner-only tab leaves. Each leg fails against the
code it guards and passes against the other — leg one fails on the pre-fix page,
leg two fails on e6ed6fdd's non-null gate. Verified by running all three
versions, not by reading them.

Dropped `vi.resetModules()` from the test's afterEach: it gave the second test a
fresh module graph including a second copy of the Svelte runtime, whose
`$effect` did not recognise the first copy's component context, and the remount
died with `effect_orphan`.

Found by Codex review round 1 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): settle membershipKnown when workspace creation fails (BUG-2978, codex round 2)

`create()` clears membership at entry for the same reason `setCurrent` does, and
a rejected `api.workspaces.create()` returned before anything settled the flag —
so a failed create left `membershipKnown` false permanently. That is the worst
value for it to be stuck at: consumers cache THROUGH the false window, so a page
still mounted would hold its permissions and never update again. Membership is
already null at that point, so settling it true says "no access", which is what
the null already meant before the flag existed.

Also narrows a claim I made in the round-1 comments and commit message:
`membershipKnown` is not "false only while a membership fetch is in flight". It
is false from the start of any call that will replace membership — workspace
resolution and creation included — and true once that call settles on every
exit. The prose now says that, because the flag is a contract other consumers
will gate on and the narrower sentence was the one that made this path look
impossible.

Four store-level tests cover the arms: in-flight, 403, workspace-not-resolved,
and failed create. The last one was mutation-tested — removing the line that
settles the flag makes exactly that leg fail and leaves the other three green.

Found by Codex review round 2 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a failed create says nothing about the current workspace (BUG-2978, codex round 3)

Round 2's fix was pointed at the wrong thing. `create()` cleared membership at
ENTRY, mirroring `setCurrent` — but `setCurrent` is switching to a workspace it
already names, while a create that fails leaves the current workspace exactly as
it was. So the clear was asserting something about the wrong workspace, and
settling `membershipKnown` on the failure path made that assertion louder rather
than removing it: it turned "we don't know" into "no access to the workspace you
are still looking at", which hid a mounted settings page's owner controls until
the next `setCurrent`. I fixed the symptom my flag exposed instead of the clear
that was wrong underneath it.

Nothing is cleared now until the create has SUCCEEDED, so a failure changes no
state at all. The sequence token is claimed after the call rather than before,
which keeps the guard it was written for: this create still supersedes anything
started before it, and a `setCurrent` started after it still supersedes this.

Two test legs the previous round missed, both of which it should have had:
- a failed create leaves the CURRENT membership and `isOwner` intact — the flag
  alone could not have caught this, which is why the old leg passed while the
  regression was live;
- `membershipKnown` is false while the WORKSPACE is still resolving, not merely
  while `/me` is, which is the contract the prose now claims.

Two stale comments corrected: the settings page still said `membershipKnown` is
"false only while a fetch is in flight" (the claim 7fb49e09 narrowed in the
store but not here), and the hash-restoration comment still named
`workspaceStore.canEditWorkspace` as `validTabIds`' dependency when it has been
the sticky `canEditWs` since e6ed6fdd.

For the record, since a pushed commit message cannot be edited: 4a1bfd2a's
"false only while a membership fetch is in flight" is wrong in the same way, and
this is the correction.

Found by Codex review round 3 (P2 + two nits).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): observe the sequence token at entry, claim it on success (BUG-2978, codex round 4)

Moving the sequence claim after the create call in d7ccaeaf fixed the failure
path and opened an ordering hole: a create that STARTED EARLIER but resolved
later would override a `setCurrent` the user began in between, switching the
store out from under a navigation that was the newer intent. The comment I wrote
in that commit claimed the opposite, which is the part worth flagging — the
prose asserted the property the code had just stopped having.

Both obvious spellings get one ordering wrong. Claiming at entry (the original)
makes a FAILED create invalidate an in-flight `setCurrent`, whose writes are then
discarded on the seq check with nothing left to resolve membership. Claiming
only after the call is the hole above.

Reading the token at entry and comparing before claiming gives all three
orderings: a navigation started after this create wins, a navigation still in
flight from before loses to it, and a failed create claims nothing so it
invalidates nothing.

New leg for the ordering itself, mutation-tested: restoring the claim-after-call
spelling fails exactly that leg and leaves the other five green.

Two nits from the same round: the top-level comment still said every create exit
settles the flag "including a create that threw", which stopped being true when
failed creates went back to changing no state; and the failed-create leg's title
claimed the current workspace was untouched while asserting only the permission,
so it now asserts the workspace identity too.

Found by Codex review round 4 (P2 + two nits).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* fix(web): a workspace that was created belongs in the list, whoever wins the selection race (BUG-2978, codex round 5)

Two concurrent creates both succeed on the server, so both workspaces exist —
but only one can be selected, and every spelling of this token protocol so far
dropped the other from `workspaces` entirely, differing only in which one. The
entry-claim original dropped the earlier-STARTED create's workspace; round 4's
version dropped the later-COMPLETING one. Neither loss was chosen by anyone, and
the visible symptom is the same either way: a workspace the user just created is
invisible until the next `loadAll`.

The list is additive, so the append moves ahead of the token check and no longer
depends on the race at all. Only the SELECTION is raced, and that stays
first-to-complete and is now documented as deliberately unspecified beyond
that — with two creates in flight there is no intent to honour, and the part a
user would notice missing no longer hangs on the answer.

Mutation-tested: moving the append back behind the token check fails exactly the
new leg and leaves the other six green.

Nit from the same round: the `membershipSeq` comment said every setCurrent and
create call increments it, which stopped being true when create moved to
observing the token at entry. It now says which call claims and when.

The remaining round-5 nit is 4a1bfd2a's commit message, which still carries the
"false only while a membership fetch is in flight" overstatement. A pushed
commit message cannot be edited; d7ccaeaf records the correction and the PR body
carries it where a reader will actually meet it.

Found by Codex review round 5 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* docs(web): correct two store comments, and record what round 6 found out of scope (BUG-2978)

Two comment corrections, both places where the prose was wider than the code:

- `membershipKnown` is settled by membership RESOLUTION, not by a membership
  fetch. A workspace that does not resolve at all settles it without any `/me`
  request being made, which the old wording excluded.
- `recoverIfMissing` credited `loading` with preventing a duplicate list call.
  The actual guard is `inFlightFor`, which JOINS the run already in flight;
  `loading` is a rendering signal and is not consulted for that.

Round 6's P2 is filed as BUG-2981 rather than fixed here: a `loadAll` that
STARTS before a `create` completes commits a server list that legitimately
predates the new workspace, replacing the array and erasing the append. It is
PRE-EXISTING — verified against origin/main, where `create` does the same
wholesale append into the same array — and this branch only changed when that
append happens relative to the membership token. It is a different operation
pair, its fix contains a real design choice about what a list snapshot older
than a create means, and this is a settings-tab fix already five rounds deep.
Widening it there is how a small fix acquires a large blast radius.

Found by Codex review round 6 (two nits; its P2 filed).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 16:23:59 -04:00
David Barkhausen f262449b18 feat(cli): pad token create/list/revoke — CLI mint path for API tokens (#1237)
Contributed by b4rk13 (#879 follow-up). Reviewed under DECIS-212-style read: sits on the existing user-scoped /auth/tokens endpoints, no server changes; create requires a login session per #1267 and answers 403 session_required under PAD_TOKEN, list/revoke stay PAT-reachable.

Claude-Session: https://claude.ai/code/session_01W71Y4K5hGbbqqAhbVFnjB4
2026-09-09 16:09:29 -04:00
xarmian 0b16be492b fix(web): wrap the settings tab bar so no tab is hidden at phone width (TASK-2245 / C82) (#1306)
* fix(web): wrap the settings tab bar so no tab is hidden at phone width (TASK-2245 / C82)

The five owner tabs are 562px intrinsic and the bar's box is the viewport
minus the page's 48px of padding, so below ~610px the row overflowed. With
`overflow-x:auto` plus `scrollbar-width:none` it overflowed INVISIBLY: the
row ended after a tab with clean trailing whitespace and looked complete.
Measured at 390x844 on the unfixed build, Storage was 9.3% visible and
Danger Zone 0% — workspace export and deletion reachable only by a swipe
nothing advertised. At 320/360 three tabs were clipped.

No single-row shape can hold the full labels: 562px does not fit 342px, and
dropping the tab padding to 10px still needs two rows. Of the three shapes
the item proposed, an edge fade leaves a tab clipped by construction, and a
picker keeps four of five labels off screen until a tap — which is the
defect itself. Wrapping is the one that makes every label legible at once.

Deliberately not inside a media query: `flex-wrap` is inert while the row
fits. That is measured, not assumed — at 640/768/1024/1280 the bar stays one
row at 35px with the content top unmoved at 184.6, identical to the
scrolling build; only 320-430 wrap, at a cost of +38px of content offset at
390 and +76px at 320.

Two e2e legs, each with a non-vacuity precondition: the mobile leg asserts
nothing is clipped and the bar no longer scrolls, and the desktop leg pins
the inertness claim — it fails if the rule is ever widened into an
unconditional wrap.

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): read horizontal page scroll off the real scroll chain (TASK-2245 / C82)

The spec's "no horizontal page scroll" oracle read
`document.scrollingElement`, but the app scrolls inside `.main-content`,
whose `overflow-y:auto` computes `overflow-x:auto`. Overflow is therefore
contained there and never reaches the document, so that assertion could
not fail — it was inert, not a guard.

It now walks the tab bar's ancestors to <html> and asserts none of them
scroll horizontally. Verified to discriminate rather than assumed: forcing
a 3000px-wide child into `.settings` makes the list
`[div.settings, main.main-content]`, which the previous oracle reported as
clean.

Found by Codex review round 1 (P2).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm

* test(web): a scroll-chain oracle must check the container, not just overflow (TASK-2245 / C82)

`scrollWidth > clientWidth` is true of any element with a wide descendant,
including one whose `overflow-x` is `visible` and which therefore cannot
scroll at all. The ancestor walk now requires computed `overflow-x` to be
`auto` or `scroll` before treating an element as a scroll container, so a
long settings value can no longer fail the leg spuriously.

My own negative control had already shown the false positive and I read it
as confirmation instead of as the defect it was: forcing a 3000px child into
`.settings` listed BOTH `div.settings` and `main.main-content`, and only the
second is a scroll container. With the filter the same control lists
`main.main-content` alone, which is the claim the comment now makes.

Also narrows an overclaim in the CSS comment: "any scrolling shape leaves a
tab clipped by construction" is broader than anything measured. What was
measured is that a row opening at scrollLeft=0 leaves the later tabs clipped
in the initial view.

Found by Codex review round 2 (P2 + nit).

Claude-Session: https://claude.ai/code/session_01WS9QAnxk1gA3LBha3PvKVm
2026-09-09 14:42:47 -04:00
github-actions[bot] 249a8f879f chore(nix): heal vendorHash for the module set in 31d11e76ea (TASK-2954) 2026-09-09 18:33:41 +00:00
dependabot[bot] 31d11e76ea chore(deps)(deps): bump github.com/mark3labs/mcp-go from 0.58.0 to 1.0.0 (#1275)
* chore(deps)(deps): bump github.com/mark3labs/mcp-go from 0.58.0 to 1.0.0

Bumps [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) from 0.58.0 to 1.0.0.
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v0.58.0...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(mcp): pad owns the protocol revision it advertises, not the library (TASK-2972)

mcp-go 1.0 moves LATEST_PROTOCOL_VERSION to 2026-07-28. `MetaPayload.
MCPProtocolVersion` was sourced from that constant, on the reasoning — written
in the comment — that doing so meant the value "never drifts from what
NewMCPServer actually advertises in the handshake".

1.0 falsified that, and in the direction the comment was guarding against. The
handshake answers through `mcp.NegotiateLegacyVersion`, which returns at most
LATEST_LEGACY_PROTOCOL_VERSION and CANNOT return the modern revision at all:
measured, a client asking for 2026-07-28 is told 2025-11-25, and a client that
sends nothing is told 2025-03-26. So the bump would have left the handshake
where it was and moved the meta document alone — publishing a claim to
negotiate a revision this server cannot negotiate.

The advertised revision is now a pad-owned literal. Moving it means reading the
new revision's delta against this server's surface first; a library bump must
not move it on its own.

The test that should have caught this was a tautology: it compared the payload
against the same constant the payload was built from, so it could not fail, and
it would have passed through this bump. Replaced with two assertions that each
catch what the other cannot — against the LITERAL, so moving pad's claim is a
deliberate edit visible in a diff, and against what the library's handshake
ACTUALLY answers, which is the property the old comment claimed and never had.
Both legs verified to fail when the constant is moved.

Refs: TASK-2972

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-09-09 14:25:08 -04:00
xarmian cbfc073ef1 fix(attachments): make allowlisted formats reachable by recognising their magic (BUG-2963 PR A) (#1304)
* fix(attachments): types the allowlist names are reachable from their own bytes (BUG-2963 PR A)

BUG-2961 fixed one member of a class. The class, measured over 44 real files
covering 41 extensions: 25 of 48 upload-allowlist entries could never be the
type an upload was stored under, so the door refused — or silently retyped —
files every surface advertises as supported.

This is the sniff-side half. Nothing here trusts a filename to introduce a
type; the trust decisions (the text family, the audio/video category split,
the CFB office trio) are a separate change.

- F1: two spelling aliases, the shape audio/wave and application/x-gzip
  already have. video/avi is a pure spelling difference. application/ogg is
  not: Ogg is a container and the allowlist has no video/ogg, so aliasing
  types an Ogg video as audio. Ruled the better of two answers, since the
  alternative is refusing every Ogg, and the reason is on the alias line.

- F2: magic-byte pre-checks for tar (ustar at offset 257, which is why no
  prefix matcher finds it), bzip2, 7z and FLAC. Consulted ONLY where the
  stdlib returned application/octet-stream, so they can add a detection and
  never replace one. Raw AAC is resolved in ValidateUpload instead: twelve
  bits of sync is too weak to act on alone, so it is gated on the .aac
  extension as well — the bytes must still carry the sync, the extension only
  decides whether a weak signature may speak.

- F3: the one that was accepted rather than refused, and so survived the
  first pass. The mimesniff table maps the bare EBML magic to video/webm with
  no DocType check, so a Matroska file uploaded fine and was stored as WebM.
  A DocType read separates them; an EBML header carrying neither string keeps
  the stdlib verdict, so the fallback is the behaviour that shipped before.

- F6: application/javascript, text/yaml and application/xml leave the
  allowlist — no extension reaches those spellings and no sniff emits them.
  That deletion has a trap: extMIMEMap's values are looked up in `allowed`,
  and an extension naming a removed type is the mechanism that refuses .svg
  and .exe, so .xml now names text/xml. A test covers the .xml upload, not
  just the three lookups. audio/webm is equally unreachable and STAYS by
  ruling, with the comment a future tidy-up will read.

Fixtures are real encoder output truncated to the 512 bytes the door reads,
with provenance and one recorded gap in the testdata README. The measurement
harness ships in neither PR.

Refs: BUG-2963, BUG-2961

* docs(attachments): bring prose that F6 falsified back to true (BUG-2963)

Three client comments and one store filter described the server's category
for `application/javascript`, which stopped having one when F6 removed it from
the allowlist. Nothing in the previous commit's diff points at these lines,
which is the whole reason the sweep exists (team CONVE-23).

Behaviour is unchanged in all four. The web allowlists still exclude
`application/javascript` deliberately — the string can reach the client from
somewhere that is not our upload door — and the store's category filter still
matches it, because it buckets rows that EXIST rather than deciding what may
be created: a filter that stops matching a type costs a row nobody can find,
while one that matches a type no row carries costs nothing.

Refs: BUG-2963

* fix(attachments): admit a format by its own integrity check, not by its magic (BUG-2963)

Round 1 of adversarial review walked straight through the first version of
these signatures. A prefix match is not a format, and on a default-deny door
the difference is the whole point:

- a real, EXECUTING ELF binary with "ustar" in unused padding at offset 257
  was stored as application/x-tar
- the six-byte 7z signature, alone, was a 7z archive
- "fLaC\x00" was a FLAC stream; "BZh9\x00" was a bzip2 stream
- FF F1 00 — three bytes — was an AAC frame
- a WebM carrying "matroska" inside a legal Void element was stored as
  Matroska, and a Matroska with 40 bytes of Void padding was stored as WebM,
  leaving the mistyping this change exists to fix live for any padded file
- a real VP8-in-Ogg video was accepted as audio/ogg, category audio, INLINE

Every one was refused before the change and accepted after it. So each format
is now admitted only by the integrity check the format itself defines: tar's
header checksum, bzip2's block magic, 7z's start-header CRC, FLAC's mandatory
34-byte STREAMINFO, ADTS's reserved sampling-frequency index and layer bits.
The EBML DocType is PARSED — a real element walk that skips Void — rather than
searched for as a string in a fixed window. The Ogg alias is gated on the first
packet naming an audio codec (Vorbis, Opus, FLAC, Speex); Theora and VP8 in Ogg
stay refused exactly as before, and video/ogg is deliberately not added,
because admitting a format is a review and not a side effect.

Three comments asserted things that were false and are corrected rather than
softened: that these formats always sniff as octet-stream (a tar whose first
member is BM.txt sniffs image/bmp), that a .aac without a sync is refused (it
is not, if its bytes are another audio type), and that deleting one switch case
would fail one named test (this project's own mutation matrix already showed
otherwise, and the comment was written against that data).

Tests gained the cases that decide it: every near-miss body above as an exact
upload, the four EBML directions including both adversarial files, the ADTS
guards isolated so exactly one mutation kills each, and length boundaries at
the offsets each check indexes past — the previous negatives padded everything
to 512 bytes, which hid every length guard.

Refs: BUG-2963

* test(attachments): kill two mutants the round-1 tests left alive (BUG-2963)

Both survived the expanded matrix, and both for the same reason round 1 kept
finding: a negative case that fails for a reason other than the guard it names.

- The bzip2 near-miss was five bytes, so the LENGTH guard refused it and the
  block-magic check was never reached; removing that check left the suite
  green. The new sibling is long enough to reach it and NUL-padded so the
  stdlib still says octet-stream — without the padding the body sniffs as
  text and the magic table is never consulted at all, which the first attempt
  at this test demonstrated by passing for the wrong reason.

- The ADTS octet-stream gate had no discriminating input: the two-byte case
  fails validADTSHeader on length before the gate matters. FF F1 40 41 41 41
  41 is a structurally valid ADTS header — sync, layer 00, sampling index 0,
  frame length 2570 — whose every byte the stdlib reads as text, so it answers
  text/plain. That is the only shape that separates the gate from the
  structural check, and with the gate removed the file is stored as audio/aac.

Refs: BUG-2963

* test(attachments): fuzz the sniff path, since this change added a parser (BUG-2963)

Every other check in this package reads fixed offsets and is bounded by
construction. sniffEBMLDocType walks caller-supplied length fields, which is
the one shape here that can index out of range or fail to advance, so the
question 'does it survive malformed input' deserved an answer from running it
rather than from reading it.

Asserts the two properties a sniffer owes its caller — it returns, and it does
not panic — across sniffOpaqueMagic, sniffEBMLDocType, sniffOggAudio,
validADTSHeader, SniffMIME and ValidateUpload. What it returns for nonsense is
left to the table tests.

Seeds are the real fixtures, each truncated at fourteen offsets, plus the
shapes a walk breaks on first: unknown-size elements (the reserved all-ones
VINT), a child whose declared size exceeds the data, the invalid all-zero VINT
marker, a zero-length child that must still advance, and a truncated Ogg page
header. 5.56M executions, no panic and no hang.

Also collapses a duplicate fixture loader this file had grown alongside
mime_isobmff_test.go's readFixture.

Refs: BUG-2963

* fix(attachments): use the real parsers, drop Ogg, and stop overclaiming (BUG-2963)

Round 2 found 15 issues, two of them P1, and the important one is not any
single check — it is that round 1's answer was wrong in the same WAY round 1's
defect was. Round 1 defeated prefix matching with an ELF carrying "ustar" at
offset 257. The response was a checksum. Round 2 defeated the checksum with an
ELF carrying a CORRECT one.

Measured while responding, and it settles the question: archive/tar's own
Reader.Next accepts that file too. A 512-byte tar header is exactly those
fields, and nothing forbids another format's padding from containing them. The
two are not distinguishable at this size by anything in the standard library,
so this is a property of the formats and not a defect to fix. Adding a third
round of field checks would have been the same mistake a third time.

So the change is to the CLAIMS as much as to the code. These checks RECOGNISE
a format; they do not establish one. The safety property lives elsewhere and is
now stated once, at the top of mime_magic.go: whatever the bytes are, they are
stored opaquely, never executed or decompressed here, and served under a
reviewed type with nosniff, as an attachment for every type recognised here.
The checksum-correct ELF ships as a fixture and a test asserting it is
ACCEPTED — a limitation recorded where it cannot be rediscovered as a bug.

Where a real parser exists, it is now used: archive/tar and compress/bzip2,
which cannot drift from the parsers a consumer would use and handle GNU/pax
variants for free. bzip2 keeps its magic check alongside the decode because
neither subsumes the other — the magic refuses a five-byte stream the decoder
can only call truncated, the decode refuses an empty stream whose combined CRC
is wrong.

OGG IS REMOVED. The alias was ruled in, and review showed the question it has
to answer — is this container audio — cannot be answered from the head of the
file: an Ogg with Opus first and VP8 second passes a first-packet codec gate,
because Ogg multiplexes and the video pages come later. The gate was also
wrong in the other direction, refusing legitimate Skeleton-prefixed audio.
Ogg is refused exactly as before this branch; making it work means adding
video/ogg as a reviewed entry or demuxing, and neither belongs in a change
whose premise is that it adds no new trust. The reasoning is a comment where
the next person to reach for an alias will meet it.

Other round-2 correctness fixes: a DocType value ends at its first NUL, so a
real Matroska padded "matroska\x00junk" is no longer stored as WebM; reserved
all-ones EBML IDs are refused rather than acting as zero-length children;
FLAC checks the STREAMINFO BODY rather than its declaration; 7z checks that
the start header's arithmetic is representable, not only that its CRC agrees;
ADTS accounts for the two CRC bytes a protected frame declares.

Tests gained the direction they lacked entirely — legal variants that must NOT
be refused (an empty bzip2 stream, STREAMINFO carrying the last-block flag, a
CRC-protected ADTS frame) — and the truncation loop now asserts refusal below
each check's minimum instead of discarding its return value, which is why it
had been passing while flac.head512[:8] was accepted.

Refs: BUG-2963

* test(attachments): cover the five guards the rebuilt matrix found untested (BUG-2963)

Rebuilding the mutation matrix against the round-2 code turned up seven
survivors. Six were real gaps; each now has the exact input a review round
used, or the smallest one that separates the guard from its neighbours, and
two carry a control leg so the negative cannot pass for the wrong reason.

- 7z: a start header whose next-header offset is 2^64-1, with a recomputed
  valid CRC. The CRC proves the bytes are intended, not that they are possible.
- FLAC: STREAMINFO declaring a zero sample rate.
- bzip2: the head of a 200KB archive, which must be RECOGNISED — truncation is
  what a 512-byte head of any real archive looks like, and the guard that
  distinguishes it from a structural error had nothing testing it.
- EBML: a real Matroska whose DocType payload is 'matroska\x00junk'; and a
  header with a reserved all-ones ID before a valid DocType.

The seventh is an EQUIVALENT mutant and is recorded as one rather than fixed:
lowering validTarHeader's length guard from 512 to 262 changes no outcome,
because archive/tar refuses a short block by itself. Measured, then written
into the comment so the guard reads as clarifying rather than load-bearing.

Refs: BUG-2963

* fix(attachments): delete the bzip2 magic comparison a mutation proved dead (BUG-2963)

The check compared the block magic AND decoded, under a comment claiming each
caught what the other could not. A repaired mutation run refuted it: with the
comparison removed, every input the comment credited it with stopping is still
refused — the short ones by the length guard, the rest by the decoder. So the
line could not change an outcome while its comment said it did, which is the
failure this package's own SafeFallbackExtension comment warns about. Deleted
rather than demoted, and the length guard now says what it is for.

The mutants that found this had been scoring BUILD-FAIL, which is nothing at
all rather than a survivor — repairing them to compile is what surfaced three
untested guards, two of them FLAC's STREAMINFO block-size floor and ordering.

Refs: BUG-2963

* fix(attachments): recognise by magic, to the standard this door already uses (BUG-2963)

Three review rounds walked the structural-validation path to its end and the
ruling is to stop:

- Round 1 defeated magic matching with a real, executing ELF carrying "ustar"
  at offset 257.
- Round 2 defeated the checksum that answered round 1, with an ELF carrying a
  CORRECT one — and archive/tar's own Reader.Next accepts that file too. A
  512-byte tar header is exactly those fields; the two are not distinguishable
  at this size by anything in the standard library.
- Round 3 found the accumulated validation refusing REAL files: PAX and
  long-name GNU tars, legal randomized bzip2 blocks, FLAC declaring the zero
  sample rate RFC 9639 permits. That is this bug's own defect — refusing files
  people legitimately have — reintroduced by the fix for it.

Validation could not narrow what the door accepts and had started refusing
what it should take, so it is gone. Recognition is by defining magic: ustar at
257, BZh plus its digit, the 7z six bytes, fLaC, and for ADTS the syncword
plus the layer bits, which keeps its .aac extension gate because fourteen bits
is weaker than the rest.

The fact that settles the width, read from the stdlib rather than assumed:
http.DetectContentType recognises audio/mpeg from the three bytes "ID3"
(net/http/sniff.go), audio/mpeg is on the allowlist, and this door already
serves it inline. Every signature here is at least as wide, so this is the
door's EXISTING standard rather than a relaxation of it. A test asserts that
premise so it cannot rot.

Two refusals are deliberate and say so in the code: V7 tar has no magic
anywhere and cannot be recognised by this kind of check at all, and Ogg stays
refused for the reason a previous commit records.

bzip2 is no longer decompressed anywhere in the door. The safety paragraph is
rewritten from what the code does, for the third time and the last: nothing is
executed, nothing is decompressed, archives download while FLAC and AAC play
inline exactly as every other allowlisted audio type does, and nosniff means
recognition can move a file between reviewed types but never outside them.

Tests now assert the contract that exists. The widening is asserted rather
than described — every input the validation used to refuse is accepted, each
with its SERVING BUCKET checked, because that is the property that makes it
tolerable. The files validation used to refuse are asserted accepted. What
remains of the negatives is the only thing still true: the magic has to be
there, in the right place, in full.

Refs: BUG-2963

* test(attachments): restore two EBML properties the bulk cut dropped (BUG-2963)

Cutting the structural-validation tests wholesale took the DocType
NUL-termination and reserved-ID cases with them, though the DocType walk they
cover is untouched by the magic-only ruling. A mutation run is what noticed:
both mutations had gone from detected to surviving.

Worth recording as a shape rather than a slip — deleting a test file's worth of
obsolete assertions is exactly when live coverage leaves with them, and the
matrix is the only thing that says so.

Refs: BUG-2963

* fix(attachments): tar wins a prefix collision, and the AAC gate stops refusing real files (BUG-2963)

Round 4's two blocking findings were both REAL files of listed types turned
away — the direction the ruling's convergence bar names first.

**A tar whose first member is named fLaC.txt was refused.** Every recogniser
except tar's is a prefix test, and a tar header's first 100 bytes are its
member's FILENAME — arbitrary text a user chooses. So an ordinary archive
carrying "fLaC" or "BZh9" in a name was recognised as that format and then
refused for a category mismatch against its own .tar extension. Tar is tested
first now, and the order is load-bearing rather than arbitrary: the collision
is asymmetric. A real tar carrying a foreign prefix needs only a filename; a
real FLAC carrying "ustar" needs those five bytes at exactly offset 257 in
compressed data. Losing the first case costs ordinary uploads.

**Real AAC files were refused when their leading bytes looked textual.** The
gate ran on application/octet-stream alone, but a raw AAC frame whose ancillary
payload is printable makes the first 512 bytes read as text, so the stdlib
answers text/plain and a genuine, ffmpeg-decodable .aac was rejected for a
category mismatch. Neither verdict is a format detection; both mean "nothing
here identifies this", which is the condition under which a weak signature may
speak. A type the stdlib DOES recognise is still untouched, and a test pins
that with PNG bytes named .aac.

Also, the leftovers that keep being mine: comments still describing structural
validation that is gone, a test comment claiming production delegates to
archive/tar when it no longer does, a README row saying the ELF fixture exists
to be refused when it is now accepted, an unused fixture, and two fixtures the
README never listed.

Tests strengthened where a review round showed one example was standing in for
a whole signature: the EBML legs now ask sniffEBMLDocType directly, because
routed through SniffMIME the stdlib fallback supplied the same WebM answer and
the explicit mapping could be deleted with the suite green; the ADTS signature
is walked byte by byte; the bzip2 digit range is tested at both bounds and the
FLAC marker at its width and case.

Refs: BUG-2963

* test(attachments): give the ADTS verdict gate a control that actually controls (BUG-2963)

The PNG leg could not establish the gate it was named for: PNG bytes fail
validADTSHeader on the first byte, so they are refused with the gate removed
too. A mutation run said so — dropping the stdlib-verdict gate survived the
whole suite.

The only shape that separates the verdict gate from the structural check is an
input that PASSES validADTSHeader and is ALSO identified as something else: a
buffer opening with a valid ADTS header and carrying 'ustar' at offset 257,
named .aac. It is identified as a tar and must be refused for the category
mismatch it is; without the gate the AAC branch overwrites that and accepts it.

Recording the process failure alongside it, because it is one I have written
down before: this test was lost once between writing and committing, because
the mutation runner's restore is 'git checkout -- internal/attachments/' and
the work was still uncommitted. Committing is step one of running a control,
not step one of the unit.

Refs: BUG-2963

* fix(attachments): let the extension arbitrate a magic collision, both ways (BUG-2963)

Round 5 found the mirror image of round 4's finding, which is the useful part:
my fix for round 4 created it. Round 4 showed a tar whose first member is named
fLaC.txt being refused, so tar was ordered first. Round 5 then built complete,
decodable FLAC and AAC files carrying "ustar" at offset 257 in ordinary
metadata — a Vorbis COMMENT tag is arbitrary UTF-8 (RFC 9639 §8.6) — and those
were refused instead.

The premise I wrote into the ordering comment was false. I argued the collision
was asymmetric, that real audio could not plausibly carry "ustar" at a fixed
offset. It can, in a tag a user typed. Any total order refuses somebody.

So there is no winner by order. sniffOpaqueCandidates returns EVERY matching
type, and when the filename's extension names one of them it breaks the tie.
That is a narrower thing than extension trust and the code says so: every
candidate is a type the BYTES already matched, so the extension chooses among
readings rather than casting a vote, and a name for a type whose magic is
absent can never appear in the list. An extension naming none of them changes
nothing — asserted, including that a .zip name does not make colliding bytes a
zip.

The AAC branch is fixed by the same finding from the other direction: it was
gated on the REFINED sniff, so a real AAC with "ustar" in its payload — which
this package refines to application/x-tar — was refused under its own .aac
name. The gate now reads the standard library's verdict, which for those bytes
is "nothing identifies this". Kept rather than dropped as unfalsifiable,
because no mimesniff signature begins with 0xFF today and this is what stops a
fourteen-bit match overriding one that does.

Fixtures: the FLAC is real and decodes under libsndfile. The AAC counterpart is
NOT shipped — overwriting a real frame's bytes produces a file ffmpeg rejects,
so the test uses a synthetic buffer that reproduces the condition and says so
rather than claiming to be audio.

Also cleared, and reliably my own: comments still describing structural
validation, a test's why-strings still citing checksums and CRCs, a comment
claiming PAX and GNU archives are refused when they are recognised, and a
README gap note that no longer covered all the fixtures. Two test premises
tightened where they proved less than they said: the bzip2 prefix case also
failed the digit check, and the three-byte ID3 claim was tested with seven.

Refs: BUG-2963

* test(attachments): pin the default candidate order (BUG-2963)

Reordering sniffOpaqueCandidates changed no test, because extension
arbitration settles both known collisions whichever way the list runs. The
order still decides one case — colliding bytes whose extension names neither
candidate — and nothing asserted it, so the rationale in the comment was
unenforced.

Refs: BUG-2963

* docs(attachments): the fuzz comment still named the structural validators (BUG-2963)

They were removed by the magic-only ruling. Comment-only.

Refs: BUG-2963
2026-09-09 13:28:23 -04:00
xarmian 42c0f4d58b fix(web): scope the pane focus-ring suppression to mobile, where focus is programmatic (TASK-2245 / C119) (#1305)
At <=768px the item pane is a full-screen overlay that PaneHost focuses
programmatically on open, deep-link, refresh and back. Chromium paints its UA
ring around that region, which at 390x844 IS the viewport — a ring around the
whole screen indicating nothing.

The item prescribed an unscoped `.item-pane:focus, .item-pane:focus-visible {
outline: none }`. Measured on the rig, that is wrong in the keyboard arm: on
desktop the ONLY route to a focused .item-pane is the deliberate Tab bridge at
[collection]/+page.svelte:2533-2543, where the ring is the only signal the hop
landed (a tabindex=-1 region has no other affordance).

`:focus:not(:focus-visible)` cannot separate the two either. Measured at
390x844, the programmatic focus matches :focus-visible with an `auto` outline —
identical in every selector-visible property to the desktop Tab bridge. The
discriminator is the VIEWPORT, not the modality, because desktop leaves focus
on the list on a deep-link (measured: activeElement is <body>).

Measured, three legs, against binaries serving the embedded build:

| leg                        | unfixed | fixed    | rule hoisted out of the MQ |
|----------------------------|---------|----------|----------------------------|
| 390x844 deep-link          | auto    | none     | none                       |
| 1280x900 Tab bridge        | auto    | auto     | none  <- the regression    |
| 1280x900 deep-link (ctrl)  | none    | none     | none                       |

The hoisted-rule leg is the item's own prescription, and it is why the rule
stays inside the media query. The focus MOVE is untouched in both viewports —
only the indicator changes, and only where nothing asked for it.
2026-09-09 12:44:34 -04:00
xarmian 38d8803603 fix(nix): gate the vendorHash heal on state, not on the push range (BUG-2974) (#1303)
* fix(nix): gate the vendorHash heal on state, not on the push range (BUG-2974)

The heal job asked whether THIS push touched go.mod/go.sum. That is the right
question for loop prevention and the wrong one for recovery: after a lost push
race the tree needing the fix was written by an EARLIER push, so every later
merge that did not itself move the module set refused to heal, exited GREEN,
and left main carrying a hash a clean `nix build` rejects. It happened on
b7235a1a -> bc543a1d and main stayed stale until a human committed the value.

The gate is now a question about STATE — does what main committed differ from
what the build job recomputed — which is equally true for a hash this push
broke and one an earlier push broke and could not land. The loop still
terminates and never depended on the range: the heal commit corrects
package.nix, so the run it triggers finds the build green, never sets `bumped`,
and this job does not start.

An unhealed committed hash is now LOUD. A rejected push and a recomputed
artifact identical to the committed file both end the run RED with an
::error:: naming the state, instead of the silent exit 0 that hid this.

The step is a script rather than a `run:` block so it can be driven against a
real git remote: nix/heal-vendor-hash_test.sh, wired into the Go job beside the
parser suite. Its load-bearing case is the one that shipped, with a frozen
reproduction of the pre-fix gate as the negative control.

Folds in the two prose warts parked on TASK-2954's trail: the doubled
"one commit after the merge" clause, and the comment naming the push-range gate
as the loop guard when the terminator is a corrected tree computing no bump.

* fix(nix): the heal test must own the guard's environment, not inherit it (BUG-2974)

Codex round 1, P1, reproduced: GitHub Actions always sets GITHUB_ACTIONS=true,
which the test process inherits, so case 6 satisfied the run-location guard it
was written to trip. It passed locally and would have failed the new Go CI step
on every run — the suite's first act in CI would have been to report a defect it
had introduced itself.

Fixed at the class rather than the instance: the suite unsets both GITHUB_ACTIONS
and HEAL_ALLOW_LOCAL up front, so no case can be decided by the caller's
environment, and each opts in explicitly. Case 6b is new and covers the arm this
exposed as unasserted — in Actions, with no override, the script runs and heals.

Verified green in four environments: bare, GITHUB_ACTIONS=true, a stray
HEAL_ALLOW_LOCAL=1, and init.defaultBranch=master. Mutation matrix re-run and
extended to 7 (added: guard condition inverted); all killed.
2026-09-09 10:52:14 -04:00
xarmian 89f55babd6 fix(web): title parts are route-scoped, replacing the clear that raced them (TASK-2245 / C118)
The workspace layout cleared `section`/`item` from an `$effect` whose own comment
made two claims, and the code was correct only if both held. Neither does:
reading `page.url.pathname` tracks the whole reactive `page.url`, so a
SEARCH-only change re-ran it (`?item=` opening/closing the pane, `?view=`
switching views); and "child effects run after this one" is about MOUNT order,
so on a re-run the leaf writes first and the clear lands after it.

The tab title therefore fell back to `{Workspace} · Pad` and the mobile context
bar fell through to its raw-slug fallback — "share rig" where "Share Rig"
belonged, as measured on device.

FOUND BY EXPERIMENT, and both my hypotheses were wrong. An instrument on
`setPageTitle` logging every write with its author, driven through a real
browser, named a third component neither hypothesis mentioned. The first run was
a failed reconstruction rather than a refutation — `page.goto`/`goBack` re-booted
the app instead of exercising an in-app close. The control then found a SECOND,
pre-existing instance: a genuine cross-route SPA navigation lost the
destination's section the same way.

Three ways to lose one race, each exposed by the fix for the last — the third
(`beforeNavigate` firing for navigations this app CANCELS, to prompt about an
unsaved draft) found by Codex round 1. Three failure modes for one mechanism is
the signal that the mechanism is wrong, not that it needs a fourth guard.

So the clear is DELETED. `titleStore` stamps `section`/`item` with the pathname
they were set for and ignores them elsewhere; nothing has to run at the right
moment because there is no moment. `workspace` stays unstamped — it spans every
route inside it. Unwired routes still fall back to `{Workspace} · Pad`, which was
the clear's whole purpose.

Both e2e legs were run against a REBUILT pre-fix binary — the suite serves
embedded assets, so reverting source alone would prove nothing — and both fail
there with exactly "E2E Workspace · Pad".

Scope: C118 only. C82 and C119 from TASK-2245 remain open. `humanize()` is
deliberately untouched: cosmetic here, and it would have hidden the half Kite
can see on device while leaving the half they cannot.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 21:33:24 -04:00
xarmian 5ce1103981 chore(nix): heal vendorHash for the module set in b7235a1a (TASK-2954, BUG-2974)
The heal job's commit for #1301's module set (a515b03b on run 34296097000)
was rejected non-fast-forward when bc543a1d landed mid-build, and the next
run's push-range gate refused to retry because its own push touched no
go.mod — the recovery hole BUG-2974 records. Hash taken from both runs'
recompute output, which agree: sha256-uuSOBDhEo20QyqACh3fFjjTBatP9LKpquZdYBsJeGDI=.

Claude-Session: https://claude.ai/code/session_015A7n836r64Y9THC8UWsDFF
2026-09-09 00:53:54 +00:00
dependabot[bot] bc543a1d37 chore(deps)(deps): bump the npm-minor-and-patch group (#1276)
Bumps the npm-minor-and-patch group in /web with 14 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.30.5` | `3.31.3` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.5` | `3.31.3` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.5` | `3.31.3` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.5` | `3.31.3` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.56.10` | `5.57.0` |


Updates `@tiptap/core` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-task-list)

Updates `@tiptap/pm` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/suggestion)

Updates `svelte` from 5.56.10 to 5.57.0
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.57.0/packages/svelte)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.57.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:43:24 -04:00
dependabot[bot] b7235a1a61 chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 6 updates (#1301)
Bumps the go-minor-and-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/alicebob/miniredis/v2](https://github.com/alicebob/miniredis) | `2.38.0` | `2.39.0` |
| [github.com/prometheus/client_model](https://github.com/prometheus/client_model) | `0.6.2` | `0.6.3` |
| [github.com/prometheus/common](https://github.com/prometheus/common) | `0.70.1` | `0.71.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.47.0` | `0.48.0` |
| [golang.org/x/time](https://github.com/golang/time) | `0.15.0` | `0.16.0` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.57.0` | `1.58.0` |



Updates `github.com/alicebob/miniredis/v2` from 2.38.0 to 2.39.0
- [Release notes](https://github.com/alicebob/miniredis/releases)
- [Changelog](https://github.com/alicebob/miniredis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/alicebob/miniredis/compare/v2.38.0...v2.39.0)

Updates `github.com/prometheus/client_model` from 0.6.2 to 0.6.3
- [Release notes](https://github.com/prometheus/client_model/releases)
- [Commits](https://github.com/prometheus/client_model/compare/v0.6.2...v0.6.3)

Updates `github.com/prometheus/common` from 0.70.1 to 0.71.0
- [Release notes](https://github.com/prometheus/common/releases)
- [Changelog](https://github.com/prometheus/common/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/common/compare/v0.70.1...v0.71.0)

Updates `golang.org/x/sys` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/sys/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/time` from 0.15.0 to 0.16.0
- [Commits](https://github.com/golang/time/compare/v0.15.0...v0.16.0)

Updates `modernc.org/sqlite` from 1.57.0 to 1.58.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.57.0...v1.58.0)

---
updated-dependencies:
- dependency-name: github.com/alicebob/miniredis/v2
  dependency-version: 2.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/prometheus/client_model
  dependency-version: 0.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/prometheus/common
  dependency-version: 0.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/sys
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/time
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 20:41:43 -04:00
xarmian 734b53f33c fix(attachments): a HEIC embed is decided by what can be served and painted, not by an image/ prefix (BUG-2964)
A pure-Go build derives no HEIC thumbnail, and the byte endpoint SILENTLY serves
the original when the requested variant is missing — so an editor that chose
<img> on a MIME PREFIX handed the browser HEIC bytes, which Chrome and Firefox
render as the broken-image icon.

The server now says what it did: X-Pad-Attachment-Variant names what the BYTES
are (a fallback still reports `original`), and X-Pad-Attachment-Derived names
which variants EXIST, answered only on the no-variant path so the hot image path
pays nothing. `none` is a sentinel rather than an empty value, because ABSENCE
has to keep meaning "server predates this fix".

The rule, in both renderers: embed as <img> iff THE VARIANT THIS RENDER WILL
REQUEST exists, OR the browser paints the original. Not a prefix (the bug); not
availability alone (the same build derives no AVIF thumbnail, and browsers
decode AVIF). The second disjunct is a new fourth predicate beside display.ts's
three, NOT canOpenInViewer — that one excludes image/svg+xml for active-content
reasons, and an SVG inside an <img> runs no script, so reusing it would have
flipped every existing SVG embed to a chip.

Verified on a REAL HEIF against a pure-Go build, with a PNG positive control on
the same instance: HEIF reads `none` and answers ?variant=thumb-md with 200 +
Content-Type image/heif — the defect itself; PNG reads `thumb-sm,thumb-md` and
answers with the actual thumbnail. Three counterfactuals and a negative control
on the new TS/Go lock-step test, which replaces a lock-step that had been
asserted in a comment only.

Codex CLEAN after 3 rounds; rounds 1 and 2 each found a real defect in the fix
(a per-variant fact collapsed to a boolean, and an async fact cached as durable).

The share-link 404 is deliberately unchanged — that path serves variants only
because the variant pipeline is the privacy boundary, and serving an original to
an anonymous viewer would trade a broken image for an EXIF/GPS leak. The
limitation is documented per surface beside the capabilities endpoint.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 19:40:37 -04:00
xarmian 4618876e3e fix(cli): pad server stop signals only a process it can prove is ours (BUG-2969) (#1299)
fix(cli): `pad server stop` signals only a process it can prove is ours (BUG-2969)

Measured on the merged binary before this change: a `sleep 600` whose pid had
been written into the PID file was SIGTERMed, and stop printed "Server stopped."
No pad server was running anywhere near that config.

Three things had to be true at once for that. os.FindProcess succeeds for ANY
pid on Unix. Nothing asked whether the pid belonged to a pad server. And the
confirmation loop polled the PORT — which is unhealthy from the first poll when
nothing was ever serving, so the success check was satisfied by the failure
case.

Liveness is the wrong question, and this is the trap the obvious fix falls into:
the stranger WAS alive. The question is whether the pid is OUR server.

## The discriminator

Unix takes an advisory flock on the PID file, held for the server's lifetime.
`stop` probes it non-blockingly: acquiring it proves nobody holds the file, so
the record is stale whatever the pid now names; failing to acquire proves a live
pad server holds THIS file. One implementation for Linux and macOS, no new
dependency, and the same primitive session_lock_unix.go has used since
TASK-2767.

Windows has no flock in that pattern, so it compares the process creation time
from GetProcessTimes against the one recorded at start — the attribute that
survives pid reuse, since a reused pid belongs to a process that started later.

The lead first ruled start-time comparison on every platform; I objected with
the cost (three implementations — /proc, a macOS sysctl promoting x/sys to a
direct dependency, and GetProcessTimes) and the ruling changed to this hybrid.
The cost table is on the item so the next reader sees why the shape moved.

The PID file gains a fingerprint on both platforms — pid, start time, executable
path — as JSON, with the legacy bare-integer form still parsed. A legacy record
carries no proof, which reads as UNPROVABLE, and unprovable means nothing is
signalled.

## Three races, each found by codex and each the same shape

1. Reading the record and checking ownership were separate steps, so a successor
   could claim the file between them: the lock then reported "held" — truthfully,
   about the successor — while the pid handed back was the predecessor's.
   pidFileOwner now returns the record it read from the descriptor it probed.
2. Removing the PID file after a successful stop could delete a fast successor's
   live record. It no longer removes at all there: the server removes its own on
   the way down, and a file left by a crash is handled by the next stop.
3. Removing a STALE file after the probe released the lock had the same window.
   The removal now happens inside the ownership check, while the lock is held —
   the only moment at which no replacement can have claimed the path. A claim
   arriving during that instant retries for half a second rather than losing its
   claim for the life of the process.

Windows deliberately does NOT delete a stale file: with no atomic primitive, a
check-then-remove would race a successor, and a stale file that the next start
overwrites is recoverable where a wrongly deleted record is not.

## Verified

Negative control, and it is the literal one: with the ownership check bypassed,
`go test` reports `signal: terminated` — the test binary is SIGTERMed by the
code under test, because the stale record names the test process itself.

Live, in throwaway HOMEs: a stale record naming a live `sleep` is refused and the
sleep survives (it was killed before this change); a stale record with a HEALTHY
port answering is still refused, nothing signalled, and both the stranger and the
real server survive; a server stopped through its own held record stops, and its
file is gone.

The CI smoke on windows-latest now stops the server with `pad server stop`
instead of Stop-Process, because that is the only place the Windows ownership
check runs — a smoke that killed the process directly would leave the
GetProcessTimes path unexercised on every platform.

make lint, make test green; codex CLEAN in round 4.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 19:33:15 -04:00
xarmian 5ec17a7c92 fix(cli): pad server stop stops the server that is running, or says it is (BUG-2965) (#1298)
fix(cli): `pad server stop` stops the server that is running, or says it is (BUG-2965)

`StopServer` read the PID file and, on any read error, answered "server not
running (no PID file)" — without asking whether anything was listening. The file
was written in exactly one place, EnsureServer's auto-start branch, so a server
started any other way held the port with no file to find: a service unit, a
human running `pad server start`, the seats' refresh recipe relaunching with the
killed process's argv. A stop command that says "not running" about a running
process leaves the caller believing they stopped something, and the next thing
they do rests on that belief.

Two halves, per the item's property and corollary:

  - A missing PID file now asks the port. Only an unhealthy address earns "not
    running"; a healthy one earns a message naming the address, the missing
    file, and what to do instead. Deliberately NOT "find the listener and kill
    it" — resolving a pid from a port is platform-specific, and the process
    holding it may not be ours. A stop that kills by port can kill a stranger.
  - `pad server start` claims the PID file itself, so the file exists for every
    start path rather than only the auto-started one.

The second half took four codex rounds to get right, and each round found the
previous shape reintroducing the defect it was fixing:

  1. Write-then-defer-remove let a duplicate start overwrite a running server's
     entry and then delete it on the way out, leaving a healthy server
     unaddressable.
  2. Refusing to replace a live pid fixed that and opened its mirror: the start
     that LOST the port could still own the file, so the winner was unaddressable.
     The fix is ordering, not arbitration — BIND FIRST, then claim, so the file
     always names the process that owns the address. internal/server grows
     Listen and Serve for that; ListenAndServe is now the two together.
  3. With the bind first, EnsureServer's parent-side write became the stale
     mechanism (it records a child that may never bind) and the live-pid refusal
     became actively wrong (no live process can be serving an address we just
     bound). Both removed, along with processIsAlive, whose only remaining
     callers were its own tests.
  4. Cleanup is a read-then-remove, so running it AFTER the listener closes let
     a successor bind and claim between the two steps and lose its file to us.
     It now runs before the listener closes, while nothing else can legitimately
     own the file. The cost is a drain-window where a healthy server has no PID
     file and `stop` says so — a true message in place of a silent wrong one.

Verified live against the built binary, in a throwaway HOME, in both shapes:
start writes the file naming the serving process; a second start against the
held port fails at bind and leaves the first server's file intact; stop then
stops it and removes the file; a further stop reports "not running". The first
live run also caught a flaw in my own method — `stop` reads the config's port,
so the probe answered about 127.0.0.1:7777 (this box's dev server) until it was
re-run with PAD_PORT set. Re-checked after the restructure.

Mutants: the health check removed, the health branch still answering "not
running", an empty PID file, a cleanup that does not remove, and a cleanup that
removes a successor's file are each killed by a named test. The call site itself
is wiring a unit test cannot vouch for (CONVE-19) — that is what the live runs
cover, and the Listen/Serve split is pinned in internal/server.

make lint, make test green.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 18:26:32 -04:00
xarmian c13887a55d refactor(web): the keyed single-flight loader has one definition, not two (TASK-2947)
`collections.svelte.ts` and `workspace.svelte.ts` each hand-rolled the same three
parts around a store load — a monotonic generation so only the latest call
commits, a keyed in-flight promise so a caller who only needs "a result exists"
can join rather than duplicate, and cleanup guarded by ownership. The two copies
drifted three times in one afternoon during TASK-2200, each drift found by a
reviewer rather than by a test. `createKeyedSingleFlight` is that rule, once.

It ALWAYS ISSUES; joining is a separate opt-in read (`inFlightFor`) taken only by
`ensureCollections` and `recoverIfMissing`. Eighteen of `loadCollections`'s
nineteen call sites are reacting to a change they already know about, and a
request issued before that change cannot answer them.

BEHAVIOUR CHANGE: `loadAll` now guards which RESPONSE commits. Two overlapping
calls used to leave the OLDER list in `workspaces` when it resolved last. Its two
discriminating legs were run against the unguarded store first and report ['old'].

One documented non-equivalence: `await work(...)` costs a microtask, so cleanup
no longer follows the commit in the same tick. Unobservable as wrong because every
caller checks its own committed state before asking about the slot.

Gates: vitest 148 files / 2293 tests · npm run check 0 errors · vite build ·
go build ./... — all green. Mutation matrix on the primitive, BUILD OK asserted
before every outcome, four of four detected. Codex CLEAN after 3 rounds.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 18:24:46 -04:00
xarmian a2bab75c73 fix(server): the op-log prune rides inside the write it justifies (BUG-2840 half B) (#1295)
fix(server): the op-log prune rides inside the write it justifies (BUG-2840 half B)

On the no-room / no-applier path a content PATCH pruned the item's Yjs op-log
and then wrote items.content. The prune ran FIRST, in its own statement, on this
justification: "any prior collab state is strictly older than the items.content
the caller is about to write".

That premise holds when the write LANDS. Four typed refusals can come out of
that write — the open-children guard, the optimistic-concurrency conflict, the
rename-cascade byte refusal and the item-title refusal — and on every one of
them the caller wrote nothing, so the pruned ops were superseded by nothing.
They were simply gone.

It is not hypothetical on this branch. It fires on ErrNoApplierAvailable, i.e. a
room inside its 60s grace TTL with zero connections: exactly the state where the
op-log holds a closed tab's edits that never reached items.content. Those edits
exist nowhere else, and a request that wrote nothing destroyed them.

The prune now runs INSIDE the write's own transaction, composed onto the
precheck hook that UpdateItemWithParentLink already runs there, so a refusal
rolls it back. directWriteFn takes the prune as a hook rather than performing it,
which keeps the choice of transaction with the caller that owns the write.

This is the shape version-restore already uses: PruneItemOpLogTx's own comment
says a split prune/commit "leaves a divergent state on any failure" in EITHER
order, and closes that split by running the wipe in the update's transaction.
This path was the remaining split, and it also closes the opposite window a
plain reorder would have left — a crash between a successful write and a later
prune, leaving stale ops to be replayed over fresh content.

One deliberate behaviour change: a prune failure now rolls the content write
back, where before it was logged and the write proceeded. That leniency assumed
the prune was optional cleanup; it is not. A write that commits with a stale
op-log is the "resurrect stale content on the next flush" hazard the prune
exists to prevent, and it is the same trade the restore path made.

Also removes internal/server's distantFuture, whose only use this was; the
collab package keeps its own copy for its own prune. Grepped the repo before
deleting rather than inferring deadness from the edit in front of me.

Verified by a negative control — restoring the prune-first ordering fails the
refusal test — and by three mutants: never invoking the hook and passing nil for
it are both killed by the success test. A fourth, swapping the guard and the
prune inside the hook, SURVIVES, and the comment now says so: both orders are
equivalent while they share a transaction, so that ordering is a preference and
not a rule anything enforces.

make lint, make test and make test-pg green; PG run because this moves a DELETE
into a transaction. Codex CLEAN.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 16:46:28 -04:00
xarmian 696b477b12 test(server): the mobile shells' unversioned contracts fail CI when they move (TASK-2053) (#1294)
test(server): the mobile shells' unversioned contracts fail CI when they move (TASK-2053)

The native shells talk to this server over string contracts nothing protects:
route paths, cookie names, JSON keys, a header shape. None sits behind the MCP
tool-surface version or any other gate, and the min-server-version warning the
app shows covers the opposite direction — it protects a NEW app against an OLD
server and says nothing when the server moves forward under a shipped build. A
shipped build cannot be patched on our schedule, so a renamed key is a silent
break for every installed copy until its owner updates.

One file, one table, six contracts, each naming the shell surface that breaks:

  - Both session cookie spellings. __Host-pad_session is the one that matters —
    every deployment a phone talks to is TLS — and it appeared in NO Go test
    assertion, only in a config-test comment. The insecure spelling is in
    nineteen test files as a helper building a request, which is a mention and
    not a claim about the name.
  - The six auth routes, asserted by WALKING the route table rather than by
    firing requests: a request-based check answers "did something handle this",
    which a catch-all or a redirect satisfies while the route itself is gone.
  - The /auth/session keys the app branches on before showing any UI, version
    included. A rename there reads on a phone as a blank screen or a login form
    on an instance that needs setup, not as an error.
  - The full two-step 2FA sign-in. requires_2fa, challenge_token and
    recovery_code were live request/response keys appearing in ZERO test files;
    the test drives login → login-verify with a real recovery code, so a rename
    on either side fails here.
  - Content-Disposition carrying a filename on BOTH dispositions (BUG-2910).
    The existing download tests assert the inline;/attachment; prefix and say
    nothing about the parameter after it, so the filename could be dropped from
    either branch with the suite green.

Scope is stated in the file rather than left for a reader to re-derive.
/auth/apple/native is a pad-cloud route; the app-scheme redirect allowlist does
not exist in this repo (greps for the scheme forms and for app_scheme/appScheme
return nothing in Go, TypeScript or Svelte); and the OAuth error codes this repo
emits belong to Dynamic Client Registration for MCP clients, not to mobile
sign-in — pinning them here would look like mobile coverage while protecting a
different client entirely.

Verified by an eight-mutant matrix: renaming either cookie name, the
login-verify route, requires_2fa, challenge_token or recovery_code, dropping
version from the session payload, or dropping the filename from
Content-Disposition each fails a named test. Every mutant is a change someone
could plausibly make in an afternoon, and every one of them was silent before.

Two of my own assumptions were caught by running it rather than by reading:
chi's router is built lazily, so walking it on a server that has served nothing
panicked; and the "downloaded document" case used a .txt, which is on the read
path's inline-safe allowlist and came back inline — the case would have tested
the same branch twice under a name claiming otherwise. It uses a real zip now.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 15:49:05 -04:00
xarmian 89a9fb0241 fix(server): a refused collection prefix reaches the caller as a 400 naming the rule (BUG-2951) (#1293)
fix(server): a refused collection prefix reaches the caller as a 400 naming the rule (BUG-2951)

BUG-2943 made the store refuse a prefix outside the grammar, with a message
naming the rule and an example. Three of the four doors onto that refusal threw
the message away: they mapped conflict shapes and sent everything else to
writeInternalError, so `pad collection update docs --prefix "ab1"` answered
"An internal error occurred". The refusal kept its data-protection value and
lost its entire teaching value — a 500 with no text reads as an outage, so the
honest user response is to retry or report one.

An MCP agent was told something worse than nothing. internal/mcp classifies a
stdio failure by matching CLI stderr prose; the generic message matches none of
the validation patterns, so a permanently-invalid prefix arrived as the
RETRYABLE server_error code and the correct agent response was to retry a call
that can never succeed. Nothing in internal/mcp changes here: the store's own
message now reaches stderr and the existing `invalid` pattern recognises it.
Both directions are pinned by tests, including the negative control that the
old generic string still classifies as server_error — which is right for a real
internal failure, and is why the refusal had to stop wearing that message.

The population, read door by door rather than grepped:

  - CREATE and UPDATE lost the message entirely (500, no text).
  - DELETE kept it via strings.Contains on the store's error text — the right
    status by the wrong mechanism: a reworded refusal became a 500 silently.
  - Workspace IMPORT kept the text under a 500, while its sibling bundle-import
    door already answered 400 for the same class.

store.ValidationError carries the caller-facing Reason, with AsValidationError
for the doors, following the InvalidDocumentTitleError precedent in the same
package. Constructing it is the per-site DECISION that a message is safe to
show; the alternative — returning err.Error() from the generic path — makes
that decision by default for every error any layer may later add. Doors render
Reason, never Error(), because Error() carries the sentinel prefix and whatever
a call path wrapped around it; a test helper asserts no response leaks that
prefix, after a mutant swapping Reason for Error() survived every message
assertion (Reason is a substring of Error(), so a contains-check cannot see it).

Two sites are deliberately NOT converted, both read and left:

  - The template-seeding trait validation (collections.go) checks FIRST-PARTY
    template code, not caller input. A 500 is the honest answer there.
  - The two expected_updated_at refusals are converted for uniformity but are
    unreachable through HTTP — both doors validate the token at the boundary.
    They are defence in depth, not live paths.

WIRE CHANGE: POST /workspaces/import now answers 400 for a caller-input refusal
where it answered 500. The code string (import_failed) and the message are
unchanged, and its sibling bundle-import door has always answered 400 for this
class, so this aligns two doors onto one refusal. Ruled by the lead rather than
decided here. "Cannot delete a default collection" stays 400.

Codex round 1 caught the consumer this change created: the tar.gz import door
renders a bundle failure through its own envelope and its fallback wraps
err.Error(), so the very edit that made the JSON door actionable moved the
sentinel prefix into the bundle door's message. It now detects the type and
renders Reason in its own "Bundle pad-export.json is not importable" envelope,
with a test and a mutant. A producer change is not finished until its consumers
have been read; round 2 was CLEAN.

Verified by negative control (each door arm removed in turn) and a ten-mutant
matrix in which every arm removal, every store site reverted to fmt.Errorf, and
both Reason→Error() swaps are killed by a named test. One matrix attribution
was wrong on first run — a store test appeared as a casualty of a server-side
mutant — and re-running it in isolation showed the mutant does not affect it;
the runner had attributed every FAIL line in a two-package run to the mutation.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 14:51:21 -04:00
xarmian 49d1bfcd85 ci(nix): a Go bump's Nix check is green when the build passes, and main heals its own vendorHash (TASK-2954) (#1292)
`vendorHash` pins the Go module set by content hash. Dependabot updates go.mod
and go.sum and has no idea `nix/package.nix` exists, so every Go-dependency bump
PR failed `Nix build & check` on a fixed-output hash mismatch — structurally,
and forever. Both open Go bumps (#1274, #1275) are red that way today; the
npm-side bumps (#1276, #1277) are green, which is the control that isolates the
cause. A permanently-red check is not a check: a bump that genuinely breaks the
build looks identical, at a glance, to one that only moved the hash.

Two halves, and they are deliberately in different places.

THE CHECK IS MADE HONEST WITHOUT A TOKEN. Every Nix run recomputes the hash in
its working tree before the build steps judge it, so green means the build
passed with that ref's actual module set. This runs for every author, not just
Dependabot: a human's own go.mod change moves the hash the same way, and a check
that is honest for one author only is the shape this removes.

MAIN HEALS ITSELF ONE COMMIT AFTER A MERGE. The corrected file cannot be pushed
from a Dependabot PR run: such a run gets a read-only GITHUB_TOKEN — it runs as
if from a fork — and the `permissions` key does NOT lift that. Only a
repository-wide setting does, and that setting would hand fork PRs write tokens
on a public repo, which is the surface CONVE-2438 exists to keep closed. The
merge, however, is authored by a human, so the `push: main` run that follows is
ordinary. A separate job with the only `contents: write` in the file commits the
recomputed value there, gated on the JOB (a step-level `if` is not a boundary —
the job would still hold the token and checkout persists it), on the build
having passed, and on this push having touched go.mod or go.sum.

The loop guard is the commit's own contents: the bot commit touches
nix/package.nix and nothing else, so the run it triggers finds go.mod and go.sum
unchanged and stops at the first gate. Not a heuristic about who pushed — the
fix cannot invalidate the hash it just wrote.

Three ways that gate could have lost a heal, all closed. A cancelled main run's
heal is never retried — the next push's run recomputes correctly but its gate
sees only its OWN commits — so `cancel-in-progress` is now `pull_request`-only;
and that alone is not enough, because GitHub holds only ONE pending run per
concurrency group and a third push evicts the queued second, which looks exactly
like a run that found nothing to do — so push runs get a per-commit group that
nothing can evict. The gate also compares `before..after` on a full clone rather than `HEAD~1..HEAD`
on a two-commit one, because a direct push of several commits can carry the
go.sum change anywhere in the range. A concurrent merge makes the push a
non-fast-forward: the job goes red rather than overwriting, and that merge's own
run heals.

WHAT THE PARSER REFUSES is the whole correctness argument. This build has many
fixed-output derivations: every npm tarball `importNpmLock` fetches is one, and
a mismatch in any of them prints the same block with a `got:` line. Taking "the
got: hash" writes a tarball's hash into `vendorHash` and looks like it worked —
which is what package.nix's old comment told a human to do by eye. So each line
is stripped of its runner timestamp and indentation SEPARATELY and matched
whole, and a header counts only if it says `error:`, names a single store path
segment that STARTS with a 32-character store hash then `-pad-` and ends
`-go-modules.drv':` with nothing after it — identity, not resemblance, since
`-pad-` anywhere in the name also matches `…-other-pad-tool-…-go-modules.drv`;
the hash is then taken only from a canonical-length `got:` on the line
IMMEDIATELY after a canonical-length `specified:`. Anything else exits 1 having
written nothing, and the build stays red.

40 assertions in nix/bump-vendor-hash_test.sh, wired into the CI Go job and
`make test-nix-hash` — in ci.yml rather than nix.yml because otherwise nothing
on an ordinary PR would run it, and a break would surface on the next bump.
14 mutants, all killed — but five of them survived the first suite that claimed
to cover them, each because the case written for the rule was ALSO refused for a
second reason and so discriminated nothing about it: short hashes on both lines
never exercised either length rule on its own, and a nested path that also had a
malformed store hash never exercised the single-segment rule. A sixth, dropping
the canonical length from the `got:` condition, survived because the extractor
re-stated the rule; the fix was to state it once. Portability is checked, not assumed: `awk` is
gawk here and mawk on the runners, so the suite re-runs itself under mawk, gawk
and busybox and is green only if all agree. The parser also had a real defect —
it worked only on timestamped CI logs, not the local log package.nix tells a
human to produce — found by asserting an exit code rather than file contents,
because for a no-op input "did not write" and "could not parse" leave identical
files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 14:00:39 -04:00
xarmian 6a5eb3dee0 fix(attachments): HEIC, HEIF and AVIF are recognised from their bytes, not refused (BUG-2961) (#1291)
fix(attachments): HEIC, HEIF and AVIF are recognised from their bytes, not refused (BUG-2961)

image/heic, image/heif and image/avif have been on the upload allowlist all
along and every real file of those types was rejected 415 mime_not_allowed:
http.DetectContentType implements the WHATWG mimesniff table, which has no
signature for ISO base media file format still images, so they sniffed as
application/octet-stream and ValidateUpload's first rule refused them before
the extension cross-check was ever consulted. HEIC is the iPhone camera
default, so that was every photo shared straight from an Apple device, through
the web UI today and the mobile share sheet being built.

The fix stays inside rule 1 rather than around it. sniffISOBMFFImage reads the
ftyp box and returns the allowlisted MIME, so the sniffed type is still what
the allowlist is consulted with; it returns "" for everything else, so it can
only add detections. The alternative was to trust the filename for these three,
the way ValidateUpload already trusts it for zip-based Office documents, and
that was refused in triage because a renamed .heic would then pass on a name
alone. Both tests layers pin that: an executable named .heic is still refused,
and real HEIC bytes named .jpg are still stored as image/heic.

Four decisions, each argued at its site:

  - Every brand in the box, not just the major one. The measured Apple HEIC
    carries major heic with compatible mif1 MiPr miaf MiHB heic; the measured
    Apple AVIF carries major avif with compatible MiPr avif miaf mif1; libheif
    writes major mif1. A check on bytes 8..12 alone is right for one encoder
    and wrong for the next. A specific brand wins wherever it appears; mif1
    alone means image/heif.
  - Sequence brands (hevc/hevx/hevm/hevs, avis, msf1) are deliberately absent:
    they name image/*-sequence types, none of which is on the allowlist, so
    recognising them would produce a sniff ValidateUpload then refuses with a
    stranger message.
  - A container carrying an mp4 brand stays a video and is left to the stdlib.
  - A size-1 (64-bit largesize) ftyp box is declined, because the brands shift
    eight bytes and the fixed offsets would be reading the size field.

Fixtures are real encoder output with provenance recorded in testdata/README.md
(sips on macOS 26 for the Apple pair, libheif 1.20.2 here for the other two);
hand-built byte vectors appear only for shapes no available encoder produces.
Verified by negative control — with the pre-check disabled all four new
acceptance tests fail — and by a nine-mutant matrix in which the major-only
scan, the dropped mp4 yield, generic-over-specific preference, the minor
version read as a brand, the missing box bound, the removed largesize bail, a
dropped brand table entry and an unconsulted sniffer are each killed by a named
test. The one survivor is equivalent: an 8..11-byte buffer yields no whole
brand either way.

Codex found two more refusals this owed, both now guarded and mutation-checked:
a declared box size of 2..15 is impossible for an ftyp box, and a buffer that
stops inside the 16-byte header is a truncated file — in either case the bytes
at offset 8 are not brands of a box that exists, and classifying from them let
a caller-controlled payload be stored and served as an image.

Also corrects processor.go's claim that "display always works (browsers handle
WebP / AVIF / HEIC natively)", which the client's own viewer table contradicts
and which this change would have made load-bearing (CONVE-23). What it means
for rendering on builds that cannot derive a thumbnail is filed as BUG-2964;
the wider class of allowlist entries the sniffer cannot produce is BUG-2963.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-08 13:30:42 -04:00
xarmian 90dadecb06 docs(web): the cold-path pin heals one of the eviction floor's four doors, not all four (TASK-2939) (#1290)
TASK-2920's `movedOutFloor` carried a paragraph saying the exposure its cap
leaves open would be closed by giving the cold path a cursor pin, at which
point "no per-id record is load-bearing at all", and that the pin was filed
rather than built. The pin shipped in d387d540 (IDEA-2924), which falsified
both halves of that paragraph and left the file telling the next reader the
mechanism does not exist.

Measured rather than reasoned: disabling `refusedByMovedOut` against the pin as
shipped fails seven of the ten legs in `localIndexMovedOutFloor.svelte.test.ts`.
The cold `/items-index` door is healed — it pins its cursor and replays the gap,
including ids whose floor the cap had already evicted. Two others cannot be,
because the eviction advances the cursor past itself in the same atomic write
and `ListMovedOutSince` is a `seq > since` query, so nothing re-delivers it:
`upsert` has no cursor and no replay at all (its other three guards are
resync-scoped or need an `existing` the hard evict removed), and the warm IDB
hydrate's reconcile drains from a `state.cursor` that same eviction advanced.
`resyncProjectionScope` fails too and is not counted: it pins as well, and the
leg asserts the end state before the replay its caller owns has run (CONVE-12).

Three further sites in the same class, all falsified by the pin's merge and
none swept at the time: `bootstrap`'s doc header and `applyDelta`'s doc both
asserted the cursor "only advances forward" (the first is now wrong outright,
the second is true only of that function's own writes), and
`localIndexInverseColdCheck.svelte.test.ts`'s header still described the cold
branch as keeping the higher cursor and never replaying. That header also now
says what the measurement showed about the file itself: with the floor disabled
all five of its legs still pass, so it no longer exercises the floor in any
load-bearing way and the sibling suite is where that coverage lives.

Comment-only. The floor stays; what changes is that these files now say which
door the pin covers and which two the floor is the last refusal for.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-08 13:17:31 -04:00
Matt Faltyn 43d94a35d5 fix(cli): pass PostgreSQL connection strings to backup clients (#1289) 2026-09-08 12:30:45 -04:00
xarmian 3d78e1d03e feat(web): a cold offline load renders the board from cache, fenced by scope (TASK-2946) (#1287)
* feat(web): a cold offline load renders the board from cache, fenced by scope (TASK-2946)

Unit B of TASK-2200. The rows already survived an offline cold load —
localIndex hydrates from IDB before anything fetches — but collection METADATA
was fetch-only, so the board failed closed: "Couldn't load this collection"
over a cache holding the rows the user was looking at a minute ago.

Now cached, and fenced, because a collection list is a SCOPE CLAIM. Served
without the scope it was fetched under it becomes a second way to show a
collection the caller can no longer see, which is the disclosure TASK-2922
closed for rows arriving through another door. `hydrateCollections` refuses a
list whose stamp does not EQUAL the durable rows' `meta.sync.accessEpoch` —
the same equality test as `persistUpserts`, and for the same reason: an
`access_epoch` is a hash of the live grant set and answers "same or different"
and nothing else, so nothing here says "older".

BOTH SIDES OF THE FENCE ARE DURABLE, which is what makes it work with no
network. The ruling that scoped this said "the epoch the durable cache
currently advertises", which reads as a live value; on a cold offline load
there is no server response to carry one. The question actually being asked is
whether the cached list and the cached ROWS describe the same scope, and both
answers are on disk.

THE STAMP IS BORROWED, and that is why `persistCollections` takes two epochs.
`/workspaces/{ws}/collections` returns a naked array and carries no
`access_epoch`, unlike the two items endpoints — so the only stamp available
comes from the row cache, and a borrowed stamp is honest only if the thing it
was borrowed from held still. The caller passes the epoch RAM held when the
request was issued and the epoch it holds now; when they differ a resync landed
mid-fetch and nothing is written. That costs nothing worth having: a scope
change in flight is the moment you would least want to commit a snapshot.

A genuine server stamp would need that endpoint to return an ENVELOPE, which
is a wire-shape break for web, CLI and MCP at once. Priced and declined; the
price is written into the code so the next person who wants it knows it.

ONE `meta` ROW, NOT AN OBJECT STORE, and the reason is the claim being stored
rather than convenience. This is a snapshot of the visible set under a single
scope, true only as a whole; a store keyed by collection id would invite
per-row writes, and one collection written into a list stamped under a
different scope makes the stamp a lie. One row can only be replaced. It also
keeps the change off `IDB_FORMAT_VERSION` — the `meta` store already exists —
so there is no upgrade branch and no migration to get wrong. The item filing
this predicted a new store and a format bump; the snapshot argument is what
changed it.

THE CACHE IS NOT ADOPTED INTO `collectionStore.collections`, deliberately.
Seeding the reactive array would have to stamp `collectionsWorkspace`, which
is what `collectionsAreFreshFor` answers — and TASK-2200's recovery reads that
to decide whether to keep re-fetching. A cached list marked fresh would stop
the retry that is the only route back to a real one. So the sidebar stays
empty until a fetch succeeds; a trade, stated, not an oversight.

Two premise corrections from recon, both narrowing the unit: of the board's
three parallel metadata fetches only `api.collections.get` is fatal —
`views.list` and `members.list` already carry `.catch` defaults — and
`Collection` is flat and cheaply persistable, with `schema`/`settings`/`traits`
as JSON strings and `list`/`get` returning the same type, so a cached list can
answer a `get` by slug.

Only a NON-404 renders from cache. A genuine `not_found` keeps its terminal
state: "deleted" and "unreachable" are different answers and the cache can only
speak to the second (BUG-2025 drew that line). And the page says what it is
showing — a quiet banner with a Retry — because rendering stale data silently
would be worse than the error card it replaces.

`localIndex.userIdFor` is exposed rather than letting a second store resolve
the user itself: the cache is one database per (user, workspace), and a caller
that resolved it independently could write the list into a different database
than the rows, leaving the fence comparing two unrelated caches.

Tests: eight IDB legs covering the round trip, the refusal after a resync
moves the epoch, the refusal when the scope moved across the fetch, the
never-synced case, null-on-both-sides, whole-list replacement, and a CONTROL
leg proving an ordinary delta leaves the list readable — without it the
refusals would also pass against a fence that refused everything. Each guard
was mutated: removing the read fence, the two-epoch guard, or the write-time
sync check each fails exactly one leg.

The never-synced leg was rewritten after mutation showed it passing with the
guard removed — the hydrate fence covers the same ground up to one case, a
list written with an invented `null` stamp becoming readable the moment a
null-epoch sync row lands. That case is now what the leg asserts.

Plus a store test for the cached read and a source pin for the board, whose
load-bearing assertion is POSITION: the fallback must sit after the
`not_found` branch, and a behavioural test cannot see that.

Gates: svelte-check 0 errors; vitest 2276 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the stamp records the scope the fetch happened under, not the scope on disk (codex round 1)

Round 1 found the fence defeating itself, and it found it in a comment I wrote
arguing the opposite.

`persistCollections` stamped the row with `sync.accessEpoch` — the DURABLE
epoch at write time — with a note explaining that where it differs from the
caller's `after`, "a write the caller could not see landed first, in which case
the durable value is the one `hydrateCollections` will compare against, so it
is the one worth recording". That is exactly backwards. Stamping with the
durable value makes the stamp agree with the disk BY CONSTRUCTION, so the
fence accepts a list fetched under a scope the cache has already left — the
disclosure this unit exists to prevent, installed by the defence of it.

The reachable case is cross-tab: this tab's RAM sees no change, so
`before === after` passes, while a sibling resyncs to a narrowed scope and its
write lands first.

The stamp is now the epoch the fetch actually happened under, written only when
all three vantage points agree — RAM before, RAM after, and disk now. One
property, "nothing moved", asked everywhere something can see a move. Two
guards, not because two rules accreted but because two of the three views are
in different processes.

Third time today that I stated a mechanism I had not checked and let the prose
carry it. The other two were caught by a reviewer as well. The tell each time
is the same: a sentence explaining why the less obvious choice is correct, with
no measurement behind it — and an explanation is the part a successor reuses
without re-deriving, so a wrong one is worse than none.

New leg pins the cross-tab case and fails against the reverted stamp; the other
eight are unchanged.

Gates: svelte-check 0 errors; vitest 2277 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): bracket the fetch with the DURABLE epoch, which is the value the fence reads (codex round 2)

Two findings, and the first is the one that matters: **the feature was inert in
its most common path while all nine IDB legs were green.**

`persistCollections` bracketed the fetch with RAM's epoch — the store's belief
before the request against its belief after. The workspace layout starts that
fetch BEFORE `localIndex.bootstrap` runs, so `before` was null and `after` was
whatever bootstrap had since learned. Different, so the write was refused, so a
fresh online visit cached NOTHING and the next offline cold load had nothing to
render. RAM going null → e1 is the tab LEARNING, not the scope MOVING, and a
check that cannot tell those apart is answering a different question from the
one it was written for.

Every test seeded the epochs by hand and therefore never stood where a real
caller stands. That is the shape worth keeping: nine legs covering the
mechanism, none covering the path.

The second finding is the same mistake one level down — RAM is not what the
fence reads. `hydrateCollections` compares against the DURABLE epoch, so a
RAM-based bracket could agree while the value the fence will actually use moved
underneath it.

So both reads are now durable and both are of the value the fence uses:
`readDurableEpoch` before the request, and the same row read again INSIDE the
write transaction, which is the commit-time value by construction. RAM drops
out of the question entirely — three terms replaced by two, and the two are the
ones that decide.

`DurableEpoch` is `string | null | undefined` rather than `string | null`,
because "the cache says its scope is null" and "the cache has never synced" are
different facts and collapsing them would let a list fetched before the cache
had any scope claim be stamped as though it matched one.

ONE ORDERING PROPERTY FOUND BY BREAKING IT. Awaiting the durable read before
the fetch stopped `loadCollections` issuing its request synchronously — which
is what lets `ensureCollections` (TASK-2200) see an in-flight load and join it,
so the await would have reintroduced the duplicate request that unit closed,
from the other end. Caught by that unit's tests failing, not by review. The two
are now issued together with `Promise.all`: no added latency, request still
issued in the same tick, and the epoch read is "before the fetch" in the only
sense that matters — a resync landing after that point is exactly what the
write-time comparison looks for. The property is now written down in
collectionsEnsure's header, since it was load-bearing and undocumented.

New store legs pin the path the IDB legs missed: a cold tab with no RAM epoch
still caches, stamped with the durable value; and an unreadable durable claim
is passed through as `undefined` rather than defaulted. Both fail against the
reverted RAM bracket.

Gates: svelte-check 0 errors; vitest 2279 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the durable read completes before the fetch, and the namespace comes from authStore (codex round 3)

Two P1s, both of which left the feature broken in its ordinary path.

**The "before" read was not before anything.** Round 2's fix issued the durable
read and the request together with `Promise.all`, on the reasoning that
starting them in the same tick made the read "before the fetch in the only
sense that matters". It does not: `Promise.all` starts both, but the read
RESOLVES later and can observe a resync that landed AFTER the request was
issued. The write then compares that later epoch against itself, agrees, and
stamps an old-scope list with the new scope — defeat by construction, which is
what round 1 found, reintroduced by the fix for round 2.

Now strictly sequential. The concurrency was defended on a cost that does not
exist: the join `ensureCollections` performs depends on the in-flight SLOT,
published synchronously, not on the fetch being issued in the same tick. I had
asserted otherwise in a comment, from a test failure I misread.

**The cache was being written to the wrong database.** `localIndex.userIdFor`
— an accessor I added in this unit — returns a copy of the user id captured
when `bootstrap` ran, and the workspace layout starts this fetch BEFORE
bootstrap. So it returned null on exactly the first visit the feature exists
for: the list went into the `anon` database while every later read used the
authenticated one. Accessor deleted rather than patched; the namespace now
comes from `authStore.userId`, the same source every `bootstrap` caller passes,
so the two cannot disagree.

Deleting it is the point. I introduced that accessor with a comment arguing it
prevented exactly this class — "a caller that resolved the user independently
could write into a different database" — and the accessor was itself the
lagging copy. The fix for a second source of truth is not a third.

TESTS. The ordering leg holds the durable read PENDING and asserts the request
has not been issued; an invocation-order assertion alone would pass for an
implementation that merely evaluated the read first. It fails against the
concurrent version.

Two fixture repairs, both found by mutating rather than reading:

- `collectionsCachedRead` had no `vi.restoreAllMocks()`, so `vi.spyOn(api...)`
  calls leaked between legs and the new ordering leg read a previous test's
  request as its own — failing identically against fixed and broken code.
- TASK-2200's `collectionsEnsure` legs counted requests SYNCHRONOUSLY, which
  passed only because `loadCollections` happened to have no await before its
  fetch. They were pinning the tick, not the request count, and this unit broke
  them for a reason unrelated to coalescing. They now flush first, and the
  header says what they actually pin.

Gates: svelte-check 0 errors; vitest 2280 passed / 146 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 18:26:46 -04:00
xarmian 367aae8e18 fix: one prefix grammar, and the ref parser widens to it (BUG-2943) (#1286)
* fix(collections): a DERIVED prefix is A-Z only (BUG-2943)

DerivePrefix took the first BYTE of each word, so a collection named
'TEMP Rook A 2870' got the prefix 'TRA2'. parseItemRef resolves a
PREFIX-NUMBER ref only when every prefix character is A-Z and otherwise falls
through to a slug lookup, so every item in that collection printed an issue ID
the CLI then refused: 'pad item show TRA2-2942' answered 'item not found'
while the slug resolved fine. Two functions, each locally reasonable,
disagreeing about what a prefix may contain — and the generator was the
permissive one, so the failure surfaced at read time on an identifier the
product itself minted and printed.

The first-BYTE bug had a second half: a word starting with a multi-byte rune
contributed a UTF-8 lead byte, so a collection named in most non-Latin
scripts produced a prefix that is not even valid text.

Non-letters are SKIPPED rather than mapped — there is no honest A-Z
substitute for '2' or 'Omega', and inventing one puts a character in the ID
that is in nobody's collection name. A name with no ASCII letters yields the
empty string, which store.CreateCollection already turns into its ITEM
fallback.

SCOPE, stated because the first draft of this message overstated it (codex
round 1 [P2]): DERIVED prefixes are safe now; the INVARIANT IS NOT ENFORCED.
Three other doors store a prefix verbatim and unvalidated — CreateCollection
with an explicit input.Prefix, UpdateCollection, and workspace import — so
the same unresolvable-ID defect is still reachable through the API, the
--prefix flag and a restore. Named on the trail with their call sites, held
for a ruling rather than swept into this commit, because the import door
wants a different answer from the other two: refusing a restore is not
obviously right.

The parity test lives in internal/store, where parseItemRef is: it asserts
the generator against the RESOLVER rather than against a restatement of the
resolver's rule, which is how these two drifted apart in the first place.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(store): one prefix grammar at all four doors, and the parser widens to it (BUG-2943)

The ruled shape, which dissolves the import dilemma rather than choosing a
side of it: collections.IsValidPrefix is the single definition — an uppercase
letter followed by uppercase letters or digits — and parseItemRef now asks it
instead of carrying its own stricter A-Z rule.

Because the PARSER widened, a workspace already carrying a prefix like AB1
resolves every item by its printed ID the moment this ships. No migration, no
rewrite of an identifier a user's other records may reference.

The four doors:

- derive: unchanged from the previous commit, still letters-only, still
  within the grammar;
- create with an explicit prefix: REFUSED if outside the grammar, with a
  message naming the rule. The caller typed it, so a refusal is actionable;
- update: same, and it matters more here — update is the door someone reaches
  for to FIX a bad prefix, so it must not accept another one;
- import: the most permissive door that can still be honest. Anything the
  parser resolves is accepted (which now includes digits); only a prefix NO
  surface could resolve is refused, naming the collection and saying the
  export can be edited. Carrying that verbatim would restore a workspace
  whose items print IDs the CLI answers 'not found' to, which is this
  item's defect rather than a compatibility owed.

An ABSENT prefix on import is not an unresolvable one. Old exports and every
fixture in the suite carry "", and the first version of this check refused
them — turning a fix for unresolvable IDs into one that cannot restore an old
bundle at all (caught by three server tests). It now takes the same
derive-then-ITEM fallback CreateCollection applies, which also upgrades it: an
empty prefix is itself unresolvable, since the ref would begin with a dash.

A prefix accepted only because the parser widened is logged at WARN, so an
operator can see an id-space that would have been rejected before rather than
inferring it from a resolve failure that no longer happens.

Tests for each door follow in the next commit.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(store): one test per prefix door, plus the parser round trip (BUG-2943)

Each door asserted separately: 'they all call the same helper' is a claim
about the code, not about behaviour, and the bug was two definitions
disagreeing.

- create with an explicit prefix: AB1 accepted and resolves; ab1, 1AB, 'A B',
  A-B, A!, a non-Latin letter and a bare digit refused, with the rule named;
- update: AB1 accepted, a bad replacement refused AND the stored prefix
  unchanged after the refusal — update is the door someone uses to FIX a bad
  prefix, so it must not swap one unresolvable id-space for the next;
- import: a digit-bearing prefix restores unrewritten and resolves; one no
  surface can resolve is refused naming the collection and the export; an
  ABSENT prefix takes the create-path fallback and comes back resolvable;
- the parser: every prefix the doors accept round-trips, and 1AB / 9 / 'A B' /
  A! / a trailing dash / a bare prefix stay refused.

One correction: my first version of the parser test asserted that 'ab1-42' is
refused. It is not, and the code is right — parseItemRef upper-cases before
splitting, which is what makes "pad item show task-5" work. Case-insensitivity
is now PINNED rather than mis-asserted, because a later reader working from
the grammar comment alone would otherwise 'fix' it.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

(This message was rewritten once: the sentence above lost its example because
the original was written with backticks inside a double-quoted shell string,
which the shell EXECUTED and replaced with the command's empty output. The
span was blank in the commit as first written.)

* fix: the widened grammar reaches its consumers too (BUG-2943)

Codex round 2. Widening parseItemRef without widening what CONSUMES a ref
would have left the same two-definitions bug this unit is about, introduced
by its own fix:

- cmd/pad/cmd_github.go matched [A-Z]+-\d+, so 'pad github link' on a branch
  carrying a digit-bearing ref silently found nothing;
- web localSearch's palette Enter fast-path could not recognise one either.

Both now match collections.IsValidPrefix.

Tests strengthened, both on codex's reading:

- the import fallback pinned the VALUE, not just resolvability — asserting
  'non-empty and parseable' passes an implementation that stamps ITEM on
  every absent prefix, giving every collection in a restored workspace the
  same id-space. Two legs now: an ordinary name derives TASK, a letterless
  name falls through to ITEM, which is what makes it 'derive, THEN ITEM';
- the WARN the ruling asked for had no test, so it was a line nobody would
  notice was gone. Now asserted, with a control that an ordinary prefix does
  NOT warn — a log everything trips is a log an operator learns to skip.

Three comments still described the parser's A-Z rule as current, including
one in the file that changed it.

STILL OPEN, on the trail for a ruling: web paneTarget.ts keeps the narrow
grammar on PURPOSE — its comment argues a digit-permitting shape would
misclassify a slug like 'roadmap2-5' as a ref — and that argument cited the
server rule this unit just widened. Whether the guard follows or stays is a
question about the widening's blast radius, not a line to change quietly.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(web,store): the pane guard follows the server, and the precedence is written down (BUG-2943)

Both lead-ruled after codex round 2 surfaced them.

paneTarget's REF_SHAPE kept a LETTERS-ONLY grammar deliberately, citing the
server's A-Z loop as its warrant. The server dropped that rule, so the guard
was holding a grammar nothing else holds — which does not avoid a wrong
answer, it produces a different one. It now matches IsValidPrefix.

The cost is real and is stated in the test rather than buried: an HREF whose
last segment is ref-shaped under the wider grammar is compared by NUMBER with
the prefix discarded, so a genuine slug like 'roadmap2-5' now counts as the
same pane target as TASK-5. The existing test pinned the opposite and is
REPLACED, naming what changed and why. The prefix is dropped because a moved
item keeps a stale one (the server's own number-only fallback) and
PaneGuardItem carries no prefix to compare; tightening that means widening
that type and its callers, which is a separate change and is on the trail.

The SLUG-channel leg is kept as its own test: provenance, not grammar, is
what protects it — a target naming an item by slug is judged only as a slug.

ResolveItem's ref-before-slug precedence is now documented on the function
and pinned in both directions: 'ab1-42' resolves as a SLUG when no AB1-42
exists, and a live ref wins when it does (case-insensitively). The widening
made more strings ref-shaped, so 'is my slug still findable' needed an answer
that does not depend on reading the resolver.

Web unit tests run here via a node_modules SYMLINK to the main checkout,
which CLAUDE.md permits; npm ci was not run and must not be.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(web): the self-pane guard was the fourth copy of the ref grammar (BUG-2943)

Codex round 3 [P1]. The route-level refNumber() still matched [A-Za-z]+, so a
master whose ref is R2-1 parsed as null, its item_number fell back to 0, and
the same-item guard stopped recognising ?item=R2-1 as the master — mounting a
second provider for the item already on screen.

That is the FOURTH consumer found carrying its own copy of this grammar
(github branch extraction, the search palette, the pane target guard, and now
this). Four independent copies is the argument for the shared definition
rather than for four careful edits, and it is why the widening had to be
swept rather than applied where it was noticed.

Also from round 3: comments saying these client regexes 'match
collections.IsValidPrefix' were imprecise — the validator accepts uppercase
only, while the client patterns accept either case on purpose, because a user
types a ref however they like and the server upper-cases before splitting.
They mirror the ref GRAMMAR, and now say so.

Web gates run here through a node_modules symlink to the main checkout
(permitted; npm ci is not): vitest 2195 passed, svelte-check 0 errors.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(collections,e2e): the fifth and sixth copies, and the docs that taught the old rule (BUG-2943)

Codex round 4, after I claimed the sweep was complete twice.

- Two SEEDED PLAYBOOK BODIES carry their own ref grammar and instruct agents
  with it: playbook_library_plan.go and templates_sdd_spec.go both said a ref
  'matches ^[A-Z]+-\d+$'. An agent following those literally would refuse to
  treat AB1-42 as a ref — a grammar copy that lives in PROSE and is executed
  by a reader rather than a regexp engine, which is why two sweeps of the
  code missed it.
- Three e2e comments taught the defect as a rule: one of them carries the
  empirical confirmation ('GET /items/BS1-10 404'd while the slug worked'),
  which is precisely this bug. They now say the by-ref 404 is fixed and that
  the explicit prefix those suites pass buys DETERMINISM rather than dodging
  it.

Counting honestly: six live copies of one grammar, found in four rounds of
review, two of which I opened by asserting there were no more. The shared
definition is the fix; every one of these was a place that had quietly made
its own.

Gates: go test ./... 0, make lint 0 issues, vitest 2195 passed,
svelte-check 0 errors (web run through a node_modules symlink to the main
checkout — permitted; npm ci is not).

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 17:32:36 -04:00
xarmian 22fce21132 fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200) (#1284)
* fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200)

Unit A of TASK-2200 — the shell brick. A cold load while the server is
unreachable left the shell permanently navigation-less: the sidebar builds
its links from `workspaceStore.current`, and when the server came back the
board recovered (its own Retry, plus the items cache) inside a shell with no
links at all. Only F5 fixed it and nothing said so.

WHAT NOTHING RETRIED. `workspaces` and `current` are each acquired exactly
once — `loadAll` from the root layout's per-auth-resolution attempt,
`setCurrent` from an effect keyed on a workspace slug that does not change —
and every other caller of either is a user action: a topbar reorder, the
workspace switcher, the create-workspace modal. `setCurrent` cannot self-heal
either: with an empty array it falls back to a single-workspace fetch and
CATCHES the failure into `current = null`.

The audit that filed this blamed `loadCollections`, which is the half that
does have a recovery path. The permanent half is identity, and it is in a
file the item never cites.

GATED ON THE CONDITION, NOT ON `full_refresh`, and that is the measured part.
The obvious home for recovery is the layout's existing `full_refresh` branch.
It is the wrong one: when the server comes back, `/changes` SUCCEEDS, and
because the cursor was seeded during the outage a quiet workspace answers
with nothing to report — so the result is `caught_up`. The type meaning
"nothing was missed" is exactly the one delivered when everything was. A
`full_refresh` arrives only when `/changes` itself fails, which is the case
where the server is still down and recovery cannot work anyway.
`syncPostOutageResultType.svelte.test.ts` pins that reading, with a control
leg proving `full_refresh` is still emitted when it should be — so if a
future change makes a returning server emit it after all, the gating decision
gets revisited rather than inherited.

That correction also lands on this unit's own recon, which claimed collections
"now recover on their own" via TASK-2921's `full_refresh` subscriber. True
only when `/changes` also fails. So the collection list gets the same
condition gate, using the `collectionsAreFreshFor` predicate that already
exists to tell "this workspace's list" from a stale previous one — and a
genuinely empty workspace stamps its slug on success, so it does not re-fire.

THE ROOT LAYOUT FLAG IS RENAMED, NOT RE-SEMANTICS'D. `workspacesLoaded` said
a load had SUCCEEDED while the code set it before the call and never reset
it, so a rejected `loadAll` read afterwards as a completed one. It is now
`workspacesRequested`, which is what it has always meant. Deliberately still
set before the call and deliberately not reset on failure: setting it only on
success would re-arm an effect whose guard READS `workspaceStore.loading`, so
every failed attempt would flip that dependency and re-run the effect — a hot
retry loop against a server that is down. Recovery belongs where it can be
gated on a condition, which is where it now is. The logged-out-mid-redirect
guard above it is untouched.

Tests: `workspaceRecovery.svelte.test.ts` pins the audit's own sequence
(failed cold load, server returns, recover) plus a CONTROL leg proving an
intact session issues NO request — a recovery that fired unconditionally
would re-list workspaces on every sync result of every healthy session, which
is worse than the defect and would pass any test that only checked the first
leg. Also pinned: a still-down server leaves the condition true so the next
result retries, a `current` naming a DIFFERENT workspace is re-pointed
(presence is not the property), and no second list request stacks on an
in-flight one. Neutering both conditions fails three legs; the control and
the in-flight leg pass either way, which is what they are for.

Wiring pinned in the existing source-pin file, with its limits unchanged, and
one leg asserting the recovery sits OUTSIDE the full_refresh branch — the
placement is the fix, so it is what a pin has to catch.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): one collection load, two reasons to want it (codex round 1)

Round 1 caught that a `full_refresh` arriving while the list was also stale
fired `loadCollections` twice — the recovery `if` and the changed-signal `if`
are separate tests of non-exclusive conditions. The store's load-generation
guard drops the older response, so this was a wasted request and a superseded
one rather than a wrong list, but it is a request nobody needed and it is the
accretion shape: two guards where the question is one.

Consolidated into one call with the two reasons named — `collectionsMissing`
(we do not have this workspace's list) and `collectionsChanged` (the server
says the list moved) — rather than adding a third condition to suppress the
duplicate. Answering accretion by removing a branch, per the working rule
this plan's neighbour established.

The wiring pin moves with it: anchoring on `loadCollections(ws)` alone would
now pass with the recovery term deleted and the changed-signal term left
standing, which is exactly the pre-fix state and the one a returning server
does not reach. It anchors on the MISSING term and on the combined condition.

Gates: svelte-check 0 errors; vitest 2254 passed / 142 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): ensure-a-list and fetch-a-fresh-one are different requests (codex round 2)

Round 2: the round-1 consolidation still raced the workspace effect's OWN
in-flight `loadCollections`. A sync result arriving before that request
settles sees `collectionsAreFreshFor(ws) === false` and issues a second one;
the store's generation guard discards the stale RESPONSE but nothing prevents
the duplicate CALL.

Second finding of the shape "duplicate collection request", so this answers
the population rather than the instance (CONVE-18). The population is the ~19
`collectionStore.loadCollections` call sites, and the enumeration is the
useful part: EIGHTEEN of them are reacting to a known change — an SSE rename,
a settings save, a server `collections_changed`, a reorder 404 — and for
those, joining an in-flight request would be WRONG, because that request was
issued before the change they are reacting to and cannot answer them.
Exactly one call site, the new recovery path, is asking "does a list exist".

So the coalescing is a separate method rather than a behaviour of
`loadCollections`: `ensureCollections(ws)` no-ops when the list is already
this workspace's, joins an in-flight load for the SAME workspace, and
otherwise issues a real one. The join slot is per-workspace, because a
workspace switch can leave A's request in flight while B's starts and a joiner
asking about A must not be handed B's promise; and it is released under the
same generation-ownership rule the `loading` flag already uses, so an older
load settling late cannot clear a newer one's slot.

The layout now picks by INTENT — `loadCollections` when the server says the
list changed, `ensureCollections` otherwise — which is one call either way
rather than one call plus a suppression condition.

Tests: `collectionsEnsure.svelte.test.ts`. The load-bearing leg is the
CONTROL — `loadCollections` is NOT coalesced — because moving the join down
into it would look like a tidy simplification and would pass every other
assertion in the file while quietly serving pre-change data to a rename.
Also pinned: no request when already fresh, no join across workspaces, and
the slot released after a FAILED load so a later ensure retries rather than
resolving against the dead request — the unit's own failure mode, one level
down. Removing the join fails the join leg; removing the freshness check
fails the no-op leg.

One fixture bug found and fixed while writing: holding a single `release`
across two `mockImplementation` calls leaves the first promise pending
forever, which times out and reads exactly like the product hanging.

Gates: svelte-check 0 errors; vitest 2259 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* docs(web): the in-flight slot is one tagged slot, not a per-workspace map (codex round 3)

Round 3 is right about the prose and wrong about the remedy, and both halves
are worth recording.

Right: the comment claimed per-workspace tracking and the code is a single
slot tagged with its workspace. That is a comment describing a structure the
code does not have, which is the kind of thing a successor reuses without
re-deriving.

Wrong: the proposed fix — a per-workspace map of in-flight promises — would
be the defect rather than the cure. `loadCollections` commits only the LATEST
call, so in the sequence round 3 names (alpha, beta, then ensure alpha),
beta's start has already killed alpha's first request: its response is dropped
by the generation guard. A map would let the ensure JOIN that dead request and
resolve its caller against a result that never lands — a quieter version of
the bug this unit exists to fix. Issuing a fresh alpha request is the correct
answer and is what the single slot already produces.

So: the comment now says what the slot is and why a map would be worse, and
the ordering round 3 named is pinned as a test asserting THREE requests — the
behaviour, not the proposal. The workspace tag keeps doing the job it always
did, which is the opposite mistake: without it a joiner asking about alpha
would be handed beta's promise.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): recoverIfMissing JOINS an in-flight loadAll instead of skipping it (codex round 4)

Round 4: `recoverIfMissing` skipped `loadAll` when one was already in flight
and went straight to `setCurrent`. With `workspaces` still empty that takes
the single-workspace fallback, and if THAT failed while the in-flight list
succeeded moments later, `current` stayed null and the shell stayed broken.
Recoverable — the next sync result retries — but a wasted round, and it made
the recovery depend on the fallback endpoint in a case where the list was
about to answer.

The thing worth recording is that this is the SAME QUESTION `ensureCollections`
answers three files away — "a request for this is already in flight, do I skip
or join?" — and I answered it by joining there and by skipping here, in one
unit, an hour apart. Reviewer-named instances are a sample; this one had a
sibling I wrote myself. Both now join, and `loadAll` publishes its in-flight
promise the way `loadCollections` already did.

The `!loading` condition is gone rather than repaired: with the join, "is one
in flight" is answered by the promise slot, and a second way to ask the same
question is what let the two sites drift.

The in-flight test leg is rewritten to assert the OUTCOME, not just the
request count — round 4's second point, and the fair one. It now holds the
single-workspace fallback DOWN, which is what discriminates: the old skip path
took that fallback and left `current` null, and no call-count assertion could
see it. Reverting the join to the skip fails that leg.

Gates: svelte-check 0 errors; vitest 2260 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): loadAll's cleanup is owned by the generation that set it (codex round 5)

Round 5: `loadAll` cleared its join slot unconditionally, so an older
overlapping request finishing first clears a NEWER one's slot — after which a
concurrent `recoverIfMissing` starts a third request instead of joining the
load still running. Round 4's race, reintroduced by round 4's own cleanup.

Guarded by a generation counter, which is the instrument `collections.svelte.ts`
already uses for exactly this on its `loading` flag and its own join slot.
`loading` moves under the same guard for the same reason: an older load
flipping it off while a newer one runs is the spinner half of the same
mistake. Promise identity would read more directly but forces a self-reference
the type checker cannot prove is assigned before use.

**Third time in this unit that a rule was applied at one door and not its
sibling** — join-vs-skip in round 4, and now ownership-of-cleanup — and both
siblings were in files I had open. Recording it here rather than only on the
trail, because the pattern is the finding.

NAMED, NOT FIXED: `loadAll` still has no guard on which RESPONSE commits, so
two overlapping calls can leave the OLDER list in `workspaces` if it resolves
last. `collections.svelte.ts` has that guard and this store does not. It is
pre-existing and cannot be reached through the recovery path, which only ever
joins and never issues a competing call — so it is a separate fix with its own
test rather than something to fold in here. Written into the store's own
comment so the next reader finds it at the code rather than in a commit
message.

The new leg had to be rewritten before it was worth anything. Its first draft
resolved the older request SUCCESSFULLY, which populated `workspaces`, sent
the recovery straight past its list branch, and passed against the mutant too —
a fixture that could not fail, caught by running it against unconditional
cleanup rather than by reading it. The older request now FAILS, which is what
keeps the array empty and gives the recovery something to recover.

Gates: svelte-check 0 errors; vitest 2261 passed / 143 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 16:19:53 -04:00
xarmian 3a00ea9c8c fix(cli,mcp): item update --parent "" is refused, not silently ignored (BUG-2941) (#1285)
* fix(cli): item update --parent "" is refused, not silently ignored (BUG-2941)

It exited 0 and printed the updated item while doing nothing: hasFieldChanges
tests parentRef != "", so the empty value built no patch and the key the
server's clear-path needs (parent present, empty) never reached the wire. The
two representations of the link then disagreed — parent_id read null while
parent_ref and the child listing still named the parent, and the parent still
could not be closed for open_children.

BUG-2078 shipped --clear-parent as the working route and left this one looking
like it worked. Refusing rather than aliasing it: two spellings for one
operation is what produced the confusion, and naming the flag that does the
job is the actionable answer.

UPDATE only. On create an empty --parent expresses nothing to ignore and
--parent "$MAYBE_EMPTY" is a normal shell idiom; a test pins that asymmetry
as a decision rather than a gap.

Compat note for review: a script passing --parent "$P" with P empty gets a
loud failure where it used to get a silent no-op. That is the point of the
change, but it is a real behaviour change for callers who were relying on the
no-op, and it is the one thing here worth a second opinion.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(cli,mcp): refuse before the request, and keep MCP's empty-parent convention (BUG-2941)

Codex round 1 found two P1s in the first shape of this fix.

1. THE REFUSAL WAS TOO LATE. It sat beside the parent handling, which is
   after the item fetch, so a refused call still made a GET. My test only
   watched for writes, so it passed a version that refuses after fetching —
   the test was not an instrument for the claim it was named for. The guard
   now runs first in RunE, before the client exists, and the test counts
   EVERY request rather than ignoring GETs.

2. THE FIX WOULD HAVE SPLIT THE TRANSPORTS. Stdio MCP shells out to the CLI
   and BuildCLIArgs emits a flag for any key that is PRESENT, so the
   catalog's `parent: ""` — documented inert since v0.19 — became
   `--parent ""` and would now be refused on stdio while the remote door
   went on ignoring it. A transport divergence created by a fix for a
   transport-independent bug, which is the class BUG-2870 exists to close.
   dropInertEmptyParent removes the key before dispatch.

Dropped there, refused at the CLI: same input, opposite dispositions,
because the two surfaces have opposite conventions about what an empty
declared string means. At the CLI a human typing it means "detach"; in the
catalog it means "not provided", and `clear_parent` is the documented way.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(mcp): the empty-parent drop is about parent, not about emptiness (BUG-2941)

Codex round 2 [P2]: the test could not tell 'drops an empty parent' from
'drops every empty-valued key', which would be a much larger and undiscussed
change to the tool's input handling. It now carries an empty `comment`
alongside and asserts that one still reaches the CLI.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs: two wording corrections from codex round 2 (BUG-2941)

BuildCLIArgs emits THIS STRING FLAG whenever its key is present — booleans
and hidden flags are handled differently, so the broader claim was wrong
even though it held for the case at hand. And '--parent "" reads as detach'
described the caller's intent as though it were the code's behaviour; it
never was, which is the whole bug.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 16:18:39 -04:00
xarmian ee0d945863 fix(cli,mcp): one --field key=value entry means one thing at every door (BUG-2870) (#1283)
* feat(items): one shared parse for a --field key=value entry (BUG-2870)

Six sites parsed that entry independently — item create, list, update, move
and copy in cmd/pad, plus ingestFieldKVP on the remote /mcp door — in four
spellings, and they disagreed about what it meant. The CLI sites used both
halves verbatim, so `--field " effort=l"` stored an undeclared field named
" effort" and left the declared `effort` untouched; the remote door trimmed
both halves and wrote `effort`. Same call, two stored keys, decided by which
transport the caller was on.

This is the helper only; the call sites move over in the commits that follow.

Two rules, deliberately asymmetric, per the day-60 ruling:

- a KEY whose trimmed form differs from what was written is REFUSED at every
  door, rather than silently retargeted to a different field;
- a VALUE is carried VERBATIM at every door, because trimming reinterprets a
  caller's bytes and on a text field the space is content. A padded value
  against a typed field is refused one layer down by validation, naming the
  field — measured, not assumed.

ErrFieldEntryMalformed is returned rather than handled because the six sites
deliberately disagree about a malformed entry (four skip it, copy hard-errors)
and unifying that is a separate decision.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(cli,mcp): all six --field parse sites go through the one helper (BUG-2870)

item create, list, update, move and copy in cmd/pad, plus ingestFieldKVP on
the remote /mcp door, now call items.SplitFieldEntry instead of each rolling
its own split. A padded key is refused at every door; a value reaches every
door verbatim.

Two sites keep something specific to them, both documented in place:

- `item list` is a READ filter, and it takes the same key rule deliberately:
  a padded key there filters on a field nobody declared and returns empty,
  which is indistinguishable from "no rows match".
- `item move` gets KEY normalisation only. Its values stay strings because
  the server types a declared field on that path too, so a clean
  `--field n=3` already stores the number 3 — measured before the change.

Each site keeps its historical disposition toward a MALFORMED entry (four
skip silently, copy hard-errors), which is why the helper classifies that
case rather than deciding it.

NOT YET EVIDENCE: ./internal/mcp, ./cmd/pad and ./internal/items all pass,
and that green does not show the divergence closed — the three BUG-2850
pinned tests exercise the catalog conflict pass, which never reaches
ingestFieldKVP. The door-level test and the re-grounding of that pass are
the next commits.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(cli,mcp): pin the door-parity claim at both doors (BUG-2870)

Nothing in the suite asserted what the remote door STORED for a padded entry
— the three BUG-2850 tests that cite its trimming all exercise the catalog
conflict pass, which never reaches ingestFieldKVP. So the previous commit's
green was not evidence for the thing it changed.

Three files now hold the claim: internal/items pins the rule, internal/mcp
pins the remote door, cmd/pad pins the CLI door, and each cites the other
two. Padded key refused at both; padded value carried verbatim at both; a
refusal aborts the call rather than dropping one entry, and on the CLI it
happens before any request reaches the server.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(mcp): re-ground the conflict pass on the new door behaviour (BUG-2870)

The pass's rules were derived from ingestFieldKVP trimming, so changing the
door without changing the layer built on it would have been the same
one-door lapse a level up.

- parseFieldArray splits through items.SplitFieldEntry: a padded key is
  REFUSED before dispatch on both transports, and values are indexed RAW,
  because raw is now what both doors write.
- Both comparison sites compare raw for the same reason. The round-19
  "COMPARED TRIMMED" rule is superseded and its comment says so.
- detectFieldConflicts PROPAGATES the parse refusal instead of returning nil.
  It swallowed it as "the caller owns this error surface", which was true
  when the only possible error was a shape error — reshapeItemFields returns
  early with no `fields` object, so on the no-`fields` path (this bug's path)
  nobody owned it and a padded entry turned back into a success.
- A padded entry is refused in the pass rather than skipped. Skipping dropped
  it from conflict detection entirely, turning four existing refusals into
  successes.

The last two were caught by the BUG-2850 tests, not by reasoning: the first
shape of this commit passed a full package build and turned four guards off.

Seven tests still fail. They assert the OLD door behaviour and are the
specification being changed; each gets read on its own next, and is either
kept because the behaviour survives or replaced by a test stating the new
behaviour that cites the old name.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(mcp): restate the seven BUG-2850 pins on the new rule (BUG-2870)

Each was read on its own and either kept or replaced; every replacement
names the test it replaces and why the old assertion was right at the time,
so the deletion is traceable rather than a green that appeared.

- padded value is not a conflict → IS a disagreement now that no door trims
  (" done" and "done" are two values), with an equal-values control leg.
- padded entries still caught (hierarchy) → refused EARLIER, by the padded-key
  rule, before the alias pass observes both keys. The alias guard keeps its
  three unpadded cases, which is what stops this being a hole.
- PaddedEqualDuplicateIsCanonicalized → IsRefused, plus a canonical control
  that still emits --field exactly once.
- MixedCanonicalAndPaddedDuplicatesCollapse → Refused. The round-8 finding
  survives: one canonical entry still does not make its padded sibling
  harmless, it is refused rather than swallowed.
- PaddedEntryAloneIsUntouched → IsRefused. That test pinned a DEFERRAL, in
  its own words "BUG-2870's business, not this PR's". This is that business.
- "fields carries the key — canonicalized, so accepted" → still refused,
  since nothing canonicalizes now; the per-key question it defended is still
  tested by the two legs beside it, and a canonical control was added.
- ReEmittedValueKeepsItsWhitespace → the re-emission path is gone, so it
  becomes a refusal test that also asserts the ADVISED form is accepted with
  its value untouched. The property it defended is pinned at both doors.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(server): pin that a move override is typed server-side (BUG-2870)

The fact the ruling turned on, and the easiest one in this unit to lose: it
is invisible from cmd/pad, where moveCmd plainly sends a string.

- a declared number field given the STRING "3" through field_overrides ends
  up as the NUMBER 3, which is why move needs the shared KEY parse and no
  client-side typing;
- a padded " 3" is REFUSED with a 400 and the item does not move, which is
  the answer the remote door will now give too instead of trimming and
  succeeding.

t.Parallel per CONVE-2086 — both build their own server through testServer,
so each has its own database, limiter and bus.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* chore(mcp): bump tool surface to 0.30 and sync the docs the guards enforce (BUG-2870)

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* refactor(mcp): remove the canonicalization the door change made unreachable (BUG-2870)

Two mechanisms existed to make a padded entry reach both doors as the same
write: the nonCanonical conflict guard (round 16) and the re-emission path
that rewrote a padded entry to canonical form (rounds 7/8). Both are dead
now — items.SplitFieldEntry refuses a padded key, so every entry that parses
satisfies `entry == key + "=" + value` BY CONSTRUCTION.

Removing each changed no test. That is consistent with "dead" and with
"untested" alike, so the construction argument above is what settles it —
recorded in the comments that replace them, along with what the removed
guard was defending and where that premise is enforced now.

Rewriting a caller's key was also the behaviour this bug is about, applied
by us rather than by a door: canonicalization silently changed the key the
caller wrote. Refusing says so instead.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(mcp): put the trimming narrations in the past tense (BUG-2870, CONVE-23)

Six comments described the old door behaviour in the present tense ("HTTP
trims and writes effort"), which reads as a claim about the code as it
stands. The rounds they narrate still explain why the surrounding rules
exist, so they are re-tensed rather than deleted.

Two references were checked and left alone because they are still true:
ingestFieldKVP does still store every field value as a STRING (coerce.go's
BUG-2850 note, and the github_pr hint in dispatch_http.go). This change
stopped it TRIMMING, not stringifying.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs: sync CLAUDE.md to tool surface 0.30 (BUG-2870)

The drift guards cover instructions.md and README.md but not this file, and
its own 0.27 entry records the consequence: 'This entry was missing from
CLAUDE.md — the 0.27 unit swept instructions.md and README.md and not this
file.' The unit that makes a version line stale is the unit that owes it.

Both markers updated, and the entry states the two behaviour changes in the
terms they were ruled: /mcp refuses what it silently accepted, and the
swallowed parseFieldArray refusal that was landing four refusals as
successes on the no-fields path.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(mcp): finish the removal, and correct a claim I made twice (BUG-2870)

Codex round 1: no P1/P2, two nits, both real.

1. The re-emission removal was incomplete. `reEmitFields` and the branch
   that appended its entries survived with nothing populating the map, and
   two comments still described canonical re-emission as something this code
   does. Unreachable, but my own commit message had said the path was
   removed, so the code contradicted the claim. Removed, and the round-16/17
   paragraphs that decided WHEN to canonicalize go with it — they answered a
   question that no longer arises.

2. "The only behaviour change is /mcp refusing what it silently accepted" is
   WRONG, and it was in version.go, README.md and CLAUDE.md. Every door
   refuses a padded key now; they were merely accepting it differently —
   /mcp trimmed it and wrote the declared field, the CLI stored a ghost field
   beside it. What is /mcp-only is the VALUE half. Corrected in all three,
   with the correction itself recorded in the version.go entry so the next
   reader sees the claim was checked rather than a sentence that quietly
   changed shape.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(mcp): rename the predicate to the question it asks (BUG-2870)

Codex round 2: no P1/P2, three nits, all naming and prose.

- `canonicalized` is renamed `coveredByFieldsObject`. Nothing canonicalizes
  anything any more, and the only thing that predicate ever asked was
  whether the `fields` object carries THIS key — it kept the old name only
  because the guard it used to feed had been removed a commit earlier.
- parseFieldKVP's doc said invalid entries are skipped silently. True of a
  MALFORMED entry, false of a padded key, which now aborts the call.
- Three test comments still described re-emission as live, and version.go
  described this door's trimming in the present tense.

Nothing in these two rounds was a defect in the change itself; both rounds
found prose describing a version of the code that stopped existing partway
through the unit, which is the failure mode a re-grounding pass invites.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(mcp): last of the prose that outlived the code (BUG-2870)

Codex round 3: no P1/P2, prose only.

- the predicate's own comment still asked 'will anything canonicalize THIS
  key'; it asks whether the fields object carries the key, and always did;
- two test comments described re-emission and trimmed comparison as current.
  Both tests are kept — what they pin is narrower now and still worth
  pinning — with the change in what they mean written down.

Deliberately NOT changed: the comments and replacement-test names that cite
the OLD test names. Codex reads them as stale terminology; they are the
traceability the restatement commit was asked for, so a reader can find what
each replacement replaced.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* docs(items): the coercion note names what the door does now (BUG-2870)

Codex round 4. The paragraph described ingestFieldKVP as doing
`dst[key] = val` unconditionally. Its CLAIM — every value arrives at the
server as a string — is still true and is the reason this file exists; the
description of the line is not, since that door now parses through
items.SplitFieldEntry. Restated so the still-true part is not carried by a
sentence a reader can falsify.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 15:33:05 -04:00
xarmian 5032bacfd6 fix(web): a tab may only write to the durable cache under the scope that cache advertises (TASK-2922) (#1282)
* fix(web): a tab may only write to the durable cache under the scope that cache advertises (TASK-2922)

PLAN-2903 item 1 — F2, the last of the plan's six. The durable local index
could end up holding rows fetched under a REVOKED scope while its meta row
advertised the CURRENT one, and once in that state nothing re-fired: every
later poll and every later cold boot compared the two epochs, found them
equal, and re-checked nothing. A permission-revoked row stayed readable
indefinitely.

The route is one door, not two. `persistReplace` clears the tombstone store
wholesale, so ids its snapshot DROPPED lose the only cross-tab record that
they were dropped. A tab that did not run that resync has none of the guards
that would stop it writing them back — `scopeEpoch`, `fencedIds` and
`movedOutFloor` are all session-local and none of them bumped in that tab —
so the row reaches `persistUpserts`, where both the stored row and its
tombstone were just cleared and `resolveRowWrite` sees an unopposed insert.
`persistUpserts` writes no meta row, so the epoch stays at the value the
resync stamped.

`persistDelta` was checked for the same hole and does NOT have it: a batch
from a behind tab carries that tab's own older epoch, which drags the durable
epoch BACK, and the resulting disagreement with the server's next response is
what triggers the repair. It self-heals; only the upsert door did not.

So `persistUpserts` now takes the epoch its caller believes it holds and
writes NOTHING when the stored meta row disagrees. Both sides are
`access_epoch`s and the only operator is `!==` — an access epoch is a hash of
the live grant set and can answer "same or different" and nothing else, which
is what PLAN-2903's working rule asks each unit to state before it writes a
fence. Nothing here says "older", so nothing here can be an ordering claim in
an equality costume, which is what defeated the fences on IDEA-2898's
abandoned branch.

The parameter is REQUIRED rather than optional so the type checker is the
enforcement; an optional one would make the quiet call site the unguarded
one. That is the whole of the 28-call-site churn in the two persistence test
files.

Cost, taken deliberately: a behind tab's optimistic upsert is deferred, never
lost. `localIndex.upsert` writes RAM and the search index before it persists,
so that session keeps serving its own row, and its next `/items-changes`
carries the epoch it does not hold, resyncs, and the snapshot re-includes the
row.

Also withdraws the `RESIDUAL (codex F2, lead-accepted)` note on
`persistReplace`. The acceptance rested on the hazard being self-healing —
"the next resync recomputes the fence and re-drops the row" — and that premise
is false in the case that matters, because the repair belongs to the tab that
made the stale write, so a tab that goes away after its write commits takes
the repair with it. The key-diff that note declines stays declined, and for a
better reason than cost: a tombstone is SEQ evidence and the thing being
refused is a SCOPE fact, so it fits the reachable case by coincidence of
ordering and cannot refuse a genuinely newer row the writing tab could still
see under the old scope.

Tests: `localIndexScopeWriteFence.idb.test.ts` pins the route against a real
IndexedDB — the refusal, a CONTROL leg proving the current tab still writes
through the same door, whole-batch refusal, the no-meta-row and null-epoch
boundaries, and a leg making the ORDERING explicit (a stale write landing
BEFORE the resync is dropped by the replace, not by this fence). Wiring legs
in `localIndexAccessEpochWiring.svelte.test.ts` assert the caller passes its
own belief and that the argument TRACKS that belief rather than being fixed,
plus a leg pinning that RAM is written before the persist. Each was run
against the unfixed tree: removing the fence fails the two defect legs,
passing a fixed `null` fails the two wiring legs, and moving the persist ahead
of the RAM write fails the deferral leg. The four boundary legs pass either
way, which is what they are for.

Gates: golangci-lint 0 issues; go test 30 packages ok, 0 FAIL; svelte-check 0
errors; vitest 2241 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the deferral is safe by provenance, not by the next poll (codex round 2 P1)

Round 2 attacked the "deferred, never lost" claim on the new epoch gate and
was right about the mechanism I had written. I gave the repair as "the tab's
next `/items-changes` carries the epoch it does not hold" — true whenever a
reconcile happens, and reconciles here are SSE-driven rather than timed, so a
quiet workspace whose SSE connection has dropped may not poll again in that
session. A stated mechanism that only usually holds is the shape my own trail
keeps recording: the explanation is the part a successor reuses without
re-deriving it.

The claim survives on a different and stronger reason, which is the one I had
not written down. Every row reaching `persistUpserts` is SERVER TRUTH — a
mutation response, an SSE-derived row, or on the drag-reorder path a seq-less
optimistic guess whose authoritative response follows moments later — so the
durable copy is a cache of something the server still holds, and this door
never advances the cursor, which keeps a refused mutation inside the next
delta's window. Any later snapshot, any later delta, and every cold boot
re-supply it.

The two statements bound different things and only one is load-bearing: the
poll bounds how long the durable cache LAGS; provenance is what says no row is
ever at RISK. Both now appear in the note, labelled.

New leg, `an ordinary delta restores the durable copy a refusal deferred`: it
asserts the refusal and then the restore through an ordinary delta, so the
claim is measured rather than assumed about scheduling. It fails against the
unfixed tree (the refusal assertion does), alongside the two existing defect
legs; the four boundary legs pass either way, which is what they are for.

Also rewrites the new test file in the repo's own style. It had been formatted
by a `prettier --write` run that also reflowed six untouched files into an
800-line diff — reverted there, and the new file's double quotes were what
survived the revert because it was untracked. The repo has no prettier config
and is not prettier-clean; that command should not have been run.

Gates: svelte-check 0 errors; vitest 2242 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* test(web): enumerate the repair paths instead of answering the third instance (codex round 3)

Round 3's finding is a FALSE POSITIVE on mechanism and a TRUE one on coverage,
and the second half is the part worth the commit.

The mechanism it names — `applyDelta` advancing the cursor past a row it did
not persist, stranding a refused write — cannot happen, and the code that
stops it predates this unit: when a delta's change loses to the RAM row,
`applyDelta` pushes the EXISTING row into the persist set precisely so "the
IDB cursor we're about to advance doesn't lap a row that may not be durable
yet". That guard was added for the fire-and-forget upsert whose write might
never land; an epoch refusal is a new way to REACH it, not a new hole. Two
further guards make the batch that would be needed unconstructible: deltas are
contiguous in seq from the tab's cursor, and a refused row came from a
mutation whose seq is above that cursor by construction.

Nothing pinned any of that, which is the true half.

Rounds 2 and 3 attacked ONE claim — that a refused durable write is always
re-supplied — from two directions. Two findings of a shape is a reviewer
sampling a population, and my own trail records the price of answering the
instance instead: four rounds on TASK-2921 spent one instance at a time. So
this commit writes the population down in full, in the test file, with the leg
that pins each and the reason where a leg is not owed:

  1. a delta CARRIES the row — pinned in the idb file;
  2. a delta's change is STALE or EQUAL so RAM wins — pinned here, new leg,
     and it fails when the `toPersist.push(existing)` line is removed;
  3. a change at or below the cursor floor — unreachable, argued rather than
     tested, and the argument is in the comment;
  4. a resync — no leg, deliberately;
  5. a cold boot — already covered above in the same file.

Entry 4 is the one I got wrong first. I wrote a leg asserting that a resync
carries the refused row, and it passed — because the snapshot I handed the
mock contained the row. It would have passed against any build, fixed or
broken, since the RAM copy plays no part in what `persistReplace` writes. A
resync writes the SERVER's snapshot, so the row returns if and only if it is
still in scope, which is the right answer either way and is not a repair path
this unit owns. The leg is deleted rather than strengthened; a fixture that
cannot fail is worse than an absent one, because it reads as coverage.

Gates: svelte-check 0 errors; vitest 2243 passed / 140 files.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 14:44:09 -04:00
xarmian 5d29b7715c fix(cli): item show --format markdown emits the body verbatim (IDEA-2937) (#1281)
* fix(cli): item show --format markdown emits the body verbatim (IDEA-2937)

`pad item show <ref> --format markdown` printed the body with fmt.Println,
adding a trailing newline the stored body did not have. The write half,
`pad item update <ref> --stdin`, sends what it is given and the server
stores it verbatim (measured live: a body with no trailing newline and one
with three, both stored exactly as sent). So the read-modify-write shape
every agent reaches for was not a fixed point — writing back what `show`
emitted appended one newline per cycle, without bound: 518, 519, 520, 521,
522 bytes on the reporter's fixture, reproduced here as 18 → 22 over four
cycles. With the fix, four cycles hold at 18 bytes with an identical sha.

Scope, stated because the trail records a loss as well as a growth: the
`$(pad item show ... --format markdown)` capture that LOSES a byte is NOT
this defect and is NOT fixed here. Command substitution strips every
trailing newline from whatever it captures — `C=$(cat file)` on the same
18-byte file yields 17 too — so a body ending in a newline loses that
newline through `$()` before and after this change. Tools that need a
lossless read must redirect or pipe, not capture. Whether the server should
normalize a stored body to end with exactly one newline (which would make
even the `$()` path a fixed point, at the cost of destroying deliberate
blank lines at the end of a body) is a separate question, left on the trail.

Tests assert equality with the body, not `Contains`, on both halves: what
`show` emits, and what `--stdin` sends. Both were run against the unfixed
line and fail there — restoring the Println kills all five show subtests,
and a TrimRight on the stdin read kills three update subtests.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* fix(cli): close the test stdin pipe; the comment overstated what was measured

Codex round 1 returned CLEAN with two remarks, both real:

- feedStdin restored os.Stdin but never closed the read end, leaking one
  file descriptor per subtest.
- The code comment said "Both directions were this one line", claiming the
  `$(...)` byte LOSS as this defect too. It is not: command substitution
  strips every trailing newline from whatever it captures — `C=$(cat file)`
  on the same 18-byte file yields 17 — so that loss is identical before and
  after this change. The commit message was already corrected before the
  review; the comment was not, and a comment is what the next reader has.

`go test ./cmd/pad/ -race` exit 0 after both.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR

* test(cli): captureStdout closes its pipe read end

Codex round 2 nit. The helper predates this branch, but the new round-trip
tests call it ten times, so the leak is ten descriptors per run rather than
a few. Closing after ReadFrom completes the cleanup the helper already
started with w.Close().

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 14:10:07 -04:00
xarmian d387d540cd fix(web): the cold path pins its cursor to the snapshot and owns the replay (IDEA-2924) (#1280)
A cold /items-index snapshot whose cursor is BEHIND the cache's is a stale
response that landed late — a delta was consumed while it was in flight. Keeping
the higher cursor is what made TASK-2920's reinstatement PERMANENT: nothing
would ever replay the range the snapshot did not see.

The cold branch now pins to the snapshot's cursor, the discipline
resyncProjectionScope has always had and whose safety TASK-2906 measured (a RAM
regression is safe; the merge keeps the newer row per id and a replayed range is
idempotent). Nothing changes in IDB — the durable gate already refuses a behind
cursor, whole, and the recon on the idea's trail establishes that both reachable
states make that refusal correct.

THE REPLAY OWNER IS MEASURED, NOT ASSUMED. The collection route calls deltaSync
right after bootstrap in its own effect; ItemDetail — the only other bootstrap
caller — calls it with NOTHING following. So on the item route a pinned cursor
would sit un-replayed until an unrelated event happened along, which in a quiet
workspace is never. The cold branch therefore drains it itself, through the SAME
reconcileWorkspace both other doors use rather than a second loop, and only when
the snapshot was overtaken.

Non-fatal, except for auth. The snapshot is already installed and usable, so a
network blip on a FOLLOW-UP drain must not flip the UI to 'error' — the warm
branch has always refused that. A 401/403 is rethrown so the purge still
happens.

movedOutFloor STAYS. It keeps the property true while the pin is unproven, and
removing a guard in the same change that replaces it is how a gap ships green;
its removal is its own later unit.

Two tests replaced rather than deleted: they asserted the cold reinstatement was
PERMANENT, which was true and deliberate under TASK-2920 and is what this
removes. The comment in the file records why a green test was changed.

Mutation: 7 mutants, 7 killed. Two of them found defects rather than weak tests
— the ask was undetectable until a failing-replay test existed, and writing that
test showed the replay's failure was taking the whole bootstrap down with it;
and swallowing a 403 on the replay path survived until its own leg was added.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 13:12:44 -04:00
xarmian 83f00a2c66 docs: a fresh worktree has no web/build, and that breaks the Go gates (#1279)
embed.go embeds a generated directory a new worktree does not have, so make test
and make lint fail with 'pattern all:web/build: no matching files found' before a
single test runs. go build ./... dies first and two packages report
[setup failed], which reads as a broken tree rather than a missing generated
directory — it cost two confused minutes twice in one day.

The sibling trap (.svelte-kit) is already documented one bullet up; this is the
same class and belongs beside it.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 12:51:54 -04:00
xarmian 6b9e7bcd71 fix(web): the index owns its signals — one reconcile loop, driven by the workspace layout (TASK-2921) (#1278)
* refactor(web): the reconcile loop is the store's, and both doors are the same code (TASK-2921)

bootstrap and the collection route's deltaSync each carried their own copy of
the reconcile loop, and the copies had drifted in four places: the route checked
no generation, bootstrap reimplemented ensureProjectionScope's predicate inline,
the two cleared the ask through different doors, and only one assigned
includesUnparentedMetadata per iteration. The comment this replaces named the
cost itself — a rule added at the public entry point silently did not apply to
the other copy, which it called the fourth time in PLAN-2903 that a rule landed
at one door and not its sibling.

Both now call reconcileWorkspace. Each door keeps only what is genuinely its
own: bootstrap its generation capture and its 403 cache wipe, the page its error
banner and reset.

ONE DELIBERATE BEHAVIOUR CHANGE, not a refactor artifact: bootstrap's inline
projection test let a NULL scope over a populated cache silently adopt the
incoming value. ensureProjectionScope resyncs instead, for the same reason the
access epoch's null baseline does — a cache whose scope we cannot vouch for must
not be told what it was authorised for by the response we are checking it
against. Pinned by a test.

Deletes localIndexAccessEpochPageWiring.test.ts, a source-text pin on the page's
copy, per the deletion condition its own header states: 'should be DELETED the
day the page's deltaSync grows a real harness or moves into the store'. Replaced
by behavioural tests on the public localIndex.reconcile door, which also kill the
short-circuit mutant the text pin explicitly could not.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the workspace layout drives the reconcile, so no page is the only thing keeping the index honest (TASK-2921)

IDEA-2901: the sync_required -> deltaSync subscription lived in the collection
route, so a client's promptness in reconciling depended on which route it was
sitting on. On item detail, the copy dialog, the graph or any other workspace
route, a signal arrived and nothing called deltaSync — while the index was still
being READ there (ItemPicker reads it from the copy dialog, which is not a
collection route).

Both drivers move to the workspace layout: it is mounted for every route under
[username]/[workspace], and it is where the signal source itself lives
(syncService.init + connectSSE, disconnect in onDestroy). A store-owned
subscription would have outlived its own source, which is the premise correction
that reshaped this unit — recorded on the trail.

markSynced moves with them: it advances syncService's own cursor, is
workspace-level, and needs the reconcile's outcome.

The auth-error reaction becomes the store's. The page's was localIndex.reset(ws),
which DELETES the state entry and with it the 'error' bootstrapState the banner
reads — which is why the page carried a private deltaSyncFailed flag to remember
what the store had just forgotten. dropCacheForAuthError clears in place, both
doors share it, and deltaSyncFailed is deleted: indexError now covers the case
from every route rather than one. Without this the driver move would have
silently dropped the TASK-1360 purge, since the page was the only thing reacting
and is no longer the thing running the loop.

Banner behaviour is pinned in both directions, including two NEGATIVE legs: a
cap hit and a transient failure must NOT raise it. The ruling anticipated
deriving it from pendingResyncFor, which is set in both those cases and would
have widened the banner to states it has never appeared in; the divergence is
deliberate and declared on the trail.

Prose sweep (CONVE-23), seven sentences the change falsified, one of them
written in the previous commit of this same unit:
- scopeEpoch: 'RECONCILE LOOPS DO NOT USE THIS' -> singular, with the date
- movedOutFloor cap receipt: 'both live callers' -> one caller
- ensureAccessScope: 'bootstrap carries the same comparison inline' -> it does not
- ensureAccessScope's silent-adopt note: names the layout as the SSE driver
- resyncProjectionScope x2: 'the bootstrap reconcile loop owns the flag'
- localIndex.reconcile: 'each door still owns that reaction' — mine, falsified
  by moving the reaction into reconcile two commits later

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* test(web): pin the two rules the mutation run found unpinned at the shared door (TASK-2921)

Two survivors, and neither was the code's fault:

- 'reconcile never clears the ask' SURVIVED because the assertion sat after a
  COLD boot, where pendingResync is already false — the test passed whether or
  not the clear happened. Moved to the resync test, where a resync actually SETS
  the ask first.
- 'token captured at response time' first survived an UNFAITHFUL mutant (moved
  later in the same synchronous run, still before the await). The faithful one
  then survived for real: TASK-2909's request-time-capture rule was pinned at
  neither door. Its end state is identical either way once the next iteration
  catches up honestly, so the discriminator is the REQUEST COUNT — an overtaken
  response must send the loop round again.

Matrix now 11 mutants, 11 killed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the access-revoked signal outlives the reset that erases the state (Codex round 1 P1)

On a 403 the API client's GLOBAL access-revoked handler runs
localIndex.reset(scope.workspace) BEFORE the error reaches any caller, and reset
DELETES the workspace state entry. So dropCacheForAuthError was writing
'error' onto a detached object, bootstrapStateFor answered 'cold', and the
collection route stayed on 'Loading...' forever.

This invalidates the reason the previous commit gave for deleting the page's
deltaSyncFailed flag. That flag was not a workaround for the page's own reset —
it was a memo that SURVIVED a state deletion nothing else survives, which is the
one thing a per-workspace field cannot do.

The fix is not to put the flag back on the page. It is a module-level
accessRevoked set, outliving the state exactly as resetGenerations and
reconcileTokens do (IDEA-2913 moved the reconcile token out for the same
reason), read through accessRevokedFor from any route. Cleared when a fresh
bootstrap starts, which is what the banner's Retry CTA triggers.

Two tests, and the gap they close is a gap in the SUITE as much as in the code:
every other test in that file mocks api.items.changes and so never goes through
api.request(), which is where the global handler lives. The new ones reproduce
request()'s exact order — reset, then throw. Both fail with the marker removed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): record the revocation before the staleness bail, in BOTH of bootstrap's catches (Codex round 2 P1)

The round-1 fix covered the public reconcile door only. Both of bootstrap's
catches checked isStale() BEFORE the auth branch — and the global 403 handler's
reset is exactly what makes isStale() true, so the revocation was swallowed into
a cold state with no banner. The outer catch is the likeliest path of all: a 403
on the cold /items-index, i.e. the first load after a revocation.

The staleness guard exists to stop stale WRITES. dropCacheForAuthError's writes
to a detached state are inert; the part that matters is the module-level marker,
which is not a write to that state at all. So the auth branch goes first.

One test I wrote for this was MISLABELLED: it claimed to cover bootstrap's
reconcile loop and called localIndex.reconcile, which has its own separate
catch. The mutant restoring the old ordering in bootstrap's inner catch survived
it, which is the only reason the mislabelling surfaced. It now drives bootstrap
through its reentry path.

Matrix on this path: 5 mutants, 5 killed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): one auth-error reaction, not two, when the error crosses both catches (Codex round 3 P2)

bootstrap's inner catch reacted and rethrew, and the outer catch reacted again
on the same error: two markWorkspaceDropped bumps, two search resets, and two
async persistWipe calls a retry could race against freshly persisted rows.

The inner catch now rethrows an auth error WITHOUT reacting; the outer catch
owns the reaction. The auth check still comes before the staleness bail there,
which is round 2's fix and independent — bailing first would swallow the error
and the outer catch would never see it.

Pinned via resetGenerationFor, which counts drops: one revocation, one drop from
the global handler and one from our single reaction. The mutant restoring the
double reaction dies.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): fence the public reconcile against a mid-flight reset (Codex round 4 P1)

Without it, a reset() landing while /items-changes is in flight — sign-out, user
switch, 403 purge — left the loop reading the DETACHED state's cursor and handing
the response to applyDelta, which calls ensureState(ws) and writes it into the
REPLACEMENT state. One user's rows in another's cache, persisted.

The reconcile token does not cover this: it answers 'did a resync overtake my
request' and its answer is to re-poll, not to abort, so a token bump on the drop
makes the loop re-poll against a state that is no longer the workspace's.

Older than this unit — the collection route's deltaSync never had a generation
check — but it stops being obscure once the layout drives this for every route.

Generation alone, not generation-plus-identity. A first draft carried both; the
mutation run showed each half surviving removal alone and only the pair dying,
which is a redundant guard rather than defence in depth. reset() bumps
prior.generation BEFORE deleting the entry, so the identity check is implied.
The comment names that dependency so a future change to reset has a reason to
look here.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): bind a sync result to the workspace that produced it (Codex round 5 P1)

Both layout callbacks await, and wsSlug is derived from the route. Switching
from workspace A to B while A's sync was in flight would reconcile B on A's
result, and advance syncService's shared cursor on the strength of a reconcile
about a different cache.

Both now capture the slug on entry and use the captured value throughout. The
markSynced guard additionally re-reads and compares, because that cursor is
shared and workspace-agnostic: it must not advance for a workspace the user has
since left.

The layout source pin failed on the rename, within an hour of being written,
which is the tripwire behaving correctly — the failure is the prompt to look.
Its header now says so, and says why an anchor loose enough to survive a rename
would also survive the call being deleted.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): use the captured workspace for the whole SSE callback, not just the reconcile (Codex round 6 P1)

The later loadCollections / api.items.get / link-building calls read the
reactive slug after their own awaits and predate this unit — but the reconcile
added ANOTHER await in front of all of them, so an event for workspace A
crossing a switch to B now has a wider window to load B's collections off A's
event. Every use in that callback means 'the workspace this event arrived for',
which is what the subscription was opened on.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): the index is per-workspace, the UI below it is not (Codex round 7 P1)

localIndex is keyed by workspace, so reconciling the captured eventWs is right
whatever route the user is on now. collectionStore and the toasts are GLOBAL —
they describe the ONE workspace being looked at — so applying A's event to them
while B is on screen corrupts B's UI with A's data, and capturing the slug does
not help, because the slug was never the problem for those.

So: reconcile first, unconditionally, then bail if the route moved. The two
halves want opposite things and the await between them is what makes the
distinction visible at all.

Swept the class rather than waiting for round 8 to find the next instance
(CONVE-18). Rounds 5, 6 and 7 are one family: an async callback reading reactive
route state across an await. The population is four callbacks —

  layout onSync         — was unguarded, fixed in round 5
  layout onItemEvent    — was unguarded, fixed in rounds 6 and 7
  page onSync           — already safe: reads wsSlug/collSlug as ARGUMENTS
                          before its awaits
  page onItemEvent      — already guarded by collGen/loadSeq/itemGen from prior
                          races in that file

— so the two the layout owns were the whole gap, and both are closed.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): a sync result carries the workspace it was synced FOR (Codex round 8 P1)

Round 5 made the layout capture wsSlug at callback entry. Not enough: that names
the workspace the user is on when the result is DELIVERED, and a sync issued for
A and delivered after a switch to B reads as B's from both ends — the service's
own wsSlug has moved on setWorkspace, and the subscriber's is derived from the
route. Only the service knows, so the service now stamps it at ISSUE time.

SyncResult = SyncOutcome & { workspace }: the internal helpers keep producing an
unstamped outcome, and only notify() produces a result, so there is one place
the stamp can come from.

The layout consumes result.workspace and bails when it is not the workspace on
screen. markSynced is then guarded by construction rather than by a second
comparison.

Mutation: stamping at delivery instead of issue dies in triggerSync. The
onTabResume twin SURVIVES — the visibilitychange path has no harness and
building one for a three-line duplicate was not worth the fixture. Declared in
the code at that site rather than left implied.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): every onSync subscriber filters by the result's workspace (Codex round 9 P1)

The field's own doc comment says every subscriber must compare it, and four of
the five did not — the dashboard, ChildItems, ItemDetail and the collection
page all acted on a result using their current wsSlug, so a sync issued for A
could drive their refresh work after navigation to B. An authored invariant with
four violations is a comment, not an invariant.

Guarded by an ENUMERATION test rather than five per-site pins: it walks the
source tree, finds the subscribers itself, and asserts the comparison in each,
so a sixth subscriber that skips it fails without anyone remembering to add a
case. The first draft of that test hardcoded the file list and asserted its
length — which proves nothing, since a subscriber in a file nobody listed is
exactly what a list cannot see, and that is the same shape as the bug it guards.
It also asserts it found more than zero, because a broken scan would otherwise
pass every leg vacuously.

Removing the guard from one subscriber fails it.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): a deferred sync pass stamps its own workspace (Codex round 10 P1)

The do/while defers a second pass when a sync_required arrives mid-sync. That
pass issues its OWN request, and setWorkspace may have moved wsSlug since the
first — so a single capture outside the loop ran pass two against workspace B
and labelled its result A, which every subscriber then filters on.

Captured per pass. Hoisting it back out of the loop fails the new test.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* test(web): pin that concurrent reconciles cannot regress the cursor (Codex round 11)

The finding is REFUTED on mechanism: applyDelta reads state.cursor, compares and
assigns with no await between them, so a non-advancing batch is dropped whole by
guard 1 and there is no window for a second loop to interleave. But the
concurrency is real — the layout drives a reconcile per SSE event and the page
can still call one — and IDEA-2901 asked for it to be pinned rather than
assumed, which nobody had done.

The first version of this test asserted the FINAL cursor and a mutant deleting
guard 1 survived it: the older loop regresses to 15, immediately re-polls and
re-advances to 20, so the end state is identical and guard 2 skips the stale row
either way. Nothing observable at the end distinguishes the two builds.

What does: the cursor each request is ISSUED from. The test records it per call
and asserts the sequence never goes below its own high-water mark. Deleting
guard 1 now fails it.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 12:11:01 -04:00
xarmian 110045578c fix(build): make install proves what it installed and what it restarted (BUG-2897, TASK-2787) (#1272)
`make install` made three claims it did not check.

1. It brought the server back BY SIDE EFFECT -- `pad auth whoami` triggers
   an auto-start, which does not know the killed process's argv. A server
   running `--host 0.0.0.0` came back bound to the default host alone:
   curl 127.0.0.1:7777 -> 000 while the LAN address -> 200, with the
   process count and the version both reading correct (BUG-2897).

2. `cp` copied whatever was at the repo path, not what the invocation
   built. Two sessions sharing the checkout interleave and the loser's
   build is installed by the winner, every exit code green (TASK-2787).

3. "Server restarted." was printed after a command ending in `|| true`,
   with no probe of any kind. Not "the wrong address went unverified" --
   nothing was verified. Found reading the recipe; neither filing names it,
   and it is what made the other two invisible.

The logic moves to scripts/install-refresh.sh for one reason above
readability: a script can be TESTED. internal/buildtools drives it against
a compiled stub `pad`, including the branches where a check must FAIL.
Recipe-inline logic is only exercisable by running `make install`, which
stops the developer's server -- a test nobody runs twice, which is how this
target accumulated three unverified claims.

The script checks OUTCOMES rather than steps: what got installed, and what
is answering afterwards. Two commit checks, deliberately, answering
different questions -- the ARTIFACT before the kill, so a wrong build costs
an error instead of an outage, and the DESTINATION after the copy, which is
the shared-path race TASK-2787 names. The restart uses the argv read from
/proc before the kill, and nothing is printed about a restart until the
server answers on BOTH 127.0.0.1 and the configured host (`--host 0.0.0.0`
resolving to loopback plus the primary LAN address, since 0.0.0.0 is a bind
spec, not something to curl).

Three defects were found in the fix itself, by its own tests and by the
first real run:

- The restart redirected to $HOME/.pad/server.log with nothing creating
  that directory. On a fresh HOME the redirect fails, the server never
  starts, and the probe reports "did not answer" -- true, and three steps
  downstream. Invisible to every hand-run this script could have had,
  because a developer's box has ~/.pad by luck of history.
- The post-copy check SURVIVED its mutant: both fixtures reported the same
  version from source and destination, so the guard and its absence were
  indistinguishable. Fixed by making the stub's version depend on its path.
- Comparing commits by equality rejected a healthy build. `git rev-parse
  --short` returns the shortest UNAMBIGUOUS prefix, so its width grows with
  the object database: the binary embedded `a3a1d58` and the Makefile
  produced `a3a1d586` minutes later. Now a prefix comparison in either
  direction, with a negative leg pinning that a genuinely different commit
  of the same width is still refused.

Five mutants, each verified to compile, each detected by its own leg.
Verified end to end on the real box: captured `--host 0.0.0.0`, installed
a3a1d586, restarted with that argv, both addresses answering, /proc/exe
matching the installed binary.

CONVE-2687's manual sibling-safe recipe is unchanged and remains the path
to use when another session's worktree is live; this makes the plain target
honest, it does not replace the convention.

NINE adversarial rounds ran before one returned nothing new. They found
twelve more defects, each verified in the code before being accepted:

- The probe used a fixed port while the restart preserved `--port N`.
- Only `--flag value` was parsed; Cobra accepts `--flag=value` too.
- The artifact check and the copy were separated by a window another
  session could write into, so a swap was caught only AFTER the kill. The
  copy is now STAGED beside the destination, verified there, and moved into
  place with a rename.
- `setsid` is Linux-only and would have failed on macOS after the stop.
- A host or port configured only in ~/.pad/config.toml was ignored, so such
  a user would have seen every install fail.
- A TOML inline comment (`port = 8080 # dev`) was swallowed into the value.
- $INSTALLED could be replaced between the outcome check and the exec.
- Comparing abbreviations by PREFIX accepts two different commits sharing
  seven characters -- the same mechanism that makes abbreviations grow. Ids
  are now resolved with `git rev-parse` and compared in full; an id that
  cannot be resolved is REFUSED rather than prefix-matched.
- The argv capture read /proc only, so on macOS it captured nothing and the
  restart fell back to defaults -- the fix silently absent on the platform
  the setsid fallback had just been added for.
- A wildcard bind whose LAN address could not be determined was reported as
  a success having verified only loopback. It now FAILS CLOSED.
- `localhost` was mapped to 127.0.0.1 unconditionally, failing a healthy
  IPv6 bind.
- `local` was used at top level, so bash printed an error to stderr on every
  normal refresh while the script kept working.
- More than one running server is now REFUSED rather than guessed: the stop
  is a system-wide pkill and the restart can only restore one argv.

Twenty mutants, each verified to compile, each detected by its own leg.
SEVEN survived first -- the destination check, the `--flag=value` parse, the
setsid guard, the /proc fallback, the wildcard loopback probe, the
prefix-collision refusal, and the multi-server refusal. Every one survived
for the same reason: the fixtures were drawn from my own model of the input
(equal-width commits, one flag spelling, one bind shape, a machine with
/proc), so they tested the model rather than the input. Two of them are
unreachable on this platform by construction and are reached through
documented PAD_NO_SETSID / PAD_NO_PROC knobs, because an untestable branch
is how the macOS half would have shipped broken twice.

One residual is named rather than closed: a concurrent `make install` can
still swap the binary between the final verification and the exec. The
complete answer is an exclusive lock across the whole sequence; flock is
Linux-only and a lock that silently does nothing on macOS is worse than a
named gap. Filed as IDEA-2925.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-07 11:48:58 -04:00
xarmian cab78e75ad fix(web): a snapshot cannot reinstate a row whose eviction the cache already consumed (TASK-2920) (#1273)
* fix(web): a snapshot cannot reinstate a row whose eviction the cache already consumed (TASK-2920)

`applyDelta`'s `moved_out` branch is a HARD evict — `state.items.delete(id)` —
which leaves no row for the ordinary `existing.seq` guards to compare against.
Every door that merges rows FETCHED OR READ BEFORE that eviction was consumed
therefore saw an absent id and put the row back.

The cold `/items-index` merge is the one that cannot heal. `resyncProjectionScope`
reinstates the row too, but it PINS the cursor to the snapshot's, so the caller's
next `/items-changes` re-delivers the eviction. `bootstrap`'s cold branch keeps
the HIGHER cursor and then sets `pendingResync = false` with no loop, so the
cursor stands at or above the eviction's seq and nothing will ever replay it —
and the same branch persists the merged rows, so the reinstated row survives a
reload. The window is real: the collection route's SSE handler is registered in
`onMount` with no gate on `bootstrapState`, so it drives a full `deltaSync` while
the cold request is outstanding.

Adds a per-id eviction floor (`movedOutFloor`: id -> seq of the consumed
`moved_out`) and one predicate, `refusedByMovedOut`, called at all four write
doors: `mergeRow` (the cold snapshot and the warm IDB hydrate), the resync
snapshot merge, and `upsert`. It is a SEQ FLOOR, not a blocklist — a genuine
re-add carries a higher seq and is admitted, so nothing has to expire.

The values compared are seq against seq, which is orderable (strictly monotonic
per workspace) — unlike `access_epoch`, whose unorderability defeated the fences
on IDEA-2898's abandoned branch. PLAN-2903's working rule asks for that to be
stated rather than assumed.

Not persisted: the eviction reaches IDB atomically with the cursor advance in the
same `persistDelta` call, so a reload finds the row already gone and the cursor
already past it. The map guards a within-session race only.

Tests: 9 across two files, 6 of which fail on the unfixed tree. Mutation matrix
10 mutants, 7 killed; the 3 survivors were predicted before the run and are
declared on the trail (two are the housekeeping floor-lift, which is provably
unobservable; one is a defensive max() unreachable through `applyDelta`'s own
cursor guards).

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* fix(web): bound the eviction-floor map per Codex review (round 1)

The lift on the authoritative re-add paths prunes only ids that came BACK,
which never happens for an item that moved out permanently — so it prunes
exactly the harmless entries and none of the accumulating ones, and the comment
claiming correctness did not depend on pruning was describing pruning that does
not exist for the growing case.

`noteMovedOut` now caps the map at MOVED_OUT_FLOOR_CAP (5000 =
DefaultItemChangesLimit, the server's per-page cap on /items-changes, which is
the one that applies since both live callers pass no limit), evicting
oldest-first. A raised floor keeps its insertion position rather than taking a
fresh lease: a re-raise is the same eviction learned about twice.

Three tests added for the bound, the eviction direction, and the re-add lift
making room — the last one turns the lift from unobservable hygiene into pinned
behaviour. Mutation matrix rerun: 13 mutants, 11 killed, 2 predicted survivors.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8

* docs(web): state what the eviction-floor cap does not close (Codex round 2)

The reconcile loop pages, so more than one page of evictions can be consumed
inside a single in-flight snapshot; past the cap the oldest floors are gone
before it merges and those ids can be reinstated as before. Every id under the
cap is still protected, so the cap strictly reduces exposure and never widens
it — but the comment now says bound rather than guarantee.

Closing it needs the cold path to pin its cursor to the snapshot's the way
resyncProjectionScope does, which is a change to that path's cursor contract and
interacts with TASK-2906's durable monotonicity gate. Filed on TASK-2920's trail
rather than taken here.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 10:12:11 -04:00
Matt Faltyn a3a1d5862b fix(store): preserve valid item references across workspace import (#1271) 2026-09-07 08:27:58 -04:00
xarmian e864a43644 fix(web): the reconcile token outlives the state it counts, so a verdict cannot cross a reset (IDEA-2913) (#1269)
A reconcile loop captures a token when it issues `/items-changes` and hands it
back when it reports catch-up. That token lived on `WorkspaceState`, and
`reset()` — sign-out, a 403 membership purge, a workspace deletion — deletes
that object. The replacement starts counting from zero, so a response computed
for the PREVIOUS identity can find a matching value on the replacement and be
treated as a clean catch-up for a workspace it never saw.

A classic ABA: the counter returns to a value the caller had seen, by way of a
different object. No per-state value can close it, because "unchanged" and
"reset back to zero" are the same number — which is exactly why
`resetGenerations` already lives outside the `workspaces` map (TASK-2877).

So the token moves out of the state and into a module-level map beside it, and
bumps on the two events that can overtake a verdict: a resync SETTLING, and the
workspace's rows being DROPPED. The drop bump goes in `markWorkspaceDropped`
rather than in `reset()`, inheriting that helper's guarantee of being the single
funnel every drop path calls — a guarantee
`localIndexResetGeneration.svelte.test.ts` already enforces.

ONE counter, not a second capture. The obvious alternative was to have callers
capture `resetGenerationFor` as well and compare both. Two values to compare is
two values to keep in step, and the door that forgets the second one is the next
lapse — this plan has produced four of those. Both values answer a single
question, "is the state I measured still the state I am reporting to", so it gets
a single value.

Checked against the failure this unit's predecessor produced: widening a signal
silently changes every answer it was already giving. Here the readers were
enumerated by grep first, and they are exactly the reconcile path —
`clearAskIfSettled` and the two loops. Nothing else reads it, which is what makes
the move safe and was not true of `scopeEpoch`.

Three tests. One pins the move, one pins the drop bump, one pins that a fresh
workspace does not restart the counter. Mutation matrix, four mutants, three
killed — the drop bump removed, the comparison removed, the settle bump removed
— with a non-compiling negative control scoring BUILD-FAIL.

The surviving mutant is bootstrap's loop re-check reading a fresh value instead
of its captured one, the same shape TASK-2909 recorded and for the same reason:
the loop `continue`s whenever a resync fires, so the two readings cannot differ
without an artificial yield.

One test needed its CLAIM corrected rather than its code: the headline ABA test
discriminates the move but not the drop bump, because with the counter outside
the state two resyncs already separate the numbers. A mutation run said so. Its
name would have implied it covered both, so the comment now says which half it
carries and points at the sibling that carries the other.

Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
2026-09-07 00:13:41 -04:00
xarmian bb8ec04ef1 fix(server,store): both workspace mint doors enforce their preconditions from one place (BUG-2809) (#1268)
handleCreateWorkspace and handleImportWorkspace mint the same thing
through the same store.CreateWorkspace, and enforced preconditions in two
places. Two had already diverged and been fixed one at a time, each found
by a reviewer rather than by the door that lacked it: the OAuth consent
grant (IDEA-2756) and the user-scoped plan limit (BUG-2793). A third was
live.

The shared place is internal/server/workspace_mint.go, split by WHEN a
precondition can run, and the split is load-bearing rather than tidy:

  beginWorkspaceMint  — everything that does not need the body (consent,
    plan limit, and the owner/source attributions). Runs before the body
    read, so a refused caller never uploads a bundle and a refusal cannot
    be probed by body shape; on the import route it sits above the
    Content-Type dispatch, so one line covers both body shapes.

  validateWorkspaceMintPayload — the payload-shaped rules. Returns an
    error rather than writing one, because the JSON doors answer 400
    bad_request and the bundle door answers 400 bad_bundle through
    importStatusError. The rule is shared; the envelope stays each door's.

Callers: handleCreateWorkspace, handleImportWorkspace, and importBundle.
The mint context reaches the bundle path as an ARGUMENT rather than on the
Server, because it is per-request state and the two things it carries are
exactly what two concurrent requests would differ on.

THE LIVE DEFECT. Import accepted an empty workspace name. Measured before
the fix: it created a workspace with name="" and slug="", and a second
such import landed on slug "-2" -- the first had taken the empty slug,
globally, and a slug is a routing key. Both import doors now refuse it,
checking the EFFECTIVE name (the ?name= override when given, the bundle's
own otherwise) because that is what becomes the slug. A control leg covers
the override, or the rule would be indistinguishable from "reject any
bundle whose payload name is empty" and would break rename-on-import.

SETTINGS: the item's premise was wrong and this corrects it rather than
fixing it. Malformed settings never reached the store unnormalized --
createWorkspaceQ calls NormalizeWorkspaceSettings itself and refuses. What
diverged was the STATUS: create answers 400, import answered 500
import_failed because handleImportWorkspace maps every store error that
way. Validating in the shared payload step makes both 400. Context stays
create-only: an export carries none, so applying it on import would be
inventing input.

SOURCE: imported workspaces got no attribution at all (BUG-1557).
store.ImportWorkspace now takes a source parameter, derived by the caller
from the request's auth shape exactly as create derives it -- a parameter
rather than an export field, because a bundle says what the workspace WAS
and where this copy is minted from is a fact about this request. The
operator path (pad db migrate-to-pg) passes "": it is a copy, not a
creation surface, and inventing "cli" would relabel every migrated
workspace's origin.

Userless callers (the inventory's fourth item) are deliberately unchanged.
beginWorkspaceMint preserves the userID != "" guard exactly as both doors
had it rather than changing behaviour under cover of a refactor; the
measurement and the ruling are on BUG-2914.

Five mutants, each verified to COMPILE first and each detected by its own
leg: either import door skipping the payload check, the create door
skipping it, checking the payload name instead of the effective name, and
passing "" for source. Two of them initially did not compile, and go test
answers a build failure with FAIL <pkg> [build failed], which in a
filtered run reads exactly like detection -- a false DETECTED, the mirror
of the false SURVIVED. Re-run with the orphaned variable kept alive.

Claude-Session: https://claude.ai/code/session_01HeChkgZVYb3NTgTcckF5KR
2026-09-06 23:36:58 -04:00