Commit Graph

1315 Commits

Author SHA1 Message Date
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
xarmian d7da237198 feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584) (#1107)
* feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584)

v0.16 and v0.17 made unassigning WORK. Nothing advertised it. The params
that do it — `assigned_user_id` / `agent_role_id` — were never in the
catalog, so an agent reading the tool schema to find out how saw only
`assign` (a name) and reached for `assign: ""`, which is a no-op and
deliberately stays one. The capability existed with no name an agent
could find.

`clear_assigned_user` / `clear_agent_role` booleans on `pad_item`, backed
by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on
`pad item update`.

WHY BOOLEANS rather than declaring the existing string params. Two
reasons, and the second decided it:

  1. An empty DECLARED string is inert everywhere else on this tool
     (title, content, comment, tags), so a client that pads optional
     params with "" instead of omitting them is harmless today. Giving
     one a destructive meaning would turn that same client into one that
     silently unassigns every item it touches. A boolean carries its
     meaning in its name and can't be tripped that way.
  2. Only a boolean can REACH local stdio. BuildCLIArgs emits the CLI's
     real flags, so a catalog param with no flag behind it is dropped
     before dispatch — declaring `assigned_user_id` would have left the
     direct form remote-only, i.e. would not have closed the gap this
     change exists to close. That fact reframed the design fork and is
     what the ruling turned on.

Server-side this is WIRING, not new semantics:
models.ItemUpdate.ClearAssignedUser / ClearAgentRole already existed and
the store has honoured them since BUG-2566, on the same branch as the
empty-string form. The older forms keep working and are NOT deprecated;
they're just not what the schema advertises.

UPDATE ONLY, deliberately asymmetric with create, and recorded in-place
at both the flag registration and the catalog description so a
symmetry-minded reader meets the reasoning before the "fix": clearing at
create is a request to not-set something never set, whose only honest
behaviour is a no-op — it teaches a wrong affordance and pads every
create call's schema. A test fails if someone adds them there.

CLI precedence is the OPPOSITE of the --field lift's, deliberately: an
explicit `--clear-assigned-user` beats `--assign`, because that
combination is a contradiction the user typed and the reading that
cannot silently assign somebody is the safer one. Tested.

The dispatcher forwards the booleans VERBATIM rather than only-when-true.
A `&& b` guard would read as the thing protecting a param-padding client
and would be lying: what makes `false` inert is the store. Same call I
made on #1106's `len(patch) > 0` — a guard that reads as load-bearing
while doing nothing is worse than none.

ToolSurfaceVersion 0.17 -> 0.18, ADDITIVE bump per the v0.5 / v0.6
precedent: no existing tool, action or param changed shape.

Consumed artifacts moved in the same commit, which is the whole point of
this change — the schema IS the deliverable: catalog_item.go (the schema
agents read, plus an `assign` description that now says where to find the
clear), instructions.md (leads with the boolean, mentions the older forms
as still-working), version.go, README, CLAUDE.md.

VERIFIED LIVE, five legs, both transports:
  CLI   --clear-assigned-user            -> assigned=None, role intact
  CLI   --clear-agent-role               -> role=None
  stdio clear_assigned_user:false        -> assignment SURVIVES and the
                                            update still applied (title
                                            changed) — the control that
                                            makes the boolean safe to
                                            declare at all
  stdio clear_assigned_user:true         -> assigned=None
  stdio clear_agent_role:true            -> role=None

Three mutations, each failing only its own tests: dropping the dispatcher
forwarding; hardcoding true in the dispatcher (fails the false-control);
dropping the CLI flag wiring.

go test ./cmd/pad ./internal/mcp — pass. gofmt clean.

Closes IDEA-2584.

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

* fix(mcp,cli): refuse a simultaneous set-and-clear (codex round 1)

Codex found a real bug, and the more useful half of the finding is that
MY OWN TEST FOR IT WAS VACUOUS.

