Commit Graph

1669 Commits

Author SHA1 Message Date
xarmian e64a1eb76b Merge pull request #1242 from PerpetualSoftware/feat/task-2868-relation-field
feat(web): relation fields — linked chip + picker (TASK-2868)
2026-09-03 14:32:49 -04:00
xarmian 6f7c09a44a fix(web): judge a relation's collection only when the list and index agree (TASK-2868)
Codex round 2, P1, real — and it is the retag window my round-1 fix left open.

`retagCollection` moves the indexed ROWS onto the new slug immediately;
`collectionStore.loadCollections(ws)` is fired next to it with `void` — not
awaited, and its rejection swallowed. So between those two there is a state
where the collection list still holds the OLD slug (making the declared target
read as 'live') while the row already carries the NEW one. Judging the mismatch
there reported the value as "Unresolved reference" — and because that refetch is
unawaited and its failure unobserved, the state is PERMANENT when it fails, not
a paint-frame flicker.

The fix reframes what the mismatch is evidence OF. A collection mismatch means
the value is wrong only when the collection list and the item index agree about
the world — that is, when the current list knows BOTH the declared target and
the row's own collection. Two ways they disagree, and neither is the value's
fault: the target was renamed away (round 1), or the rename reached the index
before the list (this round). Requiring both slugs to be known collapses both to
"don't judge", while a genuine cross-collection value — target `colors`, row
`tasks`, both live — still resolves to null.

That is also why this is not fixed by invalidating freshness: `collectionsAreFreshFor`
answers "loaded for this workspace", not "current", and teaching it about
pending/failed refreshes is a store-wide change to serve one consumer. The
agreement test needs nothing new.

New control leg alongside it, because two "don't judge" guards in a row are one
edit away from never judging: both slugs live, mismatch, still rejected.

Mutation matrix 17 of 17 killed (N17 new — judge as soon as the target reads
live, i.e. round 1's shape; N11/N14/N15 anchors refreshed).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2063 tests green. Context 55.8% at this boundary
(`session-shape`, which lives at /home/dave/claude/bin and is not on PATH — my
earlier "not measured" reports read `command -v` failing as the tool not
existing).
2026-09-03 17:46:44 +00:00
xarmian 09f80b3d4f fix(web): a renamed target collection must not read as lost data (TASK-2868)
Codex round 1 on this unit, P1, correct — and it is a defect the PREVIOUS
commit introduced.

`models.FieldDef.Collection` holds the target's SLUG (the schema editor binds
`<option value={c.slug}>`), and `store.UpdateCollection` re-slugifies on rename
without migrating the relation definitions that point at it — the string
"relation" does not appear in that file at all. Meanwhile `localIndex.applyRetag`
correctly moves the indexed ROWS onto the new slug. So after a rename the field
and the rows disagree, and the collection check added last commit reported every
stored value as "Unresolved reference": a schema problem presenting to the user
as lost data, on data that is completely fine.

Now three-valued. The collection check applies only while the declared target
still names a LIVE collection; a stale target falls back to id-only resolution
so the chip keeps rendering, and the field goes read-only because a picker aimed
at a renamed collection would list nothing (`getByCollection` and
`localSearch` both filter on that slug). `'unknown'` — the collection list not
yet loaded — is deliberately NOT read as stale: that is absence of evidence, and
treating it as stale would flash every relation field into read-only on first
paint.

Filed **BUG-2873** for the root cause, with both candidate fixes (migrate
dependent schemas on rename, or store the collection ID) and the argument that
the second is what PLAN-2857's own "store the ID, titles change" reasoning
implies for the collection pointer too.

**Two of the three new tests were wrong first, in ways that let a mutant live.**

- The stale-target test set up the STORE's collection list but left the row's
  `collection_slug` matching `field.collection` — so field and row agreed, and
  the mutant making the check unconditional passed. A rename retags the rows;
  modelling only half of it reconstructs a scenario that cannot fail.
- The unknown-vs-stale test asserted the chip renders. A stale target also
  renders the chip, so it could not tell the two apart. It asserts EDITABILITY
  now, which is the only thing that actually differs.

Mutation matrix 16 of 16 killed, including the four new ones (N14 stale target
invalidates values, N15 unknown reads as stale, N16 stale target stays editable,
plus N11 refreshed for the new shape).

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2061 tests green; `go build ./...` ok, gofmt clean.
2026-09-03 17:33:24 +00:00
xarmian 5079a532c9 fix(web): resolve a relation by id in its own collection; collapse the picker (TASK-2868)
Three defects, all found by driving the Cars/Colors example from IDEA-2856 in a
real browser against a locally built binary. None of them showed up in the
twelve component tests, and two are mine.

**1. A legacy free-text value rendered as a working reference.**
`localIndex.findByIdOrSlug` resolves by id OR SLUG, so the string `"red"` —
exactly what the old text fallback has been writing into these fields — resolved
to the item slugged `red` and rendered as a live chip. The field's contract is
that it stores an item ID; a slug match makes the chip lie about what is stored,
and slugs are mutable, so the same value could point elsewhere tomorrow. Now
resolves by id only, and the browser leg that was meant to prove "a legacy value
reads as unresolved" is the one that caught it.

**2. It could resolve into the WRONG COLLECTION.** That helper is
workspace-wide, so a relation declared against `colors` would render an item
from `tasks` sharing the identifier. This is the same defect PLAN-2857's recon
recorded against the server's `ResolveItem` — I wrote that finding down in the
design doc and then reproduced it in my own client code a few hours later.

**3. The field showed a permanently-open search box.** The first browser pass
rendered the chip, the picker input still holding the query, and the result list
still listing the row just chosen — the same item three times, under every
relation field on the page. A field shows its VALUE; the picker is for changing
it. Now: chip + Change / Clear, picker on demand, closing when a choice is made.

Also **filed BUG-2872** rather than absorbing it: the activity timeline on the
same page renders a relation change as `color: → <uuid>`. The panel now honours
IDEA-2856's "never a bare UUID"; that surface does not. The e2e invariant is
scoped to the field row on purpose, so the gap is recorded rather than hidden by
loosening the assertion to the page body.

Mutation matrix 13 of 13 killed. N13 (emit the value but leave the picker open)
survived the first pass — the test asserted the emit and not the CLOSE, which is
the half the browser pass had rejected. Two mutants that had to die separately
do: N1 (drop the deleted branch) kills leg (b), N2 (unresolved reads as deleted)
kills leg (c), so the two states are genuinely distinguished rather than sharing
a branch.

Gates: `npm run check` 1093 files 0 errors, 6 pre-existing warnings; full web
suite 123 files / 2058 tests green.
2026-09-03 17:18:36 +00:00
xarmian a04233aaa9 feat(web): relation fields render a linked chip and edit through the picker (TASK-2868)
PLAN-2857 U2. Absorbs the relation half of TASK-2216.

Before this, `relation` fell through `fields/FieldEditor.svelte`'s `{:else}`
text fallback: an editable free-text input in edit mode, and `{value ?? '—'}`
in display mode. Since `internal/items/validate.go:275` accepts ANY string for
a relation, that combination did not merely fail to edit — it SAVED. Typing
"red" into a relation field stored the literal string and showed no error, and
the display arm rendered a raw UUID when the value happened to be one. So U2 is
closing a silent corruption hole, not adding an editor to a read-only field.

**Three render states, not two.** A value that resolves to nothing and a value
whose target was deleted are different facts about the item, and the third is
the COMMON case on existing data — arbitrary strings are what the old fallback
has been writing. All three resolve locally: `localIndex` holds soft-deleted
rows alongside live ones (`getByCollection` filters them out rather than
dropping them), so a dangling target is a row carrying `deleted_at`. No fetch,
no loading state. The invariant across every state, asserted in every leg: a
raw item ID never reaches the user.

**The branch is gated on `wsSlug` AND `field.collection`, and the second call
site sits on the far side of that gate deliberately.** `CopyItemDialog` builds
its `FieldDef` from a preflight row whose shape carries no `collection`
(`ItemCopyPreflightNeedsValue`), and it copies ACROSS workspaces — so an
unscoped picker there would offer SOURCE-workspace items as the value for a
DESTINATION-workspace field, and look authoritative doing it. A free-text box
at least looks like something the user owns. Read-only is the honest state
until TASK-2869 (U2b) extends the preflight contract; U1 makes the garbage
write a 400 in the meantime.

That gate is a fact about the CALLERS, invisible from the component's own
render tests, so both sides are asserted at the call sites
(`fieldEditorRelationCallers.test.ts`) — including that `toFieldDef` still
builds from a shape with no `collection`, which fails loudly when U2b lands
rather than letting the gate drift.

Pin first, per team CONVE-29: the test file was written and run BEFORE the
branch existed — 4 failed / 2 passed, the two passers being the gate legs,
which pass vacuously while no picker exists anywhere. That is why they ship
with a control leg that mounts one.

One pin leg was STRENGTHENED rather than relaxed when it failed against the new
code: leg (a) asserted "some anchor exists" and failed because the test passed
no `username`, which is what builds the href. The fix was to give it one and
assert the exact href, plus a new leg (a2) for the resolved-but-no-route case —
where the chip degrades to a non-link and must still name the item rather than
degrade to the raw value, which is precisely what the old arm did.

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 122 files / 2048 tests green.
2026-09-03 17:07:36 +00:00
xarmian 7fde7a3cbc Merge pull request #1241 from PerpetualSoftware/feat/task-2862-relation-picker
feat(web): shared ItemPicker — extract the add-relationship search (TASK-2862)
2026-09-03 13:00:58 -04:00
xarmian cd5c5702d4 feat(web): give ItemPicker a source model; keep the Relationships tab on server FTS (TASK-2862)
Lead ruling on PR #1241, and the right call. The extraction had silently moved
the add-relationship search onto the warm local path, and `localIndex` strips
`content` by design — so a user who links an item by a phrase they remember
from its BODY lost that, with no signal anything had changed. Consistency with
the other pickers does not buy back a capability under CONVE-139.

`source` is now an explicit MODEL choice, not a performance one:

  'index' (default) — `localSearch` over title / ref / tags / parent / field
  values, no network call, server only as a cold fallback. Right for a RELATION
  field, where you are choosing a row from a known collection and know what it
  is called. U2 onward take this.

  'server' — always `/search`, whose FTS also indexes body content. Right for
  the Relationships tab, where you are finding an item you remember rather than
  one you can name, and what it did before this component existed. ItemDetail
  passes it.

