Commit Graph

719 Commits

Author SHA1 Message Date
xarmian ea139272ce fix(server,watchevents): shared session presence + honest push acceptance (BUG-2698, BUG-2699) (#1175)
Two coupled defects in the push path, fixed as one unit because 2699's honest-acceptance signature is the substrate 2698's fix reports through.

BUG-2699 — Bus.Publish reports acceptance. The endpoint returned 200 pushed:true
for a publish that was dropped, because Publish returned nothing and swallowed
every failure. An error is two outcomes and they are kept apart: ErrBusClosed
proves nothing was published (503 unavailable, safe to resend), while any other
error means UNCONFIRMED — go-redis retries a command whose reply was lost, which
is why the publish script already carries a dedupe token — and gets 502
push_unconfirmed, deliberately off the web client's safe-to-resend list.
MemoryBus was the worse case, not the exempt one: neither implementation checked
`closed`, and the in-process one dropped silently with no log at all. Seven
production call sites, not the six the item named; the six best-effort producers
discard through one named helper, and an AST-based test fails when a new
producer publishes directly.

BUG-2698 — RedisSessionPresence. A session-targeted push was resolved against
the answering replica's presence registry, and the handler skips the publish
when the target is absent, so a POST landing on A for a session held on B
dropped the instruction and answered delivered_sessions:0. Fixed at the REGISTRY
rather than the gate: a shared registry makes the snapshot right, which makes
the picker complete and restores the gate's original premise, so the existing
skip becomes correct for the reason it was written. Entry and index are written
atomically under a TTL renewed by a goroutine that lives exactly as long as the
connection; a crashed process stops renewing and Redis clears it. Staleness is
unchanged and now stated in full: ~30s for a dropped client, ~90s for a dead
instance.

delivered_sessions becomes nullable — null means published-but-uncountable,
never zero — documented as three states at every consumer.

35 Codex review rounds. Notable: a per-user registry cap was added and then
removed after three consecutive rounds found defects inside it and a fourth was
asked whether it belonged in this PR at all; a context bound was documented,
disproved by its own test (go-redis does not apply a command context to
connection establishment — 5.0s measured against a 150ms ctx), and rewritten to
say what is true. Every fix was mutation-checked; one instrument was deleted for
passing on broken code and one for not asserting its own premise.

Filed rather than folded in: BUG-2724 (Redis keyspace namespacing + Cluster),
BUG-2725 (delivered_sessions is an estimate with error in both directions),
BUG-2726 (no concurrent-connection limit on the watch stream), BUG-2727 (Redis
absent from readiness/metrics; silent subscriber loss), BUG-2728 (epoch-reset
resume lead).

Gates: build · make lint 0 issues · go test ./... (25 pkgs) · svelte-check 0
errors · vitest 1738 passed · CI 7/7 including Go (PostgreSQL) and Nix.
2026-08-21 20:43:20 -04:00
xarmian 402f79e016 feat(store,server,web): collection kernel traits — de-hardcode conventions/playbooks slugs (TASK-2657, BUG-2702) (#1171)
Implements SPEC-5 §Collection traits (approved v1.1) — the first unit of
PLAN-2656 phase 0. Three kernel behaviors were keyed on the literal collection
slugs "conventions" and "playbooks": what the agent bootstrap loads, which
items route by invocation slug, and which items export as portable artifacts.
Collections now DECLARE those behaviors and the kernel resolves them from the
declarations.

Fixes the KERNEL half of BUG-2702, which stays open for the rest (see below).
A slug is not a stable identifier —
UpdateCollection re-slugs on any name change, and renaming a collection is a
documented onboarding step (TASK-1510) — so renaming either collection silently
detached all three behaviors from it, with the items still present and no error
anywhere. Measured on origin/main before the fix: conventions and
convention_index dropped 1 -> 0, playbooks 1 -> 0, and GET /playbooks/{slug}
went 200 -> 404, so `/pad ship` stopped resolving with no sign the playbook
still existed. Both halves are locked by regression tests observed failing on
unfixed code.

BUG-2702 is NOT fully closed here, deliberately. Every kernel behavior follows
the trait, and library activation on the MCP dispatcher and CLI was converted
too — but the pack's own dedicated web routes (/conventions, /playbooks list and
detail, /library) still address their collection by literal slug and render
empty after a rename. Filed as BUG-2705 with the route paths and the likely fix
shape; 2702 closes when that lands. Degradation there is bounded: no data loss,
and the collection stays usable at its own /[collection] route and in the
sidebar.

SPEC-5 was amended to v1.1 BEFORE any code, per the spec tree's own discipline:
bootstrap_include becomes a LIST of {mode, filter, key} because v1.0 could not
express convention_index at all; the conventions filter is now normative and
includes status=active, which v1.0's shorthand omitted and which the
implementation does enforce (implementing v1.0 literally would have leaked
draft conventions into every agent's boot payload); v1 filters are field-
equality maps with query/1 named as the widening path, since SPEC-2 is phase 1
and PLAN-2656 forbids growing toward it; and invocation_field is constrained to
the literal `invocation_slug`, because any other field name falls outside the
partial unique indexes in migrations/054 and pgmigrations/033 that are the real
uniqueness guard.

Traits get their own column rather than a key inside the schema JSON. The
schema column is overwritten wholesale on update and every client rebuilds it
fields-only, so a traits key stored there is destroyed by one ordinary
collection edit — measured during this task, not assumed. Trait authority
cannot rest on a value an unrelated UI save deletes. UpdateCollection writes
traits only when explicitly supplied, so pre-existing clients leave them alone;
an explicit "{}" still clears.

Bootstrap keeps its three payload keys as first-party views fed from the
declarations, and gains a generic bootstrap_includes array for any other
declared key — so the boot surface is genuinely generic rather than three
hardcoded payloads, and no consumer breaks.

Existing workspaces are backfilled slug-keyed in both dialects, guarded on
traits='{}' so a re-run cannot clobber a workspace's own declarations. The
backfill inherits today's blind spot (a workspace that renamed the collection
before upgrading is not reached) but cannot do worse than the status quo, which
is itself slug-keyed; from the backfill forward the hazard is structurally gone.

Malformed declarations are refused at create and update rather than stored:
an unparseable blob degrades to "declares nothing", which is silently the wrong
behavior instead of a loud error (SPEC-0 L6).

Web groups agent-facing collections by bootstrap_include presence, replacing a
hardcoded two-slug array repeated at five call sites.

Not done, deliberately: no MCP catalog change (traits are first-party kernel
declarations, no agent needs to set them, and the separate column means
pad_collection.update passes through harmlessly — no ToolSurfaceVersion bump);
bootstrap's collections[] projection does not carry traits (PLAN-1410 trimmed
that payload and nothing consumes them there); prefix.go's NormalizeSlug is
untouched (a pure function with no workspace context, and de-hardcoding two of
its six slugs would make it less coherent, not more).

Eight Codex review rounds found nineteen real defects, all fixed here. The
serious one:
bootstrap_include filter keys FAIL OPEN. The item store's field-filter path
drops any key its sanitizer rejects, removing the predicate rather than matching
nothing, so a declaration filtering on `"stat us"` would narrow nothing and ship
every convention — drafts included — to every agent at boot, defeating the
status=active guarantee this change makes normative. Filter keys are now
validated against the store's own sanitizer shape and pinned by a cross-package
agreement test, since models cannot import store and a future divergence would
silently reopen it. SPEC-5 amended to v1.2 with the rule and its fail-open /
fail-closed asymmetry. Also fixed: an unknown declared artifact_kind reached
artifact.Encode and surfaced as a 500 (now a 400 at the export boundary, since
SPEC-5 permits unknown kinds as legal non-round-tripping declarations); and
workspace import validated traits as JSON only, so an archive could persist a
declaration that degrades to "declares nothing" (now validated, degrading to
"{}" with a warning rather than refusing an import that may be the only copy).

Later rounds found more, and several were defects this change itself created.
A hidden collection could SHADOW a visible one: resolution used to name exactly
one collection, so with several declaring, resolving across all of them and
rejecting afterwards on visibility made a visible playbook unreachable behind a
hidden one — candidates are now filtered by visibility before selection, in both
playbook resolution and artifact import. Importing a pre-traits archive produced
an INERT workspace: the migration backfill cannot reach rows inserted long after
it ran, so conventions/playbooks arrived declaring nothing, and canonical
declarations are now inferred from the slug when a collection declares none
(never overriding declarations that survived the round trip). The generic
include path had no L4 boot budget and is now capped with an overflow count.
Trait parsing claimed to be strict but json.Decoder ignores trailing bytes, so
`{...} garbage` parsed cleanly. First-party payload keys are now mode-pinned,
since their projections have fixed shapes and declaring the other mode would be
silently ignored. Duplicate artifact_kind / invocation_field declarations are
refused at the collection API, and a conflicting archive warns on import.

Agent-facing text was updated with the rest, not after it: SKILL.md,
instructions.md and the MCP catalog said the literal slugs, which is exactly the
artifact an agent acts on. ToolSurfaceVersion 0.24 -> 0.25 for the
pad_library.activate behaviour change.

Trait uniqueness is a documented BEST-EFFORT gate, not an invariant, by lead
ruling. The gate reads then writes without a lock, import bypasses it, and a
rename can mint a duplicate without touching that path. The database-level
enforcement (partial unique indexes on the extracted traits) cannot ship first:
existing deployments can already hold duplicates via rename-then-reseed, so the
index would fail the migration on precisely the databases that most need
repairing. TASK-2710 carries the de-duplication pass and the indexes; SPEC-5
v1.3 records the deferral and the reason. L6's requirement that conflicts fail
loud is met by the refusal plus the warning — the mechanism is deferred, the
principle is not.

Gates: build · make lint 0 issues · go test ./internal/... · make test-pg ·
svelte-check 0 errors · vitest 99 files / 1734 tests. Mutation-verified across
four matrices, 20 mutations, 19 caught; the survivor is a seeding path whose
trait-vs-slug difference is unreachable today (SeedCollectionsFromTemplate
creates any missing template collection before it seeds items), recorded on the
task trail rather than papered over with a test that proves nothing.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 13:26:05 -04:00
xarmian de96cce900 fix(items,server,web): reserved metadata survives a move; referential metadata travels only within its context (BUG-2674) (#1165)
* fix(items,server): reserved metadata survives a move, and dropped fields are reported (BUG-2674)

Moving an item destroyed its implementation notes, decision log and linked-PR
metadata. Well-formed data, on a routine documented operation, silently, with a
success message.

Reproduced before the fix: a note written through `pad item note` — correct
shape, visible on every surface — was gone after `pad item move`, leaving
fields as `{"status":"new"}`.

## Why it happened

items.MigrateFields drops every key absent from the TARGET schema. The reserved
keys — implementation_notes, decision_log, github_pr, convention — are system
metadata that NO collection schema declares; each renders from its own dedicated
surface rather than as a generic field. So they are absent from every targetDefs
and were dropped on every move.

That blindness is structural, not incidental: any code path reasoning about
fields BY CONSULTING A SCHEMA cannot see these keys. It is the shared root of
this bug and of BUG-2627, where the CLI types a --field value by schema lookup
and these keys fall through to a raw string.

## The enumeration comes first, deliberately

Before this there were four constants and exactly ONE non-test consumer treating
them as a set — an inline || chain in a CLI display path. Naming the set inline
again here would have created the SECOND hand-maintained list, which is the
generator pattern behind both bugs reproduced inside its own fix: the next
reserved field lands in the constants, gets wired into whichever surface
prompted it, and silently misses the other.

So models.IsReservedItemField is now the single place that knows, MigrateFields
consults it, and the CLI's || chain is converted to it — the only way it is
provably THE list rather than A list. (formatChangeValue keeps its per-key
switch: it needs to know WHICH reserved key it has, to say "notes" vs "entries",
not whether the key is reserved.)

`convention` is IN the set, settled with evidence rather than by the principle
alone: 35 of 36 conventions in a live workspace do not store the key at all, and
the one that does holds a blob that is a redundant mirror of the alias keys
beside it. No user types a `convention` object — ApplyItemConventionMetadata
writes it, via library activation and the web form. System-stamped.

## Contract

System-minted non-referential data carries; anything dropped is reported.

PLAN-2357 DR-17 settled the analogous case — tags carry because "there is no
workspace-scoped foreign key to break, so dropping them would lose information
for no safety reason". These are the same shape: inert JSON with nothing that
could dangle in a destination. The plan's carry list simply never considered
them, so there was no deliberate semantics to defer to. DR-17's own heading is
"None of this may be silent."

## The reporting half

MigrateResult.Dropped has always existed and the single-move handler has always
thrown it away, so the only record of a field disappearing was the field being
gone. It now rides the move's audit metadata — not the response body, which is
the bare item and would break every consumer, and the activity timeline is where
someone asking "what happened to my item" looks. Joined into one string because
that map is map[string]string and a raw array renders as a Go map literal in the
timeline (BUG-2628).

## Verified

Unit: reserved keys carry with their payload INTACT (asserted on the value, not
merely the key — a carry that re-encoded or zeroed it would pass a presence
check), and bypass schema matching entirely, so a target declaring
`implementation_notes` as `text` cannot coerce them. Mutants run: guard removed
-> both new tests fail; carried-but-also-reported-dropped -> the not-dropped
assertion fails; carry-everything -> the control leg fails alongside three
pre-existing tests.

Live, against a server built from this branch: the note survives the move
byte-identical, and the move's activity metadata carries
`dropped_fields: "priority, status"` for the values the target schema genuinely
has no home for.

## Known scope limit

The BULK move path still discards its Dropped list — a reporting gap only, since
the carry-through lives in MigrateFields and bulk inherits it. Threading the list
out crosses two function boundaries whose signatures serve every bulk operation,
so it is a refactor of the bulk dispatch's return contract rather than a line.
Filed as BUG-2683 rather than smuggled in here.

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

* fix(server,web): close the four gaps Codex round 1 found on the carry-through (BUG-2674)

Round 1 raised no P1 and four P2s. Three were real defects introduced or exposed
by the carry-through; one was a genuine overclaim in the previous commit. All
four closed here, each mutation-verified rather than asserted.

## A schema may no longer declare a reserved key

MigrateFields carries these keys by identity, but every caller then validates
against the target schema — and ValidateFieldsDetailed iterates schema.Fields,
so it DOES see a declared key. A target declaring implementation_notes as `text`
would receive the carried array and reject it, turning a move that previously
destroyed the notes into one that fails outright. That is a worse failure than
the one being fixed: loud, but it blocks an operation that used to work.

The gate already existed — validateNoReservedFieldKeys, with its
grandfathering — and listed only parent/plan. The four metadata keys join it,
sourced from models.ReservedItemFieldKeys() so the two lists cannot drift.
Forbidding the declaration is the honest fix; coercing the value, or skipping
validation for a key the schema genuinely declares, would be guessing at which
meaning the author wanted.

The web's RESERVED_FIELD_KEYS gains the same four, preserving the existing
deliberate asymmetry (the client lowercases and is therefore stricter than the
server's exact match) so the UI steers authors away before the 400.

## The copy preflight no longer under-reports

`carried` is built by walking the DESTINATION SCHEMA, and these keys are declared
by no schema anywhere — so after the carry-through they appeared in NEITHER
bucket. A copy of an item whose content is its notes would report "nothing
carries over" while in fact retaining them. Before the carry-through they at
least showed under `dropped`, accurately. Reporting in neither is a regression
in the preflight's honesty, which is the same defect class as the move that
reported nothing.

They are now appended to `carried` after the schema-ordered entries, marked
`type: "system"` with a rendered label since they have no author-supplied one.
The bucket's doc comment says so: a client must no longer assume every `carried`
entry resolves to a destination FieldDef.

## The audit report now reaches a human

The previous commit claimed the activity timeline is where someone asks "what
happened to my item" — true, and the timeline renderer ignored the key, so the
report existed only for API and CLI consumers. Stored-but-invisible is not
reported. TimelineActivityCard renders the dropped keys on a move.

## Test aliasing

The "untouched" assertions compared the result against the SAME objects passed
in, so an in-place mutation would change both sides and DeepEqual would stay
true. The expectations are now independent deep copies — the only thing that
makes "untouched" mean untouched.

## Mutants, each run

Preflight pass removed -> the carried assertion fails. Timeline block disabled
-> the render assertion fails. Timeline action guard dropped -> the non-move
negative leg fails (a presence-only test would have passed it). Reserved-set
helper returning everything -> the IsReservedItemField control leg fails.

## Not fixed here

Codex's remaining observation — that a cross-workspace copy now carries
github_pr into a workspace whose repository it does not describe, and leaves a
convention blob detectable on an item outside the conventions collection — is a
product question about what a copy MEANS, not a defect in this mechanism. Raised
for a ruling rather than decided inside a bug fix.

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

* fix(items,server): referential system metadata travels only within its context (BUG-2674)

Lead ruling on the copy-semantics fork Codex round 1 raised. It does not add an
exception to the carry rule — it applies the qualifier the rule already had.

The contract was "system-minted NON-REFERENTIAL data carries". github_pr is
referential: it names a repository that is a property of the SOURCE workspace's
project, and it hydrates into code_context and renders as a live PR link. Carried
into another workspace that link is a false statement about the destination's
project, not preserved information. implementation_notes and decision_log
describe the item's own history and are true wherever the item is.

So the rule stays one sentence: non-referential system data carries everywhere;
referential system data carries only where its referent's context still holds.

## Scope is a required argument

MigrateFields takes items.MigrateScope. Required rather than defaulted because
BOTH wrong answers lose something: SameWorkspace on a cross-workspace copy
carries a PR link into a workspace it does not describe, and CrossWorkspace on
an ordinary move DROPS metadata from an item whose repo context never changed. A
caller that must name its scope cannot pick one by omission.

The two move handlers pass SameWorkspace as a property of the endpoint, not a
guess — a move changes an item's COLLECTION and cannot change its workspace.

The copy and its preflight COMPUTE it by comparing workspace ids rather than
assuming cross-workspace, because that endpoint accepts a target_workspace equal
to the source; hardcoding would drop a github_pr from a same-workspace duplicate.
Both sides use the same helper, or the preview promises a carry the copy drops —
the DR-6 divergence the shared endpoint exists to prevent.

## The drop is reported, with a reason that explains itself

PLAN-2357 DR-17: "None of this may be silent." It would be perverse to
reintroduce a silent drop inside this fix's own new branch.

The preflight reports it as `referent_not_portable` rather than the generic
`no_target_field`. That generic reason would be actively misleading here: no
schema declares these keys ANYWHERE, so "the destination has no such field" is
equally true of the source and explains nothing about why the value is being
left behind.

## Verified

Mutants run: scope ignored (always carry) -> the cross-workspace leg fails;
generic reason on the preflight drop -> the reason assertion fails. The
same-workspace leg and the non-referential-sibling leg are what stop an
implementation that ignores scope in EITHER direction from passing — each half
alone is satisfiable by a constant.

Gates re-run for THIS commit: lint 0 · go test ./... 0 · make test-pg 0 (3282).
Web gates NOT re-run and not claimed: this commit touches no web file (the web
half of BUG-2674 shipped in 82577a74 and is unchanged here).

## Noted, not fixed

handlers_items_copy_preflight.go already documents the same defect class for
RELATION fields — a same-named relation carries a SOURCE-workspace item id
across workspaces and is reported as a clean carry — and says the fix "belongs
in MigrateFields, for both callers at once". MigrateScope is now the mechanism
that comment asks for, but wiring relation fields through it is a separate
change with its own semantics to settle.

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

* fix(items,server): close Codex round 2 — grandfathered schemas, stale drop reports, scope coverage (BUG-2674)

Round 2 raised no P1 and three P2s plus a nit. All four were real; two are
defects in round 1's own fixes.

## Grandfathered schemas that already declare a reserved key

Round 1 added the four metadata keys to validateNoReservedFieldKeys, which stops
the collision being CREATED — and that gate deliberately GRANDFATHERS schemas
that already have one. I did not follow through: such a FieldDef still reached
ValidateFieldsDetailed, met the system-owned array MigrateFields hands through
by identity, and rejected it. A collection whose only sin is a field name
someone was once allowed to pick would fail every move and copy.

ValidateFieldsDetailed now skips reserved keys outright. That is not "ignoring
validation": these values have no user-authored schema to validate against, by
design — the schema entry is the anomaly, not the value. ValidateFields inherits
it through the same call.

This also closes the second half of the same finding: the preflight could report
one key in BOTH needs_value and carried, because the issue came from validating
a key the carried-append also emits. No issue, no collision.

## Dropped reports that were no longer true

MigrateFields computes Dropped BEFORE overrides merge and before defaults are
injected, so a key it lists may have been supplied moments later. Both the move
audit (which I added in this branch) and the preflight's dropped bucket reported
those anyway — claiming "we discarded your due_date" about an item that HAS a
due_date.

That is worse than the silence it replaced: silence at least does not send
someone hunting for data sitting on the item, and a report that cries loss over
visible data teaches the reader to distrust the channel. items.StillDropped
filters against the FINAL map so the report is true at the moment it is written.

## Scope coverage

attachments_copy_plan_test models a copy from workspace A into B and passed
SameWorkspace — the wrong scope stated confidently in a test whose whole subject
is a cross-workspace copy. It came from the bulk edit that threaded the argument
through, which picked a value rather than reading each fixture.

And nothing proved the MUTATING copy honours scope at all, so a call site
passing the wrong one — precisely the mistake a required argument exists to
prevent — would have shipped green. TestCopyEndpoint_ReferentialMetadataTravels-
OnlyWithinItsWorkspace covers both directions end to end. Mutant run: the store
call site pinned to SameWorkspace now fails the cross-workspace leg.

## The nit was an overclaim, so it is fixed in the code

38fa8fec said the copy and its preflight "use the same helper". They did not —
the helper lived in the server package and the store duplicated the comparison
inline, which is how a preview and its copy drift apart. items.ScopeFor now
lives in the package that defines the type and both call it.

Gates: lint 0 (after a gofmt fix lint caught) · go test ./... 0 ·
make test-pg 0 (3283). No web file touched; web gates not re-run.

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

* fix(items,server): move the validation skip to the right altitude, and finish the drop-report fix (BUG-2674)

Codex round 3, no P1, two P2s. Both say round 2's fixes were applied at the
wrong altitude — correct in the case in front of me, wrong for the callers I
did not enumerate.

## The validation skip was global; the problem is local

Round 2 made ValidateFieldsDetailed skip reserved keys. That validator is shared
with create, full update, artifact import and every bulk path — none of which
migrate anything. On a GRANDFATHERED schema (one that already declared a
reserved key before the round-1 gate), those paths genuinely did validate the
key, and the skip stopped them: arbitrary junk could be written into
implementation_notes through create, while fields_patch kept rejecting it via
ValidatePartialFields. Full and partial updates disagreeing about the same key
is a worse bug than the one I was fixing.

Reverted. items.SchemaForMigratedFields strips reserved FieldDefs from the
schema used to validate the OUTPUT of a migration, and only the four migration
and copy sites call it. Create and update keep enforcing the declaration,
because on those paths the user really is authoring that key.

## StillDropped reached two of three surfaces

The move audit and the preflight were filtered; the MUTATING copy was not.
migrateCopyFields returned the raw pre-override list and the 201 response
exposes it as warnings.dropped_fields — so one request could report the key
carried in the preview, PERSIST it, and still call it dropped in the copy's own
response. Three surfaces, two answers.

## And StillDropped's own test was too weak

Presence is not the test — present-and-non-nil is. The move path writes
overrides straight into the map including a nil, where the copy path deletes the
key, so `{"due_date": null}` on a move left the key present carrying nothing.
Treating that as restored suppresses a REAL drop, which is the silent loss this
change exists to end.

## A mutant survived, and the fixture was why

`out.Fields = schema.Fields[:0]` + appends mutates the caller's backing array.
The first version of the input-not-mutated assertion passed it twice: once
because it checked length (Go passes the struct by value, so the caller's slice
HEADER survives), and again after fixing that, because the reserved key was LAST
in the fixture — the one surviving field was written back into the slot it
already occupied. With the reserved key FIRST the corruption lands in slot 0 and
the mutant dies. Recorded in the test, because the next person writing a
"does not mutate its input" assertion in Go will reach for len() too.

## Comment accuracy

The reserved-set doc claimed callers "inherit additions without edits". True for
membership tests, false for the three places that need something a set cannot
supply — referentialItemFieldKeys, reservedFieldLabel, and the web's separate
RESERVED_FIELD_KEYS. Now listed, with the test that fires as the reminder. The
collections-handler comment described only parent/plan and now says it covers
two unrelated groups.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3285). No web file touched.

## Flagged, not fixed

The preflight labels a destination DEFAULT as from:"migrated" when the source
had the key but migration dropped it — origin is keyed on presence in the source
map, not on where the final value came from. Pre-existing and untouched by this
branch; filed separately rather than folded in.

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

* fix(items,server): close Codex round 4 — grandfathered defaults, override holes, duplicate carried entries (BUG-2674)

Round 4, no P1, three P2s. All three are the same case I kept half-fixing: a
GRANDFATHERED schema that declares a reserved key.

## Reserved declarations were still live in the defaults pass

MigrateFields carried reserved keys by identity but then ran the target schema's
defaults/required loop over them unchanged. A legacy Default was injected into
system metadata as though a user had authored it, and a legacy Required produced
a migration ERROR — which bulk move rejects on BEFORE reaching the
stripped-schema validation. So a legacy target requiring implementation_notes
failed bulk move while single move and copy succeeded: same key, same item, two
answers depending on which button was pressed.

## Overrides were a hole straight through the rule

A field override naming a reserved key was merged and then validated against the
STRIPPED schema — i.e. not validated at all. Two consequences, the second worse
than the first:

  - arbitrary junk could be written into implementation_notes / decision_log,
    bypassing the append guard BUG-2627 exists to enforce;
  - on a cross-workspace copy, an override could reintroduce the github_pr that
    MigrateFields had just dropped for leaving its workspace — defeating the
    scope rule by the simplest available route.

The copy paths now gate overrides against the stripped schema, so a reserved key
is undeclared there by construction and takes the existing malformed_override
refusal. The MOVE path had no declared-key gate at all and gets a dedicated one
(items.ReservedOverrideKeys). Refused rather than silently dropped: a caller who
asked for a value and got an item without it has no way to tell.

## The preflight emitted reserved keys twice

The carried walk iterated the raw target schema, so a grandfathered declaration
was emitted there AND appended again by the reserved pass. The existing
preflight/copy parity helper collapses carried entries into a map, so it could
not see it — a check that de-duplicates before comparing cannot detect
duplication. The walk now uses the stripped schema.

## Two mutants survived, and both were the test's fault

- The defaults fix had no test at all. Written after the fact, it fails on the
  unfixed code on both halves (injected default, spurious required error).
- The override test passed with the stripping REMOVED, because the ordinary
  destination does not declare github_pr — so UndeclaredOverrideKeys refuses it
  either way. Only a schema that DECLARES the key distinguishes the two
  implementations. The grandfathered fixture added for that fails the mutant
  with the PR link visibly written onto the copy.

Also added the falsy-value legs to StillDropped (false / 0 / "" are
restorations, not absences — a truthiness filter would report them lost) and
drove SchemaForMigratedFields off the canonical set so a mutant stripping only
implementation_notes fails.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3289). No web file touched.

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

