mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
104f809e67f45afb67f34fbcc511af1ab81da32d
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 "". |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|