An empty-query LISTING stays on the index for both: it is not a search,
`/search` cannot answer one (it requires a `q`), and the rows are local either
way. Only QUERIES follow `source`.

Two supporting changes fall out of it rather than being bolted on:

**`rawResults` + a derived `results`.** The exclusion filter is now part of the
derivation, so a late `excludeIds` — `ItemDetail` loads `itemLinks`
asynchronously — re-filters on its own. Without that, honouring a late
exclusion on the server-backed caller would have meant re-issuing the request,
which is the rate-limiter pressure the debounce exists to avoid. The refresh
effect no longer needs `excludeIds` as a dependency at all, and server-sourced
QUERIES are explicitly not re-run on an index delta.

**The highlight is an ID, not an index.** `activeId` is state; `activeIndex`
derives from it. Identity survives the list changing underneath — a delta, a
late exclusion — where an index silently moves the highlight onto whatever slid
into that position. This deletes the hand-rolled preserve/restore that lived in
the effect, so no future site that changes the list has to remember to do it.

Pins, per the ruling: the server caller queries `/search` with a hydrated index
and never touches `localSearch`; the control leg asserts the default source on
the same warm index never reaches the network; and a source-level test asserts
ItemDetail's call site still carries `source="server"` — a regression invisible
from the component's own tests, which is why it is asserted at the call site.

Verified in a real browser against a locally built binary with a marker string
present ONLY in an item's body and never in its title, so the local index
cannot answer it: the Relationships picker finds it, arrows to it, and creates
the link.

Mutation matrix 20 of 20 killed, including the three new ones — ignore `source`
(3 failed), re-query on a delta (1 failed), drop `source="server"` at the call
site (1 failed).

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2041 tests green.
2026-09-03 16:16:11 +00:00
xarmian 4b0a818178 fix(web): drop stale rows on an index reset; re-filter on a late exclusion set (TASK-2862)
Codex round 4, both findings real.

**P1 — an index reset left rows on screen.** `localIndex.reset()` (sign-out, a
403 membership purge, a deleted workspace) drops the workspace state and resets
the search index, which bumps the epoch — so the refresh effect DID run, saw a
non-`ready` state, and returned. Rows the viewer may no longer be allowed to
see stayed listed and selectable, and a cold response already in flight could
still add more. The effect now tears down on that path: invalidate `seq`,
cancel the debounce, clear the results and the highlight.

I wrote that teardown behind a "only if we are showing or awaiting something"
guard first. The mutant removing the guard could not be killed, and working out
why showed the guard is dead — the one non-ready run that reaches it with
nothing to clear is the cold mount, where it changes nothing either way. So the
guard went, rather than acquiring a comment claiming it protects something. The
control leg stays: an ordinary cold mount must still complete its own search.

**P2 — `excludeIds` arriving late did not re-filter.** `ItemDetail` loads
`itemLinks` asynchronously, so a picker opened before that resolves was
offering items already linked to the source; clicking one is a duplicate-link
write the user did not know they were making. The effect now tracks the
exclusion set too.

**The first test for it was a false green, and that is the more useful half.**
Driving the prop change through testing-library's `rerender` REPLACES the whole
props object, which re-runs the refresh effect whether or not it tracks
`excludeIds` — so M20 (delete that dependency) survived while the production
path, where a real parent changes one prop, was broken. Adding "the typed query
survived, so this was not a remount" did not help: the instrument was wrong in
a different way than suspected. It now runs through `ItemPickerProbe.svelte`, a
test-only host in the shape of `FreezeProbe` / `GuardProbe`, which changes
exactly one prop. M20 dies against it.

Mutation matrix rebuilt and re-run, 18 of 18 killed. The harness itself was
rewritten this round after a regex edit corrupted its escapes and reported
seventeen false ANCHOR MISSes — anchors are now generated with explicit tabs
into a JSON file rather than hand-escaped in source.

Verified again in a real browser against a locally built binary, including the
round-4 P2 path end to end: link a target, re-open the picker, and confirm the
now-linked item is no longer offered.

Gates: `npm run check` 1092 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2035 tests green.
2026-09-03 16:01:10 +00:00
xarmian 03ec276cac fix(web): refresh on the search epoch, not the workspace cursor (TASK-2862)
Codex round 3, one P2, correct — and it catches that round 2's fix tracked the
wrong signal.

`localIndex.upsert()` and `remove()` — optimistic creates and edits, the 403
purge — mutate `state.items` and mirror to `localSearch` WITHOUT advancing
`state.cursor`. So a picker wired to the cursor stayed stale after exactly the
mutations a user is most likely to cause while it is open.

`localSearch.epoch(ws)` is the right dependency and strictly dominates the
cursor: it is bumped by every write to the search index, and every `localIndex`
path that touches `state.items` mirrors there — verified one by one this round:
`applyDelta` (three sites), `upsert`, `remove`, `removeByCollection`,
`applyRetag` (behind `retagCollection`), `reset`. It also exists for precisely
this consumer shape; its own doc comment describes an `$effect` re-deriving
search results, added for the identical staleness bug in TASK-1364 round 3. I
should have found that before reaching for the cursor.

The distinction is pinned by a test that bumps the epoch and asserts the cursor
never moved, and by mutant M16 (track the cursor instead — 3 failed). The
double deliberately KEEPS `cursorFor` rather than dropping it, so a build wired
to the cursor still runs and simply fails to refresh: a mutant that dies of a
TypeError would prove nothing about which signal is correct.

Mutation matrix, 15 of 15 killed (M13 rewritten, M16 new).

Gates: `npm run check` 1091 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2031 tests green.
2026-09-03 15:43:49 +00:00
xarmian 3de173a2d4 fix(web): keep an open picker current with the local index (TASK-2862)
Codex round 2. One finding accepted and fixed, one declined with the
measurement — see below.

**Accepted: an open picker held a stale COPY of the rows.** `results` is a
snapshot, so an SSE delta landing while the picker was on screen left it
listing what the workspace used to contain until the query changed or it
remounted. This is the same missing dependency as round 1's cold-mount finding,
so the two collapse into one effect: it now tracks the bootstrap state AND the
workspace cursor (`$state` on `WorkspaceState`, bumped by every applied delta
batch), and re-lists on either.

Re-listing on every delta made the round-1 empty-query guard the wrong shape —
it would have frozen a typed query's results for as long as the user kept
typing nothing. Replaced with something better: the re-list now PRESERVES the
highlighted row **by id**, recomputing its index against the new results. A
blind re-list takes back a row the user arrowed down to; keeping the old INDEX
is worse still, silently moving the highlight onto whatever slid into that
position. If the row is gone, the highlight clears. Both wrong variants are
pinned as mutants (M14, M15).

**Declined: "empty-picker Escape does not reach pane handling."** Re-raised
from round 1 as a behaviour rather than a prose issue. Measured before
declining:

  - Pre-change, the add-relationship box was a bare `<input type="text">` with
    no keydown handler at all, so Escape there did nothing. Same today when a
    caller omits `oncancel`. Not a regression.
  - Both pane hosts bail on text-entry targets BEFORE running the escape stack
    (`[collection]/+page.svelte:2321`, `[collection]/[slug]/+page.svelte:465`),
    with the rule stated in their own comments: "Text-editing targets own ESC
    locally". Making the picker hand Escape upward would contradict a
    route-level policy, which is not this unit's to change.
  - The one caller today, ItemDetail, DOES pass `oncancel`, so in the shipped
    configuration Escape clears the query and then closes the form — strictly
    more than it did before.

The prop doc says plainly what omitting `oncancel` costs, so U2 cannot acquire
the gap by accident.

Mutation matrix re-run on the changed tree, 13 of 13 killed (M11 rewritten for
the new effect; M13/M14/M15 new — drop the cursor dependency, re-list blind,
preserve the index instead of the identity).

Gates: `npm run check` 1091 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2030 tests green.
2026-09-03 15:33:26 +00:00
xarmian 3c63bfbd9d fix(web): re-list a scoped picker after hydration; correct the Escape prose (TASK-2862)
Codex round 1, both findings P2, both real. The second one is the more useful
of the two because what it caught was a claim I made without reading the code
it was about.

**A scoped picker mounted before the local index hydrated stayed empty.**
`recent()` ran on mount and on input only, and returns [] while the index is
cold — so a relation field opened during hydration (U2's case) showed nothing
until the user typed, then silently started working. A `$effect` now re-lists
when the bootstrap state reaches `ready`. It tracks that read ONLY; `query` is
read inside `untrack`, because reading it reactively would re-run the effect on
every keystroke and race `oninput` — the same reason `onMount` owns the first
run.

The refresh is guarded on an empty query, and that guard is now pinned by its
own test: without it, hydration landing mid-session resets `activeIndex` and
takes back a row the user had already arrowed down to — a keystroke they never
made, at a moment they cannot predict.

**The Escape comment was wrong.** It said that declining to stopPropagation
"correctly lets Escape reach the pane". It does not: both pane hosts'
keydown handlers call `isTextEntryTarget(target)` and RETURN before they run
the escape stack (`[collection]/+page.svelte:2321`,
`[collection]/[slug]/+page.svelte:465`), on the deliberate rule that a text
field owns Escape locally. So a picker with no `oncancel` leaves Escape with no
owner at all — which is also what the inline search this replaces did, so it is
not a regression, but it is not what I wrote either. I had the escapeStack file
open and read the STACK rather than its DRIVER.

Fixed in three places: the `oncancel` prop doc now says plainly what omitting
it does, the handler comment states the real mechanism and marks the
stopPropagation as belt-and-braces rather than load-bearing, and the control
test is renamed off the false claim — it asserts what it actually establishes
(the picker does not consume a key it has nothing to do with, including
`defaultPrevented === false`).

Test-harness note: `bootstrapStateFor` is now backed by a `SvelteMap` in the
suite instead of a `vi.fn` return value. A bare mock return is not reactive, so
a test driving it that way cannot tell a working refresh from a missing one —
the first version of the hydration test passed against both. `SvelteMap` is a
runtime class, so a plain `.ts` file gets the tracked get / triggering set that
the real store gets from `$state`.

Mutation matrix re-run on the changed tree, 12 of 12 killed — the two new ones
being M11 (drop the hydration re-list, 1 failed) and M12 (drop its empty-query
guard, 1 failed; survived until the selection test above was added).

Gates: `npm run check` 1091 files 0 errors, 6 pre-existing warnings; full web
suite 121 files / 2028 tests green.
2026-09-03 13:16:15 +00:00
xarmian 17ec291908 fix(web): align the type-select to the picker input, not to the whole picker (TASK-2862)
Caught in a real browser, not by a test. `.add-link-controls` is a flex row and
the picker is a COLUMN (input, then its result list), so the row's default
`align-items: stretch` made the "Related / Blocks / …" select as tall as the
input PLUS the open result list. Before the extraction the results were a
sibling of this row, so the question never came up.

`align-items: flex-start` restores the pre-extraction look. Verified against a
locally built binary on both Playwright projects — desktop-chromium and
mobile-chromium (Pixel 7) — driving the real flow: the form opens focused, the
input carries `role=combobox`, typing filters, ArrowDown sets
`aria-activedescendant` to the first option's id, Enter creates the link (read
back from `/links`) and closes the form. At the mobile width the row keeps its
shape and long titles ellipsis rather than overflow.
2026-09-03 13:03:35 +00:00
xarmian 43c33d1aeb test(web): make the picker's fence and consumption guards actually discriminate (TASK-2862)
Two tests from the previous commit passed against builds with the thing they
name deleted. Both are fixed here, and the mutants that exposed them now die.

