Commit Graph

2060 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 7376023ff2 fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812) (#1550)
* fix(server): the request-body gate reads tokens with jsontext, so a NUL behind a repeated key or beside 1e999 is refused, and a repeated request member is refused on every body (BUG-2812)

The body gate and the store's JSON-column guard decoded into
map[string]any and walked the tree. The tree is a lossy model: it keeps
one value per repeated key, so a NUL in a shadowed copy passed (and the
typed decode could merge it into storage), and a number no float64
holds made the decode fail, so the gate answered "no NUL" for a body the
typed decode then accepted. Walking that tree also cost about 2x on any
body that merely mentioned an escape.

- internal/jsonscan wraps github.com/go-json-experiment/json/jsontext
  (pinned v0.0.0-20260820222146-c27c302e5fc3, BSD-3, The Go Authors).
  It is the module's only importer; its doc comment names the exit to
  stdlib encoding/json/jsontext. A differential test pins its acceptance
  to encoding/json v1's, which found that jsontext refuses a lone
  surrogate v1 accepts; that case falls back to v1's unquote.
- scanRequestBody reads every body once, recording a NUL and a repeated
  request member separately and reading to the end, so malformed input
  still reports nothing. Repeats count in the request's own structure,
  and not in caller data under JSON-encoded keys (BUG-2896 collapses
  those). Names are case-folded where the destination is a struct:
  decodeJSONBytes reflects its destination's TOP-LEVEL type, so a struct
  folds its members, a slice of structs each element's, and a map
  (admin settings, admin user limits) none. Below the top level only an
  exact repeat counts. decodeJSONBytes and both bundle paths refuse
  either.
- textguard's document walk and artifactIsBindableText read tokens too,
  so every layer refuses the shadowed NUL together: the one
  textguard.KnownGaps case moves into Corpus.
- The store's suspect path stays, redundant but correct, by ruling; its
  deletion is its own unit. Its instruments now pin what is true: no
  refused value is a suspect, and the remaining suspects are the
  doubled-backslash class the destination accepts.
- The map-model walk survives only as the differential's oracle.

* fix(server): update vendorHash and the Postgres-only suspect instruments for the token scan (BUG-2812)

The Postgres-backed legs of the suspect path fired when the shared
predicate learned to see a NUL behind a repeated key: that value is now a
violation everywhere, and the store's own guard refuses it before the
destination cast. The preflight legs now assert that, the unmigrated-
table fixture uses a doubled-backslash literal (still a suspect), and
PostgreSQL's native refusal stays measured by
TestNativePostgresAgreesWithTheCorpus.

vendorHash is the value this PR's Nix job recomputed.
2026-09-25 21:48:30 -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 bdfb8a989c fix(server): a request that repeats fields_patch or field_overrides is refused, where it silently dropped the first copy (BUG-3219) (#1548)
BUG-3202 (#1545) decodes fields_patch from its raw member and
field_overrides through models.FieldValues, to keep number literals. A
plain map member MERGED a repeated occurrence; a raw member keeps the
LAST, so a request repeating either answered 200 having discarded part
of the write.

A repeat is now refused with a 400 naming the member, and nothing is
written:

- models.RefuseRepeatedMember scans an object's top-level members and
  matches names by encoding/json's own rule (exact, or its case fold),
  so fields_patch followed by FIELDS_PATCH is a repeat. A test pins the
  matcher against encoding/json itself.
- ItemUpdate.UnmarshalJSON refuses a repeated fields_patch; the move
  request (now a named type) and the copy/preflight request refuse a
  repeated field_overrides. The three handlers answer with the message
  unwrapped; the copy door no longer flattens it to "Invalid JSON body".
- FieldValues refuses a second non-null decode itself, as the backstop
  for a future struct that forgets the scan.

Out of scope: repeated keys INSIDE one fields object (BUG-2896), and
members that were already raw before #1545 (fields, tags), which kept
the last occurrence before and after.
2026-09-25 18:23:31 -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 228abe9702 fix(store): a JSON blob that repeats a member is stored collapsed, so SQLite's json_extract and Go read the same value (BUG-2896) (#1547)
SQLite's json_extract reads the FIRST occurrence of a repeated member; Go
and Postgres jsonb read the LAST. Two doors stored such blobs raw:

- collection create/update with `traits` in the string wire form (the object
  form was already re-encoded). Measured: a PATCH whose pre-check read
  `playbook` was refused 409 by the unique index, which read `convention`.
- workspace import, for collection traits and item fields.

At each door, a blob in which some object repeats a member (any depth,
models.FirstDuplicateJSONKey, moved from internal/server) is stored
collapsed: traits as the parsed declaration's own encoding (what the
resolver reads), item fields as a map re-encode that keeps number literals
(last wins, as jsonb). Any other blob keeps its bytes.

Surfaces: collection create/update responses carry additive omitempty
warnings.collapsed_duplicate_keys; imports carry
X-Pad-Import-Collapsed-Duplicate-Keys (always set, "0" when clean) plus a
slog line per blob. No MCP tool can send `traits`, so no ToolSurfaceVersion
bump; a guard test fails if pad_collection gains a traits param.

Live census: 0 of 233 collections and 0 of 9,796 items affected.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 16:24:50 -04:00
xarmian 8ee68e9b4e test(server): the body-reader accounting asks the type checker, so a request reached through a struct field, context value, alias or embedding is counted (BUG-2820) (#1546)
TestEveryRequestBodyReaderIsAccountedFor scanned internal/server with a
parser that matched the spelling `http.Request` and names bound to it. A
request reached through a struct field, a context value, a type alias, an
embedded field, an import alias, a dot import, a var declaration, a
call-derived alias, a named result or a range binding was invisible, and
accounting was per file with a reader count as a workaround.

- collectRequestBodyReads loads the package with go/packages and counts
  every selector whose resolved object is the SAME object that looking the
  member up on net/http.Request yields (Body, FormValue, PostFormValue,
  ParseForm, ParseMultipartForm, MultipartForm, PostForm, MultipartReader,
  FormFile). Accounting is per call site: file::function::member -> count,
  with the per-file reasons kept.
- A fixture package (testdata/bodyreaders) pins the 12 forms the parser
  could not see, the 9 its own controls held it to, and 6 non-readers,
  including the two shadowing cases the parser over-flagged on purpose,
  which are now exact.
- New: TestEveryRequestHandoffIsClassified. A request passed to code outside
  the module must be classified. fosite's five New*Request calls read the
  OAuth form body (BUG-2811) and are pinned per site; interface dispatch
  (http.Handler.ServeHTTP) is named as statically unresolvable.
- Measured on this tree: the type checker finds exactly the readers the
  parser found, file for file. The blind spots were real, but nothing
  used them.
- The parser (scanFileForRequestBodyReads) and its controls
  (TestBodyReaderScanDiscriminates) are retired; their cases live in the
  fixture.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 14:22:55 -04:00
xarmian 25d76af0ba fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202) (#1545)
* fix(fields): field writes keep every number's literal, so an integer above 2^53 is no longer rounded by a write that never named it (BUG-3202)

Every door that writes an item's fields decoded the blob into
map[string]any, turning each JSON number into a float64, so the re-encode
rounded integers above 2^53 (9007199254740993 stored as ...992). The stored
blob was decoded that way too, so a fields_patch to `status` rewrote every
large integer in the row.

- models.DecodeFieldsJSON / DecodeJSONKeepingNumbers decode with UseNumber.
  They accept and refuse exactly what json.Unmarshal does (trailing data,
  out-of-float64-range numbers); only the representation changes.
- Used at every fields decode that feeds a write: create, full-fields
  update, the fields object on the wire, fields_patch (request and store
  merge), the carried-blank-relation normalisation, the Append/Set metadata
  helpers, move, bulk move, cross-workspace copy and its preflight, and the
  move/copy field_overrides (models.FieldValues).
- items.validateFieldType accepts a json.Number that parses as a finite
  number. items.coerceValue and the CLI's parseFieldFlag keep a numeric
  string that is a JSON number literal as that literal.
- Read-only decoders are unchanged (display, hydration, guards).

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

* fix(fields): compare field values by value, so an equal number in another spelling is no change and two integers that share a float64 are (BUG-3202)

Field writes now keep a number's literal, so one value can be stored as
different text (1e3 vs 1000). Three places compared field values:

- item.updated's change detector compared the fields blob's TEXT whenever
  it was not masking a string done key: an equal-value rewrite, or a
  key-reorder-only rewrite, emitted item.updated.
- the activity `changes` diff and the BUG-3163 carry check compared
  float64s: equal spellings were fine, but two different integers above
  2^53 compared equal, so such a change went unrecorded and a changed
  reserved value passed as carried.

models.CanonicalJSONNumbers gives every number one exact canonical
spelling. All three decode keeping literals and compare canonical forms.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 14:22:09 -04:00
xarmian d4ec587de9 fix(cli): a CLI call during an install-refresh bounce waits for the refresh instead of auto-starting a server over it (BUG-3215) (#1544)
install-refresh.sh stops the server, installs, and restarts it with the
captured argv. Any pad CLI call in between found nothing answering and
auto-started a server of its own: the caller's binary, cwd and default
flags. That server held the port against the refresh's restart (day 79:
127.0.0.1:7777 went dark and the script exited 1).

- The script writes <DataDir>/refresh.lock {pid, port, deadline} by rename
  before the stop and removes it in its EXIT trap, only while it is still
  its own. The deadline is 10s + PAD_PROBE_TIMEOUT (30s by default).
- EnsureServer defers to a live marker for its own port (writer pid alive
  AND deadline not passed): it polls health and never spawns. If the
  writer is still alive past the deadline it errors naming the pid. A dead
  writer, a passed deadline, another port or an unparseable file means
  the marker is ignored.
- The script names a holder that took the port before the restart spawns
  (and does not spawn it), and refuses success when the listener after
  the probe is not the restart it spawned. A health probe answers for
  whoever holds the address.
- The script-test dependency guard now reads every install_refresh*_test.go.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 09:55:48 -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 8d81f3a75c fix(timeline): structured entries get a persisted id instead of a positional one that renumbers when a sibling moves (BUG-2788) (#1540)
* fix(timeline): structured entries get a persisted id instead of a positional one that renumbers when a sibling moves (BUG-2788)

An implementation note or decision with no usable id, or with an id an
earlier entry already claims, fell to the timeline's "<prefix>-idx-<i>"
fallback. That id is the cursor's tie-breaker and the client's list key, and
it changed whenever an earlier entry was inserted or removed, or when the
first of two duplicates was deleted, so an entry could be skipped or shown
twice across a page boundary.

models.StructuredEntryIDKey is now the one rule for what a stored id means in
the timeline, and models.EnsureStructuredEntryIDs persists a minted id on
exactly the entries that rule leaves without one (first holder of a duplicate
keeps it). It runs in both append helpers, in workspace import, and in a
startup backfill that is silent by ruling (no seq, updated_at, activity or
event); the Q1 census that licenses the silence is in its comment. A repaired
entry's timeline id changes once, from the positional form to its persisted
id, and never again. No MCP surface change.

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

* fix(timeline): the entry-id repair touches only what the timeline reads, keeps values byte-stable, and its backfill compares the fields it read (BUG-2788 review round 1)

- Skip a kind whose array the timeline's typed extraction rejects (a
  non-object element or non-string id hides the whole kind there), so the
  repaired population is exactly the positional one.
- Leave a blob that is not exactly one JSON value untouched, instead of
  dropping its trailing bytes; re-encode without HTML escaping.
- The backfill's UPDATE compares the fields text it read as well as seq, so
  a write that changed fields without moving seq is never overwritten.
- Import's comment no longer claims byte-verbatim storage for a repaired blob.

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

* fix(timeline): the entry-id repair leaves a blob the timeline cannot parse at all (BUG-2788 review round 2)

The timeline parses the whole fields blob with a plain Unmarshal first, which
fails on a number outside float64's range and then shows no entries; the
repair decoded with UseNumber and would still have minted ids there. It now
requires that same first-stage parse.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 07:10:26 -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 f896af9398 fix(store): restore and move return their own in-tx snapshot, and bulk member snapshots are read in batches (BUG-2717, BUG-2718) (#1539)
* fix(store): restore and move return their own in-tx snapshot, and bulk member snapshots are read in batches (BUG-2717, BUG-2718)

BUG-2717: restoreItemOnce and MoveItemWithPreCheck read the item inside
their transaction for the outbox event and then re-read it after COMMIT to
return it. The re-read could fail after a successful commit and could hand
back a concurrent writer's row. Both now return the in-tx snapshot, as the
update path has since BUG-2264. A test-only post-commit seam lets a test land
a real concurrent write in the old window; with the re-reads put back, both
tests return the other writer's title and seq.

BUG-2718: outboxMemberSnapshotsTx issued one joined read per bulk member,
inside the caller's transaction and under its lock. It now reads in IN (...)
batches of 400 through the SAME select and scanner as a single-item read
(itemSnapshotSelect / scanItemSnapshot, extracted from getItemScanQ), so a
batched snapshot cannot differ in shape. De-duplication, first-occurrence
order, skip-if-missing and the PII scrub are unchanged, pinned against the
per-member reader across a chunk boundary.

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

* fix(store): a batched member-snapshot error names its chunk (BUG-2718 review round 1)

The per-member read wrapped each error with the member id; a batch has no
single member, so its errors now carry the chunk's size and first id.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 06:02:57 -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 c1fa878889 fix(attachments,mcp-audit): record where a stored filename or tool name came from, instead of sentinel strings a caller can choose (BUG-2819) (#1537)
* fix(attachments,mcp-audit): record where a stored filename or tool name came from, instead of sentinel strings a caller can choose (BUG-2819)

attachments.filename and mcp_audit_log.tool_name each held either the
caller's value or a server substitution with no marker, and the caller could
send the substitution itself ("upload.bin", "(unknown)"). New columns,
CHECK-constrained on both dialects and defined once in Go:

- attachments.filename_source: caller | normalised | substituted | derived |
  unknown, decided by NormalizeFilenameWithSource at the upload and import
  doors (raw vs stored), derived for thumbnails and transforms, carried by
  clones, and carried through bundle export/import as the bundle's claim.
- mcp_audit_log.tool_name_source: caller | sanitised | synthesised | unknown,
  decided at each branch of the audit parser.

Existing rows default to unknown, never caller. The (sanitised)/(unknown)
namespace reservation stays as defence in depth. filename_source reaches
pad_attachment list on both transports, so ToolSurfaceVersion 0.53
(additive).

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

* fix(attachments): the bidi filename backfill keeps filename_source true when it rewrites a name (BUG-2819 review round 1)

The startup backfill rewrites a stored filename that carries a Bidi_Control
character, and left filename_source untouched, so a row recorded as the
caller's own name would keep that claim over a server-altered name. A
rewritten 'caller' row now reads 'normalised'; every other value is still
true after the rewrite. The 0.53 entry now says the key is always present.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 05:13:05 -04:00
xarmian 8f297f7430 fix(store): a collection gated by ID is queried by ID, so a slug freed and re-taken in between cannot answer for another collection (BUG-2631) (#1535)
Six sites resolved or gated a collection by ID and then filtered the item
query by that collection's slug; a rename landing in between could hand the
response another collection's items (worst case the public collection share
link, which serves anonymous viewers). They now set a new
ItemListParams/ItemIndexParams.ScopeCollectionID, ANDed on its own and kept
out of the OR-ed CollectionIDs/ItemIDs permission pair, which is what made
the obvious fix a visibility hole on the first attempt. scalarRelationFilterKeys
reads the scope too, so the empty-relation-filter broadening (BUG-3028) still
finds the schema.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 04:25:42 -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 372f91f03b fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206) (#1530)
* fix(events): a resume from exactly the discarded peak is refused after a counter-backwards reset, instead of being handed the new sequence as though it followed (BUG-3206)

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

* docs(BUG-3206): the floor does not refuse discarded+1, which is narrower than serving it (codex round 1)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 02:35:38 -04:00
xarmian 6d25e67456 test(BUG-2683): pin that a bulk move records an ordinary dropped field in its activity row, the case the bug was filed for (#1531)
The report itself shipped in TASK-2878 (#1246); its existing pin covered only a relation drop.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 02:03:55 -04:00
xarmian 7458fa87f4 test(BUG-2684): pin that the copy preflight labels a dropped-then-defaulted key from=default, the case the bug was filed for (#1533)
The labelling fix shipped in TASK-2878 (#1246, codex round 9); existing pins covered only null-source and destination-only defaults.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 01:36:38 -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 67b25d1dc6 docs(BUG-2743): the ambiguity boundary refuses a new-space client on every reconnect until the space climbs past the old peak, not once (#1529)
Corrects the stated cost in the field comment, the counter-reset arm, and docs/deployment.md. The behaviour is unchanged, by lead ruling (a) on BUG-2743's trail.

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 01:04:30 -04:00
xarmian e4a8398e29 test(BUG-3199): the concurrency test's straddle is built with two barriers instead of hoped for with a 2ms sleep (#1519)
* test(BUG-3199): the concurrency test's straddle is built with two barriers instead of hoped for with a 2ms sleep

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

* test(BUG-3199): release the producer's barrier on every exit, and say the race is possible rather than forced (codex round 1)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 00:21:35 -04:00
xarmian 70f7c0c15c fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728) (#1518)
* fix(watchevents): a resume from a cursor inside an abandoned id space is refused on every arm, instead of being served the new space's ids or told it is caught up (BUG-2743, BUG-2728)

Every arm that detects a watch-stream id-space restart (the epoch arm and
both counter-backward arms) now records the old space's high-water mark,
and replaySince refuses any cursor at or below it. The boundary is never
cleared: climbing past the old peak does not make a cursor below it any
less ambiguous.

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

* docs(BUG-2743): the counter-reset arm's prose states the boundary's cost and which id the resume check compares (codex round 1)

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

* fix(watch): pad watch --stream prints a PAD (resync) line on sync_required, so the agent reading it learns it missed notifications instead of carrying on (BUG-2743 follow-through)

Clearing the cursor was the process's whole response; the consumer of its
stdout could not see a cursor and was never told. The line goes through the
same consent gate as a notification, and repeats with no notification
between them are folded into one.

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

* docs(plugin): tell the agent what a PAD (resync) monitor line means and what to do with it (BUG-2743 follow-through)

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

* docs(BUG-2743): the resync line says the stream MAY have missed notifications, since sync_required means the span is unvouched, not certainly lossy (codex round 3)

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-24 23:40:26 -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 b2bee3242f fix(scripts): install-refresh restores the server's own cwd and matches the running server by identity (BUG-3196) (#1512)
* fix(scripts): install-refresh restores the server's own cwd with its argv, refuses a deleted one, and matches the running server by identity rather than the built binary's name (BUG-3196)

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

* fix(scripts): one existence check refuses a deleted server cwd, naming the path without the kernel's suffix (BUG-3196)

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

* test(scripts): the no-/proc refresh test declares lsof, which now reads the server's cwd there (BUG-3196)

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

* test(scripts): the lsof dependency sits on the requireScriptDeps line the guard reads (BUG-3196)

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

* docs(make): the install target's guarantees include the restored cwd (BUG-3196)

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

* fix(scripts): the server's cwd is re-checked immediately before the stop, and the capture's limits are stated (BUG-3196)

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

* fix(scripts): a failed restart cd says what state it leaves, not that no server runs (BUG-3196)

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

* fix(scripts): an unreadable server cwd is refused naming what was read and its error, and the case is tested (BUG-3196)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 10:38:16 -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 7c2c514d72 fix(store): a workspace import no longer re-inserts every item into items_fts after its commit, and migration 095 rebuilds the index (BUG-2758) (#1507)
ImportWorkspace and RemapAttachmentReferencesInWorkspace each ran a
post-commit rebuildFTSForWorkspace: one autocommit INSERT INTO items_fts per
item. items_fts is an external-content FTS5 table kept by the items_fts_*
triggers, which had already indexed those rows inside the transaction. So the
pass double-indexed every imported item (FTS5 integrity-check reports the
table malformed), and it turned an N-item import into N write transactions
after the commit, which stalled every other writer on the database. Under
e2e load that stall outlasted the 30 s busy_timeout, which is why
version-restore-collab and workspace-bundle-roundtrip failed together.

The pass and both call sites are removed; the triggers are the index.
Migration 095 (SQLite chain only) drops and recreates items_fts and its three
triggers with 005's definitions, then runs FTS5 'rebuild', so it repairs
existing databases whatever state they are in. slowSQLiteMigrations logs a
line before it starts.

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
2026-09-24 09:16:17 -04:00
xarmian f5b0062d6e fix(store): NUL Layer B covers columns added after 084 through later generated trigger files (BUG-3108) (#1506)
* fix(store): NUL Layer B covers columns added after 084 through a later generated trigger file (BUG-3108)

The trigger generator had one hardwired target, 084, which runs before every
later migration's columns exist, so every post-084 column could only be
baselined. The trigger files are now an ordered list; each protected column is
assigned to one, 084 by default. The generator writes every file and the pin
test compares every file. The startup re-assertion wants and re-executes only
the files already applied, because it runs after every migration, including the
ones before a later column exists.

094 carries the three post-084 columns that hold caller text: items.lease_holder
(request body, verbatim), item_relation_links.source_field_key (a schema field
key) and item_decisions.model (the admin model pin). 084 is byte-identical.

TestNULTriggerFilesFollowTheirColumns walks the migration chain one file at a
time and fails when a column's trigger file does not run after the migration
that introduced it, naming the fix. The next post-084 column therefore fails
there instead of going quietly into the baseline.

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

* test(store): a NUL trigger restore does not re-execute a trigger file that is not yet applied (BUG-3108)

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

* fix(store): item_relation_links.source_field_key stays out of Layer B on migration 088's derivation, now recorded as an exclusion (BUG-3108)

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

* docs(store): ScanNUL's cost comment counts the two post-084 protected columns (BUG-3108)

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

* fix(store): later trigger files get their own name prefix so an older binary cannot drop them; the ordering instrument takes a column's latest appearance (BUG-3108)

An older binary's restore reads triggers by GLOB 'pad_nul_*' and drops any it
does not expect. Had 094's triggers used that prefix, a binary predating 094
started with --force would have removed them all. Each later file now uses
pad_nul<NNN>_, and the restore reads one GLOB per file it knows.

columnIntroductions records the last time a column appeared after being
absent, so a column dropped and re-added, or a table renamed and replaced,
counts as introduced again. The instrument is parameterised so a synthetic
chain can show that rule going red.

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

* docs(store): the source_field_key exclusion names the check that covers it, a decoded value (BUG-3108)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 08:13:59 -04:00