* docs(items): correct the scope claim on ReservedOverrideKeys (BUG-2674)

Codex round 5. The previous commit message said reserved keys are refused "on
any path". True only for FIELD-OVERRIDE maps — the same-workspace move, the copy
preflight and the mutating copy. An ordinary `fields` / `fields_patch` map still
reaches them from the CLI, MCP, the web editor, artifact import, and Pad's own
note / decision / convention / GitHub writers, which is by design for the system
writers and a pre-existing exposure for the rest.

The doc comment now says which paths it covers and, more importantly, what it is
NOT — a general write gate. That distinction is the kind a future reader would
otherwise take on trust from the function name.

Round 5 was asked a different question than rounds 1-4: not "what is wrong with
this diff" but "enumerate every path that could meet a declared reserved key,
and is this approach right at all". It found ~10 further latent sites (create,
full and partial update, artifact import, bulk status/priority, terminal
options, unique_scope, computed, the web field editor, search, share
presentation) — all PRE-EXISTING, none regressions from this branch, and all in
the same grandfathered-schema case rounds 3, 4 and 5 kept surfacing.

They are filed as BUG-2685 with the full map rather than patched here. Four
rounds each finding another site is evidence about the DESIGN — reserved
metadata living in the generic fields blob means every schema-aware consumer has
to remember a special rule — and that is TASK-2657's territory, not a bigger
version of this bug. This branch's scope was: a move destroys system metadata.
That is fixed, tested and mutation-verified.

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

* docs(mcp,cli): disclose the move/copy metadata rules where the ACTOR reads them; ToolSurfaceVersion 0.22 (BUG-2674)

Caught by the pre-push step my own record exists for: I had documented this
change carefully in commit messages, the PR body and the item trail — every one
of them read by a human REVIEWING the work — and not at all in the artifacts read
by the agent or operator ACTING on it. That is the same miss twice before, both
times in this exact file.

`field` is accepted for `pad_item.action=move` (catalog_item.go), so the refusal
this branch adds is a limit an MCP agent will hit. It now says so in the param's
own description and in instructions.md, which is the text agents receive at
handshake. CLAUDE.md's `pad item move` and `pad item copy` blocks — the operator-
facing reference — gain the carry rules and the github_pr exception.

## ToolSurfaceVersion 0.21 -> 0.22

BEHAVIOR bump on the v0.9 / v0.16 / v0.17 grounds: no tool, action enum or param
SHAPE changed, but two things an agent can observe did.

A move used to DESTROY implementation_notes / decision_log / github_pr /
convention, silently, and now preserves them; drops of ordinary fields are
reported in the move's activity entry instead of vanishing. And a `field` setter
naming one of those keys answers `malformed_override` instead of writing it —
a write that was never legitimate, since it bypassed BUG-2627's append guard and
could reintroduce a github_pr the migration had just dropped.

Compat posture stated deliberately: a caller passing such a setter today gets a
400 where it previously got a silent corrupt write. Relying on the old behaviour
is relying on a defect — the same reading v0.17 took for the fields-blob
shadowing.

The bump was not free, which is the point: TestInstructionsMDVersionMatchesTool-
Surface and TestReadmeVersionMatchesToolSurface both went red and forced the two
other surfaces to be updated. That is the enforcement working — a version
constant nobody could change without visiting every place it is published.