**The staleness fence.** The first version released a superseded response while
the picker was still `loading`, and asserted the stale row was not in the DOM.
It passed with `if (mySeq !== seq) return;` removed — the loading branch renders
instead of the result list, so the row was in `results` and merely off screen.
The scenario now lets the SECOND request land first, so the picker is settled
and the fence is the only thing between the stale row and the DOM.

**The unmount leg.** "Unmount, resolve, assert the row is absent" cannot fail:
an unmounted component renders nothing whatever the teardown does. Replaced
with the half that IS observable — closing the picker inside the debounce
window means the request is never sent. `onDestroy`'s `seq` bump is kept and
now says in a comment that no test can kill its removal, rather than being
defended by one that would pass either way.

**The consumption guard.** `/<ItemPicker\b/` matches inside an HTML comment, so
commenting the mount out left all five source assertions green — the guard
passed against the exact regression it exists to catch. It now runs against a
comment-stripped copy, which also removes a false `{#key itemSlug}` match from
the explanatory comment above the real directive.

Mutation matrix, all ten killed (each applied to the shipped tree, suite run,
tree restored from a COPY — never `git checkout`):

  M1  drop the row cap ...................... 2 failed
  M2  always take the server path ........... 1 failed
  M3  never take the server path ............ 3 failed
  M4  drop the staleness check .............. 1 failed  (survived before this commit)
  M5  stop consuming Escape ................. 1 failed
  M6  drop aria-activedescendant ............ 1 failed
  M7  ignore the exclusion set .............. 1 failed
  M8  drop the collection scope on /search .. 1 failed
  M10 drop onDestroy's clearTimeout ......... 1 failed
  M9  comment out ItemDetail's mount ........ 3 failed  (survived before this commit)
2026-09-03 12:58:52 +00:00
xarmian a85016972a feat(web): extract the add-relationship search into a shared ItemPicker (TASK-2862)
PLAN-2857 U3. The relation-field editor (U2) and the Relationships tab need
the same "find an item and choose it" control; today it exists once, inline in
ItemDetail, closing over that component's `item` and `itemLinks`. This lifts it
out so U2 mounts it rather than copying it.

WHERE THE CANDIDATES COME FROM. The design pass ruled OUT the dropdown-vs-
search threshold PLAN-2857 asked for. `localIndex` is a workspace-wide in-RAM
read model of every item — `/workspaces/{ws}/items-index` takes no limit
parameter — and `localSearch` is a MiniSearch index built over it, so a target
collection's rows are already in memory, already ranked, and cost no network
call. The threshold was pricing a round-trip that does not happen. One control,
always filter-shaped, correct at three items and at three thousand; the tests
assert the rendered row count is a function of `limit`, not of collection size.

The server `/search` endpoint stays as the COLD path — used while the local
index has not hydrated, which is also the behaviour ItemDetail had before this
change. It is not a mode the user can select. Only that path is debounced: the
debounce exists to keep per-keystroke requests off the rate limiter, and the
warm path issues no requests to limit.

FENCES. The per-query `seq` moves into the picker and additionally fires on
unmount. The item-switch fence is NOT duplicated: ItemDetail already mounts
this region inside `{#key itemSlug}` (PLAN-2105 / TASK-2112), so a switch
destroys the picker and its continuation with it — a source-level test pins
that the picker stays inside an OPEN key block, since "a key exists somewhere
above" would pass with the picker outside all of them.

New behaviour beyond the extraction, both required by the unit:
  - keyboard navigation (arrows / Enter / Escape) with aria-activedescendant,
    which the inline search never had;
  - a scoped picker opens with the target collection listed most-recently-
    updated first, so it is useful before the user types.

Escape consumes only what it actually closes and stops propagation only then —
the page's Escape driver is a bubble-phase window listener feeding
`runTopEscape`, so an empty picker with no `oncancel` correctly lets the key
reach the pane. There is a control leg for exactly that.

`formatItemRef` is widened from `Item` to `Pick<Item, 'item_number' |
'collection_prefix'>`: the picker deals in `ItemIndexRow` (`Omit<Item,
'content' | 'moved_to'>`), which carries both fields but is not an `Item`.
Strictly wider, so every existing caller still type-checks.

The result-list CSS moves into the picker rather than being shared by class
name — Svelte scopes styles per component, and the note already in ItemDetail
records what reusing a class name across that boundary costs. The picker sizes
everything in `em` so a host sets the scale once on its own wrapper.

Verification: `npm run check` 0 errors (the 6 warnings are pre-existing and in
other files); 15 new component tests + 5 source-level consumption tests green.
2026-09-03 12:53:47 +00:00
xarmian 70099c2724 Merge pull request #1239 from PerpetualSoftware/fix/bug-2848-pane-jk-anchor
fix(web): capture the pane-follow target at keypress, not when the timer fires (BUG-2848)
2026-09-02 18:43:14 -04:00
xarmian af24997c72 fix(web): re-resolve the follow target by id at fire time (BUG-2848)
Codex round 1, P2, and a real latent bug in the previous commit. Capturing the
item OBJECT and reusing it 140ms later keeps a stale snapshot: a rename during
the debounce changes the slug, `openItemPane` builds the URL from that slug, and
the id-only existence check passes happily on the way to a dead URL.

What is captured is now the IDENTITY — `targetId` — and the callback re-resolves
the current row from `filteredItems` before opening it. That still follows a row
that MOVED, which is the whole point of the fix, and still skips one that was
DELETED, while picking up any change to the row itself.

Also answering the round's second P2 in the spec rather than in code: the race
is probabilistic and cannot be made deterministic without a seam in the page.
The asymmetry is what makes that acceptable, and it is now written down — a
round that misses the 140ms window still PASSES on a correct build, because the
cursor moves, the pane follows and the intended row is where it should be. So
missing costs power, not correctness; the failure mode is a false green, never a
false red. Three rounds put a false green around 1 in 1700 against a build that
loses the keypress 11 times in 12.

pane-follow-live-list + pane-controller: 44/44 across both projects.
2026-09-02 22:07:46 +00:00
xarmian 1a76531eb3 fix(web): capture the pane-follow target at keypress, not when the timer fires (BUG-2848)
The list is SSE-live and the pane-follow is debounced 140ms. The callback
re-read `filteredItems[focusedIndex]` when the timer fired, which made a
keystroke depend on the list holding still for those 140ms. It does not.

The failure was silent, and that is what made it hard to see. `j` advanced
`focusedIndex`; an item arriving during the debounce shifted every index below
it, sliding the PANED item down onto that very index; the callback read it
back, found "the focused row is already the paned item", and returned through
its own guard. No cursor move, no re-target, no error — a discarded keystroke.

The target is now captured BY IDENTITY at keypress time. The callback still
re-checks pane state and that the row still exists — identity, not position, so
a row that MOVED is followed correctly and only a row that was DELETED is
skipped.

MEASURED, because the first two explanations were both wrong.

The trail's diagnosis was that an insert leaves `focusedIndex` behind so `j`
lands on the already-open row. A snap-back $effect re-syncs the cursor to the
open item on every `filteredItems` change and prevents exactly that; a pin that
waited for the row to settle passed every candidate assertion.

So the second hypothesis was that the snap-back undoes the cursor move during
the debounce, and the fix was to suppress it while a follow is in flight.
Measured: 12 of 12 failures, WORSE than the 11 of 12 baseline. The stale index
lands on the paned item by itself; the snap-back was never the culprit.

Capture-at-keypress, same harness, same sweep size:

    baseline (unfixed)          11/12 lost the keypress
    suppress snap-back          12/12 lost the keypress
    capture target at keypress   0/12

across all three measured properties — the pane re-targeted, the cursor moved,
and the pane landed on the row that was actually below the cursor.

The new spec CAUSES the race rather than waiting for it: it seeds a row above
the cursor, then lands a second insert across the keypress inside the debounce.
Three rounds per run, because one round caught the unfixed build 11 times in 12
and three make a false green not worth reasoning about. Counterfactual against
the unfixed controller: 3 of 4 desktop runs fail with the bug's signature —
`Expected: "DOC-16"` (the intended row) versus `Received: "DOC-15"` (the row
that was already open).

It asserts three things and none is redundant: `retargeted` alone passes if the
pane wanders anywhere; `cursorMoved` alone passes if the cursor moves and the
pane ignores it; `intended` is what pins the actual contract. A fourth that
suggests itself — "the pane agrees with the focused row" — passes VACUOUSLY on
the bug, since cursor and pane are then both stuck on the opened row. It was
measured doing that and is deliberately absent.

pane-controller.spec.ts is unchanged and still green (21/21). Its intermittent
failure was this defect, not the shared-workspace pollution it was filed as —
it just could not cause the race, so it only caught it when a sibling test's
seed happened to land in the window.
2026-09-02 22:02:11 +00:00
xarmian 58f50909af Merge pull request #1234 from PerpetualSoftware/feat/idea-2843-composer-quote-handle
feat(web): comment on a selection, with comments back under the item content (IDEA-2843)
2026-09-02 17:39:27 -04:00
xarmian e92f6f235d fix(web): the sidebar footer's Settings label wrapped once the GitHub link joined the row (BUG-2844)
MEASURED, not eyeballed. The desktop sidebar is 260px wide, less 24px of
.sidebar-inner padding, so the footer row has 235px. Four 32px controls and
four 8px gaps are fixed cost; .settings-btn is the only flex:1 item and got
what was left — a 75px box, 51px of content after its 12px padding. The label
"⚙ Settings" needs 56.3px. Five pixels short, so the gear and the word landed
on separate lines and the row grew from 33.9px to 51.7px.