The store's branch order is `if AssignedUserID != "" { set } else if
ClearAssignedUser { clear }`. So `--assign wren --clear-assigned-user`
assigned Wren and the clear evaporated. My in-place comment claimed the
opposite ("an explicit clear wins"), and the test I wrote to prove it
asserted `body["clear_assigned_user"] == true` — that the FLAG was set,
not that the item ended up unassigned. The flag was set. The behaviour
was backwards. A test that asserts a field is present says nothing about
which field wins.

Both surfaces now REFUSE the contradiction rather than silently resolving
it. Rejecting beats picking a winner here: the store already picks one
silently, which is the bug; and a caller who typed both wants to be told,
not guessed at. Precedent in the same command family — `item list`
already makes `--parent` and `--unparented` mutually exclusive.

PLACEMENT IS THE LOAD-BEARING PART, and I got it wrong first. There are
two routes to a competing value: `--assign` / `assigned_user_id`, which
resolve early, and `field: ["assigned_user_id=<uuid>"]`, which reaches
the payload via liftFieldsToColumns LATER. My first version checked
between them and its comment asserted the lift "has already" run — it
hadn't. That version rejects the direct case and lets the lifted case
through: a half-fix that reads as complete. The check now runs after
both, in the CLI after --assign/--role resolution and the lift, in the
dispatcher immediately before the body marshal.

That mutation is now a test: moving the dispatcher check back to the
pre-lift view fails ONLY the two `lifted …` subtests and passes the
direct one — the exact shape of the bug I nearly shipped.

Tests assert the OUTCOME, not the message: a refused conflict must leave
the item's assignment AND role untouched, and the CLI must issue no PATCH
at all. An error string alone wouldn't prove the write didn't happen.

Agent-facing text moved with it (the consumed-artifact step): both
catalog descriptions, instructions.md, and the v0.18 version entry now
say the combination is refused. An agent that pairs them gets a
structured refusal, so the schema has to say so.

go test ./cmd/pad ./internal/mcp — pass. gofmt clean.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 13:17:01 -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 847ee73327 fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583) (#1106)
* fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583)

`pad item update TASK-9 --field assigned_user_id=<uuid>` wrote the pair
into the item's FIELDS JSON BLOB while the column stayed stale, and then
printed "Updated TASK-9". Two defects in one line: a success message for
a write that did nothing the caller asked for, and a blob key shadowing a
real column's name, so the CLI surface diverged from store/HTTP/MCP
truth. The empty-string case was the same defect wearing a worse hat —
it was the only route an agent had to unassign an item.

Blast radius beyond the CLI: local stdio MCP (`pad mcp serve` — Claude
Desktop, Cursor, Windsurf) dispatches through ExecDispatcher, which
shells out to this CLI. So TASK-2571's fix reached the remote /mcp
transport only, and the transport most agents actually use still could
not unassign. This closes that half.

`cmd/pad/cmd_item.go` now lifts `columnFieldKeys` out of the --field map
onto the column pointers, on CREATE and UPDATE both, mirroring
internal/mcp/dispatch_http.go's liftFieldsToColumns — including its
INVARIANT, which is the part that matters: only keys with defined
clear-to-NULL semantics for "" belong in the list, and `tags` never does
(an empty write corrupts a JSONB column rather than clearing it). A test
fails if anyone adds it.

Two compat changes, ruled separately by the lead:
  Q1  non-empty values move to the COLUMN and stop writing the blob key.
      Accepted: relying on the old behaviour is relying on a shadowing
      defect.
  Q2  empty values clear the column. Falls out of the lift, inheriting
      BUG-2566's store semantics.
`agent_role_id` gets identical treatment. Existing stray blob keys are
left alone per the ruling — this stops minting new ones; a sweep would
be its own change.

Precedence is explicit and tested: `--assign` / `--role` win over a
lifted --field value, matching liftFieldsToColumns' "caller-supplied
top-level values win". It is delivered by the ORDER of two blocks in the
command, which is exactly the kind of thing that gets reordered by
accident, so there is a test whose only job is to fail when it does.

A non-string --field value is deliberately NOT lifted: a collection that
genuinely declares a field with one of these names makes parseFieldFlag
return a typed value, which cannot address a column. It stays in the
blob — today's behaviour and the only lossless option.

ToolSurfaceVersion 0.16 -> 0.17, and v0.16's transport-scope paragraph
now points forward rather than claiming a limitation that no longer
holds. Behaviour-only bump again, same grounds as v0.16 and v0.9. The
CLI's own marker, CmdhelpVersion, deliberately does NOT move: its
contract is flag/arg SCHEMAS, and no flag or argument changed shape.

instructions.md — the text agents receive at handshake — drops the
"remote only" caveat it carried since TASK-2571. That file is the reason
this PR exists in the shape it does: it is the artifact the actor reads,
and it was the one place the previous PR overclaimed.

VERIFIED LIVE against a running server, with a negative control, because
the claim is about a transport rather than a function:

  legs, fixed binary
    --field assigned_user_id=          -> column CLEARED, blob clean
    --field assigned_user_id=<uuid>    -> column SET, blob clean
    --field agent_role_id= / <uuid>    -> same, sibling column untouched
    stdio MCP tools/call pad_item
      action=update field=["assigned_user_id="]
                                       -> column CLEARED, blob clean

  control, PRE-FIX binary, same server + same item + same JSON-RPC bytes
                                       -> column UNCHANGED, blob polluted
                                          with {"assigned_user_id":""}

Six unit tests in cmd/pad/item_column_fields_test.go, four mutations each
failing only its own test (no lift; drop non-strings; flip the
lift/assign precedence; add `tags` to the list). One assertion was
rewritten after mutation testing showed it was VACUOUS: `len(fields_patch)
!= 0` passes whether the key is absent or present-and-empty, so it now
asserts key PRESENCE — confirmed by mutating `omitempty` off the model
field and watching the old form stay green. The redundant `len(patch) > 0`
guard that assertion was meant to cover is gone too; `omitempty` already
does that job, and a guard that reads as load-bearing while doing nothing
is worse than no guard.

go test ./cmd/pad ./internal/mcp — pass. gofmt clean.

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

* test(cli): cover the create half of the column lift (BUG-2583)

Codex came back CLEAN, but the review reminded me I'd changed `item
create` and only tested it through `liftColumnFields` directly — no test
asserted what create actually puts on the wire. That's the weaker half to
leave uncovered, not the stronger one: on update a wrong write contradicts
a visible prior value, while on create the column-named key is simply
baked into the blob at birth with nothing to contradict it.

The assertion has to parse rather than index, because ItemCreate.Fields is
a JSON-encoded STRING and not a nested object — a body["fields"]["…"]
lookup would have been vacuous in a way that looks fine.

Mutation-tested like the rest: neutralizing the create-side lift fails
this test and only this test.

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

* docs(mcp): say WHICH form of the unassign works on which transport (codex round 2)

Codex round 2, and it is the same class of defect as the previous PR's
round 2 — an overclaim in the artifact agents actually read. My
instructions.md said "works on BOTH transports" of two forms that do not
behave the same:

  field: ["assigned_user_id="]   clears on BOTH transports
  assigned_user_id: ""           clears on REMOTE ONLY

The direct params are not declared in pad_item's schema. They reach the
remote mapper only by riding the verbatim input map; on stdio,
BuildCLIArgs drops unknown keys, so the call does nothing.

VERIFIED, not accepted on the reviewer's word, and the verification
corrected my own first reading. My initial probe appeared to show the
stdio call CORRUPTING the fields blob — but that blob key was leftover
state from the earlier pre-fix control leg, not something the probe
wrote. Re-run against a freshly created item, the two forms separate
cleanly:

  before                             assigned=b6786b13...  fields={priority,status}
  after assigned_user_id:""          assigned=b6786b13...  fields={priority,status}   (clean no-op)
  after field:["assigned_user_id="]  assigned=None         fields={priority,status}   (cleared)

So the stdio behaviour of the direct param is a DROP, not a corruption —
worth stating precisely, because "it corrupts the blob" would have sent
the next reader hunting a bug that isn't there. (Identity-doc rule: a
guessed mechanism stated as the reason is a claim, not a hedge.)

instructions.md now leads with the form that works everywhere and names
the remote-only limitation of the other; version.go and CLAUDE.md say the
same. IDEA-2584 — declare the params properly — is the fix that would
collapse this distinction, and is now cited from all three.

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

* fix(cli): don't lift a field the collection actually DECLARES (codex round 3)

Nothing reserves `assigned_user_id` or `agent_role_id` as field names, so a
collection may legally declare a field with one of those keys. For that
collection `--field assigned_user_id=foo` means the DECLARED field — and
the lift I just added would redirect it to the assignment column while
dropping the value the user set. Two wrongs from one line: the intended
write vanishes and an unintended one happens.

liftColumnFields is now schema-aware and never lifts a declared key. Cheap
to do here because both call sites already fetch the collection schema for
parseFieldFlag. The check is PER-KEY — an undeclared sibling still lifts,
so one collision doesn't disable the feature — and a schema-fetch failure
degrades toward lifting, matching how the rest of --field handling degrades.

This makes the CLI deliberately STRICTER than the MCP dispatcher it
otherwise mirrors. liftFieldsToColumns has the identical collision and
can't make the same check as written: it builds its fields map straight
from the tool input without fetching a schema. Filed as IDEA-2587 rather
than fixed here, because closing it costs a round-trip on a hot path while
the CLI fix was free — and recorded so the divergence is KNOWN, in the safe
direction, rather than something a later reader "fixes" by loosening the
CLI to match.

The old non-string branch stays as belt-and-braces: parseFieldFlag only
returns a non-string for a declared field, which the new check already
catches, but if that stops being true a non-string still can't address a
column.

Mutation-tested: ignoring the schema declaration fails the new test and
only that test.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 12:12:32 -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] a8989a0ad3 chore(ci)(deps): bump actions/attest-build-provenance (#1071)
Bumps the actions-minor-and-patch group with 1 update in the / directory: [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance).


Updates `actions/attest-build-provenance` from 4.1.1 to 4.2.2
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/0f67c3f4856b2e3261c31976d6725780e5e4c373...4d101475d8b20a2381f78447822ac1eab6504dd8)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-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:35:24 -04:00
dependabot[bot] f1bd144b02 chore(ci)(deps): bump actions/checkout from 6.0.2 to 7.0.1 (#1073)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6.0.2...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  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:25:36 -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 ee05c58446 fix(mcp): let an agent clear an item assignment (TASK-2571) (#1104)
* fix(mcp): let an agent clear an item assignment (TASK-2571)

Two filters in the MCP dispatch path dropped an empty-string assignment
value before the request body was built, so an MCP agent had no way to
UNASSIGN an item — `assigned_user_id=""` was a silent no-op rather than a
clear or an error:

  - mapItemUpdate's top-level pass-through (dispatch_http_advanced.go)
  - liftFieldsToColumns (dispatch_http.go), which lifts `--field` entries
    onto their columns. This is the path an agent actually reaches: the
    catalog exposes `assign` (a name) and `field`, but no
    `assigned_user_id` param, so `field: ["assigned_user_id="]` is the
    only schema-visible way to ask.

Both were right when written — `""` had no defined meaning at the store
and bound an empty string into a FK column. BUG-2566 gave `""`
clear-to-NULL semantics for exactly these two columns and the HTTP
surface inherited it, which left MCP the odd surface out. Uniformity
restoration, not a new feature.

Compat posture ACCEPTED per the lead's ruling: a caller sending `""`
today gets a no-op, and after this gets a clear. That is the correct
reading of the input — nobody sends an empty assignment ID meaning
"leave it alone" — and the no-op is the surprising half. Option (b)'s
clear_assigned_user / clear_agent_role schema flags are deliberately
skipped as additive sugar.

The empty-string filter on `tags` three lines above STAYS (codex #547 r3
P2): `tags: ""` is not a clear, it is a corrupt write into a JSONB
column on Postgres and TEXT on SQLite. Same-looking guard, opposite
justification — the new test's control leg fails if someone "unifies"
them.

ToolSurfaceVersion 0.15 -> 0.16. No tool, action, or parameter shape
changed, so this is a BEHAVIOUR bump on the v0.9 precedent (which moved
for a return shape with an unchanged signature). Flagging it for the
lead as my call, not theirs — it is a one-line revert if they read the
contract differently.

TRANSPORT SCOPE, established live rather than assumed: this fixes the
REMOTE /mcp transport, where both filters lived. LOCAL STDIO MCP still
cannot clear, because ExecDispatcher shells out to the CLI and the CLI
has no unassign at all — `--assign`/`--role` skip on empty, and
`pad item update TASK-9 --field assigned_user_id=` writes
{"assigned_user_id":""} into the item's FIELDS BLOB while the column
stays set (observed against a running server). Separate defect, CLI-wide
blast radius, filed separately rather than riding along on a ruled-scope
PR. The version-history entry says so explicitly so the note can't be
read as covering it.

Tests: internal/mcp/dispatch_http_clear_assignment_test.go drives the
REAL server + store, not a recording handler — asserting the dispatcher
merely puts `""` in the payload would restate the fix rather than test
it. Three mutations, each failing only its own test: restoring the
top-level filter fails the two direct-param tests; restoring the lift
filter fails the --field test; removing the tags filter fails the
control leg.

go test ./internal/mcp ./internal/store ./internal/server — all pass.

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

* docs(mcp): record why an empty `assign` alias still doesn't clear (codex round 1)

Codex's finding is REAL: the catalog exposes `assign` / `role`, not
`assigned_user_id` / `agent_role_id`, so an agent reading the schema will
reach for `assign: ""` to unassign and get a no-op. The fix as shipped
only covers the params an agent has to already know exist.

Its suggested remedy — map the empty aliases to a clear — is the riskier
of the two it lists, and I've deliberately not taken it.

`assign` is SCHEMA-DECLARED. Every other schema-declared string on this
mapper (title, content, comment, tags) follows one convention: empty
means NOT PROVIDED. An MCP client that fills declared optional params
with "" instead of omitting them is harmless today; making `assign: ""`
mean "clear" would turn that same client into one that silently
unassigns every item it touches — destructive, silent, and inconsistent
with the four params beside it. That is exactly why the same change IS
safe for `assigned_user_id`: an agent can only send it deliberately.

The remedy that closes the gap without that hazard is the other one
codex names — explicit clear_assigned_user / clear_agent_role params,
i.e. option (b) on TASK-2571, which the lead deferred as additive sugar.
This finding is new evidence for revisiting that, so it goes to the lead
as a decision rather than being taken unilaterally in a ruled-scope PR.

Adds the reasoning at both call sites and a test that pins the limit, so
a future "finish the job" edit fails a test and has to be a decision
rather than a drive-by. The MCP instructions already name the working
form meanwhile.

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

* docs(mcp): scope the unassign instructions to the transport where it works (codex round 2)

Codex round 2, and it caught a defect in my own round-1 documentation
fix. instructions.md is the text sent to agents at handshake, and BOTH
transports serve the same string — so telling agents "pass
assigned_user_id: '' to unassign" was true on remote /mcp and a lie on
local stdio, where ExecDispatcher shells out to a CLI that has no
unassign path. I had scoped the claim carefully in version.go and the
commit message and then overclaimed in the one place agents actually
read.

The instructions now name the transport, say plainly that stdio ignores
the value, and tell the agent to verify rather than assume. An agent can
act on a conditional; it cannot act on a claim that is false half the
time.

Both gaps are now filed rather than merely described:
  BUG-2583  — the CLI has no unassign at all, and `--field
              assigned_user_id=` writes into the item's FIELDS BLOB
              while the column stays set (verified live: fields became
              {"assigned_user_id":"", ...} and the CLI printed
              "Updated TASK-9"). This is what makes stdio MCP fail.
  IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
              so an agent reading the schema still cannot discover the
              clear. Reopens option (b) with codex's evidence.

version.go and CLAUDE.md now cite both refs, so the version-history
entry can't be read as covering more than it does.

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

* docs(mcp): cite BUG-2583 / IDEA-2584 in the version history and CLAUDE.md

Follow-up to the previous commit: its scripted edits to version.go and
CLAUDE.md silently no-op'd (a gofmt rewrap moved the anchor text), so
only instructions.md actually changed. Caught by grepping for the refs
rather than trusting the commit.

Both files now name the two filed gaps, so the v0.16 entry cannot be
read as covering more than it does:

  BUG-2583  — the CLI has no unassign, which is why local stdio MCP
              still can't clear.
  IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
              so the clear stays undiscoverable from the schema.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 11:11:45 -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 79b3220c61 test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) (#1102)
* test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570)

The reload-fault closure now narrows the member's access on faulting
tick 1 and lifts the fault on faulting tick 2, so consecutive reload
failures stop at exactly 2 — strictly below the clear-the-watch-set
bound — and the green path carries no timing bet at any load. The
300ms sleep is gone; readiness is signaled by the tick sequence itself.

Codex round 1 on this fix surfaced that regression DETECTION still has
a window (a successful tick 3 masks a hypothetical reset-skipped-on-
fault regression), so the interval is set to 500ms to give the revoked
PATCH ~10x headroom over measured loaded-runner request latency, and
the control-leg wait — the one that timed out in both CI instances —
is widened to 10s since it asserts delivery-at-all, not latency.

Verified: 5x -race green at both 50ms and 500ms; counterfactual mutant
(reset moved to the reload success path) leaks 3/3; full suite + lint
green; Postgres leg 2x -race green.

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

* test(server): drive reval ticks through a seam — deterministic in both directions (BUG-2570)

Codex rounds on the first fix found two regression-DETECTION windows
the interval-tuned shape could not close: a stray successful tick
before fault installation or after the tick-2 lift resets visCache /
reloads the watch list, masking the reset-skipped-on-fault regression
this test exists to catch. Interval tuning trades green-determinism
against detection-determinism; a free-running ticker cannot give both.

So the handler gains watchRevalTickOverride — a test seam mirroring
watchPredicatesLoadFault (atomic pointer, read once at stream setup)
that lets a test substitute the reval tick source. The test now drives
exactly ONE tick, after the access change, with the reload fault
active: no early tick can mask via a pre-fault reset, no late tick can
mask via a post-lift reload, and one faulting tick can never reach the
clear-the-watch-set bound. No sleeps, no interval mutation, no wall-
clock bets in either direction.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-15 09:33:29 -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 403a6de19d docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) (#1100)
* docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541)

`skills/pad/SKILL.md` — the //go:embed source `pad agent install` writes into
user projects — had TASK-2537's minimal safety note but not the structured
branch the plugin copy carries. Ports it, minus the Claude-Code-specific shell
advice (the embed source also serves Codex, Cursor, Windsurf, OpenCode and
pure-MCP agents with no shell at all), so it now names the two stderr
signatures as separate cases with opposite handling.

Also adds the Onboarding routing entry's missing precondition. It sent the
agent to load the onboard playbook, which lives IN a workspace — so on the
unlinked path `pad playbook show onboard` fails exactly the way bootstrap just
did, and the entry routed into a dead end.

Ride-along from the lead's citation sweep: the stale "hangs indefinitely / no
timeout" wording still shipped in plugin/skills/onboard/SKILL.md and
plugin/skills/pad/SKILL.md (two places). All three now carry the bounded
wording.

RE-VERIFIED RATHER THAN INHERITED, per this item's own requirement — and the
inherited correction needed one too:

- Read the code myself. First-admin setup is capped at 20m
  (bootstrap.go::bootstrapPollTimeout). The auth poll
  (cmd_auth.go::pollAndSaveCLIAuth) has NO wall-clock limit of its own: it
  exits only on ctx.Done, `approved`, or `expired`, and `continue`s past
  transient errors. So the ~5m bound is entirely the server-side session TTL
  (cli_auth_sessions.go::cliAuthSessionTTL) — if the server becomes
  unreachable after the session is created, it polls forever. "Bounded, not
  indefinite" is right for the ordinary case and wrong for that one; the skill
  text now says both. Filed separately.

- Observed it, not just read it. On a configured-but-unauthenticated HOME,
  `pad workspace init` printed the browser URL and was still waiting when a
  25s cap killed it. `pad auth whoami` returned in 0.106s in that state AND in
  the unconfigured one, with distinguishable output — the "fast, safe" claim
  the whole branch rests on.

- The exact stderr strings were wrong in both copies: the not-configured case
  has NO `Error:` prefix (`Pad is not configured. Run 'pad auth configure'
  first.`), only the unlinked one does. Corrected from captured output.

Verified through `pad agent install claude` into a scratch project and read
back off the installed file, not the diff.

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

* docs(skill): `pad auth whoami` blocks in a TTY when unconfigured — qualify the claim (codex review)

Both plugin copies said `pad auth whoami` "never blocks waiting on input".
It does, in one state: `whoamiCmd` → `getConfiguredConfig()`, and on an
unconfigured machine that enters the interactive configure flow whenever
`canPromptForConfig()` is true — i.e. stdin AND stdout are both terminals
(configure.go:357). My own measurement (0.106s) was non-interactive, so it
could never have caught this; Codex found it by reading the call path.

The embed copy already had the right qualification ("in non-interactive use it
returns immediately"); this brings the two plugin copies in line and says why
the qualification is the operative one for an agent.

Codex's other finding — that the embed source's opening still frames `/pad` as
THE command, for surfaces with no slash commands — is real but pre-existing and
editorial rather than part of this port. Filed as BUG-2573. The auth-poll
timeout gap found while re-verifying the hang is BUG-2572.

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

* docs(skill): `pad init` does not fail fast either — correct both plugin copies (codex round 2)

Both plugin copies told agents that in the configured-but-unauthenticated
state, `pad init` "fails fast" and merely "needs a TTY to complete" — offered
as the contrast to `pad workspace init`'s browser-poll block.

It is false. `cmd/pad/init.go`'s Step 4 calls `doBrowserLogin` with no TTY
guard when the server is already initialized but the client isn't
authenticated. Observed: on a configured-but-unauthenticated HOME, non-
interactive, `pad init` printed the same browser URL and was still waiting
when a 20s cap killed it — identical to `pad workspace init`.

That parenthetical was the one thing in the paragraph that could have made an
agent run a command instead of handing back, so it was the worst line to have
wrong. Both copies now say it is no safer as a probe.

Third claim in these three files this task that was wrong because it was
reasoned rather than run — the first two being "hangs indefinitely" (bounded,
mostly) and "whoami never blocks" (it prompts in a TTY).

Codex's other two round-2 findings are real but out of this port's scope and
filed: BUG-2574 (the plugin onboard skill inlines its own script instead of
loading the canonical onboard playbook) and BUG-2575 (plugin decompose entries
omit SPEC targets the embed source and playbook both support).

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

* docs(skill): narrow the `pad init` claim to the state it is actually true in (codex round 3)

The previous commit replaced one over-broad claim with the opposite one. "Fails
fast" was wrong for the configured-but-unauthenticated case; "no safer as a
probe, blocks identically" is wrong for the genuinely-unconfigured one. Both
measured, non-TTY, same binary:

  unconfigured                  → 0.106s, "Error: Pad is not configured..."
  configured-but-unauthenticated → browser URL, still waiting at 20s

The instruction ("don't run it yourself") was right in both readings, so this
is the justification being wrong rather than the advice — which is exactly the
failure mode this whole item is about, and I reproduced it while fixing it.
Both plugin copies now say which state each behaviour belongs to.

Codex's other round-3 finding — that the plugin onboard skill gates its
recovery branch on `.pad.toml` being absent, so a STALE link skips it entirely
and dead-ends at the same bootstrap failure — is real and is the pre-link half
of the same skill's problem. Added to BUG-2574 rather than widened into this
port.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:36:56 -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 7943234dd7 fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566) (#1098)
* fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566)

PATCH with {"assigned_user_id": ""} 500'd with a raw FK constraint
error and left the item assigned. An explicit empty string is what a
JSON client sends when a user blanks the field (JSON null on a *string
decodes to nil = "don't change", so it can't express clearing), and
validateAssignmentScope already skips "" as nothing-to-validate — the
SQL builder just never learned the same convention and bound it
verbatim into the FK column.

Store-level fix so every surface (HTTP, bulk, MCP, CLI) inherits it:
"" now clears assigned_user_id / agent_role_id exactly like
ClearAssignedUser / ClearAgentRole on update, and binds NULL on
create. The mutation signal keeps the ClearAssignedUser shape (tested)
so watch notifications are unaffected. parent_id deliberately NOT
given the same coercion: parent relations also live in item_links, and
clearing the column alone would desync them.

Web UI is unaffected either way — it already sends
clear_assigned_user: true; only API clients hit this.

Tests reproduce the exact FK failure pre-fix (verified by stash-run)
and pass on both SQLite and PostgreSQL post-fix.

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

* fix(store): relocate nullIfEmptyID out of createItemTx's doc comment

Codex round 2 P3: the helper was inserted between createItemTx's doc
block and the function, orphaning the doc comment.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 22:39:39 -04:00
David Barkhausen b9381bf5f1 feat(cli): markdown output on the remaining list surfaces; broaden ANSI stripping (#1080)
Completes #898 and fixes #1076.

Markdown on the seven surfaces left out of #1070, so `--format markdown` is now
honestly global and the flag help collapses to "table, json, markdown":

- `item comments`, `item deps`, `project activity`, `attachment list`,
  `library list`, `role list`, `workspace members`.

Two of those are not tabular, and markdown follows the terminal shape rather
than forcing a table onto them:

- `item comments` keeps the attribution-line-then-body form, and the body is
  emitted VERBATIM. A comment body is authored as markdown; escaping it would
  turn its lists and code fences into literal text. Only the attribution line,
  which we construct, is sanitized.
- `item deps` keeps its two sections as `## Blocks` / `## Blocked by` lists.
  Colour carried the direction in the terminal (yellow out, red in); headings
  carry it here.

New shared spine: `cli.RenderMarkdownTable(w, headers, rows)`. Every cell is
escaped, and ragged rows are padded or truncated to the header width so a short
or long row can't shift the column count and break the table. Wiring a surface
is now naming columns and mapping rows.

#1076 — ANSI stripping covered only SGR (`ESC[…m`), so non-SGR CSI sequences,
OSC-8 hyperlinks, and stray C0 controls survived, both in the table width maths
and in markdown output whose doc comment promised escape-free text. Replaced
`sgrPattern` with `ansiPattern` + `stripANSI` covering OSC, CSI, two-character
Fe escapes, and stray C0/DEL, with TAB/LF/CR deliberately preserved for callers
that normalize them. `displayWidth` now uses it too: a control sequence is
zero-width, so counting it was a column-alignment bug of the same family.

Tests: 12 stripping cases, 4 table-helper cases (including ragged rows), 4
renderer cases for the two non-tabular surfaces, and the routing test extended
to 8 subtests — one per surface, driven through cobra against an httptest
server. Also covers the two gaps named in #1076: `item starred` and the scoped
`item list <collection>` path. Each new guard was proven by mutating the source
and watching it fail, not just by passing.

Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues.
Both touched packages show the same 6+2 pre-existing Windows failures as clean
main under an identical sandboxed run.
2026-08-14 22:35:23 -04:00
xarmian ad1e919290 chore(deps): bump otel exporters to v1.45.0, clearing GO-2026-4985 from the Nix baseline (#1097)
* chore(deps): bump otel exporter cluster to v1.45.0 (GO-2026-4985)

Clears GO-2026-4985 (otlptracehttp oversized response bodies, fixed
v1.43.0) from the Nix artifact's accepted-advisories baseline. The
whole cluster is transitive — pad has no direct otel usage; it arrives
via fosite → ory/x → otelx, and fosite's latest (v0.49.0, already
pinned) still requires the vulnerable exporter, so MVS override is the
only path. Pulls otel core/metric/sdk/trace v1.44→v1.45, proto/otlp
v1.0.0→v1.11.0, grpc v1.82.1→v1.83.0, genproto refresh. The jaeger
exporter stays at v1.17.0 (its final release) and coexists.

BUG-2085 deferred this bump pending a blast-radius assessment; the
assessment is this diff, measured: go build ./..., go vet, full SQLite
test suite, and golangci-lint all green; artifact-faithful proxy scan
(GOTOOLCHAIN=go1.26.5, -s -w) reports 9/9 accepted with no new
advisories. Remaining baseline: 8 stdlib (nixos-26.05 backport) +
openpgp (no upstream fix exists).

vendorHash refresh follows in the next commit via the PR's Nix CI run.

Refs BUG-2085, BUG-2567.

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

* chore(nix): refresh vendorHash for the otel exporter bump

