mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
dc70ff3d7ff7c3d3ba696ebaa92abbbbedc1b4e8
1792 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 ( |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
249a8f879f |
chore(nix): heal vendorHash for the module set in 31d11e76ea (TASK-2954)
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
43d94a35d5 | fix(cli): pass PostgreSQL connection strings to backup clients (#1289) | ||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
a3a1d5862b | fix(store): preserve valid item references across workspace import (#1271) | ||
|
|
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 |
||
|
|
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
|