The five pixels arrived with the GitHub link (IDEA-2711, PR #1229): a 32px
control plus a fifth gap took 40px out of a box that had about 22px of slack.

space-2 -> space-1 on the row returns 16px and the Settings padding another
8px, all of which lands in the one shrinkable item: a 75px content box against
56.3px of text, a 33% margin rather than the 5% either change alone would have
left. Measured after: one line box, row height back to 33.86px.

ONLY THE DESKTOP ARM WAS BROKEN, which the dispatch's "every width the layout
supports" is what surfaced. Below 768px the sidebar is 280px AND the collapse
button is gone, so the row carries four controls in 255px and the label had
135px to itself. It measured one line box before this change and still does —
and both mobile legs of the new spec PASS against the unfixed CSS, which is
what makes the desktop failures mean something.

.github-btn also gains `flex-shrink: 0`, which every other control in the row
already had. Harmless today — its automatic minimum size equals its 32px
content box — but it made the row's one shrinkable item ambiguous, and
.settings-btn is meant to be that item.

THE TEST IS AN E2E SPEC BECAUSE NOTHING ELSE CAN HOLD IT. jsdom performs no
layout, so a vitest render of Sidebar.svelte reports identical geometry with
and without the bug. It asserts LINE BOXES rather than row height: height grows
for other reasons and could stay put through a wrap, while
Range.getClientRects() returns one rect per line, so the count is the question
itself. Counterfactual run against the reverted declarations: desktop fails
"Expected: 1, Received: 2", the shrinkable-control leg fails on the extra item,
mobile stays green.

Gates: web unit tests 115 files / 1978 tests green; svelte-check 0 errors (the
6 warnings are pre-existing, in files this does not touch); go build and go vet
clean. The full Go suite was NOT re-run locally — no Go file changed — and CI
covers it; naming the narrowing rather than reporting a leg I did not run.
2026-09-02 17:37:29 -04:00
xarmian 0900be6241 chore(deps): bump golang.org/x/crypto to v0.56.0 (BUG-2851)
Two advisories published 2026-09-02 19:12Z (GO-2026-6354, GO-2026-6355; DoS in golang.org/x/crypto/ssh, fixed in v0.56.0) made govulncheck fail the Nix job on runners whose vulnerability database had them — intermittently across runners, not as a threshold: main at 704ba874 and a PR tip based on it passed while another failed on an identical dependency tree, four minutes apart. x/crypto/ssh is not linked into pad (go list -deps ./cmd/pad shows no crypto/ssh; go mod why: bcrypt), so this is a CI unblock, not an exposure. The bump beats an accepted-advisories entry: an exception would encode "not linked today" as permanent and would sit beside a check that disagrees with itself.

go.mod one line, go.sum two lines, nix/package.nix vendorHash one line. No nix on the build box, so the hash was lifted from CI's own mismatch on a lib.fakeHash placeholder, which is why it is a build-sourced value and not a guess:

    specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
       got:    sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=

Squashed so the placeholder commit (fails to build by design) never enters main's history. Gates on the tip: Nix green, Go suite green on SQLite and Postgres uncached (PG legs verified by timing), lint 0, vet clean; CI 7/7 on 51a0efd2.

Claude-Session: https://claude.ai/code/session_01TkxKnJpLgk5UxKS8T896dk
2026-09-02 16:52:22 -04:00
xarmian 7a7e9d669b fix(web): the selection toolbar is a row again (IDEA-2843)
Codex round 7.

`.bubble-menu` had no layout of its own. Its buttons are themselves
`display: flex`, so they are block-level and STACK — invisible while the
menu held one action, wrong the moment Comment joined Extract. It also
falsified the dimensions `positionMenu` clamps against, so the menu drifted
over the text it points at. A row layout on the container; the expanded
state opts out, since the extract form lays itself out.

Layout is not observable in jsdom, so the assertion lives in the e2e: the
two buttons share a row (y within 4px) and Extract sits to the right of
Comment. Removing the row layout fails it in a real browser — verified.

Declined, with the reason recorded in the code: "Comments 1+" can appear
when the only unfetched entries are activity or versions. `+` reads as a
LOWER BOUND, and a lower bound of 1 over exactly one comment is true.
Knowing whether more comments exist means fetching the rest of the feed, so
the alternative trades a true imprecise count for a confident wrong one.

Gates: 119 files / 2005 unit tests, svelte-check 0 errors, e2e 2/2 on the
selection spec against a rebuilt binary.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 4ebad409a2 fix(web): gate the composer's item identity during A→B navigation (IDEA-2843)
Codex round 6.

1. [P1] During an A→B navigation `item` still holds A while loadData
   fetches B, so ItemTimeline received A's itemId/collectionId beside B's
   itemSlug — and an attachment dropped in the composer inside that window
   is associated with the WRONG item.

   The wiring is PRE-EXISTING and identical on main. What changed is the
   exposure: the composer used to sit behind the Activity tab, and tabs
   reset to Details on an item switch, so reaching it inside the load window
   took a deliberate tab click. It is now on the tab you land on. Widening a
   latent hole is the same as opening one, so it is fixed here.

   Fixed by feeding honest inputs rather than adding a gate: the host passes
   itemId/collectionId only while `itemMatchesRef`, and ItemTimeline's
   canEdit already derives false without them, so the composer hides until
   the identities agree.

2. [P2] A load failure's banner outlived it — `loadMore` set `error` and
   never cleared it, so a successful retry left the failure sitting beside
   the entries it claimed had not loaded. Newly visible because the error is
   mirrored to the tabs now.

Test boundary, stated: the new test covers ItemTimeline's half of the gate
(no identity ⇒ no composer), verified by a control that flips its default to
permissive. The host's half — the `itemMatchesRef ? … : undefined` — is not
unit-testable here, since ItemDetail cannot be mounted in jsdom.

Gates: 119 files / 2005 unit tests, svelte-check 0 errors. E2E: 37 passed
across the five affected specs. An earlier run of that same set had one
failure — capstone's "stale back-settle" nav test — which did not reproduce
alone or in an identical re-run, and sits outside this diff's surface
(history back-settle and drill targeting; nothing here touches either).
Recorded rather than dropped.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 09a183844d fix(web): a submit no longer erases what arrived mid-flight; empty states wait for the last page (IDEA-2843)
Codex round 5.

1. Data loss, in the handle I added. `doSubmit` clears the composer on
   success, and a quote pushed in through `appendMarkdown` during the round
   trip was cleared with it — the quote simply vanished. The clear now
   requires the composer to still hold what was SENT.

   This also fixes a PRE-EXISTING loss by the same mechanism: text the user
   typed while a submit was in flight was erased too. It is the same class
   as the item-identity capture already guarding this path (PLAN-2105 /
   TASK-2112) — that one asks "is this still the same item", this one asks
   "is this still the same content".

2. Empty states appeared while more pages remained. A first page carrying
   only other kinds made a filtered view say "No versions yet." before the
   pages that would have contradicted it were fetched — a claim about the
   item made from one page of a feed. Both views now wait for the last page;
   until then the "Load more" button is what the reader sees.

Gates: 119 files / 2004 unit tests, svelte-check 0 errors. The mid-flight
fix has a negative control — restoring the unconditional clear fails the new
test.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian b82c689eda fix(web): pagination honours the caller's filter; the host's Load more is styled (IDEA-2843)
Codex round 4.

1. Filtered pagination, fixed properly this time. Round 1 gave the TABS a
   host-side retry wrapper and left the same defect in the owner's own
   button: the Comments view could also page without showing a new comment.
   Fixing the instance and not the class, twice on the same defect.

   `loadMore(forKinds?)` now takes the caller's view filter and the hop loop
   counts only entries that filter admits, defaulting to the component's own
   `visibleKinds`. Whoever pressed the button says what progress means. The
   host wrapper is deleted — MAX_EMPTY_HOPS already bounds the walk, so the
   six-round loop on top of it was compensation for the missing filter.

   Caught while wiring it: the owner's own button was `onclick={loadMore}`,
   which passed the MouseEvent as `forKinds`. svelte-check found that; no
   test would have.

2. The host's "Load more" was unstyled. Its class name matches
   ItemTimeline's, but Svelte scopes styles per component, so the copied
   NAME got browser defaults and nothing warned — CSS is the part no test
   here asserts. Styles copied over with the reason recorded. Swept the
   other class names the split moved across that boundary
   (entry-list, compose, timeline-header, entry-count, empty): none is used
   in the host, so the population is this one.

Gates: 119 files / 2003 unit tests, svelte-check 0 errors. The pagination
fix has a negative control — restoring the unfiltered break fails the new
test, which needed descending fixture timestamps to avoid passing for the
unrelated "cursor did not move" reason.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian abe8d93c10 fix(web): per-view titles, counts and empty states; a failed quote is not silent (IDEA-2843)
Codex round 3.

1. The split left every view describing the WHOLE feed. The comments
   section was headed "Timeline" with a count of every entry, so an item
   with three activity entries and no comments read "Timeline 3" over an
   empty list — a count of things the reader cannot see. And `showEmpty`
   was computed over the whole feed, so a view whose own slice was empty
   rendered nothing at all: no entries, no explanation.

   Title, count and empty state now describe what the view RENDERS.
   `title` and `emptyLabel` are props: "Comments" / "No comments yet." on
   Details, "No changes yet." / "No versions yet." on the tabs. The
   deliberate choice this reverses is mine — I passed showEmpty over the
   whole feed on the grounds that a filtered-out tab must not claim the
   item has no history. Right premise, wrong fix: the answer is to say
   something true about the slice, not to say nothing.

2. A failed quote was silently discarded. `appendMarkdown` returns false
   precisely so "did nothing" is distinguishable from "inserted" — and the
   only caller threw the boolean away and hid the menu, putting the silent
   no-op back exactly where the handle was built to remove it. A false now
   keeps the menu and the selection and reports it.

Gates: 119 files / 2002 unit tests, svelte-check 0 errors, 14 e2e in
desktop-chromium against a binary rebuilt from this tree. The
discarded-return fix has a negative control — restoring the bare
`onComment(...)` call fails the new test. (A first attempt at that control
failed on shell quoting and silently ran against UNMUTATED code; it was
re-run properly before this claim.)

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 8d7fdc22f3 fix(web): consult the error state everywhere success was assumed (IDEA-2843)
Codex round 2, and both findings are follow-through misses on my own round-1
fix: I added an `error` to the mirrored feed and then left the code that
assumes a load succeeded reading only `entries` and `hasMore`.

- A failed load rendered the error AND "No timeline entries yet." One is a
  statement about the ITEM; a failed load knows nothing about the item, so
  the pair says something false beside something true. `showEmpty` now
  consults `error`.
- The filtered "load more" wrapper retried a dead server up to six times per
  click. `loadMore()` catches and resolves, leaving `hasMore` true, so the
  loop had no reason to stop. It bails on `error` now.

CONVE-18 sweep rather than the two named instances. The population is every
consumer of the mirrored feed — six read sites in the markup, four in the
wrapper. Two were defects (both above). One is a deliberate non-change: the
"Load more" button still renders while an error is showing, because that is
the retry affordance. The class also reaches the OWNER, where the same
empty-beside-error contradiction is PRE-EXISTING on main and is fixed here
too, since leaving it would mean the comments view kept the bug the tabs just
lost.

The regression test asserts the mounted owner's DOM. A first version
recomputed `entries.length === 0 && !loading && !error` and asserted that —
which passes whatever the component actually renders — so it was replaced.

Gates: 119 files / 2001 unit tests, svelte-check 0 errors. Reverting the
owner's guard fails the new test.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 5c0d8e9d34 fix(web): three codex round-1 findings on the timeline split (IDEA-2843)
All three are consequences of the two-view split that the split's own tests
did not reach.

1. Multi-paragraph selections lost their paragraph breaks. The bubble menu
   builds `selectedText` with `textBetween(..., ' ')` because Extract uses it
   as an item TITLE, where newlines would be wrong — so quoting through it
   flattened two paragraphs into one run-on line. Worse, it made
   `toBlockquote`'s blank-line handling unreachable from production: that
   behaviour had a passing test and no call site that could produce it. The
   quote now re-extracts with a paragraph separator, and a test asserts the
   blockquote's blank line end to end.

2. Activity and Versions rendered a FAILED load as "No timeline entries yet."
   Loading and error were the owner's states and did not cross the mirror, so
   an unreachable server and an empty timeline looked identical on the tabs
   that only render entries. `error` joins the mirror; both states render.

3. "Load more" could visibly do nothing on a filtered view. The owner's hop
   loop stops as soon as a page adds an entry of ANY kind, so a page of pure
   comments ends it having added nothing to the changes view. Pre-existing on
   Versions; widened to Activity when comments moved off it. The host now
   pages until THIS view's list grows, bounded at six rounds — not fixed in
   the owner, which would have to know what the other view is rendering.

Gates: 119 files / 2000 unit tests, svelte-check 0 errors. Fixes 1 and 2 have
negative controls: reverting to the space-joined text fails 1, dropping
`error` from the mirror fails 1.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 346a5c92a9 feat(web): Comment action on the selection toolbar, quoting into the composer (IDEA-2843)
GitHub #1228. Selecting a passage in an item's content now offers Comment
beside Extract; it quotes the selection as a markdown blockquote into the
comment composer under the content, appending after a blank line so an
in-progress draft survives. The selection is NOT consumed — unlike Extract,
which replaces it with a wiki-link — so a reader can quote the same passage
twice or keep reading.

- toBlockquote() prefixes EVERY line including blank ones. An unprefixed
  blank line ends a blockquote in markdown, so quoting two paragraphs
  without it silently drops the second out of the quote and leaves it
  looking like the commenter's own words.
- The action renders when the host supplies `onComment`. A composer to
  quote into IS the capability; a flag that is always true beside a
  callback that is always supplied would be two ways to say one thing.
- The button's accessible name is "Comment on selection". The composer's
  submit button is also named "Comment", and two identically-named buttons
  with different effects is a real ambiguity for name-based navigation —
  found by the first end-to-end run failing on a locator, not on behaviour.

A NEGATIVE result, measured and kept. The action was briefly gated
peek-independently, reasoning that a peeking master keeps a live composer
(BUG-2263) but could not act on a selection. That state does not exist: a
drag-selection in a peeking master RE-ACTIVATES it (focus-follows-editing,
PLAN-2179 DR-2), so a selection and a frozen master never coexist. The gate
is back on `mutationsEnabled`, and e2e/selection-comment-peek.spec.ts asserts
the re-activation so a future change that makes selections survive the freeze
turns red there instead of quietly reopening the question.

Gates: 119 files / 1998 unit tests, svelte-check 0 errors, and 37 e2e in
desktop-chromium — the 2 new ones plus the 35 in the four specs the comment
relocation touched, run against a binary built from this tree.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian 7aef246dcd feat(web): comments move under the item content; Activity keeps changes (IDEA-2843)
GitHub #1228. Reviewing an agent-written doc meant many small comments, and
every one cost a trip to the Activity tab and back. TASK-2294's own spec put
an activity preview on the Details panel; it never shipped, and the comments
being tab-only is the half that was left. Dave ruled the full move.

One component cannot render in two DOM locations, so ItemTimeline stays the
SINGLE owner — one fetch, one SSE subscription, one composer — mounted under
the content on Details rendering comments, and mirrors its feed out through a
new bindable `feed` prop. The Activity and Versions panels render that same
feed through a second TimelineEntryList.

- The mirror publishes the WHOLE feed, not the owner's rendered slice. The
  owner renders comments only, so publishing `visibleEntries` would leave
  both tabs permanently empty with nothing to report. Tested, and the
  one-word mutation fails it.
- `loadMore` rides in the mirror: pagination is a property of the ONE feed,
  and a tab that can show older entries but not ask for them is a dead end.
- The kind partition is three shared constants with an exhaustiveness check,
  not literals at the mount sites. A kind in none of them renders NOWHERE —
  which is how note/decision shipped invisible the first time (BUG-2301).
  Adding a kind to TimelineEntry without routing it is now a build error or
  a failing test rather than a silent hole.
- The comments section carries its own {#key itemSlug}: it left the block
  that used to provide that remount, and dropping the guard would have been
  invisible. It wraps only the timeline — the collab editor must never be
  keyed.

Five e2e specs asserted comments behind the Activity tab and are updated.
attachment-lifecycle's tab round-trip is preserved deliberately: its claim is
that the panel is CSS-hidden rather than unmounted, so it now goes out to
Activity and BACK rather than asserting against a hidden panel.

Gates: 117 files / 1987 tests pass, svelte-check 0 errors. Both new
properties have negative controls — publishing the rendered slice fails 1,
unrouting a kind fails 2.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian f932469380 refactor(web): extract TimelineEntryList from ItemTimeline, no behaviour change (IDEA-2843)
Comments move under the item content on Details while Activity keeps
changes and Versions keeps versions, so the one feed has to render in two
DOM locations. One component instance cannot be in two places, and the
constraint on this work is ONE subscription and ONE composer — so the
rendered list becomes its own presentational component and ItemTimeline
stays the single owner of fetching, SSE, pagination, the attachment probe,
the paint fence and every mutation.

This commit is the extraction only. Nothing moves location and no
behaviour changes; the second mount site is the next commit.

- TimelineEntryList.svelte: the entry loop, rail chrome, the five card
  branches and their CSS, lifted verbatim. `listEl` is bindable because
  the owner's delegated lightbox listeners and imperative image-a11y pass
  attach to the container and stay with the owner. `showEmpty` is passed
  rather than derived from the rendered entries, preserving the owner's
  condition over the WHOLE feed — a tab that filters everything out must
  render an empty list, not claim the item has no history.
- The comment-card callbacks are optional here with no-op defaults: a list
  rendering no comments has nothing to hand them, and a card that could
  call one only renders when the owner supplied the real handler.

Evidence, and the reason it counts: the existing suite passes UNCHANGED —
116 files, 1983 tests — and svelte-check reports 0 errors. A refactor
whose only claim is "nothing changed" is exactly where an untouched suite
is the right instrument, but only if it actually exercises the moved
markup. It does: rendering the list over an empty array instead of
`entries` fails 64 tests across 7 files.

Claude-Session: https://claude.ai/code/session_011Q4b1iHtJtSyMs7BA2ySxo
2026-09-02 19:42:50 +00:00
xarmian a2bdd904a5 docs,test(web): correct two claims the mutation matrix refuted (IDEA-2843)
Both corrections are to MY OWN rationale, not to behaviour.

- The setContent-over-insertContentAt comment read as a defect avoided.
  Measured: swapping to insertContentAt leaves all five tests green, so
  both routes preserve the blockquote today. Restated as what it is — a
  preference for not depending on normalizeInline's leading-<p> rule —
  and marked explicitly unenforced.
- The explicit `empty = editor.isEmpty` was inert: dropping it leaves the
  suite green, because setContent emits an update by default and onUpdate
  maintains the flag. Removed. The test asserting submit becomes enabled
  is the real guard, and it goes red if a tiptap bump flips that default.

The test file's claim that its blockquote assertion catches an
insertContentAt implementation was false for the same reason; it now
states what the assertion does catch (a genuine flatten, verified) and
what it does not.
2026-09-02 19:42:50 +00:00
xarmian 9363dbb749 feat(web): imperative appendMarkdown handle on CommentEditor (IDEA-2843)
The selection toolbar's forthcoming Comment action needs to drop a
blockquote of the reader's selection into the ALREADY-MOUNTED composer.
The obvious route is a silent no-op: CommentEditor reads `content` once,
inside `new Editor({...})` in onMount, and has no $effect syncing it, so
writing the prop on a live composer drops the text with no error.

- appendMarkdown(markdown): appends after a blank line when a draft
  exists, never replaces; returns false when there was nothing to insert
  or no live editor, so a caller can tell 'inserted' from 'did nothing'.
- setContent (block parse) rather than insertContentAt: tiptap-markdown
  overrides insertContentAt with { inline: true }, where a blockquote
  survives only incidentally.
- A {#key} remount was the ruled-out alternative: it would destroy an
  in-progress draft, which is what doSubmit's identity capture
  (PLAN-2105 / TASK-2112) exists to protect.

Tests assert the quote TEXT and its blockquote tag, not the composer's
visibility — the broken version opens the composer too.
2026-09-02 19:42:50 +00:00
xarmian 704ba874d4 Merge pull request #1233 from PerpetualSoftware/fix/bug-2810-nul-repair
fix(store,server,cli): count and repair the legacy NUL population (BUG-2810)
2026-09-02 15:29:10 -04:00
xarmian e4415ddd04 fix(cli): name the skipped-table suspects instead of counting them (BUG-2810)
Codex round 12, polish rather than a defect. The advisory reported how many
values in non-migrated tables mention a NUL escape, and then made the operator
run `pad db scan-nul` to learn which — when the rows were already in hand.

They are listed now, in the same shape as every other row this command prints.
The test asserts the table.column appears rather than only the surrounding
phrase, so a regression to a bare count fails it.
2026-09-02 18:42:36 +00:00
xarmian d9f3fe3881 fix(cli): filtering suspects out of the check also filtered them out of the report (BUG-2810)
Codex round 11. Round 10 stopped probing suspects from tables the migration
does not copy, which was right — but it also dropped them from the output,
while the comment two lines below still claimed "the others are still
REPORTED". A legacy shadowed-NUL in activities.metadata produced no warning at
all.

They are now COUNTED and named, pointing at `pad db scan-nul` for detail.
Counted rather than probed on purpose: whether one is actually fatal can only
be answered by the destination, and asking would put them back inside the
fail-closed rule the filter exists to keep them out of.

The test captures stderr and asserts the advisory appears, and is
mutation-verified: suppressing the notice fails it with "the suspect was
filtered out of the check AND out of the report".

THIS IS THE THIRD TIME on this branch that the same shape has appeared — a
filter that is right about what to ACT on quietly becoming a filter on what to
SAY. The first was the scan dropping suspects entirely; the second was the
preflight refusing on tables it does not copy and then, fixing that, going
silent about them. Each fix was correct about the action and wrong about the
reporting, and each time the comment stayed true while the code stopped being.
Worth naming as the pattern rather than as three unrelated defects.
2026-09-02 18:29:55 +00:00
xarmian 39a8366060 fix(cli): filter suspects by table BEFORE asking the destination (BUG-2810)
Codex round 10, and it is round 9's over-refusal reintroduced through the other
path. The fail-closed rule refuses on a suspect that could not be VERIFIED, and
it ran over every suspect before MigratedTables was applied — so an
unverifiable row in `users`, `sessions` or `activities` blocked a copy that
would never have touched it. Filtering first also stops the oracle making round
trips whose answer cannot matter.

Two things learned writing the test, both worth more than the fix.

**The first version of it proved nothing.** It used a nil destination, but the
fail-closed branch only runs once there is something to ask, so it passed with
and without the fix. The real fixture needs a live destination AND a genuinely
unverifiable row: a NULL primary key, which SQLite permits in a declared TEXT
PRIMARY KEY and no other engine does. Mutation-verified in the new shape — with
the filter back in its old position the test fails with the reported symptom,
"1 suspect value(s) could not be checked; nothing was migrated".

**Layer B is STRICTER than the shared predicate for this shape.** The fixture
would not insert until the triggers were dropped: SQLite's json_tree walks
tokens rather than building a map, so it sees the NUL in the shadowed member
that our Go predicate cannot. That narrows how such a row can exist at all — it
must be legacy data written before the triggers, which is exactly the
population BUG-2810 is about. Recorded in the fixture rather than left as a
surprise for the next person whose insert is refused.
2026-09-02 18:17:40 +00:00
xarmian e89c8c8ab6 fix(cli): two ways the preflight and its remedy disagreed with the migration (BUG-2810)
Codex round 9, both confirmed against the code rather than reasoned about.

**PAD_DATABASE_URL was treated as proof of a PostgreSQL deployment**, so the
flow this unit prescribes broke on itself. cmd_server.go opens PostgreSQL only
when PAD_DB_DRIVER=postgres; PAD_DATABASE_URL is ALSO migrate-to-pg's target,
and its default. An operator who follows the preflight — refused, told to run
`pad db repair-nul`, with the target URL still exported in their shell — got
"This deployment is PostgreSQL ... Nothing to scan or repair" and exit 0. The
remedy the refusal names did nothing, which is the failure mode this unit has
now produced three separate ways. PAD_DB_DRIVER alone decides. Verified by
running the real command with the target exported.

**The preflight refused on tables the migration does not copy.**
ExportWorkspace / ImportWorkspace read six tables, and migrate-to-pg's own help
says users, platform settings and auth data are not migrated — so a NUL in
users.name blocked a copy that would never touch it, demanding the operator
rewrite content unrelated to the migration they asked for.

Refusal is now filtered to store.MigratedTables(). Those rows are still
REPORTED: `pad db scan-nul` lists them, they are real, and going quiet about a
broken row because this command does not care about it would be the
information-discarding the preflight was already corrected for once.

The table set is pinned by REFLECTION over models.WorkspaceExport's shape, not
by a regex over ExportWorkspace's SQL — TASK-2825 already established that
multi-line and Sprintf-composed SQL are invisible to any source-level
instrument. It fails in both directions: a new export section with no entry
(a miss, ending in a half-finished migration) and a spurious entry (an
over-refusal).

One residual, stated rather than hidden: the export also skips SOFT-DELETED
collections and items, and this filter is per-table. A NUL in a soft-deleted
item still blocks. Narrowing it needs a per-row deleted_at check at every
candidate, which costs more than the remaining over-refusal — the operator's
way out is the same single command either way.
2026-09-02 18:01:37 +00:00
xarmian d86a499f56 fix(store): the SQLSTATE extractor indexed one string and sliced another (BUG-2810)
Codex round 7. sqlStateOf searched strings.ToUpper(msg) for the marker and then
sliced the ORIGINAL message at that offset. Correct only while every byte
before the marker is ASCII: Unicode case mapping changes byte LENGTH for some
runes, and PostgreSQL renders messages in lc_messages, so a non-English server
is not a hypothetical.

One string now serves both the search and the slice, which also makes the
returned code uppercase without a second conversion.

Mutation-verified rather than argued: against a message carrying U+0131 (two
bytes, uppercasing to a one-byte "I") the old code returns "TE 22" where the
code is "22P05". That garbage happens to classify as unavailable — the safe
direction — but only by luck; a different offset lands on a spurious "22"
prefix and turns a check that never completed into a verdict about the value.

The regression leg uses a localised message shape for that reason, and the
failure it produces is the one above.
2026-09-02 17:35:58 +00:00
xarmian 6d4c3b4b75 fix(store): an operational SQLSTATE is not a verdict about the value (BUG-2810)
Codex round 6, and it is the round-5 fail-open one level deeper. That round
split "the server answered" from "the server did not", and I implemented the
first half as "does the error carry a SQLSTATE at all" — which is wrong,
because 57014 (query cancelled), 57P01 (terminated by administrator), the 08
class (connection exception) and the 53 class (out of resources) all carry
SQLSTATEs while saying nothing whatever about the value. Classified as
verdicts, they let the preflight proceed with an UNVERIFIED suspect, which is
the exact thing the three-way split was added to stop.

The test is now INVERTED: only SQLSTATE class 22 — data exception, PostgreSQL's
class for "this value is wrong" — counts as a verdict about the value.
`SELECT $1::jsonb` produces 22P02 for malformed JSON and 22P05 / 22021 for the
NUL cases. Everything else, code or no code, means the question was not
answered, and the caller refuses rather than guessing. Erring toward
"unavailable" is the safe direction: its cost is a refused migration an
operator re-runs, against a half-finished one they have to unpick.

The coverage is split deliberately, and both halves are needed. The operational
codes are from PostgreSQL's error-code table, formatted the way pgx renders
them, because provoking an administrator shutdown inside a unit test is not
worth it. What is NOT assumed is the rendering, or the premise that class 22 is
what a bad value yields: the real-server test now extracts the SQLSTATE from a
genuine malformed-value rejection and asserts it is class 22 and a completed
verdict, and the closed-pool test covers the no-code path. Neither half stands
on its own.

sqlStateOf's own edges are pinned too — a truncated "SQLSTATE 22" must not
yield a partial code that then matches a class prefix, and the marker search
being case-insensitive means the extraction has to be as well.
2026-09-02 17:20:31 +00:00
xarmian 0363c139a9 fix(store,cli): the oracle failed open, and it over-refuses one column (BUG-2810)
Three findings from codex round 5, all real; the third corrected a claim I had
made about the design.

**The suspect path could leave data unrepaired and exit 0.** The CLI printed
SuspectsFailed and then returned nil, checking only the violation bucket. A
script sees success; an operator who trusts the status moves on. Both buckets
now decide the exit code, and the decision is extracted into
nulRepairExitError so it is testable without a database — the bug was in the
decision, not in the repair, and a test that needs a fixture to reach it is a
test nobody writes.

**The destination oracle failed open.** Connection failures, timeouts and
read-back errors were bucketed with "the destination answered, about something
else" — reported and not refused on. So an UNVERIFIED suspect passed the
preflight, which is the defect the suspect class was added to correct arriving
by a different route.

There are now three outcomes rather than two: the server answered with a NUL
code (refuse), the server answered with another complaint about the value
(report, because a NUL preflight that quietly grew into a general one would
block migrations unrelated to this bug), and the server never answered
(REFUSE). ErrDestinationCheckUnavailable carries the third, and
TestDestinationOracleFailsClosedOnAnUnusableConnection pins it against a real
closed pool — with an open-pool control first, since a classifier that answered
"unavailable" for everything would satisfy the assertion and refuse every
migration.

**The oracle is not a perfect model of the migration, and I said it was.**
Codex claimed workspaces.settings is normalised on import, so the cast
over-refuses there. Measured rather than argued, by importing the same
shadowed-duplicate value into three columns against a real server:

	workspaces.settings  -> import SUCCEEDS, stored as {"a": "clean"}
	items.fields         -> import FAILS, SQLSTATE 22P05
	collections.schema   -> import FAILS, SQLSTATE 22P05

CreateWorkspace runs models.NormalizeWorkspaceSettings, a map round-trip that
drops the shadowed member. So the claim was right, and my own runtime demo
earlier on this branch — which used workspaces.settings — was showing a
spurious refusal.

The cast STAYS. That row is a value Layer B refuses on every write today and
exists only because it predates enforcement, so surviving the migration is an
accident of one column's normaliser rather than a property worth preserving,
and repair-nul clears it in one command. Deriving "would this column's writer
normalise it" is a per-column enumeration, which is the shape this cluster
keeps proving unmaintainable.

What changed is the CLAIM. The file header no longer says the oracle is "exact
in both directions" — it is exact about the VALUE and is not a model of the
MIGRATION; the refusal no longer tells an operator PostgreSQL would reject the
row, only that the value carries a NUL jsonb refuses; and the measurement and
the over-refusal are written into CheckJSONBAcceptable's doc comment and
docs/backup.md, which also now states that the check errs toward refusing.

The disposition is flagged to the lead rather than settled here: skipping
normalised columns is a scope call, not mine.
2026-09-02 17:05:13 +00:00
xarmian 57b7ca5f48 feat(store,cli): ask the destination about suspects instead of dropping them (BUG-2810)
Day-54 lead ruling on PR #1233, and the ruling names the defect precisely: the
scan's own SQL pre-filter already surfaces the shadowed-duplicate row as a
candidate, and `ParameterRefused` then drops it. So the preflight was
discarding information it was holding and going on to promise the migration
would go through. I had recorded that as an accepted residual on the grounds
that closing it would violate DOC-2823's one-layer rule — but that rule is
about what the enforcement layers REFUSE. It says nothing about a preflight
throwing away a candidate it had in hand.

**The SUSPECT class.** A pre-filter hit the predicate does not refuse. Most are
doubled-backslash literals — text that writes ABOUT the escape, which is the
false positive this whole predicate family exists to avoid. One member is not:
a NUL in a value shadowed by a LITERAL duplicate key, which a map-model decode
drops and PostgreSQL refuses. Nothing here can tell them apart, so nothing here
tries: `pad db scan-nul` lists them under their own heading, apart from the
violations, with what resolves them.

**The destination is the oracle.** `pad db migrate-to-pg` casts each suspect on
the TARGET connection — `SELECT $1::jsonb`, side-effect-free, and the very cast
an INSERT performs — and refuses on 22P05 / 22021. That is exact in both
directions precisely because it is not a fourth opinion of ours. Measured
against a real server: the literal is ACCEPTED, the shadowed duplicate is
REFUSED with 22P05, and a non-JSON value fails for a reason that is reported
rather than refused on, because a NUL preflight that quietly grew into a
general one would block migrations unrelated to this bug.

**The repair had to be measured, not assumed, and the answer changed the
design.** `textguard.Repair` leaves the shadowed value completely untouched:
its scanner is gated on DocumentDecodesNULAnyShape, a map-model question that
answers false for exactly this shape, so it never runs. A preflight that
refused the row and printed `pad db repair-nul` would have been printing a
command that does nothing to it — a remedy nobody ran (PATTE-135). So the
repair reaches the class through the token-level scanner, exported for this,
which rewrites the shadowed escape and still leaves the literal byte-identical
because it consumes escapes in order.

Suspects get their own buckets in the repair report rather than being folded
into Repaired, so the dry run's promise and the run's result stay the same
number.

**Nothing about what any layer REFUSES changed.** textguard.KnownGaps and its
pin are untouched, and TestScanNULInheritsTheRecordedKnownGaps still asserts
the scan does NOT detect the shape. TestSuspectsCollapseWhenBUG2812Lands fails
when the token-walk makes that false, and names every file to delete — the
suspect path is a second mechanism that exists only while the predicate is
blind.

**One defect this found that no test did.** Running the real command against a
real Postgres, the refusal announced "0 stored value(s) carry a NUL; nothing
was migrated" while listing one — the count used the violations only, and the
tests asserted the message CONTAINED "nothing was migrated" without reading the
number. Fixed, and the assertion now reads the count. The whole loop is now
verified end to end: preflight refuses, `repair-nul` fixes, the migration
completes.

My own prose from earlier on this branch is corrected with it. ScanNUL's doc
comment, the preflight's, and docs/backup.md all said this shape passes the
preflight and fails mid-copy, which the same commit makes false.
2026-09-02 16:45:38 +00:00
xarmian a65252dab1 fix(cli): print the NUL report on stdout so it can be captured (BUG-2810)
`pad db backup` and `pad db restore` keep their progress on stderr because
stdout may carry the backup itself. These two commands emit no data at all,
and their REPORT is the whole point — an operator piping
`pad db scan-nul > affected.txt` was getting an empty file and the list on
the terminal, which is the opposite of what the command is for.

Report to stdout; the confirmation prompt, its warning and the Postgres
not-applicable notice stay on stderr, where a prompt belongs.

Verified by running the real command with 2>/dev/null and reading the list.
2026-09-02 16:28:42 +00:00
xarmian 178b6b5010 fix(server,store): two more from codex rounds 3 and 4 (BUG-2810)
**The import repair could silently change what gets imported.** It decodes into
map[string]any, where a repeated object member keeps only the LAST value. The
TYPED decode that runs next does not agree: encoding/json unmarshals members in
order into the same struct field, so two `"workspace"` objects MERGE there and
collapse here. A body with duplicate members would therefore import differently
with --repair-nul than without, which is outside what a flag by that name may
do.

It now DECLINES such a body: returns it untouched, lets the gate judge it
exactly as it would without the flag, and says why in the refusal — "the
payload repeats the member X, and repairing it would change which value is
imported". Detection is a token walk, because a decode is what loses the
information: by the time there is a map the duplicate is gone. The detector's
own test carries the false positive that matters — the same member name in
SIBLING objects is not a duplicate, and a single shared set of names would
decline every real export, since items all carry `id`, `title`, `slug`.

Rewriting such a body faithfully wants a token-preserving pass, which is
BUG-2812's token-walk and not a rider on this. A real export cannot contain
duplicate members (json.Marshal does not emit them), so declining costs nothing
an operator meets by accident.

The tally now owns the repair — decodeJSONRepairingNUL takes it and calls
Apply — so the count and the declined reason come back through one object
instead of a return value a caller has to remember to record. That is the same
mistake this branch already made once, when the JSON path dropped the count and
the header reported 0 for an import that had rewritten a value.

**A row the repair could not address was reported as a failure.** A NUL in a
key column the list does not protect, on a row whose violation is elsewhere,
makes the address unbindable: Layer A inspects every bound parameter, including
a WHERE clause's, so the lookup is refused before SQLite is asked to find the
row. It landed in Failed carrying "invalid text parameter: parameter 2" — the
same information phrased as a fault in the repair rather than a property of the
row. Now detected up front and reported as a skip with the reason, alongside
the two skips that already existed.

**One finding NOT fixed, deliberately, and recorded instead.** Round 3 raised
that the scan misses a NUL in a value shadowed by a LITERAL duplicate key, so
such a row passes the migrate-to-pg preflight and then fails during the copy —
the exact failure the preflight replaces, surviving for one shape. That is
textguard.KnownGaps: a blind spot every layer shares on purpose, which DOC-2823
forbids closing in one layer alone, because layers disagreeing about one value
is the defect this cluster is made of. So it is named in ScanNUL's doc comment,
in the preflight's, and in docs/backup.md for the operator, and
TestScanNULInheritsTheRecordedKnownGaps pins the miss and FAILS when it stops
being one — the notification that BUG-2812 has landed and those three prose
sites need updating. The consequence is recorded on BUG-2812's trail.

Round 2's single finding was refuted rather than fixed: it predicted
TestRepairFlagReachesTheNestedAndObliqueForms would fail, on a mechanism that
describes the raw-byte scanner this branch had already replaced. The test
passes; the outer decode resolves the oblique spelling before the walk sees it.
2026-09-02 16:24:25 +00:00
xarmian 49bd342e4c fix(store,server,cli): three defects from codex round 1 (BUG-2810)
**The import flag could not repair the column it exists for.** `--repair-nul`
scanned the RAW body for a live escape, which is right for a value the gate
reads at the top level and wrong for the one that actually matters. An item's
`fields` blob travels through an export as a STRING: a NUL escape in the stored
blob marshals into the body with a DOUBLED backslash, which a raw scan must
leave alone because at that layer it is literal text — while the gate refuses it
anyway, since it decodes the body and re-parses that string as the document it
is.

So the repair now walks the DECODED body with the same classing bodyDecodesNUL
uses, one verb changed: where the gate asks textguard whether a value decodes to
a NUL, this asks textguard to repair it. Two walks of one shape in one package
is a real risk, and the mitigation is that they are measured against the same
corpus in both directions rather than reviewed for similarity —
TestBodyRepairMirrorsTheGateOverTheCorpus drives every case through the body
shape and asserts refused-becomes-accepted and accepted-stays-byte-identical.

Two consequences worth stating. The walk also reaches the OBLIQUE spelling — the
backslash written as its own escape, so the six characters never appear in the
raw bytes at all — which the scanner could not, so the test that pinned that
limit is replaced by one asserting the capability. And re-encoding is now
possible, so it is bounded: UseNumber, so an integer wider than float64 is not
silently re-emitted in scientific notation; SetEscapeHTML(false); and a body
with nothing to repair is returned byte-identical rather than round-tripped. The
mutation that removes UseNumber turns 9007199254740993 into ...992, and a test
says so.

The header is now X-Pad-Repaired-NUL-Values, because at the decoded layer an
escape is not a thing that exists any more and one nested document may have
carried several.

**The scan could not run on the databases it exists for.** Several protected
tables carry a NULLABLE workspace_id — activities, api_tokens, mcp_audit_log —
and the scan selected it into a plain *string, which fails with "converting NULL
to string is unsupported" and takes the scan, the repair and the migrate-to-pg
preflight down with it. Every column is now scanned as sql.NullString: SQLite
also permits NULL in a declared PRIMARY KEY that is neither INTEGER PRIMARY KEY
nor NOT NULL, which no other engine does, and a NULL key cannot address a row
for an UPDATE — such rows are reported and skipped with the reason rather than
handed a WHERE that matches nothing. Verified against the unfixed code: the scan
returned `scan activities.actor row: sql: Scan error ... converting NULL to
string`. It needed a VIOLATING row in such a table, which is why every fixture
that planted its rows in `items` missed it.

**--force by accident.** The repair skipped the running-server check whenever
--from was given — and the most natural --from an operator types is the path
`pad db scan-nul` just printed, which IS the live database. The check is now on
the resolved path (Abs + EvalSymlinks, so a symlinked data directory or a
relative path still matches), and a --from naming an unrelated backup stays
unguarded, which is correct: nothing is writing it.

The ordering moved with it. `store.New` runs pending migrations, so the refusal
now happens BEFORE the database is opened; opening first and refusing second
made the guard arrive after the thing it guards against.
2026-09-02 15:15:11 +00:00
xarmian 63da2f4f5f feat(store,server,cli): count and repair the legacy NUL population (BUG-2810)
Layers A and B stop the value being written. Neither makes a row that
already carries one go away, and BUG-2810's filing is what that costs: an
affected workspace exports with a 200 and re-imports with a 400, so a
self-hoster restoring their own backup is blocked with no path forward in
the product, and `pad db migrate-to-pg` fails partway through the copy
against PostgreSQL's jsonb parser rather than up front.

This is DOC-2823's S3, on Dave's day-54 rulings: U+FFFD as the replacement,
repair standalone only with a migrate-to-pg preflight that refuses and
prints the command, `--repair-nul` on import shipping default-strict.

ONE REPAIR, beside the one predicate. textguard.Repair lives next to
ParameterRefused because four layers that agree about what is REFUSED and
disagree about what a repair PRODUCES is this bug family arriving one step
later. Its contract is a property over the same corpus, in both directions:
every refused value becomes one all four layers accept, and every accepted
value comes back IDENTICAL. The second half is the load-bearing one — a
repair that tidies values nobody complained about rewrites
`{"a":"x\\u0000y"}`, six literal characters after a doubled backslash, and
corrupts it.

The JSON arm is a string-literal SCANNER, not decode-walk-remarshal, which
is what the recon write-up proposed before it was written. Re-marshalling
changes four things nobody asked to change — object key order, insignificant
whitespace, integers wider than float64, HTML-ish characters — and silently
drops one of a document's LITERAL duplicate keys, which is a gap BUG-2812
owns and the last thing a repair should do. Scanning copies every byte it
does not deliberately rewrite, so an untouched document is byte-identical
without that having to be argued. A substring replace is not equivalent and
the test that proves it took a mutation to find: a doubled-backslash literal
ALONE never reaches the scanner, so the discriminating fixture is one
document carrying a live escape AND a literal.

THE COUNT IS COMPUTED IN GO. Measured on the read path in this worktree: a
row planted with `bad<NUL>name` reads back into a Go string with all 8 bytes
and the NUL intact, while `length(name)` in the same database answers 3.
TASK-2824 found that C-truncation and concluded no DB-side REPAIR could be
trusted; the same measurement on the read path says no DB-side COUNT can be
either. SQL narrows — `instr(col, char(0))`, plus the escape prefix on
JSON-classed columns, which is textguard's own pre-filter — and never
decides. The decision stays ParameterRefused with isJSON from the shared
86-column list, i.e. Layer B's classing.

Row addressing is read from the live schema rather than a hand-kept map:
39 tables carry protected columns, one (item_wiki_links) declares no primary
key and is addressed by rowid, two have composite keys, and five have a
single key that is not `id`. The repair checks RowsAffected because an
address that stopped selecting its row would otherwise commit an UPDATE that
touched nothing and report it as repaired — the one failure an operator
cannot see in the output.

`email_optouts(email)` is both a protected column and its own primary key.
Repairing it changes the row's identity and can collide with an existing
row, which in that table means somebody starts receiving mail again. It is
reported and skipped, with the reason.

The import flag is NOT an exemption from the gate. `--repair-nul` buys the
body one repair attempt and then runs the same `bodyDecodesNUL` on the
repaired bytes, which still decides — a decode path that skipped the check
is the door BUG-2803 spent thirty rounds closing, on the endpoint carrying
the largest attacker-controlled body in the product. Only the ESCAPE form is
repaired: a raw NUL byte makes the document invalid JSON, and widening what
parses is not this flag's job. Both doors are covered, JSON and tar.gz,
because giving them different answers is how one of them keeps being
forgotten.

Postgres is settled with evidence rather than sent up as a ruling: it cannot
hold either defect (22021, 22P05) and the four-way differential test already
pins that, so the scan reports not-applicable WITH the reason rather than
returning a zero a reader could mistake for a clean database.

Spellings settled here, per the dispatch: `pad db scan-nul` and
`pad db repair-nul` as siblings rather than `repair --nul`, matching
`migrate-to-pg`'s hyphenated compound — a repair verb that errors when given
no flag is a worse shape, and there is no second repair to share it with.
scan-nul IS the dry run, so repair-nul grows no --dry-run. It refuses while
the server is running unless --force, on the `pad db restore` precedent: the
report is a claim about a database, and one somebody else is concurrently
writing makes it a claim about a moment that has passed.

docs/backup.md's section on this is rewritten. It still said the rule lives
in the binary and not the database, which S2 made false, and it pointed at
this item for a preflight and a repair that now exist. Its import examples
also showed `pad workspace import < file`, which has never worked — the file
is an argument.

Closes BUG-2810.
2026-09-02 14:53:31 +00:00
xarmian ebd1886ada Merge pull request #1232 from PerpetualSoftware/fix/bug-2827-outbox-bound
fix(store,server): bound the event outbox's writes, claims and scrub (BUG-2827)
2026-09-02 09:33:44 -04:00
xarmian 2dde493305 chore: ignore the Go build cache the codex sandbox leaves in the checkout
codex exec's sandbox runs go with GOCACHE inside the working directory,
at .tmp-gocache/. On this branch a git add -A after a review round swept
5,688 of those files into a commit (created 01:44Z during round 4,
committed 02:16Z); the two affected commits were rewritten without them
before the PR. Ignoring the directory means the next seat's add -A
cannot repeat it.
2026-09-02 12:33:08 +00:00
xarmian f54a0e41d4 docs(store,server): four comments and one log line that had stopped being true (BUG-2827)
Codex round 6. No logic finding; it confirmed the refusal ordering, the
split-budget claim and the first-tick scan as sound. Five statements in
the branch's own prose were untrue of the code as it stands:

- MaxOutboxPayloadBytes' comment still argued from 64 MiB ("two orders
  below both ceilings") after the constant became 128 MiB, which is 4x
  under the lowest ceiling, not two orders.
- maxOutboxClaimBytes' comment counted a scan-into-string-then-copy
  transient that round 1 removed; the scan lands straight in []byte.
- maxOutboxClaimRows' comment used the item BODY mean (~2.4 KB) as the
  payload mean; the measured payload mean is ~3.5 KB, so 5,000 rows is
  ~17 MiB, not ~12.
- emitBulkItemEventTx's early-out said the numbers the caller sees still
  come from writeOutboxTx; when the early-out fires they come from the
  projection, and the error says so in Measured.
- The drain's oversized-row log said "not claimed". OversizedPendingOutbox
  filters only dispatched_at, so during a rolling upgrade a binary older
  than the ceiling may be holding a claim on the row it names. The line
  now states what the query establishes: this instance will not claim it.
  The doc says why claimed_at is deliberately not a filter.
2026-09-02 12:33:08 +00:00
xarmian 0836808ca5 fix(store): drop a past-the-hop-bound event before judging its size, and correct three comments (BUG-2827)
Codex round 5. No production defect found; one ordering edge and three
comments that had stopped being true.

THE ORDERING. writeOutboxTx has two refusals that disagree about the
mutation. The hop bound drops the event and lets the mutation stand,
because only the cascade it would extend is illegitimate. The size cap
fails the mutation, because there the mutation and the event are the
same fact. An event that trips BOTH was judged for size first, so it
failed a mutation over a row that was never going to be written. The
hop drop now comes first. Unreachable today - nothing propagates a hop
- which is exactly why the ordering is worth pinning before something
does. TestAnOversizedEventPastTheHopBoundIsDroppedNotRefused fails with
the two checks swapped back (run before the crash that interrupted this
round, and again on this tree).

THREE COMMENTS. The drain-limit constant said whole batches are claimed
past it; the byte budget and row cap can now split one. The claim
candidates' doc said every sibling; it is as many as the budget still
allows. OversizedPendingOutbox's doc named the write cap while its query
uses the claim ceiling, and said it ran every tick when the caller
throttles it to once per five minutes.

Gates: gofmt clean, golangci-lint 0 issues, full SQLite suite exit 0,
full Postgres suite exit 0.
2026-09-02 12:06:02 +00:00
xarmian 57caa7f92e docs,test(store): correct five comments and strengthen the shrink fixture (BUG-2827)
Codex round 4. Its one P1 does not hold, but the test it named as weak
genuinely was, and five comments in this branch had drifted from the
code they describe.

THE P1, CHECKED RATHER THAN ARGUED. The claim measures size at candidate
selection and never rechecks it, so a payload that GREW during a
concurrent scrub could be claimed over the ceiling. The proposed growth
path was Go's HTML escaping: json.Marshal writes < as its six-character
unicode escape, where the source had one byte. Measured against Postgres 16:

  a one-key object whose value holds the four characters x<y>z&w,
  written with those characters LITERAL            -> 16 bytes
  the same object written with < > and & as their
  six-character JSON unicode escapes instead       -> 16 bytes

Postgres parses the escapes and stores the characters, so the escaped
and literal forms are the same size and the round trip cannot grow the
row. On SQLite the payload is stored exactly as Go wrote it, so
re-marshalling is idempotent. The rejection from round 3 stands, now on
a measurement instead of an assertion about key removal.

But the test defending it was weak, and codex was right about that: its
fixture was one repeated ASCII letter, which cannot tell any of these
encoder paths apart. It now carries <, >, & and non-ASCII, so it
exercises the divergence rather than asserting past it.

Mutation note worth keeping: a whitespace-padding mutant is caught on
SQLite and NOT on Postgres, because jsonb discards insignificant
whitespace — the mutant does not actually grow the stored row there. The
faithful mutant adds a key, and that one dies on both.

FIVE COMMENTS THAT SAID SOMETHING UNTRUE, all introduced by this branch:

- OversizedOutboxPayloadError was documented as the write cap's error;
  three sites raise it, against two different limits.
- "The two things Measured can name" listed three.
- measuredStoredRow said "as the database stored it", but OctetLength
  measures what the driver hands back, which on Postgres is the ::text
  rendering rather than storage.
- OversizedPendingOutbox described its threshold as the write cap while
  querying the claim ceiling.
- ClaimPendingOutboxEvents still said batches are claimed whole, which
  the byte budget and row cap deliberately interrupt.

Tests also now assert Measured at all three refusal sites — without it
the field could be blank everywhere and every existing assertion still
passes — and the claimability property test's refusal branch checks the
row actually rolled back, so "refused" cannot be satisfied by a write
that committed anyway.

Gates: gofmt clean, golangci-lint 0 issues, full SQLite suite exit 0,
full Postgres suite exit 0.

Claude-Session: https://claude.ai/code/session_01XLtX4dbjBpApbAv3SuBcTm
2026-09-02 12:05:57 +00:00