Same flow as #1096: value from the PR's own failed Nix CI run.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 21:41:49 -04:00
xarmian ff201939b9 chore(deps): bump x/image to v0.45.0, clearing GO-2026-6222 from the Nix baseline (#1096)
* chore(deps): bump golang.org/x/image to v0.45.0 (GO-2026-6222)

Clears GO-2026-6222 (VP8L decode memory allocation) from the Nix
artifact's accepted-advisories baseline — the advisory's fixed version
is exactly v0.45.0. Pulls x/text v0.41.0, x/mod v0.38.0, x/tools
v0.48.0 as transitive requirements.

Verified against a build-faithful proxy (GOTOOLCHAIN=go1.26.5, -s -w):
scan reports 10/10 accepted, no new advisories, no prune warnings.
Full SQLite test suite and golangci-lint clean locally.

nix/package.nix vendorHash refresh follows in the next commit, using
the PR's Nix CI job as the builder (no local nix; the flow is the one
package.nix documents).

Refs BUG-2085, BUG-2567.

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

* chore(nix): refresh vendorHash for the x/image bump

Codex round 1 P1: go.sum changed, so buildGoModule's fixed-output
vendor derivation no longer matches the pinned hash. Value taken from
the PR's own failed Nix CI run (the got: line), per the regeneration
flow package.nix documents.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 21:12:19 -04:00
xarmian cfad8d989e ci(nix): gate the Nix-built binary with govulncheck (BUG-2567) (#1095)
* ci(nix): gate the Nix-built binary with govulncheck (BUG-2567)

The main CI govulncheck job scans a go-built binary, which honours
go.mod's toolchain line — so the Nix artifact (GOTOOLCHAIN=local in
nixpkgs, go 1.26.5 until nixos-26.05 backports 1.26.6) shipped with no
vulnerability gate over it at all.

Add nix/vulnscan.sh: binary-mode govulncheck against result/bin/pad,
compared to nix/accepted-advisories.txt. Known advisories stay green
and recorded in-repo; any NEW advisory fails the Nix job; a cleared
advisory emits a warning annotation so the list gets pruned and
BUG-2567 closed when the backport lands.

The accepted list carries 11 entries, measured against a
build-faithful proxy (GOTOOLCHAIN=go1.26.5, CGO_ENABLED=0,
ldflags "-s -w"): the 8 reachable stdlib advisories from BUG-2565,
plus 3 module-level entries that only appear because -s -w strips the
symbols govulncheck needs for call-graph precision — a symbol-precise
scan of the same source shows all three uncalled.

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

* ci(nix): guard vulnscan against empty or non-binary govulncheck output

Codex round 2: an exit-0 govulncheck run that produced empty, truncated,
or garbled JSON — or silently ran in a mode other than binary — was
indistinguishable from a clean scan. Assert the stream's config message
reports scan_mode=binary and make both jq extractions fail closed
(exit 2, operational error).

