Commit Graph

224 Commits

Author SHA1 Message Date
xarmian b8b610812c feat: a per-user time zone for calendar days, backend (TASK-4101 PR1) (#2337)
* feat(users): users.timezone column, internal/usertz and the conditional set-if-unset write (TASK-4101)

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

* feat(auth): timezone on /auth/me and the session user, PATCH with if_unset, tz-mismatch dismissal keys (TASK-4101)

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

* fix(server): compute overdue, standup and stamped dates in the viewer's zone, UTC when unset (TASK-4101)

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

* test(server): regenerate the cloud contract goldens for the additive timezone field (TASK-4101)

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

* fix(store): add the Postgres twin of the users.timezone migration (TASK-4101)

pgmigrations has its own numbering; 160 alone left the column missing on
Postgres, and make test-pg failed on 'column "timezone" does not exist'.

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

* feat(store): NUL invariant triggers for users.timezone (TASK-4101)

The census exists so no single validator is the only guard (ruling
1431c980 item 1): protect the column with its own post-160 trigger file
instead of excluding it.

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

* fix(server): decode a dismissal key once, and test the viewer zone through /me/needs (TASK-4101)

A double-encoded %252F reached the handler already decoded once (net/url
keeps no RawPath for it), and the second PathUnescape read it as a slash.
Unescape only when chi routed on RawPath. Adds the unescape cases, the
too_many_dismissals code assertion, an HTTP-level test that a zone ahead
of UTC makes an item overdue on /me/needs, and the api.md notes for the
cap and the additive dashboard/standup fields (ruling 1431c980 items 2-5).

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

* fix(server): the standup test expects the UTC day, and the zone note names no PR2 surface (TASK-4101)

The standup date is the viewer's zone, UTC when unset; the test still
compared against the host's local day and failed on any non-UTC host
when the two dates differ. The note pointed at the web settings and
`pad auth timezone`, which land with PR2 (ruling 00877b60).

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-28 14:51:33 -04:00
xarmian d36f11cf40 fix(server): a workspace whose owner no longer resolves never yields a '//' path (BUG-4050) (#2334)
* fix(server): a notification whose workspace owner no longer resolves has no deep link, never a '//' path (BUG-4050)

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

* fix(needs): a needs row whose workspace owner no longer resolves has an empty path and no link, never '//' (BUG-4050)

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

* fix(needs): fail the request on an owner lookup error instead of reading it as no owner (BUG-4050)

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

* test(needs): an owner lookup error must answer 500, not merely not-200 (BUG-4050)

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

* test(needs): rebind the owner-corruption UPDATEs so they run on Postgres (BUG-4050)

The raw ? placeholders were a syntax error under make test-pg, so both
postgres subtests failed before reaching the handler.

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-28 10:02:06 -04:00
xarmian 58c53d68eb feat(web): agent roster on /messages with Start chat, and a sign-out redirect (TASK-4044) (#2324)
* feat(web): ensureConversation client call, carried from the chat window work (TASK-4044)

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

* feat(web): chat roster, ensureDM and a reference-counted presence poll (TASK-4044)

Ruling 018faf64: counter clamped at 0, DM keys filtered to kind dm, ensure fenced by identity.

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

* feat(web): roster rows with Start chat on /messages, and a sign-out redirect (TASK-4044)

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

* test(web): census entry for the roster each (TASK-4044)

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

* fix(web): roster keeps DMs by the server's 'direct' kind; presence owners are balanced (TASK-4044)

The roster filtered on kind 'dm', which the server never writes
(migration 158 allows 'direct' and 'group'), so against a real server
every conversation dropped off the roster. Fixtures are corrected to
'direct', the Q3 row now uses a group conversation, and an e2e spec reads
the server's real value. The chatInbox afterEach asserts no presence
timer is left armed before one safety drain, so a leaked owner fails.

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

* fix(web): roster ensure does not navigate after the page is left (TASK-4044)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-26 08:35:18 -04:00
xarmian 6402eb3055 fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082) (#2315)
* fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082)

stdio's pad_item patch was passThrough, so edits reached the CLI unread:
its JSON decode into bodypatch.Edit.LineHint (an int) refused a string
hint that remote coerces ("5"), and took a JSON number of 2^53 or more,
which mcp-go's float64 decode may already have rounded, as a line number.
Remote has validated each edit's line_hint since BUG-4065, inline in
mapItemPatch.

That loop is now editsWithLineHints, called unchanged by mapItemPatch and
by a new stdio action, actionItemPatch. On stdio:
- a valid hint is forwarded as its int64;
- a refusal is validation_failed with remote's message, naming the edit
  ("(in edits[1])");
- null is absent, and a non-object edit keeps its path.
Neither transport modifies the caller's map.

Tests (lead ruling 86931478): one table, bug4082Cases, runs every input
through both transports and requires the same forwarded edits or the same
refusal (C1). Remote's BUG-4065 rows stay green unchanged. A row on each
transport pins the caller's map (C2). Red on main: stdio forwards "010",
1.5, 2^53, true and {} raw while every remote half passes.

ToolSurfaceVersion 0.105 -> 0.106 (C3), with the changelog in version.go
and docs/mcp.md; the README and the instructions.md heading follow. The
wire goldens follow in the next commit.

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

* test(mcp): stdio's line_hint refusal checked on its own; actionItemPatch's input pinned directly (BUG-4082)

Mutants on 928bc363f: H2 (the helper drops the edit index) turned only
remote red, because stdio was compared with remote's message, which
changed alike. Stdio now also checks the expected text. S2 (the action
writes the new edits into its input) survived: the fan-out handler hands
actions a private copy, so the handler-level C2 row cannot see it. A row
calls actionItemPatch directly.

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

* test(mcp): wire goldens follow ToolSurfaceVersion 0.106 (BUG-4082)

Version string only: the capability version and the instructions heading
in the two initialize goldens. With 0.105 and 0.106 normalised, the old and
new lines are identical.

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

* docs(mcp): name the layer that rounds a 2^53 line_hint (BUG-4082 r1)

r1 hunter Low, ruled FIX (comment and docs only): the actionItemPatch
comment, the v0.106 changelog in version.go and docs/mcp.md said "the
decoder may already have rounded" a hint of 2^53 or more, which read as
the CLI's JSON decode, which is exact. They now name mcp-go's float64
decode of the request.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 14:08:59 -04:00
xarmian acc45070f6 feat(server): deliver chat inbox refs to the owner's own web stream (TASK-4044) (#2314)
A cookie-session stream that opts in with ?chat_inbox=true now receives
kind "chat" notices addressed to its own user, so the global web chat
view can refetch through /me/conversations instead of polling.

- Pref: honoured only for a non-bearer, non-app session; ignored (not
  refused) on any bearer/PAT stream. No armed required.
- Predicate: a notice with an empty target agent key delivers iff the
  stream has chat_inbox and the target user is the stream user. The
  existing agent clause is unchanged; the two cases are disjoint.
- Publish: one human-target notice per human member, plus a self-notice
  for a human author (the store's recipient list leaves the author out),
  deduplicated on the delete path. Human notices are ref-only: excerpt,
  reply_to_seq and target agent key are cleared at publish time. Agent
  notices keep excerpt and reply_to_seq for the monitor.
- docs/api.md: the param, the payload, the ignored-on-bearer rule, and
  the agent-path "never match" sentence scoped accordingly.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 12:00:28 -04:00
xarmian 729ae5f69c fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070) (#2313)
* fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070)

On stdio, a string for a "number" param reached the CLI verbatim. An int
flag parses it base 0 (pflag's ParseInt(s, 0, 64)), so "010" was 8, "0x10"
16, "1_000" 1000 and "+5" 5; pad_chat delete's positional seq parses base
10, so "010" was 10. Remote refuses all of them (decimalIntString,
BUG-4065). A JSON number was formatted and forwarded whatever its size,
where remote refuses one of magnitude 2^53 or more as possibly rounded
(wholeInt64, BUG-4061 / ba981cb4f).

makeFanOutHandler now runs rejectMalformedNumbers after
rejectUndeclaredKeys. For every top-level param the catalog declares
"number", a string or float value goes through numericInput, remote's
rule, and a failure is refused with validation_failed naming the param,
before any action runs. A value that passes is forwarded unchanged. Other
types keep their path, where the CLI's own parse refuses them.

Tests: rows at a flag param (chat show limit), a positional (chat delete
seq) and a custom handler (chat send reply_to), and a population walk
over every catalog number param (20 today). Red on main: the walk and the
door rows fail there, e.g. "010" dispatched as --limit 010.

ToolSurfaceVersion 0.104 -> 0.105 (lead ruling e522d1be), with the
changelog in version.go and docs/mcp.md; the README, the instructions.md
heading and the wire goldens follow. The goldens move by the version
string only. The nested edits[].line_hint is BUG-4082.

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

* test(mcp): a valid earlier number param does not end stdio's check (BUG-4070)

M5 (the loop stops after the first number param present) survived: every
row sent one number param. A since that passes, then a malformed limit,
must still be refused naming limit.

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

* test(mcp): pin where a non-string, non-float number value goes on stdio (BUG-4070 r1)

r1 row 1 (hunter Low, ruled FIX): nothing pinned the claim that other
types keep their path. Rows for true, false, null, [1] and {} at the flag
door (chat show limit) and the positional door (chat delete seq) assert
the new check passes each through unchanged, and pin the text it is
forwarded as, which pflag's ParseInt or cmd_chat.go's ParseInt refuses.

The claim's wording was narrow: pad_item list and history read limit
through intParam, which refuses these types before the CLI does. The
catalog.go comment, the version.go changelog and docs/mcp.md now name
intParam too.

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

* docs(mcp): null is absent at intParam, not refused (BUG-4070 r2)

Comment and changelog only (lead ruling on r2's Codex Low): the r1
rewording said intParam refuses a non-string, non-float number value, but
numericInput reads null as absent, so a null pad_item list or history
limit takes its default (v0.104). The rejectMalformedNumbers comment, the
v0.105 changelog in version.go and docs/mcp.md now say so; elsewhere the
CLI's own parse refuses all of these types.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 10:12:24 -04:00
xarmian cba880ca17 fix(config): warn on a relative PAD_DATA_DIR / PAD_DB_PATH; anchor install-refresh's marker and config reads (BUG-4066 phase 1) (#2308)
* fix(config): warn once per process on a relative PAD_DATA_DIR / PAD_DB_PATH; install-refresh anchors its marker and config reads (BUG-4066 phase 1)

config.Load takes PAD_DATA_DIR verbatim, or PAD_DB_PATH's dirname, with no
filepath.Abs, so every process resolves a relative value against its own cwd:
a CLI and a server started in different directories silently use different
data (database, attachments, key). Phase 1 (lead ruling 4b4cb4ff) leaves the
resolution unchanged and makes it loud:

- One helper, envDataDir(), is the only reader of the two variables (Load and
  userConfigPath both use it). A relative value warns ONCE per process,
  naming the absolute path this process resolved, that a CLI and a server in
  different directories will use different data, and that a future release
  refuses it (TASK-4072). A literal '~' gets a hint. A CLI prints it to
  stderr; `pad server start` logs it through slog; `pad watch --stream
  --for-session` (the plugin monitor) suppresses it.
- install-refresh anchors the refresh marker and config_value the way 2a
  anchors the log (${SERVER_CWD:-$PWD}), so the marker is written and
  cleaned up in one place (it used to leak under the caller's cwd), and warns
  before anything is read or stopped.
- docs/deployment.md: both variables must be absolute; relative is
  deprecated and warns.

Tests: the warning rule (and Windows, GOOS-guarded), the marker and the
warning-before-stop end to end, config_value at function level, and
child-process tests for the monitor's silence and the server's slog line.

Harness guard (after this unit's draft test stopped the box's shared :7777
server twice): every install-refresh exec in internal/buildtools now passes
requireSafeScriptEnv first, which refuses an environment whose resolved port
is missing (the script would fall back to 7777) or is 7777, whose HOME or
data dir is the real user's or outside a test temp dir, or whose port answers
on a LAN address without this test's random stub token.

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

* fix(config,mcp,scripts): the relative data-dir warning names the server's path and stays out of agent-facing errors; harden the test guard (BUG-4066 r1)

r1 rulings (c0ef54e8):
- install-refresh's warning now runs once SERVER_CWD is captured (still
  before the marker and the stop) and names the anchored path
  (${SERVER_CWD:-$PWD}/<dir>, a leading ./ stripped); when the caller's cwd
  differs it also names the caller's reading. It used to name $PWD/<dir>,
  which the server does not use.
- `pad mcp serve` checks the data dir once on its own stderr; the stdio
  dispatcher's `pad` children get PAD_INTERNAL_NO_DATA_DIR_WARNING=1, so the
  warning is not folded into a failed tool call's agent-facing error. The
  variable is internal (code-documented only), so users cannot silence the
  deprecation. Shell completion no longer prints the warning mid-Tab.
- Test guard: refuses a relative data dir with a config-sourced port (the
  script reads that port from the server's cwd, which the guard cannot see);
  compares symlink-resolved temp paths (macOS); asks for the stub token on
  the LAN address that answered; compares 7777 numerically; and a source-scan
  test fails if any install-refresh exec in the package is unguarded.

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

* fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

- PAD_DB_PATH warning names the resolved data dir (Dir of the value), in Go and in install-refresh.sh; the script normalises . and .. in pure shell.
- Shell completion (__complete, __completeNoDesc) sets the silent sink in main.
- MCP resource and bootstrap fetchers exec with dispatchChildEnv; a population scan pins every pad exec in internal/mcp.
- Harness guard: numeric 7777 refusal before any dial (asserted by message), PAD_HOST must be empty or loopback, temp-dir check scoped to this test, hardened and self-tested source scan, marker-presence probe in the restarted stub, child timeout, sink reset.

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

* fixup! fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

test(buildtools): the config-file 7777 row asserts the numeric refusal by message too (BUG-4066 r2 item 12, same class)

* test(buildtools,cmd): close the m15, m24 and m28 survivors (BUG-4066 r2)

m24: a scanner self-test case whose variable never runs in its function,
with a guard in a later function, so the function boundary is what flags it.
m28: insideTestTempDir refuses another test's directory, and admits a
subtest's, which t.TempDir names <Parent><sub...> with no separator.
m15: the --workspace completion func no longer sets its own sink. main()
silences every completion request, which made that line dead code.

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

* fix(cli,buildtools): an auto-started server does not inherit the warning silencer; the guard reads the config port like the script (BUG-4066 r3)

Item 2: an MCP tool-call child carries PAD_INTERNAL_NO_DATA_DIR_WARNING=1
and is often the process that auto-starts the server, so that long-lived
server never logged the relative data-dir warning. serverStartCommand drops
that one variable from the spawn env, and keeps every other one. It is the
only `server start` spawn in internal/cli; a source test pins both facts.

Item 7: the harness guard took the leading digits of config.toml's port, so
"8080abc" read as 8080 where install-refresh rejects it and falls back to
7777. It now reads the first port line the way config_value does, and a
non-numeric value is refused as unreadable.

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

* fix(scripts): install-refresh's relative-dir warning is one line; the completion tests run main() (BUG-4066 r4)

Item 1: when the server's cwd differs from the caller's, the warning printed
a second "Warning:" line naming the shell's-directory reading, and a third
for a leading ~. Phase 1's contract is one warning per process, as
config.Load prints it, so both now ride in the one line. The test asserts
both paths sit in that line and that it prints once.

Item 6: the completion child cases called silenceWarningsForCompletion
themselves, so deleting main()'s call passed every test. They now set
os.Args and run main().

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 01:13:51 -04:00
xarmian bc2a32895e feat(chat): monitor reply quotes and a session-start DM ensure (TASK-4043) (#2309)
* feat(chat): render monitor reply references and excerpts (TASK-4043)

* feat(chat): ensure owner-agent DM once per monitor process (TASK-4043)

* docs(chat): describe monitor quotes and DM startup (TASK-4043)

* test(chat): accept startup DM ensure in recovery fixture (TASK-4043)

* fix(chat): wait a default when a 429 has no Retry-After (TASK-4043 r1)

* fix(chat): bound Retry-After before it can overflow the wait (TASK-4043 r2)

A Retry-After too large for a time.Duration (>= 9223372037s) wrapped
negative and made the ensure retry hot. Seconds are now bounded before
the multiply; zero, negative, absent and overflowing values all wait
the 120s default, and the largest value that fits is honoured.

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

* test(chat): keep the Retry-After table portable and hermetic (TASK-4043 r3)

The overflow rows are int64 and skip where int cannot hold them; the
rows run with a temp HOME.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-24 22:55:32 -04:00
xarmian 82f81ee828 fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065) (#2306)
* fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065)

numericInput read any value outside float64, int, int64 and json.Number as
absent, so a string, a bool or an object for limit, offset or days was
silently replaced by the default. itemExpectedSeqParam had its own switch with
the same fall-through, so a string expected_seq sent the content write out
UNGUARDED.

Now only null is absent:
- A string is admitted only as a plain decimal integer (an optional '-' and
  digits), parsed exactly and bounded like a JSON number (|x| < 2^53), so "10"
  is 10 and "123" is a guarded write. That matches what stdio's CLI flags and
  the buildQuery doors already accept (lead ruling af4bff40, Q1). Every other
  string, and every other type, is refused with validation_failed naming the
  parameter.
- Every Go integer type is admitted exactly; an unsigned value above MaxInt64
  is refused.
- itemExpectedSeqParam, pr_number and chatSequence share that one rule
  (pr_number's bound becomes the exclusive 2^53; chatSequence's float64 gets
  the 2^53 bound and an exact json.Number). Their messages and hints are
  unchanged.
- item patch validates expected_seq and edits[].line_hint locally, and the
  collection and role update doors validate sort_order, so each answers
  validation_failed naming the field rather than the server decode's 400.
- buildQuery routes the native integers through the same check; strings are
  still forwarded verbatim.
- The item-list resource's limit flag is refused when malformed, not
  defaulted.

ToolSurfaceVersion 0.103 -> 0.104, with the changelog in version.go and
docs/mcp.md; the README, instructions.md heading and wire goldens follow.

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

* fix(mcp): numeric query keys and leading-zero strings are refused, not defaulted or re-read (BUG-4065 r1)

r1 found two Mediums (lead ruling 5a06782e):
- The buildQuery doors forwarded a bool, an object or a non-integer string
  for a numeric key (item backlinks, item search, item context's budget,
  project activity, project standup and changelog). The server Atoi'd it and
  silently kept its default. Each route now names its numeric query keys
  (routeSpec.numericQuery, and the explicit lists at the direct buildQuery
  callers), and those keys go through numericInput. TestBUG4065_NoRouteForwards
  ANonNumberForANumberParam walks every remote route against every parameter
  its tool declares "number", so a new numeric key cannot be left off.
- "010" was 10 here and 8 on stdio (pflag parses base 0). A plain decimal
  string is now ^-?(0|[1-9][0-9]*)$, so a leading zero is refused. The stdio
  side is BUG-4070.

Also: an end-to-end row through the pad_item tool handler, value rows at the
chat doors, an array value at every door, absent-means-default, the resource
flag's negative row, stale expected_seq comments, and the v0.104 changelog
corrected in version.go and docs/mcp.md.

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

* test(mcp): the numeric-param population walk cannot pass vacuously; uintptr admitted (BUG-4065 r2)

r2 found the r1 population walk could pass on broken code (lead ruling
b9025d7c): it looked for the raw bad value under the catalog name only, so a
rename, a path segment or a value dropped as absent was invisible, and its
"x"-filled inputs tripped unrelated validation, so most routes refused
before reading the number. With a route's numericQuery dropped, the walk
alone stayed green.

The walk now uses a differential oracle from a curated valid base: each
(route, number param) is dispatched with the param absent, as "7", and as a
bad value. If "7" changes what is forwarded, the route reads the param and a
bad value must be refused naming it with no write; otherwise a bad value must
forward exactly what the absent case does. An inconclusive case fails. The
derived read-set is pinned in bug4065ReadSet, so a route that stops reading a
param altogether cannot be reclassified and go green.

Also: uintptr is admitted like the other unsigned types; an empty string and
false are named as refused in the v0.104 changelog; rows for a missing key, a
null expected_seq (still unguarded), "" and a 20-digit string at every door.

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

* test(mcp): false, the catalog limit doors, and the chat null claim (BUG-4065 r3)

r3 rulings (9bd7fa2d), all Low:
- false is fed to the unit table, the door table and the population walk;
  the changelog says it is refused, and nothing pinned it.
- pad_item.list and pad_item.history read limit through intParam before the
  dispatcher; TestBUG4065_CatalogLimitNonNumbers refuses "abc", true, false,
  an object, "010" and "" there naming limit, and uses "7".
- The v0.104 changelog says pad_chat's numeric params still refuse a present
  null, as before, with the same message; "only null is absent" read as if
  they did not.
- A misnamed row; the refused pr_number rows assert no request went out.

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

* docs(mcp): reflow a joined comment; state the population walk's limit (BUG-4065 r4)

Comment-only (lead ruling 86de95f1): my r1 edit joined two comment lines in
dispatch_http_advanced.go into one; and the walk's doc comment now says
conditional reads are outside it, with edits[].line_hint covered by the door
table.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 20:04:54 -04:00
xarmian ba981cb4f2 fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061) (#2303)
* fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061)

numericInput converted a float64 with int64(x), so limit 1.5 read as 1, -2.7
as -2, and a value outside int64 as an implementation-defined number. A
json.Number that did not parse read as absent, so the parameter was dropped.

- numericInput returns an error for a fraction, a value outside int64 (2^63
  is out), and an unparseable json.Number. intParam names the parameter.
- All 10 caller-input sites refuse with validation_failed: the route mappers,
  item history, attachment list, and the pad_item.list and pad_item.history
  catalog actions. pad_item.list's documented truncation of a fractional
  limit to the default is reversed by ruling; zero still means the default.
- actionItemGet's read of the server's own seq stays lenient (pinned).
- itemExpectedSeqParam refuses a float64 outside int64 and gets the same
  json.Number refusal, rather than dropping the token (an unguarded write).
- Comments note the unreachable float32 paths in formatNumber and
  chatSequence.
- ToolSurfaceVersion 0.103: the parameters are declared "number", so the
  refused inputs were valid under the advertised shape. Goldens move only by
  the version string.

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

* fix(mcp): buildQuery refuses a number that is not a whole int64 (BUG-4061 r1)

Round 1 (Codex Medium, and the hunter's standup/changelog question): the
query-string doors built by buildQuery (item backlinks, item context budget,
project activity, item search, project standup and changelog) forwarded
"1.5", and the server parsers read it as absent and used their default.
buildQuery now refuses with the key named, and its four callers return
validation_failed (lead ruling on Q2, option (a)).

- TestBUG4056BuildQueryNumbersUnchanged: the 2.5 and 1e19 rows are now
  refusals, superseded by that ruling. The notation rows stand, and
  json.Number "1e6" forwards as "1000000".
- jsonNumberInt64: a whole json.Number in exponent form ("1e6") is
  accepted. Int64() alone refused it. Used by numericInput and
  itemExpectedSeqParam.
- The v0.103 changelog door list is corrected. The instructions.md and
  README version headings follow 0.103, which 54fc02855 missed. Goldens
  move only by the instructions heading.

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

* fix(mcp): read a json.Number exactly, and envelope a refused expected_seq (BUG-4061 r2)

- jsonNumberInt64 fell back to Float64, which rounds: 9007199254740993e0
  became ...992 and 1.0000000000000001e0 became 1, changing the caller's
  number again (Codex r2 Medium, hunter r2 Low). It now parses exactly with
  big.Rat. A length bound (64) and an exponent bound (|e| <= 64) keep a
  literal like 1e999999999 from costing a huge allocation.
- dispatchItemUpdate returned a refused expected_seq as a raw error, which
  the transport surfaces as a protocol failure. It is now a validation_failed
  envelope, as every other refused parameter is and as the v0.103 entry says
  (hunter r2 Medium). The fraction half predates this bug.

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

* fix(mcp): refuse a float64 integer above 2^53, which the decoder has already rounded (BUG-4061 r3)

Codex r3 Medium: the MCP JSON decoder turns expected_seq 9007199254740993
into float64 9007199254740992 before any check runs, so a whole, in-range
value was forwarded as the wrong number. wholeInt64 now refuses |x| > 2^53
("beyond the exact integer range of a JSON number"). This applies to the
float64 path only, per the lead ruling on Q3. A json.Number is parsed exactly
and keeps the full int64 range. chatSequence and formatNumber are unchanged;
the chat seq limit is tracked on BUG-4065.

Also corrects the chatSequence comment (hunter r3 L3): a json.Number in
exponent form is refused there, not parsed.

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

* fix(mcp): refuse a float64 of magnitude 2^53 or more, not only above it (BUG-4061 r4)

Hunter r4 Low, ruled a fix by the lead's correction of Q3: 2^53 is the
rounded image of 2^53+1, so a float64 2^53 cannot prove it is the number
sent either. wholeInt64 now refuses |x| >= 2^53, so the largest accepted
float64 is 2^53-1, the safe-integer bound. json.Number keeps the exact
int64 range. Test rows: 2^53-1 accepted, 2^53 and -2^53 refused.

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

* fix(mcp): read a json.Number only if it is a plain decimal literal (BUG-4061 r5)

Hunter r5 L1, ruled a fix: jsonNumberInt64 filtered big.Rat-only syntax
with a deny-list ("/xXpP"), which missed the 0b and 0o prefixes, so a
hand-built json.Number "0b101" was read as 5. An allow-list,
isDecimalLiteral, now admits a sign, digits with at most one '.', and at
most one e/E exponent with an optional sign. Refusal rows: 0b101, 0o17,
0x10, 1_000, 4/2. 12e+0 is accepted.

Hunter r5 L2: the standup and changelog refusal hint is neutral ("Pass
numeric parameters as whole numbers.") instead of naming days for any key.

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

* test(mcp): cover the remaining query doors and the lenient seq at 2^53 (BUG-4061 r7)

Test rows and one comment, no runtime change (lead ruling on Q7):
- Door rows through Dispatch: item context budget and project activity
  limit (the generic route mapper's other numeric query keys), and a
  json.Number limit on item list (h2 L1).
- actionItemGet's lenient server-seq read at float64 2^53 leaves Seq zero
  (h2 L2).
- chatSequence's comment says a float32 or exponent-form json.Number
  producer should be routed through numericInput's check, and that this
  path does not do that today (Codex r7 Low).

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

* test(mcp): the door table requires the refusal clause to start with the key (BUG-4061 r8 Q8)

The door table matched a bare "<param> " substring, which the hint text
also satisfies on the two doors whose hints repeat the key (expected_seq,
item history's limit). A door naming the wrong key in its message passed.
The check now requires ": <param> must be " or ": <param> is ", the
validationFailedResult rendering of "<cmdKey>: <msg>".

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

* test(mcp): pin expected_seq's 2^53 bound and the exact fraction refusal; narrow the float32 note (BUG-4061 r9)

- itemExpectedSeqParam's float64 branch had no 2^53 row of its own, so a
  looser conversion there passed. Rows for 2^53-1 (accepted) and ±2^53.
- The json.Number fraction-past-float64-precision row asserted a prefix the
  "in range" refusal also has; it now asserts the fraction refusal itself.
- formatNumber's float32 note: no non-test code passes a float32, but its
  own tests do.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 15:50:52 -04:00
xarmian d3f87d205a feat(chat): delivery, framing and the accept-instructions opt-in (TASK-4041) (#2305)
* WIP TASK-4041: add ref-only chat delivery and explicit acknowledgements

* WIP TASK-4041: implement local consent and fetched monitor delivery

* WIP TASK-4041: document chat delivery and session instruction consent

* WIP TASK-4041: rename chat arm files (_arm.go is a GOARCH build constraint)

* test(chat): fix delivery fixture and strengthen consent guards

* test(chat): pin replay publication guard after mutation audit

* test(chat): make review guards detect their named regressions

* docs(chat): clarify bounded excerpts and defer unwired command guidance

* test(chat): cover empty agent pairs and the Redis envelope

* fix(chat): seed gap fills and acknowledge contiguous output

* test(chat): restore gap regressions for amended ack rule

* fix(chat): advance contiguous delivery across own rows and tombstones

* test(chat): cover final tombstones, ack retries, and sync recovery

* fix(chat): acknowledge final contiguous tombstone events

* fix(chat): retry contiguous delivery ack on duplicate events

* fix(chat): reseed delivery baselines on sync required

* fix(chat): wire session consent commands and reuse chat client

Pin arm/disarm execution through the real root tree and restore the deferred skill and monitor guidance.

Wiring adds both local consent commands to the cmdhelp Document supplied to MCP. Preserve TestChatArmHasNoMCPPath's failure for the requested ruling; do not change MCP goldens or versions.

* test(mcp): pin chat direction dispatch across transports

* docs(cli): add chat direction command examples

* test(chat): expose delivery memory loss and unsafe acknowledgements

* fix(chat): retain process delivery memory independently of fill cursors

* docs(chat): describe local delivery memory and failed-summary recovery

* test(chat): pin both monitor adapter fetch limits

* docs(api): state chat owner-message framing in the present tense (TASK-4041)

The TASK-4039 provenance caveat described TASK-4041 behaviour as future
work. It is implemented now: say what is true and point at the framing
rules under GET /api/v1/events/stream.

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

* test(chat): expose failed resync, late retraction and identity collisions

* test(chat): keep push delivery when monitor identity is unavailable

* fix(chat): preserve closed recovery and retraction ownership per identity

* docs(chat): clarify recovery, page cap and identity memory limits

* test(chat): recover after a transient monitor identity lookup failure

* fix(chat): retry transient identity failures on chat references

* test(chat): require deferral until a safe server summary

* fix(chat): defer events until a server delivery floor is available

* test(chat): pin per-conversation server floors and caller boundaries

* feat(chat): expose caller delivery floor on conversation GET

* fix(chat): fetch per-conversation floors before monitor delivery

* test(chat): require bounded synchronous first-contact delivery and backoff

* fix(chat): fetch first-contact floors synchronously with bounded backoff

* test(chat): remove redundant break from synchronous stream fixture

* test(chat): cover stream deadlines, retry windows and cookie agent isolation

* fix(chat): bound stream HTTP calls and back off failed conversations

* docs(chat): clarify direction consent and stream delivery limits

* test(chat): preserve independent dedupe eviction and retry clocks

* fix(chat): require bearer authentication for stream agent addressing

* test(chat): cover r10 tombstone, summary and arm-generation regressions

* fix(chat): fill tombstone gaps and back off unread summaries

* fix(chat): bind direction consent to a random arm generation

* test: pin r11 retractions and session direction cleanup

* fix: retract owed chat deletions before network backoff

* fix: report session action when direction removal fails

* docs: clarify chat direction rearming and connect state

* test(chat): bound HTTP deadline failures and cover unresolved retractions

* docs(chat): clarify immediate retractions and session re-arm direction loss
2026-10-24 15:16:30 -04:00
xarmian 0ed34ea9f7 fix(mcp): integral numbers keep decimal notation on both transports (BUG-4056) (#2300)
* fix(mcp): preserve decimal numeric arguments (BUG-4056)

Share numeric formatting across CLI dispatch and HTTP queries, and validate remote chat sequences directly with int64 bounds. Preserve the existing tool surface and wire goldens.

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

* fix(mcp): numeric array elements, playbook tokens and buildQuery's default share formatNumber (BUG-4056, r1)

Lead ruling on r1 (tier A from here): toStringSlice spelled each JSON number
element of an array with fmt.Sprint, so a repeatable flag or variadic
positional given 1000000 got '1e+06'; playbook raw_args tokens used %v, so a
string- or ref-typed argument would bind '1e+06'; buildQuery's default
branch kept fmt.Sprint for float32. All three now use formatNumber.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 08:47:40 -04:00
xarmian 5308535c02 fix(chat): one 64-rune bound for agent keys (BUG-4055) (#2297)
* fix(chat): share the 64-rune agent key bound (BUG-4055)

Use the attribution and presence name limit for chat headers and stored keys. Refuse overlong explicit body identifiers while normalizing self-declared headers, and cover Unicode round-trips and presence agreement.

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

* test(chat): an agent names itself by the normalised key on ensure and unread (BUG-4055, r1)

Lead ruling on r1: the agent-caller equality checks (ensure body agent_key,
unread ?agent=) were untested now that headers truncate. A 70-rune header's
agent must pass the 64-rune key and be refused the raw name. The shadowed
rune-count case in the ensure switch now says it is shadowed and by which
gate.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 02:50:45 -04:00
xarmian 51b0bb4de9 feat(cli,mcp): pad chat CLI and pad_chat MCP tool (TASK-4042) (#2295)
* feat(cli): add global pad chat commands (TASK-4042)

Add conversation targeting, inbox and message commands with caller-owned idempotency and precise read markers. Preserve API responses for JSON output and use cwd workspace links for reference context.

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

* fix(cli): add chat examples and isolate command tests (TASK-4042)

Satisfy the CLI help-tree example contract and restore package-level flags after command regression tests.

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

* feat(mcp): expose global pad_chat on both transports (TASK-4042)

Add six chat actions with shared target parsing, uniform authenticated dispatch, precise read markers and structured unnamed-agent errors. Exclude agent chat from ChatGPT and pin the direction guard. Bump the surface to 0.102, update documentation and goldens, and shorten duplicated prompt prose within the fixed budgets.

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

* test(mcp): label global chat scope judgments (TASK-4042)

Mark the membership-based global-chat exemption reasons explicitly, as required by the allowlist coverage guard.

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

* fix(chat): preserve unread gaps and successful outcomes (TASK-4042, seat r0)

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

* fix(chat): restore agent rules within prompt budgets (TASK-4042, seat r0)

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

* fix(chat): keep scope fixture contiguous with read marker (TASK-4042, seat r0)

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

* fix(chat): guard read boundaries and direction inputs (TASK-4042, r1)

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

* fix(chat): validate MCP bodies and report mark outcomes (TASK-4042, r1)

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-23 22:08:29 -04:00
xarmian f8756a0b5b feat(server,cli,plugin): agent presence — agent name, worker id and busy/idle activity (TASK-4040) (#2293)
* test: pin agent presence and worker-scoped session activity

* feat: carry agent presence and track activity by user and worker

* docs: describe worker presence and session activity states

* fix: include hook activity examples in CLI help

* fix(cli): keep session activity hooks silent on failures

* fix(server): tolerate activity read failures in session lists

* docs: place activity keys after presence registry details

* fix(plugin): wire silent Claude Code activity hooks

* docs: explain session busy-state hook limits

* fix: bound the entire session activity command

* fix: batch session activity reads across workers

* docs: describe detached activity reports and timestamp ordering

* fix: order worker activity atomically by client timestamp

* fix: detach hook activity reports with captured timestamps

* test: reproduce detached activity losing registered identity

* fix: capture activity identity before detaching the reporter

* test: isolate activity registry home on Windows

* docs: clarify activity ordering and hook failure behavior

* fix: bound activity reordering to ten seconds

* fix: replace activity records with corrupt client timestamps

* test: prove activity parent exits while child response is held

* fix(session): derive activity reorder window from report bound

* fix: replace non-string Redis session activity records

* test: cover cleared and expired session activity ordering

* test: cover older timestamp after legacy activity

* test: cover corrupt activity nanos and malformed JSON

* test: verify activity child deadline and process isolation

* docs: clarify read-only tokens cannot report hook activity

* test: strengthen session activity liveness and timing guarantees

* test: pin malformed null session activity timestamps

* test: harden session activity detach regression checks

* test: skip activity process-group check when ps is unavailable

* test: tie activity parent-exit bounds to the HTTP timeout

* test: bound activity liveness checks by the child HTTP deadline

* test: require ps for process-group checks in Go CI
2026-10-23 19:46:02 -04:00
xarmian 1c6c9740ab feat(server): chat HTTP API and access model (TASK-4039) (#2292)
* feat(server): chat API core: conversations, messages, read marker, unread, limits (TASK-4039, part A)

Add membership-gated chat HTTP endpoints, credential-derived authorship,
private wire DTOs, replay and read markers, and per-agent/user limits.
Document the access and provenance model and pin it with handler guards.

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

* feat(server): chat read-time visibility for context workspace and item refs (TASK-4039, part B)

Authorize context workspaces on send and at read time, omitting hidden
contexts and projecting item refs through the caller's current access.
Keep unknown and denied refs indistinguishable, with unresolved overflow
past 50 distinct refs per response. Pin the wire behavior and document it.

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

* fix(chat): a retry replays after context loss; sentinel for context refusal; source, ref cap and empty body (TASK-4039, r1)

F1: Add a read-only replay lookup scoped to the author's current conversation
membership, and replay identical HTTP retries after context access loss with
the same ChatSend charge as normal replays.
F2: Use a context refusal sentinel wrapping ErrInvalidConversationInput while
preserving the store error text and HTTP response body.
F3: Derive chat source from the actor's bearer signal, preserving in-process
remote MCP attribution; assert the router rejects stray Basic authorization.
F4: Count only distinct refs with a workspace toward the 50-ref lookup cap.
F5: Accept empty conversation-ensure bodies for agents while humans still
require agent_key. Document the lookup cap and remaining-token header.

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

* fix(chat): refs capped at 200 with refs_truncated; ensure charges only a create (TASK-4039, r2)

L1: Cap deduplicated response refs at 200, retain the 50-lookup limit,
report refs_truncated on list/send/replay, and stop extraction after a drop.
Document plain-text fallback and cover 6,000 refs, duplicates, and false flags.

L2: Validate agent keys and check the owner's inbox before charging ChatDM.
Reuse is free; only creation attempts spend a token. Document the concurrent
create race and cover 40 reuses, invalid keys, and the 30/hour creation cap.

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

* fix(chat): a human's agent_key must already be a normalised agent name (TASK-4039, r3)

Claude-Session: https://claude.ai/code/session_01LL3BV3H4zot6QrMQMsVfVc
2026-10-23 16:07:40 -04:00
xarmian fdbba1a31e feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035) (#2290)
* feat(cli,mcp): pad project catch-up and pad_project action=catch-up, read-only (TASK-4035)

Read-only wrappers over GET /workspaces/{ws}/catch-up on the CLI, local and
remote MCP, and the ChatGPT surface (catch_up). Mark caught up (PUT /me/seen)
stays web-only (ruling 380aa793): no verb, flag or client method moves the
cursor. ToolSurfaceVersion 0.100 -> 0.101, additive. Existing tool
descriptions were reworded to fewer bytes (no rule, caveat, refusal or ref
dropped) so tools/list keeps 901 B of headroom under an unchanged ceiling.

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

* test(cli,mcp): ChatGPT catch_up in the wire golden and the leak sweep (TASK-4035)

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

* fix(cli,mcp): catch-up json skips the typed decode; the re-indent claim is corrected (TASK-4035)

--format json prints the server's JSON re-indented without the typed
decode; table and markdown keep it. The MCP description names reopened.

Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
2026-10-23 00:05:52 -04:00
xarmian 8204f03d25 feat(server): push dispatch timings are configurable, and a bad value refuses startup (TASK-3939) (#2288)
* feat(server): push dispatch timings are configurable, and a bad value refuses startup (TASK-3939)

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

* docs(deployment): push gateway env vars, dispatch timings and the cleartext warning (TASK-3939)

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

* fix(server): the lease refusal names the cause, and an overflowing margin is refused (TASK-3939)

SetPushDispatchConfig wraps errPushLeaseTooShort with the resolved lease,
send timeout and margin; configurePush names both knobs and appends it.
A margin within sendTimeout of MaxInt64 is refused before the sum can
wrap. Whitespace-only values stay malformed and are now documented and
tested; values are otherwise uncapped.

Claude-Session: https://claude.ai/code/session_01D6M9onL7HhMovW8DZbs6f5
2026-10-22 20:38:25 -04:00
xarmian 6cfe322461 feat(push): GET /me/notifications/{id} enrichment and the real-enqueue mention leg (TASK-3939 part B2b) (#2284)
* feat(push): GET /me/notifications/{id} enrichment and the real-enqueue mention leg (TASK-3939 part B2b)

The endpoint resolves a push's nid through the dispatcher's own
notificationAccess gate; every denial is one byte-identical 404 and the
recipient's notification settings are never consulted. The mention leg
drives a real comment write through the real enqueue into a dispatch tick.

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

* fix(push): enrichment refusals are tested through the real router, docs corrected (TASK-3939 B2b codex r1)

A disabled account is refused 403 account_disabled by RequireAuth for every
id, not 404; docs/api.md said otherwise. Tests now go through ServeHTTP: a
disabled user's real and unknown ids are byte-identical, and an installed
app's padapp_ token (401 on this route) answers identically for both. The
direct-handler tests are gone. docs/api.md also states a 500 is an
operational error, not a denial.

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

* test(push): an unpinned API token reads its own notification through the router (TASK-3939 B2b codex r2)

Pins the docs/api.md claim that any authenticated person, an API token
included, can resolve their own notification.

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

* fix(push): enrichment returns 500 on an owner or actor lookup error (TASK-3939 B2b codex r3)

GetUser errors were ignored, so a store failure could answer a partial 200
with an empty path owner or actor name. A missing user is still (nil, nil)
and an empty field; only a real error is now a 500, as docs/api.md says.

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
2026-10-22 12:04:46 -04:00
xarmian d97c528d1c fix(store): push token ownership follows a registration sequence, not updated_at (TASK-4031) (#2282)
Two users sharing a push token were ordered by updated_at (second
resolution) then id (a random uuid), so a same-second reclaim could go to
the previous registrant. Add push_devices.reg_seq (SQLite 157, Postgres
125): unique, advanced by every register (INSERT and the same-user
in-place UPDATE), untouched by the password-rotation session rebind. The
backfill orders by (updated_at, id) so no current owner changes.
LivePushDevices compares reg_seq.

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
2026-10-22 05:25:18 -04:00
xarmian ba3a0ab69b fix(store): a same-user push re-register keeps the device's created_at and row (TASK-4027) (#2281)
Ownership of a token shared across users now follows updated_at, so a
reclaiming user is not outranked by the previous registrant.

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
2026-10-22 02:27:28 -04:00
xarmian aad3c35bdb fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024) (#2279)
* fix(cli): pad item list --format json pages to completion when --limit is not set (BUG-4024)

`pad item list --format json` stopped at the CLI's own 200 default and said
so on stderr only, so a consumer parsing the array saw 200 of 204 rows with
no signal in the payload. Ruling: with --format json and no --limit (cobra
Changed, not a value compare) the CLI follows X-Pad-Next-Offset to the end,
sends no limit and prints no notice. The payload stays a plain array; no
API, schema or MCP change.

An explicit --limit, including --limit 0, keeps today's behaviour and its
stderr notice. --limit 0 has always meant "the default" (clamped to 200),
not "no limit"; a test pins that. Table and markdown are untouched.
--offset with no --limit pages from that offset to the end.

One private pager (listAllPages) now backs ListAllItems and
ListAllCollectionItems (the old names stay as wrappers) and new *Report
variants taking a start offset and returning the first page's undeclared
filter keys, so the BUG-3480 warning still fires. A non-advancing next
offset, or more than 1000 pages, is an error and a non-zero exit with a
message naming the cap, never a partial array. This also applies to the
existing callers (reconcile, github status, project standup), which used to
return a short list for the non-advancing case.

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

* fix(cli): the list pager requires has-more pages to be full and contiguous (BUG-4024 R2-F1)

listAllPages accepted any next offset greater than the current one, so an
empty has-more page, or a next offset that skipped rows never returned,
followed by a has-more=false page, ended the loop on a partial list with no
error. The server sets has-more only when it trimmed the probe row
(handlers_items.go listItemsPage), so such a page holds exactly `limit` rows
and next-offset == offset+len(items) with len(items) > 0. The pager now
requires exactly that and otherwise returns an error with nil items. The
check subsumes the old `NextOffset <= offset` test.

Tests: empty page (advancing and non-advancing next offset), a jump, and one
short, at the client level; the jump at the `pad item list --format json`
level (non-zero exit, empty stdout).

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

* fix(cli): the list pager reads a negative start offset as 0, as the server does (BUG-4024 R3-F1)

Claude-Session: https://claude.ai/code/session_01UiMM6MkrYr1nkuaNkSmkeD
2026-10-21 22:34:38 -04:00
xarmian ca184cff96 feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2) (#2274)
* feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2)

GET /me/needs takes sort=importance and sort=simplicity: the primary score
then the secondary, both highest first, then the item id; a row a scorer left
unscored sorts last. The cursor carries the key as score/score2 (absent under
age, so an age cursor is unchanged), and a score cursor missing either, or
out of -1..100, is invalid_cursor. docs/api.md documents the sorts.

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

* fix(needs): r1 — differing-order sort test, degraded-page and missing-score docs, comments (TASK-3959, part 2 r1)

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

* fix(needs): r1 — range-check every cursor field, pin the as_of clamp (TASK-3959, part 2 r1)

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

* fix(needs): a cursor the server minted is always followed; key times clamp to int64 nanoseconds (TASK-3959, part 2 r2)

r2 found that r1's key/created_at range check (> 0, before 2200) refused
cursors the server itself mints: a pre-1970 date, or a zero time from an
unparseable stored created_at, gives a non-positive UnixNano, and paging
dead-ended at that row under every sort. A check may refuse only what the
server could never mint, so key and created_at are no longer checked.

A time outside int64 nanoseconds (a zero time, or past 2262) also did not
round-trip: UnixNano wraps, so an age cursor named a different instant from
the row's key. KeyFor now clamps both key times to that range, so the sort
and the cursor see the same instant.

The table drops the key/created_at 400 rows, keeps negative values as
followed, and adds the age-cursor-with-a-score rows (r2 L2). A new test pages
pre-1970, zero, unparseable and year-3000 boundary rows through every sort
at limits 1 and 2.

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

* fix(needs): the cursor docs say only what the server could never mint is refused; the odd-time test runs on Postgres (TASK-3959, part 2 r3)

r3 (both reviewers): docs/api.md still listed the key/created_at range
check removed in r2. The sentence now lists exactly what is refused, says the
key times are positions that any value of follows, and says a key time
outside about 1677 to 2262 is clamped, so such rows tie on it (hunter L3).

The odd-boundary test wrote created_at through the raw *sql.DB with a ?
placeholder, which is not rewritten for Postgres; it now writes literal SQL,
as setCreated does, and passes on both backends. It reports each sort with
Errorf, and its comment says what each sort proves: every sort that the
cursor's key times are followed, and only age that the clamp holds (r3 M2).

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

* fix(needs): the cursor docs list the version check; the real-scorer sort test runs on both backends (TASK-3959, part 2 r4)

r4 hunter L1: the sentence listing exactly what a cursor check refuses left
out a cursor of another version (v). r4 hunter L2: the test that sorts by the
real scorers through the router ran on SQLite only; it now runs under
needsBackends, and passes on Postgres.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD
2026-10-21 12:38:23 -04:00
xarmian 5aa9e49ab4 feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b) (#2273)
* feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b)

Registers the two needs scorers in server.New. Each fills needs.Score
{Value, Hint, Why} on every row, reading through the part 1a batch reads
and a request-scoped memo, so one request does each read once.

- Importance: round(100 x (.25P + .20B + .20D + .15S + .10H + .10A)).
  B is a breadth-first fan-out over blocks links (visited set, depth 5,
  cap 99). It counts and expands only targets visible under the scope and
  still open, and returns a count, never refs. S is 0 until the
  high_stakes eval (TASK-4011, ruling 440f5c63), so importance is capped
  at 85 and not renormalised.
- Simplicity: (3 - effort)/3 x 100. The effort level is the argmax and the
  hint comes from a fixed table (cap 40). With no decision runner, effort
  is the text heuristic, with no hint.
- needs.Viewer gains Now, set from asOf, as the scorers' clock.
- Why emits only a fixed vocabulary, listed in docs/api.md; a test holds it
  to that list.

The PLAN-3535 guard classes the new IsTerminalItem call as "keep".

Rulings 99c2bcfe and 6901f2f5 on the TASK-3959 trail.

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

* fix(needs): pass the workspace to the scoped decision and job reads (TASK-3959, part 1b)

Follows part 1a's round-1 fix, which scopes LatestDecisionsForItems and OwedDecisionJobs to a workspace. The needs memo passes its own workspace id.

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

* fix(needs): fail closed on scope and unknown collections, local-day deadline, request ctx (TASK-3959, part 1b r1)

Leak test through the router for a restricted viewer, fail-closed fan-out
pins, isOpen fails closed for an unknown collection, the question-hash read
takes the request ctx, D measures the server-local day like the dashboard,
H is clamped to [0,1], and docs/api.md's degraded prose names the real causes.

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

* fix(needs): pin every importance input, overdue parity with the dashboard, snapped rounding (TASK-3959, part 1b r2)

Per-input contribution table; overdue decided by itemOverdue; weighted sum
snapped before rounding; injectable location instead of mutating time.Local;
restricted-viewer score compared exactly to the hidden-item-deleted score;
docs: overdue rule and the WorkspaceFlagsForItems degraded path.

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

* test(needs): pin the deadline key pick, priority case and unreadable effort; scores at as_of (TASK-3959, part 1b r3)

- The importance parts table pins the earlier of due_date and end_date
  (either key earlier, end_date alone) and the case-insensitive priority.
- A current effort answer whose level cannot be read falls back to the
  text heuristic with no hint; a test now pins it, and docs/api.md says so.
- docs/api.md: scores are computed at the request's as_of, which the
  cursor carries to later pages.

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

* test(needs): as_of reaches the scorers through the handler; threshold, key and date pins (TASK-3959, part 1b r4)

- A request with a cursor scores at the cursor's as_of, end to end through
  ServeHTTP (page 1 at the wall clock is 33; a cursor a week after
  waiting_since is 28).
- "judged to need a human" from the attention threshold up, not below.
- Unreadable effort keys -1 and x fall back to the heuristic, like 7.
- A date too short to parse scores D=0.
- blocks() states the lock it runs under.

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

* test(needs): the as_of check is its own test, so the binding test stays one claim (TASK-3959, part 1b r4)

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

* test(needs): a failed read degrades both real scorers; short-date overdue; effort without a medium (TASK-3959, part 1b r5)

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

* docs(needs): every scorer sharing a memo passes the same rows (TASK-3959, part 1b r6)

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

* fix(needs): needs-human weighs only an answer the inbox's flag rule holds current (TASK-3959, part 1b r7)

H and "judged to need a human" read needs_human through the flag path
(WorkspaceFlagsForItems at threshold 0 over the rows), so an answer made
stale by an edit, with no refresh owed, no longer weighs or says so while
the router does not flag the item. Effort keeps question-hash currency;
docs/api.md states both rules. EffortMediumMinProb is pinned at exactly .5.

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

* test(needs): a needs-human answer held while its refresh is owed; the asked-row exception in the docs (TASK-3959, part 1b r8)

Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
2026-10-21 10:46:44 -04:00
xarmian d76f06d199 feat(server): push device registration and notification settings API (TASK-3938) (#2269)
* feat(store): push device register/delete, SessionInfo.ID, one-tx session rotation (TASK-3938)

RegisterPushDevice is one transaction: delete rows on either unique key,
evict the oldest at the 20-device cap, insert. RotateUserSessions mints the
replacement, rebinds the caller's own devices, deletes the other sessions,
and fails closed to revoke-all.

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

* feat(server): push device and notification-settings routes, push_available (TASK-3938)

Session-only PUT/DELETE /me/push-devices/{connection_id}, GET/PUT
/me/notification-settings, push_available on /auth/session,
PAD_PUSH_GATEWAY_URL, and all credential-change rotations through
RotateUserSessions. docs/api.md updated.

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

* test(server): classify GET /me/notification-settings in the compress census (TASK-3938)

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

* fix(server): the pinned-token refusal on notification settings takes BUG-3982's shape (TASK-3938 r1)

* fix(server): notification settings leave the pinned-token refusal to the BUG-3982 gate (TASK-3938 r1)

* test(store): concurrent registers at 19 devices hold the push-device cap (TASK-3938)

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

* test(store): make the concurrent-register cap test able to fail on Postgres (TASK-3938)

Reuse sessions across rounds and widen the count-to-insert window with a
test-owned slow insert trigger, so removing the user-row lock overshoots
the cap on every run.

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

* docs(server): a workspace-pinned token on /me/push-devices gets the BUG-3982 refusal, not session_required (TASK-3938 r1)

* fix(server): push_available goes to a signed-in caller only (TASK-3938 r2)
2026-10-21 06:49:54 -04:00
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

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

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

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

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

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

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

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

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

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

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

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

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

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

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00
xarmian dd412a022d feat(needs): GET /me/needs, the cross-workspace needs-you inbox and its router (TASK-3920) (#2253)
* feat(store): reads behind the cross-workspace needs inbox (TASK-3920)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 15:23:54 -04:00
xarmian 164768dab8 fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861) (#2255)
* fix(server): an invite with an unknown role answers 400, not 500 (BUG-3861)

The invite door passed any role straight to CreateInvitation, so a value
outside owner/editor/viewer reached the invitations CHECK constraint and
answered 500. It now answers the role-change door's 400 ("role must be
owner, editor or viewer") and writes nothing. Both doors that take a
workspace role from the caller call one check, requireKnownWorkspaceRole,
so the answer cannot drift between them.

Population: those two are the only doors that take a workspace role from
request input (non-test Go under internal/ and cmd/); every other writer
passes the literal "owner".

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

* fix(server): check the invite role after the door's existing refusals (BUG-3861 r1)

Every answer the invite door gave before is unchanged; the only one that
changes is the old 500. Pinned by a setup-window case with a bad role.

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

* fix(server): check the invite role just before CreateInvitation (BUG-3861 r2)

The check ran ahead of the already-member 409, so a bad role turned it
into a 400. It now follows every refusal the handler makes; only the store's
own pre-insert refusals (disabled inviter, workspace deleted mid-request)
lose to it. Pinned by an already-member case with a bad role.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 14:30:33 -04:00
xarmian 393d986ea9 fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975) (#2252)
* fix(auth): a browser-approved CLI token gets its own lifetime (TASK-3975)

The CLI token minted by /auth/cli/sessions/{code}/approve was dated by the
approving web session's created_at, so approving from an 85-day-old session
left the token a 5-day window under the 90-day cap. It is now minted with no
issuedAt: created_at is the approval, 30-day sliding window, 90-day cap from
approval. No TTL lengths change.

The widening is accepted: a stolen near-expiry web session can now mint a CLI
token good for up to 90 days from approval. Step-up-on-approve is filed as its
own idea.

created_at doubled as the sign-in recency anchor for confirm-only account
delete (BUG-3336), so dating the CLI token from approval would have made it
read as a fresh sign-in. CLI-kind sessions can therefore no longer satisfy
confirm-only delete recency (403 reauth_required): approach (b), which keeps
the BUG-3336 property with no migration. Passwordless accounts confirm from
the web. The credential-change rotation keeps the replaced session's
created_at (pinned by a new test); its 7d-vs-30d TTL is BUG-3978, untouched.

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

* fix(auth): only a browser session approves a CLI sign-in (TASK-3975 r2)

With the approval now minting the CLI token with its own lifetime, a
CLI-kind session that could approve could mint its own successor: a CLI
bearer creates a pending code with POST /auth/cli/sessions, approves it
with itself, and receives a new token with created_at = now and a fresh
90-day cap. Repeated before each cap, that chain keeps a CLI credential
(or a stolen one) alive forever. Before this PR the approval inherited
the approver's created_at, so the chain stayed bounded by the original
sign-in. Reproduced on the previous head: an 85-day-old CLI bearer got
200 and a session aged 0s.

The accepted widening is ONE hop from a web session; the chain never
was. /approve now refuses any approver that is not a web-kind session
with 403 session_required, before anything is minted. The web client
approves on its cookie and is the only caller. docs/api.md says so.

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

* test(auth): the rotation cap pin starts from a 7-day web session (TASK-3975)

#2254 (BUG-3978) makes a credential-change rotation keep the replaced
session's own sliding window. TestTASK3975_RotationKeepsParentCap
rotated the 24-hour session oauthOnlyCloud mints, so after that change
the 24-hour window bound before the 90-day cap it exists to pin, and it
failed on the rebased tree (expiry now+24h, want now+5d). The parent is
now a 7-day web session, so the cap five days away is what binds.
Test-only.

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

* docs(api): who may approve a CLI sign-in, by credential (TASK-3975 r4)

A CLI-kind bearer gets 403 session_required, a CLI-kind session sent as
a cookie is no credential (BUG-3350) and gets 401, and an API token gets
403. The sentence said any CLI-kind session got 403, and called the
web-kind check "browser" though a web session sent as a bearer passes.

Claude-Session: https://claude.ai/code/session_01JRXHP9ZVfEN3YEuzmucxP3
2026-10-20 13:30:31 -04:00
xarmian 096bde6954 feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980) (#2258)
* feat(apps): admin Apps toggle, paused webhook status, app-name attribution, developer docs (TASK-3980)

Polish from the Connected Apps walkthrough, items 1-6:

1. Console > Admin > Settings gains an Apps section on the existing
   GET/PUT /api/v1/admin/apps (draft-then-Save, read-only on Cloud); the
   Apps tab's off-state copy now names it.
2. The install preview drops the stale "will activate when this server
   supports them" notice: events and item actions are live.
3. An item an app's bot created reads "Created ... by <app name>" instead
   of "by agent"; a person acting through an app keeps "by <person> via
   <app>". created_by storage is unchanged.
4. An install's webhook status is derived as "paused" while the install
   is not active, matching the delivery gate, instead of reading "active".
5. The context-code redeem route is /api/app/v1/context/redeem in both
   code comments that named it under /workspaces/{ws}.
6. docs/apps.md: a developer guide to the manifest, install and redeem,
   tokens, the app API, webhooks, item actions and lifecycle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r1 FIX-OR-CITE on the four untraced areas (TASK-3980)

Every claim in webhook delivery, install refusals, manifest limits and
comment author_kind is now backed by a file:line or corrected:

- The text limits (title, publisher, description, name, label, path)
  are bytes, not characters.
- id and slug say their start and end rules.
- Reserved slugs refuse with 422 invalid_manifest.
- Unknown keys are refused in the manifest and collection schemas, but
  not inside the free-form config_schema.
- Confirm documents 409 install_review_mismatch.
- Redeem documents its limits: 10 a minute per address, 5 per install.
- author_kind says when each value occurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

* docs(apps): r2 Lows and FIX-OR-CITE on the OAuth paragraphs (TASK-3980)

r2 Lows:
- Apps off: the apps list answers 200 available:false, and every other
  app route answers 404.
- Preview failures add invalid_base_url 400, artifact_fetch_failed 502
  and artifact_destination_missing 422.
- paused takes precedence over the held notice. Enable restores the
  hook's earlier status. Only rotate takes the secret away.

FIX-OR-CITE on delegated consent and OAuth (24 claims):
- Do not discover the token endpoint from the well-known document. It is
  gated on MCP OAuth and describes MCP clients.
- Only reads are re-validated before send; writes and downloads re-check
  at their own points.
- Name the three OAuth doors that stay open with MCP off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012tVNjc1RAdLdRN1ae77u4U

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:29:59 -04:00
xarmian 07215d4953 fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970) (#2249)
* fix(plugin): session start runs nothing; presence and the arm check wait for the first use of Pad (TASK-3970)

Dave: "the plugin should not be doing anything when a session is started."
session.start now only registers /pad-pane and restores the pane. It no
longer runs `pad session register` / `should-arm`, no longer spawns
pad-monitor.sh, and no longer starts the 5s refresh timer, which now runs
only while the pane is open.

wake() runs once per session, on the first pad Bash command, a Pad MCP tool
call, or opening the pane. The first pad command waits for registration
(never for should-arm), because register mints the worker id that leases key
on. A .pad.toml push.auto_arm opt-in is therefore read at first use.

Tests: session start runs no process and asks for no timer; non-pad Bash
wakes nothing; wake runs once; an MCP tool wakes it; registration happens
before the first pad command runs. Prose swept (skill, monitor script,
CLAUDE.md, registry comments); docs/plugin-releases.md gets the stuck-0.3.x
remedy.

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

* fix(plugin): a pad command inside a shell string or after an assignment wakes the session (TASK-3970)

Hunter r1 F1: `bash -c "pad ..."`, `sh -c 'pad ...'` and `X=... pad` missed
the first-use match, so that call ran before presence was recorded. The
class now takes quotes and `=`; a false positive only wakes early.

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

* fix(plugin): pad as a whole word, checked against a table of shapes (TASK-3970)

Round r2 found two more missed shapes (Codex: `\pad ...`; hunter: a bare
`pad` before a quote, `)` or `;`). Rather than add a third case, the match
is now `pad` as a whole word, not inside a longer name. watch.test.ts
tables 25 shapes that run pad and 8 that do not, and asserts that
registration ran before each matching command reached the tool
(Codex r2 note).

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

* fix(plugin): a Pad MCP tool wakes the session whatever the server is called (TASK-3970)

Codex r3: the matcher required "pad" in the server part of the tool name,
so a Pad server registered under another name (work-tracker) was missed.
It now matches any mcp__<server>__pad_* tool. The tests table Pad tools
under four server names and four tools that are not Pad's, and add ./pad
and ../bin/pad to the Bash shapes.

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

* fix(plugin): the pane refreshes after a Pad MCP edit under any server name (TASK-3970)

Hunter r4: the pane-refresh matcher still required "pad" in the server
name, so it disagreed with the wake matcher fixed in 25c39f2aa. Both now
take any server. The wake matcher also allows digits in the tool part.
New pane test.

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

* docs(plugin): the file header describes session start and wake (TASK-3970)

Codex r5 nit: the header still said the mod only listens until /pad-pane
runs. Comment-only.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-20 00:26:16 -04:00
xarmian 94c6d3cc52 fix(brand): the favicon's gaps tighten to match the mark (TASK-3974) (#2248)
pad-mark-small moves from 4px tiles with 2px gaps (gap/tile 0.50) to 4px
tiles with 1px gaps and a 1px inset (0.25), the grid pad-favicon-tile
already used. It stays pixel-aligned: the master's own 0.19 renders its
sub-pixel gaps as smeared half-tones at 16px 1x in Chromium and WebKit,
and this grid stays crisp at 1x, 2x and 3x. icon.svg, favicon-16/32 and
favicon.ico (16/32/48) are re-exported with export.mjs; the outputs of
unchanged masters came out byte-identical, so the export reproduces the
committed set. docs/brand.md records the change and why.


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:32:39 -04:00
xarmian 16aaaf5450 feat(collections): awaits_human collection setting (TASK-3919) (#2244)
* feat(collections): awaits_human collection setting (TASK-3919)

A collection whose items are asks of a person (IDEA-3906): the needs-you
router will treat an open, assigned item in it as "asked". Stored as
settings.awaits_human like tracks_work: absent means false, a settings write
that omits it keeps the stored value, a typed member on create and update
sets it alone, and a non-boolean is refused 400. No migration (A3 default).

- models: CollectionAwaitsHuman[JSON], Carry/Validate/Set, on shared bool
  setting helpers that the tracks_work functions now use too
- store: create and update apply it; update carries it under the row lock
- CLI: pad collection update --awaits-human[=false], checked on the response
- web: "Items are asks of a person" toggle on the editor's Display tab
- docs/api.md

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

* fix(collections): read awaits_human per key, not through CollectionSettings (TASK-3919)

A plain bool field made a stored non-boolean awaits_human (an import is not
validated) fail the whole CollectionSettings unmarshal, so a reference
collection read as work. CollectionAwaitsHumanJSON now reads the one key.
Also corrects the --awaits-human comment: the flag shape is not MCP-safe.

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

* test(store): assert the returned settings after turning awaits_human off (TASK-3919)

staticcheck SA4006: the UpdateCollection result was assigned and never read.

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

* fix(cli): pad collection create takes --awaits-human (TASK-3919)

Codex r2 P2: only `collection update` had the flag, so marking a
human-tasks collection took a second call. create now sends the typed
member and, like update, refuses to report success when an older server
ignored it.

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd
2026-10-19 19:58:46 -04:00
xarmian e598bc4f64 feat(decision): effort, effort_medium and high_stakes on the attention set, staged; drain-paced backfill (TASK-3921) (#2236)
* feat(decision): effort, effort_medium and high_stakes on the attention set, staged; one-shot backfill (TASK-3921)

- attention set gains effort (Score), effort_medium (Choice) and high_stakes
  (Noul) in the same call; needs_human_decision's text is unchanged.
- QuestionSet.Unsurfaced names the staged keys: WorkspaceFlags and
  SurfacedDecisions skip them (no flag, no state read, no counter); the
  decisions list and endpoint still return every row.
- attention chips only render keys that have a label.
- store: per-set enqueue body extracted (owedDecisionJobTx), OweDecisionJobs
  (chunked, under the installed debounce) and ItemsMissingDecisionKey.
- Runner.BackfillEffort owes the attention set once for eligible items with a
  needs_human_decision answer and no effort answer, marker
  effort_decisions_backfilled written after every chunk; hooked at the end of
  reconfigureDecisionsLocked (boot and later admin enable); makes no provider call.
- eval harness: titlebody now sends the live set; RESULTS.md rows pending.
- docs/api.md and tests updated.

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

* test(decision-eval): keep titlebody as the day-73 control, add titlebody-live; record the TASK-3957 and TASK-3921 runs

- titlebody again sends the day-73 pair (needs_human_decision + blocked), so it
  stays comparable with its 09-22 row as the model-drift control.
- titlebody-live sends the live attention set on the same {title, body} state:
  the TASK-3921 gate (needs_human_decision AUC 0.936, within .02 of 0.939).
- Every run prints the question keys it sends, before -dry too, so a row's
  call shape is in its own log.
- RESULTS.md: rows for the one-question call (TASK-3957) and the
  four-question call (TASK-3921).

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

* fix(decision): effort backfill lifecycle, staged keys off item reads, dry-run guard (TASK-3921; #r1 F1-F4)

- F1: startEffortBackfill checks stopped and does bg.Add under one
  decisionTick.mu hold; BackfillEffort/EffortBackfillCandidates take a ctx
  checked before each candidate read and each chunk; cancel returns ctx.Err()
  and never writes the marker. stopDecisionTick cancels the run.
- F2: reconfigure cancels a running backfill; only a non-nil runner starts a
  new one. A per-run generation replaces the atomic latch so a cancelled run
  cannot clobber its successor.
- F3: item show / --agent / MCP item get use Runner.ItemReadDecisions, which
  drops the set's Unsurfaced keys; the decisions endpoint stays raw. docs/api.md
  updated.
- F4: the file dry run refuses a path with a -wal/-shm sibling or under
  $HOME/.pad (refuseLiveDatabase, tested without the env var).

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

* test(decision): a chunk write that fails in the store writes no marker (TASK-3921; mutant M6)

The two failure tests fail the per-chunk test hook, so the path where
OweDecisionJobs itself returns an error was never exercised: a mutant that
ignores that error and marks anyway survived. The new test breaks the
decision_jobs table from the hook so the error comes back through the store,
asserts no marker, then restores the table and re-runs to completion.

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

* refactor(decision): drop the pre-marker cancel check; a cancel after the last chunk may mark (TASK-3921; M5, lead ruling)

Mutant M5 (that check removed) survived every test, and could not do
otherwise: a cancel can land there only after every chunk was owed, and the
marker means every candidate was ENQUEUED, not answered, so marking then is
true. The check is removed rather than kept as a guard no instrument can
turn red. The doc comments on BackfillEffort, cancelEffortBackfill and
startEffortBackfill now say a cancel before the last chunk writes no
marker and one after it may.

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

* docs(api): a backfill chunk in flight when decisions are disabled may still commit (TASK-3921; r2 L1, lead ruling bdce2116)

docs/api.md said that with no provider "no job row is ever written". That is
false for one window: a disable that lands while the one-shot effort
backfill is inside a chunk's write lets that chunk commit, and its jobs wait
until a provider returns. Ruled docs-only. The disable test's comment now
says it covers a backfill parked before its first chunk, not that window.

Sweep for the claim (CONVE-23): grep of docs, internal, cmd, scripts and
skills (.go, .md) for "no job row", "job row is", "no job is", "no jobs
are written/owed/enqueued" and "never written/owed/enqueued". Two hits are
about this mechanism: api.md:72 (fixed) and the test comment (narrowed).
Unchanged, still true:
- store/decisions.go:27 is about the WRITE DOORS, which still enqueue
  nothing without a resolver.
- runner_test.go:238 is about the nil-provider runner, which never starts a
  backfill.
- effort_backfill_lifecycle_test.go:128 covers a run parked before its
  first chunk.
The other hits are unrelated mechanisms.

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

* fix(decision): pace the effort backfill on drain; dry run opens a copy; docs eligibility (TASK-3921; r3 M1, L2, L4)

- M1: the backfill owes one tick pass of items per chunk (20 default, wired
  from the tick limit) and owes chunk k+1 only once chunk k's own jobs have
  drained: none claimable now, by ClaimDecisionJobs' own predicate
  (new Store.CountClaimableDecisionJobs). Debounced jobs do not hold it.
  The wait polls at the tick interval, honours ctx, and a cancel writes
  no marker; the marker still follows the last chunk being owed.
- L2: TestEffortBackfillDryRunOnFile copies the named file under GOTMPDIR
  and opens only the copy.
- L4: docs/api.md states the real eligibility and the drain pacing.

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

* docs(decision): the Unsurfaced comment names ItemReadDecisions (TASK-3921; r4 L2)

The field's comment listed WorkspaceFlags and SurfacedDecisions as the
readers that skip staged keys, but not ItemReadDecisions, which item show
(and so --agent and MCP item get) has used since the r1 F3 fix. Comment only.

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

* test(decision): break the table before the LAST chunk so the store-failure test pins M6 (TASK-3921; lead ruling)

Since the drain pacing (51c545850), the store-failure test broke the
decision_jobs table before chunk 1 of 3. Under mutant M6 (a failed chunk
write ignored) the drain wait that follows read the same broken table and
failed, so no marker was written by a different mechanism and the test stayed
green: an end-state assertion another path also produced (CONVE-12). Breaking
the table before the last chunk leaves no drain wait after it (the marker does
not wait for the last chunk to drain), so only the chunk's own error check can
keep the marker unwritten.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 13:05:05 -04:00
xarmian ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-19 10:25:43 -04:00
xarmian eb7f8a200c feat(needs): per-user dismiss/defer storage and the needs/snooze routes (TASK-3918) (#2231)
IDEA-3906 half A, unit A2.

- Migrations 153 (SQLite) and 122 (Postgres) add item_needs_snoozes, one
  row per (user, item), FKs cascading, indexed on item_id.
- A deferral holds until defer_until (an RFC3339 time after now and within
  366 days), whatever changes before then (IDEA-3906 A2). A fractional time
  is stored rounded up to the next whole second, never early.
- A dismissal holds until something happens after its marker: a live
  comment by anyone else newer than seen_comment_at, another such comment
  at that same second (seen_comment_ids), or a later status transition
  (MAX(status_transitions.seq); items.seq and updated_at don't move on a
  comment). The user's own human comments never break it; an agent's
  comments under their credentials do, and so does the user's own status
  change. Imported comments are history and never break it.
- The marker comes from the client, clamped to the server's values so it
  can only claim less was seen. No marker means the current state.
- PUT|DELETE /api/v1/workspaces/{ws}/items/{slug}/needs/snooze, beside
  stars and watches: requireItemVisible (404 when hidden) and no edit
  check, so viewers and item-grant guests may snooze. Read-scoped tokens
  are refused by tokenScopeAllows. DELETE is idempotent.
- nulcolumns classifies the table's TEXT columns as server-written.
- A migrate-to-pg marked SQLite file refuses snooze writes: the table joins
  migratedRefusalTables, since models.WorkspaceExport does not carry it.
  TestMigratedDatabaseRefusesNeedsSnoozeWrites drives the store's own verbs
  rather than iterating the list, so removing the entry goes red.
- Workspace purge relies on the FK cascade; the purge census
  (wsChildTables, seedFullWorkspace) now counts item_needs_snoozes.
- docs/api.md documents the endpoint.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 07:02:00 -04:00
xarmian e432d0f5bc feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916) (#2230)
* feat(decision): debounce decision re-asks with decision_jobs.not_before (TASK-3916)

IDEA-3906 half B, unit B2.

- Migrations 150 (SQLite) and 120 (Postgres) add decision_jobs.not_before
  TEXT. A job is claimable only once not_before is NULL or past, in the one
  decisionJobClaimable predicate both the scan and the claim UPDATE use.
- The enqueue sets not_before to the item's latest evaluation for the set
  plus the set's window, or NULL when there is no window, no prior
  evaluation (a first evaluation is never delayed), or the window is past.
  The arithmetic is in Go on decisionTime TEXT, so both backends agree. On
  conflict COALESCE(old, new) keeps an existing not_before, so a stream of
  comments cannot push the re-ask back.
- A superseded completion RECOMPUTES not_before from the latest evaluation
  rather than setting now+window (ruling ae2e0a90): the mid-run write read
  the evaluations before this run's answers landed. A run that stored
  nothing moves nothing, so it never invents a delay.
- Runner.Install wires Store.SetDecisionDebounce: ReaskDebounce (1h) for the
  item-level sets, none for a PerComment set or an unregistered one.
- The dashboard's attention comment says the pending window can now last
  up to an hour (#2229 r3-D1, ruling ce3e9dc5).
- The tick-interval and release comments no longer promise a job is
  claimable at once: a debounced one waits for its window.
- docs/api.md and README updated.

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

* docs: a debounced burst costs at most one call an hour (TASK-3916; #2230 r1 codex Low)

* docs: the debounce is per question set (TASK-3916; #2230 r2 codex Low)

* fix(store): debounce helpers stay inside the app-fence boundary (TASK-3916; #2230 CI TestAppstoreBoundary)
2026-10-19 04:08:03 -04:00
xarmian b1785f8952 fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) (#2229)
* fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924)

IDEA-3906 half B, units 1 and 3.

- The attention set asks one question, needs_human_decision. The
  text-derived blocked Noul is gone: blocked is the dependency graph's
  alone, and the dashboard no longer appends a "its text says it is
  blocked" entry. waiting_on_external flagged nothing on 10-17 and goes too.
- conventions_comments is no longer registered in production. It took 42%
  of provider calls on 10-17 for 7 chip-level answers. The set definition
  and the PerComment machinery stay, since tests and the conventions eval
  gates use them. The web drops the comment-card chips and their store.
- Stored answers to the retired keys and set need no migration: no
  registered question carries their keys, so they read current=false and
  the brief counts them as stale questions.
- WorkspaceFlags pending tolerance: an above-threshold answer whose item has
  moved on is kept while a job is owed for it with no recorded failure and
  the set still applies. Without that, the dashboard entry vanishes for the
  whole wait for the re-ask, which TASK-3916's debounce will stretch to
  an hour.
- The day-73 titlebody eval control keeps the retired blocked question text
  locally, so it still re-runs that shape.
- docs/api.md and README updated.

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

* test(decision): a claimed re-ask still counts as pending; rewording one question keeps the others (TASK-3915 r1)

r1 hunter Lows, all FIXED: the per-key rewording rule had lost its
coverage with the one-question set, so a two-question test set keeps it;
a claimed-but-in-flight job leg is added to the pending test; the
ProductionRegistry comment's run-on join is split.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-19 00:44:38 -04:00
xarmian ed559deb4d feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B)

The card renders GET /catch-up above Active Work, fenced on identity and
keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches.
See everything opens Activity in Audit with ?since= set to the window's
start, so the activity endpoint's since now also accepts an RFC3339
instant (shape item 5, moved from PR A to PR B by the lead's ruling).

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

* test(e2e): catch-up card flow against the real binary (TASK-3615, PR B)

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

* test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B)

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

* fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B)

- sinceFromUrl range-checks the fields Go's time.Parse checks instead of
  trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00
  (and refused offsets Go takes, up to +-24:60). The table's verdicts are
  pasted from go1.26.
- CatchUpCard numbers its loads, so A->B->A cannot let the first A answer
  paint over the second.
- A mark that lands but whose refetch fails keeps the card and says the mark
  worked, instead of clearing it.
- The Activity page applies a changed ?view= on a mounted page, as it
  already did for ?since=, still without persisting it.
- The e2e waits for a new second before Mark, since the window floor is
  inclusive at second precision.

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

* fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B)

Clearing only filterSince left ?since= in the URL, so a reload re-applied a
cleared filter, and re-following the same link changed no URL value, so the
URL effect never re-ran. Both clear paths now navigate (replacing history)
to the URL without since. The shallow replaceState would leave page.url,
and so the derived since, stale. The route has no load function, so the
navigation re-fetches nothing.

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

* fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B)

r3 findings. clearSince falls back to the shallow replaceState when its goto
rejects, so a reload stays cleared. A superseded goto needs nothing, since
the newer navigation decides the URL. The e2e reload leg now checks the
reloaded feed request carries no since, instead of a chip absence that
passes before a late effect could add one.

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

* fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B)

r4 Medium, the same class as the since fix: a Live or Audit click cleared
viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied
the linked view over the user's choice. The page reads two URL values, since
and view, and every path that clears or overrides either now drops it from
the URL through one helper.

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

* fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B)

r5 finding. An offset can carry 9999-12-31 into year 10000, and the store
compares the cutoff as RFC3339 text, so "10000-..." sorted below every row
and opened the window to everything. The handler now refuses it with 400
invalid_since, and sinceFromUrl mirrors the bound so such a link loads the
unfiltered feed instead of an error. The new rows' verdicts come from a Go
probe of the handler's condition. docs/api.md says so.

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

* test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B)

eachKeyCensus.test.ts requires every keyed each to name why its keys cannot
repeat. CatchUpCard's sections come from a four-entry literal with distinct
ids, only filtered.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-18 12:12:51 -04:00
xarmian b13e842740 fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879) (#2222)
* fix(mcp): a refused tool call is audited as denied, not ok (BUG-3879)

A failed tool call answers the /mcp POST with 200 and an error result,
so the audit middleware, which classified only the transport status,
wrote result_status=ok for every refused write. MCPAuditLog now installs
an outcome sink on the request context; a tool-handler middleware on
every MCP server pad builds records a failed result's error code (and
details.reason) into it. permission_denied, auth_required and
rate_limited are denied; any other failure is an error; the code is the
error_kind.

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

* test(mcp): unit rows for the tool-outcome sink, codes and middleware order (BUG-3879)

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

* docs: the MCP audit status is the call's outcome, not the transport's (BUG-3879)

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

* fix(mcp): a JSON-RPC error inside a 200 is audited too, via an OnError hook (BUG-3879 hunter r1 M1)

mcp-go refuses an unknown tool, and answers a missing resource or
prompt, before the tool-handler middleware runs, so those calls were
still audited as ok.

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

* test(mcp): a failed tools/list audits as error; the prose names what OnError cannot see (BUG-3879 r2)

Lead ruling R2 pins the unscoped hook; hunter r2 L1 narrows the claim:
a message rejected before method dispatch never reaches OnError.

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

* test(mcp): a refused write's audit kind carries the reason BUG-3864 added (BUG-3879)

After rebasing onto #2218 (BUG-3864), the 403s for a read-only token's
write and a viewer's write carry details.reason, token_scope and role.
recordToolOutcome writes error_kind as <code>:<reason> whenever a reason
is present, as designed, so the two transport rows now expect
permission_denied:token_scope and permission_denied:role. On the old
base those 403s carried no reason, which is what the rows had pinned.

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

* fix(mcp): an unverified email's refused call is audited as denied (BUG-3879 r4)

Since BUG-3864, a write refused because the account's email is
unverified answers with its own MCP code, email_not_verified, rather
than permission_denied. classifyMCPToolOutcome only knew the older
three refusal codes, so after the rebase onto #2218 that refusal was
audited as error. It is denied now, as the classifier's own comment and
this bug's contract already said. The plan and member limits stay
errors (BUG-3894 owns that policy).

The unit rows that paired permission_denied with an email_not_verified
reason described a payload the server never sends; they now use
token_scope, a reason it does send, plus a row for the real code. A new
transport row drives an unverified owner's write on the Cloud fixture
and requires a denied row. docs/api.md and the metric's Help text name
the code.

Claude-Session: https://claude.ai/code/session_016Msqj8nRQUNbgdsXxhBBVR
2026-10-18 11:12:41 -04:00
xarmian ed89604ad8 fix(mcp): a refused call names its cause on both transports (BUG-3864) (#2218)
* fix(mcp): a refused call names its cause on both transports (BUG-3864)

A 403 from one of the shared gates now carries details.reason (role,
token_scope, no_access, workspace_not_authorized), with code and message
unchanged. MCP builds one permission_denied envelope from it on both
transports, with a hint that says what to do next. It used to lead with
"Insufficient role" for every 403, so a bot with a read-only API token was
told to ask for a role. An unverified Cloud email is reported as the
server's own email_not_verified code.

Stdio gets the same envelope from a structured marker the CLI root writes
for a 403 coded forbidden, permission_denied or email_not_verified. Before
it, the token-scope and unverified-email refusals said server_error there.

ToolSurfaceVersion 0.99 -> 0.100 (behavior).

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

* fix(server): the inline workspace-role gates name their reason too (BUG-3864)

The bulk endpoint, the owner-only member doors and the owner-only app
install and upgrade doors say role, or no_access for a guest.

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

* fix(mcp): the permission hint is the same on both transports (BUG-3864)

The remote hint appended the route, which stdio has no way to know, so one
refusal reached the two transports with different hints. The tests compared
prefixes only and missed it; both now assert the transports agree exactly.

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

* fix(server): no role hint for a caller with no role to change (BUG-3864)

requireMinRole and the grants list said role for a guest, who holds grants
rather than a role, and for a caller with no workspace role at all. Both
now go through roleGateReason, which answers no_access for either.

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-17 19:11:58 -04:00
xarmian d51aba9adb fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870) (#2217)
* test(invitations): a member accepting a dead invitation is already in (BUG-3870, red)

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

* fix(invitations): a member accepting a dead invitation is already in, not told to get re-invited (BUG-3870)

When the inviter check refuses (removed, demoted, or restricted; BUG-3770,
BUG-3634), AcceptWorkspaceInvitation reads the invitee's membership in the
same transaction (FOR SHARE on Postgres) and, for an existing member, takes
BUG-3281's idempotent path: marked accepted, role kept, nothing inserted or
emitted. A non-member is refused as before. The /join preview reports
inviter_can_grant true only to the invitee's own session when they are a
member, so nothing about membership is disclosed before auth (lead ruling,
preview option a).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01HsAbPwmN6oyEuQAp6z6NhQ
2026-10-17 06:13:14 -04:00
xarmian 7e7bb61989 fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770) (#2214)
* fix(invitations): a pending invitation dies with its inviter's authority (BUG-3770)

An accept re-read the inviter's membership and let it through when the
inviter was no longer a member, so a removed owner's pending codes kept
minting members until they expired. requireInviterScopeTx now refuses
unless the inviter is still a member, still an owner, and not restricted
to specific collections: one share-locked select of role and scope on
Postgres, never upgraded. A demotion, a removal (there is no self-leave
door) and a restriction all refuse with the existing inviter_scope_changed
at all three accept doors. Instance admins stay exempt.

The doors that offer an Accept use the same predicate, unlocked
(InviterCanStillGrant): the pending list hides such an invitation (hidden,
not deleted, so it returns if the authority does), and the /join preview
reports inviter_can_grant, which the page turns into an "inviter can no
longer grant access" state with no Accept and no form.

Five fixtures built invitations whose inviter held no authority in any
reachable state (an invitee inviting itself, an owner_id user with no
member row); they now invite from a real owner.

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

* docs(api): the inviter re-check covers removal and demotion; preview inviter_can_grant (BUG-3770)

Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
2026-10-16 21:14:10 -04:00
xarmian eac92797f4 fix(store): a workspace slug is slugified whichever door supplies it; migrate-to-pg keeps existing slugs (BUG-3769) (#2205)
* fix(store): a workspace slug is slugified whichever door supplies it (BUG-3769)

An import with a name override used the raw name as the slug ("Interchange
Golden", answered 201), and the workspace's own export URL could not be
built. The same defect had three more doors: an import bundle's own slug
and a create body's slug landed verbatim, and a name slugify keeps nothing
of (all punctuation or all non-ASCII) minted an empty slug.

createWorkspaceQ, which every mint goes through, now slugifies the supplied
slug, falls back to the name's slug, then to "workspace", as roles, items
and collections fall back to theirs. The import override sets the name and
leaves the slug to be derived from it.

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

* test(server): import tests name their imports in slug form (BUG-3769)

They looked the workspace up by the raw import name, which was the slug
only because of the bug. Four were absence checks (Gone, Dup, Husk, the
interrupted ones) that the fix alone would have made vacuous, so the names
are lowercased rather than the lookups slugified.

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

* test(server): the import tests' URLs and quoted names use the slug form; the clean-import GET must be a 200 (BUG-3769)

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

* docs(api): a workspace slug is canonical and the response's slug is the one to use; the fallback test is order-independent (BUG-3769)

Codex r1 Medium: a client that builds URLs from the slug it sent can miss
now that every door slugifies it, so docs/api.md states the rule and that
the 201's slug is authoritative, including the bundle re-canonicalization
the hunter's r1 L1 describes. Hunter r1 L2: the punctuation-only fallback
case no longer depends on the table's order.

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

* fix(store): pad db migrate-to-pg keeps each workspace's slug verbatim (BUG-3769)

Hunter r2 Medium: the migration copies existing workspaces through
ImportWorkspace with no name override, so the new canonicalization renamed a
legacy slug (my_ws, Foo) on the way to Postgres, breaking URLs, bookmarks and
.pad.toml links, and two slugs that canonicalize alike collided into -2.

A MintOption, WithPreservedSlug, keeps a supplied slug verbatim (still made
unique) in createWorkspaceQ. migrateImportWorkspace, which the command now
calls, passes it; every door that mints a new workspace leaves it off. The
docs bullet names the exception.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 21:24:48 -04:00
xarmian 202f53daef fix(mcp): MCP's long-lived streams end with their credential (BUG-3837) (#2201)
* fix(mcp): MCP's long-lived streams end with their credential, with an OAuth liveness door (BUG-3837)

The GET stream and a subscriptions/listen POST never re-checked the
credential that opened them, so a signed-out session, a revoked PAT or a
revoked OAuth grant kept receiving notifications until the client hung up
(the BUG-3007 rule in docs/api.md).

- The MCP OAuth accept point records authKindOAuth. credentialLiveness's
  new door re-introspects the token: ErrInactiveToken (a revoked token or
  grant), ErrNotFound and ErrTokenExpired end the stream; any other error
  (fosite wraps storage failures in the same outer error) is "could not
  ask" and keeps it, as the session and PAT doors do.
- mcpBoundWrites runs a jittered tick, at the /events/stream cadence, on
  the doors that took an admission slot. It ends the stream when the
  credential is invalid or its user is deleted or disabled, by cancelling
  the context mcp-go leaves on; the existing defers stop the keepalive and
  release the slot.
- docs/api.md's credential rule lists the MCP streams and says an OAuth
  stream ends when its access token expires.

Tests: a revoked PAT and a revoked OAuth grant each end a GET and a listen;
an expired token ends a stream; a live OAuth stream survives 20 ticks; a
disabled user ends an OAuth stream; a store failure keeps one. Six mutants,
all red.

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

* fix(mcp): the credential tick reads the bearer as admission does; r1 fixes (BUG-3837)

r1 blind hunter:
- M1: the PAT and OAuth doors trimmed "Bearer " case-sensitively, while
  admission's extractBearer takes any scheme case (RFC 6750). A PAT stream
  sent as "bearer <tok>" closed on every tick. Both doors use extractBearer.
- L1: GetUser returns (nil, nil) for a missing row; the ErrNoRows branch was
  dead and is removed.
- L2: the expiry test's lifespan leaves setup seconds of margin.
- L3: the tick's interval and first delay are read on the handler goroutine;
  tests swap both package variables and the tick goroutine is not joined.
- L4/L5: stale test comments updated; api.md says a refresh rotation ends an
  OAuth stream, and that no revocation kicks an MCP stream at once.
New tests: a deleted token row ends the stream (M8 was alive); lowercase
"bearer" streams stay open and still end on revocation.

Codex r1's Medium (errors.Is cannot see ErrInactiveToken through fosite's
ErrRequestUnauthorized wrapper) is refuted by measurement: removing the
ErrInactiveToken case fails the revoked-grant test (M7).

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

* test(mcp): a deleted user is not still valid; the tick skips a stream that already ended (BUG-3837)

r2: Codex's Medium (GetUser wraps sql.ErrNoRows, so a deleted user would
read as "could not ask") is refuted by a test that pins the premise:
GetUser returns (nil, nil) for a missing row, and the principal check
reports the stream invalid (red on a nil-means-valid mutant). Hunter r2
Lows: api.md no longer claims MCP clients reconnect on their own, and a
timer that fires together with the stream's end no longer logs a
credential invalidation.

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

* test(mcp): a refresh rotation ends a live stream at a frame boundary, and the new token opens one (BUG-3837)

Pins the lead's KEEP ruling on rotation: the old access token's stream ends
within one tick of the refresh, its body is whole frames, and the
rotated-in token opens a fresh stream that stays open.

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

* test(mcp): the tick skips tool calls and keeps a stream on a user read failure; r3 fixes (BUG-3837)

The OAuth token is minted last in the fixture, the rotation test's bound is
two ticks plus slack, and the comments and api.md no longer count three tick
sites or claim the dispatcher's requests carry no OAuth kind. A listen ends
with an ordinary result, which api.md now says.

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

* test(mcp): the listen tests send a modern-era listen, the only kind admission counts (BUG-3837)

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

* test(mcp): a transient access-token store error is not read as revocation, pinned against real fosite (BUG-3837)

r4 (lead ruling): the decline of Codex's Medium rests on fosite v0.49.0
returning the access lookup's error when the refresh fallback also fails.
A fosite bump could flip that silently, so the OAuth door is driven through
real introspection with the access store failing and the fallback finding
nothing (unknown, the stream is kept), and with the access row deleted
(invalid, the stream ends) as the control.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 14:37:04 -04:00
xarmian 7825f3afc5 fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835)

An idle MCP event stream made no write, so #2182's write deadline never
fired on it: a client that stopped reading but held TCP open pinned a
handler goroutine and its session until a notification happened to be
written, and a signed-in client could open any number of them.

- A ': keepalive' comment at the /events cadence on any response the
  transport commits as 200 text/event-stream, written through the same
  bounded writer. mcp-go writes each SSE event with one Write, so a mutex
  at Write boundaries keeps keepalives between events. No keepalive after
  a JSON-RPC response (the final event of a POST stream) or after the
  handler returns. mcp-go's own heartbeat is not used: it sends ping
  requests every client must answer.
- The shared streamAdmission gate, keyed by streamPrincipal, taken at
  request entry for the two long-lived doors: GET /mcp and a
  subscriptions/listen POST. A refusal is the same plain 429
  sse_limit_exceeded with Retry-After 5, sent before the transport runs. A
  tool call upgraded to SSE mid-call is not counted: a refusal there would
  land after its side effects. Listen is detected by decoding the whole
  body exactly as mcp-go does (encoding/json into a "method" field), not by
  a fixed-size peek that key order could evade.
- Docs, config, the startup log and the metric help say the budget now
  covers MCP's long-lived streams.

Credential expiry for /mcp streams is BUG-3837.

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

* docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835)

Codex r1 (Medium, proposed declined): a keepalive tick between the
transport's return and end() can add one comment frame; it is still inside
mcpBoundWrites, which joins the keepalive goroutine before returning, so
net/http never sees a write after the handler. The comments now say so.
Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp.
Hunter r1: the one-Write premise holds only without an event store
(writeSSEEventRaw writes twice); stated at the premise. Detection of a
stalled idle client takes up to about three keepalive intervals; stated in
docs/api.md.

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

* fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835)

Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP
without defer, so a panicking transport left it running past the handler,
with chi's Recoverer swallowing the panic. Now deferred; pinned by
TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred).

Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is
long-lived. The MCP transport's GET and subscriptions/listen streams are,
and do not yet re-check their credential; that is BUG-3837.

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

* docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835)

r3 hunter Medium: with default socket buffers a comment every 30s takes days
to fill them, so the "caught within about three keepalive intervals" claim
held only in the shrunk-buffer tests. api.md, the file header comment and the
stalled-reader test comment now say what the keepalive catches and what the
budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now
include MCP's long-lived streams. The end()/ended comments state the
one-frame window between the transport's return and end().

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

* fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835)

r4 blind hunter:
- M1: mcpLongLivedRequest reads the request body and was not in the body
  reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is
  now listed, with the reason and the second-buffer cost the lead accepted.
- M3: a half-open peer accepts keepalive writes into the socket buffer until
  TCP gives up on it; "within one keepalive plus the write deadline" held only
  in the shrunk-buffer tests. api.md and the header comment now say so.
- L3: the panicking-transport test waits for its first keepalive, read under
  the writer's mutex, instead of sleeping a fixed 10ms (M7 still red).

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

* fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835)

Pad serves the legacy MCP protocol versions only, and mcp-go serves a
subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version
header is modern and matches _meta. So a POST without a modern header is
never a listen, and admission no longer reads its whole body a second time
(r6 hunter M1, gate approved by the lead). The docs, the metrics help, the
startup log and the census reason say listen is counted once the modern era
is served. api.md states the keepalive outlives a proxy idle timeout until
BUG-3837, the deployment note says the per-user bucket is shared with the
browser's /events, and a test pins isFinalSSEEvent against the real
transport's response frame.

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

* fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835)

The listen gate now also requires the header's version to be one the mount's
transport serves, read through mcp-go's SupportsProtocolVersion (forwarded
through the ChatGPT wrapper). A client probing an unserved modern version at a
full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7
ruling). Pad serves the legacy versions only, so no POST body is read for
admission today, which the docs now say. New tests pin that mcp-go never
serves a listen without a modern header (with a control that it serves a
well-formed one), that an unserved modern version is not read or counted, and
that both production mounts report their served versions.

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

* test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835)

r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports,
so a shim around either production binding would silently send admission
back to counting every modern version. Server.MCPMountServesVersion exposes
the gate's read of the transport the server holds, and a cmd/pad test drives
wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim
mutants on each binding turn it red.

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

* docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835)

r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z):
internal/events/bus.go said streamAdmission is acquired by "both
endpoints", falsified once MCP's long-lived streams acquire it too, and
mcpBoundWrites' doc comment did not mention the admission slot or the
keepalive. Comment-only.

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

* docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835)

r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated
the 429 refusal twice, the second time after the keepalive text it no
longer fitted. Docs-only.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 13:18:36 -04:00
xarmian d95f90850c fix(store): a role change or disable of a vanished user is 404, and the unguarded DisableUser is gone (BUG-3826) (#2196)
* fix(store): a role change or disable of a vanished user is 404, and the unguarded DisableUser is gone (BUG-3826)

Follow-ups to BUG-3811's last-active-admin guard, one unit per the lead:

1. Store.DisableUser wrote disabled_at with no last-admin guard and no
   lock, the one admin-removing writer outside the invariant. Removed.
   Its test callers need the pre-BUG-3349 state (disabled, credentials
   not revoked), so they move to DisableUserLeavingCredentialsForTesting.
2. A user deleted between the handler's read and the write: a promotion
   answered ErrLastAdmin (a false 409 last_active_admin), a demotion a
   plain error (500), and a disable succeeded on nothing (200). All three
   now return store.ErrUserNotFound, which both handlers answer 404. The
   disable takes the users row FOR NO KEY UPDATE after the guard, so a
   concurrent deletion cannot slip between the check and the UPDATE.
3. The race test only held demote or disable open as the first door.
   Account deletion is now held at its user-row lock too, so moving its
   guard after that lock goes red.
4. docs/api.md documents 409 last_active_admin on delete-account, the
   admin role PATCH and disable, and the new 404.

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

* docs(api): the vanished-user 404 is promised for a role change only (BUG-3826)

Codex r1 (Low): the plan fields of the same PATCH do not map ErrUserNotFound.

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

* docs,test: the delete-account 409 is checked before the Stripe cancel and re-checked after; the race test comment names its contrast (BUG-3826)

Blind hunter r2 (Lows): the docs said the refusal comes before any Stripe
cancel, which is true of the pre-check only; the handler answers 500
partial_delete when the locked re-check refuses after a cancel ran. The race
test's comment read as if it described the other test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 08:23:56 -04:00
xarmian 01021632c9 fix(store): version rows record their body's hash, and the walk refuses what it cannot verify (BUG-3820) (#2192)
When a reverse patch fails, the walk resolves the next older row against the
newer body (BUG-3655). diff-match-patch matches fuzzily, so when the failed
row was a real link in the chain, the older patch can apply to the wrong base
and return a wrong body with no error. That body was served in History,
diffable and restorable.

Every version row now records body_sha256, the hash of the body it stands for
(migrations 148 / PG 118, nullable, never backfilled). Create, update and
fenced-create rows stamp it; export carries it, and import keeps a well-formed
one. The walk checks every resolved body against it. A mismatch is treated as
a failed apply. An unhashed diff row below a failure or mismatch is served
with its best-effort body, flagged unreconstructable with reason "unverified",
and refused for restore and diff like a placeholder. A full-body or verified
row re-anchors the walk. The BUG-3745 rebase leaves a hashed row whose rebuilt
body does not match alone. The attachment remap verifies by hash, and re-mints
a hash only for a body that verified.

The History card renders the diff's 409 version_unreconstructable as "this
edit's changes can't be shown" instead of a load error, and clears that
state when a later fetch of the diff succeeds. It keeps Restore offered: the
diff also reads the next newer row, so a verified, restorable version whose
newer neighbour failed is refused by the diff. The restore checks only the
version and decides; its refusal shows as "Restore failed: <message>".

Out of scope: rows already wrong from pre-BUG-3745 throttle damage with no
failure above them (no signal exists), and the legacy documents versions
table.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 07:20:42 -04:00