Gates re-run for this commit: lint 0 · go test ./... 0 · make test-pg 0 (3289).
CI was already 7/7 green on f6775bcb; pushing this restarts it, which is the
correct trade against shipping agent-facing docs that describe the old behaviour.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 16:53:03 -04:00
xarmian d24df55670 refactor(web): delete unmounted components carrying real logic (TASK-2632) (#1163)
A one-shot sweep, not a standing process. Two dead components had been found
incidentally in one week, each discovered only because someone was about to
change behaviour it appeared to depend on -- VersionHistory during BUG-2608
(its apparent liveness would have blocked a default history limit) and
EditorToolbar before it. Retired UI left in-tree costs every future reader who
greps for a component, finds a plausible implementation, and reasons about
behaviour nobody mounts; it also silently constrains fixes.

Instrument, two passes over all 110 components under web/src/lib/components:

1. Plain substring grep of each basename across web/src + web/e2e. Four
   zero-hit. This pass counts COMMENTS as liveness, so it under-reports
   deadness -- conservative in the safe direction.
2. Import/mount-only regex (a from-import of the .svelte path, a dynamic
   import of it, or a <Name element). Six zero-hit; the two extras were
   exactly the comment-shadowed cases pass 1 could not see.

Controls: a known-live component (BacklinksPanel) resolves to its single
consumer under both passes; each of the six candidates then took a repo-wide
plain grep with no include filters, and every surviving hit was read. Pass 2's
one known blind spot -- a component referenced only by vi.mock(path) -- was
checked by enumerating every vi.mock target ending in .svelte; all are
.svelte.ts store/service modules except CommentEditor, which is independently
imported. None of the six is in that set.

Deleted (six dead, two cascade orphans):

- activity/ActivityFeed.svelte -- a live /activity route page and
  TimelineActivityCard both exist; neither touches it.
- charts/LineChart.svelte and charts/layers/Lines.svelte -- from the TASK-1632
  LayerCake library; only BarChart reached the insights pages. Lines had
  exactly one consumer (LineChart), so it falls with it. AxisX/AxisY stay:
  shared with BarChart.
- charts/Sparkline.svelte (TASK-1638) -- its only repo-wide reference was a
  prose comment recording that PLAN-1542 chose not to show it. Zero mounts.
- editor/MermaidRenderer.svelte -- superseded by the MermaidCodeBlock NodeView
  in Editor.svelte, which owns the render queue, toggle and error state.
- versions/VersionHistory.svelte -- the BUG-2608 find. The live path is
  ItemTimeline to TimelineVersionCard to DiffView; DiffView stays.
- attachments/fixtures/LightboxStub.svelte and fixtures/lightboxStub.ts -- a
  pair that referenced only each other. Their last consumer was removed by the
  TASK-2489 atomic cutover, so they were orphaned rather than born dead.

Nothing was reclassified live-but-obscure, so no import-site comments were
owed. Two docs updated so no artifact points at a deleted file: the web README
component tree drops the activity/ line, and the UserOverviewTab comment now
says the Sparkline component was deleted here and is recoverable from history,
rather than leaving a dangling decision record.

Git history is the archive; anything worth resurrecting is one revert away.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 11:39:39 -04:00
xarmian e36be90f05 fix(web): order-and-merge contract for the localIndex cache — tombstones, durable retag overlay, equal-seq merge (PLAN-2636 unit 2, BUG-2633/2634/2635) (#1157)
The persisted localIndex cache decided writes with a bare seq compare that
(a) had no memory of hard deletes, (b) could not arbitrate out-of-band
fields (collection_slug), and (c) blind-accepted at equal seq. Those are one
gap seen three ways. This lands the single write policy + read overlay the
PLAN-2636 unit-2 design checkpoint specifies.

- itemRowMerge.ts (new, pure): resolveRowWrite(stored, tombstone, incoming)
  replaces the boolean shouldWriteRow at both persistence call sites —
  tombstone gate (2633) -> seq guard + no-seq asymmetry (unchanged from 2609)
  -> equal-seq MERGE (2635) -> preserveProjectionMetadata on write. The two
  projection helpers move here verbatim from localIndex.svelte.ts so the IDB
  layer can share them without importing the Svelte store; RAM stays
  bit-identical (pinned by localIndexUnparented.svelte.test.ts).

- Tombstones (2633): new `tombstones` object store, IDB format v1->v2 (row
  shape unchanged, LOCAL_INDEX_SCHEMA_VERSION stays 3). Hard removals in
  persistDelta stamp deletedAtSeq=cursor; persistRemovals stamps the persisted
  meta.sync cursor, or — when no sync has run — the removed row's own seq, so
  an upsert-then-remove before the first sync is still un-resurrectable (codex
  F1). raiseTombstone never lowers an existing stamp, so an out-of-order
  cross-tab eviction can't weaken the gate (codex F4). A stale/seq-less
  snapshot behind the stamp can't resurrect; a strictly-newer write supersedes
  and clears the tombstone. persistReplace clears the store. Old build opening
  a v2 DB gets a VersionError -> memory-only degrade.

- Durable retag overlay (2634): persistRetag also upserts {key:'retags', map}
  in the meta store; hydrate reapplies it to matching rows (by collection_id)
  after the read, so a rename survives a racing older-slug delta and a reload.
  persistReplace drops the key (server rows carry live slugs — BUG-2601).

Tests: itemRowMerge.test.ts (pure decision matrix), localIndexPersistence
.unit2.idb.test.ts (tombstone/overlay/cross-tab/migration outcomes through
fake-indexeddb), harness raw-readers for tombstones + retags. Every regression
mutation-verified discriminating — including the codex-round-1 fixes: neuter
tombstone gate / blind-accept equal seq / skip overlay-apply / drop preserve /
break the shared helper / skip pre-sync tombstone (F1) / blind tombstone
overwrite (F4) / ignore overlay membership (F5) — each reddens exactly the
matching test across RAM and persistence. shouldWriteRow removed (superseded);
its sibling test repointed.

Gates: npm run test 1730 passed (98 files); npm run check 0 errors;
check:tiptap-pins OK; vite build clean. WEB-ONLY, zero Go, no dep churn
(fake-indexeddb already on main from unit 1).

Codex round 1: F1/F4/F5 fixed above. F2 (persistReplace clears tombstones for
omitted ids — pre-existing cross-tab window, self-healing) and F3 (durable
overlay can revert a newer authoritative slug after a missed rename SSE; no
local disambiguator — collection_slug is out-of-band) are lead-accepted as
documented residuals (F2 at persistReplace, F3 at the hydrate overlay-apply
site, each with its trigger + heal paths). True F3 disambiguator would be
server-side collection-slug versioning; ruling on the PLAN-2636 trail.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 17:42:29 -04:00
xarmian b759898d5b test(web): fake-indexeddb harness for the localIndex persistence layer (PLAN-2636 unit 1) (#1156)
The localIndex IndexedDB persistence layer ran as a no-op under vitest —
there is no IndexedDB in the node test environment, so `isSupported()`
returned false and every persist/hydrate call short-circuited. The entire
layer, and BUG-2609's seq-guard fix, shipped on live-browser evidence runs
only (the #1148 finding). This adds the harness that makes it testable, the
prerequisite for unit 2's order-and-merge regression matrix.

- fake-indexeddb dev dependency (exact-pinned).
- A dedicated `idb` vitest project (glob `*.idb.test.ts`, node env) whose
  setup installs fake-indexeddb's globals and a fresh IDBFactory per test.
  It self-disables when the dep can't be resolved — mirroring the jsdom
  project — so a symlinked worktree without it keeps `npm run test` green,
  and CI activates it once installed. The idb glob is excluded from the node
  project so the persistence layer can't no-op there and pass vacuously.
- Harness helpers unit 2 builds on: a second cross-tab connection to the
  same database (2635), a v1-database seed + higher-format-version reopen +
  downgrade VersionError (the v1→v2 migration exercise), a fresh-module
  loader that clears the connection cache, and raw ground-truth reads.
  `harnessDbName` mirrors the module's `dbName` exactly, pinned by a test so
  a drift can't make assertions read an empty sibling database.
- BUG-2609's evidence run is ported as a deterministic sequential regression:
  a newer delta commits its atomic rows+cursor transaction, then a stale
  older-seq snapshot lands last and is refused (IDB serializes overlapping
  transactions, so no interleaving control is needed). Plus a raw
  serialization characterization pinning that platform guarantee.

No production code changes.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 15:20:13 -04:00
xarmian b5f0cd3963 feat(web,server): render embedded image attachments on share pages via a variants-only byte endpoint (BUG-2389 2b, TASK-2637) (#1153)
Merged by lead on accepted infra cancellation, Dave-approved in chat (day-44). Evidence basis: E2E tests demonstrably pass at pinned SHA d3c8234e — run 1: 191 passed (3.7m), job cancelled by CI/Nix twin-run concurrency race; run 2: 190 passed + 1 flaky (pane-controller, PLAN-2154's known flake, unrelated to this diff), job cancelled by the 10-minute cap after the flaky retry; run 3: rerun expired inside terminal-cancelled parent at 38s, no test signal. All six code-testing checks green (Go, Go-PG, Web, Nix, Smoke×2). The gate defect is filed as BUG-2645 (cap breachable by one flaky retry + twin-run race); the fix ships as its own reviewed workflow unit. Lead spot-check of the diff and full pin inventory: TASK-2637 trail.
2026-08-18 12:26:00 -04:00
xarmian 482815aa58 feat(web): composer + quick actions target armed sessions with honest counts (PLAN-2613 S4, TASK-2619) (#1151)
* feat(web): push composer + quick actions target armed sessions with honest counts (PLAN-2613 S4, TASK-2619)

Presentation truthfulness (D3): only an armed session receives a push (the
server filters delivery to armed sessions), so connected is not the same as
accepting. Surfaces that decided push-vs-copy or enabled Send on the raw
connected count would fire-and-forget into a connected-but-unarmed session and
lose the instruction.

- LiveSession TS type gains `armed` (S1 shipped it server-side; the web type
  had not caught up).
- PushToAgentDialog: counts and targets the ACCEPTING (armed) subset. The
  presence line shows the split honestly — "M sessions accepting pushes
  (of N connected)", and the "N connected, 0 accepting pushes" empty state with
  /pad:connect enable instructions rather than hiding connected-but-unarmed
  sessions behind a bare zero. Send is gated on accepting > 0; the picker offers
  only armed sessions; broadcast reaches only accepting sessions (server-
  filtered). Degrades to N == M once the S3 rollout completes.
- QuickActionsMenu: routes push-vs-copy on the accepting count, so a quick
  action copies (never silently pushes) when nothing is accepting; tagline and
  the shared dispatch copy say "accepting pushes", not "connected".

No new server state — this consumes S1's per-session armed bit from
GET /api/v1/sessions.

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

* fix(web): quick-actions tagline shows the accepting-of-connected split (Codex R2)

The menu tagline showed only the accepting count, hiding connected-but-unarmed
sessions — the split D3 wants for quick actions as much as for the composer.
PushPresence now carries `connected` alongside the accepting `count` (routing
still keys on accepting), and the tagline renders "M accepting session(s)
(of N connected)", plus the "N connected, 0 accepting pushes — run /pad:connect
to enable" state instead of collapsing to a bare zero.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 01:07:35 -04:00
xarmian 2322fb273f fix(web): give the IDB write path the seq guard RAM already had (BUG-2609) (#1148)
* fix(web): give the IDB write path the seq guard RAM already had (BUG-2609)

`upsert` and `applyRetag` hand persistUpserts a snapshot taken from RAM and do
not await it. An SSE delta for the same row can commit its own atomic
rows+cursor transaction in between, after which the older snapshot lands LAST:
IndexedDB then holds a pre-delta row while the persisted cursor sits past that
delta. Warm boot hydrates the stale row and `/items-changes?since=cursor` never
returns it again, so it stays stale until the item happens to change. RAM is
unaffected — single-threaded and already seq-guarded by mergeRow — which is why
this only ever showed up as a warm-boot regression.

The fix is the guard localIndex has had in RAM all along, applied at the layer
that lacked it: a read-modify-write inside the transaction, comparing against
what is STORED at write time rather than what was in RAM at snapshot time. Both
writers get it, because the race runs in both directions — a delta must not
overwrite a row that is already newer in the cache either.

Three boundary decisions, each with a reason rather than a default:

  - EQUAL seq still writes. RAM merges same-seq projections before persisting,
    so the incoming row IS the merged one; refusing it would drop that merge
    and leave the cache behind RAM with no seq difference left to correct it.
  - A MISSING seq on either side writes. Absence is not evidence of being
    older, and refusing would silently disable the cache for any row the server
    has not stamped.
  - seq 0 is a value, not a blank. A falsy check here would let a stale row
    through, so the comparison tests for undefined explicitly.

Worth stating because the outcome looks lossy and is not: `applyRetag` rewrites
collection_slug WITHOUT bumping seq, so a delta at a higher seq now SKIPS the
retag write. That leaves IDB agreeing with the persisted cursor while its slug
lags RAM — which self-heals through the sync-pass reconcile (BUG-2601). What it
replaces does not self-heal: a row behind the cursor is invisible to delta sync
by construction.

VERIFIED IN A REAL BROWSER, because the failure mode of getting this wrong is
silent. Awaiting inside an idb transaction risks the transaction auto-closing
mid-loop, after which the remaining puts throw into a swallowed catch and the
cache quietly stops being written — worse than the bug. In-repo precedent said
it was safe (hydrate already awaits a get and keeps using the same tx), and a
live run confirmed it: 2625 rows persisted, all seq-stamped, cursor advanced,
zero page errors. The instrument was checked against a control build whose
guard refuses every write — 0 rows, cursor still advanced, which is the
cursor-ahead-of-rows divergence this bug is about, so the check demonstrably
reads the thing it claims to.

The decision itself is covered by unit tests, each mutation-verified against
the specific assertion written for it (always-allow, strict-greater-than, and a
falsy seq check each fail only their own cases).

* fix(web): route retags through a field-level write, and stop overclaiming the guard (BUG-2609)

Codex round 1. The P2 is a correction to my own commit message, and the more
important of the two findings.

I wrote that a retag write skipped by the seq guard "self-heals through the
sync-pass reconcile (BUG-2601)". It does not, and the code says so where I
should have read it: `applyRetag` does not bump seq, a collection rename
touches no items so no item delta ever re-stamps them, and localIndex's own
comment states pendingRetags is "not persisted (the window it guards is within
a single session)". A persisted slug that loses its rename stays wrong across
reloads with nothing left to correct it — so my guard would have turned a
last-write-wins race into a permanent staleness.

That is the second time today I stated a mechanism I had not read, and this
one made it into a commit message as the justification for shipping.

Fixed by design rather than by rewording. A retag is a FIELD-LEVEL intent:
"these rows are in a collection that got renamed". Expressing it as a whole-row
put makes a second claim — that every other field still matches a RAM snapshot
— and it is that claim the guard has to refuse. persistRetag reads each row
inside the transaction and changes only collection_slug, so the newer row's
fields survive AND the rename lands. Rows absent from the cache are skipped:
nothing to rename, and inserting a snapshot there would resurrect rows a delta
may have removed.

Codex's P1 — a delayed snapshot can resurrect a HARD-deleted row, because a
deleted row leaves no `existing` for the guard to compare against — is real,
pre-existing (a blind put resurrected it too), and not fixed here. Refusing it
needs a tombstone carrying the seq it was removed at, i.e. an IDB schema change
plus a version bump. Filed as BUG-2633.

The guard's doc no longer implies it covers either case. It now names both
exclusions, which is what it should have said before Codex had to ask.

Re-verified live after the redesign: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

* docs+test: name the direction persistRetag does NOT close, and stop the tests implying coverage (BUG-2609)

Codex round 2, three findings.

P1 is real and I had only closed half the problem. persistRetag stops a late
RETAG from being refused, but a delta captured BEFORE the rename can commit
after it, whole-row put an older collection_slug at a NEWER seq, and pass the
guard legitimately. The root shape is that collection_slug is OUT-OF-BAND
relative to the item's own version — it changes without seq changing — so no
seq comparison can arbitrate it in either direction. Pre-existing (a blind put
lost the same race) and filed as BUG-2634 with the fix that actually closes it:
make the rename DURABLE, persisting the retag intent and reapplying it on
hydrate, the way localIndex already does in RAM with pendingRetags. That is a
different mechanism from this bug's guard, which is why it is filed rather than
folded in. The guard's doc now names the direction it does not cover instead of
implying it covers renames outright.

The other two are accuracy defects in what I wrote. A comment still said
`applyRetag` hands snapshots to persistUpserts, two paragraphs above the
exclusion list explaining that it no longer does. And the new test block read
as persistRetag coverage while testing only shouldWriteRow — a no-op
persistRetag would pass this entire file. It now says so in its name and its
doc, and the harness note says the same about the live browser run: that was
evidence the write path commits, not a regression test, and it will not run
again on its own.

Three of this bug's four review findings have been about my prose claiming more
than the code does, which is worth noting as the pattern rather than fixing
quietly a third time.

* docs: name the equal-seq residual where the decision is made (BUG-2609)

Codex round 3. Equal seq is permitted, and the justification I gave — RAM has
already merged the projection — holds WITHIN a tab and not across them: a tab
that never had the row in RAM can persist an unmerged snapshot over another
tab's merged one at the same seq.

Measured the reachability rather than accepting the report as stated. After
this branch, persistUpserts has exactly one caller, and `localIndex.upsert`
returns early when `next.seq <= existing.seq` — so it cannot persist an
equal-seq row when it HAS the row in RAM. The regression needs two tabs, the
writing tab lacking the row, and the other having merged a same-seq projection.
Narrow, and strictly better than before this branch, when a blind put regressed
rows at ANY seq.

Not fixed here because accept-or-refuse is the wrong axis: refusing would
mirror the bug onto persistDelta, where a re-delivered same-seq row carrying
computed projection fields would be skipped. What equal seq actually wants is a
MERGE, which this layer does not have and which is a deliberate layering
decision (persistence importing projection semantics). Filed as BUG-2635.

That makes three residuals from this one bug, and they are one family rather
than three coincidences — the persisted cache has no total order and no merge
semantics of its own. BUG-2633 (deletes have no seq to compare against),
BUG-2634 (collection_slug is out-of-band relative to seq), BUG-2635 (equal seq
needs merging). Each has a distinct fix, but they touch the same two functions
and share the same testing prerequisite, so 2635 carries the cross-links and
suggests triaging them as a batch — one pass giving the cache real
order-and-merge semantics beats three patches.

* fix(web): refuse a seq-less snapshot over a stamped row (BUG-2609)

Codex round 4, and this one was a real defect in my guard that my own test
enshrined.

I treated a missing seq symmetrically — "absence is not evidence of being
older, so write it" — which is true in one direction and wrong in the other.
The optimistic reorder path deliberately clears `seq` so the row bypasses
localIndex's RAM guard and the drag paints immediately (TASK-1357, verified at
the call site). Persisting that copy is incidental to the intent, and my rule
let a delayed seq-less snapshot overwrite an authoritative row that had already
landed at a real seq.

The result is worse than an ordinary stale row: the persisted row then has NO
seq at all, so neither this guard nor the RAM guard can order it on the next
warm boot, while the cursor sits past the delta that would have corrected it.

Now asymmetric, with the reasoning in place: an incoming row carrying ordering
evidence beats a stored row with none; two unstamped rows have nothing to
arbitrate; a stored STAMPED row refuses a snapshot that has no seq. Refusing
costs the reorder nothing — RAM still shows the optimistic order, the
authoritative response persists with a real seq moments later, and the cursor
has not advanced past that response, so a warm boot in between simply refetches
it.

The test that asserted the old behaviour has been replaced rather than
adjusted, and the mutation restoring the symmetric rule now fails only the new
case. Re-verified live after the change: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

Distinct from the three filed residuals — this is the guard being wrong, not a
gap it deliberately leaves.

* fix(web): re-check collection membership inside the retag transaction (BUG-2609)

Codex round 5, a defect in the function I added last round. persistRetag read
each row by id and applied the renamed collection's slug without re-checking
that the row was still IN that collection. A row that moves between the RAM
retag and this transaction would then be persisted with a collection_id and a
collection_slug that disagree — behind the cursor, so no delta repairs it.

The irony is the point: persistRetag exists BECAUSE trusting a RAM snapshot at
write time is unsafe, and it went on trusting the snapshot's collection
membership. Re-reading the row was never the whole fix; re-checking what the
row says is.

The per-row decision is extracted as shouldApplyRetag so it is reachable by
tests at all — the transaction itself is not, in a harness with no IndexedDB —
and it now covers three refusals with a reason each: a row that moved
(mismatched id/slug persisted behind the cursor), a row already carrying the
new slug (idempotence), and an absent row (inserting one would resurrect it
behind the cursor, the BUG-2633 shape).

Each mutation-verified against its own assertion: dropping the membership check
fails only the moved-row case, and inserting absent rows fails only the
resurrection case. Re-verified live: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

* docs: correct two claims this file made about itself (BUG-2609)

Codex round 7, both accuracy defects in my own prose after five rounds of
edits to it.

shouldWriteRow's opening line still said it returns false ONLY for strictly
older rows, which round 4 made untrue — it also refuses a seq-less row over a
stamped one. The paragraph below described the exception correctly, so the
summary contradicted its own body. Now states both cases up front.

The test file asserted "jsdom has no IndexedDB", and this file does not run in
jsdom: a plain .test.ts belongs to vitest's `node` project, per vitest.config.
The conclusion happened to hold — Node has no indexedDB global either — which
is the part worth flagging: right answer, wrong stated reason, and I had not
opened the config before writing it. It also claimed every function in the
module is a no-op under vitest, which is false of the exported decision
helpers being tested two screens above; that distinction is the whole reason
they were extracted.

Fixing the first attempt at this broke the file, and the cause is worth
recording: the glob I wrote to name the vitest project contained the character
pair that ENDS a block comment, so the doc terminated early and the rest parsed
as code — 19 type errors and a suite that reported "no tests". Same family as
backticks inside a double-quoted shell string: content carrying a delimiter the
surrounding syntax acts on. Rephrased to avoid the sequence rather than
escaping around it.

* docs: name the abort trigger at persistRetag, folded into BUG-2634 (BUG-2609)

Codex round 9. persistRetag is best-effort like everything in this module, so
an aborted transaction (quota, eviction, tab freeze) loses the rename outright
— and a lost rename does not self-heal for the same reason it cannot be
reordered: no item delta re-stamps those rows, and pendingRetags is in-memory.

Not a fourth filing. It is BUG-2634 reached by failure instead of by racing,
and the fix already proposed there — persist the retag INTENT and reapply it on
hydrate — closes both, because a recorded intent survives a failed write as
readily as a lost race. The real defect is that a rename is persisted as an
EFFECT with no durable intent, which makes it losable by anything.

Recorded on BUG-2634 so whoever takes it builds for both triggers (an
ordering-only fix would leave the abort case open and look complete), and noted
at persistRetag so a reader there meets the limit rather than inferring the
function is reliable.
2026-08-17 21:10:17 -04:00
xarmian 6f16003199 fix: surface implementation notes + decision log in the item timeline (BUG-2301) (#1144)
* fix(server): merge implementation notes + decision log into the item timeline (BUG-2301)

`pad item note` and `pad item decide` have written structured entries since
c61f4cda, and 998716ae deleted their renderer the next day as collateral of
the unified-timeline PR. The write paths kept working on CLI and MCP, so the
entries accumulated with no read surface outside `pad item show`.

Surface them as two more timeline kinds rather than rebuilding a separate
renderer: the endpoint already merges comments, activities and versions under
cursor pagination, and notes/decisions carry the same timestamp/actor/body
shape the merge handles.

They differ from the other three kinds in one way that matters. They are
elements of the item's fields blob, not rows, so they arrive whole on the
already-resolved item instead of through a cursor query. Without an explicit
filter they would therefore repeat on every page, so structuredTimelineEntries
applies the same (created_at, id) predicate the SQL sources use.

The blob is also hand-writable, which makes three shapes representable that a
table would not, all covered:

  - no created_at: anchored at the item's own creation instant, the earliest
    moment the entry could have existed. A zero-time fallback would render as
    1970 and sort below everything real.
  - no id: positional fallback, keeping the sort total and the cursor stable.
  - not an array at all: models.ExtractItem* already returns nil, so it
    contributes nothing. One live docapp item is in exactly this state
    (double-encoded JSON string) — filed as BUG-2627, a different defect.

Every guard here was mutation-verified: dropping the merge, neutering the
cursor predicate, and removing each of the two fallbacks in turn each fail
the tests that cover them. That pass also caught a vacuous assertion in the
actor test, which now counts the entries it asserts on (CONVE-12).

Frontend wiring follows in the next commit; the kinds are invisible until
ItemDetail's visibleKinds whitelist admits them.

* fix(web): render note + decision timeline entries and admit them to the tab filter (BUG-2301)

The server half is inert without this. `visibleKinds` is a WHITELIST with one
live call site, so a kind ItemDetail does not list renders on NEITHER tab — a
perfectly merged feed and an empty Activity tab, which is how this feature
shipped invisible the first time.

Two halves, both needed and both covered by mutation-verified tests:

  - ItemTimeline gains render branches for the `note` and `decision` kinds
    plus their rail dots. Without a branch the entry falls through the {#if}
    chain and draws an empty rail.
  - ItemDetail admits both to the Activity set. They belong there rather than
    with Versions: they record things that happened to the item, not restore
    points.

One TimelineStructuredCard serves both kinds. They share a shape — headline,
optional body, actor, timestamp — and differ in label, accent and weight, so a
variant keeps them from drifting the way two near-identical components would.
A decision carries the heavier treatment: it is the thing you go back looking
for.

Body text renders as plain text with `white-space: pre-wrap`, never through
the markdown pipeline, because that is what the writers produce — `pad item
note --details` and `--stdin` take raw text. A test pins that markup in an
entry stays inert.

The actor label reads the entry's self-declared `created_by`. That field lives
inside the item's fields blob and no server stamps it (BUG-2542), so the label
reports a claim, not a verified author; the comment in the card says so.

* docs(skill): document `pad item note` / `pad item decide` now that they have a read surface (BUG-2301)

The bug's own measurement found 185 notes and 33 decisions across seven
workspaces written by people and agents who found these commands on their
own — nothing in the skill, no convention, no playbook ever mentioned them.
That was defensible while the entries were invisible outside `pad item show`;
it is not once they render in the item timeline.

Flag names verified against the built binary's `--help` rather than the
source, since the skill is what an agent acts on.

* test(server): assert timeline paging is exactly-once, on both drivers (BUG-2301)

The single-page cursor assertions cover the predicate but not the property
that matters to a reader scrolling an item: every entry appears exactly once
across the whole feed. A too-loose predicate repeats the in-blob entries on
every page and a too-tight one drops them at a boundary, and neither is
visible from one page.

Run on Postgres as well as SQLite because there is a genuine seam here: the
structured entries are filtered in Go against a parsed time.Time while the
comment/activity/version sources are filtered in SQL against a formatted
string, and this endpoint has a Postgres-specific paging history (BUG-1086,
the \xff sentinel). Portability is asserted, not assumed.

The Postgres leg asserts the driver before doing anything, so it cannot pass
by silently re-running SQLite — verified both ways: it SKIPs without
PAD_TEST_POSTGRES_URL and PASSes with it. Mutation-verified too: neutering
the cursor predicate fails the leg on both drivers.

* fix(server): align the structured cursor with the SQL predicate and make blob ids unique (BUG-2301)

Three defects from Codex round 2, all in the cursor path this change added.

1. The "g" sentinel split the two kinds on their first letter. When a client
   sends `before` without `before_id` the handler substitutes "g" — an upper
   bound whose whole job is to KEEP same-second entries, and which does that
   only because every lowercase-hex UUID character sorts below it. Structured
   ids are not UUIDs: `note-…` sorts above "g" and `decision-…` below, so
   comparing against it literally dropped every note at the cursor instant
   while keeping every decision. The handler now says whether beforeID is
   synthetic, and the filter honours what the sentinel MEANS.

2. Two comparison spaces met on one page boundary. The SQL sources format the
   cursor to whole-second RFC3339 text and compare against a text column,
   while this filter compared full-precision time.Time. A structured entry can
   carry sub-second precision — a hand-written created_at, or the item's own
   createdAt standing in for an absent one — so the two predicates could
   resolve the same boundary differently and drop or repeat entries around it.
   Both sides now compare formatted whole-second text; the seam is removed
   rather than compensated for.

3. Duplicate ids were trusted. Nothing validates them on write, and a repeat
   is not cosmetic: it collides in the client's keyed {#each} (a hard render
   error), the client's loadMore dedupes by id and would drop the older entry,
   and the cursor cannot page past two entries it cannot tell apart. Repeats
   now take the same positional fallback an absent id takes, in one map shared
   across both kinds since they land in one merged stream.

Round 2's fourth item was a test gap rather than a defect, and is closed here
too: the paged walk asserted only that the three structured ids appeared once,
so a boundary mismatch that repeated a COMMENT or a VERSION would have passed.
It now asserts no entry of any kind repeats.

Round 1's only finding — structured entries do not live-refresh because the SSE
filter excludes item_updated — is DECLINED and recorded on the item. That
exclusion predates this diff and is deliberate (refreshing on every content
save caused visible shakiness and rate-limit errors); version entries already
carry the identical staleness, and these kinds have no web writer at all, so
no user acts and waits on one.

Each fix has its own negative control: removing the sentinel branch, reverting
to full-precision comparison, and trusting raw ids each fail exactly the test
that covers them.

* fix(server): truncate structured entry timestamps to the shared whole-second space (BUG-2301)

Codex round 3, P1 — and a correction to the previous commit, which fixed the
comparison and left the value itself alone. Filtering in formatted whole-second
text made the PREDICATE agree with SQL, but the entry still carried
full-precision time, so two paths stayed wrong:

  - the merge sorts on TimelineEntry.CreatedAt, so a fractional structured
    entry interleaved against same-second rows by a component those rows do
    not have, in an order the SQL ORDER BY cannot reproduce.
  - the client echoes the last entry's created_at back as the next page's
    `before`, where the store formats it down to the second. A cursor of
    10:00:00.5 becomes 10:00:00Z and EXCLUDES same-second rows that were still
    owed — silent data loss in comments and versions, sources this change
    never touched.

Truncating where the entry is built puts it in the same space as every other
source for all three purposes at once, which is what the fix should have been
the first time. Covered end to end: a fractional entry at a page boundary must
not cost a same-second row on the next page.

Round 3's P2 (a `has_more` heuristic that can stay true without pagination
progress when an over-fetched source is emptied by dedup) is NOT addressed
here. It is pre-existing — the heuristic and the discards it counts on both
predate this branch, and structured entries are never discarded by
buildTimeline, so this diff neither causes nor worsens it. I have not
reproduced it; recorded on the item for triage rather than asserted as real.

* fix: render payload-less structured entries, and make the fractional-boundary test actually discriminate (BUG-2301)

Codex round 4, all three findings.

The important one is against my own test. The fractional-timestamp regression
test walked two structured entries and no SQL-sourced row, so the data loss it
was named for could not occur in it — and confirmed by mutation: with the
truncation removed it still passed. Reworking it to include a real comment at
the note's own second was not enough either, and the reason is worth writing
down: the cursor's second term is the id, the SQL sources keep same-second rows
with `id < before_id`, and a realistic `note-<nanos>` id sorts ABOVE every
lowercase-hex UUID. The sibling row was rescued by the tie-break no matter what
the timestamp did. With an id below the UUID space the loss is reachable, and
the test now fails on the unfixed code by dropping the comment outright.

Two rounds of a correct-looking test that could not fail. The tell both times
was the same: I checked that the test passed with the fix and not that it
failed without it, on a fixture I had reasoned about rather than run.

Also:
  - A structured entry whose payload is missing now still renders its card.
    Guarding the branch on the payload left the rail dot and connector drawn
    beside nothing, which reads as a broken render rather than a thin entry;
    the card was already null-safe. Covered, and mutation-verified by
    restoring the guard.
  - Corrected a comment that claimed a zero-time fallback renders as 1970. Go's
    zero time is year 1, not the Unix epoch.

* docs(models): qualify the timeline paging claim to the static-dataset case (BUG-2301)

Codex round 5. The finding — the five sources are read at five instants with
no shared snapshot, so a concurrent note write can land between the item
resolve and the activity query and put one page briefly out of step — is real
but is NOT fixed here, deliberately:

  - It is the endpoint's existing shape, not something the structured kinds
    introduce. Comments, activities and versions were already three separate
    reads at three instants; this adds a fourth source, not a fourth class of
    problem.
  - Nothing is durably lost. The blob is authoritative and the very next fetch
    is consistent; the window is a request's worth of milliseconds on a
    read-only feed.
  - Every fix that would actually close it (a shared snapshot or a read
    transaction spanning all five sources) is a change to the endpoint's
    contract and the store's API, which is not something to do inside a bug
    fix for a missing renderer.

What IS wrong and is fixed: my own comment claimed paging "behaves identically
for all five" without qualification, and the earlier commit claimed exactly-once
paging flatly. Both are true over a stable dataset and neither said so. That is
the failure mode I keep hitting from the other side — being precise in the
artifact I am editing while an unqualified claim sits where the next maintainer
will actually read it. The type's doc comment now states the limit and says
whose problem it is.

* docs(web): record why the structured kinds inherit the timeline's SSE staleness (BUG-2301)

Codex raised the live-refresh gap twice and it was declined twice, which is
itself the signal that the reasoning belonged in the code rather than in a
review thread. The exclusion's comment now says what the two structured kinds
inherit from it and why admitting item_updated would be a bad trade.

* docs(server): name the cursor sentinel's UUID assumption at the sentinel (BUG-2301)

Lead's pre-merge ask, and the existing text was worse than merely silent: case
3 stated that the "g" sentinel keeps same-second entries, full stop. That is
true only for ids from the lowercase-hex UUID alphabet. Anything sorting above
"g" is dropped at the cursor instant instead, and a source whose ids straddle
it is split in half on their first character — which is exactly what happened
to `note-…` and `decision-…` here.

So the assumption is now named where someone adding a non-UUID id will read
it, rather than only in the helper that already works around it. An unqualified
claim at the point of use is the failure mode I keep meeting from both sides;
this is the same fix as qualifying the paging comment two commits ago.

Comments only — no behaviour change.
2026-08-17 12:58:09 -04:00
xarmian cc26288794 fix(web): share pages render attachment refs as honest placeholders (BUG-2389) (#1135)
The public share route (/s/{token}) rendered item content with a bare
marked() call, so pad-attachment: references fell through as broken
<img src="pad-attachment:..."> tags and dead links. Two halves:

1. CommentThread.svelte is deleted outright — grep proved it was
   unmounted dead code (its only reference was a prose mention in
   ItemDetail.svelte), so its half of the bug resolves by deletion
   rather than by fixing a component nothing renders.

2. The share route now renders through a new opt-in wrapper,
   renderMarkedWithAttachments(), which threads an AttachmentRenderContext
   into the existing marked renderer hooks. With a null resolver and the
   new renderAttachmentUnavailable() placeholder, every ref becomes an
   honest "Attachments aren't available on shared pages yet" chip —
   deliberately NOT the "missing or has been deleted" wording, because
   the attachment exists; the share surface just cannot serve its bytes.
   Sanitization is unchanged: the wrapper returns unsanitized HTML and
   the share page keeps its single DOMPurify pass.

The `missing` hook is a parameter (default: renderAttachmentMissing) so
authed surfaces keep their existing wording, and the wrapper clears the
module context in a finally block so bare marked() callers are
unaffected (pinned by test).

The token-scoped byte endpoint that would serve real images on share
pages (2b) is deliberately NOT built here — it adds a new
unauthenticated ACL surface and is tracked separately pending approval.
A real resolver through the same wrapper is the plug-in point (pinned
by test).

Tests: markdown.shareAttachments.test.ts (6 unit legs incl. bare-marked
opt-in control and context-clearing) and
bug-2389-share-attachment-placeholder.spec.ts (e2e: real upload → item
ref → item share link → anonymous visit; verified failing on the
pre-fix build).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 05:19:25 -04:00
xarmian 2521e3e1c7 fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)

In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions () and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).

Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).

Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).

* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)

* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions

* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
2026-08-17 03:24:24 -04:00
xarmian fbea9484d6 fix(web): source-identity guards on the SSE non-sync listeners (BUG-2611) (#1131)
close() does not retract already-queued event tasks, so on a fast
workspace switch a torn-down EventSource's queued events could fire
after the next workspace's source existed. BUG-2540 guarded onopen /
onerror / 'connected'; the remaining listeners had no guard, so
workspace A's stale events dispatched into workspace B's callbacks —
spurious sync passes and cross-workspace item events fanned onto B's
BroadcastChannel, and the sharp member: a stale 'unauthorized' closed
B's LIVE EventSource, flipped the status indicator, and cleared
currentWorkspace over A's auth state, with nothing reconnecting until
a navigation.

Same one-line guard on all four (sync_required, items_bulk_updated,
unauthorized, the ITEM_EVENTS loop); unauthorized additionally closes
its OWN source rather than whatever eventSource currently points at —
the guard has just proven they are the same, and the old shape is what
made the stale path destructive.

Unit harness (BUG-2540's stubbed-EventSource pattern): fast A→B
switch, fire each event type on the torn-down source — zero dispatch
into B — with a live-source control arm per leg so a guard silencing
both cannot pass. All four legs verified failing on the unguarded
build.
2026-08-17 02:07:07 -04:00
xarmian 54526c5b33 fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602)

Seven sites assigned ItemDetail's collection snapshot under fences that
ordered STARTS, and loadData's cross-collection escape hatch admitted
any generation-stale write that fetched a different collection — so a
loadData continuation spanning a cross-collection MOVE restored the
SOURCE collection over the freshly adopted TARGET (the live item,
itemGen-fenced, kept the move: the pane rendered the item against the
wrong collection's schema).

All writes now route through adoptCollection, backed by the pure
shouldAdoptCollection decision: (1) a snapshot disagreeing with the
LIVE item's collection_id is vetoed regardless of freshness — id, not
slug, so renames still apply and a reused slug can't satisfy it (this
also closes a latent foreign-write in the SSE collection_updated
refresh, which fetched by slug); (2) same-collection refreshes keep
newest-started-wins; (3) the legitimate cross-collection correction
the old hatch existed for still lands when the live item agrees.

On the veto path, an embedded pane whose collection was still null
(fresh mount — refreshCollectionIfMoved's !collection guard skips
there too) converges on the live item's collection instead of being
left schema-less (adoptOrConvergeToLiveCollection); non-embedded
masters stay route-authoritative per the existing policy.

e2e reproduces the filed race deterministically (route-hold on the
realColl fetch, API move mid-hold, release): the control build renders
the moved item against SrcMarkerField's schema verbatim; the fixed
build converges on the target's.

* fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e

* fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims

* fixup: two stale comments (codex round 5 docs)
2026-08-17 00:27:34 -04:00
xarmian 904878522a fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) (#1127)
* fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601)

Two stranding layers, both from the same root: a collection rename
changes the slug without touching items, so nothing item-shaped ever
re-announces it.

1. ROUTE: delta-sync catch-up covers item changes only, and /changes
   says nothing about renames — a rename-only gap even reports
   caught_up — so a client that missed the collection_updated SSE
   (replay gap, disconnect) kept a dead route slug; slug-keyed fetches
   404'd until a manual navigation. The collection route now reconciles
   its slug against the live collections list by STABLE collection id
   on every sync pass (resolveSyncRenameTarget — pure, unit-tested —
   wired via reconcileRouteCollectionSlug), mirroring the BUG-2272
   SSE/reorder-404 heals and sharing the renameNav intent tracker.

2. DATA (discovered by this fix's own e2e, present on the LIVE SSE
   path too): cached localIndex rows keep the old collection_slug —
   rows only re-stamp when the item itself changes — so EVERY
   rename-healed route rendered an empty board while the sidebar
   counted the items. New localIndex.retagCollection re-stamps rows by
   stable collection id (search + IDB write-through, upsert pattern),
   called from the workspace layout's global collection_updated
   subscriber (any route, live SSE) and from the sync heal (missed
   SSE, where the layout subscriber also missed the event).

e2e covers both paths with an aborted-SSE missed-event leg and a live
SSE leg; both specs fail on the pre-fix build (verified) and the
missed-SSE spec guards its own vacuity (asserts the strand before
triggering the heal).

* fixup: codex round 1 — foreign-snapshot gate on sync heal, pendingRetags for pre-hydration renames, layout loadCollections widened, goto-failure renameNav reset, worker-unique e2e slugs

* fixup: stamp pendingRetags with recording user's identity; discard on mismatch at warm-hydrate apply (codex round 2)

* fixup: heal the full-page item route's collection segment on sync pass too (codex round 3 — the bug body's own example route)

* fixup: reconcileCollectionSegment switch-safety — pre-await fence, destroyed guard, identity-compared bridge cleanup (codex round 4)

* fixup: compare the read-back $state proxy, not the raw literal (codex round 5)

* fixup: codex round 7 — navigating guard on both healers, replaceState on the list heal, null-owner adoption for pendingRetags, in-window vacuity pins in e2e
2026-08-16 20:52:32 -04:00
xarmian cada8777e7 fix(web): full-page pane host gets its own navigate-away handling (BUG-2178) (#1123)
The full-page item host reused the shared controller's
handlePaneNavigateAway, whose goto targets are collection-host-shaped
(the base route IS the collection page). On this host both emits
abandoned the master: a pane collection-rename landed on the renamed
collection's root page, and a pane item-move hard-navigated to the
moved item's full page.

The host now wires planFullPageNavigateAway (new pure planner in
paneController.ts, next to planPaneDrill and friends):

- COLLECTION RENAME (keeps-pane emit): IGNORE. Nothing the host owns
  is invalidated by the emit alone — if the renamed collection is the
  MASTER's, the master ItemDetail's own BUG-2272 SSE rename handler
  already gotos the new-slug URL with the full search string (?item=
  included), so the route self-heals with the pane intact; a foreign
  collection never touched the master route. The embedded pane needs
  no URL change either — it trusts item.collection_slug.
- ITEM MOVE (no ?item=): RETARGET the pane to the moved item's slug
  via the existing drill machinery (navigatePaneTo), which preserves
  the master pathname by construction and handles depth/ownership/
  focus. When ?item= already held the slug (a same-workspace move
  keeps slugs, so the drill same-ref-guards to a noop) the pane
  self-heals via the item_updated SSE refetch instead.
- Malformed/pathless URLs: IGNORE — staying on the master beats
  navigating somewhere unparseable. (decodeURIComponent throws on
  malformed percent sequences; the hostile-input unit test caught
  that crash before it shipped.)

The controller's handlePaneNavigateAway is annotated collection-host-
only; every property of its spec comment is untouched for that host.

Tests:
- planFullPageNavigateAway unit table (both real emit shapes verbatim,
  encoded slugs, trailing slash, malformed/empty/foreign-origin).
- E2E (pane-full-page-capstone.spec.ts): move the PANE item to another
  collection from the docked pane; assert the pathname never leaves
  the master route and ?item= retargets to the moved slug. Mutation-
  verified against a control binary with the old wiring: it fails with
  ?item= gone and the master abandoned — the reported bug, verbatim.
  The spec header's BUG-2178 deferred note updated to covered.

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-16 15:12:21 -04:00
xarmian d843752091 docs: worktree web-tooling rules in CLAUDE.md; fix vitest.config.ts's dangling pointer (TASK-2590) (#1118)
CLAUDE.md gains the "Working in a git worktree" section that
web/vitest.config.ts:41 has pointed at since the fs.allow fix — it
never existed (grep worktree/npm ci/node_modules: zero hits). Content
per the corrected day-38 ruling on TASK-2590, not the task's original
body: the symlink stays fine and stays the recommendation; the real
prerequisite is `npx svelte-kit sync` (a fresh worktree has no
generated web/.svelte-kit, and vitest fails on the missing tsconfig
either way — the 2x2 on the trail shows the symlink was never the
variable); and npm ci through a symlinked node_modules is the one
genuinely destructive move (deletes the shared tree, stalls every
session), which the original "npm ci, never symlink" rule would have
instructed agents to do.

Both documented legs verified as written in this very worktree:
fresh + symlink -> vitest fails with the exact quoted TSCONFIG_ERROR;
npx svelte-kit sync -> same test passes through the symlink (and
through this edited config file).

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-15 23:16:14 -04:00
xarmian 3098a1f569 fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128) (#1117)
* fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128)

The palette's Enter fast-path only matched bare digits (/^\d+$/,
BUG-910), so typing a full ref + Enter fell through to the
arrow-selection guard and did nothing. Extract the routing decision as
parseGoToTarget() beside REF_PATTERN_RE (pure, unit-tested): bare
number keeps its match-any-collection semantics; a PREFIX-N ref
(case-insensitive) must match prefix AND number via formatItemRef, so a
typo'd prefix is an honest no-op rather than a cross-collection jump,
and TASK-007 deliberately matches nothing rather than guessing TASK-7.
The server-search fallback queries the bare number for both forms —
the query shape the item_number path has always relied on.