Also sharpen the accepted-list comment on the three module-level
entries: on the stripped artifact govulncheck reports them as affected
with symbol frames (it cannot prune the call graph, so every vulnerable
symbol of an imported package counts as potentially called); the
round-2 reading of "degrades to module-level reporting" as functionless
findings was wrong, verified against the actual JSON stream.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 20:39:42 -04:00
xarmian c84cf7437c feat(sessions): announce session identity on the event stream (PLAN-2558 S2, TASK-2560) (#1094)
* feat(sessions): announce session identity on the event stream (TASK-2560)

PLAN-2558 S2. S1 gave the presence registry a count of anonymous
uuids; this makes each row nameable, which is what S3 needs for an
honest empty state and S5 needs for a target picker.

A monitor now announces itself when it opens the stream:
X-Pad-Session-Label (the working directory's basename) and
X-Pad-Session-Pid. The server sanitizes both and stores them on the
LiveSession; GET /api/v1/sessions returns them.

TRANSPORT. The task body sketched "the stream connect carries it"
without picking a mechanism and explicitly left the call open. Headers,
because a query param would put the label and pid into every access-log
line (this server logs path= for each request) and any proxy log in
front of it — which is the same "don't let local detail travel further
than it needs to" the privacy line below is about — and a separate
registration POST would need its own correlation to the connection it
describes, plus a matching lifecycle, when the registry entry already
lives and dies with the stream. Headers ride the request that exists
and sit alongside Last-Event-ID, already doing this job on this
endpoint. Cost, written into the code rather than discovered later: a
browser EventSource cannot set headers, so a future web-tab consumer
needs a deliberate query-param fallback or a fetch-based SSE reader.

PRIVACY. The basename crosses, never the full cwd — "/home/dave/Dev/
docapp" additionally hands over a home directory and usually an account
name for no gain — and messaging_socket_path never leaves the machine.
Pinned by a test rather than by the implementation being one line.

WHAT THIS DELIBERATELY DOESN'T DO: read ~/.pad/sessions/. The task
framed S2 as giving `pad session register` its first consumer, and the
monitor cannot honestly be one. Registry entries are written by
whatever process ran that command — a different pid — and the only
matchable fields are pid and cwd, so two agent sessions in one checkout
are indistinguishable and "pick the newest" is a coin flip that would
put a confident wrong name in the S5 picker. Process ancestry settles
it exactly and is platform-specific (this binary ships for macOS and
Windows). The monitor's own cwd basename and pid are never wrong and
answer the question the label exists to answer; correlating a stream to
the agent session that spawned it needs an identifier the harness
passes down, which is worth doing when something needs it and worth not
faking until then.

Also moves S1's STALENESS doc block, which sat above LiveSession.Label
where it read as documenting the name rather than the whole entry.

Tests: sanitizer units (whitespace collapse, control-char stripping,
rune-not-byte truncation), header wiring, the end-to-end labelled
session, the unannounced-client compatibility leg (a pre-S2 monitor
must still register and still stream), a hostile-input leg over the
wire, the client's omit-when-unset behaviour, and the basename promise.

Measured rather than assumed: Go's server answers 400 to a header value
containing a control byte before any handler runs (verified with a raw
socket, since Go's own client refuses to send one and the two refusals
are indistinguishable from a normal client test). So that arm of the
sanitizer is unreachable over HTTP; it stays as defence in depth for
the next caller in, and both the comment and the wire test say so
instead of the test quietly passing because the transport refused the
input.

Mutation-tested four ways, each revert grep-verified: handler ignoring
the parsed identity, monitor sending the full cwd, dropping the
truncation, and the client always setting the headers.

Refs TASK-2560, PLAN-2558

* fix(cli): sanitize the session label client-side per Codex review (round 1)

Codex round 1's only finding, and it is a bigger deal than a missing
label. Unix directory names may contain control bytes — "doc\napp" is a
legal directory — and Go's http.Client REFUSES to send a request whose
header value holds one: Do returns "invalid header field value" and
nothing is transmitted. In the monitor that is indistinguishable from
an unreachable padd, so the retry loop backs off and tries again,
forever, printing nothing by contract. A user who named a directory
that way would simply stop receiving notifications, with no signal
anywhere. The server cannot defend against a request that never
arrives.

Reproduced before fixing, with a real directory and a real client,
rather than reasoned about from the error message.

Sanitizing in NewWatchEventsStreamRequest rather than in
monitorSessionIdentity: the invariant is "this function never builds an
unsendable request", which belongs at the point where a value becomes a
header, not at one caller. The client's cap (256 runes) is deliberately
looser than and independent of the server's (64): the server decides
what a label should look like, the client only has to keep the request
sane, and neither has to track the other to stay correct.

The regression test does the ROUND TRIP instead of inspecting the
header, because the header contents were never the bug — http.Header.Set
stores anything, so an assertion on the value passes against the broken
version too. Only attempting the request tells the two apart.
Mutation-verified: reverting the sanitizer fails the test with exactly
the "invalid header field value" error from the field report.
2026-08-14 19:03:33 -04:00
xarmian 8af62d1c4e fix(ci): build with go1.26.6 to clear the govulncheck gate (BUG-2565) (#1093)
* fix(ci): build with go1.26.6 to clear the govulncheck gate (BUG-2565)

main has been red since da6ce642 on CI's Go job, at the govulncheck
step — not a test, lint or build failure, and not caused by that commit,
which touches no dependency or toolchain pin. govulncheck reports 8
reachable Go standard-library advisories, all "Found in: net@go1.26.5 /
Fixed in: net@go1.26.6" (GO-2026-5942 via net.Resolver.LookupCNAME,
GO-2026-5026 via http.Client.Do, and friends). A vuln-DB entry published
in the window turned the gate red with nobody committing anything, which
is the failure mode a govulncheck gate has by design. No dependency bump
fixes a stdlib advisory; the toolchain has to move.

Adds `toolchain go1.26.6` rather than raising the `go 1.26.5` directive.
The distinction is load-bearing: every GitHub Actions job here exports
GOTOOLCHAIN=auto explicitly, so all of them fetch 1.26.6 and stamp it
into the binary govulncheck scans, while builders pinned to
GOTOOLCHAIN=local ignore the line and keep satisfying the 1.26.5 floor.
nixpkgs nixos-26.05 still ships go 1.26.5 (verified against the channel's
go/1.26.nix), so raising the floor would have failed the Nix build
outright to buy nothing.

That leaves the Nix-packaged binary on the 1.26.5 stdlib. Not silently:
BUG-2567 tracks it, with the two heavier options (move the channel,
override go in the package) written down and deliberately not taken here.

The GOTOOLCHAIN=auto comments in both workflows now say what they are
protecting — under `local` the build silently falls back to setup-go's
patch and the gate goes red again, and released binaries would ship the
vulnerable stdlib.

Verified: make vuln goes from "affected by 8 vulnerabilities" to "No
vulnerabilities found" (exit 0), and `go version -m` on the built binary
reads go1.26.6, so the mechanism is the stamp and not a scanner quirk.
Build, make lint (0 issues) and go test ./... all green on 1.26.6.

Fixes BUG-2565. Refs BUG-2567.

* fix(ci): give native-smoke the GOTOOLCHAIN override too, per Codex review

Codex round 1, P2, and correct: native-smoke resolves its Go via
`go-version-file: go.mod`, which reads the `go` directive (1.26.5), so
setup-go's GOTOOLCHAIN=local previously had nothing to block and the
override was unnecessary. Adding `toolchain go1.26.6` changes that —
without it this job keeps smoke-testing a 1.26.5 binary on macOS and
Windows while every other job and every release artifact moves to
1.26.6.

Worth fixing precisely because it is not a gate failure. Nothing goes
red; the platform smoke coverage just quietly stops matching what ships,
which is the pins-disagree failure mode this PR exists to avoid rather
than introduce.

Uses the documented Out-File form rather than `>>`: this job runs pwsh,
where redirection encoding is version-dependent and a UTF-16 line in
$GITHUB_ENV is silently ignored.

* fix(ci): drop the redundant native-smoke override, document why (round 2)

Codex round 2 flagged the comment I added in round 1 as stale, and
checking the pinned setup-go source settles it — but not quite the way
the note said, so recording what the code actually does:
parseGoVersionFile (installer.ts) prefers go.mod's `toolchain` directive
over the `go` directive, CONDITIONALLY — only when GOTOOLCHAIN is not
already `local` in the environment at parse time.

In this job it isn't, so setup-go installs go1.26.6 itself and the
override I added does nothing. Removing it rather than keeping a no-op
step whose comment asserts a mechanism that isn't operative — a wrong
explanation in the tree is worse than no explanation, since it is the
part the next person reuses without re-deriving.

Replaced with a comment on the setup-go step covering both halves: why
this job needs no override where its siblings do, and the one way it
regresses silently (a job- or workflow-level `env: GOTOOLCHAIN: local`
added above it would flip the parser back to the 1.26.5 floor while
everything else ships 1.26.6 — no red, just smoke coverage that stops
matching the artifact).

The round-1 finding was still right: before checking, "native-smoke
builds 1.26.5" was the reasonable read.
2026-08-14 17:49:47 -04:00
xarmian 599fdbd3f4 feat(watch): drop assignment from the addressed-to-you stream (IDEA-2544 Phase 2, TASK-2551) (#1092)
* feat(watch): drop assignment from the addressed-to-you stream (TASK-2551)

IDEA-2544 Phase 2. Assignment is bookkeeping (who owns this); push is
dispatch (where attention goes now). Conflating them meant one triage
session assigning N items sprayed N notifications into every open
session of the assignee, so Dave's product call (day-33) was to drop
assignment from addressed-to-you entirely — no opt-in flag, no config
key.

watchNotificationVisible loses its KindAssignment early-return; an
assignment notification now falls through to the watch-map check like
any other item-level fact, which is what an unconditional watch already
promises to deliver. Producers are untouched and AssignedUserID is still
populated, so a future opt-in re-addressing would be a consumer-side
change only. KindPush is now the only addressed kind.

Tests: six tests rode the deleted path and are reworked, not deleted.
The two mid-stream visibility tests needed new vehicles — the
persistent-reload-failure test uses a push (same watch-map-independent
property), and the reval-ordering test uses collection-access revocation
with a still-granted control item, since push is self-addressed only and
its subject is a user losing access. That test's reval interval goes
50ms -> 200ms: at 50ms the clear-the-watch-set bound landed ~30ms behind
the assertion and the control leg lost the race.

New coverage for the asymmetry the change creates: a push stays
exclusive of watch-matched delivery, an assignment does not — a watcher
is entitled to see who an item was assigned to.

Mutation-tested three ways (restore the old branch; make assignment
exclusive addressed-only; couple visCache.reset() to reload success);
each is caught by the intended test and each revert was grep-verified.

Live: assigning a fresh unwatched item to the connected user leaves the
plugin monitor silent, pushing the same item prints one line, and
assigning a WATCHED item still delivers — verified end to end against a
sandboxed server, not just in tests.

Refs TASK-2551, IDEA-2544

* docs(watch): note the deferred plugin wording per Codex review (round 1)

Codex's only finding: plugin/monitors/monitors.json and
plugin/skills/pad/SKILL.md still describe assignment as
addressed-to-you traffic. Correct observation, deliberately out of
scope — installed plugins are version-pinned at install, so
plugin-visible text reaches nobody without a version bump, and
TASK-2564 (PLAN-2558 S6) owns the wording and the bump together.

Recording it in code next to the deleted branch rather than leaving a
reader to discover the mismatch, and on TASK-2564 with the exact line
refs so the follow-up does not have to re-find them.
2026-08-14 17:16:10 -04:00
xarmian 21001bc4c3 feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1) (#1091)
* feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1)

Slice 1 of PLAN-2558 (IDEA-2544 Phase 3, web-UI push). The server can now
answer "is anything actually listening right now?" for the calling user.

WHY. `pad push` (Phase 1, da6ce642) is fire-and-forget with no
"no session connected" warning. That's a defensible contract for a CLI
verb typed by someone who knows whether their own session is running.
It is not a defensible contract for a web-UI button: "Push to Claude"
that silently goes nowhere is worse than the clipboard ferry it
replaces, because the user cannot tell the two outcomes apart. Presence
lets the UI answer the question before the click, and — once sessions
carry a label (S2) — turns the same data into the target picker S5 needs.

This also closes the substrate half of PLAN-2469 Phase 3 ("presence
surface: SessionStart hook -> live-sessions view", IDEA-2464). The two
Phase 3s were the same work; see PLAN-2558's opening section.

- internal/server/session_presence.go: SessionPresence interface +
  MemorySessionPresence. Registered from handleWatchEventsStream,
  bracketed to the SUBSCRIPTION's lifetime (defer pairs with
  Unsubscribe's on the adjacent line) so every exit path — ctx.Done, a
  failed SSE write, the replay-loop returns, the reval-tick paths —
  releases both or neither. A leaked entry is the failure that matters:
  it makes the UI promise a listener that is gone, i.e. the same silent
  nowhere-push with a confident label on it.
- internal/server/handlers_sessions.go: GET /api/v1/sessions, self-scoped.
  No ?user_id=, no admin bypass — who has an agent session open is a
  presence signal about a person, and the same reasoning that made push
  self-addressed only applies. 503 (not 200-with-empty-list) when no
  registry is wired: "I can't tell" and "nobody is listening" must not
  look the same to the UI, since collapsing them is exactly the
  dishonesty this slice exists to remove.

Interface from day one because MemorySessionPresence is per-process.
Its doc comment states the boundary precisely rather than hand-waving
it: watchevents.Bus is blind in the SAME direction (a push published on
instance A never reaches a stream on instance B), so per-instance
presence is as accurate as per-instance delivery and both stop being
trustworthy at the same boundary — except that a load balancer may
route a POST and a GET to different instances, at which point they
disagree. A Redis-backed presence must therefore land WITH the Redis
watchevents.Bus that package already anticipates, not separately.

No pad-cloud change required (checked, not assumed): /api/v1/sessions is
a plain JSON GET served by nginx-router.conf's default `location /`
pass-through — the special long-lived-connection blocks are for
/api/v1/events and /api/v1/collab/ only.

Verified: go test ./... (SQLite) clean; make test-pg clean (25 pkgs,
exit 0); make lint 0 issues; new tests pass under -race. Live, on the
installed binary: 0 sessions with nothing connected -> 1 with one
stream open -> 2 with two, oldest-first -> back to 0 after both
disconnect, with a second user's list staying empty throughout.

* fix(sessions): no-store the presence response; document two lifetime constraints (PLAN-2558 S1)

Codex round 2 findings, both verified against source before acting.

P2 — Cache-Control. GET /api/v1/sessions set no cache header: writeJSON
sets none and the jsonContentType middleware only sets Content-Type, so
the response was heuristically cacheable. Now `private, no-store`,
matching the house pattern for per-user sensitive responses
(handlers_attachments.go:585). Wrong two ways without it: a shared cache
could serve one user's presence to another (the same boundary this
endpoint's absent admin view exists to hold), and a cached liveness
answer is exactly the confident-but-wrong "1 session connected" the
slice exists to prevent. Pinned by a test.

P2 — Shutdown, REFINED rather than adopted as reported. Server.Shutdown
delegates to http.Server.Shutdown, which does not cancel an in-flight
handler's context; SSE handlers therefore hang until their own ctx.Done
or a failed write. True, but for MemorySessionPresence it is HARMLESS,
and that is the useful half: the registry lives in the process that is
going away, so its entries die with it. There is nothing to reap. A
Redis-backed implementation does not inherit that — its entries outlive
the writing process, so a crash strands them permanently rather than for
30 seconds. Recorded as a hard constraint on the interface: any
out-of-process implementation must carry its own reaping story (TTL plus
heartbeat renewal, or instance-keyed ownership swept at startup).

Also documents the staleness window neither codex round surfaced, found
in my own pass: a clean disconnect deregisters immediately, an ungraceful
one is invisible until the next keepalive write fails, and the keepalive
is 30s. So the list can name a dead listener for up to ~30 seconds. That
bound is fine for a fire-and-forget channel — a push to a session that
died 5 seconds ago loses a message that was lost anyway — but consumers
must not upgrade it into a delivery guarantee. Shortening it means
shortening the keepalive, which taxes every idle connection; the right
answer for a consumer that needs delivery confidence is an ack, not a
faster heartbeat.

Verified: go build, go vet, make lint 0 issues, presence tests green
under -race.
2026-08-14 17:16:07 -04:00
xarmian da6ce642da feat(push): pad push — user-authored instruction dispatch to agent sessions (IDEA-2544 Phase 1) (#1090)
* feat(push): add pad push <ref> -m vertical (IDEA-2544 Phase 1)

Self-addressed, human-to-harness dispatch over the existing watch-events
bus/stream: CLI -> POST .../items/{itemSlug}/push -> a new KindPush
Notification (carrying the generalized TargetUserID addressed-to field
KindAsk will later share) -> watchNotificationVisible delivers it back
to the pushing user's own connected monitor sessions. Transient,
fire-and-forget by design (no migration, no durable inbox) since
assignment already covers the durable-notification case and this is
meant to be the explicit, no-inference dispatch verb instead.

* docs(plugin): document the push notification contract (IDEA-2544 Phase 1)

Push is the one notification kind that IS an instruction rather than a
passive fact, so it gets its own lead bullet in the plugin skill's
notification-etiquette section (ahead of the read-only/park default,
which it explicitly lifts) and a mention in the monitor's description.
The embed-source skills/pad/SKILL.md has no notification section to
mirror this into (the two files diverge by design) and is left
untouched.

* fix(push): reject over-long push messages instead of unbounded Summary

Comments truncate their notification Summary to a preview (the full
body is still fetchable), but a push message IS the payload — silently
truncating it would corrupt the instruction with nothing to recover it
from. Add maxPushMessageLen (4096, measured post-collapse) and reject
anything over it with a 400 rather than truncating; state the same
bound in `pad push --help` so it's discoverable before a 400, not only
from one.

* fix(push): close the watch-fallthrough leak, disambiguate SKILL.md exceptions

Codex round 1 P1: watchNotificationVisible's push branch only returned
early on a MATCH — a non-target caller fell through to the watch-map
check below it, so anyone holding an unconditional (or predicated)
watch on the item received every push addressed to every OTHER user,
instruction text included. Push is addressed private dispatch, not an
item-level fact watchers have a legitimate claim on (unlike assignment,
which watchers are expected to see per `pad watch --help`) — the branch
now returns unconditionally for KindPush, gating strictly on
TargetUserID and never reaching the watch-map fallback either way.
Pinned explicitly since Phase 4's session targeting is expected to
inherit this same exclusivity.

Also (codex P2): reworded the SKILL.md notification-etiquette bullets —
the new push exception and the pre-existing assignment/ask exception
literally contradicted each other ("the ONE narrow exception" claimed
singularity after push had already claimed exception status). Now
explicitly enumerated as the first and second exceptions to the
never-write rule.

* test(cli): pin that PushItem inherits X-Pad-Agent (BUG-2542 rebase)

Verified, not assumed: PushItem builds its request via c.post ->
c.newRequest like every other mutating client method (CreateWatch
included), so the attribution fix's client.agentName wiring covers it
for free with zero code changes needed on this branch. Adds a live
httptest assertion rather than trusting the code-path read alone —
the same shape as TestClientSendsResolvedAgentHeader, scoped to
PushItem specifically since that's the one method this PR added.

* fix(push): disambiguate workspace in the monitor line and skill contract

Codex round 2 P1: the watch-events stream is user-scoped ACROSS every
workspace a caller has watches in, but formatMonitorLine printed only
ItemRef/Kind/Actor/Summary and dropped the Workspace field the wire
payload already carried — a session linked to workspace A receiving a
notification for workspace B would resolve the wrong item (or 404) with
no signal in the line that anything was off.

Fixed universally, not push-only: grepped plugin/ and skills/ for
anything parsing "PAD ..." lines and found none — the Claude Code
plugin host ingests the stdout line as free-text notification prose,
formatMonitorLine's only real consumer is its own fmt.Println, so there
is no wire-format consumer a workspace prefix could break. The
ambiguity predates push (any watched item across workspaces already had
it); push just makes the consequence sharper because it carries an
instruction rather than a passive fact.

SKILL.md's push bullet now tells the agent to resolve with
`pad --workspace <workspace> item show <ref>` using the slug read off
the notification line, not a bare `pad item show <ref>`.

* fix(push): respect --format json instead of hardcoding plain text

Codex round 2 P2: pushCmd's RunE ignored the global format flag and
always printed "Pushed <ref>", silently discarding --format json.

- server.pushResponse replaces the bare map the handler wrote before —
  a typed {ref, workspace, pushed, message} shape, with workspace
  resolved to the CANONICAL slug via s.getWorkspace (not merely echoed
  from whatever the URL contained), matching the same disambiguation
  need the round-2 P1 fix addressed for the monitor line.
- cli.PushItem now returns (*PushResult, error) instead of discarding
  the response body.
- pushCmd checks formatFlag == "json" and calls cli.PrintJSON, mirroring
  runCreateWatch's existing pattern.

internal/cli/agent_identity_test.go's TestPushItemSendsResolvedAgentHeader
needed a one-line update for PushItem's new two-value return — caught by
`go vet ./...`, not `go build ./...` (which doesn't compile test files);
folding vet into my own pre-flight going forward.
2026-08-13 18:41:46 -04:00
xarmian f9195c5b09 ci: make the go test timeout explicit everywhere (TASK-2545) (#1089)
* ci: make the go test timeout explicit everywhere (TASK-2545)

The v0.13.0 release pre-flight died on `panic: test timed out after
10m0s` in internal/store, on a commit whose Go tree was identical to a
green run an hour earlier. Nothing hung — the package's runtime simply
crossed a budget nobody had chosen.

`go test` without -timeout uses a 10m per-test-binary default. This repo
raised the two RACE steps to 45m twice as the suite grew (BUG-1371 30m,
BUG-1913 30m→45m), each time with a careful comment — and each time left
their non-race siblings on the silent default. Three steps were still
running on it, including the release gate:

  ci.yml       "Run tests"                    (SQLite)
  ci.yml       "Run tests against PostgreSQL" (the one that panicked)
  release.yml  "Run tests"                    (the release gate itself)

All three now carry -timeout=45m, matching the race legs so the file has
one number, with comments saying it is a hang-catcher rather than a
performance budget and that job wall-clock is the signal for "the suite
got slow".

Measured at 212d59e7 on a dev box, both drivers, before and after:

  PostgreSQL  whole suite 4m43s wall; internal/store 280s; server 103s
  SQLite      whole suite 1m52s wall; internal/server 107s; store 64s

CI runners are roughly 2x slower, which is what put store's PG binary
over 10m. 45m is ~4.5x current CI headroom.

This raises the ceiling; it does not change the slope. internal/store on
PG costs ~0.43s per test in database setup alone (CREATE DATABASE plus a
full migration replay, where the SQLite harness copies a pre-migrated
template — IDEA-1914), so every test added costs PG CI ~0.43s forever
and that package is 99% of the job's critical path. Measured and filed
as IDEA-2550 rather than fixed here: it changes shared test
infrastructure that gates every merge and deserves its own review.

Verified by running the exact post-change commands on both drivers: PG
green in 4m42s, SQLite green in 1m52s, 25 packages each.

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

* ci: time the Makefile's go test targets too (TASK-2545)

The previous commit said the timeout was explicit "everywhere" and it
wasn't — `make test`, `make test-pg`, and `make check` were all still on
the 10m default. That matters twice over: it's the same trap the commit
is about, and `make test-pg` is the local mirror of the CI leg that
actually panicked, so a developer reproducing the failure would have hit
a different budget than the one they were debugging.

Found by sweeping every `go test` in the repo rather than only the
workflows — which is what the commit message's own claim required and I
hadn't done when I wrote it.

Verified: `make test` green, 25 packages.

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

* ci: time the nix checkPhase, cap the Go jobs, correct two claims (TASK-2545)

Codex review. No P1s; the two P2s were both right and one of them
catches me stating an explanation I had not checked.

COVERAGE. `nix/package.nix`'s checkPhase runs `go test ./...` on the
default too, and .github/workflows/nix.yml exercises it — a fourth site
after the three workflow steps and the three Makefile targets. Now
timed. Every `go test` invocation in the repo carries an explicit
-timeout; the sweep is `grep -rn "go test"` over workflows, Makefile and
nix, not just the workflows I happened to be looking at.

JOB CAPS. Codex objected that 45m lets a hung binary burn a
release-gating job. Fair, and the real hole was worse: `go` and
`go-postgres` had NO `timeout-minutes`, so they inherit GitHub's 6-HOUR
default. Both now capped at 100m — deliberately above the two 45m test
steps so the per-binary timeout always fires first, because that is the
one that prints the goroutine dump naming the hung test. The cap only
catches a runaway that isn't a single test (wedged service container,
stuck download).

CORRECTIONS to 496f521f's message:

- It said the race steps were raised "twice (BUG-1371 30m, BUG-1913
  30m→45m)". BUG-1371 kept 30m and fixed the bcrypt cost that had blown
  past it; BUG-1913 made the only 30m→45m change. One raise, not two.
- It said CI runners are "roughly 2x slower, which is what put store's
  PG binary over 10m". That does not survive its own arithmetic: 280s
  local x 2 is 9m20s, under the budget. What is actually known is that
  the CI binary exceeded 10m and the local one takes 280s, so CI is
  >2.14x slower on that binary — a lower bound derived from the failure,
  not an explanation of it. I have not measured CI's runtime and should
  not have written a factor as if I had.
- "Nothing hung" and "cost the cut ~40 minutes" are TASK-2545's findings
  from the goroutine dump and the release timeline, not mine. Attributed
  rather than restated as my own observation.

The 0.43s per-test setup figure and both driver runtimes are mine, taken
on this box at 212d59e7 and reproducible with the commands in IDEA-2550.

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

* ci: put the corrections in the file, not only in a commit message (TASK-2545)

Codex's re-review came back with no P1s or P2s and four nits, all the
same shape: the claims I retracted in 4623cae9's COMMIT MESSAGE were
still sitting in the workflow comments. That's the half that matters —
nobody reads a commit message while editing a CI file, and a correction
that lives only in git log is a correction almost nobody receives.

Fixed in place:

- The raise history: BUG-1913 raised 30m→45m once. BUG-1371 kept 30m and
  dropped the test-only bcrypt cost that had blown past it. My comment
  said "raised twice (BUG-1371, BUG-1913)".
- The pre-existing race-step comment claiming BUG-1371 kept the step
  "well under the 30m budget" — contradicted by BUG-1913 having to raise
  it later. Reworded to say what each change actually did. Not my text,
  but it is wrong in the file I am editing and the next reader inherits
  it either way.
- The "~2x slower, which put store over 10m" line, which its own
  arithmetic refutes (280s x 2 = 9m20s). Now states the lower bound the
  failure actually supports — CI's store binary exceeded 10m, so >2.14x
  this box — and names the retracted claim so a reader who saw the old
  version knows it was withdrawn rather than lost.
- "so it never fires before they do" on the job caps, which a job-level
  timeout cannot promise: it covers setup and every step, not just the
  two 45m ones. Now says "in practice", not a guarantee.

Attribution of TASK-2545's own findings (the ~40 minutes, the goroutine
dump showing nothing hung) moved into the comment too.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 17:15:30 -04:00
xarmian 212d59e7c6 fix(cli,server): make agent attribution actually happen (BUG-2542) (#1088)
* fix(cli,server): make agent attribution actually happen (BUG-2542)

Agent CLI writes were recorded as the human whose credentials they used.
Three independent defects, each verified by reading the path AND by
probing a live instance — the item deliberately held the mechanism open,
so none of this is inherited.

1. THE HEADER WAS NEVER SENT. actorFromRequest sets actor="agent" on one
   signal: the X-Pad-Agent header. The only code that sets it took the
   value from `agent_name` in .pad.toml and nowhere else — no
   environment detection, no session detection. This repo's .pad.toml
   has only `workspace`, so the header has never been sent from here and
   every agent write has looked human. ResolveAgentName now resolves
   .pad.toml → $PAD_AGENT → detected runtime.

2. ITEM CREATE DISCARDED THE ACTOR. createItemChecked called
   actorFromRequest and kept only the source (`_, src :=`), never
   setting input.CreatedBy, so store.CreateItem fell through to its
   "user" default — even for an agent that DID send the header.
   Comments have always stamped it correctly; item creation silently did
   not, which made the skill's own contract false on its own terms.

3. SINGLE-ITEM PATCH NEVER STAMPED LastModifiedBy. Bulk ops do
   (handlers_items_bulk.go); the single-item path did not, so an item
   edited only by agents read as human-edited.

Only entries VERIFIED against a live session belong in the runtime
detection table, so it has exactly one: Claude Code exports CLAUDECODE=1
to child processes, confirmed by reading a pad subprocess's environment
inside one. Guessing at Cursor/Windsurf/Aider variable names would put
unverified claims in a shipped binary and misattribute silently when
wrong; those set $PAD_AGENT until someone confirms a signature.

WHAT THIS DOES NOT DO, stated in the code and the skill rather than left
for someone to assume: the header is client-supplied and self-declared.
An agent that omits it is indistinguishable from the human it borrows
credentials from, and a human running `! pad ...` inside an agent's
terminal inherits that environment and is attributed to the agent. This
makes the trail HONEST, not VERIFIED — it is not a basis for
machine-verifiable human-approval provenance, which needs a channel the
agent cannot author at all. The incident behind this item is exactly
that distinction: an agent's relay of a human's words was recorded
indistinguishably from the human typing them.

Contract corrected in both skill copies, since the item's first question
was which of contract and behavior was wrong. It was the contract: it
promised automatic agent attribution that only ever applied to
workspaces that had opted in.

Tests, each mutation-tested against its own defect reverted alone:
- TestResolveAgentName — precedence plus the negative that makes it mean
  something: a plain human shell must still resolve to "". Fails 2/5
  reverted.
- TestItemAttribution_AgentVsHuman — agent and human legs for create,
  update and the create-stamp-survives-edit invariant. Fails on the
  create stamp reverted; fails 2/2 on the update stamp reverted.
  The update leg deliberately uses the OTHER writer: insertItemTx seeds
  last_modified_by FROM created_by, so a same-writer edit passes whether
  or not the PATCH stamps anything — the first version of this test did
  exactly that and passed its own counterfactual. Caught only because
  each fix was reverted separately.
- TestItemAttribution_ExplicitBodyValueWins — an explicit body value
  still beats the header.

End-to-end on a live instance through the real CLI, no .pad.toml opt-in:
agent session → created_by/last_modified_by/comment all `agent`; same
binary with CLAUDECODE stripped → all `user`.

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

* fix(server): artifact import wrote a UUID into created_by (BUG-2542)

Found by Codex while reviewing the attribution fix. handleImportArtifact
set `input.CreatedBy = u.ID`, which is the wrong DOMAIN for the field
rather than merely the wrong value: created_by holds the role — "user"
or "agent" — and consumers compare it against those literals
(CommentThread.svelte, TimelineVersionCard.svelte). An imported item
matched neither and rendered as neither.

It also would have defeated the fix in the parent commit at this path: a
non-empty CreatedBy suppresses the actor stamp, so imports would have
kept a UUID while every other create path started recording the actor.

The line contradicted the comment directly above it, which said Source
was being left blank precisely so createItemChecked could stamp it "like
every other create path". Now both fields are left blank and stamped
together.

The user's identity has its own home — the items.created_by_user_id
column — which no create path currently populates. That is a separate
gap and is not widened into this change.

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

* fix: close the remaining attribution bypasses Codex found (BUG-2542)

Review found no P1s and three P2 families beyond the artifact-import bug
already fixed in 6fac5dec. Two are closed here; two are deliberately not,
and the reasons matter more than the diff.

CLOSED — paths that asserted "user" and so SUPPRESSED the new stamp,
which made them worse after the parent commit rather than merely stale:

- cmd/pad/notes.go sent CreatedBy/LastModifiedBy = "user" from the
  CLIENT on all four note/decision writes. An explicit body value beats
  the header by design, so every agent note claimed a human wrote it,
  and would have kept claiming it. The client shouldn't assert an
  attribution it cannot know; all four now leave it to the server.
- handlers_item_versions.go hardcoded LastModifiedBy "user" / Source
  "web" on restore, so an agent-driven restore recorded itself as a
  human web edit. Now stamped from the request.

Also closed Codex's nit that the tests injected X-Pad-Agent directly and
never proved the resolver reaches the wire — TestClientSendsResolvedAgentHeader
runs the real client against an httptest server and asserts the header,
with a human-shell leg asserting its ABSENCE. Fails when the client wiring
is reverted. And the Source assertion now pins "web" rather than
merely non-empty.

NOT CLOSED, on purpose:

- Collab flush. An agent PATCH stamps `agent`, then the browser's later
  ?source=collab-snapshot PATCH stamps `user`. Codex reads that as lost
  attribution; I'm not convinced it's wrong — the browser really is the
  writer of that flush, and the agent's edit is already recorded on the
  PATCH that carried it. Deciding whose name belongs on a
  human-flushed doc containing agent edits is a semantics call about
  what last_modified_by MEANS, not a bug I should settle inside a fix
  commit. Filed rather than guessed.
- Move paths don't touch last_modified_by at all. That predates this
  change and is the same question (is a move an edit?), so it goes with
  the above.

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

* fix(cli): note/decision entries self-declare instead of going authorless

Self-caught regression from the previous commit, found by checking the
thing I changed rather than assuming it behaved like its neighbours.

I removed the CLI's hardcoded CreatedBy: "user" from note and decision
entries on the reasoning that applies to every OTHER write in that file:
an explicit value suppresses the server's stamp, so the client should
stay quiet and let the request context decide. That reasoning does not
reach these two. The entries live INSIDE the item's fields JSON, which
the server stores as an opaque blob and never parses for attribution —
so nothing downstream fills the gap, and blanking it would have written
authorless notes. Worse than the bug I was fixing: "user" was at least
right half the time.

They now carry cli.ActorKind() — the same self-declared signal as the
header, reduced to the user/agent enum the field holds. Its doc says
plainly that this is the ONE place a client should assert attribution,
and why, so the next person doesn't generalise it back the wrong way.

The item-level LastModifiedBy in the same functions stays server-stamped;
that half of the previous commit was right.

TestActorKind covers agent, explicit PAD_AGENT, and human-shell legs.

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

* fix(server): stamp the actor on non-parent item links (BUG-2542)

Last P2 from the review. Parent links pass the actor to SetParentLink;
every other link type (blocks / blocked-by / relates / implements) goes
through CreateItemLink, which the CLI calls without created_by, so the
store defaulted it to "user" and an agent's `pad item block` recorded a
human. Same one-line shape as the create path, explicit body value still
wins.

TestItemLinkAttribution_AgentVsHuman covers both writers; fails on the
agent leg when the stamp is reverted, control passes either way.

That closes every actor-dropping path the review found except the two
filed as IDEA-2549 (collab flush, move), which are semantics questions
about what last_modified_by means rather than defects — Codex agrees the
deferral holds if the field means content author, and flags that they
become real follow-ups if we decide it means sender-of-write.

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

* test(server): table-drive every non-parent link type (BUG-2542)

Codex nit: the link regression only covered `blocks`, and "shared routing
makes the other types fine" was doing the work. It cost nothing to stop
assuming, and the table earned itself on the first run — my initial list
included `blocked-by`, which is CLI surface sugar that inverts
source/target into a `blocks` row rather than a stored link type. The API
rejects it with a 400, on BOTH writer legs, which is also how that failure
reads differently from an attribution one.

Now covers blocks / related / implements / supersedes / split_from
against both writers.

One precision fix owed on 06938079's message: it says "THE HEADER WAS
NEVER SENT". Not true in general — a workspace with agent_name in
.pad.toml did send it, which is exactly how I probed the behaviour before
fixing it. Accurate version: the header was absent for anything that had
not opted in, which is every workspace I can see, including this repo's.
The body of that commit says it correctly; the headline overstates.

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

* style: gofmt notes.go after the attribution edit (BUG-2542)

Removing the hardcoded LastModifiedBy from the two ItemUpdate literals
left the surviving fields aligned to a column that no longer had a
member, so gofmt disagreed and CI's golangci-lint failed the Go job in
42s.

The real fault is upstream of the whitespace: my gates line for #1088
read "go test ./... green · Codex to CLEAN" and lint was simply not in
it. The omission in the report and the failure in CI are the same fact —
I reported a matrix that did not include the axis that broke. `make lint`
runs the pinned suite CI runs and takes seconds; it belongs in every
report I make, alongside test and build.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 16:21:10 -04:00
xarmian b87b3028e7 chore(nix): bump package version to 0.13.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01CXHLbTC1AiwSC87xwThRGT
v0.13.0
2026-08-13 17:19:10 +00:00
xarmian f241a6298e docs(skill): port the applicable plugin-review corrections into the embed source (TASK-2537) (#1087)
* docs(skill): port the applicable plugin-review corrections into the embed source

skills/pad/SKILL.md is the //go:embed source that `pad agent install`
writes into user projects; it had drifted from corrections made to the
plugin's copy during TASK-2534's review rounds. Selective port — the two
files diverge by design (surface-agnostic vs Claude-Code-only), so none
of the plugin's Claude-Code-specific material comes across.

Each ported item re-verified against the code, since line numbers and
wording differ between the copies:

- Ideation example passed `--content "..." --stdin` together.
  cmd_item.go:141 shows --stdin OVERWRITES the --content value and then
  blocks on io.ReadAll with nothing piped, so the example as written
  hangs. Dropped --stdin.
- Retro's plan-status flip carried no --comment, contradicting Key
  Principle 2 four lines below it.
- Key Principle 2 named `blocked` as a task status. templates.go:157 has
  open / in-progress / done / cancelled — no `blocked`.
- The role-board pointers claimed `pad server open` → /{workspace}/roles.
  cmd_server.go:999 appends only the workspace slug; there is no
  sub-path. Replaced with navigate-to-the-Roles-page wording.

Two more from the same review rounds that apply here and were not listed
on the task, found by diffing the copies:

- The convention/playbook BODY loads used `--format json` without
  `--full`. Since the v0.9 summary shape, `pad item list` returns
  cli.ToItemSummaries, which has no `content` field at all — so a skill
  told to "follow ALL returned conventions" was reading titles. Added
  --full to the seven trigger-load examples and to the retro's task
  load, where the bodies are the point.
- `open "$IMG"` is macOS-only, in a file installed on every platform.

Not ported, and not a gap in this file: the whoami-gated
`pad workspace init` routing. That correction guards a blind self-heal
in the plugin's bootstrap-failure branch, and the embed source has no
such branch — its only `pad workspace init` mention is a neutral
see-also. The absence of ANY bootstrap-failure guidance here may be
worth its own item; inventing that section is outside a port.

Verified through the real embed path, not by reading the diff: rebuilt
and ran `pad agent install` for both targets (claude → .claude/skills,
codex/cursor/windsurf/opencode → .agents/skills) into scratch dirs, and
confirmed the installed artifacts carry every correction and none of the
superseded strings. go test ./... green.

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

* docs(skill): --full on the eighth body-load; file the bootstrap-failure gap

Codex found one miss and one scope question.

The miss: the `convention_index` bullet's own body-load example lacked
both `--format json` and `--full`, so the call that the bullet exists to
describe — pull the triggered convention BODIES this index only names —
came back in the summary shape with no `content`. It is the eighth such
call; I corrected seven and missed the one inside prose rather than in a
code block. Added, with a clause saying why, since this is the bullet a
reader consults specifically to learn how to fetch bodies.

The scope question: the embed source has no bootstrap-failure branch at
all, so an unlinked workspace routes into onboarding that cannot run,
and a naive `pad workspace init` self-heal can hang the tool call
indefinitely. Codex is right that it is a real gap and right that it is
surface-agnostic. It is not a port, though — the plugin's correction
guards a self-heal this file does not contain, so there is nothing here
to correct, only something to write. Filed as BUG-2541 with the failure
modes, the verified hang, and the de-Claude-ing needed, rather than
widened into this PR.

Re-verified through `pad agent install`: 9 body-load calls now carry
--full in the installed artifact.

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

* docs(skill): warn off the blind `pad workspace init` self-heal

Codex held on the bootstrap-failure gap after I deferred it to BUG-2541,
and the objection is fair on one point: deferring the whole branch left
the shipped artifact one step from the hazard, since the documented
fallback ("use the individual CLI calls") needs the same workspace link
that just failed, so a reader following this file has nowhere to go and
`pad workspace init` is the obvious next reach.

So the hazard gets addressed here and the structure stays in BUG-2541.
Four sentences, surface-agnostic: bootstrap failing usually means setup;
the individual-call fallback won't help; do NOT run `pad workspace init`
blind, because on a configured-but-unauthenticated machine it hangs
indefinitely on a browser-setup URL with no timeout and no
non-interactive fallback — wedging the tool call rather than failing it;
gate on `pad auth whoami` and hand back to the user otherwise.

That IS what TASK-2537's finding-1 asked for — "the same whoami-gated
treatment" for this file's onboarding-adjacent text. I first read its
parenthetical ("if any references pad workspace init as a blind
self-heal") as gating the whole item, and since this file has no such
reference, as nothing to do. The intent was to keep the file from
leading an agent into the hang, which it could.

What stays in BUG-2541: the two stderr signatures as named cases, the
Onboarding routing entry's missing precondition, and re-verifying the
hang rather than inheriting the claim. Noted there.

Verified through `pad agent install` again, not the diff.

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

* docs(skill): narrow the workspace-init warning to what the code does

I wrote that `pad workspace init` "hangs indefinitely ... with no
timeout", inherited from TASK-2534's write-up. Codex flagged it and it
is wrong. What the code does:

- first-admin setup polls under an explicit 20-minute cap
  (internal/cli/bootstrap.go::bootstrapPollTimeout);
- the configured-but-unauthenticated branch polls a CLI auth session
  every 2s with no wall-clock timeout of its own, but exits on the
  server's `expired` status, and that session's TTL is 5 minutes (20 for
  the setup handoff) — internal/store/cli_auth_sessions.go.

Bounded, then. The hazard is still real and still worth the warning — it
blocks an agent's tool call for minutes on a flow only a human at a
browser can finish — but the text now says that instead of claiming a
permanent wedge. Also qualified the whoami claim: it returns immediately
in NON-INTERACTIVE use; getConfiguredConfig can prompt on an interactive
TTY (cmd/pad/configure.go:80).

Worth naming because it is the same failure I had just written into
BUG-2541 as work to do — "re-verify the hang claim rather than
inheriting it" — and then committed the inherited claim as fact in the
same breath. An explanation I have not checked is a claim, not a hedge,
and putting it in a file that ships to users makes it everyone's. The
correction is on BUG-2541 too, so that item's body isn't left asserting
it.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 12:43:22 -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 d8627968d0 feat(plugin): pad Claude Code plugin — nested layout, monitors, skills (PLAN-2469 Phase 2) (TASK-2534) (#1085)
* feat(plugin): assemble Phase-0 scaffold v3 into a nested plugin layout — PLAN-2469 Phase 2 (TASK-2534)

Drops scaffold v3 (plugin.json, marketplace.json, monitors.json, the pad/
status/capture/onboard skills) in under plugin/, verbatim, rather than at
repo root. Repo root already embeds skills/pad/SKILL.md into the binary
as the surface-agnostic `pad agent install` source (embed.go); loading
the plugin flat at repo root would register BOTH that copy and the
plugin's Claude-Code-only trimmed copy as separate skills in the same
session (confirmed empirically via --plugin-dir) and would ship the
whole repo as the marketplace install payload (confirmed via a real
marketplace add + install + cache inspection). Nesting under plugin/
with marketplace.json's source: "./plugin" scopes both discovery and
the install payload to exactly the plugin's own files, with zero
content changes to the scaffold itself.

The stale minimal .claude-plugin/plugin.json at repo root is removed —
superseded by the real one at plugin/.claude-plugin/plugin.json; repo
root's .claude-plugin/ now holds only marketplace.json, which is what
`/plugin marketplace add PerpetualSoftware/pad` needs to find there.

`claude plugin validate --strict` passes clean on both the repo root
(marketplace) and plugin/ (plugin manifest) independently.

* fix(plugin): round-1 review fixes — invocation reality, degradation ordering, confirm-by-default, license (TASK-2534)

Six findings from codex round 1 + dispatcher's shallow pass:

- pad/SKILL.md's "How This Works" claimed a literal `/pad <anything>`
  command, but under plugin namespacing this skill registers as
  `pad:pad`, not a bare `/pad`. Rewrote to state the true DR-1 model
  (contextual trigger by description, not typed) and defined the
  document's own `/pad <anything>` notation as shorthand for "what the
  user said," not literal syntax. Fixes the earlier /pad:pad cosmetic
  note at its root and the two "onboarding" sections that repeated the
  same wrong dual-syntax framing.

- status/SKILL.md and capture/SKILL.md invoked `pad` before stating
  their missing-CLI fallback; reordered so the failure modes are
  handled as part of the same instruction, not an afterthought.
  monitors.json's command had no guard at all — a padless machine
  would hit a raw spawn error, not silence. Added a `command -v pad`
  shell guard (verified live, both branches: silent when pad is
  absent, invokes correctly when present) and re-verified the two
  original degradation legs (no .pad.toml, unreachable padd) through
  the exact guarded command string, not just the bare binary.

- `pad bootstrap` hard-exits ("no workspace linked...") when unlinked
  (cmd/pad/main.go's getWorkspace()), but the skill assumed it always
  succeeds and the onboarding NL routing tried to load the onboard
  PLAYBOOK before a workspace — and therefore that playbook — could
  exist. Added the unlinked-workspace branch to Context Loading and
  taught onboarding routing to run `pad workspace init` first in that
  case, matching what skills/onboard/SKILL.md already did correctly.

- capture/SKILL.md's "no ceremony" immediate-create contradicted the
  main skill's "always confirm before creating/modifying." Made
  confirm-first the default, with an explicit opt-out for workspaces
  whose conventions declare autonomous capture.

- Two `open "$IMG"` examples were macOS-only; added the Linux
  (xdg-open) alternative and a describe-the-path fallback.

- plugin.json declared MIT; the repo is Apache-2.0 (LICENSE,
  web/package.json). Corrected.

`claude plugin validate --strict` clean on both the repo root
(marketplace) and plugin/ (plugin manifest) after the fixes.

* fix(plugin): make DR-4's no-write default unmistakable (TASK-2534)

Second independent dogfood run found the etiquette text underdetermined
behavior: one run correctly parked a context nudge, another interpreted
"fold it in" as license to run pad item comments and post a real reply
to the watched item — a write action from a context notification, which
DR-4 never intended and the plugin's noise-discipline promise depends on
never happening. Rewrote the section: read-only one-line park-and-summarize
is now the explicit default for every watch notification and any addressed-
to-you event the model isn't certain about; writing to Pad in reaction to a
notification is called out as prohibited by name (no more "fold it in"
euphemism), with a single narrow exception (an assignment/ask explicitly
addressed to the session's user, where acting is unambiguously expected)
and an explicit "when in doubt, park" tiebreaker.

claude plugin validate --strict clean on both repo root (marketplace) and
plugin/ (plugin manifest).

* fix(plugin): loop the pad-missing monitor guard instead of a one-shot sleep (TASK-2534)

Verifying the finding-2 guard through Claude Code's REAL monitor spawn
path (--bg --plugin-dir, not a manual `sh -c` test) surfaced a real gap
the manual test couldn't: the runner does NOT relaunch a monitor command
after it exits. The round-1 guard (`command -v pad && exec ... || sleep
3600`) is a single check-then-sleep-then-exit — once that one hour
elapses, the process exits and, per this observed behavior, is never
restarted, leaving the session permanently monitor-less for the rest of
its life on a machine where pad wasn't yet on PATH at session start.
That silently breaks DOC-2479's "sleep-retry hourly" contract, which the
Go binary's own `pad watch --stream --for-session` loop honors correctly
for its two conditions (no .pad.toml, padd unreachable) via an actual
internal retry loop — this shell guard needs the same shape for its one
condition (pad binary absent).

Wrapped the check in `while ! command -v pad; do sleep 3600; done` so it
keeps re-checking indefinitely instead of dying after one interval, then
falls through to the same `exec pad watch --stream --for-session` once
pad becomes resolvable. Verified live through the real spawn path with a
shortened sleep interval on a throwaway plugin copy: a fresh sleep child
appears every cycle (confirms the loop keeps running, not one-shot), then
re-verified the actual committed command (real sleep 3600) resolves a
live `pad watch --stream --for-session` process when pad is present and
sits in a healthy, silent, no-error `sleep 3600` loop when it's absent —
both through claude --bg --plugin-dir, not manual shell invocation.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): probe watch capability, not just binary existence, in the monitor guard (TASK-2534)

Codex round 2, P1: the round-1 fix looped on `command -v pad`, but an
old pad binary predating `pad watch` resolves on PATH fine and then
dies non-zero the moment `exec pad watch --stream --for-session` runs
— and the previous commit already established the runner never
relaunches an exited monitor command, so that death is permanent for
the session. `command -v` only proves the binary exists, not that it
has the subcommand this guard needs.

Changed the loop condition to `pad watch --help`, which probes the
actual capability in one call: covers both missing-binary (no such
command) and old-binary-without-watch (unknown subcommand, non-zero
exit) with a single condition. Verified first that `--help` is safe to
call as a probe — it succeeds (exit 0) with no `.pad.toml` anywhere
and padd unreachable, so it can't deadlock a legitimate unlinked-
project or padd-down user behind the guard.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): round-2 doc fixes — capture routing, confirm scope, invocation wording, CLI accuracy (TASK-2534)

Findings 2-8 from codex round 2:

- capture/SKILL.md: no unlinked-workspace branch — would fail opaquely
  on `pad collection list` without .pad.toml. Mirrors the same routing
  fix the main skill already has: run `pad workspace init` first.

- pad/SKILL.md's notification write-exception didn't say whether the
  confirm-always principle (Key Principles #3) still applies inside it.
  It does — the exception only lifts never-write, not confirm-first —
  now stated explicitly where the exception lives, same rule as
  capture's autonomous-capture opt-out.

- The `/pad` shorthand disclaimer scoped itself to "the playbook-routing
  and examples sections below," missing Context Loading's "on every
  `/pad invocation`" a few lines above it. Widened to cover the whole
  document. plugin.json's own description repeated the bare "/pad
  conversational surface" claim; reworded to name the real typed
  shortcuts instead, and synced marketplace.json's plugin-entry
  description to match (was already a paraphrase, now identical).

- Ideation example passed both `--content` and `--stdin` to the same
  `pad item create` call — verified --stdin wins and hangs on ReadAll
  with no piped input. Dropped --stdin, kept --content.

- Plan-retro example was missing the mandatory `--comment` this same
  doc teaches elsewhere. Key Principles #2's status enumeration listed
  "blocked" as a task status — verified against
  internal/collections/templates.go: the tasks schema's actual options
  are open/in-progress/done/cancelled, no "blocked". Fixed both.

- Two role-board pointers claimed `pad server open` lands on
  `/{workspace}/roles` directly; verified cmd/pad/cmd_server.go's open
  command only appends the workspace slug, never a sub-path. Reworded
  both to describe navigating to Roles from the opened UI rather than
  asserting an unverified deep-link.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): safe (non-hanging) unlinked-workspace self-heal, onboarding offer in status, ask-events wording (TASK-2534)

Codex round 3, findings 1-3:

- The routing added in rounds 1-2 told the skill to run bare
  `pad workspace init` whenever bootstrap reported "no workspace
  linked." Verified live this can HANG INDEFINITELY: with pad
  configured (mode/URL set) but no admin account created yet /
  session not authenticated, `pad workspace init` calls a
  browser-based setup/login flow with no TTY guard and no
  non-interactive fallback — unlike `pad init`, which has an explicit
  `!canPromptForConfig()` check at the same step and fails fast with a
  headless-bootstrap hint instead. Bootstrap's own two failure
  signatures ("Pad is not configured" vs "no workspace linked") don't
  fully disambiguate this either — verified live that "no workspace
  linked" fires even when the deeper problem is "no admin account
  exists yet," which is exactly the state that hangs.

  Fixed by adding a `pad auth whoami` preflight (verified live: fast,
  safe, never blocks, in all three states — unconfigured, configured-
  but-unauthenticated, and fully set up) before ever attempting
  `pad workspace init`. Only self-heal when whoami reports a real
  user; otherwise tell the user to run `pad init` themselves in an
  interactive terminal (Claude Code: suggest `! pad init`). Applied
  consistently across pad/SKILL.md's Context Loading and Onboarding
  routing, onboard/SKILL.md, and capture/SKILL.md's unlinked-workspace
  branch (round 2 addition, same hang risk).

- status/SKILL.md ran `pad project dashboard --format json` but never
  checked its `needs_onboarding` field (verified present on that
  response too, not just bootstrap's — internal/server/handlers_
  dashboard.go's NeedsOnboarding), so the mandated onboarding offer
  couldn't fire via `/pad:status`. Added the same offer wording the
  main skill uses.

- monitors.json's description claimed the stream delivers "asks for
  your role" alongside assignment; verified against
  internal/watchevents/watchevents.go: KindAsk is contract-reserved
  with no Phase 1 producer. Reworded to describe what ships today,
  noting ask-events as reserved-not-yet-emitted.

Filed docapp TASK-2537 for the fourth finding (embed-source
skills/pad/SKILL.md has the same --content/--stdin bug and has
drifted from these corrections) — selective port, out of this diff's
scope per the two skills' by-design framing divergence.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).

* fix(plugin): --full on body-promising queries, capture loads always-on conventions, onboard's already-set-up path (TASK-2534)

Codex round 4, findings 1-3:

- Every `pad item list conventions|playbooks --format json` example whose
  surrounding prose promises "bodies" was missing `--full` — verified
  against cmd/pad/cmd_item.go: the flag exists specifically because JSON
  output defaults to a token-light summary shape with no content.
  Without it, all 8 of these calls (the Context Loading bootstrap-field
  description at :41, the 7 trigger-query examples under "Before
  Performing Work") would return exactly what the surrounding text says
  they're loading bodies to avoid: metadata with no content. Added
  --full to all 8, not just the block the finding cited — the "Template"
  lines carry the identical "pull their bodies" promise as the
  "Concrete examples" lines a few lines below them, so leaving one set
  fixed and the other not would just move the inconsistency.

  Swept the rest of the file's `item list ... --format json` examples
  (Ideation load-context, Status Check role queue, Daily Standup) —
  left those alone; their surrounding prose asks for enumeration/counts
  ("3 items in your queue", "Yesterday/Today/Blockers"), not body
  content. The one exception found: Retrospective's task-load line
  promises "what shipped, what was deferred, lessons learned" — that
  needs actual task content to synthesize, so it got --full too, with
  a one-line note explaining why (the other fixed lines didn't need
  one; their promise was already explicit in the surrounding prose).

- capture/SKILL.md never loaded conventions at all. Per the dispatcher's
  ruling: low ceremony means skipping bootstrap's dashboard/playbook/
  role weight, not skipping mandatory project rules (trigger=always
  conventions exist precisely to be small and always-applied). Capture
  now runs the always-on conventions query (with --full) before
  creating and applies whatever it returns; the autonomous-capture
  exception now checks the SAME load instead of running its own
  separate (previously --full-less) query. One sentence documents the
  tradeoff as deliberate: always-on rules yes, full bootstrap no.

- onboard/SKILL.md only defined the needs_onboarding=true branch. Added
  the false branch: say the workspace is already set up, summarize what
  exists (collection/convention counts from the bootstrap payload
  already in hand), and offer extend/audit instead of re-running
  first-time setup — same confirm-first rule as everywhere else.

claude plugin validate --strict clean on both repo root (marketplace)
and plugin/ (plugin manifest).
2026-08-12 23:49:47 -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 ec7fd027fc feat(server,cli): watches, user-scoped event stream, plugin monitor command — PLAN-2469 Phase 1 (TASK-2533) (#1082)
* feat(store): race-free status/assignment mutation signal (TASK-2533)

Adds models.Item.LastMutation (ItemMutationSignal), populated inside the
SAME transaction that already writes status_transitions / assigned_user_id
in UpdateItemWithParentLink and MoveItemWithPreCheck. This is the
foundation for TASK-2533's watch-notification pipeline: a before/after
snapshot taken in the HTTP handler layer would race concurrent writers of
the same item, so the signal is computed where the authoritative diff
already happens, in-transaction.

* feat(store): watches table migration, both drivers (TASK-2533)

watches(id, workspace_id, user_id, item_id, predicate, created_at) per
DOC-2479's subscription-table design: durable, server-side subscriptions
that survive both the plugin-monitor process and a padd restart.
uq_watches_user_item makes `pad watch <ref>` idempotent (re-watching
upserts the predicate). Wires watches into the workspace-purge child-delete
list, mirroring item_stars.

* feat(watchevents): add in-process notification bus (TASK-2533)

New package: a global (not per-workspace) in-process pub/sub bus carrying
watch-worthy Notifications (status-change / assignment / comment; ask
reserved in the enum with no producer yet — see the follow-up server
commit). Bus is an interface specifically so a Redis-backed implementation
can slot in later without touching any caller; only MemoryBus exists today.
Package doc comment states the single-process/multi-instance limitation
explicitly, mirroring internal/events' shape.

* feat(store): watches CRUD (TASK-2533)

models.Watch + Store.CreateWatch (upsert on user+item)/GetWatchByUserItem/
ListWatchesForUser (unscoped by workspace — a watch is personal, and the
event-stream handler needs every watch a caller holds across all their
workspaces)/DeleteWatch.

* feat(server): watch/nudge event stream + CRUD endpoints (TASK-2533)

GET /api/v1/events/stream (DOC-2479): a user-scoped, cross-workspace SSE
stream, filtered server-side to the caller's watches (with optional
--until field=value predicate) plus "addressed to you" — narrowed to
assignment-to-you only for Phase 1, confirmed with the dispatcher: this
codebase has neither a Collection.Kind field nor any user->active-role
binding to ground DOC-2479's "human-gate-shaped collection targets your
active role" half mechanically. watchevents.KindAsk stays in the wire
enum with no producer. `pad session register` is the natural future hook
for a session-carried role identity.

POST/DELETE .../items/{slug}/watch, GET /api/v1/watches (unscoped,
mirrors /auth/tokens' shape for a personal, not workspace, resource).

Producer wiring (TASK-2533 audit) publishes from every live mutation path
that can produce a LastMutation signal or a new comment: handleUpdateItem
(incl. its collab sub-paths and the comment-attached-to-update path, which
bypasses handleCreateComment entirely), handleMoveItem, handleCreateComment,
item creation with an initial assignee, and the bulk-items loop (covers
archive/restore/move/set-priority/tag/untag/assign uniformly via one call
site). Named, not silent, bypasses: import bundle, status_transitions
backfill, workspace restore/purge — none are live human-facing mutations.

Known Phase-1 tradeoff, flagged not fixed: bulk mutations are NOT batched
into one notification the way the existing SSE/webhook bulk path is — a
bulk-assign of N items surfaces N individual notifications. Each is still
correctly scoped by the recipient's own watches/addressed-to-you filter
(a narrower audience than the workspace-wide SSE firehose the existing
batching protects), so this is a noise-discipline tradeoff, not a leak.

* feat(cli): pad watch + pad session register (TASK-2533)

pad watch <ref> [--until field=value] creates/upserts a durable watch;
pad watch list / pad watch remove <ref> are the hygiene companions the
dispatcher asked to be included explicitly rather than silently added.
pad watch --stream --for-session is the plugin-monitor command: one
stdout line per matching event ("PAD TASK-214 -> kind (actor): summary"),
silent on startup with no .pad.toml (hourly retry) or an unreachable padd
(backoff retry) per DOC-2479's noise-discipline contract. The retry/
backoff math and line formatting are pure, unit-tested functions; the
actual sleep loop is not (per the dispatcher's ask).

pad session register writes ~/.pad/sessions/<pid>.json (pid, cwd,
CLAUDE_CODE_MESSAGING_SOCKET when set) -- forward-looking infra for
Phase 3's live-sessions/presence surface; nothing consumes it yet in
Phase 1/2.

* fix(server): comment replies never published a watch notification (TASK-2533)

Codex round 1 finding 2 (verified real, not a false positive):
handleCreateReply is a SEPARATE code path from handleCreateComment — it
calls store.CreateComment directly via POST .../comments/{id}/replies,
not POST .../comments — and was missing the watch-notification hook
entirely. A reply to a comment on a watched item produced zero
notification. Same kind=comment publish as the top-level path, plus a
regression test covering the reply route specifically.

* fix(server): re-check current access before serving/delivering watches (TASK-2533)

Codex round 1 finding 1: ListWatchesForUser filtered only by user_id — a
watch row survives a revoked workspace membership or grant (nothing
deletes it), so GET /api/v1/watches and the event-stream's notification
filter could keep leaking item title/ref, workspace slug, actor, and
summary for access the caller no longer has.

Adds Store.ListWatchesForUser's ItemCollectionID column (needed for the
visibility check) and server.filterWatchesByCurrentAccess, which mirrors
computeSSEVisibility's RBAC resolution (handlers_events.go) — admin
bypass, VisibleCollectionIDs for member/guest full-collection access,
GuestVisibleResources for item-level grants — grouped by workspace since
a caller's watches can span many, unlike a single SSE connection scoped
to one. Fails closed on any lookup error.

Wired into handleListWatches here; the event-stream's loadWatchPredicates
call site picks up the same filter in the next commit, which also
restructures that function's Subscribe/replay sequence and therefore
touches the same lines.

* fix(watchevents): atomic ID assignment + subscribe-and-replay (TASK-2533)

Codex round 1, findings 3 and 4 (same subsystem, fixed together):

Finding 4 — sequence assignment and replay-buffer insertion happened
under SEPARATE locks in MemoryBus.Publish. Two concurrent Publish calls
could append to the ring buffer out of ID order, corrupting since()'s
ordering assumptions (it walks the ring oldest→newest assuming monotonic
IDs). Fixed by unifying seq assignment, buffer append, and the
subscriber-list snapshot under one lock; the (already non-blocking)
fan-out send still happens after releasing it.

Finding 3 — GET /api/v1/events/stream called Subscribe() and, later
(when resuming via Last-Event-ID), EventsSince() as two separate calls.
A Notification published in the window between them landed in BOTH the
replay result and the live channel, double-delivering it. Bus gains
SubscribeAndReplaySince(sinceID), which atomically subscribes and reads
the replay buffer under the SAME lock; the stream handler now uses it
whenever a Last-Event-ID is present (this commit carries that call-site
change, plus the finding-1 loadWatchPredicates filter wiring from the
previous commit — both land in the same lines of this function).

Adds a concurrent-publish ID-ordering test and a subscribe-then-
concurrent-publish no-duplicate test, both run with -race.

* fix(cli): monitor silent-start ordering + sync_required handling (TASK-2533)

Codex round 1, findings 5 and 6:

Finding 5 (P1) — runWatchMonitor called getClient() once, before the
loop and before the .pad.toml check. getClient() -> getConfiguredConfig()
os.Exit(1)s when unconfigured with no TTY, or launches an INTERACTIVE
configuration wizard when one is attached — either way a direct violation
of DOC-2479's silent-start contract, which requires "not ready yet" to be
a silent retry, never a crash or a prompt. Adds monitorClient(), which
builds the client the same way but returns a plain error instead of
exiting or prompting; client construction now happens INSIDE the loop,
after the .pad.toml gate, on every iteration, and its failure folds into
the existing padd-unreachable backoff path.

Finding 6 (P2) — streamWatchEvents ignored "sync_required" (the server's
signal that the requested Last-Event-ID was evicted from its replay
buffer), so a stale cursor got resent on every reconnect forever. Now
clears the cursor on sync_required so the next reconnect is a fresh,
non-resuming subscription instead.

Both covered by tests that assert the goroutine returns promptly on
context cancellation (proving no os.Exit / no blocking prompt was hit,
since the test process itself is still running to observe the return)
and that streamWatchEvents clears/re-tracks the cursor correctly around
sync_required.

* fix(server): uniform current-access gate for watch AND addressed-to-you delivery (TASK-2533)

Codex round 2, findings 1 and 2 — same subsystem (watch/nudge delivery
access control), fixed together; finding 2 explicitly falsifies finding
1's fix's own admin-bypass argument, so this replaces that reasoning
rather than patching around it.

Finding 1 (confirmed real): VisibleCollectionIDs / GuestVisibleCollectionIDs
deliberately over-widen for navigation — a collection ID is included if the
caller has an item grant on ANY item inside it, explicitly leaving
item-level narrowing to the caller (their own doc comments say so).
computeWatchAccessVisibility used that over-wide set directly as the
"fully visible" gate, so a guest granted item A was treated as having full
access to A's WHOLE collection, including an ungranted sibling item B.
Fixed by building the "genuinely full access" set from
GuestVisibleResources' fullCollectionIDs (populated only from direct
collection_grants, never widened by an item grant) + GetMemberCollectionAccess
/ ListSystemCollectionIDs for an actual member — exactly computeSSEVisibility's
own fullCollSet construction, not an approximation of it.

Finding 2 (confirmed real): the addressed-to-you (KindAssignment) branch in
watchNotificationVisible returned true unconditionally, with NO access
check. validateAssignmentScope (internal/store/items.go) only checks
WORKSPACE membership, never collection access, so an item can be assigned
to a "specific"-access member whose granted collections don't include it
at all — an ordinary assignment, no revocation timing required. Fixed by
gating EVERY notification kind — watch-matched and addressed-to-you alike —
through the SAME watchAccessVisibility check before either branch runs.
watchevents.Notification gains CollectionID so the check has what it needs
without a second lookup; the stream handler resolves it lazily per
workspace via a small connection-scoped cache (workspaces aren't known in
advance for addressed-to-you the way watch workspaces are), cleared on the
same reval tick that reloads the watches map.

This also required replacing computeWatchAccessVisibility's admin-bypass
argument, not just its code: "every call site filters the caller's OWN
watches" stopped being a sufficient justification once addressed-to-you
(which is fundamentally about *this* caller's own assignment activity
across every workspace) shares the same gate — a bearer-borne admin token
unconditionally trusted for that is exactly BUG-1616's blast radius. Now
mirrors computeSSEVisibility's cookie-vs-bearer distinction exactly.

Tests: guest-with-item-grant no longer sees a sibling item's watch or
stream notification (filter-level and HTTP/SSE-level); an assignment
outside a restricted member's granted collections is denied at both
levels; addressed-to-you is proven still gated (denied with no access,
visible once granted) as a pure unit test.

* fix(store): always re-read existing under lock, not just for precheck/patch updates (TASK-2533)

Codex round 2 finding 4, verified real: updateItemWithParentLinkOnce's
`existing` snapshot was only refreshed under the write lock when precheck
!= nil, ExpectedUpdatedAt != "", or FieldsPatch != nil — any update
touching none of those (e.g. a plain title-only PATCH) kept the STALE
pre-tx `existing` for the rest of the function, including the
LastMutation assignment-delta comparison added in TASK-2533's first
round. A concurrent OTHER transaction's assignment change landing between
this transaction's pre-tx read and its lock acquisition would get
misattributed to THIS transaction: a title-only update could report a
spurious, wrongly-attributed AssignmentChanged for a transition it never
made, duplicating the one the other transaction already reported
correctly (or missing a real one, depending on interleaving).

The status-transition capture already defended against exactly this with
its own separate conditional re-read; the assignment-delta capture added
later did not replicate that guard. Fixed by making the re-read
unconditional — once, right after the locks are held, before any SET-
clause building or the UPDATE itself — so every existing.* comparison in
this function is race-free by construction, not by each caller
remembering to guard itself. Also removes the now-redundant duplicate
re-read the status code had of its own.

Reproduces the exact race deterministically using UpdateItemWithPreCheck's
precheck hook as a synchronization point (TX2's assignment change blocks
mid-transaction while TX1's title-only update races its own pre-tx read
against it) — the new test fails reliably against the pre-fix code and
passes reliably (including under -race, and in Postgres mode) against
the fix.

* fix(watchevents): send under the same lock Unsubscribe/Close use (TASK-2533)

Codex round 2 finding 3, confirmed real and high-severity: Publish
snapshotted subscriber channels under the lock, released it, and only
then sent to them. A concurrent Unsubscribe or Close could close one of
those channels in the window between the snapshot and the send — a send
on a closed channel PANICS in Go, which crashes the whole padd process,
not just one subscriber's connection. The reasoning for releasing the
lock before sending ("a slow subscriber would stall everyone else") didn't
hold up: the send is already non-blocking (select/default — a full
channel is dropped-and-logged, never awaited), so holding the lock
through it costs nothing and closes the window structurally.

Adds a hammer test (many iterations of concurrent Publish / Subscribe /
Unsubscribe / Close, short-lived churned channels, recover()-wrapped so a
regression fails cleanly instead of crashing the whole `go test` run) that
reproduces "send on closed channel" dozens of times per run against the
pre-fix code (plus an independent -race detection) and passes cleanly,
repeatedly, against the fix.

* fix(server): re-fetch the user, not just the vis map, on each reval tick (TASK-2533)

Codex round 3, confirmed real: watchVisCache captured *models.User ONCE
at connect time (newWatchVisCache) and never re-fetched it; reset()
cleared only the per-workspace visibility map. computeSSEVisibility's own
doc comment explains why it re-fetches the user fresh on every call —
"so mid-stream role changes (admin demotion, user.disabled flips) take
effect on the next tick" — and the round-2 commit claimed to mirror that
"exactly," but only carried over the collection/bearer logic, not the
re-fetch itself. Net effect: a demoted or disabled admin kept fullAccess
on an open stream (both watch-matched and addressed-to-you delivery,
since both go through this same cache) until reconnect.

Adds watchVisCache.refreshUser, called by both the constructor and
reset() so the cadence matches computeSSEVisibility's actual cadence in
handlers_events.go (that function is invoked once at connect and again
only on each membershipCheck tick — never per event — so "per cache
reset" here is the same cadence, not a narrower one). Deliberately fails
CLOSED (not open-to-stale like computeSSEVisibility's own transient-error
fallback) on a fetch error, a deleted user, or a disabled user — a nudge
stream's wrong failure mode is delivering a fact to someone who
shouldn't see it, not a dropped UI update, so this trades
computeSSEVisibility's availability-leaning fallback for a stricter one
and says so in the comment rather than repeating the "mirrors exactly"
claim the fix falsified.

Tests: a unit-level pair (mirroring handlers_events_revalidation_test.go's
existing admin-demotion/disable coverage of the analogous SSE gap
exactly) proves an admin loses fullAccess after a demotion + reset(),
and a disabled user is denied outright; an HTTP/SSE-level test proves a
live stream stops delivering entirely once its connected user is
disabled and a reval tick passes. All three reproduce the bug reliably
against the pre-fix code and pass cleanly against the fix.

The HTTP-level test deliberately runs serially (not t.Parallel()): it
mutates the package-level watchListRevalInterval var, which every other
parallel watch-stream test in this package also reads via its own
ticker — writing to it from a t.Parallel() test raced against those
reads under -race (misattributed by the race detector to a whole
cluster of unrelated concurrently-running tests before this was
diagnosed). Full server package -race pass is clean after the fix.

* fix(server): decouple vis-cache reset from watch-list reload success (TASK-2533)

Codex round 4, confirmed real: on a reval tick, if ListWatchesForUser
errored, the handler's `continue` skipped visCache.reset() entirely —
the two were coupled, with reset() only reachable on the reload's
success path. A demoted or disabled user's stale identity/visibility
(round 3's fix) stayed live for exactly as long as that UNRELATED query
kept failing, so the round-3 leak reopens for the duration of any
watch-list reload error.

Fixed by running visCache.reset() first, unconditionally, before
attempting the watch-list reload. On a reload failure, the stale watch
list is kept (its own staleness is already bounded by
watchListRevalInterval's "eventually consistent" contract) but is now
gated by the FRESH visCache regardless — a demoted/disabled user is
denied via visCache even while the watch list itself lags a tick.
Chose this over dropping all delivery for the tick (the other option the
finding offered) because tying stream availability to an unrelated
query's transient health seemed like the wrong tradeoff; the comment at
the call site states this choice explicitly.

Adds a watchPredicatesLoadFault test seam on *Server (mirrors the
existing restoreAckFault pattern) so the reload failure can be forced
deterministically without breaking the DB connection for the whole test.
Reproduces the exact bug: forces the reload to fail on every tick while
concurrently disabling the connected user, and asserts addressed-to-you
delivery (which depends only on visCache, never the watch list) is
denied anyway. Fails reliably against a reverted (pre-fix, coupled)
version of the reval branch and passes cleanly against the fix.

Full server package -race pass, full suite (SQLite + Postgres) pass,
lint clean — this is the pre-PR verification matrix; round 5 will be a
narrow re-verify of this fix only.

* fix(server): bound stale watch set under persistent reload failure; atomic test seam (TASK-2533)

Codex round 5, two P2s, both confirmed real:

Finding 1 — `watches = fresh` only ran on the reload's success path, so
under a PERSISTENT (not single-tick) reload failure the watch set stayed
live indefinitely: a dead watch (removed, item deleted) kept matching
forever, and a watch created during the outage was silently missed
forever — visCache (round 4) gates current ACCESS, not whether a watch
still legitimately exists, so it couldn't catch this on its own. Fixed
by tracking consecutive reload failures and clearing the watch set once
maxConsecutiveWatchReloadFailures (3 ticks) is crossed, failing closed
on watch-matched delivery specifically while addressed-to-you delivery
(visCache-only, unaffected either way) continues throughout. Updated the
tradeoff comment at the call site so the "eventually-consistent" claim
now matches the bounded, not unbounded, behavior it actually describes.

Finding 2 — the watchPredicatesLoadFault test seam was a plain `func()
error` field, written by a test AFTER the SSE stream's background
goroutine was already running and reading it on every reval tick:
genuinely racy, unlike restoreAckFault's own use of the identical field
shape, which is set once, synchronously, before the single HTTP request
that reads it — goroutine creation's happens-before edge makes THAT
usage safe without any extra synchronization. Verified restoreAckFault
does not share the flaw and left it untouched. Fixed the watch seam with
atomic.Pointer[func() error] instead.

Test for finding 1: forces maxConsecutiveWatchReloadFailures+1
consecutive reload failures via the (now-atomic) fault seam and asserts
watch-matched delivery is suppressed once the bound is crossed while
addressed-to-you keeps delivering, then clears the fault and confirms
watch-matched delivery resumes on the next successful reload — a bounded
outage response, not a one-way ratchet. Fails reliably against the
bound disabled, passes cleanly restored.

This is the (re-run) pre-PR verification matrix per the dispatcher:
SQLite + Postgres + full-suite -race + lint + gofmt, all clean. Round 6
is a narrow re-verify of these two fixes only.

* test(store): bound the concurrent mutation-signal test's wait (TASK-2533)

CI-triage follow-up: PR #1082's plain Postgres step hit go test's default
10-minute per-binary timeout. Investigated whether any store test added by
this branch scales with runner slowness (lock-wait defaults, sleep-based
polling, transaction-hold durations):

- Watches CRUD tests (8): 0.63-0.80s each under Postgres, isolated and in
  the full 741-test package run.
- Mutation-signal tests (6), including the precheck-hook two-transaction
  race test: 0.48-0.80s each; the race test held at 0.48-0.51s across 10
  consecutive runs (no variance) and across the full-package run.
- Full store package under Postgres: 279.17s and 277.12s across two runs
  on this branch, matching the ~275s/297s baseline team-lead measured
  locally and on PR #1081 — no reproducible slowdown from anything this
  branch adds.

No pathological test found locally. The one test with genuine
cross-goroutine DB lock contention (TestLastMutation_AssignmentDelta_
NotMisattributedUnderConcurrentWrite) had an unbounded wg.Wait() as its
only unbounded wait — TX2's release was already unconditional (fixed 50ms
sleep, not gated on TX1's progress), so there's no deadlock risk, but
there was no ceiling on how long legitimate lock contention could
stretch it under a slow/shared runner. Replaced with a bounded 10s wait
that fails fast with a diagnostic instead of silently consuming
test-binary budget if it's ever exceeded. Verified the regression test
still fails reliably (5/5) against a revert of the round-2 fix it guards.

Could not reproduce the CI timeout locally; likely the pre-existing
~297s CI baseline (already noted as close to the 10-minute ceiling)
plus environmental variance on the shared runner, not a specific test
this branch adds.
2026-08-12 15:50:41 -04:00
xarmian 7332a7f9f8 feat(collections): add spec workspace template — spec-driven development (IDEA-2527) (#1081)
* refactor(collections): extract tasksCollection/ideasCollection helpers

Pulls Tasks and Ideas out of Defaults() into standalone functions,
mirroring the existing docsCollection extraction. Seeded schema is
byte-identical; this just lets a template compose Tasks/Ideas without
also getting Plans, which the upcoming spec template (IDEA-2527) needs.

* docs(collections): generalize decompose playbook to plan-or-spec

The shared `decompose` library playbook was worded plan-only
(target description, pre-flight checks, body-analysis step). Broadens
the wording to also recognize a spec's `## Implementation plan` /
`## Acceptance criteria` sections as decomposition sources, ahead of
the spec template (IDEA-2527) reusing this playbook. Title is
unchanged (looked up by exact string elsewhere); wording is additive
so startup/scrum/product, which have no Specs collection, are
unaffected.

* feat(collections): add spec workspace template (IDEA-2527)

New "spec" template positions Pad as a spec-driven-development
platform: Specs (SPEC, draft→in-review→approved→implemented→
superseded, version+area fields, content_template skeleton) replaces
Plans as the parenting artifact — idea/bug → spec → tasks → PRs.
Deliberately no Plans collection; implementation-plan material lives
in the spec body's optional "## Implementation plan" section instead
(cheaper than maintaining two overlapping artifacts).

Ships:
  - SpecConventionTriggers/SpecPlaybookTriggers, extending the
    software trigger vocab with on-spec-draft/approve/change
  - Four seed conventions gating implementation, PR review, and
    spec-edit discipline on the spec lifecycle
  - Three full-prose playbooks: `/pad spec` (draft-first interview
    with IDEA/BUG graduation), `/pad verify` (walk acceptance
    criteria against the diff/behavior), `/pad extract-specs`
    (brownfield extraction with a subsystem-map human checkpoint and
    provenance-marked observed-behavior specs)
  - Reuses `decompose` (generalized in the previous commit) and
    `ship` (unchanged) as the remaining two seed playbooks

Registered in templates.go; adds the three new playbook bodies to
TestInvocationFramingStaysNLCanonical's scanned surfaces. Dedicated
tests in templates_sdd_test.go cover registration, Specs schema
shape, extended (not replaced) trigger vocab, the four conventions,
and the five playbooks.

Positioning/marketing page descoped from this PR — recon found no
marketing-page infrastructure in this repo to extend (the root route
redirects straight to /console); tracked separately.

* fix(collections): gate spec graduation, fix strict-parser arg contract

Codex round 1, findings 1-2:

- `/pad spec` accepted ANY ref matching the generic ref pattern
  (e.g. TASK-7) and would enter graduation mode, terminalizing an
  unrelated item's status. Dispatch now resolves the ref, checks its
  collection is actually Ideas-like or Bugs-like before graduating,
  and otherwise falls back to using it as recon context for a
  new-topic draft with no status flip.
- `target` was documented/declared optional, but the strict CLI/MCP
  parser only fills REQUIRED args positionally (internal/server/
  handlers_playbooks.go) — `pad playbook run spec "<topic>"` silently
  failed to bind it. Made `target` required, matching the `plan`
  playbook's `topic` precedent. `extract-specs`'s `target` stays
  optional by design (bare invocation is a supported flow); its
  Arguments docs now show the strict-path key=value form instead of
  implying positional works.

Adds a regression test (TestSpecPlaybookTargetArgumentRequirement)
pinning target's required-ness for both playbooks.

* docs: sync decompose's structured arg metadata + SKILL.md to plan-or-spec

Codex round 1, finding 3: the decompose playbook body was generalized
to plan-or-spec in an earlier commit, but its structured
`arguments` JSON metadata (the queryable contract) and
skills/pad/SKILL.md's Decomposition entry were left plan-only —
exactly the drift the body's own comment says these two surfaces
must not have.

* fix(collections): treat unedited Implementation-plan placeholder as absent

Codex round 1, finding 4: the spec content_template always ships a
populated "## Implementation plan" section, which meant decompose's
"no implementation plan -> fall back to acceptance criteria" path
never triggered for skeleton-created specs — it would treat the
placeholder's angle-bracket instruction text as a real task
candidate. The skeleton placeholder now tells the author to delete
the section if unused, and decompose's source-analysis step treats
an unedited placeholder the same as a missing section.

* docs(collections): drop phantom TASK-2528 reference

Codex round 1, finding 5: TASK-2528 doesn't exist in the docapp
workspace. The tasksCollection/ideasCollection extraction comments
now cite IDEA-2527 only.

* docs: add spec-target routing example to SKILL.md Planning section

Codex round 2, finding 1: the Planning section's decomposition routing
example only showed a plan target ("break plan 2 into tasks" → PLAN-2).
Adds a spec-target example alongside it, consistent with the
Decomposition entry further down (already generalized to plan-or-spec)
and the decompose playbook body/arguments.

* fix(collections): generalize ship playbook target to plan-or-spec

Codex round 3, P1: the spec template seeds ShipPlaybook() unchanged,
but its target contract documented only PLAN-ref | TASK-ref. Decompose's
step-7 report tells the user to run `/pad ship <target-ref>` on the
source ref, which in a spec workspace is SPEC-N — so the seeded
idea->spec->tasks->ship handoff broke at the last step.

Generalizes target's wording across all three surfaces (the Arguments
line, the argument-parsing PLAN-ref bullet, and the arguments-JSON
description) to PLAN-ref | SPEC-ref | TASK-ref, mirroring the same
additive pattern already used for decompose: a spec is a parenting
artifact with identical expansion mechanics to a plan (same
--parent-child wiring), so the change is inert for startup/scrum/product,
which have no Specs collection. No test pins the exact argument text,
so existing structural tests (TestStartupTemplateShipsShipPlaybook,
TestPlaybookLibrary_ShipBodyShared) pass unchanged.

* fix(collections): generalize ship's remaining plan-only mentions

Codex round 3 follow-up: two plan-only mentions left over from the
target-contract generalization.

- Commit-message template's "Parent: PLAN-XXX." -> "Parent: PLAN-XXX /
  SPEC-XXX.", plain aliasing matching everything else already
  generalized.
- Step 11's parent-closing guidance keeps the existing plan sentence
  as-is and adds the spec case as a judgment-trigger pointer rather
  than a parallel unconditional flip: a spec's terminal status is
  gated by verification (that's what the `verify` playbook is for),
  so ship tells the agent to run `/pad verify SPEC-XXX` instead of
  flipping the spec's status directly — it moves the spec to
  `implemented` only once the acceptance criteria actually hold.

* fix(collections): ship's PR-body guidance cites specs and their criteria

Codex round 4, P1: ship's PR-context generation still said "parent
plan" and templated the PR body under <PLAN-REF> only — so
`/pad ship SPEC-N` produced a PR that never cited the governing spec,
directly violating the spec template's own seeded on-pr-create
convention ("PRs cite the spec and which criteria they satisfy").

Generalizes the PR-body template to <PARENT-REF> (PLAN-ref or
SPEC-ref) and adds explicit guidance: when the parent is a spec, the
PR must also list which acceptance criteria it satisfies (e.g.
"Implements TASK-12 under SPEC-4, satisfies AC-1, AC-2") — this is
what makes /pad verify fast later, since the reviewer walks the cited
criteria instead of re-deriving intent. Also generalized step 1's
"check the parent plan's content" to plan-or-spec, since a spec
parent's acceptance criteria are exactly what step 8 needs to cite.

* fix(collections): verify gates the implemented flip on spec approval

Codex round 4, P1: /pad verify only excluded draft specs from the
flip-to-implemented, so it could promote an in-review or superseded
spec straight to implemented on the strength of passing acceptance
criteria alone — bypassing the workspace's own approval lifecycle.

Verification still runs and reports AC results regardless of status
(useful information either way), but the Resolve step's all-pass path
now branches on status: approved -> offer the flip (unchanged);
already implemented -> report the re-verify confirms it still holds,
nothing to flip; in-review -> report the pass but tell the user
approval isn't done yet, point at finishing review; superseded ->
report the pass but point at whatever spec replaced this one, since
that's the one that should be verified and implemented going forward.

* fix(collections): decompose treats placeholder ACs as absent too

Codex round 4, P2: the AC-fallback path treated unedited skeleton
placeholders (AC-1: <a statement...>) as real task candidates, so a
fresh untouched spec could decompose into bogus tasks. Extends the
same placeholder-as-absent rule already applied to the
Implementation-plan section: an AC-N line still holding the unedited
angle-bracket instruction text isn't a real criterion and doesn't get
a task proposed for it. If every AC-N is still a placeholder, there's
nothing to decompose from either source — decompose stops and tells
the user the spec has no real acceptance criteria yet.

* fix(collections): unify AC placeholder idiom, cover bare-ellipsis form

Codex round 5, P2: the seeded skeleton's AC-2 used a bare-ellipsis
placeholder ("AC-2: ...") while AC-1 used angle brackets and the
decompose placeholder rule only named the angle-bracket form — a
literal-minded agent could propose a bogus task for an untouched AC-2.

Two one-line fixes: the skeleton's AC-2 now uses the same
angle-bracket idiom as AC-1 ("AC-2: <the next verifiable criterion>"),
so the seeded skeleton has one placeholder style; decompose's
placeholder rule now also names bare ellipsis ("AC-N: ...") as a
placeholder form, as belt-and-suspenders for user-typed shorthand
beyond just the seeded skeleton.

No test pinned the AC-2 text, so no test changes needed.

* fix(collections): spec's circulate-for-review branch actually sets in-review

Codex round 6, P2: the "circulate for review" branch said to leave the
spec at in-review and stop, but the create command always uses
--status draft and no update followed it on that path — so the spec
silently stayed draft forever. Since round 4's fix gates verify's
implemented-flip on approval status, an item stuck at draft (never
even reaching in-review) is a stuck workflow, not just a label
mismatch.

Adds the explicit `pad item update <new-spec-ref> --status in-review
--comment ...` step to the circulate branch, parallel to the
approved-outright branch's existing update command, keeping the
audit-comment habit consistent with the rest of the body.

* fix(collections): add resume mode so circulate-for-review specs converge

Codex round 7, P1: the circulate branch stopped the playbook before
the graduation step, and nothing ever completed it — a later
`pad item update SPEC-N --status approved` was a bare status flip with
no agent step attached, so the source IDEA/BUG never got terminalized.
Worse, the advertised rerun path was broken: `/pad spec SPEC-N`
dispatched as non-graduation (a spec isn't Ideas/Bugs-like per the
round-1 gate) and would have created a SECOND spec instead of
resuming the first.

Three coordinated edits:

- New dispatch mode: a target resolving to the specs collection
  itself enters resume mode, never creates anything. Branches on the
  spec's status — in-review is the normal resume case (confirm
  approval, complete any pending graduation, offer decompose);
  draft/approved/implemented/superseded get the sensible remainder
  (offer the original choice again, report already-resolved state, or
  point at the successor spec).
- The circulate branch now records a "Graduation pending approval:
  <source-ref>" comment on the new spec when in graduation mode, so
  resume mode has something mechanical to find rather than relying on
  re-deriving intent from the Context section.
- The circulate branch's stop text now tells the user how the loop
  closes: rerun `/pad spec SPEC-N` when review is done.

Updated the `target` argument docs (body + arguments JSON) to name the
third accepted form. templates_sdd_test.go doesn't assert argument
description text, so no test changes needed.

* fix(collections): restructure graduation as one idempotent reconcile rule

Codex round 8, two P1s: the approved/implemented branch of Resume
never checked the pending-graduation marker (so a plain manual
`--status approved` never graduated the source), and the circulate
branch's marker write sat after "stop here" — a skippable step three
rounds of findings kept landing on. Scattering graduation state and
handling across branches was the actual bug; restructuring so the
class can't recur, not another branch-local patch.

- The graduation-link comment now gets written unconditionally at
  spec-creation time (step 5, graduation mode), before any
  approve/circulate branching — no ordering problem, no skippable
  step, exists on every path including a crash before either branch
  completes.
- One Reconcile rule, stated once in the Resume section: on every
  resume that reaches it (draft/superseded stop earlier and skip it;
  in-review-not-yet-approved also skips it), if the spec is now
  approved or implemented and its comments name a graduation source
  that's still open, complete the graduation — idempotent, so it's
  safe to run on every resume regardless of how approval happened
  (through this playbook, a crash-recovery rerun, or a bare manual
  status flip outside the playbook entirely).
- Step 6 (approve-outright path) is now just an invocation of
  Reconcile rather than parallel instructions — graduation mechanics
  described exactly once, referenced from both call sites.

Dispatch and Arguments text re-read coherent after the restructure;
no changes needed there beyond what round 7 already added.

* fix(collections): graduation idempotent by source, custom collection, promise wording

Codex round 9, five findings — the last substantive round before
remaining crash-window-shaped gaps become documented limitations
rather than more branches:

1. (High) Rerunning /pad spec IDEA-x mid-flight created a second
   spec — graduation wasn't idempotent by SOURCE, only by spec ref.
   Pre-flight step 2 (graduation mode) now checks, before drafting,
   whether the source's trail already shows a "Graduating into
   <spec-ref>" comment, or whether a search of the specs collection
   finds a spec whose Context names this source. Either match means
   this run is really a resume — switch to resume mode on the found
   spec instead of creating.
2. (High) A crash between create and the marker comment left an
   unlinked spec. Step 5 now writes markers on BOTH sides (source and
   new spec) immediately after create, back-to-back, shrinking the
   window. Documents the actual recovery mechanism instead of
   pretending atomicity: the skeleton's Context section always names
   the source, so finding 1's recon check catches even a marker-less
   spec on the next run.
3. (Medium) The resume-mode dispatch check tested only the literal
   "specs" collection, breaking for a custom `collection` argument.
   Now tests against the resolved `collection` argument (checking
   `pad collection list` if renamed), consistent with the existing
   ideas/bugs check.
4. (Medium) The opening promise ("nothing gets created until the
   user approves") contradicted the circulate path, which creates an
   in-review item. Reworded to match actual behavior: nothing is
   created until the user chooses approve-or-circulate; the draft is
   always presented in chat first.
5. (Note) The superseded branch skipped Reconcile unconditionally,
   stranding any pending graduation. Now checks the marker before
   stopping: if the source is still open, the pending graduation
   transfers to the successor spec (if findable) via the same marker
   comment, so the successor's own future Reconcile picks it up.

* fix(collections): make graduation's recovery claims actually true

Codex round 10, four sentence-scale edits closing the gap between
what the prose claimed and what it actually did:

1. (High) Pre-flight step 2's "recon check is the actual recovery"
   claim was false for a marker-less spec after a crash: the search
   found the spec, but Reconcile still needed marker comments that
   were never written, so it would no-op and strand the source. Now
   the discovery path repairs — writes both sides' markers right then
   if missing — before proceeding to resume, so the recovery claim
   holds by construction instead of by accident.
2. (High) Transferring a pending graduation to an already-approved-or-
   implemented successor (superseded branch) wrote the marker but
   never re-triggered anything to act on it. Now runs Reconcile on the
   successor immediately in that case (idempotent, source ref already
   in hand) instead of waiting on a rerun that might never come.
3. (Medium) "Skip straight to Resume below" bypassed Resume's own
   pre-flight (loading the spec's comments), which Reconcile depends
   on. Now explicit: run Resume's pre-flight first.
4. (Medium/borderline) "Never creates a second spec" overstated the
   guarantee for a SPEC-ref passed with a mismatched --collection.
   Softened to "never creates a duplicate within the resolved specs
   collection" everywhere the claim appears (Arguments prose, Dispatch,
   pre-flight, and the arguments JSON description) — consistent
   scoped truth in every location rather than a strong claim in one
   place and a weaker one elsewhere.

* fix(collections): enforce the Context-citation premise, walk the chain

Codex round 11, two findings:

1. (High) The whole crash-recovery mechanism (pre-flight step 2's
   search, step 5's recovery claim) depends on a graduated spec's
   Context section naming its source — but nothing enforced that; the
   skeleton's own Context hint says "(if any)" since most specs
   aren't graduated, and step 3 never mandated the citation for the
   ones that are. Added the explicit rule to step 3: in graduation
   mode, Context MUST cite the source ref by ID (e.g. "Grew from
   IDEA-12"), stated with the reason — it's the search key crash
   recovery depends on. Reinforced in step 5's and pre-flight step
   2's claim text: attributed the guarantee to the playbook's own
   mandate, not to "the skeleton," which doesn't itself enforce
   anything.
2. (Medium) Transferring a pending graduation to a successor that is
   itself superseded parked the marker somewhere no rerun would ever
   look — chains of supersession weren't walked. The superseded
   branch now walks to the LIVE HEAD of the chain (bounded, ~10 hops)
   before transferring or reconciling; a loop or dead-end mid-chain is
   treated the same as no successor found, rather than guessed at or
   walked forever.

* fix(collections): don't silently pick a branch in the supersession chain

Codex round 12, the last: the chain-walk from round 11 silently picked
one live head when supersession branches (more than one spec claims to
supersede the same spec). One clause, grouped with the existing
loop/dead-end stop rule: if more than one spec claims to supersede the
same spec at any point in the walk, stop and ask the user which is
canonical before transferring — don't pick silently.
2026-08-12 14:20:02 -04:00
xarmian a66c9ab402 chore(nix): bump version to 0.12.0
Pre-tag version sync required by PLAYB-1160 step 1 — the flake builds
from source at whatever ref the user names, so the tag snapshot must
self-report the version being tagged. Without this, binaries from
'nix run github:PerpetualSoftware/pad/release' report 0.11.0 forever.

Refs TASK-2445.
v0.12.0 v0.12.0-rc.1
2026-08-11 17:37:53 +00:00
xarmian dd95d755d2 Merge pull request #1079 from PerpetualSoftware/fix/bug-2334-sse-toast-deflake
fix(e2e): silence cross-actor SSE creation toasts suite-wide (BUG-2334)
2026-08-11 12:35:07 -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 a0ca8fa028 Merge pull request #1078 from PerpetualSoftware/feat/attachment-touch-gestures
PLAN-2392 phase 3d: viewer touch gestures + mobile proof
2026-08-11 11:02:26 -04: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 d01873b508 Merge pull request #1077 from PerpetualSoftware/feat/attachment-lifecycle-completeness
PLAN-2392 phase 3c-iii: attachment lifecycle completeness
2026-08-11 02:06:51 -04: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