Commit Graph

18 Commits

Author SHA1 Message Date
xarmian dd412a022d feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920)

GuestWorkspaceIDs and GetWorkspacesByIDs for the fan-out; NeedsCandidateItems
(visibility scope in SQL), OpenMentionsForItems, NeedsSnoozesForItems and
NeedsMarkersForItems as batches, WorkspaceOwnerIDs, LatestAssignmentTimes.
Snoozes are joined through items.workspace_id and read only for ids the caller
already scoped by visibility (day-19 rulings on TASK-3920).

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

* feat(needs): the routing rule, row types and cursor for the needs inbox (TASK-3920)

Resolve applies asked, mentioned, assigned, admin_fallback in strict exclusive
order over pure inputs (TestResolve_Order). WaitingSince is the earliest open
claim. Score.Value is an int 0-100; Scorer takes the viewer and scope. The
cursor carries as_of, view and ws and is position only.

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

* feat(server): GET /me/needs, the cross-workspace needs-you inbox (TASK-3920)

Fans out over the caller's member and guest workspaces (token allow-list, and a
token bound to one workspace stays in it), reads candidates under the caller's
visibility, routes them with needs.Resolve, applies the caller's snoozes
(joined through items.workspace_id), and pages a total order. degraded is set
only on a provider error. Documented in docs/api.md.

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

* fix(server): classify the needs inbox's route and lifecycle call (TASK-3920)

Adds GET /me/needs to the compress census and its IsTerminalItem call to the
PLAN-3535 lifecycle classification, and drops an unused helper and test field
the linter found. Follows the previous commit; both ride the same PR.

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

* fix(needs): a mentioned row is built from the caller's own mentions; attention flags read only caller-visible items (TASK-3920)

The mentioned rule built one routing for every mentioned person, so a
caller's row carried the earliest mention of anyone and other people's
commenters and comment ids. Resolve now builds each person's view from
their own mentions (Routing.For), and the handler takes the caller's.

The attention read evaluated the whole workspace before visibility, so an
error from an item the caller cannot see set degraded. A new
Runner.WorkspaceFlagsForItems filters decision rows to the given ids
before any state read; the handler passes the caller-visible ones.

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

* fix(needs): only surfaced needs_human rows set a claim; clamp a forged future as_of (TASK-3920)

The needs_human claim time was filled from every latest decision row, so a
candidate whose row did not surface (below threshold, a no, stale) got a false
needs_human reason whenever the workspace had any truly flagged item. Record
and apply it only for flagged items. A cursor as_of later than now is clamped
to now so the defer snapshot cannot be forged forward.

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

* test(needs): restricted-scope store test; document the display-name assignee match (TASK-3920)

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:23:54 -04:00
xarmian ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
  census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
  removed / changed / superseded / current state, and the advisory
  possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
  criterion hash is stored against the body it came from, via resolved
  in-workspace and visibility-checked, activity "verified", item_updated
  SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
  and reconcile.

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

* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)

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

* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)

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

* docs(api): the verifications endpoint (TASK-3617)

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

* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)

- F1: when the item moves between the criteria read and the write, the
  retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
  Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
  item that keeps moving (409), and a grant revoked mid-retry (404).

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

* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)

- The retry re-runs the via resolution and its visibility check, the same
  re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
  share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).

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

* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)

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

* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)

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

* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)

- F1: VerificationExport, exported for the bundle's live items and
  imported with the item and via ids remapped, every other value
  verbatim. Each bundle row is checked before its insert (a refused
  statement on Postgres loses the whole import); a row that fails, or
  whose item did not import, is dropped and counted. Tests: a SQLite round
  trip, the migrate-to-pg path (SQLite export into a Postgres import with
  the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
  validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.

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

* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)

The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.

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

* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)

#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.

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

* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-19 10:25:43 -04:00
xarmian 9dc679e786 feat(server): per-user catch-up digest and read cursor (TASK-3615, PR A) (#2219)
* wip(store): workspace_read_cursors table and cursor store (TASK-3615, PR A part 1)

The per-user, per-workspace read cursor behind the catch-up card. seen_at
moves only on Mark caught up (Dave, 10-17) and never backwards; reading never
writes (lead ruling, day 12). Untested checkpoint commit; the digest
endpoint, PUT /me/seen and tests follow.

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

* wip(server): catch-up digest store query, GET /catch-up and PUT /me/seen (TASK-3615, PR A part 2)

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

* test(server): catch-up digest sections, window, cursor, visibility, needs_you, agents (TASK-3615, PR A)

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

* fix(store): read activity metadata as text on Postgres in the catch-up queries (TASK-3615)

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

* test(server): catch-up fixtures for reopen, abandoned, own and unrelated comments, old cursor, done-but-blocked, scoped needs_you (TASK-3615)

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

* fix(server): needs_you skips reference items; classify the catch-up and blocker lifecycle sites (TASK-3615, PLAN-3535)

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

* fix(store): catch-up window's lower bound is inclusive, so a same-second event is re-shown, never hidden; document the endpoints (TASK-3615)

Review round 1 (hunter, Low): an event stamped in the cursor's second could
have happened after the press and was never shown.

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

* test(server): classify GET /catch-up as compress in the route census (TASK-3615)

Its response carries refs, titles, display names and timestamps: no token,
code, signed URL or secret.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-18 12:12:51 -04:00
xarmian b901ec629c feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866) (#2216)
* feat(web): choose an API key's access when creating it, and see every key's access (TASK-3866)

Console settings now offers Read and write (["*"], the default, the same
value the store writes when scopes are omitted) or Read only (["read"])
when making a key, and the token list says what each key can do. The
label mirrors tokenScopeAllows, so legacy ""/[] keys read as
unrestricted and null, unparseable or all-unknown values read as No
access, never as full access. One fixture,
internal/server/testdata/token_scope_levels.json, is checked by both the
Go test and the vitest test, so the label cannot drift from the server.

Both create doors (POST /auth/tokens and POST /workspaces/{ws}/tokens)
now refuse scopes the server would not honour, with 400 validation_error
naming the value: non-arrays, the string "null", the legacy [] form,
and unknown values. They used to be stored verbatim, minting dead keys.
Omitted still means ["*"]. Documented in docs/api.md.

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

* test(e2e): mint through the admin session; the suite PAT is refused session_required (TASK-3866)

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

* test: fixture rows where JS trim and Go TrimSpace disagree, and read after write; scopes is always a string (TASK-3866)

Mutants T2, T6 and T8 survived the first set: NBSP is trimmed by both
runtimes, no row put read after write, and the null branch was
unreachable because the API's scopes is a Go string.

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

* fix(server): an explicit "scopes": null is refused, not read as omitted (TASK-3866)

Codex r1 (High): decoded into APITokenCreate, a JSON null scopes field
arrived as "" and minted a full-access key. Both create doors now keep
the field raw (apiTokenCreateRequest), so a null, an array instead of a
string, or a number is refused 400 validation_error naming the value.
Omitted or "" still keeps the ["*"] default.

describeTokenScopes treats any non-string as No access instead of
throwing: the existing settings tests build tokens without scopes, and
the string-only signature from the previous commit crashed them.

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

* fix(server): only an omitted scopes field gets the default; "" is refused (TASK-3866)

Codex r2 M1: an explicit "scopes": "" minted a full-access key. "" is
the stored legacy unrestricted form, refused for the reason [] is. No
caller sends it: APITokenCreate.Scopes has carried omitempty since the
model was added, and the web sends no field or a non-empty one.

The raw field now decodes into a *string, so a null is nil however it
is spaced. Codex r2 H1 (a spaced null bypassing the check) did not
reproduce: encoding/json hands RawMessage the bare literal.

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian 87101002a2 fix(import): bundle import remaps attachment references in version history, and on a kept partial import (BUG-3655, BUG-3656) (#2156)
* fix(import): bundle import remaps attachment references in version history, and on a kept partial import (BUG-3655, BUG-3656)

BUG-3655: RemapAttachmentReferencesInWorkspace rewrote item content, fields
and comments but not item_versions, so restoring a version (a full snapshot,
or a reverse patch that re-inserts a reference) brought back the SOURCE
workspace's attachment id. Each live item's chain is now resolved against its
pre-remap body, every resolved body remapped, and the chain re-encoded
against the remapped bodies (full stays full; a patch is re-diffed, kept while
smaller). A chain that does not resolve keeps the newest-only rebase and is
logged. The BUG-3745 remap expectation changes with it: the newest version
now resolves with the new id.

BUG-3656: the final remap ran only on success, so a plain error after some
attachments were rehydrated (an oversized blob) kept a workspace whose
references named the source ids; and a remap failure was logged and the
import answered 201. One deferred door now remaps on every keep-path exit,
and a remap failure is a kept-partial error with its own marker category.

The interchange golden gains history that carries the reference below the
newest version (a full snapshot and an older reverse patch), with coverage
flags for both. Red without the store fix on the golden and both new tests;
red with the keep-path door or the error propagation mutated.

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

* fix(store): a version patch that does not apply blinds only its own segment of the chain (BUG-3655, codex r1 Medium)

The remap skipped an item's whole history when one patch failed to apply, so
a full snapshot elsewhere in that chain kept the source id. A full row
restarts the walk, so rows below it are re-encoded as usual; rows without a
body have their patch text remapped as written (a same-length id swap keeps
the hunk counts). Red on the previous commit: the full snapshot, the
unresolvable patch and the rows below kept the source id.

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

* fix(store): the version remap walks the chain exactly as the reader does (BUG-3655, codex r2 Medium)

ListItemVersionsResolvedPage skips a patch that fails to apply and resolves
the row below it against the same successor. The remap stopped resolving at
the failure instead, so that row's patch text was remapped raw, which misses
an id a character-level diff splits. It now walks the same way and re-diffs
every row the reader can resolve. The test asserts through the real resolver,
with a row below the failed patch whose patch does not carry the id whole: red
on both previous versions of the remap.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 08:05:34 -04:00
xarmian 7f342ef336 test(interchange): golden bundle fixture and a full export, import, export round-trip diff (TASK-3628) (#2125)
* test(interchange): a golden bundle round-trips through import and export, and every loss is listed (TASK-3628)

testdata/interchange/golden is a bundle laid out as files: collections with
schemas (select with terminal and abandoned options, number, date, relation),
an archived collection with a live item, a parent and children, tags, a pin,
versions, comments, links (one to a soft-deleted item), reminders (armed;
fired and acknowledged), a set-aside collaborative edit (content_state), the
onboard playbook with its builtin_origin, a convention, and an attachment
referenced from an item body.

TestInterchangeGolden_RoundTrip imports it through the HTTP bundle door,
exports it through the same door, keys every entity by a natural key, rewrites
every id (including pad-attachment refs and relation values) to that key, and
diffs. Each difference must match interchangeKnownLosses, which gives the
reason; an entry that matches nothing fails as stale. Two known losses today:
an attachment's created_at is re-stamped at import, and a link to a
soft-deleted item is exported but not imported.

PAD_UPDATE_INTERCHANGE_GOLDEN=1 regenerates the golden.

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

* test(interchange): pin coverage, exact loss counts, versions by position (TASK-3628, codex r1)

- The golden must carry each shape the round trip checks (22 named, from an
  archived collection to a link to a dropped item); a regeneration that loses
  one fails before it is written.
- Each known loss covers an exact number of differences, so a new loss at an
  allowed path fails.
- Versions are keyed by their position in the item's history: two can share a
  second and a body. The generator now writes real edit versions
  (ForceVersion; the throttle had folded the edits into none), one of them by
  an agent, and an item created by an agent.
- UUIDs match case-insensitively; regeneration is SQLite-only and swaps the
  new golden in only after it is complete.

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

* test(interchange): regeneration never leaves no golden on disk (TASK-3628, codex r2)

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

* test(interchange): say what the golden swap guarantees (TASK-3628, codex r3)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-13 16:11:27 -04:00
xarmian aa7bdd4f15 feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b)) (#2094)
* feat(server): GET /items/{ref}/context, one deterministic brief for working an item (PLAN-3598 slice (b))

An agent rebuilt this context by hand: bootstrap, the item, its parent plan
or spec, linked items, and trigger-matched conventions (which need --full
for bodies). Each agent did it differently. The endpoint returns it in one
call, with no LLM in the loop:
- item: fields, body, seq;
- parents: the visible ancestor chain, nearest first, each with acceptance
  criteria parsed by internal/acceptance (#2091), and the nearest one's body;
- conventions whose trigger is always or the intent's, with bodies, sorted
  must, then should, then nice-to-have, then by ref;
- playbook metadata;
- deps with statuses, decisions with `current`, and linked items;
- omitted and conflicts.

The intent resolves against the workspace's declared trigger options (on-X,
then X); an unknown one is 400 unknown_intent with the valid list. The role
must be a slug and filters by the agent-role column (lead ruling).

The budget admits whole parts in a fixed order and stops at the first that
does not fit. That part and all later ones are listed in omitted, so a small
part never slips in after a refused one. The item always ships.

Visibility: the item uses requireItemVisible (hidden equals missing).
Ancestors, deps and links use itemVisibleFor plus the app-token collection
ceiling and live rows only; conventions use bootstrap's visibility sets.
Invisible parts are absent and not counted in omitted, so the endpoint is
not an existence oracle.

Conflicts are structural only: open_blocker, open_under_closed_parent,
stale_decision, role_and_global_convention.

Tests cover the brief, the refusals, budget order (the stop rule's mutant is
caught, after the first version of the test was found blind to it), a guest
seeing nothing ungranted with a positive control, and hidden-equals-missing.
CLI and MCP surfaces are slice (c).

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

* test(server): an archived item's context follows the single-item GET (PLAN-3598 slice (b))

Codex round 1 at d10663cf3 read an archived item served to a reader as a
P2, on the premise that ordinary reads exclude deleted items. They do not.
handleGetItem resolves with ResolveItemIncludeDeleted and serves an archived
item read-only to any caller who passes checkItemVisible; mutation handlers
then answer 409 archived. The context endpoint applies the same rule, so it
exposes nothing GET does not. This pins that: the owner gets the brief of an
archived item, and an ungranted caller gets the same 404 as for a missing
item. docs/api.md says so.

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

* fix(server): item context reveals no trigger names to a caller who cannot see conventions (PLAN-3598 slice (b))

Codex round 2 at 605187fcc (P2): the intent was validated against the
conventions collection's declared trigger options before checking whether
the caller could see that collection. An unknown intent's 400 then listed
those triggers in valid_intents to a guest, a restricted member, or an app
token whose ceiling excludes conventions. Such a caller now gets no
validation and no list: the intent is taken as on-<intent>, and its
conventions are absent anyway. The new guest assertion fails on 605187fcc.

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

* test(server): classify GET /items/{ref}/context as compress in the route census (PLAN-3598 slice (b))

The brief carries item and convention bodies, field values, link metadata
and decisions: no token, code, signed URL or unmasked secret, the same as
the single-item GET and /decisions beside it.

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

* fix(server): item context decodes schemas canonically and treats reference items as no work (PLAN-3598 slice (b))

CI's Go job at d290fb37c failed three guards in internal/models, a package
my local gate did not run.
- TestReservedFieldDecodeSitesAreClassified / TestGuardSeesEveryKnownDecodeSite:
  the trigger-option read and the done check decoded a collection schema
  with raw json.Unmarshal. Both read item fields, so both now use
  models.UnmarshalItemFieldSchema.
- TestPLAN3535_LifecycleHelperCallSitesAreClassified: the new IsTerminalItem
  call was unclassified. Classifying it exposed a real defect. A reference
  item (a doc, in a collection that does not track work) read as OPEN, so a
  doc blocking the item raised open_blocker. CollectionTracksWork now answers
  first, as childProgressState's isReferenceCollection does, so a reference
  item is neither an open blocker nor a closed parent. The site is
  classified "exclude".

The new reference-item test fails on d290fb37c.

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

* fix(server): an unreadable collection stays open work in item context (PLAN-3598 slice (b))

Codex round 4 at e45860db9 (P2): e45860db9 put CollectionTracksWork in
front of the done check, but a collection that failed to load, or whose
schema did not decode, also read as "not work". That silently suppressed
open_blocker where the code before e45860db9 read the item as open. An
unreadable collection now keeps that conservative reading, and only a
collection positively known not to track work excludes its items. The new
test (a blocker in a collection with a corrupt schema still raises
open_blocker) fails on e45860db9.

Scope: only the context handler's own lifecycle check changed. No other
IsTerminalItem caller is touched.

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

* fix(server): item context's open_blocker uses the dashboard's predicate, so a reference blocker blocks (PLAN-3598 slice (b))

Lead ruling on #2094: PLAN-3535 PR 2 keeps an explicitly linked reference
BLOCKER blocking, and firstActiveBlocker does the same. The brief now
builds the dashboard's doneContext map and reuses its predicates instead
of its own CollectionTracksWork-first cache:

- open_blocker: the blocker is active when !isItemDone, firstActiveBlocker's
  test, so a draft doc linked as a blocker blocks until it is archived;
- the subject item is open work by the dashboard's workItems rule
  (!isReferenceCollection && !isItemDone);
- open_under_closed_parent: the parent is closed when isItemDone, in any
  collection, since the close guard refuses to close a parent over an open
  work child.

A new test asserts the brief and the dashboard agree on a reference
blocker, draft and archived. The lifecycle guard classifies the new
isItemDone site.

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

* test(server): drop the unreadable-collection item-context test, vacuous since the brief reuses the dashboard's predicates (PLAN-3598 slice (b))

It pinned a decision the brief's own lifecycle cache made (an undecodable
schema is open work, not reference). b350f7f09 deleted that cache: the
brief now asks isItemDone and isReferenceCollection, which never consult
whether the schema decoded (reference-ness reads settings; a zero schema
compares status against the default terminal values), so no change to the
brief could make it fail. It also failed on Postgres in setup: the json
schema column refuses the non-JSON string it wrote (22P02).

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

* test(server): fold the undecodable-schema blocker into the item-context/dashboard agreement test (PLAN-3598 slice (b))

60cf8adc8 deleted the unreadable-collection test as vacuous; Codex r7
disagreed, rightly: it was the only check that the brief keeps an
undecodable-schema blocker as open work, and a brief-side "skip unreadable"
rule would pass the doc-based agreement test, whose schema decodes.

TestPLAN3598_ItemContextBlockerAgreesWithDashboard now asserts the brief and
the dashboard agree on three blockers: a draft doc (blocks), the same doc
archived (does not), and an open item whose collection schema is valid JSON
of the wrong shape (blocks), with a precondition that it fails to decode.
The fixture no longer writes a non-JSON string, which Postgres's json
column refused (22P02, the CI failure at b350f7f09).

Evidence: passes on SQLite and Postgres; a mutant that skips reference
blockers fails the draft-doc case, and one that skips undecodable-schema
blockers fails the third.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 02:49:27 -04:00
xarmian 70c57f8202 feat(auth): a first Cloud signup lands in its new workspace; OAuth comes first (TASK-3595) (#2075)
* feat(auth): a first Cloud signup lands in its new workspace; OAuth comes first (TASK-3595)

A Cloud signup auto-creates and seeds a workspace, then landed on
/console, a list with a Create button.

- autoCreateWorkspace returns the workspace only when it is usable.
  Every failure arm returns nil, including the two that keep a
  workspace with an unknown or wrong-role owner row.
- Register (self-serve) and oauth-login add an additive workspace_path
  ("/<username>/<slug>") when a workspace was just made. The pad-cloud
  sidecar uses it for OAuth signups (its own PR, stacked on the go-live
  line).
- The register page lands there through postSignupTarget, the "Continue
  to Pad" of an unverified signup included. An explicit ?redirect=
  (OAuth authorize, CLI/MCP auth, an invitation, a deep link) or an
  accepted invitation still wins.
- The register page puts the GitHub/Google buttons first, with the "or"
  divider below them (AuthOAuthButtons divider="below").
- The generated username becomes a "Username: ada · Change" line. It
  opens on click (focus follows), or by itself without moving focus
  when the generated name is taken.
- The register page moves from $app/stores to $app/state, so it mounts
  under jsdom.

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

* test(cloud): the oauth-login contract golden carries workspace_path (TASK-3595)

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

* fix(web): a taken generated username keeps its field open while the user types (TASK-3595 codex r1)

Editing the field reset the availability check, and the field's
visibility read it, so the input vanished under the typing. A taken
name now opens the field for good. Also note in postSignupTarget that
an explicit /console counts as no destination, by design.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:09:29 -04:00
xarmian 3201342d3d feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half) (#2043)
* feat(cloud): a Stripe event marker is a claim with a lease, so a handler that dies does not lose the event (BUG-3561, pad half)

pad-cloud marked a webhook event processed BEFORE its handler ran, and the
mark was final. A sidecar crash, OOM or redeploy mid-handler, or a failed
unmark, left the event 'processed' forever: every Stripe retry was
acknowledged as a duplicate. On an annual plan cancelled early, the user kept
Pro until the period end.

The marker now has two states (migration 136 / PG 108; existing rows read
as 'done', which is what they meant):
- ClaimStripeEvent inserts a new event 'processing' with a 5-minute lease
  (claimed). A 'done' event is a duplicate. A 'processing' event with a
  live lease is in_progress. One with an EXPIRED lease is taken over by one
  conditional UPDATE with a new token: of several racing retries exactly
  one wins.
- POST /api/v1/admin/stripe-event-complete {event_id, processed_at} marks it
  done after the handler succeeds. It is token-scoped, so a handler whose
  lease was taken over cannot complete it for the one that took it.
- POST /admin/stripe-event-processed keeps already_processed with its old
  meaning (anything but a fresh claim) and adds 'state', which a newer
  sidecar feature-detects (lead ruling). A sidecar that predates this never
  completes; its rows stay 'processing', and a redelivery after the lease
  re-runs a handler that re-syncs from current Stripe state, which is
  harmless.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, BUG-3361's cloud-only route list, and the cloud contract
goldens (processed gains 'state'; complete is new). The NUL census records
the two new server-written columns. Test: claim, in-progress, done, an
expired lease taken over exactly once by 4 racing claimers, and a
stale-token complete as a no-op. Without the takeover, 0 of 4 claim.

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

* test(cloud): the contract test gets a roomy cloud-admin limiter (BUG-3561)

It calls every sidecar route once from one address to pin response shapes,
and with billing-users and stripe-event-complete the list outgrew the real
limiter's burst of 10 (the 11th call answered 429). Rate limiting is not what
it tests.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 00:02:50 -04:00
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00
xarmian 772c1a86ba feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549) (#2027)
* feat(cloud): the Stripe customer link is compare-and-set, and readable by user (TASK-3549)

POST /admin/stripe-customer-id no longer replaces a DIFFERENT stored
customer: it answers 409 stripe_customer_conflict with
details.stored_customer_id. The same id again is idempotent. Overwriting
the link is how a second checkout orphaned the first customer's
subscription (still charging, its events finding no user). An admin USER
may still replace with replace:true; the cloud secret alone cannot.

GET /admin/user-by-id (cloud secret or admin) returns user_id, email, plan
and stripe_customer_id, so the sidecar reads the stored customer before it
writes one (an older pad still overwrites) and can compare a webhook's
customer with it. Added to the cloud-admin allow-list, rate limiter,
compress census (no secret) and the cloud-only admin route list.

* test(cloud): golden user-by-id and the compare-and-set answers pad-cloud decodes (TASK-3549)

The contract shaper keeps "code" values verbatim: pad-cloud branches on
stripe_customer_conflict, so a placeholder there would hand it a golden
it cannot decode.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-10 15:59:17 -04:00
xarmian 503afd5c3a test(cloud): golden the pad responses pad-cloud decodes (#2026)
TestCloudContract drives the real handlers for every pad endpoint the
pad-cloud sidecar decodes (user-by-customer, plan, stripe-event-processed,
stripe-event-unmark, payment-failed, oauth-login, auth/me) and pins each
response's SHAPE (values replaced by type placeholders) in
testdata/cloud_contract/. pad-cloud keeps a copy and decodes each file
through its own client, so a key renamed or dropped on either side of
the repo boundary fails a test. The class this closes: user-by-customer
answered user_id while pad-cloud read id, so every subscription webhook
synced user "".
2026-10-10 14:41:23 -04:00
xarmian 95046c9b3b feat(server): gzip what the client accepts, except streams, upgrades, ranges and secrets (TASK-2225) (#1934)
* feat(server): gzip what the client accepts, except streams, upgrades, ranges and credential responses (TASK-2225)

Audit C18: the Go server ignored Accept-Encoding entirely, so the items
index (2.1 MB on a large workspace, 6.8x smaller gzipped), every JS chunk
and every API poll went out uncompressed. Invisible on a LAN; most of a
page's load time on a self-hosted WAN link. Pad Cloud already gets Brotli
from Cloudflare, so this is the self-host win.

CompressResponses wraps chi's Compress(5) (negotiation, Vary:
Accept-Encoding, and compressible types only: text, JSON, JS, CSS, SVG;
never images, archives or the export bundle) and skips:
- long-lived streams: /api/v1/events* (SSE) and /mcp (streamable HTTP);
- the collab WebSocket and any Upgrade request;
- Range requests (a 206's Content-Range names identity offsets);
- credential responses (/api/v1/auth/*, /oauth/*, */tokens): the BREACH
  shape, and small enough that skipping costs nothing.

Measured on a throwaway instance: the largest JS chunk 1,456,216 ->
452,518 bytes (3.2x), index.html 6,444 -> 1,354.

Tests: gzip for an accepting client (Vary set, body decodes) and identity
otherwise; every skip; images untouched; an SSE stream still delivers a
flushed event while open; wired into the real router (/api/v1/health).
Mutants: a skip list that never skips, the middleware unwired: each
killed. e2e on the built binary: the SSE and collab specs (14 pass).

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

* fix(server): compress reads only, so no minted secret is ever compressed (TASK-2225, codex r1)

Every secret the API mints comes back from a write; write responses are small.
The account export's flushes still deliver (chi's writer flushes gzip then the
connection), now pinned by a test.

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

* fix(server): a workspace claim code is never compressed (TASK-2225, codex r2)

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

* fix(server): a HEAD keeps its Content-Length: compress GETs only (TASK-2225, codex r3)

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

* fix(server): the members list is never compressed: it can carry a legacy invitation code (TASK-2225, codex r4)

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

* fix(server): a share view's signed attachment references are never compressed (TASK-2225, codex r5 sweep)

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

* test(server): a census of every GET route, each classified compress or skip (TASK-2225, lead condition)

The secret-bearing GETs compressSkips leaves alone are a deny list, and a
deny list rots: the next GET route that returns a secret would be
compressed silently. testdata/compress_get_routes.txt holds every GET
route the router serves (149, 18 skipped) with its classification;
TestCompressRouteCensus walks the router with chi.Walk and fails when a
route is missing from the census, when the census names a route the router
no longer serves, or when compressSkips disagrees with it. A new GET route
therefore fails CI until someone decides whether its response can carry a
secret and regenerates the file (PAD_UPDATE_COMPRESS_ROUTES=1), making the
decision a reviewable diff. Checked: a dropped census line fails naming the
route; dropping the claim-code skip fails naming the disagreement.

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

* fix(server): leave attachment bytes uncompressed (TASK-2225)

CI on #1934 failed TestTask3401c_AStalledClientDoesNotOutliveTheDeadline
in both Go jobs: the client got EOF before any headers. chi's compressor
holds the headers until its first flush, and a 24 MiB file of repeated
text compresses ~1000:1, so on a slower runner the 300ms route write
deadline expired before a byte reached the wire. Attachment downloads are
users' files streamed under that deadline; they now go out as written.
The attachment list at /attachments still compresses.

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

* docs(server): state the deadline rule beside the attachment skip (TASK-2225)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 03:16:26 -04:00
xarmian 52dc41b96a feat(apps): app install provisioning, install codes and redeem (TASK-3397, SPEC-6 U8b) (#1772)
* feat(apps): app install provisioning, install codes and redeem (TASK-3397, U8b)

Confirm re-normalizes the staged pack, compares it with the reviewed
preview, and provisions in ONE store transaction that re-checks every
state-dependent fact: the install row, companion collections (via_app),
the bot (editor/viewer, collection_access specific over the companions),
the install client, the artifacts as owner drafts stamped with their pack
and digests, a one-time install code, and the pending record's deletion.
A refusal names the artifact or collection, the field and the holder, and
writes nothing.

Redeem is mounted outside the auth stack with a 4 KB whole-body cap and
per-address and per-install limiters; it consumes the code and rotates the
client secret. Owners can issue a new code to recover a lost redeem.

* fix(apps): U8b codex round 1: re-check the destination schema in the provisioning tx, a dead install code never charges the install limiter, a reserved companion slug is refused (TASK-3397)

* refactor(apps): U8b provisioning re-derives the normalization inside its transaction under locks (TASK-3397)

Replaces the per-fact re-checks (codex rounds 1-2 kept finding facts the list
missed) with a re-derivation: ProvisionAppInstall takes the pending row, the
workspace lock, then on Postgres FOR SHARE on every collection row and the
owner's membership row, re-runs the preview normalization on the transaction
through the caller's derive function, locks the relation targets it resolved,
and derives again; the caller compares each pass with the reviewed digests.
SQLite is covered by BEGIN IMMEDIATE.

The normalization path takes a Queryer (relation passes, unique check, slug
probe, kind lookup); the pool versions delegate to it. Parity is pinned over
the 8 importer fixtures plus a relation-bearing case captured before the
refactor, the derivation is proven write-free under the write-capture
harness, and the lock table is pinned on both dialects.

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

* fix(apps): U8b codex round 3: compare the reviewed changes list, lock the caller's visibility rows, take items before the membership (TASK-3397)

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

* docs(apps): U8b lock table matches the round 3 order (TASK-3397)

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

* refactor(apps): U8b locks break dependency cycles, not every read (TASK-3397)

Lead ruling, day 86, superseding "lock every read": the visibility locks
added in round 3 created deadlock cycles with member removal and account
deletion and could never cover an inserted grant or a cached role (codex
round 4). Provisioning now takes the pending row, the workspace lock and FOR
SHARE on the workspace's collection rows, reads the owner role plainly, and
derives once. The serializability argument for each raced writer class is in
the lock comment. The two deadlock scenarios stay as tests.

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

* fix(apps): U8b codex round 5: lock the confirming owner's users row first, account deletion is a real cycle (TASK-3397)

Account deletion reads the bot and membership provisioning writes and
deletes the owner provisioning read, and its FK cascade from users reaches
the pending row. Provisioning now takes the owner's users row FOR SHARE
before the pending row; deletion's first statement locks the same row, so
the two serialize whole in either order. Tested through the real deletion in
both orders.

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

* test(apps): U8b account-deletion cycle test uses the canonical owner (TASK-3397)

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

* feat(apps): U8b adoption re-stamps via_app, only from an uninstalled install (TASK-3397)

Lead ruling, day 86 (from the #1771 review): companions are via_app =
install, so an adopted companion is re-stamped to the new install inside the
provisioning transaction. Any holder that is not uninstalled is refused
(app_already_installed, naming the state). Lock 3 becomes FOR NO KEY UPDATE
so the re-stamp never upgrades a share lock.

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

* test(apps): U8b redeem stays outside the app API token router (TASK-3397)

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

* feat(apps): bot lookups key on app_installs.bot_user_id, the address as the agreement check (TASK-3397)

The U5/U8b agreement: whichever landed second switches the issuance barrier,
AppPrincipalForInstall and the members list from the bot's address to the
bot_user_id provisioning sets, keeping the address as an agreement check.
An install without bot_user_id (made before provisioning) falls back to the
address.

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

* chore(apps): drop a dead assignment in diffChanges (lint) (TASK-3397)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-04 11:59:42 -04:00
xarmian ca158c22d8 feat(apps): app install staging: apps/1 manifest, fetch policy, pending reservations, preview (TASK-3397, U8a) (#1770)
SPEC-6 U8a (DOC-3371 §1, §2 steps 1-5, §9). Strict apps/1 parsing;
an https/no-redirect fetch policy screened at dial time with an admin
private-origin list; pending-install reservations under the owner lock
and the instance advisory lock, holding the full charge until the fetch
ends; the owner preview with every artifact normalized by the importer's
own code (normalizeArtifact, prepareCreateFields, both extracted
behaviour-preserving and pinned by a parity golden); apps_enabled and
the admin apps settings; a background sweep of expired pending
installs; migration 118/092. The preview is advisory: it may refuse
what the import would accept, never the reverse; U8b re-checks
atomically.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-04 07:19:07 -04:00
xarmian cb99879d53 feat(materialize): recover unsaved editor sessions into items.content (TASK-2198 U4) (#1683)
* feat(materialize): recover unsaved editor sessions into items.content in the background (TASK-2198 U4)

Wires the U3 supervisor in. A collab room reclaimed at the end of its
grace TTL with content-bearing rows above the flush watermark (T3), or a
dormant pending item found by a one-minute sweep (T1), is queued; one
worker goroutine materializes the item's whole content-bearing op-log
with the whole-workspace link index and lands it through
store.MaterializeFlush, under the collab item lock with no room open.

The write is a flush: cursor == MAX(op-log id), rows pending, no
set-aside rows, checked in the write's own transaction; watermark to the
cursor only; seq bumped; one version row attributed to system/recovery;
the op-log is never pruned. PAD_MATERIALIZE=off disables it entirely.
A per-item in-memory failure budget (3 failures, doubling backoff, reset
by a new op-log row) keeps untrusted input from being retried in a loop.

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

* test(store): recovery watermark is the cursor, not MAX at UPDATE time (TASK-2198 U4)

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

* test(store): a second recovery still writes its own version row (TASK-2198 U4)

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

* docs(mcp): op-log recovery bounds the stale-body window; tool surface v0.60 (TASK-2198 U4)

content_state applied_pending_flush now clears on its own after the
item's room closes, unless recovery fails or PAD_MATERIALIZE=off. Every
sentence that called the window unbounded, or said nothing server-side
moves op-log content into items.content, is corrected (CLAUDE.md,
instructions.md, README, docs/deployment.md, docs/backup.md, code
comments). BEHAVIOR bump to 0.60; goldens regenerated.

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

* fix(materialize): count ErrNoMemoryCap silently against the per-item budget (TASK-2198 U4)

U3 now refuses every job with ErrNoMemoryCap when no memory cap can be
established, and warns once itself. The recovery worker counts it like
any failure but logs it at debug, not per item. Its log lines go through
a logger seam so the test can tell.

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

* style: gofmt (TASK-2198 U4)

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

* feat(materialize): one log line per budget exhaustion; count set-aside skips on the sweep line (TASK-2198 U4)

An item that uses up its failure budget logs exactly one WARN naming the
item, the failure count and the last error kind (DEBUG when the kind is
no_memory_cap, which the supervisor already warned about once), and logs
again only after op-log growth resets the budget and it is exhausted
anew. The schema-version skip goes through the same line.

Items skipped for set-aside rows are counted and reported on a new
single sweep summary line, written only when it has news; never per item.

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

* fix(materialize): keyset-page the recovery sweep so no candidate starves (TASK-2198 U4, codex r1)

The sweep read the oldest 200 candidates from the start every tick, and
the room check and the failure budget filtered them afterwards. Once 200
sat in backoff or exhausted, every sweep returned the same 200 and the
201st was never queued: filtering above a bound is starvation.

Each tick now reads one page strictly after an in-memory keyset cursor
(newest op-log created_at, item id) and wraps to the start after a short
page, so every candidate is offered within ceil(N/limit)+1 ticks,
whatever sorts ahead of it. Keyset over NOT IN: no parameter-count limit,
and it also covers the room check, which an exclusion list would not.

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

* test(materialize): the sweep cursor wraps; docs for the exhaustion line, the set-aside count and paging (TASK-2198 U4)

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

* docs(deployment): reflow the recovery budget paragraph (TASK-2198 U4)

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

* test(materialize): recovery strips the follows-title marker as a tab's save does (TASK-2198 U4, BUG-3315)

Since #1688 a tab loads a link that follows its target's title with the
follows-title marker in the link's title attribute, so the marker lives in
Y.Docs that recovery replays. Three fixture cases (a follows-title link;
the same link after a rename; a URL edited to an external address) are
generated from documents that really hold the marker, and the existing 8
are unchanged. A new leg recovers each through the production path
(whole-workspace index) and through the runner with a restricted and an
empty index, and asserts no 'pad-follows-title' reaches the body. It goes
red in all 7 ways when the bundle's marker strips are disabled.

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

* test(store): a recovery version row carries #1691's history data (TASK-2198 U4)

Rebased onto PLAN-2348 U2 (#1691), which adds user_id, line counts and a
create marker to version rows. Recovery writes its row through the same
UpdateItem insert, so it gets them automatically: no user_id (a system
row), not a create, and line counts equal to diff.LineCounts of the stale
and recovered bodies. The leg pins that.

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

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

Claude-Session: https://claude.ai/code/session_01A3rXV7GDLAhBe9mL4V6z2m
2026-09-25 14:22:55 -04:00
xarmian 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