Live-verified against a sandboxed build (playwright, 4 legs): TASK-9
and task-9 navigate to /tasks/TASK-9, bare 9 still navigates
(regression leg), TASH-9 stays put (control leg — the instrument
detects non-navigation, which is exactly what the pre-fix build does
on a full ref).

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

* fix(web): ref miss probes without clobbering results; stale-query fence (codex r1)

Three findings from review: (1) a ref-form miss overwrote the palette's
results/total/facets with the bare-number probe's result set — query
and display diverged, and loadMore() would page the typed ref against
numeric results; the ref probe now reads into a local and leaves
displayed state alone. (2) the async fallback had no guard against the
user typing past the pending probe (pre-existing on the numeric path,
newly exposed for refs) — fenced on the typed-at-Enter query. (3) the
numeric miss fallback now searches exactly what was typed again
(leading-zero queries had silently switched to the canonical number).

Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
2026-08-15 22:22:58 -04:00
xarmian 00a91dfcf4 feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)
* watchevents: add session-targeted push delivery predicate

PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.

* server: accept target_session_id on push, report delivered_sessions

PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.

* web: session picker in the push composer, targeted-miss handling

PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.

* server: bound target_session_id, skip publish on a targeted miss

Codex round 1 fixes for TASK-2588:

- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
  caller can't park arbitrary garbage in the bus's shared replay buffer;
  a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
  order raced a target disconnecting between publish and count, which
  could report delivered_sessions=0 on a push that had already landed
  once. A targeted push now skips the publish entirely when its id
  isn't in the pre-publish snapshot — session ids are per-connection
  and never reused, so a target absent now can never be matched later,
  making the 0 a guarantee rather than a race. Broadcast is unaffected
  (still publish-always, pre-publish count).

Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.

* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses

Codex round 2 dispositions for TASK-2588:

- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
  moved the ruling from a test comment onto the contract itself —
  pushResponse.Pushed's own doc comment in Go, mirrored in the TS
  ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
  session, a <select> can visually fall back to "All connected
  sessions" while the bound value stays the stale id, so the wire
  would carry a dead target the UI no longer shows as selected.
  Added reconcileSelectedSession(), called at every point `sessions`
  is reassigned outside the fresh-open reset (a live poll, a failed
  read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
  negotiation (the deployment shape — web assets embedded in the
  server binary — bounds this to a transient stale tab, argument
  recorded in the comment): delivered_sessions is now optional on the
  wire type, and a targeted send whose response omits it entirely is
  treated as UNKNOWN (info toast, dismiss like a normal success) —
  never inferred as a confirmed miss.

Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.

* push targeting: fix stale publish-guarantee comments (codex round 3)

Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:

- watchevents.KindPush's doc comment ("publishes exactly one of
  these") now notes handlePushToItem decides whether to publish at
  all, and points at TargetSessionID / pushResponse.DeliveredSessions
  for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
  promise means "published to the bus" unconditionally — a targeted
  miss resolves with delivered_sessions: 0 and nothing published.

Comment-only; no behavior change.
2026-08-15 14:52:25 -04:00
dependabot[bot] 312f28dd14 chore(deps)(deps-dev): bump vitest from 3.2.6 to 4.1.10 in /web (#1045)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.6 to 4.1.10.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 12:34:23 -04:00
xarmian b887b0bfe1 test(web): capture pristine DOM probes at module load in mockOpenModals helpers (#1105)
vitest 4 hands back the SAME spy when vi.spyOn targets an already-spied
method, so a helper that re-captures "the real function" mid-test captures
the spy itself and the pass-through branch recurses (swallowed by the
:modal probe's guard, which then reads as ':modal unsupported'). Capturing
document.querySelectorAll / Element.prototype.matches once at module load
is correct under both vitest 3 and 4; suite measured 1609/1609 on each.

Unblocks the vitest 3->4 major (dependabot #1045), whose merged-tree run
failed 3 Lightbox drag-abort tests (TASK-2458) through this pattern.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-15 12:12:25 -04:00
dependabot[bot] a7b70c2092 chore(deps)(deps-dev): bump @testing-library/jest-dom in /web (#1044)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1.
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.1)

---
updated-dependencies:
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:46:01 -04:00
dependabot[bot] 8ba08c7164 chore(deps)(deps): bump the npm-minor-and-patch group (#1072)
Bumps the npm-minor-and-patch group in /web with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.0` | `11.16.1` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.77` | `0.9.78` |
| [yjs](https://github.com/yjs/yjs) | `13.6.31` | `13.6.32` |
| [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.4` | `4.7.5` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` |


Updates `@dagrejs/dagre` from 3.0.0 to 3.1.0
- [Release notes](https://github.com/dagrejs/dagre/releases)
- [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md)
- [Commits](https://github.com/dagrejs/dagre/compare/v3.0.0...v3.1.0)

Updates `mermaid` from 11.16.0 to 11.16.1
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1)

Updates `svelte-dnd-action` from 0.9.77 to 0.9.78
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

Updates `yjs` from 13.6.31 to 13.6.32
- [Release notes](https://github.com/yjs/yjs/releases)
- [Commits](https://github.com/yjs/yjs/compare/v13.6.31...v13.6.32)

Updates `marked` from 18.0.7 to 18.0.9
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9)

Updates `svelte-check` from 4.7.4 to 4.7.5
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.4...svelte-check@4.7.5)

Updates `vite` from 8.2.0 to 8.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite)

---
updated-dependencies:
- dependency-name: "@dagrejs/dagre"
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.16.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.78
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: yjs
  dependency-version: 13.6.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.7.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 11:24:28 -04:00
xarmian bfa90826e9 feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)

PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.

Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.

Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:

  session(s) live   push the collapsed prompt; toast hedges ("delivery isn't
                    confirmed") because a push gets no ack
  zero sessions     copy, "No agent session connected — copied to clipboard
                    instead"
  can't tell        copy. This is where S4 DIVERGES from S3's dialog, which
                    leaves Send enabled on an unreadable presence answer. The
                    dialog is right to: the warning is on screen and the user
                    chooses with it in front of them. A quick action asks
                    nobody, so the tie goes to the lossless branch — copying
                    when we could have pushed costs one paste, pushing into
                    nothing loses the instruction outright.
  no item to        collection-scope actions keep the pre-S4 behavior exactly.
  address           The endpoint is POST .../items/{slug}/push and there is
                    nothing to point it at, so they don't even spend a
                    presence read finding that out.

PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.

The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.

Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.

Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.

Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:

  no session     tagline "No agent session connected — actions copy to your
                 clipboard"; toast matches the ruling; clipboard holds
                 "Implement TASK-9: Ship the thing (status open)"
  one session    tagline "Pushes to your connected agent session"; the
                 connected stream RECEIVED {"kind":"push","item_ref":
                 "TASK-9","summary":"Implement TASK-9: ..."}; clipboard
                 untouched
  presence 503   same live session still connected, /sessions aborted: copies
                 instead, and the stream's push count did NOT increase — the
                 counterfactual, not just the end state

Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.

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

* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)

Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.

A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.

The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.

Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.

npm run check 0 errors · web unit suite 1607 passed.

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

* fix(web): the offered copy reports its own outcome (codex round 2)

Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.

The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.

Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.

npm run check 0 errors · web unit suite 1608 passed.

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

* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)

Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.

A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.

`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.

Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.

npm run check 0 errors · web unit suite 1609 passed.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 10:03:42 -04:00
xarmian 2c5803a204 fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) (#1101)
* fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540)

A page reads its items and only then subscribes to SSE. A mutation landing in
that window reaches nobody: no subscription exists yet so the frame is never
received, and nothing reconciles the gap afterwards — the row stays stale until
some unrelated event happens to trigger a sync.

`pendingSyncOnConnect` is exactly the right mechanism and already existed, but
was armed only on leader promotion and the lock-failure fallback. The FIRST
connect — the one every page load performs — was uncovered. Arms it on every
EventSource open instead.

Cursor-advance semantics are untouched: the delta is asked from whatever cursor
syncService already holds. IDEA-2535 owns that question.

Also removes the "first leader vs promoted follower" classification (a
`navigator.locks.query` probe plus a >100ms grant-delay heuristic from
TASK-1359 rounds 2-3). It existed solely to gate this flag; with the flag armed
unconditionally nothing reads it, and keeping it would mean a `locks.query()`
round-trip per connect producing a value no one consumes. Strictly more
coverage, not a trade — every case it classified as "promoted" still arms.

VERIFICATION — and three instruments that did NOT discriminate before one did:

- Unit tests (5) pin the mechanism: armed on the lock path, on the
  no-leader-election fallback, dispatched AFTER open rather than before (the
  TASK-1359 round-4 ordering property this must not lose), claimed once across
  the onopen/`connected` arms, and re-armed for the next connect. Reverting the
  fix reddens all 5.

- Collection page + "is the row visible": CANNOT discriminate. That page runs
  its own deltaSync on mount, which covers the same window either way. Fixed
  and unfixed both "passed".

- Graph page + "is the node's title in the page text": BLIND. A positive
  control — item created with no race at all — is also "not present", so every
  reading was measuring nothing.

- Graph page + /graph response bodies, natural timing: still cannot
  discriminate. The write consistently lands before the page's own first read,
  so nothing is ever missed and both builds "recover".

- What finally worked: builds with the EventSource open delayed 3s so the
  window is wide enough to aim at, write timed into it. 3/3 LOST on unfixed,
  3/3 RECOVERED on fixed, both binaries confirmed serving and differing only in
  the arming sites.

One hypothesis was refuted along the way rather than written down as fact: I
suspected the graph subscribed too late to receive the connect dispatch. An
instrumented run showed `dispatchSyncRequired subscribers=2` — both syncService
and the graph are registered before it fires. The real reason those runs failed
was that a stale server process was still bound to the port, so they were
served by an unrelated binary.

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

* fix(web): fence the connect-sync against a stale EventSource (codex review)

`pendingSyncOnConnect` is shared state and the open/connected handlers closed
over no identity, so on a fast workspace switch source A's already-queued
handler could still run after B existed — clearing the flag and broadcasting on
B's channel, at which point B's own open would find it false and SKIP the sync
it needed. `close()` does not retract queued event tasks, so this is reachable.
It would silently reopen the exact gap this branch closes, on the switch path
where a fresh read is most likely to be stale.

Each handler now returns unless its own source is the current one. Those guards
also stop a torn-down source writing `status` / broadcasting, which they never
did before. Listener registration uses the local `source` throughout rather
than re-reading module state (behaviour-identical; the two are the same object
at registration).

`disconnect()` also clears the flag. Labelled in-code as belt-and-braces
rather than implied load-bearing: mutation-testing shows removing that line
ALONE changes nothing observable, while removing the identity guards reddens
the stale-source test. Kept because leaving per-connection state set after the
connection is gone is how this bug arose.

Three tests added, and the mutation testing is worth recording because the
first attempt was a false green: dropping the identity check inside
`claimPendingSync` reddened NOTHING, since the handler-level guards still
caught it — a layered-guard mask. Only removing every guard isolates which
layer acts. The tests now discriminate at that granularity.

Also covers the lock-failure fallback arming path, which had no test at all.

Codex's other finding — follower tabs have the same uncovered window and cannot
use this mechanism, since they never open an EventSource — is real and filed as
BUG-2576, along with the adjacent unguarded listeners this commit had no reason
to touch.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:41:00 -04:00
xarmian e03ba45b5c feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)

PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.

The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:

  N > 0        send, worded "N session connected", never "will be
               delivered" — the registry can name a session that died up
               to ~30s ago and no push gets a receipt
  N == 0       send DISABLED. Nothing listening means the message is
               lost, not queued; the empty state offers the clipboard
               instead (the fallback S4 rules for quick actions)
  can't tell   send ENABLED, uncertainty stated. A 503/401/network
               failure is not zero — rendering it as zero is the exact
               lie handleListSessions returns 503 rather than an empty
               list to avoid

The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.

$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.

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

* fix(web): close the push composer's races and ambiguity gaps (codex review)

Round-1 review findings on the S3 composer, all real:

- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
  is {#key itemSlug}-remounted while `open` is owned by the parent, so a
  stale `true` silently REOPENED the composer pointed at the new item.
  The reset block's existing comment (written for copyDialogOpen)
  describes this exact failure. Verified live, with the counterfactual:
  reverting the one-line fix reopens the dialog on item B after a
  client-side navigation. (The typed draft does NOT carry over — the
  {#key} remount clears it — so the defect is the silent reopen, not a
  retargeted message.)

- Presence polls shared one generation counter, which fences OPENINGS,
  not requests. A stalled poll could resolve after a later one and
  overwrite a fresh count with a stale one, re-arming Push against a
  session list already known to be empty. Added a per-request sequence;
  only a strictly newer response is applied.

- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
  request that never settled stranded the composer with a dead button and
  no explanation. It now degrades to the honest "can't tell" state after
  5s; a later response still lands and upgrades the answer.

- A failed send re-armed Push unconditionally. The handler publishes
  BEFORE writing its response, so an unstructured failure (rejected
  fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
  click can deliver the instruction twice on an endpoint with no
  idempotency key. Split on the same line CopyItemDialog draws (DR-13):
  a structured PadApiError means the server refused before publishing —
  re-arm; anything else latches an outcome-unknown state.

- `willCollapse` compared against `String.trim()`, reintroducing the very
  JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
  trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
  strips). Added `trimPushMessage`, which trims with Go's class.

- The textarea described only the counter, so the collapse note and the
  over-length error reached no screen reader. Both now live in one stable
  referenced node that swaps text rather than mounting and unmounting —
  an aria-describedby pointing at an absent id resolves to nothing.

- Positive presence wording implied the count was current. It now says
  "as of the last check" and names the ~30s window.

Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.

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

* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)

Three findings, one of them introduced by round 1's own fix:

- The send/copy continuation fence used the generation counter, which
  cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
  instance with its own counter, so item A's in-flight send still saw its
  own `gen` unchanged and called the SHARED parent `onclose` — closing the
  composer the user had just opened for B. Added a per-instance
  `destroyed` flag, which is what actually distinguishes "still mine to
  close" from "I no longer exist".

- The outcome-unknown split treated any PadApiError as proof the server
  refused before publishing. It isn't: the API client turns EVERY JSON
  error envelope into one, including a gateway 5xx invented after the
  handler published. Replaced with a whitelist of codes the handler and
  its middleware actually emit pre-publish; everything unrecognised is
  now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
  tell" costs the user a check, a wrong re-arm delivers twice.

- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
  froze the count at its last value indefinitely while the UI kept
  rendering "1 session connected" as fact. A known answer now expires to
  "can't tell" after 30s without a refresh — the server's own presence
  staleness bound, so past it our answer carries no more authority.

Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.

The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.

Each fix mutation-tested 1:1 against its new test.

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

* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)

Two of round 3's three findings were real:

- `csrf_error` and `email_not_verified` are middleware refusals, written
  strictly before the handler runs, so they belong in
  PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
  couldn't tell whether their message was sent, when nothing had been.

- The 30s staleness expiry didn't fence requests already in flight. A
  poll issued before the expiry could land after it and reinstate the
  very count we had just declared too old to trust. Expiry now advances
  `presenceAppliedSeq` to the current `presenceSeq`, retiring those
  responses; the poll issued in the same tick carries a newer seq and
  still applies.

The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:22:09 -04:00
xarmian e91c4fc261 fix(store): include the cursor's own second in /changes deltas (BUG-2539) (#1086)
* fix(store): include the cursor's own second in /changes deltas (BUG-2539)

items.updated_at / items.deleted_at are RFC3339 whole-second strings
(store.now()), while the /changes cursor is a unix-millisecond value —
normally the previous response's server_time. ItemsModifiedSince
formatted that cursor with the same second precision, truncating it
DOWN, then compared with a strict `>`. Every change landing in the
cursor's own second compared equal and was dropped, permanently: the
caller advances its cursor past that second and nothing reaches back.

User-visible symptom: a bulk archive ~450ms after a page seeded its
cursor left the item rendering as LIVE indefinitely — no banner, no
redirect — while the server had deleted_at set. It was never
archive-specific (updates were dropped identically); a missed update is
usually re-delivered by the next event, a missed deletion never is.

Compare inclusively against the truncated second instead. The boundary
second may be re-delivered, which every consumer of this endpoint
applies idempotently, and it is bounded to one second of changes per
sync. Sub-second storage is the other fix and is a migration, not a
one-liner: these comparisons are lexicographic on TEXT columns and
mixing precisions inverts them ("…20.451Z" sorts BEFORE "…20Z").

Verified against a live instance with four cursors all strictly earlier
than the archive in real time: two inside its second MISS, two in
earlier seconds HIT.

Tests:
- TestItemsModifiedSince_SameSecondCursor — same-second leg plus a
  previous-second control. Fails 3/3 unfixed, passes 3/3 fixed; the
  control passes on both.
- e2e bug-2539-sync-window — the banner must appear in the already-open
  page AND follow a /changes delta that carried the deletion, so a
  reload cannot satisfy it. The 450ms leg fails unfixed; 1200ms control
  passes on both.

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

* test(store,e2e): close the review gaps in the BUG-2539 counterfactuals

Codex review of 7905ed06 found no P1s and three P2s, all on whether the
tests actually measure the fix. Each was right.

Store test:
- It derived "same second" from a wall-clock reading taken BEFORE the
  writes, so a leg whose writes drifted into the next second would pass
  under the unfixed query and still be counted as evidence. It now reads
  the timestamps the writes actually STORED and compares those against
  the cursor's second.
- Misalignment retried instead of skipped, so the leg cannot silently
  stop testing anything.
- It never asserted the archived row comes back in `updated`, leaving
  the `(deleted_at IS NULL OR deleted_at >= ?)` arm free to regress to
  `>` unnoticed. Now asserted.

E2E:
- It accepted ANY /changes response carrying the deletion, including the
  cursor-seeding request setWorkspace fires during load, and never
  established that the page had loaded a live row. Both holes let it
  pass without exercising the incremental path. It now requires the
  page's own item GET to have seen deleted_at null AND the deletion to
  arrive on a /changes that resolved after the archive POST completed.
- Assert check.ok() before reading deleted_at; delete the scratch
  workspace at the end.

Also softened the comment's claim about re-delivery: `>=` makes the
endpoint at-least-once at the boundary and rows can repeat across
several rapid syncs, not just one. What makes that safe is that the
payload is server state rather than an increment, so the comment now
says that instead of "bounded to one second".

Counterfactuals re-run against a genuinely reverted query (the earlier
stash-based attempt was a no-op once the fix was committed, and passed
for that reason): store test fails 3/3 with all three assertions firing,
e2e 450ms leg fails, both controls pass.

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

* test(e2e): decide BUG-2539's oracle by request order, not response timing

Codex's second pass kept one P2 on the e2e, and it was right on both
halves. The oracle compared response arrival against the moment the
archive POST completed, which is racy in both directions: the server
publishes the SSE event BEFORE the bulk handler finishes writing its
response, so the incremental /changes can resolve first and be scored as
"not after the archive" (false failure); and a slow cursor-seeding
response can resolve after it and be scored as incremental (false pass).
The live-row check was existential — any live read of the row counted,
including one issued after the archive.

Both are now decided on the REQUEST side, where ordering is not racy:

- `/changes` requests are numbered as they are issued. The seed that
  setWorkspace fires on mount is number 0; only a LATER one carrying the
  deletion satisfies the assertion.
- Item GETs record their issue time, and `archiveSentAt` is stamped
  immediately BEFORE the POST goes out, so "the page read a live row
  before the archive" is decidable without waiting on anything.
- The async response handlers are collected and awaited before the
  assertions read their flags, instead of racing them.

Cleanup moved into a finally so a failing assertion no longer leaks the
scratch workspace, and its response is checked.

Counterfactual re-run at repeat-each=3 against a genuinely reverted
query: the 450ms leg fails 3/3, the 1200ms control passes 3/3, and both
legs pass 3/3 with the fix in.

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

* test(e2e): anchor BUG-2539's legs to preconditions, not a fixed delay

The fixed 450ms/1200ms offsets measured from navigation start were a
proxy for the real condition, and a machine-speed-dependent one. Under
parallel workers a slow load put the page's own item read AFTER the
archive, so the page rendered an already-archived row and the sync path
was never exercised — the live-row assertion then failed, correctly
reporting that the leg had not reproduced the scenario.

The legs now wait for the two things that actually have to be true —
the page has READ a live row (server-attested deleted_at null) and its
EventSource is subscribed (the /events response headers have arrived;
the handler subscribes before writing them) — and are named after the
mechanism: archive inside the cursor's own second vs after crossing the
next second boundary.

Also from Codex's third pass:
- the /changes URL match is anchored so it cannot also match
  /items-changes, whose requests would otherwise consume ordinals and
  let the cursor seed pass as the incremental sync;
- the incremental check now requires ordinal > 0 AND issue time at/after
  the archive, so a retried seed cannot pass on ordinal alone;
- the live-row check no longer compares clocks at all — a response
  carrying deleted_at null cannot come back after the archive applied,
  so the server attests it;
- the response-handler drain loops until no new handler was queued while
  awaiting, instead of snapshotting the array once;
- cleanup is recorded in `finally` and asserted after it, so a cleanup
  failure cannot replace the real one, and setup now runs inside the try
  so a failed setup cannot leak the scratch workspace.

Counterfactual against a genuinely reverted query: the same-second leg
now fails 6/6 (the delay-based version managed 7/8), control passes 6/6.
130 runs green with the fix in.

Both assertions carry the recorded state in their message: this leg
flaked twice in ~70 runs of an earlier revision and the artifacts were
cleared by the next run before they could be read, so a recurrence has
to explain itself from the failure text.

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

* test(e2e): make BUG-2539's leg PROVE it hit the second it is named after

Codex's fourth pass: the same-second leg never checked that it actually
landed in the cursor's second. It archived immediately after the
preconditions and assumed. On a slow machine that drifts across the
boundary, at which point the reproducing leg quietly becomes a second
control and passes against the very query it exists to convict — the
same "a test that silently stops testing anything" shape the store test
was already hardened against, which is what makes the point land.

Each attempt now compares two SERVER values: the `server_time` of the
page's first /changes (the seed — that value IS the client's
lastSyncTime, the cursor the failing sync used) against the `deleted_at`
the server stored. Same-second leg requires equality, boundary leg
requires difference, and a misaligned attempt is retried on a fresh
workspace (up to 6) instead of asserted on. Exhausting the attempts
fails with both seconds in the message.

Also from that pass:
- cleanup swallows a rejected delete rather than replacing the real
  failure with a transport error;
- `createdSlug` is set from the requested slug BEFORE parsing the
  response, so a malformed success cannot leak a workspace;
- request/response listeners are removed per attempt, so retries do not
  stack handlers;
- the live-row comment now says "carries no deleted_at" — a live item
  omits the field (omitempty) rather than sending null. The previous
  commit message said `deleted_at: null`; the check was always a falsy
  one, so only the wording was wrong.

Counterfactual against a genuinely reverted query: same-second leg fails
4/4, control passes 4/4. 12/12 green with the fix.

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

* test(e2e): scope BUG-2539's matchers per attempt; don't accept an unknown seed

Codex's fifth pass, both findings real:

- Retry ordinals were not scoped to the attempt's workspace. A retry
  reuses the page, which is still showing the previous attempt's
  workspace when the listeners go on, so an in-flight /changes from that
  one could take ordinal 0 and be mistaken for the new seed. The
  /changes, item-GET, and /events matchers are now built per attempt
  against that attempt's slug.

- A seed response that was never observed left seedServerTime null,
  which the alignment check folded into "different second". For the
  same-second leg that already meant a retry, but the CONTROL leg would
  proceed on an unknown and claim it had proven a difference it never
  saw. An unknown seed is now its own retry.

Counterfactual re-run after the change: same-second leg fails 4/4
against a reverted query, control passes 4/4; 10/10 green with the fix.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 12:18:26 -04:00
xarmian 2cb5e0aab0 fix(web): surface swallowed sync failures; defer a mid-sync sync_required (BUG-2508) (#1084)
* fix(web): stop losing sync changes when a consumer fails to apply them (BUG-2508)

Three defects on the incremental-sync path, all reproduced before any fix. The
reproduction IS the regression test (syncCursor.svelte.test.ts) rather than a
throwaway, because what proves the loss and what pins it are the same artifact.

1. THE CURSOR ADVANCED BEFORE CONSUMERS APPLIED. `triggerSync` set
   `lastSyncTime = changes.server_time` and then notified, so a consumer whose
   refetch failed left the cursor past changes nobody had applied — and since
   `/changes` is asked FROM that cursor, the server could never re-deliver them.
   Silent and permanent. The `full_refresh` arm three lines below already had
   the right discipline ("don't advance until pages confirm success"), so the
   file disagreed with itself; the incremental arm now shares it via `deliver`.

2. ASYNC CONSUMERS' REJECTIONS WERE NEVER OBSERVED AT ALL. `notify` wrapped
   `cb(result)` in try/catch, which catches synchronous throws only, and two of
   the five consumers are async. Their rejections did not reach that catch —
   they surfaced as unhandled rejections while the service went on believing
   the sync had been applied. Callbacks are now awaited, so "caught and ignored"
   and "not caught at all" collapse into one honest answer, and the failure is
   logged instead of dropped.

3. A `sync_required` ARRIVING MID-SYNC WAS DROPPED, not deferred. The in-flight
   request was issued before that signal, so its window cannot cover it, and
   nothing re-announces the gap. `triggerSync` now records it and runs one more
   pass; the flag is cleared before each request so a signal arriving during one
   is not swallowed by the pass that predates it.

Plus the second half of the report: the timeline's SSE-driven refresh caught its
failures and did nothing at all with them, leaving the panel quietly missing a
comment somebody else had just posted, with no indication and no retry — the
next refresh only comes with the next relevant SSE event, which may never
arrive. It now logs and retries ONCE on a backoff. Not a banner (this is a
background refresh, and a modal-weight failure surface would be worse than the
bug) and not unbounded retries (the debounce exists because SSE replay can
hammer that endpoint).

Scope held to the triage: `doIncrementalOrFull`'s fallback behaviour is
untouched.

The tests assert the `since` ARGUMENT OF THE NEXT REQUEST, never an end state
(team CONVE-12): "changes lost", "changes never made" and "refetch succeeded
with nothing to do" are indistinguishable by end state, and what separates them
is whether the server can still be asked. A control leg pins that a clean sync
still DOES advance the cursor, so a fix that simply never advances it fails.
Three mutants, each failing only its own tests. The rig itself nearly produced a
false green — the service is a module singleton whose `setWorkspace` issues its
own `/changes` call, and the first version counted it and leaked cursor state
between tests, which made one leg pass on another leg's calls.

* test(web): cover the timeline's SSE-refresh retry, and make its SSE mock fan out (BUG-2508)

The retry added in the previous commit had no test. Adding one required fixing
the harness first: this file's `sseService` mock returned a disposer and dropped
the callback, so the component was subscribed to nothing and any test of how it
REACTS to an event would have passed vacuously. It now fans out, which is the
same mock defect BUG-2509 hit on the attachment bus.

Both legs assert on the REQUESTS issued, not on rendered entries: a timeline that
never refreshed and one that refreshed successfully with nothing new look
identical on screen (CONVE-12). The control leg — a successful refresh must NOT
retry — is what stops a fix that simply retries unconditionally from passing,
which would double every successful refresh on an endpoint the debounce exists to
protect.

Two mutants: removing the retry and dropping the once-only guard each fail only
this test.

* fix(web): fence the timeline's SSE refresh against teardown (BUG-2508)

The retry added earlier in this branch could fire after unmount: a rejected
request schedules it from its own catch, and the identity fence there
(reqSlug/reqWs) is not a teardown fence — a remounted panel can legitimately
carry the same identity, so "same item" never meant "still alive". `onDestroy`
only unsubscribed SSE and left both timers running.

Now `onDestroy` clears the shared timer and latches `destroyed`, which every
continuation that can outlive the mount checks: entry, the success path before
it writes state, and the failure path before it schedules the retry.

Found by an independent review pass on this branch — the leak was mine, introduced
with the retry.

* fix(web): revert the cursor gate; keep the failures observable (BUG-2508)

Scope call after review: keep this bug narrow, and revert the coordinator change
rather than ship it.

The gate ("advance the cursor only if every consumer applied") was correct at the
service boundary and INERT in production, because no consumer reports failure —
verified at four sites, and confirmed by a live leg that behaved identically on
gated and ungated builds. Shipping it would have read as a fix for the reported
bug while changing nothing, which is worse than the open bug: the next person
cites it as handled. Reverting an inert change loses nothing users ever had.
The design half — consumer contract, the poison-consumer case that would pin the
cursor for everyone against an unbounded /changes window, and the
markSynced/onTabResume inconsistencies — is filed as IDEA-2535.

What ships here, all within "surface the failures":

- FAILURES ARE OBSERVABLE. The old try/catch caught synchronous throws only, and
  two of the five consumers are async with their promise discarded — those
  rejections were not "caught and ignored" but unobserved entirely, surfacing as
  unhandled rejections with nothing tying them to the sync that caused them. A
  rejection handler is now attached to whatever a callback returns, and both arms
  log. Deliberately NOT awaited: consumers keep running concurrently and delivery
  stays synchronous, so observability does not smuggle in an ordering change.
- A `sync_required` ARRIVING MID-SYNC IS DEFERRED, not dropped. The in-flight
  request was issued before that signal, so its window cannot cover it, and
  nothing re-announces the gap.
- Cursor semantics are UNCHANGED, and a test pins that deliberately: if someone
  reinstates the gate, it fails and sends them to IDEA-2535 rather than letting an
  inert contract ship quietly a second time.

Three mutants (drop the async observer, drop the sync log, drop the deferral
flag), each failing only its own test, plus a control that a sync with nothing
pending runs exactly one pass.
2026-08-12 21:38:44 -04:00
xarmian d8b68c443e fix(web): clear the attachment NodeView missing-latch on parent restore (BUG-2509) (#1083)
* fix(web): clear attachment NodeView missing-latch on parent restore (BUG-2509)

Archiving an item 404s its attachments without deleting them (DR-13). Any
attachment surface that PROBES inside that window therefore observes exactly
what a deletion produces, latches it as permanent, and stays dead after the
restore. The strip and the timeline already reconcile this themselves via the
`parentArchived` prop plus their own epoch + no-store re-probe; the editor
NodeViews could not — their latch is closure-private state inside a Tiptap
view, unreachable from a prop — so they were routed out of PLAN-2392 3c-iii's
scope and never got the equivalent.

Two distinct defects, both reproduced live before any code was written:

1. The shared HEAD metadata cache memoizes `missing` for the page lifetime, on
   the premise that a settled result is a durable fact about a content-addressed
   row. True for deletion, false for archive: a `missing` observed in the
   archived window is a fact WITH an expiry cached as though it had none. Every
   later reader replays a 404 the server would no longer give — INCLUDING a
   NodeView constructed fresh, which is why remounting the editor did not heal
   the file chip.

2. The NodeView `deleted` latch is cleared only by a uuid swap, and a restore
   does not change the uuid.

Which one bites depends on `canEdit`, which is forced false while archived: an
edit-permissioned user's restore flips the content branch and builds a fresh
editor (healing the image, whose load event repaints it, but not the chip, which
makes no request and reads the poisoned cache), while a viewer keeps the SAME
editor across the whole flip and sees both stay dead.

The fix is a restore channel on the attachment bus — deliberately NOT the mirror
of the deletion channel. Deletion is authoritative and subscribers latch it;
this signal carries NO VERDICT and only prompts a re-ask. Subscribers re-probe
no-store and clear the latch ONLY on an authoritative `ok`, so an attachment
genuinely deleted while its parent was archived 404s and stays dead, and a
mis-routed signal costs one HEAD and changes nothing. That is what keeps restore
from becoming an undo-resurrection vector (DR-17).

`announceAttachmentParentRestored` does both halves because they cover different
populations: the notify reaches surfaces already mounted and latched (a cache
invalidation cannot — their latch is not a cache read they repeat), and the
invalidation covers surfaces built later, which a notify cannot reach because
they did not exist when it fired. Invalidate first, so a subscriber re-probing
synchronously inside the notify is not answered from the entry being dropped.

Verified in a real browser against an isolated instance, on both legs (owner and
viewer, item opened while archived then restored with no reload), plus the DR-17
leg (attachment row genuinely deleted during the archived window — stays dead)
and the control (opened live, then archive/restore — never latches, since the
latch requires the NodeView to be CONSTRUCTED inside the archived window).

Tests: the three bus mocks now fan the restore channel out to subscribers rather
than only recording the subscription — a spy-only mock leaves the NodeView
subscribed to nothing and every reaction test passes vacuously. New coverage for
the cache invalidation, the channel (including the invalidate-before-notify
ordering, asserted through its observable consequence), and both NodeViews' heal
/ stay-dead / routing / teardown behaviour. Each new assertion was mutation-
tested: no-op'ing the invalidation fails 3, short-circuiting either listener
fails the heal tests.

* fix(web): fence the restore probe against deletion, workspace and item-switch (BUG-2509)

Three defects an independent review pass found in the previous commit. The
first is the safety property that commit claimed to protect.

1. DR-17 RESURRECTION, reachable in one interleaving. The deletion bus sets the
   latch synchronously, but the restore continuation fenced only on teardown and
   a uuid swap — so "restore probe starts → delete is confirmed and broadcast →
   probe resolves ok" cleared the latch and repainted a row the server no longer
   has. The browser leg missed it because there the delete preceded the signal,
   so the probe itself 404'd; the hole is only in the overlap. Fixed with a
   monotonic deletion generation captured before the probe and re-checked after —
   a bare `deleted` re-check would not do, since the latch can be set and cleared
   again while one probe is in flight.

   Relatedly, a non-`ok` result was treated as "do nothing" when `missing` is in
   fact the authoritative existence answer the probe asked for. It now latches,
   which also settles two concurrent probes resolving out of order rather than
   letting whichever answered first win.

2. The continuations never re-read the address after the await, so an answer
   about ws-A's copy could heal a node whose editor had since moved to ws-B —
   the same cross-workspace staleness that made these files read the workspace
   off the live reader in the first place. Re-checked on landing, as
   ItemTimeline's probe already does.

3. The ItemDetail edge was keyed on the LEVEL (`itemMatchesRef && isArchived`),
   which also drops when you navigate AWAY from an archived item — so every such
   navigation announced a restore that never happened, invalidating a workspace's
   metadata cache and prompting probes. The latch now carries the item id: a
   restore is "the same loaded, matched item went archived → live", nothing else.
   Extracted as `parentRestoreEdge` and unit tested, because ItemDetail resists
   jsdom mounting and that case is otherwise unpinnable.

Also: the two teardown tests passed vacuously — a leaked listener still sees
`destroyed` and returns, so they stayed green with the dispose call deleted.
They now assert the registry is empty. Mutation testing then caught the same
class in two of my own new tests: the image's mid-probe-deletion and
workspace-change cases asserted only that the placeholder was still visible,
which is true either way until a `load` fires. They now assert the observable
consequence of a wrong heal — the cache-busted reload it would arm.

Every new fence was mutation-tested individually (5 mutants, each failing only
its own tests). Full suite 1496 passed; svelte-check 0 errors; all three browser
legs re-verified against a binary built from this tree.

* fix(web): generation-fence every probe; key the restore signal by item (BUG-2509)

Round two of independent review. Three more findings, all confirmed against the
source; the third means the previous fix was incomplete for a realistic
navigation, not merely unfenced.

1. A STALE PROBE COULD STILL REACH THE LATCH PATH. The previous fence was
   per-CAUSE (did a deletion land?) rather than per-TRANSITION, so it only caught
   one direction. The other: an OLDER probe answering after a NEWER transition —
   the image's archived-window probe still in flight when the restore heals, then
   re-latching what the restore just fixed; and the chip's CONSTRUCTION probe
   (issued inside the archived window, which is the whole bug) landing after the
   restore signal and marking a live chip dead with nothing left to undo it.

   Replaced with one monotonic per-NodeView generation, bumped on every
   authoritative transition — deletion, missing-latch, heal, uuid swap, and
   receipt of a restore signal — captured by every continuation that mutates
   presentation. The invariant is now structural rather than a list of cases:
   a continuation may only act if nothing authoritative happened since it
   started. Bumping on receipt is what invalidates a probe issued before the
   restore, including the early-return path where there is nothing to heal.

2. UUID FENCING WAS BY VALUE, so a swap away and back passed the check again —
   and the deletion listener filters on the CURRENT uuid, so a delete arriving
   while the node pointed elsewhere was ignored. Stale ok + same uuid on return =
   resurrection. The swap now bumps the generation, which is what the
   continuations compare.

3. THE EMITTER MISSED RESTORES THAT HAPPEN WHILE THE PANE IS AWAY. Archive A,
   navigate to B, let someone else restore A, come back: no archived→live edge is
   ever observed by this tab, so nothing was announced and a fresh chip read the
   archived window's cached 404 — the original bug by another route. A per-mount
   edge latch cannot see this, and keying it to the item id does not help; the
   memory has to outlive the mount. Replaced `parentRestoreEdge` with
   `archivedItemRegistry`: mark an item when seen archived, announce when it is
   next seen live. That subsumes every case the latch handled (restore in place,
   navigate away, mount-on-archived) and covers the one it structurally could not.

Verified live, same document throughout (asserted, not assumed — the leg stamps
the document and checks the stamp survives, since a full reload would drop the
very state under test). Control: the same leg against a binary built from the
PREVIOUS commit's emitter leaves the chip dead on return while the image heals,
which is exactly the predicted symptom — so the leg discriminates rather than
passing for free.

Tests: registry unit tests including both the false positive and the miss; the
stale-probe orderings for both NodeViews; the uuid away-and-back case; and the
restore probes' `cache: 'no-store'` (the mocks were discarding the options
argument, so the point of the re-probe was untested). Ten mutants total across
both rounds, each failing only its own tests — one (the uuid-swap bump) survived
first time and got the test it was missing.

* fix(web): make the latch fence structural; correct three stale comments (BUG-2509)

Round three of review. The generation fence I added covered the two probes I had
been looking at and missed three others that also latch: both toolbar MIME probes
and the activation probe. Same defect as the one already fixed — a probe issued
inside the archived window answering 404 after the restore healed the node — at
call sites I had not enumerated.

Rather than patch three more sites, the fence moved INTO `latchMissing`, whose
signature now REQUIRES the captured generation. Every path into it is an async
probe answering a question it asked earlier, and a latch is destructive and
permanent, so "the caller will remember" was the wrong shape: an unfenced call
site is now a type error rather than a bug found in review. Fixing this class one
site at a time is what produced the miss.

Three comments were left saying things that are no longer true, which in this
file is not cosmetic — the comments are how the next reader learns the rules:
  - the PLAN-2411 seam note still said the restore channel was "stated, not built
    here" and that only that future channel could clear the latch. This branch is
    that channel. Rewritten to state what actually holds now (the signal never
    clears anything; only a server `ok` does).
  - my own generation docstring claimed EVERY mutating continuation captures it,
    which the transforms and the activation-open path do not. Restated to say
    what it governs (the latch and the heal) and what it deliberately does not
    (opening a viewer, which `activationSeq` owns; rotate/crop, which mint a new
    attachment).
  - the metadata cache's "no staleness concern" predates the distinction this bug
    turns on. Split: a settled `ok` cannot go stale, a settled `missing` can,
    because it describes reachability rather than contents.

Two review findings are DECLINED, recorded in the code where the next reader will
ask. Re-checking `address().itemId` after the await: itemId is routing, not
ownership, and the probe's answer is about the ATTACHMENT — fencing on it would
imply a relationship the code does not have. Eviction for the archived-item
registry: it holds one uuid per archived item viewed, and dropping a mark early
reintroduces this bug silently for long sessions only, which is the worse trade.

The new toolbar-probe test passed vacuously at first (`selectAll` does not build
the toolbar, so the probe under test never ran and the stale-release handle was a
no-op default). It now drives a real NodeSelection and asserts the probe fired.
Twelve mutants across the three rounds; two survived first time and each got the
test it was missing.
2026-08-12 18:57:35 -04:00
xarmian 7b46894413 fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
The e2e suite shares one pad instance and one workspace, so items seeded
by OTHER concurrently-running specs arrive over SSE and stack
"X created: ..." info toasts bottom-right — directly over bottom-right UI
(the graph drawer's detail card), turning unrelated specs' clicks into a
race. pane-content-link-anchors:238 paid a ~40-minute rerun tail at
nearly every merge gate.

The fix is a narrowly-scoped test-surface kill switch, not a retry:

- `quietExternalToasts()` (toast store): reads a localStorage flag no
  production code ever sets; never throws whatever storage does.
- The ONE call site announcing another actor's SSE work — the external
  `item_created` toast in the workspace layout — checks it. Toasts the
  page earns with its own actions are untouched, so specs still exercise
  the real toast surface (copy-dialog's no-force-click policy keeps its
  protective value).
- The shared e2e fixture installs the flag on every context via
  `quietCrossActorToasts()`; collab-persistence's self-built contexts
  install it explicitly; account-delete's contexts never enter workspace
  routes and stay bare.
- sse-toast-quiet.spec.ts pins BOTH sides: the quiet leg anchors on the
  layout branch's own by-uuid GET (pre-attached response log — no
  arm-order race; SSE-stream response gates the create; bounded settle
  before the negative assert), and a deliberately unflagged CONTROL
  context proves the product toast still fires — the real behavior
  cannot silently regress behind the suite-wide silence.

Evidence: three consecutive full local suite runs with ZERO failures
(baseline: 1-3 interception/load flakes per run); unit tests pin the
helper's contract. Reviewed to fresh-angle CLEAN over four Codex rounds
(vacuous-anchor, arm-order, SSE-connectedness, and self-built-context
holes all found and fixed by the loop).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 15:43:42 +00:00
xarmian 837035e3ce fix(viewer): disarm failed promotion, gate degrade, rebase pinch on flip (PLAN-2392 3d fix round)
Three final-pass P2s in the attachment viewer's touch gestures:

1. FAILED-PROMOTION / STALE-OWNER: tryPromoteToPinch's missing-founder
   (!a || !b) path returned with the pan scalars still set — a stranded
   phantom pan that ate later gestures. It now fully disarms (release
   capture, clear scalars, drop the stale entry, disarm the tap, clear
   swallow) and re-arms the incoming touch as a fresh first touch,
   superseding the stale owner the way the first-touch reconcile does.
   The onTouchDown non-touch guard now requires the owner registry entry
   PRESENT, so a reconciled-out owner routes to that disarm instead of
   being misclassified as a live non-touch owner and swallowing the press.

2. GATES-ON-DEGRADE: the 2->1 degrade armed a survivor pan without the
   pointerGatesOpen check every START path carries — a native modal or
   stacked viewer opening mid-pinch left a pan that resumed when the layer
   closed. degradeToPan now gates on the leased viewer root and full-clears
   (before clearing `pinching`, so the held suppressClick drops too) when
   the gates are shut.

3. FLIP-MID-PINCH: the sheet class flips synchronously with the breakpoint
   while the ResizeObserver re-clamp is async, so an immediate post-flip
   move mixed the new stage rect origin with the old midpoint baseline and
   jumped the offset. onPinchMove now tracks the baseline's rect origin and
   re-seeds the midpoint (zero delta; scale is rect-independent) the moment
   the origin shifts, before the async re-clamp catches up.

Four discriminating tests (all fail on the reverted code); the 36-test V2
pinch suite + all existing suites stay green unmodified.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 14:45:01 +00:00
xarmian 84eef5dd38 test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof
for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real
touch is driven through the compositor via CDP Input.dispatchTouchEvent (a
CdpTouch helper in the e2e lib), since Playwright's touchscreen is
single-tap only.

New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs:
- two-finger spread/converge zoom in/out
- off-centre affine anchor oracle: a moving-midpoint translate+spread keeps
  a known image-local point under the midpoint (sub-pixel residual; a
  zoom-around-centre mutant misses by ~75px, TOL=6px)
- double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close
- 2->1 lift degrade: jump-free hand-off + survivor pan arms
- '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move)
- touchCancel all-cancel teardown + next gesture arms fresh
- letterbox touch never pans + letterbox tap closes
- image/stage touch-action:none, backdrop auto
- tap-to-load first-tap priority

CDP semantics empirically pinned (not assumed): touchStart/Move carry the
full active set; touchEnd names the ending point (pointerup#<id> observed);
touchCancel is all-or-nothing.

Emulation boundary recorded honestly in DOC-2521 (device-proof checklist):
the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic
touch releases the survivor's implicit pointer-capture on the next move,
tearing the fresh pan down (a real digitiser keeps it), so the leg proves
arm+no-jump and the continuation is device-verified. Also checklisted:
gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit
CI project), off-root release.

Mutation-verified (build web+go at worktree root, fresh CI server per run):
pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre ->
oracle red while spread stays green (discrimination); double-tap disabled ->
toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red.

Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a
docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase
test wasn't discriminating -> sampled on a tiny move + mutation-proved).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 13:39:05 +00:00
xarmian 9d9670e5cc feat(viewer): touch pan, pinch, double-tap + touch-action:none (TASK-2518)
3d-V2 of PLAN-2392: the attachment viewer now owns touch. `touch-action: none`
on the image and stage lets pointer handlers drive single-touch pan, two-finger
pinch, and double-tap-to-toggle; the letterbox stays a native tap-to-close (the
backdrop keeps `touch-action: auto`).

Gesture state machine (built on V1's pointer registry):
- Touch gestures arm only on a PAINTED-IMAGE hit, gated by a per-element paint
  generation (`paintedGen === loadToken`) so retry-loading is inert while the
  thumb→original upgrade stays live. The accept-gate snapshots the loader's fence
  inputs before `decoded()` mutates them.
- Pinch composes ONE candidate at the clamped final scale (anchor-zoom around the
  previous midpoint + midpoint translation), clamped once; PINCH_MIN_DIST=12 with
  the below-min HELD-scale skip and re-entry rebase.
- 1→2 promotion surrenders the pan capture (swallowing its lostpointercapture);
  2→1 degrade rebases to the surviving founder; third-and-beyond touches are
  registry-only; per-pointer pointercancel routes degrade-vs-full-clear.
- DOUBLE_TAP_MS=300 / SLOP=24, image-only, with compat-dblclick dedup; a live
  touch gesture is never seized by a mouse press, and a mouse pan keeps the looser
  bitmapPresent arm.

Owed premise inversions: the sheet e2e now asserts touch-action none; the restore
guard + test comments updated (the viewer owns touch via pointer events, but a
touchmove is still not defaultPrevented, so the origin check remains the catch).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 12:45:07 +00:00
xarmian cf85e8b8d3 feat(viewer): pointer registry + touch tap semantics (TASK-2517)
PLAN-2392 phase 3d V1 — the multi-pointer plumbing V2's pinch needs,
shipped inert. A `pointerId -> {x,y,type}` Map ('registry') tracks every
primary pointer (mouse, pen, touch); the mouse/pen drag keeps byte-
identical semantics as the 1-entry case (ownership still keys off
`gesturePointerId`, capture off `capturedPointerId`).

- Remove the `touch stays native` early-return gate; branch touch at the
  top of onPointerDown: touch ENTERS the registry but arms NO drag and
  takes NO capture. Taps still work — a touch tap falls through to the
  backdrop onclick (close), the chrome exclusion, and the deferred
  tap-to-load button (first-tap priority).
- Registry hygiene precedes every guard (round-2 P1): registry.delete
  runs FIRST in onPointerUp AND onPointerCancel, before any owner guard,
  so browser-claimed touches (which pointercancel routinely under
  touch-action:auto) never leak. abortGesture / onLostPointerCapture
  delete the pointer too; cancelGesture is restructured to clear the
  registry UNCONDITIONALLY (before its no-gesture early return).
- Reconcile a STALE armed owner (an off-root missed pointerup, pre-
  capture) out of the registry on the next superseding press, guarded so
  a same-id re-press never drops the entry it just set.
- The id-change effect deliberately does NOT touch the registry: nav
  doesn't change the physical pointer set.

Test inversion (falsify-don't-contort): the former ':2248' test pinned
"a touch pointerdown is IGNORED". It is replaced by the V1 contract,
split honestly into (a) touch press+move arms nothing / mouse byte-
identical and (b) a real no-move touch tap closes via the backdrop.
Added: registry drains on pointerup AND pointercancel (direct assert via
a test-only __registrySize accessor — the registry is inert in V1, so a
leak has no indirect observable), a pointercancel-storm-during-mouse-drag
leak/ownership test, a chrome-tap-inert test, a stale-owner
reconciliation test, and a touch tap-to-load first-tap-priority test.

NO touch pan, NO touch-action change, NO pinch in this task (V2).
Sheet-swipe-dismiss routed out of V1 as IDEA-2520.

Codex: 2 rounds, final CLEAN (round 1 caught the stale-owner leak + a
conflated inverted test; both fixed).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 11:24:12 +00:00
xarmian 3d191017ed fix(web): fence ordinary attachment loads against in-flight deletes
U2 (TASK-2511) added the ref-counted `inFlightDeletes` marker but applied
it only to `revalidateAfterRestore`'s merge. The ordinary list-load
reconciliation paths still filtered on `deletedIds` alone, and that set is
latched only AFTER a delete's API await (via the deletion bus self-
broadcast). In the gap between an optimistic removal and that broadcast a
row is gone from `attachments` but not yet tombstoned, so an ordinary
list() response issued (or in flight) across that window — a mount/retry
load, or the restore path deferring to an in-flight same-view load — could
carry the row and repaint the tile the user just removed.

Honor `isDeleting(id)` on every ordinary-load reconciliation path, exactly
as the restore merge already does: the response row filter, the pending-
upload merge, and the load-failure repaint. The settle-time rollback stays
a direct write into `attachments` (the marker is cleared in performDelete's
`finally`, after the catch re-inserts the row), so a genuinely failed
delete still rolls its row back into view. Continuation-count math is
unchanged in spirit — `rows`/`missed` simply exclude the same ids the
restore path already excludes.

Tests (jsdom, each mutation-verified): the P1 flow via a retry load, the
pending-upload merge leg, the load-failure repaint, and the rollback-
after-failure discipline surviving a list response that landed mid-delete.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 05:17:26 +00:00
xarmian 885871a638 test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one
Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix
for the U3 count test that was authored but never run.

- Navigation-step (U3): a 3-image set whose viewer order is DERIVED at
  runtime (created_at-DESC ties are the DB's, not the upload order); the
  middle-navigated "arrival" is deleted via a SEPARATE API context so the
  process-local bus never tombstones it, its metadata is primed with a
  cacheable 200 in the PAGE context, and arrowing onto it after a reopen
  forces a no-store HEAD that 404s DESPITE the primed 200 (armed
  waitForResponse, causally the arrow's probe) → tombstone-advance to a
  distinct survivor.
- Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE
  stream starves the delta-sync cursor), archive via per-item event then
  RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving
  the prop-driven strip revalidation covers what a per-item SSE subscription
  would miss. Asserts no attachments.list on archive, and a one-shot route
  HOLDS the restore's revalidation list in flight to prove the tiles never
  blank DURING the fetch, not just after.
- Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the
  process-local bus) reconciles a comment thumbnail img→missing live, with
  the document + timeline element stamped to prove no reload or remount.

Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless
HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires
requestfailed, never requestfinished — the completion barrier now keys on
the response.

New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc,
archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 04:38:37 +00:00
xarmian ffaba632cf test(web): pin attachment open-set mutation contracts (TASK-2513)
PLAN-2392 phase 3c-iii U4. Pin the open-set mutation contracts DR-15
style — assert the chosen behavior, don't assume it.

- Upload-during-open: a new REAL end-to-end test mounts the strip + the
  AttachmentSurfaceHost, opens the surface on a 2-image set, fires the
  upload bus, and asserts the open surface's set is unchanged (counter
  stays 1/2) while the strip's own tile list DOES gain the row. The two
  legs are independent: a dead upload bus fails the strip leg, a
  live-following surface fails the surface leg — neither masks the other.
  It pins the no-live-follow half; the in-place-mutation half stays pinned
  by events.test.ts's deep-snapshot test.

- Rename/metadata-change: no channel exists to exercise it (api.attachments
  has no rename/update-in-place op; transform mints a new peer row; metadata
  is immutable), so the contract is WRITTEN DOWN in the events.ts
  deep-snapshot doc rather than tested, and the future channel is routed to
  IDEA-2515.

Codex-reviewed (3 rounds): tightened the doc to scope claims to the event
channel, correct the api.attachments surface, and account for deletion
reconciliation + downstream metadata completion.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 03:13:33 +00:00
xarmian c5190d6a96 feat(web): revalidate attachment metadata per navigation step (TASK-2512)
Generalize T6's per-open forced-probe (`forcedNonce`) to per-(openNonce,
attachment): `forcedFor: { nonce, ids: Set }`. The opened entry AND every entry
navigated to now gets exactly one automatic `no-store` revalidation, while
arrowing BACK to an already-probed entry within the same open takes the fast
path. A cross-tab deletion of a sibling is no longer invisible when arrowing to
it. A reopen mints a fresh nonce, so the set resets and every entry re-probes.

Two semantics pin the accounting:

- COMPLETION, not dispatch (round-2 P1): a pair is recorded only when its forced
  probe resolves non-stale. A probe discarded stale (arrow away before it
  resolves) leaves the pair unseen, so arrow-back re-probes rather than painting
  a maybe-deleted entry live off the seed.
- AUTOMATIC only (round-4 P2): a Retry-/restore-driven forced probe (the reload
  path) never records the pair, keeping the two mechanisms independent — an
  arrow-back after a Retry still gets its one automatic probe.

The mark is a plain-object write in the async continuation, guarded by the
existing `req.stale()` check and keyed to the pair the run dispatched for, so it
joins no tracked scope and cannot self-invalidate the effect.

Tests: this task owns the T6-era expectations its behavior change INVERTS.
- surfaceMetadata.svelte.test.ts: the two "navigation keeps the nonce → no
  additional forced probe" tests now assert navigation to a fresh sibling forces
  a second no-store revalidation (complete OR incomplete seed); added an
  arrow-back-is-fast-path test and two new-behavior tests (delayed probe →
  stale-discarded → re-probes; completed Retry does not record → arrow-back still
  auto-probes), both mutation-verified to fail on the naive regressions.
- AttachmentSurfaceHost.svelte.test.ts: the arrow test inverts to "arrowing to a
  fresh entry forces one no-store probe of the arrival; arrowing back does not".
- Lightbox.svelte.test.ts: corrected two tombstone-advance comments that claimed
  advanced-to entries use the plain fetch (they now force per U3).
- attachment-surface-chrome.spec.ts (e2e, not runnable in this worktree): the
  no-store counting test inverts — arrowing to a fresh sibling now forces one
  HEAD of the arrival; final counts a:2,b:1. Kept to race-free claims only.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 02:53:43 +00:00
xarmian 08b6d94d30 feat(web): revalidate attachment strip content on parent restore (TASK-2511)
The item attachment strip has no SSE subscription — its only live inputs are
the in-process delete/upload buses — so a restore that happened while this
browser was elsewhere never reaches it, and its rows keep rendering from the
pre-archive fetch (thumb URLs work again by accident, metadata may be stale).

Thread `parentArchived` from ItemDetail (the same signal the timeline U1 and the
surface host DR-14 take, so it covers BULK archive/restore whose
`items_bulk_updated` carries no item_id) and reconcile the CONTENT gap
(PLAN-2392 3c-iii U2):

- RESTORE (true->false edge): `revalidateAfterRestore` re-fetches the attachment
  list and MERGES it over the current rows — a DIFFERENT, gentler path than the
  load effect's non-retry rerun, which blanks attachments/expanded/pendingDelete/
  deletedIds/pendingUploads synchronously. It never blanks (rows stay painted
  until replaced), preserves the tombstones / pending uploads / expanded-overflow
  / open confirmation the reset path would wipe, recomputes the continuation
  count off the fresh `total`, and clears a stale load error on success. A failed
  revalidation is swallowed (not surfaced as the blocking error row) — the strip
  already holds a good pre-archive list.

- ARCHIVE (false->true edge): a content no-op. Tiles keep their painted bytes;
  the interaction paths already fail server-side (DR-14's 404 correction).

Edge correctness:
- The archived latch is keyed to VIEW IDENTITY, not just the boolean: the strip
  persists across item switches, so an archived item A -> active item B is also a
  true->false transition — reseeding the latch on any view-key change keeps a
  SWITCH from firing a duplicate racing load (round-3 P2).
- `inFlightDeletes` (ref-counted, so a concurrent second delete of the same id
  can't be cleared early) excludes a row whose optimistic removal has run but
  whose tombstone broadcast — post-await in `performDelete` — hasn't yet, so a
  restore refetch landing in that window can't repaint the just-removed tile
  (round-3 P1).
- The revalidation DEFERS to a load already fetching THIS view (per-view counter,
  since the api client has no request abort and a stale prior-view load lingers):
  that load returns fresh-enough data, and superseding it would strand the strip
  empty if the revalidation then failed.

Latches are plain `let`s read/written under `untrack` (the Svelte self-write
trap). Adds the strip's `parentArchived` prop mount in ItemDetail. 22 new unit
tests; each guard mutation-verified.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 02:16:38 +00:00
xarmian e3d80619f9 feat(web): reconcile timeline attMeta across attachment lifecycle (TASK-2510)
ItemTimeline cached attachment HEAD-probe results in `attMeta` with no
invalidation path: a deleted attachment stayed a live `<img>`, an archived
parent kept painting a soon-to-be-broken image, and a restore never escaped a
`missing` cached while archived.

Add three reconciliation surfaces (PLAN-2392 3c-iii U1):

- Deletion bus subscription + per-id `tombstoned` set. A HEAD that resolves ok
  AFTER the delete can't repopulate `attMeta`; the tombstone is per-id so one
  deletion never false-fences another attachment's in-flight probe.
- A per-timeline lifecycle epoch captured at probe dispatch and checked before
  every authoritative write, bumped by both archive/restore edges — a
  pre-archive ok or pre-restore missing that lands after the edge refuses to
  write.
- A `parentArchived` PROP (threaded from ItemDetail, mirroring the surface
  host's DR-14 prop — NOT an SSE subscription, so it covers bulk
  archive/restore whose `items_bulk_updated` carries no item_id). While true,
  every probe goes through `revalidateAttachmentMetadata(..., {cache:'no-store'})`
  so a stale cached ok can't repaint a broken image and a genuine 404 lands as
  missing (the LEVEL rule). The false->true edge drops this item's tracked
  attMeta/probe state; true->false re-probes the unresolved set no-store via a
  reactive `probeNonce`.

Both lifecycle edges reconcile over the whole tracked set (referenced ∪ attMeta
∪ probed ∪ unresolved), not just currently-referenced ids, so an attachment
resolved-then-unreferenced can't replay a stale ok as a broken image or skip a
restore re-probe on a stale probe mark.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-11 01:29:45 +00:00
xarmian bbb5a69219 fix(attachments): dock-clear the viewer nav on the mobile sheet (PLAN-2392 3c-ii)
The prev/next arrows were direct children of the fixed backdrop, centred
`top: 50%` against the FULL viewport. The T5 phone sheet shortens the stage
and docks meta+toolbar at the bottom, but the arrows had no sheet-scoped
anchor, so on short/landscape phones they landed in or over the dock —
obscured, or stealing the dock's taps.

Move the two `.lightbox-nav` buttons INSIDE `.lightbox-stage`. On desktop the
stage is `position: static`, so their `position: absolute` still resolves
against the fixed backdrop — byte-identical full-viewport centring. In the
sheet the stage is `position: relative`, so `top: 50%` re-anchors to the
shortened stage box and the arrows clear the dock with no magic-number dock
height. Add `pointer-events: auto` to `.lightbox-nav` (the stage is
`pointer-events: none`); on desktop that was already the inherited value.

Nav now trails the toolbar in DOM order (Close, toolbar, Previous, Next);
accessible-name addressing keeps the trap tests green — adjusted the two
order-naming assertions in the modal-contract spec. Adds a 720x400 landscape
e2e leg asserting the arrows centre on the stage (not the viewport), sit clear
of the dock, and stay clickable; the pre-fix DOM fails the stage-centre
assertion by a dock-half.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 22:39:32 +00:00
xarmian 9c9107176c test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host,
one Lightbox for ANY attachment; the options-panel + image-viewer
channels retired).

Falsified + rewritten (the convergence changed the premise, so these
were rewritten to assert the new behaviour, not deleted):
- strip file-tile / editor file-chip open the role=dialog surface
  (no-bytes fallback arm), not a role=menu options panel
- modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts
  at most one Lightbox by construction)
- owner-4 BottomSheet source moved from the retired file-panel to the
  surviving strip delete-confirm menu
- parity/two-host exact dialog-name -> anchored RegExp (T2b grew the
  accessible name to "name, type · size"); enforced in the hostile-name leg
- zoom thumb->original timeline, switch-safety, and mobile deferred-load
  counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD
  hits the same variant-less URL and polluted the counts)

New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP);
T6 no-store HEAD count (one per open, none on arrow, one on reopen);
DR-14 archived-parent probe-gate + archive-while-open close; dual-host
peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity /
backdrop-vs-chrome dismissal / shortened-stage zoom / file route /
overlay-centring / DR-18 label reveal / native-pinch touch-action /
forced-colors Canvas plate; desktop-unchanged contrast.

Each of the four load-bearing behaviours was MUTATION-verified (break in
source, rebuild the worktree ./pad, confirm the targeted leg FAILS,
restore, confirm green): fallback admission, host event addressing,
archive-close transition, and the T6 forced no-store probe.

Codex-reviewed to CLEAN over five rounds. New selectors live in
web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name
(never a bare [role="dialog"]); assertions are item-scoped / by-id /
by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 5ee40d728f feat(attachments): mobile phone-sheet layout for the viewer (TASK-2492)
The AM-3 Lightbox-owned phone-sheet layout (PLAN-2392 3c-ii / T5). A
reactive `isSheet = $derived(viewport.isMobile)` toggles a `.lightbox-sheet`
class on the existing dialog root, and CSS scoped under that class re-lays-out
the EXISTING chrome into a bottom-anchored sheet: the toolbar and meta leave
their desktop absolute anchors and dock, stacked, to the bottom edge (via
`position: static` + `order`), the stage fills the space above (and becomes
its own containing block so its overlays centre over the shortened stage, not
the dock), and the counter moves to the top-left.

A class, not a bare `@media`, so JS and CSS share the one app breakpoint and
the flip is a DOM fact the modal-contract jsdom suite can drive and read. The
layout is fully layout-independent of the modal contract: the portal, lease,
focus trap, escapeStack registration, loader and zoom transform are untouched,
and nothing is keyed on the viewport, so a breakpoint flip mid-open re-lays-out
the SAME instance with zoom/selection state intact. No `BottomSheet`/`Menu`
instance nests, no swipe dismissal, and no `touch-action`/pointer-capture
changes (per the amended DR-6). Every rule is scoped under `.lightbox-sheet`,
so the desktop layout is byte-identical, and the sheet chrome carries its own
forced-colors boundary.

Tests: sheet selection, a mid-open flip proving same-instance re-layout (root
+ img identity, src + zoom survive), the docked chrome staying excluded from
all three pointer-gesture lists (pointerdown/wheel/dblclick, each with a live
control), no dismissal on a chrome click, and the full modal contract re-run
under the mobile viewport mock. Geometry, touch, `@media`/forced-colors visuals
are named for T7's browser legs.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 44ee5ad806 feat(attachments): always-revalidate-on-open via per-open nonce (TASK-2491)
The converged attachment surface now revalidates the OPENED entry's metadata
on every open, so a cross-tab / background delete is caught rather than shown
as a live-looking row backed by a stale HEAD.

Mechanism: AttachmentSurfaceHost mints a per-open `openNonce`, incremented once
per accepted surface request and ridden on the request object into Lightbox (so
the `{#key request}` remount carries the matching nonce). The nonce joins the
metadata machine's SUBJECT identity (ws, attachmentId, nonce) and drives a
`forcedNonce` tracker that forces exactly one probe of the opened entry per open
— gated on the probe's own precondition (`isOpen && addressable`) so a not-yet-
open/addressable subject can't burn the nonce. Navigation keeps the nonce, so
arrowing does not force (3c-iii owns navigation-step revalidation). Unlike
`seenReload`, the nonce is deliberately NOT seeded from the incoming value: the
guarantee is to force on the first nonce seen.

The forced probe passes the literal `cache: 'no-store'` fetch option, threaded
through revalidateAttachmentMetadata -> fetchAttachmentMetadata -> fetch init,
so the endpoint's `max-age=3600` HEAD cannot serve a stale cached HEAD and
defeat detection. A `missing` result routes through the existing tombstone path.

Deliberate behavior change: the strip's zero-probe fast path is gone. A
complete-seed open previously issued no HEAD; it now issues exactly one forced
no-store revalidation (the displayed fields still come from the seed — seed-wins
merge — so the header is unchanged). A HEAD is not a byte fetch: the mobile
deferred cell's no-auto-bytes rule is untouched. The renderer load key does NOT
gain the nonce — a reopen is a whole new keyed mount, so cross-open coherence is
the remount's job and the nonce is constant within an open.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian af2a6ed57d refactor(attachments): retire the two legacy attachment channels (TASK-2490)
3c-ii T4b: with every producer already on the surface channel (T4a), delete
the two legacy channels and the cutover-window bridge. Gone from
`events.ts`: `notifyViewerOpen`, `notifyAttachmentPanelOpen`,
`ViewerReadyImage`, `ViewerOpenRequest`, `AttachmentViewerOpenEvent`,
`AttachmentPanelOpenEvent`, both legacy predicates
(`isAttachment{Viewer,Panel}EventForHost`) and registries
(`registerAttachment{Viewer,Panel}Listener`), plus the producer-boundary
MIME-drop gate (it lived inside `notifyViewerOpen`). The surface channel —
no admission MIME gate, renderer arm decides — is the sole open channel.

`AttachmentSurfaceHost` loses its two legacy subscriptions and the
`fromPanel` / `fromViewer` translators, and its `wsSlug` prop retires (the
surface channel captures its own workspace at emit); `ItemDetail` stops
passing it. The now-dead viewer-toolbar context props left by T4a
(`mutationsEnabled` / `getItemContent` / `getLiveContent`) delete from
`ItemAttachmentStrip` and `ItemTimeline` — the host forwards them to
`Lightbox` directly. Stale `AttachmentViewerHost` / panel-channel comments
updated across the touched files.

Tests: the legacy-channel unit + bridge tests delete with the channel; the
three host tests that exercised real surface behavior through a legacy event
re-point onto the surface channel (and the invoker + one-probe tests gain
discriminating assertions). Full suite green (1357), `npm run check` 0 errors.
Reviewed to a fresh-angle CLEAN over four Codex rounds.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 72a7491dff feat(attachments): all six producers emit the surface channel (TASK-2489)
3c-ii T4a: every attachment producer now emits `notifyAttachmentSurfaceOpen`
with a captured `workspaceSlug`, its `invoker`, and its seeds — the two direct
`Lightbox` mounts (the strip's and the timeline's) are gone, and the ONE
`AttachmentSurfaceHost` owns every open. The bridge stays subscribed throughout,
so this window still ships a working route.

- Strip IMAGE tile (`openLightbox`): direct mount deleted; emits the raster
  `lightboxImages` set at the clicked index, invoker = the tile.
- Strip FILE tile (`openOptions`): panel → surface, a single-image set from the
  list row, invoker = the anchor. The flat seeds are normalized to match
  images[0] exactly (`filename || null`) — a blank `''` filename against a `null`
  record would fail the notify validator and silently drop the open.
- Timeline (`openLightboxFromImg`): direct mount deleted; emits the sibling
  `viewerImageFor` list, invoker = the img.
- Image NodeView: the raster/non-raster fork collapses to ONE surface emit after
  the (unchanged) resolve-before-emit gate — svg and raster both emit the same
  event, and the surface's own `getSurfaceRenderer` picks the arm.
- Chip: panel → surface single; `workspaceSlug` captured from the live address.

Both direct-mount producers gain a `paint.isCurrent()` STALE-ACTIVATION fence
they lacked: the strip reuses its existing paint fence; the timeline gains one (a
`viewIdentity` + `createPaintFence` recorded in an effect that tracks `entries`
and captures the view through `untrack`, so a bare workspace change that has not
yet reloaded keeps the old paint and refuses a stale click). The timeline's old
`lightbox`-clear-on-switch is retired — the host closes the open surface on the
resource switch now.

Tests: the nine producer suites migrate to the surface channel — each producer's
emitted workspaceSlug / invoker / seeds / index asserted, and the strip/timeline
opens asserted THROUGH a mounted `AttachmentSurfaceHost` (the real Lightbox end
to end). `viewerImagePayload`'s direct-mount premise is falsified and rewritten to
assert the emitted set. The svg cases assert one surface emit / the fallback arm
rather than "opens nothing". Housekeeping: the stale `AttachmentViewerHost`
comments in the touched files updated.

npm run test 1389 pass, npm run check 0 errors; the blank-filename open is
regression-pinned; reviewed to a fresh-angle CLEAN (round 1 caught the flat-seed
mismatch, round 2 clean).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian 9dc569c866 feat(attachments): the atomic cutover — one surface host, panel retires (TASK-2488)
3c-ii T2b, the commit users feel. ItemDetail's two attachment hosts collapse to
ONE `AttachmentSurfaceHost` at the top-level position, carrying the union prop
set (wsSlug for the legacy-panel bridge + itemId + hostToken + resourceGen +
mutationsEnabled + the content getters + parentArchived). BUG-2413's server
fail-closed disposition is on the base, satisfying the merge-gate.

Deleted: `AttachmentPanelHost`, `AttachmentViewerHost`, `AttachmentDetailsPanel`
(551 lines, + its CSS + its Menu/MenuItem usage). `AttachmentDeleteConfirm`
survives as the drill-down inside the surface. The panel's `closeAfterNavigation`
retires with it — the surface stays open after Open/Download, where the panel
closed itself.

Every open now flows through the one host and the file-capable Lightbox (T3):
a strip file tile → the fallback arm + file toolbar (through the legacy panel
channel + the bridge — producers repoint in T4a), a chip → the same, an image
NodeView → the raster arm, a non-raster redirect → the fallback arm.

Preserved three panel behaviors the cutover would otherwise have dropped (the
round-3 "what did the panel do that nobody ported" lens):
- A SINGLE-item surface whose file 404s shows the panel's inert "no longer
  available" overlay instead of flash-closing — `soleMissing` keys on
  `images.length === 1` (a panel open is always single), disposes the loader
  (no bytes) and keeps the toolbar inert; a MULTI-image set still advances /
  closes through the tombstone path, and an EXTERNAL bus delete still closes a
  single, exactly as the panel host did.
- The dialog's accessible name is the display name plus the header's type · size,
  not a bare alt.
- A null-filename file is named with the shared "Untitled file" fallback (the
  bridge uses `displayFilename`), not the Lightbox's bare "Attachment".

Test migration (named, not silently dropped): the extraction grep-gate →
`Lightbox.extraction.test.ts` (same contract, new consumer); the NodeView →
host → Lightbox whole-route test retargeted onto this host (the SVG redirect now
lands on the fallback arm); the three host suites consolidated into
`AttachmentSurfaceHost.svelte.test.ts` (lifecycle/addressing from T2a) and the
Lightbox suite (panel behavior), with a migration manifest naming what moved
where. Grep: zero PRODUCTION references to the three deleted components.

npm run test 1388 pass (Lightbox 190, host 22), npm run check 0 errors; the
single-item overlay + the aria-name changes are pinned; reviewed to a
fresh-angle CLEAN (round 1 caught the three dropped behaviors, round 2 clean).

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian e0c06b0bdf feat(attachments): unified surface host with three-channel bridge (TASK-2487)
3c-ii T2a: `AttachmentSurfaceHost` — the one host that replaces the legacy panel
host + viewer host, mounting the grown Lightbox that opens ANY attachment. Built
COMPLETE but mounted NOWHERE: T2b does the atomic cutover (mount this, delete the
two legacy hosts + the panel), T4a repoints the six producers. Only its own suite
mounts it (grep confirms zero app-code references).

Three channels, one request (the bridge invariant). During the migration the
producers still emit on the two LEGACY channels, so the host subscribes to all
three (surface, panel, viewer) and TRANSLATES the legacy shapes INTERNALLY into
its own request state — it never calls `notifyAttachmentSurfaceOpen`. One `$state`
request → `{#key request}` → one Lightbox mount, so an accidental old+new double
emission for the same open supersedes rather than double-opens.

Translation, explicit: a legacy PANEL event becomes a single-open request with
`invoker = anchor` (never the live activeElement; no positioning), a one-element
images set from the seeds, and `workspaceSlug = the host's wsSlug prop` — the
transitional exception, since the panel channel carries no workspace (the new one
always does). A legacy VIEWER event maps field-for-field, keeping its captured
workspace and filling the flat seeds from images[index].

Lifecycle, ported from the two hosts and stated as the rule T3 deferred:
- Archive-closes / restore-revalidates, TRANSITION-based — which naturally splits
  archive-while-open (close) from open-while-already-archived (no transition → the
  surface mounts probe-gated INERT, not a flash-close: the user asked for the file,
  so show the inert "unavailable" state rather than blink it shut).
- Resource-switch clear on itemId change OR resourceGen advance (the complete rule).
- External-deletion close-when-SINGLE; a multi-image set is left to the Lightbox's
  own tombstone path (advance / close-last), never preempted by the host.
- closeRequest bound to its target (stale-continuation fence) and `request?.` guards
  on the lazily-read Lightbox props (a delete continuation reads them after the
  close nulled request).

Adds a minimal `revalidateToken` prop to Lightbox (threaded into the metadata
address, replacing the hardcoded 0) so restore re-probes an archived-at-open
surface; T6's always-revalidate-on-open openNonce layers onto the same input.

Tests: the three subscriptions; exact-once per channel (one legacy event → one
request → one mount → one probe → one focus return; old+new double emission does
not double-open); translation fidelity incl. the transitional-wsSlug and
captured-wsSlug cases; anchor→invoker for focused/null/disconnected; dual-host
addressing isolation; and the ported panel-host lifecycle (archive/restore/
item-switch/resourceGen/deletion). npm run test 1440 (host 21), npm run check 0
errors; the set-vs-single deletion guard mutation-verified; reviewed to a
fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian af055a9bc9 feat(attachments): Lightbox admits any attachment — file route and reclassification (TASK-2486)
3c-ii T3: the image viewer becomes the converged surface that opens ANY
attachment. PURELY ADDITIVE — no producer routes files/archived to it yet (T4a),
so only the tests exercise the new capability and production behavior is
unchanged.

Admission flips (DR-20 final form). The last-mile filter that kept only
allowlisted-raster MIMEs navigable and REFUSED null/unsafe is gone, along with
the `unsafeAtOpenIds` snapshot: every entry is admitted, and safety moves to the
ARM. `shownRenderer` (and the toolbar's Open, and the fallback icon) derive from
the RESOLVED MIME — the seed's, or what the metadata machine's HEAD probe filled —
so `'raster-image'` mounts the `<img>` + bytes while a non-raster type (unsafe,
a file, or a still-unresolved MIME) mounts the no-bytes icon fallback. Admitting
unsafe/unresolved renders no hostile bytes: the arm fails closed on the resolved
MIME, joins the load key, and the loader is disposed off the raster arm.

Reclassification. A null-seed open shows the fallback until its probe answers,
then re-derives: raster → the image arm, PDF → fallback + Open, ZIP → fallback
without Open. The raster load hands the loader the RESOLVED mime
(`{ ...img, mime_type: resolvedMime }`) so its own DR-16 gate — which reads the
img it is given — agrees with the arm rather than refusing a null-seed row the
arm admitted.

Archived parent. `parentArchived` is a prop now (was hardcoded false), threaded
to the metadata machine so an archived-parent open forces a reachability probe
(DR-14). Every toolbar action is inert while `missing || unreachablePending`,
where `unreachablePending = parentArchived && (phase !== 'ok' || slow) && !missing`
— inert until a SETTLED ok, covering seeded, transient (slow-timeout), and a
forced re-probe after a prior ok. Disabled anchors drop their href (keyboard-inert),
not just aria-disabled. Threading the prop through the production hosts is T2a/T4a;
the archived lifecycle (archive-closes / restore-revalidates) lands with the host.

Tests: swept and rewrote the falsified 3c-i at-open-refusal pins to
admission+fallback assertions; added the file route (PDF Open present, ZIP absent),
delayed null-seed reclassification (raster/PDF/ZIP), and archived-parent gating
(pending / transient-stays-inert / ok→archived re-probe / re-enable-on-ok). The
3c-i unsafe-mid-view tests still pass. npm run test 1419 (Lightbox 190), npm run
check 0 errors; key invariants mutation-verified; reviewed to a fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00
xarmian da1b3f82ed feat(attachments): unified surface-open channel (TASK-2485)
The 3c-ii convergence's first task (T1): one bus channel for opening ANY
attachment on the grown Lightbox — image, file, or a row whose type is not yet
resolved — alongside the existing panel and viewer channels. Purely ADDITIVE:
the two legacy channels are untouched and nothing repoints yet (T2a builds the
host, T4a repoints producers, T4b deletes the legacy channels).

Adds `AttachmentSurfaceOpenEvent`, `notifyAttachmentSurfaceOpen`,
`isAttachmentSurfaceEventForHost`, and `registerAttachmentSurfaceListener` to
events.ts, matching the sibling channels' conventions (DR-8 addressing via
`isAddressable`, one host token per mount, same guard order and comment voice).

Differs from the viewer channel by design: NO admission MIME gate (files and
null-MIME/unresolved rows pass — the allowlist governs the render arm
downstream, never admission), and no `anchor` (the centered surface returns
focus via `invoker`). The event carries a CAPTURED-at-emit workspaceSlug
(required, no host fallback) and nullable single-attachment seeds that, when
present, describe images[index].

The emitter is the convergence boundary every producer will funnel through, so
it is hardened accordingly: it reads every input EXACTLY ONCE (event scalars,
the array length, each of the seven record fields), enforces the event's own
invariants at the boundary (index in range; images[index].id === attachmentId;
a non-null flat seed must agree with that record), and delivers a DEEP snapshot
built by explicit field projection — a fresh all-primitive record per entry and
an explicit event projection — so a caller that keeps mutating its set, a
getter/proxy TOCTOU, a shadowed `.map`, a stray property, or a non-string
identity field cannot reach an open surface. `invoker` is the one intentional
live reference (the focus target).

Tests (events.test.ts): predicate address isolation + null event; the capture
rule; null-MIME pass-through (the old gate's drop asserted ABSENT here); the four
boundary cases each their own test (out-of-range index, id mismatch, inconsistent
seed, deep snapshot of array AND records); plus record-integrity, projection, and
undefined-seed cases. cd web && npm run test (1410) + npm run check (0 errors)
green; key assertions mutation-verified; reviewed to a fresh-angle CLEAN.

Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
2026-08-10 21:59:41 +00:00