POST /workspaces/{ws}/items/{ref}/patch {edits:[{old,new,line_hint?}],
expected_seq?} replaces exact text in the body. It runs through updateItem,
so it takes every PATCH guard; the body is computed inside the store
transaction from the row it replaces. Each old must occur exactly once;
edits apply in order, all or nothing; every patch writes a version. The
answer carries a unified diff of what changed.
expected_seq does not refuse: a moved seq is applied over when the
anchors still match, and answered rebased with the intervening writes,
read from a seq chain stamped into item activity entries ({prev_seq, seq,
version_id}). complete is decided at runtime, so any write that left no
stamp reads incomplete; a guard test classifies every seq-moving store
statement. Refusals write nothing: patch_anchor_missing,
patch_anchor_ambiguous, patch_room_open (an open editor; step 2).
CLI: pad item patch, pad item show --lines. MCP 0.87 (additive):
pad_item.patch, get lines, three allow-listed codes. The timeline treats
the chain and patch keys as bookkeeping, not a change.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
Phase 1 of the ruling that agent content writes carry the version the agent
read. Nothing is refused yet; phase 2 (428 at the agent doors) follows a week
of these counts.
- server: `replace` request member; pad_content_writes_unguarded_total
{door,kind}; warnings.unguarded_content_write on an agent door's unguarded
content write; a seq update_conflict on a content write (applier route
included) carries content_write, changed_at, changed_by_kind and a
"do not resend your body" message.
- CLI: `pad item update --replace` (refused with a token or without a body);
a stderr warning on an unguarded body write; the merge line on conflict.
- MCP 0.86 (additive): pad_item.update `replace`; the content-conflict hint
on both transports; the update description leads with read, edit, write
with the seq.
- Docs and prompts: SKILL.md (both copies), instructions.md, api.md,
mcp.md, deployment.md, README, update help, plan and onboard playbooks.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
From the day-91 outside review's op-log durability claims, both measured
and ruled by the lead.
(1) Recovery (materializeWith) serialized an op-log whose replay left
structs PENDING, because they referenced a row the log did not hold. It
returned a truncated but nonblank body ("alpha" for "alpha beta gamma"),
which the store's only guard (blank output over a nonblank body) let
through: the recovery flush stored it and moved the watermark over content
it never saw. Compaction (snapshotWith) always refused the same input.
- The bundle now refuses it too ("pending structs").
- The Runner and the supervisor map that to a typed
materialize.ErrIncompleteLog (wrapped with ErrJobFailed through the
supervisor).
- Recovery treats it as deterministic: it exhausts at once with kind
incomplete_log, so the existing WARN exhaustion line and
pad_materialize_giveup_total{kind="incomplete_log"} are the refusal's log
and metric. The body and watermark do not move, and there is no retry
until the op-log changes.
- The GC sweep already leaves such an item's rows alone: it prunes only
fully flushed logs. A test pins it.
(3) With compaction on (PAD_OPLOG_COMPACT, default off), a failed snapshot
left the item to the sweep, which deleted its log and with it the Yjs
identity compaction exists to keep. A failed (or unreadable) snapshot now
KEEPS the log and retries with in-memory backoff: 10 minutes, doubling,
capped at 24h; a restart retries once. Success clears the backoff, and
entries for items no longer listed are dropped. The test that asserted the
delete is flipped to assert keep, back off, then compact (lead ruling).
Tests: the bundle refuses the incomplete log while the complete one
materializes; recovery leaves the body, seq and state untouched, gives up
once with incomplete_log, and the sweep does not list the item; the
compaction keep/backoff/retry test. Counterfactuals: without the bundle's
refusal, the bundle returns "alpha" and recovery APPLIES the truncated body;
without the keep, all 27 rows are deleted.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
SetAsideAndClearOpLog kept only the content-bearing rows above the flush
watermark. Those rows build on structs in the rows below it, so replayed
alone they leave every edit pending and render nothing (measured: pending,
''), and no decoder could ever recover them from the set-aside rows; the
base existed elsewhere only as markdown in items.content.
When the log holds any unflushed content-bearing row, the rebuild now sets
aside the WHOLE op-log, every row verbatim and in op-log order, the flushed
base and the non-content-bearing frames included (a SyncStep2 can be the
only copy of an edit, TASK-3574 (2); the rest replay as no-ops). A fully
flushed log still sets aside nothing and the item stays clean. Not live
while SCHEMA_VERSION is frozen; TASK-3246's decoder now gets a replayable
quarantine.
Tests: the store sets aside all four rows of [u1 flushed, u2, step2, u3]
verbatim and in order; a flushed log sets aside nothing; a vitest shows
those rows replay to 'alpha beta gamma' while the old suffix replays to ''.
TEST CHANGE (the ruling replaces what it pinned):
TestBUG3244SchemaRebuildKeepsUnflushedEditsMarked's 'only unflushed
content-bearing rows' subtest now asserts the whole log, in order.
Counterfactual: with the old selection the store keeps 2 of 4 rows.
docs/api.md updated.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
A SyncStep2 is stored non-content-bearing (BUG-3523): it usually repeats
content other rows carry. But it can be the ONLY persisted copy of an edit:
the edit's own update append failed but was still broadcast, its tab closed
before its catch-up, and another tab answered a joiner with a step2 carrying
it. LoadMaterializeInput read bytes only for content-bearing rows, so
recovery never saw that edit. Measured: rows 0, pending 0 for a step2-only
item, and a recovered body of 'alpha' where the document held
'alpha beta gamma'.
LoadMaterializeInput now hands recovery every content-bearing row PLUS every
SyncStep2 (by the store's own strict yjsFrameIsSyncStep2 parse). Replaying a
step2 that repeats content is harmless (Yjs deduplicates); one that holds the
only copy recovers the edit. It still does not count toward Pending, so
content_state is unchanged; whether a step2 should count needs a design note
first (lead ruling). Byte-identical twins and SyncStep1 frames stay out.
Tests (real Yjs frames pinned as literals): recovery over [u1, a step2
carrying u1+u2, u3] stores 'alpha beta gamma'; a step2-only op-log hands its
bytes to recovery and still reads Pending 0. Counterfactual: without the
step2 branch, recovery stores 'alpha' and the input omits the step2.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* 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
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
A user can own a Stripe customer stamped with their id that is not the
linked one (TASK-3549 keeps one link and unions the rest), and before
this only the linked customer was cancelled; with nothing linked the
sidecar was never asked. The other customer kept charging after the
account was gone.
CancelCustomer now carries the user id, and in cloud mode deletion asks
the sidecar even with no linked customer. A sidecar that predates
user_id refuses an empty customer_id with 400; for a user with no
linked customer that is exactly what not asking did before, so that one
answer lets the deletion proceed, and either deploy order is safe. Any
other failure, and any failure with a linked customer, still aborts.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
An invitation records who sent it. On a fresh install with no account yet,
requests run without auth, so pad workspace invite reached CreateInvitation
with no inviter, failed the inviter foreign key, and answered 500, which
reads as transient and retryable. handleInviteMember now refuses a request
with no resolved user: 409 setup_required with sign-in's own message
("This Pad instance must be initialized with pad auth setup") when no account
exists, else 401. Nothing is written.
ToolSurfaceVersion 0.85, a BEHAVIOR bump on the v0.58 / v0.45 grounds:
pad_workspace.action=invite over local stdio now gets that refusal where it
got server_error. docs/api.md notes it on the invite route.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
GET /admin/mcp-audit gains user_name (the name, else the email) and
connection_name (the OAuth client's name, else the connection's
label; a PAT's name), resolved in one IN-list query per kind per page
and omitted when the id no longer resolves. The page shows them,
falling back to the short id. docs/api.md documents the route.
MCPAuditNames reads oauth_refresh_tokens and oauth_connections, so it
is classified in the TASK-3399 reader census as censusPerGrant: it is
addressed by request ids already present in audit rows and never lists
a user's chains.
Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
* 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
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 "".
* feat(collab): count and log watermark stamps that cover content-bearing rows (TASK-3541 step 0)
A browser tab's watermark stamp (BUG-3124 unit B) advances the flush
watermark on the tab's word that its document renders to the stored body.
When the advance covers content-bearing op-log rows, the server is taking
that word about rows it cannot read: a buggy, outdated or hostile tab can
make unstored edits read as flushed. TASK-3541 proposes verifying that case
on the materialize worker; step 0 measures how often honest traffic reaches
it first.
store.StampContentWatermarkIfCaughtUpCounted reports, in the stamp's own
transaction, the previous watermark and how many content-bearing rows the
advance covered. The HTTP door counts advancing stamps as
pad_collab_watermark_stamps_total{covers="content"|"view_only"} and logs the
content case at WARN (item, from/to, rows, user, user agent). No behaviour
changes; recovery's own stamps are not counted.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): drop the raw User-Agent from the stamp WARN (TASK-3541)
TestTextSafeHelpersAreUsedAtEveryCallSite (BUG-2803) refuses a raw
User-Agent read and pins the number of requestUserAgent sites. A user
agent does not identify the web bundle version, so the WARN keeps item,
from/to, row count and user id, and logs no user agent.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(oauth): the consent screen names Pad, the instance, the user's workspaces and the return host (TASK-1071)
The consent screen is the trust anchor the RFC 8707 relaxation leans on.
It had lost its 'your Pad workspaces' identification, the default 'All
my workspaces' choice showed no workspace names, and the app's
self-chosen name was unbounded and unlabelled.
- 'Pad · <host>' above the heading; the card reads '<app> is requesting
access to your Pad workspaces on <host>'.
- The app's name is labelled unverified, with the redirect host the
browser returns to.
- The first five workspace names (and N more) under 'All my workspaces'.
- Client names capped at 80 runes, whitespace collapsed, long tokens wrap.
- Tests pin the identification, the membership-only list, and the four
name/logo edge cases.
* fix(oauth): name a custom-scheme return with its scheme (TASK-1071, codex)
A content PATCH routed to an open tab (the designated applier) replaced
typing that was still on the wire. The BUG-3133 pending-edits check reads
the op-log when the PATCH arrives and cannot see it.
The server now marks the applier_request `guarded` unless the caller sent
overwrite_pending_edits. A tab whose edits may be missing
(WsProvider.editsMayBeMissing: unsent, catch-up owed, or sent without a
barrier ack) answers applier_refuse instead of applying. The server takes
that as final (no re-election) and the PATCH answers 409
content_not_applied with apply_reason unconfirmed_edits and content_landed
false; other fields land, as on every content_not_applied. Tokenless writes
are refused too, by ruling; overwrite_pending_edits is the only escape.
ToolSurfaceVersion 0.84 (BEHAVIOR bump). docs/api.md and docs/mcp.md updated.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B)
Lead ruling: a repeated or empty field key or select option is a broken
schema, and the web renders fields and options as lists keyed by them, so
one repeat blanked the view (BUG-3538). PR A made the web render such a
schema; this stops new ones being written.
- Collection create and update refuse, with 400 validation_error naming
it, a schema in which a field has no key, two fields share a key, or a
select field lists an empty option or the same option twice
(models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions).
WRITE check only: a stored schema with a repeat still reads, and an update
that sends no schema still applies. Live census on :7777: 0 of 213
collections affected.
- Dynamic Client Registration registers a repeated redirect URI once (it is
unauthenticated, and the connected-apps page lists URIs keyed by value).
- ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and
["1"] apart while both scanned to "1".
- Pins that convention commands and surfaces are deduped on read: they are
already (normalizeItemConventionMetadata); the census verdict that said
otherwise was wrong.
Not done, deliberately: refusing repeated app-manifest event names or
companion field keys. appmanifest.Parse also re-reads a STORED manifest on
upgrade, so a new refusal there would fail a read of an installed app; PR A
made both web lists render repeats.
ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls
they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400.
* fix(server): hold companion and template schemas to the key/option rule (TASK-3539)
Codex round 1: writers that bypass the collection handlers could still store
a schema that repeats or empties a key or option.
- An app's companion collection schemas are checked in the install/upgrade
preview, on the NEW manifest beside the reserved-key check, and refused
422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an
installed app's stored manifest.
- Every template and default collection schema the repo ships is checked by
a test (they seed through the store, not the handlers).
- The legacy-schema test also reads and creates items in a collection stored
with a repeat.
Workspace and artifact import, and the library option widening (which
already dedupes), are unchanged: refuse on write only.
* docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539)
Codex round 2: the current-version labels said v0.83, the history began at
v0.82.
* docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539)
Codex round 3: the intro still said v0.81, stale since before this change.
* fix(server): the option check covers select and multi_select only (TASK-3539)
Codex round 4: it refused empty or repeated options on every field type, but
only a select or multi_select's options are a rendered list. Another type can
carry a legacy options list (the old field DSL put a third part there), and
refusing it would block an unrelated schema save. Tested both ways.
* test(collections): the template schema check is independent of the validator (TASK-3539)
Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was
circular. It now uses its own check. Also records, at the write, why the
library option widening is not held to the validator: it only appends missing
words (never a repeat), and refusing would fail an item create over a legacy
schema.
* feat(mcp): count authenticated MCP requests by method and client (TASK-2307)
The go-sdk port would run its Streamable HTTP transport stateless, and a
stateless transport answers GET with 405, which ends the standalone SSE
stream. No unit test can say whether a real client opens that stream, so
before any engineering time goes into the port this measures it:
pad_mcp_http_requests_total{mount, method, client}, incremented on entry for
every authenticated request on /mcp and /mcp/chatgpt.
On entry, because pad_http_requests_total records a request when it ends,
and a GET holding an SSE stream open ends when the stream does. After
MCPBearerAuth, so refused requests stay with pad_mcp_preauth_denied_total.
The client label is a closed set read from the User-Agent (claude-code,
claude, codex, openai, cursor, windsurf, vscode, mcp-remote, mcp-inspector,
python, node, go, curl, okhttp, other, none), never the header itself.
* fix(mcp): read the User-Agent through requestUserAgent (TASK-2307)
The BUG-2803 call-site guard refuses a raw User-Agent read; the class is
computed from the sanitised value. The guard's exact count moves from 7
call sites to 8, re-pinned with the reason: like fromNativeShell, the counter
only substring-matches the header into a closed label and binds nothing.
* docs(mcp): name what the request counter admits, not 'every authenticated request' (TASK-2307)
Codex round 1: a caller refused by the per-token rate limit inside
MCPBearerAuth is authenticated and is not counted. That is the intended
boundary (a refused GET opens no stream, and the refusal is already counted
in pad_mcp_authz_denials_total{reason="rate_limited"}), so the comment, the
metric help and docs/deployment.md now say what is counted.
* test(mcp): a rate-limited MCP request is not counted (TASK-2307)
Codex round 2: the documented boundary (MCPBearerAuth's per-token 429 is
not an admitted request) had no test. Counted equals admitted; the mutant
moving the counter ahead of auth counts the 429 and fails.
suggested_next added one candidate per active plan, so an item that is a
child of two (a parent link to one, an implements link to the other) was
suggested twice. The web keyed that list by item_slug, and Svelte 5 throws on
a duplicate key during render in production builds too: the dashboard never
left its loading skeleton (cookie-sales, day 90; the link was made 10-02, so
this predates PLAN-3535).
- Server: one plan-children candidate per item; the first active plan names it.
- Web: the Up Next list is keyed by position. item_slug is not unique there
by design (two fired reminders on one item are two entries).
Tests: a server test with a task under two active plans (fails without the
dedupe), and a dashboard page test with a repeated item (stays on the
skeleton with the old key).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(search): include_archived on /search, and an is:archived search token (TASK-2864)
/search hard-filtered soft-deleted items on every query path, so the web
search's Show archived toggle could not reach a body: query. It now takes
include_archived=true, which widens the full-text query, the ref and number
lookups, the total and the facets alike; an archived result carries its
deleted_at. The permission filter is unchanged. An individual item grant
names live items only, so a guest sees an archived item only through a
collection grant, as on the item index's include_archived.
The web search box recognises is:archived (the token the TASK-1367 prefix
parser had prototyped and pulled): the collection page shows archived rows
for it as the toggle does, sends it on body: queries, and fences the
response on it; the command palette sends it and strips it from the text it
sends. The collection identity gate re-records 16 hashes: 14 units reach
the items view, which now reads the token, and none moved an await or a
check.
* test(search): drive is:archived through the palette and page bindings (TASK-2864)
Codex round 1: the parser tests could not see the call sites. The palette
test mounts CommandPalette and reads api.search on the body, cold-index and
content-supplement paths; the collection fence harness reads the body
search. Mutants (filter dropped, token left in the text on each path, the
page ignoring the token or not sending it) each turn a case red.
* test(search): an item-grant guest with a live grant sees no archived ungranted item (TASK-2864)
Codex round 2: the all-archived access cases short-circuit to empty access
before the store, so they could not catch include_archived widening a
genuinely applied item-grant filter. Mutant (skip the permission filter
when IncludeArchived) turns the new case red.
* docs(search): the archived filter comment does not claim facets cover direct hits (TASK-2864)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
content attention, suggested_next and by_role (store and visible recount),
and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.
A workspace bundle written before the key existed imports its system
collections as reference, as the migration marked them.
Guards: a three-phase behavioural fixture (work / reference / deleted) over
every excluded surface, each proven sensitive; and a name-based
classification of every lifecycle-helper call site in Go.
MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat: a collection can be reference material, not work (PLAN-3535 PR 1)
The switch, with no counting change yet (PR 2 server, PR 3 web).
CollectionSettings.tracks_work: false makes a collection REFERENCE
material; absent means work, today's behaviour, so old rows, old writers
and a client/server skew are unchanged. One Go predicate
(models.CollectionTracksWork) and one TS twin (collectionTracksWork).
- New collections: the creator's value, else system collections are
reference and others are work iff their done field can finish. New
template Docs collections are created as reference (Dave).
- Migration 135 / PG 107: existing Conventions and Playbooks become
reference; everything else, Docs included, stays work.
- Settings are written wholesale, so an update whose settings omit the
key KEEPS the stored value (CarryTracksWork), for every writer.
- A typed top-level `tracks_work` on POST/PATCH sets just that key
server-side. CLI: --tracks-work=true|false (a string flag, so false
survives stdio MCP). MCP: pad_collection tracks_work, v0.81. A
non-boolean is refused 400; the CLI checks the response, since an
older server ignores the member.
- Web: the Display tab's "Track as work" toggle; the editor now starts
its settings write from the STORED settings (buildCollectionSettings),
so keys it does not render (content_template, tracks_work) survive.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(store): carry tracks_work from the locked row; order the SQLite migration's JSON checks (PLAN-3535, codex)
The update now computes the carried settings from the row it re-read under
the write lock, so a concurrent settings write cannot be reverted by a carry
read before the lock. The SQLite migration evaluates json_type/json_extract
only inside a CASE guarded by json_valid, so malformed settings on a system
row cannot abort it.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(models): a collection whose options the default terminals close is work (PLAN-3535)
DefaultTracksWork required declared terminal_options, but every lifecycle
reader falls back to DefaultTerminalStatuses when a done field declares
none, so a `status: open, done` collection finishes items and was being
created as reference. Work now means one of the done field's options is
terminal by the same resolver the readers use. Found by PR 2's store tests,
whose fixture collection is exactly that shape. Also corrects api.md's
MCP version (0.81).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(store): compaction deletes exactly the rows it read (TASK-3532)
On Postgres a row can be allocated a lower id and commit after the
compaction job read the op-log. MAX(id) is unchanged, so the swap's
re-check passes, and the range delete (id <= MAX) removed a row the
snapshot never contained: its content was gone. Measured on Postgres
with the interleaving built from two transactions: range delete leaves
[snapshot] and the late row is lost; the exact id set leaves [late,
snapshot].
LoadMaterializeInput now returns the ids it read, and CompactItemOpLog
takes them and deletes exactly those (chunked IN lists). The dormant
prune needs no change: its DELETE is one statement that re-checks
dormancy itself, so it never deletes a row it did not evaluate, and an
uncommitted row is invisible to it.
A surviving late row sits below the snapshot's id. A tab whose cursor
passed it without receiving it is the same allocation-vs-commit cursor
gap the room's single-writer appendMu already prevents for live tabs;
it arises only if the per-item lock / no-room contract is broken.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): the compaction pass passes the ids it read (TASK-3532)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(store): refuse the compaction swap when a row the job did not read is left (TASK-3532, codex)
The exact-id delete kept a late lower-id row, but moving the watermark
past it would have marked it flushed though the snapshot omits it. The
swap now rolls back when any row remains after the delete; a later pass
reads that row too and compacts everything.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): a shared collection lists items in the owner's arranged order (BUG-3530)
The public share resolve called ListItems with no sort, so readers got
its default, pinned and then most recently updated: every edit reshuffled
a shared collection, and the owner's manual order never showed. It now
orders by sort_order, then oldest first, then id, the manual order every
collection page opens in (the web comparator, and the child lists since
BUG-3527). The share page renders the payload in the order it arrives.
buildItemSort gains `_id` for the last tie-break; spelled with an
underscore because a field may be keyed `id` and no field can be keyed
`_id`.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(web): the share page's order note says what the server now sends (BUG-3530)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5)
The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off).
U3: with a compactor set, each op-log GC tick first compacts every
dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the
snapshot is built on the materializer worker WITHOUT the item lock, then
swapped in under the lock with no room open, and CompactItemOpLog
re-checks the log is the one it read. The sweep then runs as
PruneSweepKeeping and keeps compacted logs. Anything not compacted (a
failed job, a refused swap, set-aside rows, another schema) is left to
the sweep, which deletes it as before. cmd/pad builds the materializer
before the GC starts and hands it over as the compactor only when
PAD_OPLOG_COMPACT=on and the materializer is enabled.
U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers,
which requires the snapshot row to exist) instead of force_refreshing it;
the tab replays the snapshot and its own edits merge. A failed check
refreshes.
Telemetry for the default flip (lead ruling): pad_oplog_compactions_total
{outcome}, pad_oplog_compaction_snapshot_bytes_total against
pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total.
U5: server tests (compact then keep, the no-compactor and failed-job
deletes, a room opened mid-job blocks the swap, a covered resume admitted
vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges,
or with compaction off is handed its text back).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex)
Compaction shares the materializer's single job slot with op-log recovery,
which carries unflushed edits, so a dormant backlog must not hold it for a
whole pass. At most 25 snapshot jobs a tick; items past the cap are kept
from that tick's sweep (deleting them would lose their chance) and
compacted by a later tick. The opt-in e2e's mode=off timeout now names
the likely cause.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531)
The merge property in CI, not only in the opt-in e2e (lead): over the
corpus, a snapshot of the first k rows followed by the rows after k
materializes to the full document (150 cases). Those later rows depend on
the prefix's structs (lone, 114 of them are pending), so this is the
dependency resolution a deleted-and-reseeded log fails.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(server): cap the MCP request body at 7 MiB, answering 413 (BUG-3534)
The MCP transport (mcp-go's StreamableHTTPServer) reads a POST body
whole into memory and has no cap of its own, so any caller, before auth,
could make the server buffer any amount. Both MCP mounts now refuse a
body over max(3 x the 2 MiB JSON cap, 6 x the artifact import cap) +
1 MiB with 413 and a JSON-RPC error, before auth and before anything
reads the body. A declared length is checked without a read; a chunked
body is read up to the limit and replayed.
The bound is derived: every tools/call's payload lands in a downstream
request already capped at 2 MiB of JSON (up to 3x once a client
\u-escapes it) or in an artifact (up to 6x as control characters
inside a JSON string), so no call that could otherwise succeed is
refused. It follows PAD_IMPORT_ARTIFACT_MAX_BYTES.
A sweep of every request-body reader (the body-reader census plus every
reader outside internal/server) found mcp-go's the only uncapped one.
* fix(server): cap the MCP body after auth, so an unauthenticated body is never read (BUG-3534, codex r1)
* test(server): pin that a 413 is audited; narrow the unread claims to the limiter (BUG-3534, codex r2)
* feat(server): a read-scoped token may run a playbook (TASK-2863)
POST /api/v1/workspaces/{ws}/playbooks/{ref}/run is side-effect-free:
it binds the caller's args and returns the body, storing nothing. It is
now the one POST a read or pad:read token may make, through an
exact-path allowlist in tokenScopeAllows (lead ruling, option A).
/playbooks/match stays refused, as do every write and the item
claim/release routes.
Both variable segments must be plain names (RFC 3986 unreserved
characters, not a dot segment). The MCP dispatcher checks the path
string it built from caller input before http.NewRequest parses it, so
without that a ref of "match?" passed as eight segments ending in run
and routed as POST /playbooks/match. Pinned through the real catalog
action and dispatcher.
* test(server): key the read refusal on TokenAuth's scope message (TASK-2863, codex r1)
* chore(mcp): ToolSurfaceVersion 0.79, a read grant may run a playbook (TASK-2863)
* feat(mcp): scope in the /mcp challenge, and a 403 insufficient_scope for read-only OAuth writes (TASK-2308)
The /mcp 401 challenge now names scope="pad:read pad:write", so a client
asks for the tiers the consent screen offers instead of falling back to
scopes_supported. resource_metadata stays the last parameter.
An OAuth connection whose grant cannot write that calls a write action
(any catalog action outside readOnlyActions) is now answered HTTP 403
with an error="insufficient_scope" challenge, before dispatch. Until now
the refusal could only be a permission_denied tool result, because the
dispatcher refuses after mcp-go owns the response. The challenge is what
lets a client re-authorize with the wider grant. A read-scoped PAT keeps
the tool error, since it cannot re-authorize, and the ChatGPT mount keeps
its own tool-result challenge.
Every write action was already refused for a read token, pinned by a
population test that drives every catalog action through the real
dispatcher under a write control and a read token. So nothing that
worked is refused now. pad_item.import is exempt there: it fails over
HTTP for every token (BUG-3533).
ToolSurfaceVersion 0.77 -> 0.78 (behavior bump).
* test(server): account for the /mcp scope pre-check's body peek in the reader census (TASK-2308)
* test(mcp): use the population test's result-text helper (TASK-2308)
* fix(server): challenge only tools/call requests mcp-go would run; check the import exemption's refusal (TASK-2308, codex r1)
* docs(mcp): the 403 covers a tools/call of up to 1 MiB (TASK-2308, codex r2)
Pins TASK-798's open scope 1d-1f and 2 through HTTP with real sessions:
a user Stripe demotes from pro to free, while their workspace is over the
free item and member limits (lowered via the plan_limits_free_* platform
settings an admin edits), can still LIST every item, READ and EDIT one
(d); cannot CREATE past the limit, refused 403 plan_limit_exceeded (e);
keeps every existing member's access, while a NEW invite is refused (f);
and the audit log carries plan_changed pro -> free, source stripe (2).
Mutants, each caught by the leg named: the limit check treating free as
unlimited (e, f); the over-limit check also refusing edits (d); the audit
call removed (2).
Pins summary, active items, active plans (including plan progress),
attention and suggested_next for the three ways a caller's view narrows:
a member with collection access 'specific' (2 of 4), a guest with item
grants only (siblings in the same collections must not leak), and a guest
with collection grants plus an item grant elsewhere. Hidden items are
shaped to land in every section if visible, and the leak check reads the
whole serialized response; each case also asserts the visible side per
section, and an owner control shows every item reaching its sections.
Mutants: dropping the main item query's visibility scope fails all three
cases; dropping the plan-progress child filter fails the two cases whose
plan is visible (3 children counted, want 1 / 2). No leak found.
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)
The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.
The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.
Also counts materializer give-ups (pad_materialize_giveup_total{kind}).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)
A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.
Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.
Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.
Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): take the server's data dir from one module (BUG-3523)
The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
assetlinks.json and apple-app-site-association name the official Pad app,
so Pad Cloud's edge router serves them (pad-cloud#67) and the binary must
not: pins that both paths answer the JSON 404, self-hosted and in cloud
mode.
A convention keeps its metadata twice: in the ordinary fields (trigger,
scope/surfaces, enforcement/priority, category, commands), which agents
and bootstrap read, and in the reserved `convention` key, which
item.convention prefers. Since BUG-3163 nothing but the create member can
write the reserved copy, so a fields_patch changing `trigger` left the
copy behind and the two disagreed.
UpdateItem now mirrors every mirrored key the write CHANGED into the
reserved copy, under the write lock, after the BUG-3163 carry guard. A
convention with no reserved copy is untouched. The merged copy is
validated with models.ValidateConventionMetadata; a write that would leave
it invalid is refused 400 validation_error (new, deliberate: storing an
invalid copy is what the reserved key exists to prevent).
The web Conventions page's edit form gains trigger / surface /
enforcement selects (sent as fields_patch with both spellings) and an
"Open item" link to the full item.
Drift measured before the change: 194 conventions, 5 with a reserved
copy, all 5 agreeing, so no reconcile follow-up was filed.
* feat(report): bucket the throughput series in the viewer's time zone (TASK-3524)
* feat(web): Insights asks for buckets in the viewer's time zone (TASK-3524)
* test(report): the tz parameter is echoed, and an unknown zone or Local is refused (TASK-3524)
* feat(auth): Deny and request context on the CLI sign-in approval page (TASK-2253)
* test(store): ScanNUL cost figures count the two cli_auth_sessions columns (TASK-2253)
* fix(web): state the password rule on every password field, and show its refusal there (TASK-2260)
* test(server): pin the password refusals the web forms route on (TASK-2260)
Both pages fetched full-content items (about 500 KB for a 50-item tag)
plus their own copy of the collection list, to render card summaries
the local index already holds. They now make no request of their own:
the tag page filters index rows by exact tag, and the starred page lists
the starred store's ids, kept in star order, against index rows. Both
read the collection list the workspace layout keeps.
The rules the server queries applied move with them, in
lib/items/localLists.ts: exact tag match (as the tags index counts, and
as Postgres matched; SQLite's LIKE folded case and treated _ and % as
wildcards), open judged by each collection's done field, and pinned then
most recent order. The starred store asks /starred?summary=true, a new
additive param that leaves out bodies.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* 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
* feat(import): a partly imported workspace is marked until its owner keeps or deletes it (TASK-896)
A bundle import that hits a DATA error after pad-export.json was imported
keeps the partial workspace (the rule is now written in docs/api.md:
transport failure, validation reject and pre-creation refusal roll back;
only a post-mint data error keeps). The 400 that said "kept" was the only
record, and a client may never read it.
- workspace_import_status (side table, SQLite 131 / PG 104): instance-
local, never exported, purged with the workspace.
- The keep path writes {status: partial, note}. The note is SANITIZED:
a fixed category and a correlation id (imp-...); the raw error goes to
the server log under that id.
- GET /workspaces/{slug} carries import_status (omitempty); the note only
for owners and instance admins. The list carries the status only.
- PATCH /workspaces/{slug} {"clear_import_status": true} clears it
(owner-only, like every workspace PATCH).
- pad workspace list marks it "partly imported" (text only; the JSON
mirrors the MCP workspace-list resource).
- Web: a banner on the workspace landing page and settings, with the
reason for owners, "Keep it" (clear) and "Delete workspace".
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(import): a rollback that cannot remove the workspace marks it too; the docs name what keeps (TASK-896, codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(import): only members who are admins read the note; the banner's note fetch is fenced on identity (TASK-896, codex r2)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(mcp): cloud refuses to start without an https origin for MCP OAuth; a blocked self-hosted MCP says so at startup and in health (TASK-1069)
* fix(mcp): pin the cloud origin combinations; health skips the setting read when MCP cannot be blocked (TASK-1069 codex r1)
* test(mcp): pad-cloud's production env (empty PAD_MCP_PUBLIC_URL, https PUBLIC_URL) starts, through the real env loading (TASK-1069)
* fix(server): the /me summary mirrors the admin bypass; every admin route proven open to an admin session (BUG-1926)
Dave (day 89): bearer tokens never reach platform administration; admin
needs a session. Verified on main: BUG-3361's refuseAPITokenForAdmin
already guards the whole /api/v1/admin group (the cloud billing
handlers' admin alternative included; the sidecar's secret is in the
body), and /audit-log has BUG-3334's refusal. Two gaps remained:
- /workspaces/{ws}/me answered "all" for any platform admin, but the
workspace middleware gives an admin no bypass over a bearer (an API
token, a CLI session: BUG-1616's isBearerAuth). Over a bearer it
advertised access the gates then denied. The admin early-return now
takes the same predicate, so a bearer is answered as the member the
admin actually is. Test: an admin who is a restricted member reads
owner/all over a browser session, and editor/specific over a PAT and
over a CLI session.
- BUG-3361's walk refused an admin PAT on every /admin route but checked
an admin session on one. It now also requires that every /admin route
accepts the admin's browser session and CLI session (lead ruling: a
`pad auth login` session counts; PATs are the line). The answer may be
400 or 404 for the placeholder params, never session_required or
"Admin access required".
Mutants killed: the /me early-return unfixed; the admin gate refusing
any bearer (which would lock CLI sessions out). The
PAD_ADMIN_API_TOKENS self-host escape hatch is untouched, pending Dave.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(server): the admin-session walk has a control per route, and the cloud billing routes are proven on a cloud server (BUG-1926 codex r1)
Codex r1: a 400/404 from placeholder params proved nothing about the role.
Each route now has a CONTROL: a non-admin session on the same request must
be refused for its role, so the role check runs before whatever the admin
then meets. The seven routes behind requireCloudMode answer 404 to everyone
off Cloud; the walk now requires that set to be exactly those seven, and a
cloud-mode test proves them: an admin PAT is refused (session_required), a
non-admin session without the secret is refused, and an admin's browser
and CLI sessions get past the secret-or-admin gate. POST bodies are an empty
object so that gate is the first thing that can refuse. Mutant killed:
handleSetPlan without its admin alternative.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
TestSSE_TASK2197_HeartbeatsArriveOnABusyStream drove load through the
HTTP API every 20ms and failed when a heartbeat came after fewer than 3
item events. On a loaded -race CI runner, item creation was slower than
the 200ms keepalive, so the premise failed (main 58be45b3: "only 2 item
events; the stream was not busy").
Events are now published straight to the bus every 20ms, which is cheap
under -race, and each heartbeat is judged on its own. It qualifies when
5 or more events came before it and the last came less than half an
interval earlier. A heartbeat that does not qualify (a stalled runner)
is skipped, not failed; the test fails only when none qualifies within
30s. A ticker reset by every write can never produce a qualifying
heartbeat: that mutant fails with 1,499 events and no heartbeat. Passes
-race -count=10 and -race -cpu=1 -count=20 while the internal/server
package ran alongside.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* feat(mcp): remote /mcp writes are agent writes, named by the client's declared clientInfo (BUG-2772)
A write over remote /mcp reached the handlers with no X-Pad-Agent and
was recorded as the human. It is now an agent write, named by the
client's own MCP clientInfo.name: a modern request's _meta clientInfo,
else the clientInfo its session sent at initialize (remembered by the
Mcp-Session-Id pad issued; mcp-go's session state does not survive
pad's stateless transport), else unnamed; never the human, never a
guessed name. Each initialize logs the declared client (name, version,
protocol, transport; no tokens).
A remote call's default lease holder is that name plus a short session
id (claude-code#3f9a12), inside defaultLeaseHolder so claim and
next/ready agree; two connections of one client no longer share a
lease. Folds TASK-3495. ToolSurfaceVersion 0.77.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(mcp): a refused claim reaches both transports as lease_held with its holder (BUG-3496)
* test(mcp): register WriteLeaseHeldError in the stdio marker vocabulary table (BUG-3496)
* fix(mcp): a remembered client name is bound to the account that declared it (BUG-2772 codex r1)
* fix(mcp): the remote lease holder carries eight hex digits of the session id (BUG-2772 lead ruling)
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: leases default to the agent's name, and next/ready leave out others' leased items (TASK-1809)
Several agents commonly share ONE account. A claim with no holder
defaulted to the account's email, so a second agent's claim matched the
first one's label and account and silently refreshed its lease: both
"won" (measured: two claims as rook and wren on one account both
answered 200 on the same lease).
- defaultLeaseHolder: the request's X-Pad-Agent name, else the email,
else the user id. An explicit holder still wins. The lease stays bound
to the account (BUG-3341). A second agent now gets 409 lease_held
naming the first, and cannot release it.
- next / ready (the dashboard's suggested_next) leave out items whose
live lease a claim by the caller would lose: same identity, the claim's
own predicate (store.ListForeignItemLeaseIDs, spelled positively
because NOT(...) over a NULL user compare is NULL), filtered before the
cap of three. Reminders are not filtered.
- Teaching: SKILL.md (claim before you start, pick another on
lease_held, release when done, register the session), instructions.md,
the pad_item holder param, and claim's CLI help, including that a
custom --holder's items leave your own next / ready.
- MCP ToolSurfaceVersion 0.76 (BEHAVIOR bump on the v0.70 grounds),
version.go entry, docs/mcp.md, README, golden bytes regenerated. The
holder description was trimmed to stay under the tools/list budget.
Remote /mcp sends no agent name, so there the default is still the email
(folded into BUG-2772). A lease taken before the upgrade is another
holder's to the same agent for up to its TTL.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(mcp): the catalog paragraph names v0.76 (TASK-1809 codex r1)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
internal/server's -race step measured 1692s after #1885, 62.7% of its
45m cap, and the package ran almost entirely serially. Of the 30 slowest
serial tests:
- three (the *IsNilInProduction seam checks) built their store with
store.New(":memory:"), which runs the whole migration chain, about 17s
each under -race, to check one field. They now use testServer(t),
which copies the once-built template.
- 25 more call t.Parallel(). Each was checked with a go/ast pass over
the test and every same-package function it reaches: none writes a
package-level variable, sets the process environment, or listens on
a fixed port. The shared caches two of them use are mutex-guarded or
sync.Once.
- two stay serial: TestRateLimit_DisabledByEnv uses t.Setenv, which
t.Parallel refuses, and TestBUG3474_RevalErrorFailsClosedOnWrite
writes the package-level collabMembershipRevalInterval.
Co-authored-by: Claude <noreply@anthropic.com>
* fix: item lists refuse a filter key that names no field (BUG-3480)
The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)
---------
Co-authored-by: Claude <noreply@anthropic.com>