Commit Graph

962 Commits

Author SHA1 Message Date
xarmian 9e4ffe5cee fix(web): a field value whose shape does not match its declared type shows its raw text with a note and is replaced explicitly, never coerced (BUG-3052 unit 2) (#1556) 2026-09-25 23:30:49 -04:00
xarmian 81e58a7ed6 fix(web): a field filter keeps exactly the items its lane holds, so a number under a retyped select or a multi_select value is found (BUG-3052 unit 3) (#1554) 2026-09-25 22:38:29 -04:00
xarmian 96a75e7dfb fix(web): a stored field value String() cannot convert no longer crashes the list, board, table, child items or shared views (BUG-3052 unit 1) (#1553)
An item's field values are JSON of any shape, whatever the declared type. A
stored object like {"toString":0} makes String(v), template interpolation,
new Date(v) and Svelte text conversion throw "Cannot convert object to
primitive value". The census on the BUG-3052 trail found 13 such throw sites.
The worst was laneValue, the helper every grouped view reads lanes through.

This adds the read-side layer the lead ruled for, web/src/lib/fields/fieldShape.ts:
- safeString: exactly String, but total. For a value String cannot convert, it
  falls back to the value's JSON text.
- safeText: null/undefined read as '', otherwise safeString.

Routed through it:
- laneValue, which covers list grouping, board buckets and drops, relation
  lanes and shared-board grouping;
- the TableView sort and plain text cell;
- the ChildItems group key;
- shareView.matchesFilter and the /s/[token] field formatter;
- QuickActionsMenu and quick-action-preview (categoricalTemplateValue, fields,
  plan/phase);
- the playbooks sort and trigger/scope chips;
- three ItemDetail sites;
- ChildChart's parent dates, where only objects are converted and every
  primitive reaches new Date untouched.

Output is byte-identical for every value the old code could convert. Rendering
garbage, type-changing writes and misfilters are units 2 and 3; the server
SQL is split out as BUG-3221.

Tests cover each throw class (String conversion, a string method on a
non-string, new Date, Svelte text), plus the helpers' own contract. With every
changed source file swapped back to origin/main, the new legs fail.
2026-09-25 21:48:05 -04:00
xarmian 9d5abc7bad test(e2e): viewer specs no longer assume the order of two attachments uploaded in the same second (BUG-3210) (#1552)
attachment-surface-chrome.spec.ts:353 failed about 6% of first attempts under
8-worker load, expecting the lightbox counter at '2 / 2' after one ArrowRight
from ns-a. Measured with an instrumented copy: in every failure the counter
already read '2 / 2' BEFORE the key, and the key was claimed and wrapped to
'1 / 2'. The viewer paged correctly. The spec assumed ns-a sorts first, but the
two uploads usually share a second of created_at, and the attachments list's
newest-first sort has no tiebreaker, so their order is the database's tie-break.

- attachment-surface-chrome: assert the shown attachment id (the image src) is
  ns-a before the arrow and ns-b after it, instead of the counter position.
- attachment-viewer-parity, strip surface: open the first tile rather than
  strip-a.png by name, so the matrix's opening '1 / 2' holds by construction.

Population: every e2e counter assertion (10 sites in 4 files). Only these two
tests were exposed. The sheet spec opens the first tile, the lifecycle spec
derives order at runtime, and the other parity surfaces follow content order.

Proof (same box and command, 120 repeats, 8 workers, --retries=0):
:353 went from 7/120 failing on main to 0/120; the parity strip test went from
6/120 to 0/120. A product mutant that makes next() a no-op fails the new
assertion.
2026-09-25 21:48:01 -04:00
xarmian 4d4ab1d69f fix(web): the collection table owns its scroll in table view, so its header row sticks while the rows scroll (BUG-3164) (#1551)
The table's header row is `position: sticky; top: 0`, but its nearest scroll
container is `.table-scroll`, whose `overflow-x: auto` (sideways scrolling for
wide tables) computes to auto vertically too. That box never scrolled
vertically; the page did (`.main-content`, or `.list-column` with the pane
open), so `top: 0` never engaged and the header scrolled away with the rows.

Option A, as ruled on the BUG-3164 trail: in table view the table fills the
list's visible region and owns both scroll axes, the way the board does.

- `.collection-page.table-active` fills `.main-content` (height 100%, flex
  column). `.list-column` stays `overflow-y: auto`, so a table-less state
  (loading, empty, no matches) still scrolls.
- `.table-scroll` is `overflow: auto; flex: 1 1 auto; min-height: 12rem`.
- `listScrollTarget` / `listScroller` return the table's scroller in table view,
  so scroll restoration and the BUG-3165 pane-open anchor handoff act on it.
- Keyboard j/k now scrolls a focused table row into view (it only matched
  `.item-card.focused` before), by exactly its overlap with the sticky header
  or the table's bottom edge.

Tests: web/e2e/bug-3164-table-header-sticky.spec.ts. Its no-pane, pane-open and
wide-table legs fail on main (header at -461 vs the scroller top at 44). Its
keyboard leg runs in its own workspace, because the page resets the cursor
whenever the list changes. The BUG-3165 helpers now scroll the view's real
scroller, and its table leg also covers Expand to full page + Back.

content-visibility was measured, not assumed: 300 rows, rendered after
scrolling 38 then 66 on main and 38 then 63 here.

A switch between table and list now starts the new view at the top, as a
switch to or from board already did.
2026-09-25 20:06:49 -04:00
xarmian 4e2a7cda9f fix(web): a board lane named __proto__ or constructor keeps its own bucket and sort, so one option can no longer un-proxy the whole board (BUG-3208) (#1549)
bucketByColumn built a plain object keyed by schema option values. An option
named __proto__ did not create a lane: the assignment re-parented the object
onto that lane's array, and because BoardView assigns the result to $state,
and Svelte 5 proxies only Object.prototype objects, the whole board lost its
drag reactivity. The per-lane sort overrides had the same shape, wider: a
bare overrides[value] ?? sortMode read the inherited member for every
Object.prototype name, so a lane named constructor or toString ignored the
page sort and lost its reorder menu.

- bucketByColumn returns a Map.
- boardColumns exports laneKey ('lane:' prefix, the BUG-3054 answer), and
  BoardView keys columnData and laneSortOverrides through it.
  Object.create(null) is out because these records are $state.
- ListView and ChildItems import the shared laneKey. Their private copies are
  gone, and laneKeyCallers.test.ts forbids a private copy on all three
  surfaces.

Census of keyed-by-lane-value objects on the board path (full table on the
BUG-3208 trail): two live members fixed (the bucket record and the sort
overrides). Draft maps (draftKey is field-prefixed), relationLaneByValue (a
Map), column colours (hasOwn, BUG-3041), the public board (Map buckets) and
the roles board (keyed by role UUID) are safe.
2026-09-25 18:23:16 -04:00
xarmian 209e18c58b fix(web): the remaining direct fetches run under the request deadline (BUG-3216) (#1543)
BUG-3211 bounded request() and every direct fetch a gate waits on. The rest
were view-only, but a hung one still left a spinner or a disabled button until
navigation: the share view and the share page's password submit
(passwordLoading), adminFetch (admin stats and actions), the invitations
page's delete (actionSaving), the billing plan-limits read, and the attachment
text preview (phase 'loading').

A new withRequestDeadline(fn) runs a fetch and its body reads under the API
deadline and maps a timeout to request_timeout (a write's message says it may
have landed). Every site's budget is the API's 30s. The text preview links it
to its repoint controller, so a repoint stays an AbortError and a timeout lands
in 'error', which offers a retry.

Uploads and exports stay unbounded by design: their time scales with size.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 09:12:59 -04:00
xarmian 38034bb69c fix(web): a lane draft is keyed by the field it was typed under, so a regroup cannot drop it into another field's same-named lane (BUG-3214) (#1542)
Lane drafts were keyed by the lane VALUE alone, and the page does not clear
them on a regroup. BUG-3043 re-homed a draft whose key named no lane of the
new grouping, but when the old key equalled a lane of the new field the
draft showed there unmarked and saved into that field (a draft typed in
status's "open" lane became stage: open, with status left at its default).

Drafts are now keyed by draftKey(groupField, lane). A draft typed under
another field is orphaned whatever its lane is called, and is re-homed or
blocked by the BUG-3043 rules, marked with its field ("Moved from Open
(Status)"). Blocked notices carry the map key, so Discard hits the right
draft.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 08:58:48 -04:00
xarmian 2c8960bd58 fix(web): a request the server never answers times out, so a gate held across it is released instead of held for ever (BUG-3211) (#1541)
request() passed no signal and set no timeout, so a request the server
accepted but never answered (a stalled proxy, a half-open connection after
a laptop sleep, a wedged handler) was awaited indefinitely, and every gate
held across the await held with it: sync's lock, the local index's dedup
promises, the pane's save indicator, the SSE reconnect.

- request(): a 30s deadline per attempt, covering headers and body, linked
  by hand to a caller's signal. It is armed only around the fetch, so the
  429 cooldown and retry sleep never count against it. A timeout is
  PadApiError 'request_timeout'; on a write the message says the change
  may still have been saved, and it is never retried. The value's receipt
  and its unmeasured limits sit beside the constant.
- The direct fetches a gate waits on get the same bound: auth.session()
  (authStore.load dedup), billing checkout (checkoutInProgress), the
  attachment-metadata HEAD (a shared promise cache), probeRefusal (10s;
  reconnectPending), and attachments.transform (its own 120s).
- Uploads and exports stay unbounded by design; the remaining view-only
  direct fetches are BUG-3216.

Every gate in the population releases on a rejection (finally or a
resetting catch), so the timeout reaches it; the table is on the trail.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 07:01:21 -04:00
xarmian 46c91b9062 fix(web): a lane draft whose lane no longer exists moves to Uncategorized, marked, instead of blocking Save all invisibly (BUG-3043) (#1538)
* fix(web): a lane draft whose lane no longer exists moves to Uncategorized, marked, instead of blocking Save all invisibly (BUG-3043)

A lane draft is keyed by the lane it was typed in, and nothing tied that key
to a lane that still exists. Delete or rename the option, retype the group
field, or regroup the board, and the draft's card vanished while its text
stayed in the page's map. "Save all" then either sent the dead lane value
(the server refused it; the dialog could not complete, blocked by a draft the
user could not see) or, where the client refused it first, read the create's
null as success and deleted the text behind a toast.

Lead ruling, RE-HOME: an orphaned draft shows inside the Uncategorized lane,
marked "Moved from <lane>", and saves there. The lane is rendered for it even
when no item is in it. Where Uncategorized cannot receive the create (the
field omits a blank and a default would fill it, or it is required), the draft
is kept, a notice names the lost lane, and Save all is blocked for that
reason. Whether Uncategorized can receive it follows the server's validator
per field type (laneDrafts.ts header).

quickCreateInColumn refuses by throwing, never by returning null, and the
Save-all loop (saveAllDrafts) treats a null with the identity held as a
failure that keeps the text.

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

* test(items): pin the validator facts the lane-draft re-home rule mirrors (BUG-3043)

draftSaveTarget decides client-side whether Uncategorized can receive a
draft's create. It mirrors three facts about ValidateFields: a required
select stores '' (required fires only on an absent or nil key, and ''
skips the options check), a required multi_select stores [], and an
omitted key takes its default or is refused as required. Pinned here so a
validator change fails a Go test instead of leaving the mirror wrong in
silence. Removing the select's '' exemption turns fact 1 red.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 05:53:33 -04:00
xarmian 225205052b test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) (#1536)
* test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213)

settleScale accepted two equal reads of the rendered matrix as "settled".
Under an 8-worker load the renderer can produce no frame between two polls,
so a mid-transition scale read as final: the touch spec's pre-zoom stopped at
1.19-2.20 when it needed more than 2.3, on 11/120 runs on main and on c0819dd4^
alike.

"Settled" now means no live transform CSSTransition on the viewer image,
checked after flushing style. Two animation frames are awaited first: Chromium
delivers touch moves aligned to frames, and without the flush the pinch oracle
read one move step early (exactly 6.25px = 50px/8 in X, 17/120). The old
fixpoint's polling delay had been hiding that.

The zoom spec's private copy of the same fixpoint now uses the shared helper.

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

* test(e2e): settleScale awaits each transform transition's finished in the page instead of polling (BUG-3213)

expect.poll's 100/250/500ms backoff, paid once per zoom step, stretched
the zoom spec's 20-step zoomToMax toward its 30s budget under load. A
cancelled (retargeted) transition rejects finished, so the wait loops
until no transform transition is live.

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

* test(e2e): zoomToMax presses in bursts and settles once per burst, so an honest settle does not stretch its callers toward the timeout (BUG-3213)

Each '+' computes from the viewer's zoom state, so a burst of four lands
exactly four steps; settleScale waits for the transition to end, so a
burst that does not climb is the clamp. Measured x40 at 8 workers
(p50/max): controls-at-max 14.1/15.6 on main -> 9.7/12.9, pan-clamp
17.2/19.6 -> 14.2/18.7, resize-clamp 15.2/19.5 -> 11.6/17.2.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 05:13:44 -04:00
xarmian a814a96b42 fix(api): a workspace that does not resolve answers 404 not_found marked details.scope=workspace, so a client can tell it from a missing item (BUG-3069) (#1534)
* fix(api): a workspace that does not resolve answers 404 not_found marked details.scope=workspace, so a client can tell it from a missing item (BUG-3069)

The workspace-resolution refusal and a missing sub-resource were byte-identical
404 not_found responses that demand opposite reactions. The web client inferred
which from the request path (BUG-2983), which cannot classify item reads and
breaks silently the day a workspace-level endpoint grows its own not_found.

Every workspace-resolution refusal (13 sites) now goes through one helper that
adds details {"scope":"workspace"}; status, code and message are unchanged, so
no consumer keyed on not_found moves (ruled over a new code). The web client
keys its purge seam on the marker and keeps the path rule as the fallback for
older servers. No MCP ToolSurfaceVersion bump: the MCP error surface does not
forward 404 details on either transport.

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

* fix(api): a store error on workspace delete or oauth claim is a 500, not the marked workspace 404 (BUG-3069 review round 1)

Both sites mapped any lookup/delete error onto the workspace-not-found
response, which after the marker tells a client to stop asking about a
workspace that may be perfectly live. Only sql.ErrNoRows (delete) and a nil
workspace (claim) are resolution refusals now.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 03:46:16 -04:00
xarmian 326acd29af fix(web): the item pane's save indicator counts the writes outstanding, so "Saved" never shows over a write still in flight (BUG-3044) (#1532)
* fix(web): the item pane's save indicator counts the writes outstanding, so "Saved" never shows over a write still in flight (BUG-3044)

saveStatus was one value written by whichever write reached it last. An older
write's success showed "Saved" while a newer one was in flight (or after the
newest had failed); one field's failure hid another's write; a previous save's
two-second timer fired mid-request. The pane's SSE gates read that value, so
each also admitted an incoming event while a local write was outstanding.

SaveTracker (web/src/lib/items/saveTracker.svelte.ts) counts outstanding
writes: 'saving' while any is out, 'saved' once the last settles only if the
newest-issued one succeeded, 'idle' otherwise. Starting a write clears the
old timer. A reset at load bumps an epoch, so a previous item's late settle is
ignored. saveStatus is now $derived from it, and the ten SSE-gate reads are
unchanged.

All nine save paths begin a token, record success through showSaved(saveTok),
and settle in a finally, so the returns that used to leave the status alone
(a superseded field write, a superseded content write, the collab flush's
force_refresh skip, an item switch) still count down. Without that, a count
would pin 'saving', and every SSE gate with it, until navigation. The raw
drain uses one token for the whole drain.

Identity-fence gate (lead ruling): each settle after an unfenced await is
tabled per row as an allowed commit whose reason cites the tracker's
stale-epoch test. The analysis is not taught to trust the epoch. Rows whose
coverage changed are re-signed. Every changed coverage run traces to this
diff: 25 rows, 285 token-run edits; the 20 not verbatim in it are boundary
splits of the import, the finally blocks, the removed timer declaration and
the helper body. Fixtures are migrated by the same rename. Kill table, old
fixtures on main against migrated fixtures here: 171 fixtures, 0 verdict
flips.

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

* docs(web): the raw drain's one save token is the truthful status, stated on its own terms (BUG-3044)

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 02:40:34 -04:00
xarmian 53c567dd4a fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207) (#1525)
* fix(web): the sync cursor is only ever a server time taken before the reads it vouches for (BUG-3207)

Three cursor writes used the client's wall clock: the initial value, a failed
seed, and markSynced() after every successful full reload. A client running
ahead of the server by D skipped every change committed in that window, on
the hottest of the three paths. markSynced also stamped at the END of the
reload, which skips a change committed between the reads and the stamp even
with a perfect clock.

- serverClock.ts estimates the server clock from response Date headers,
  projected by monotonic elapsed time, minus 1 s. It errs early only: an
  early cursor re-delivers (a duplicate), a late one skips (a miss).
- stamp() is taken BEFORE a full reload's reads and passed to
  markSynced(stamp). With no response seen yet it asks /changes once.
- A failed seed writes nothing. An unseeded cursor (0) answers full_refresh
  instead of asking since=0.
- The seed's since uses the estimate, so a client running ahead still gets
  the changes between its page load and the seed.
- ItemDetail no longer calls markSynced (lead ruling): a single-item reload
  cannot vouch for the workspace cursor. The layout is the only writer.

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

* test(web): re-sign the ItemDetail fence rows the markSynced removal moved, and state what the duplicate legs measure (BUG-3207)

The AST gate hashes each async unit with its enclosing onMount, so removing
the sync callback's markSynced call moved both the SSE and the sync row. Both
were re-read against the new code: the SSE callback is untouched, and the
sync change removes one synchronous call after the existing fence, adding no
await and no commit. Both rows hold, and their hashes are updated.

The reconcile-duplicate legs now say what mutants measured: the same-row
duplicate is idempotent by content (no guard mutant can redden it), and the
late older copy is held by the cursor-floor and existing-seq guards together.

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

* fix(web): the server-clock estimate is a whole millisecond, so /changes accepts the cursor it stamps (BUG-3207)

performance.now() is fractional, and handleGetChanges parses `since` with
ParseInt. A stamp taken from the estimate became a fractional cursor, every
/changes asked from it answered 400, and each sync degraded to a full reload
for the rest of the tab. Measured on the e2e trail: 11 of 11 first-attempt
failures of bug-2539-sync-window sent a fractional since after the archive,
0 of 49 passes did.

Floor rather than round, so the estimate still never runs ahead. The cursor
model's /changes mock now refuses a non-integer since the way the server does.

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

* fix(web): a sync signal that beats the seed waits for it instead of reloading the workspace (BUG-3207)

An unseeded cursor answers full_refresh, and the SSE connect and the first
events routinely arrive while the seed request is still out. Each of those
signals cost a whole-workspace reconcile where main asked one incremental
/changes: 77 of 160 instrumented page loads under load. A signal reaching an
unseeded cursor now awaits the in-flight seed and asks from its server_time,
which is taken before the seed's reads, so the pass covers everything the
seed's own delta did not. A tab whose seed failed still answers full_refresh.

bug-2539-sync-window: the oracle now awaits every /changes issued after the
archive before reading the record. One issued after the archive but not yet
answered at check time read as "nothing carried it".

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

* fix(web): an early sync signal waits for the seed at most SEED_WAIT_MS, and a failed seed still ends in full_refresh (BUG-3207)

The API client has no request timeout, so an unbounded wait on a seed that
never answers held the signal, and the sync lock with it, forever. The wait is
bounded at 5 s against a measured seed latency of p50 160 ms / max 874 ms at 8
workers. Two legs pin the lead's question: a FAILED seed and a HUNG seed each
leave the awaited signal answering full_refresh, never dropped.

bug-2539-sync-window: a non-seed /changes that carried the deletion passes
whenever the client sent it. Client send time was the wrong test: a response
that carries the deletion was read after the archive committed. Negative
control: with ItemsModifiedSince comparing deleted_at with > instead of >=,
20 of 20 same-second runs fail.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-25 01:24:09 -04:00
xarmian 7e2545a0d3 fix(web): two quick clicks on a checkbox field are two toggles, not one lost (BUG-3047) (#1526)
The toggle sent !value from the prop, which lags every write until its round
trip lands, so two quick clicks both sent the same value and the field ended
where it started. It now toggles the newest value it sent for this item while
that write is outstanding, and the switch shows it at once, through the same
record and display hold the number step uses (sendTyped / typedDisplay). So
it inherits their release paths: its echo coming home, the write settling (a
refused toggle returns to the stored value), a subject swap, unmount.

The number step half of the filing was already fixed on main by BUG-3039
(it steps from what was last sent); a leg pins it.

Tests: two toggles send true then false; the switch shows each click before
the prop catches up; an older echo does not flip it back; a refused write
releases it; a toggle outstanding for another item is not a base. Four
mutants (base from prop, display from prop, no display hold, no item check)
all killed.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 23:15:12 -04:00
xarmian c9aae56463 fix(web): a queued open-children confirmation that is no longer live is never shown (BUG-3046) (#1528)
A request queued behind another open-children prompt was shown after it even
when its reason had gone (the pane switched items, or a newer edit superseded
the one that raised it), asking the user to decide something no longer live.

The requester now supplies an isLive predicate (only it knows its pane
identity and write order), which the store asks when a queued entry is about
to be SHOWN. A dead one resolves false, the cancel answer, and the queue moves
on to the next live entry. ItemDetail's field write passes stillCurrent() and
supersession, the move passes stillOnSource, and the collection page passes
its identity fence.

Tests: the store skips dead entries and asks at show time; the helper hands
the predicate through (a dead prompt never runs the forced retry); a source
guard pins that every call site passes one. The two ItemDetail fence rows the
change moved (updateField, handleMove) were re-read and re-signed.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 22:10:07 -04:00
xarmian a2917bf97a fix(web): a collection named "Constructor" no longer crashes the command palette, and no other value naming an Object.prototype member crashes a view or loses its lane (BUG-3054) (#1527)
* fix(web): a value that names an Object.prototype member no longer crashes a view or loses its lane (BUG-3054)

Plain objects keyed by user data (a group value, a status, a collection slug,
a file extension) answered an INHERITED member for keys like constructor,
toString or __proto__: a push onto a function threw, __proto__ re-parented
the object, and ?? fallbacks passed the truthy member through.

Swept by construction (population table on the BUG-3054 trail):
- ListView and ChildItems lane buckets: prefixed keys (lane:<value>). They
  stay plain objects because they are $state, and Svelte proxies only objects
  whose prototype is Object.prototype.
- graph palette (the page now uses the shared assigner instead of its private
  copy), CommandPalette grouping (extracted to groupResults.ts), and
  EditCollectionModal option renames: null-prototype objects.
- literal lookups keyed by user data (EmptyState copy, the collection page's
  empty hint, attachment extension families, the playbook status order, the
  library enforcement colour): the new ownValue helper.
- the collection page's itemCounts was dead code (never read) and is deleted.

Residual, recorded: boardColumns.bucketByColumn with a schema option literally
named __proto__.

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

* test(web): index the constructor group with a string-typed key, since TypeScript types .constructor as Function (BUG-3054)

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 21:36:10 -04:00
xarmian a2e08bfe5a test(web): the ItemGraph mount helper waits for readiness by time, not by poll count (BUG-3203) (#1524)
* test(web): the ItemGraph mount helper waits for readiness by time, not by poll count (BUG-3203)

The helper gave the graph 50 polls of setTimeout(5), about 250 ms. The mount
chain includes a dynamic import('@dagrejs/dagre'), which is IO-bound, so under
full-suite load the timers keep firing while it waits and the budget ran out
before the first node rendered (seen once in make web-test, at mount :163).

Measured: every mount is ready on the first poll (72-76 ms isolated for the
file's first test, 150 ms under the full suite). Control: a 400 ms async step
in the chain failed all 8 tests at :163 with the old helper and passes with
the new one; a 4 s step still fails loudly at the node assertion rather than
timing out. Siblings with the same loop (console shell, TopBar) wait on a
mocked probe and rAF, not IO, and are left alone.

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

* test(web): the ItemGraph mount budget carries its measured receipt (BUG-3203)

Lead condition on the GO: state the budget and why it is enough, so it does
not drift upward. 3 s against a worst measured mount of 150 ms; a planted
400 ms step fails the old budget 8/8 and passes this one.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 21:34:11 -04:00
xarmian 5742393520 fix(web): a pending lightbox delete confirmation owns the pointer gestures, as it already owned the keyboard (BUG-2522) (#1522)
* fix(web): a pending lightbox delete confirmation owns the pointer gestures, as it already owned the keyboard (BUG-2522)

While the toolbar's delete confirmation was pending, onKeydown returned for
every key, but wheel zoom, double-click, mouse/pen pan, touch pan, pinch and
double-tap all stayed live on the stage behind it. Each gesture START path now
stands down while deleteConfirm.pending (the wheel is still consumed, so the
page behind cannot scroll), and the reactive gesture teardown also fires when
the confirmation opens, so a pan already held when another finger taps Delete
stops there.

Tests: one leg per start path plus the already-live pan, each red on the old
code, each with a cancel-then-same-gesture control.

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

* test(web): a mouse pan live when the delete confirmation opens is torn down (BUG-2522)

The touch straddle leg could not see the confirmation's own teardown trigger,
because the paint-arm leg of the same effect already tears a touch gesture
down (a mutant removing the trigger survived it). This leg kills that mutant.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 20:38:44 -04:00
xarmian aa43128917 test(e2e): a stuck list cursor in the attachment-viewer owner-2 spec names the guard that dropped the key (BUG-3204) (#1523)
The spec failed once in CI (run 36036738521) with the cursor stuck through
both j and k, kept no trace, and has not reproduced locally (0/50, including
6x CPU throttle). The collection route drops j/k for a still-held viewer
lease or native modal (a product fault) OR for focus inside the pane, an
editable or a menu (a harness fault), and the log cannot tell them apart.

expectCursorMoves now records, at each press, where focus is, whether it sits
where the route bails, the viewer roots and native modals isBlockedByModal
reads, and the inert body children, and attaches them to the failure. A
negative control (focus planted in the pane) produced activeInPane: true.
No fix is chosen until an occurrence carries this evidence.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 20:22:15 -04:00
xarmian c0819dd48c fix(web): a wheel over a lightbox nav arrow or the close button no longer zooms the image (BUG-2507) (#1521)
The wheel handler excluded only the toolbar and meta, while pointerdown and
double-click share chromeExcluded, which also names close, nav, retry and
tap-load. Close and nav are on screen with a bitmap present, so a wheel over
either zoomed the image under the cursor. The wheel now calls chromeExcluded,
so the three gestures share one list and cannot drift apart again.

Tests: a wheel-inert leg for prev, next and close, each red on the old code
(scale 1 -> 1.25) with a same-wheel-over-the-image control that zooms.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 19:59:20 -04:00
xarmian be37c14c4a fix(web): the workspace sync seed delivers the changes it returns instead of dropping them (BUG-3201) (#1520)
syncService.setWorkspace seeds its time cursor from `/changes?since=<now>`
and kept only server_time. That response holds the changes committed between
the request's `since` and the server's clock, and the cursor moves past them,
so no later sync can return them. Measured with a network-held seed: a child
renamed inside that window stayed stale in its parent's children panel, 3/3,
even across a tab-resume (the resume answered caught_up, and ChildItems
reloads only on a result that is not caught_up), while the same rename after
the seed healed on the resume, 3/3. The collection list was never stale: it
renders from the seq-based local index, which the resume also drains.

A non-empty seed delta is now delivered as an incremental result for its
workspace; an empty one sends nothing. The cursor is written only by the
newest seed (a generation counter, so A, then B, then A cannot let the first
A's late clock win), while a seed's delta is delivered whenever its workspace
is still the current one, including when a later seed for the same slug
superseded it (codex round 1: dropping that delta was the same loss, one call
removed). A superseded failure no longer writes the client-clock fallback.

Residual, unchanged: when the NEWEST seed fails, the cursor still falls back
to the client clock, which skips changes if that clock runs ahead of the
server's.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 17:52:32 -04:00
xarmian 271174f2a5 fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761) (#1517)
* fix(web): a server sync_required is answered after a per-tab random delay, so a fleet-wide coverage drop does not land every tab's resync on the database at once (BUG-2761)

Item-change reconciles (items_bulk_updated, a collection change that
rewrote items) and the first-connect sync stay immediate, per the lead's
ruling on the item's trail.

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

* test(BUG-2761): pin the fold at the first signal's delay, and read needsSync after clearing the connect's own flag

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

* fix(BUG-2761): relay the spread as a flag on the existing envelope, so an older-bundle peer answers at once instead of dropping the signal (codex round 1)

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

* docs(BUG-2761): onSyncRequired describes the mid-stream signal this client actually receives (codex round 2)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-24 17:00:59 -04:00
xarmian 1169a05929 fix(editor): opening an item without typing no longer rewrites a body the editor does not reproduce byte for byte (BUG-3197) (#1515)
The collab flush dedupes a no-edit view by comparing the editor's markdown
with the seed it was loaded from. The editor does not reproduce every stored
dialect exactly (`* a` comes back `- a`, a setext heading comes back ATX, a
leading rule gains a blank line, a bare URL is autolinked to `<url>`, a body
ending in a table gains a newline), so for those bodies the compare missed and
merely opening the item PATCHed the normalised body and wrote a version row. A
census of a copy of the live DB found 7,119 of 9,002 non-empty bodies are not
fixed points of the round trip.

The editor-space dedupe gains a second arm: the editor's own serialization of
the seed. It replays setContent on a DETACHED state (the live editor's parser
and schema, then one replace-document transaction applied to a state carrying
the live editor's plugins minus the Yjs ones and filterInvalidContent, so
appendTransaction plugins run as they do for real), and the live document is
never touched. Parse-only diverged from the real path on 28 of 9,002 live
bodies; this diverges on 0. A transaction a filter refuses answers null
rather than the empty document, so a whole-body delete can never be deduped
against it. The result is memoized on the flush context and primed while the
editor is alive, because the teardown flush runs after the child editor is
destroyed; any failure falls back to the old compare. Revert-safety is
unchanged: the arm applies only before the session's first flush.

A real edit still writes, and may normalise the whole body; that is accepted.

The collab-watermark proof's prose is widened to the canonical case: the stamp
still sends the stored body's hash, and in both cases the proof is about
markdown.

Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ
2026-09-24 16:42:14 -04:00
xarmian a231d0b65a fix(BUG-2367): a move or copy that would reopen, close or un-abandon an item must name the status instead of inheriting whatever survived migration (#1516)
Before this, every migrate door carried whichever done-field value survived
migration, or injected the destination default. A done task moved into
ideas silently reopened as `new`, and an abandoned item could land counted
as shipped.

Now every door (single move, bulk move, cross-workspace copy and its
preflight) compares the item's CloseState (open / done / abandoned, derived
from IsTerminalItem + IsAbandonedItem) before and after. A change is
refused unless the caller names the destination done field. Only the state
is compared, so two different values that both mean done carry without
asking. A destination with no done select field is exempt.

- move: 400 state_change_requires_value, details {field, options, from, to}
- bulk move: the same refusal in failed[]; `move` + `status` counts as supplied
- copy: 400 validation_error with the same sentence, after the unique check,
  in the preflight's order
- preflight: a needs_value row with reason `state_change`, which the web
  dialog's existing picker renders
- CLI: `hint: pad item move REF COLL --field status=<a|b|c>`

ToolSurfaceVersion 0.52 (behaviour bump). Missing needs_value routes on the
pane-menu move, CLI move and MCP are BUG-3200.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 15:53:06 -04:00
xarmian fd95b79694 fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door
(single move, bulk move, cross-workspace copy and its preflight):

- a value was carried into a destination field marked `computed`. It is
  now dropped, with preflight reason `target_computed`.
- a value colliding on a destination `unique_scope` field answered 500
  on single move (invocation_slug, which has an index), leaked the SQL
  error through bulk move, and stored a duplicate on an unindexed unique
  field. Provenance decides now, as TASK-2878 did for relation referents:
  a CARRIED collision is dropped and reported as `not_unique`, and an
  override or schema default that collides is refused with 409 conflict.
  A required field emptied by the drop is refused as required.

One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ)
serves all four doors, including the tx-bound copy. The report names the
value, and names the item holding it only when the caller may see that
item. The activity row always uses the holder-free sentence.

Reporting, all additive and omitempty: warnings.not_unique on the move
response, the copy response and bulk updated[] rows; `detail` on the
preflight's dropped row; CLI copy dry run and result, plus `  dropped:`
lines on `item move` stdout; web copy dialog, move and copy toasts, and
the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump).

The relation leg of the original report was already closed by TASK-2878.
Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed
separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64
precision; the migrateCopyFields comment now points there).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 13:12:32 -04:00
xarmian a1b4c4c867 fix(editor): an upload finishing while the master is frozen is inserted on thaw, and interrupted image edits and source refreshes say so (BUG-2177) (#1511)
When a detail pane opens, the master editor is frozen in place. An
attachment upload that finished during the freeze used to drop its result:
the bytes were stored and nothing referenced them, with no notice. A rotate,
crop or source refresh finishing then was dropped the same way.

The upload is now deferred: its result is parked on the upload plugin's
placeholder entry, which is mapped through every transaction, and the plugin
view inserts it once the editor is editable again. A placeholder whose
position was deleted outright (its content removed, or the document
replaced) is never inserted at the deletion boundary; the user is told the
file is stored but not inserted, as they are when the editor is destroyed
first. A failure while frozen is reported, since every entry point refuses
to start an upload on a read-only view.

Rotate, crop and source refresh are not replayed on thaw (applying them
minutes later would change the document while the user is not looking);
the user is told they were interrupted.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 11:50:46 -04:00
xarmian e4224da26b fix(progress): an abandoned child leaves both the done count and the total, and 0/0 renders as no progress (BUG-3195) (#1510)
* fix(progress): an abandoned child leaves both the done count and the total, server side (BUG-3195)

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

* test(progress): every progress door leaves abandoned children out, member and guest, through the router (BUG-3195)

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

* test(store): done-field progress tests expect abandoned children out of both counts (BUG-3195)

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

* fix(progress): the web's per-row child counts leave abandoned children out, and 0/0 renders as no progress (BUG-3195)

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

* test(progress): e2e for a cancelled child left out of the parent's progress; the redundant TableView guard is reverted (BUG-3195)

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

* test(progress): the web's abandoned resolver and its Go/TS negative-terminal parity are pinned (BUG-3195)

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

* fix(progress): the web's per-row counts judge each child by its own collection's done field, case-insensitively, as the server does (BUG-3195)

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

* fix(progress): the web resolvers fall back on odd stored JSON, and per-row counts ignore a collection store stamped for another workspace (BUG-3195)

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

* fix(progress): the web done-field resolvers validate stored schema fields in one place and fall back as Go does on a mistyped schema (BUG-3195)

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

* docs(progress): the schema validator's comment states its parity limit on corrupt data (BUG-3195)

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

* test(progress): one shared vector pins the Go and web abandoned-child rules, including the corrupt-schema divergence (BUG-3195)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 11:24:33 -04:00
xarmian 0362057861 fix(web): an item change delivered while the item page is still loading is recorded and re-read, not dropped (BUG-3198) (#1513)
ItemDetail's loadData installs `item` only after the item read, the
collection read and the index bootstrap all resolve, and the bootstrap is
the slow leg. A change could land after the item read and be delivered
(a sync result, an SSE item event, a collection migration) before `item`
existed. Every handler returned early without an item, so the change was
dropped and the pre-change row stayed on screen: an item archived in that
window never showed its Archived banner.

BUG-3192 Unit A (#1502) exposed it. The route effect used to re-run loadData
on each bootstrapState write, and the rerun after the index landed read the
item again, which covered the drop. One load per page is correct; the drop
was not.

The handlers now record what they would have applied (item ids, a full
refresh, or a migrated collection) while no item of the requested ref is
installed, and loadData re-reads the item once if the record names the item
it installed. That read is issued after the change, so no ask is answered by
an older read. runOwedRefresh, which does the re-read, now also stops if the
pane is destroyed mid-read.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 11:08:34 -04:00
xarmian 7fcbcaabcf test(web): a superseded collections load does not commit, tested through collectionStore (BUG-2144) (#1509)
loadCollections already drops a response a newer load superseded
(collectionsFlight's isLatest), which is BUG-2144's fix, but nothing tested that
binding: with the drop removed, every store test still passed. The existing
superseded-load leg resolves every request with the same list, so it could not
see which one committed. This one answers each workspace with its own list and
resolves them out of order.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 10:16:11 -04:00
xarmian f7d4b61620 fix(editor): a leading YAML frontmatter block survives the markdown round trip instead of flattening into a heading (BUG-2692) (#1508)
The editor parsed `---\ntitle: x\ndate: y\n---` as a horizontal rule followed
by a setext heading, and serialized it back as `---\n\n## title: x date: y`,
a fixed point. Four blog posts are stored that way, and the blog-publish
contract reads the block.

A markdown-it block rule now turns a strictly recognised leading block into
the existing codeBlock node with language `frontmatter`: no new node type, so
the persisted Y.Doc shape and SCHEMA_VERSION are unchanged. It fires only for
a `---` at byte 0, a later `---` line, and only key: value / key: lines,
indented continuations or blank lines between, so a body that opens with a
horizontal rule stays a rule. The block serializes back as `---` only when it
is the first block and still passes the same check; otherwise as a
frontmatter fence, so an edit never re-flattens it. Every other language keeps
tiptap-markdown's default codeBlock spec.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 09:40:52 -04:00
xarmian 6862c6fc5c fix(BUG-3192): a parent's progress is the server's, and a tab loaded hidden defers its non-essential panels until shown (#1505)
Two parts, per the lead's ruling on Unit B.

1. Progress has one writer. ItemDetail recomputed computedOverrides.progress
   from ChildItems' children list, racing the load's GET /progress, and the
   two disagree. Measured on a private instance: children in a collection
   whose done field is `stage` (board_group_by), one shipped and one not,
   gave server 2/1/50% and client 2/0/0%, because the client read `status`.
   Two of three plain tasks done gave 66% vs 67%. Whichever landed last won.
   The client no longer computes it. A change in the children (ids or
   statuses) within a load re-reads /progress, and the load's first report
   does not, since the load already asked; if the load's own read failed,
   that first report re-asks once. /progress reads are ordered by a ticket
   and high-water mark, so an older answer landing last never wins.
   ChildItems' header takes the same numbers through a new `progress` prop,
   with NO local fallback: without them it shows the child count alone and
   no bar.

2. A tab loaded hidden (session restore, middle-click, several at once)
   mounts decisions, attachments, the comments timeline, children,
   backlinks and relation backlinks only when it is first shown.
   visibility.seenVisible flips once and never back, so nothing is ever
   unmounted, and PLAN-2290's never-unmount rule stands. A visible load is
   unchanged.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 07:51:43 -04:00
xarmian 019f8c984e fix(BUG-3192): a 429 on a GET is retried up to 3 times, each no sooner than Retry-After and jittered (#1503)
Two tabs opened together each spend an item page's worth of requests from
one per-user burst. The refused GETs all carry the same Retry-After, so the
client's single fixed retry sent them back together, the herd was refused
again, and the tab showed errors it never recovered from without a reload.

GET/HEAD now retry up to MAX_RATE_LIMIT_RETRIES (3) times. Each delay is
drawn uniformly from [floor, min(2 x floor, 5 s)]. The floor is the
UNCLAMPED Retry-After, or 750 ms doubled per attempt with no header, so a
retry is never earlier than the server asked. A floor above the 5 s cap is
not clamped down, which would retry early into a bucket that said wait:
that request surfaces rate_limited at once. Writes still never auto-retry,
and the global cooldown is unchanged.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 06:52:13 -04:00
xarmian 25a6d17088 fix(BUG-3192): one item-page load is one loadData, and a reconcile ask is answered by one read issued after it (#1502)
A cold item page sent 32-33 API requests (measured). Two defects accounted
for six of them:

- The load $effect ran loadData() in its tracking scope, so the effect
  depended on everything loadData read before its first await. That
  included localIndex.bootstrap()'s bootstrapState, which bootstrap
  writes twice ('cold' -> 'loading', then 'loading' -> 'ready'), so the
  item and its collection were fetched three times. The effect now reads
  wsSlug/collSlug/itemSlug explicitly and runs loadData under untrack.

- localIndex.reconcile started its own /items-changes read per ask, even
  while the bootstrap's /items-index snapshot was in flight at cursor 0.
  The layout's first sync result sent since=0, a full delta duplicating
  the snapshot. Each ask is now answered by ONE read issued after it:
  a bootstrap still hydrating, or an already-queued pass, is joined. A
  read already in flight gets one shared pass queued behind it, reading
  from the snapshot's cursor. The wait is bounded at 15 s, and reset()
  releases the queue.

Measured with the checkpoint-1 fetch instrument on a cold task page:
origin/main 32, 32, 33 requests; this branch 26, 26, 27.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 06:34:01 -04:00
xarmian c50f0585ad fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap, and the editor jitters its reconnects (BUG-1308) (#1499)
* fix(collab): WebSocket dials get their own per-user bucket and a per-user socket cap; the web provider jitters its reconnects (BUG-1308)

A collab dial drew on the general per-user API bucket (burst 60), so 100
dials from one user got 60 through, and a user's own dials and REST
calls refused each other. Dials now draw on a CollabDial bucket (5/s,
burst 50), and how many sockets a principal may hold is bounded
separately by PAD_COLLAB_MAX_PER_USER (default 50), refused with 429
collab_limit_exceeded and Retry-After. Both defaults are sized from the
reconnect-storm measurement on BUG-1308's trail (checkpoint 2).

A browser cannot read a refused handshake's status, so the provider's
backoff is what spaces a refused herd out: each step is now jittered to
between half and all of itself.

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

* docs(collab): PAD_COLLAB_MAX_PER_USER and the collab dial bucket, in the deployment docs, samples and load-test usage (BUG-1308)

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

* fix(collab): only a WebSocket upgrade is a dial; per-user cap pinned within one workspace; dial-bucket keying stated correctly (BUG-1308, codex round 1)

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

* docs(collab): a PAD_COLLAB_MAX_PER_USER below 1 means unlimited, as the SSE knobs do (BUG-1308, codex round 2)

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

* docs(collab): the struct field agrees, 0 or less is unlimited (BUG-1308, codex round 3)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 04:51:57 -04:00
xarmian 4eb5b9ef19 test(BUG-3152): bug-2602 waits for its event stream; the collab editor first mount gets a 20s budget (#1494)
* test(BUG-3152): bug-2602's move waits until the page's event stream is subscribed

The move is observable only through this page's event stream. A fresh
EventSource has no Last-Event-ID, so a move committed before the page
subscribed was never delivered, and the refetch oracle timed out at 15s.
Under full-suite load the stream's headers took 532ms, and the move landed
inside that window (full run 3 on c49ee768).

Deterministic: with /events held 1.5s by route interception, the old spec
failed 3/3 with the identical timeout; with this wait it passes 3/3 under
the same delay, and 3/3 unmodified.

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

* test(BUG-3152): the collab editor's first mount waits EDITOR_MOUNT_TIMEOUT (20s), not the 5s default

version-restore-collab:209 (2/8) and collab-persistence:41 (1/8) failed in
local full runs at the first editor-visible wait after a goto or a reload:
one request in the mount chain stalled 1.4-2.2s under box contention (load
17-23 on 8 cores), and the socket was still connecting at 5s.

Measured as NOT a product defect: in isolation, under 40 concurrent writers
(94s of cumulative pool waits), the same reads stayed under 90ms. So mount
latency under load is not what these specs test.

expectEditorMounted() in collab-helpers waits the suite's existing
SYNC_TIMEOUT figure (20s) for the FIRST mount only. The nine default-timeout
first-mount waits (the population: every editor-visible expect in web/e2e)
use it. No content or ordering assertion changed.

Deterministic: with collections/docs held 6s, the old wait failed 2/2 at the
mount step; with the helper it passed 2/2.

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

* test(BUG-3152): SYNC_TIMEOUT has one definition, in collab-helpers; EDITOR_MOUNT_TIMEOUT is it

Six specs each declared const SYNC_TIMEOUT = 20_000. The value is
unchanged; they now import it, and the first-mount budget is defined as
that constant rather than a repeated 20_000 (lead ruling on #1494).

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

* test(BUG-3152): bug-2602's comment says why the oracle needs the subscription, not that the change is lost

A move before the subscription still reaches the pane, through the connect
catch-up (adopted from the /changes payload, no item GET); only this spec's
refetch oracle misses it. The earlier comment claimed the change was never
delivered, which a probe refuted (BUG-3187, closed).

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 03:28:59 -04:00
xarmian c49ee768c9 fix(BUG-3112): a mermaid diagram rendered in dark mode prints light (#1489)
* fix(BUG-3112): a mermaid diagram rendered in dark mode prints light

Printing always uses the light palette (app.css pins it for every
theme), but mermaid bakes its palette into the SVG at render time, so a
diagram drawn in dark mode printed dark on white. Printing an item is
browser-native and beforeprint is synchronous, so a re-render cannot
land in the printout; the flip is CSS, applied at print paint.

Each diagram now records the theme it was rendered on
(data-mermaid-theme), and an @media print rule applies
filter: invert(1) hue-rotate(180deg) to dark-rendered diagrams only.
It is an approximation, ruled on the trail: mermaid's dark palette
inverted, with author classDef colours keeping roughly their hue and
flipping lightness. The exact alternative (a light twin rendered per
diagram) was declined as doubling mermaid work for every dark-mode
reader.

e2e (print emulation, painted pixel inside a node): dark mode is dark on
screen and light in print; light mode stays light in print and is not
inverted.

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

* test(BUG-3112): sample the node's shape fill, not the g.node box that holds the label (codex round 1)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 00:25:03 -04:00
xarmian 001d2438a9 fix(BUG-3013): a relation id that does not resolve is "Unavailable item", never a claim that its target is gone (#1487)
* fix(BUG-3013): a relation id that does not resolve is "Unavailable item", never a claim that its target is gone

A restricted member's local index leaves out items in collections they
cannot see (the visibility filter in handlers_changes.go), so a live
target misses exactly like a dangling id. Every surface rendered that
miss as "Unresolved reference", with the hover text "This value does not
match any item in this workspace.", which asserts non-existence. The
server's not_found collapse exists to withhold that assertion.

The label and hover text are now one pair of shared constants in
relationGroups.ts (UNRESOLVED_LABEL / UNRESOLVED_TITLE). They are used by
the FieldEditor chip, the table cell and the activity value, and by the
board/list lanes and the filter chip through relationChipFor.
"(deleted)" is unchanged: it is shown only when the index holds the row
and the row carries deleted_at, which is positive evidence. BUG-3014's
text-value branch in FieldEditor is unchanged.

A shared test helper (src/test/existenceClaim.ts) checks rendered text
AND title/aria-label, root included, against the claim words. Guards
cover the constants and the three render sites.

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

* test(BUG-3013): the claim pattern covers the curly apostrophe and 'point at nothing', and tests itself (codex round 1)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 00:20:20 -04:00
xarmian a39d83b405 fix(BUG-2992): a failed links refresh schedules its own retry, because the sync cursor cannot carry it (#1488)
A failed links refresh keeps the last-good rows (BUG-2871) and the
full-refresh path still advances the sync cursor, so a link added or
removed elsewhere, which emits no item event, could stay stale
indefinitely.

Withholding the pane's markSynced() (the filed preference) cannot fix
it: markSynced is one shared lastSyncTime = Date.now(), and the
workspace layout advances it on its own after every clean reconcile.
So the pane remembers the failure itself.

- web/src/lib/items/linksRetry.ts: one pending target per pane, retried
  at 2s / 10s / 60s, restarted by kick() or a new failure, cleared by a
  success, cancelled on destroy.
- ItemDetail: the preserving helper and the initial load record
  failure / success; every sync result kicks the retry before the
  caught_up return (a gap holding only link changes reports caught_up);
  retryLinks is the attempt. It commits only if nothing wrote the links
  since (itemGen, which the identity listener's loadData bumps, and the
  itemLinks identity), and a newer write answers "not fresh" so the
  retry is kept rather than dropped.
- The identity-fence gate tables retryLinks and allows the helper's two
  record calls; the rows whose hash moved were re-read and hold.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 00:05:38 -04:00
xarmian 71de56f6de fix(relations): a relation value stored as text reads as text, not as an unavailable target (BUG-3014, ToolSurfaceVersion 0.50) (#1484)
* fix(relations): a relation value stored as text reads as text, not as an unavailable target (BUG-3014, ToolSurfaceVersion 0.50)

HydrateRelationTargetsQ treated every stored relation value as an id. A value
that is not UUID-shaped (a title a bundle import carries verbatim, which is that
door's contract; or legacy free text) resolved to nothing and hydrated as an
id-only entry, which means "the target is gone or not visible". So an imported
workspace showed relations as broken when the title might name a live item.

Such an entry now carries an additive, omitempty `stored_as_text: true`. The
predicate is syntactic ("not UUID-shaped"), decided from the stored bytes alone
and never from a lookup, so the flag discloses nothing about any item, and it is
deliberately not coupled to the resolver's ladder (lead ruling). A UUID-shaped
value, resolved or not, marshals byte-identically to 0.49, and a UUID that fails
to resolve is never marked.

The CLI renders `"<text>" (text, not a reference)`. The web chip, which resolves
locally from its index rather than from relation_targets, uses the same
syntactic predicate and says the same. The MCP instructions tell agents to
render a marked entry as text.

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

* test(relations): one shared vector table pins the Go and web stored_as_text predicates together (BUG-3014)

The web carries its own copy of the not-UUID predicate, because it resolves
relation chips from its local index rather than from relation_targets. Two
implementations of one contract rule drift. Both now read
internal/store/testdata/relation_stored_as_text_cases.json (21 cases: upper
and mixed case, braces, no dashes, URN form, wrong dash positions, length off
by one, titles, refs, blanks, and padding), and their KEEP IN SYNC comments
point at each other.

The table found a real divergence: the web trimmed with String.prototype.trim,
which disagrees with Go's strings.TrimSpace on U+0085 (Go only) and U+FEFF (JS
only). The web now trims with serverTrimmedTitle, the Go-parity helper the title
limit already uses. Swapping JS trim back in fails exactly those two vectors,
and dropping Go's TrimSpace fails the five padded ones.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 22:50:47 -04:00
xarmian 536e90cc58 fix(BUG-3181): the activity page brings the workspace index up through the collection page's own sequence, now shared; nothing else calls localIndex.bootstrap directly (#1486)
* fix(BUG-3181): the workspace activity page brings the local index up through the collection page's own sequence, now the shared enterWorkspaceIndex; nothing else calls localIndex.bootstrap directly

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

* test(BUG-3181): the activity-page test stubs enterWorkspaceIndex (the real one never settles against its API mock) and checks the page enters its workspace's index

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

* test(BUG-3181): the sole-caller scan counts REFERENCES (alias, destructure, ?., bracket) with comments stripped, and its own spellings are pinned (codex r1)

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

* test(BUG-3181): the scan follows a renamed import of localIndex

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 22:47:12 -04:00
xarmian 0b2a8d9e9b fix(BUG-2858): the date picker opens from a focused input, so Safari can close it on macOS and show it at all on iOS (#1480)
* fix(BUG-2858): the date picker opens from a focused input, so Safari can dismiss it (macOS) and present it at all (iOS)

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

* test(BUG-2858): Chromium e2e — the picker's input holds focus, and Escape there leaves the split pane open

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

* docs(BUG-2858): the Escape handler's comment names what actually keeps the pane open (the hosts' text-entry guard), per the e2e mutant

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 20:40:50 -04:00
xarmian d06e9f2b62 fix(BUG-2872): an activity change on a relation field shows the target (REF · title), never the item ID it stores (#1479)
* fix(BUG-2872): an activity change on a relation field renders the target (REF · title / deleted / unresolved), never the item ID it stores

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

* fix(BUG-2872): timeline leaves import no stores; the owning page hands down a ChangeContext (the collection store's module-scope identity listener broke seven timeline suites)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 19:52:45 -04:00
xarmian ca004cf221 test(BUG-3166): the drill-after-Back race legs time the drill in the page from the settle's arming; a drill after the window mints the popped item first (#1476)
* test(BUG-3166): drill-after-Back legs time the drill in the page from the settle's arming; add the after-window leg

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

* test(BUG-3166): the settle helper latches only a 140ms timer armed inside the Back's popstate dispatch (codex r1: the pane-follow debounce is also 140ms)

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

* test(BUG-3166): the settle helper clears its safety timer and popstate listener on either outcome (codex r2)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 19:00:57 -04:00
xarmian e346b858da fix(BUG-3165): the collection list keeps its position when the split pane opens and closes, and on Back from Expand to full page (#1475)
* fix(BUG-3165): the collection list keeps its position when the split pane opens, closes, and on Back from Expand to full page

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

* test(BUG-3165): reflowing rows leg (the clicked row, not the top row, holds) + handoff unit tests

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

* test(BUG-3165): skipNextRestore unit tests

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

* test(BUG-3165): name listColumnEl as a DOM binding in the identity-fence guard

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

* fix(BUG-3165): the pane popstate skips the snapshot restore only when there is a position to keep (codex r1)

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

* test(BUG-3165): reflow leg uses 250-character titles, so rows grow in the column whatever the font (CI's fonts wrapped the shorter title in both layouts)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-23 18:29:25 -04:00
xarmian 9b8f72d202 fix(insights): a default report leaves out system collections (Conventions, Playbooks), so they stop counting in Insights (BUG-2410, ToolSurfaceVersion 0.49) (#1471)
* fix(BUG-2410): a default report leaves out system collections; Insights' picker stops offering them; ToolSurfaceVersion 0.49

* fix(BUG-2410): a saved layout naming a system collection no longer filters the report by a selection the picker cannot show (codex r1)

* fix(BUG-2410): with no collection list the report goes unfiltered, and a failed load never erases the saved layout (codex r2)

* fix(BUG-2410): a collections response for a workspace the user left is ignored (codex r3)
2026-09-23 18:21:38 -04:00
xarmian db37dc2bcd fix(web): the split pane's Back returns to the previous item at the position it was read at (BUG-2182) (#1473)
* test(BUG-2182): reproduce pane Back returning to the top of the previous item

* fix(BUG-2182): the split pane's Back returns to the previous item at the position it was read at

* test(BUG-2182): the drill-after-Back race with a scrolled A, the case the saved position turns into a shallow entry

* test(BUG-2182): drop the scrolled race variant; the race it copies is flaky on main (filed separately)
2026-09-23 17:15:46 -04:00
xarmian 175a81c9a2 fix(BUG-2837): table header labels wrap to two lines and clip to their column, so a long label no longer paints over its neighbour (#1472) 2026-09-23 16:38:56 -04:00
xarmian 6cc2c7eb95 fix(items): create refuses reserved metadata keys; convention metadata goes through a typed create member; a full fields update may only carry them (BUG-3163, ToolSurfaceVersion 0.48) (#1470)
Item CREATE was the last door that stored a hand-written reserved metadata
key (implementation_notes, decision_log, github_pr, convention). A `--field`
or MCP `field` value landed as a string no extractor reads. For notes and
decisions, that also made `note` / `decide` refuse on the item until the row
was repaired.

Create's `fields` now refuses all four keys with 400 validation_error. The
check runs on the caller's map. Library activation, the only system writer that
used this door, moves to a new typed `convention` create member, which is
validated (an empty object is refused), normalized and lowered into the blob.
Every convention writer switches to it: the CLI and remote MCP library
activate, the web library activate, the web Conventions page create, and the
e2e seed helper. The CLI checks the STORED `convention` key to confirm the
member landed. It cannot use item.Convention for that, because that value is
derived from the sibling keys and is non-nil even when an older server has
dropped the member.

A FULL `fields` update may CARRY stored reserved values unchanged. A differing
value, or an OMITTED stored key (which the whole-blob replace would delete),
is refused with 400. The comparison uses decoded JSON, never bytes, and runs
in the update transaction against the row re-read under the write lock
(composed into the precheck, like the BUG-3133 guard). A note appended
concurrently therefore cannot be erased by a stale blob. A stored blob that is
not a JSON object has no readable reserved metadata, so a full write can
still repair it.

Artifact import shares createItemChecked but needs no gate, because
artifact.Decode fills Fields from a fixed frontmatter key list. Existing test
fixtures that put reserved keys in through the two closed doors now seed at
the store level. The stdio refusal texts are pinned to the classifier's
validation_failed table.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-23 16:23:06 -04:00
xarmian 512572be53 fix(BUG-3161): a role with no icon shows the robot character, not a literal HTML entity (#1467) 2026-09-23 15:22:22 -04:00