Commit Graph

17 Commits

Author SHA1 Message Date
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 94191b2bdc feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a) (#2271)
* feat(needs): store reads for importance and simplicity scoring (TASK-3959, part 1a)

GetBlocksEdges now returns each blocked item's collection and fields as
well (TargetCollectionID, TargetFields). The needs scorers walk blocks
forward and count only targets the viewer may see that are still open.

New batch reads for the needs scorers (internal/store/needs_scoring.go)
and the effort answer reads (internal/decision/effort_read.go). The stored
answer shape is private to the decision package, so the reads live there.
The effort_medium rule (a top probability under .5 reads as unclear) is
EffortMediumMinProb.

Nothing calls these yet. The scorers that do are part 1b. Split at the
store/server seam by lead ruling 6901f2f5 on the TASK-3959 trail.

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

* fix(needs): scope the decision and job reads to the workspace; a NULL body reads as empty (TASK-3959, part 1a r1)

Review round 1 on part 1a (codex and hunter):
- LatestDecisionsForItems and OwedDecisionJobs now take the workspace and
  read only live items of it, scoped through the item's own workspace_id,
  like NeedsScoringItems. Before, they trusted the caller's ids:
  OwedDecisionJobs answered for a deleted item, and neither checked the
  workspace.
- NeedsScoringItems reads COALESCE(length(content), 0). items.content is
  nullable, and one NULL failed the Scan for its whole chunk.
- The CurrentQuestionHashes comment says what a hash match does and does
  not prove: the question is unchanged, not the item (ruling 99c2bcfe, 3).

Tests cover the other-workspace and deleted-item cases for both reads, a
NULL body, and LatestDecisionsForItems over the IN-list chunk limit.

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

* test(needs): the effort readers: argmax, ties, unreadable answers (TASK-3959, part 1a r2)

Review round 2 on part 1a: EffortLevel, ChoiceValue and CurrentQuestionHashes had no tests of their own. The new tests cover:
- argmax rather than a truncated mean (a distribution whose mean is 1.7 and whose argmax is 2);
- a tie going to the lower level;
- a stray or out-of-range winning key reading as unreadable, which fails safe;
- other kinds and malformed answers;
- ChoiceValue's weak, no-distribution and no-choice cases;
- a nil runner.

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

* test(needs): discriminating tests for hashes, duplicate ids and blocks targets (TASK-3959, part 1a r3)

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

* test(needs): compare blocks-edge fields as JSON, not text (TASK-3959, part 1a)

make test-pg failed TestGetBlocksEdges_CarriesTheTarget: Postgres stores items.fields as JSONB and returns it with keys reordered and spaced, so an exact string comparison cannot hold there. The fields are now compared as parsed JSON. The source and target still carry different values, so a source/target swap in the query still fails the test.

Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
2026-10-21 08:02:26 -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 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 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 559d9d1422 feat(server): the item brief carries only the decisions Pad would surface, and counts the rest (TASK-3618) (#2107)
The brief listed every typed-decision row for an item with no threshold filter.
On 40 recent docapp items that came to 2210 rows, 2202 of them below the
threshold at which any surface shows them, and all of them were pushed into
`omitted` by the conventions that fill the budget first.

- decision.surfacing is the one row test: the question is current and the Noul
  reaches the threshold. WorkspaceFlags (the dashboard) and the new
  Runner.SurfacedDecisions (the brief) both call it.
- QuestionSet.Threshold carries each set's surfacing threshold from the
  existing named constants (AttentionThreshold, ConventionThreshold).
- The brief adds decisions_below_threshold and decisions_stale_question counts.
  It raises stale_decision only for a surfaced answer, and it keeps a surfaced
  answer whose item has moved on (current=false), which WorkspaceFlags drops.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-12 11:53:29 -04:00
xarmian 560ab27b33 feat(server): reference items stop counting as open work (PLAN-3535 PR 2) (#2000)
The items of a collection with tracks_work=false are left out of:
- parent progress (GetItemProgress / GetAllItemProgress SQL, and
  childProgressState for the restricted recompute and the dashboard);
- the open-children close guard (all four doors share runOpenChildrenGuard);
- a default report (resolveReportCollections: !tracksWork replaces IsSystem);
- the dashboard's active items, stalled / overdue / orphaned / blocked /
  content attention, suggested_next and by_role (store and visible recount),
  and so next / ready / stale and the bootstrap dashboard;
- standup and changelog completed work and in-progress, server and CLI;
- the attention decision set's eligibility;
- the admin items_open count.
Sidebar and summary totals, item lists, search, links, the graph, stars and
reminders keep counting every item.

A workspace bundle written before the key existed imports its system
collections as reference, as the migration marked them.

Guards: a three-phase behavioural fixture (work / reference / deleted) over
every excluded surface, each proven sensitive; and a name-based
classification of every lifecycle-helper call site in Go.

MCP ToolSurfaceVersion 0.82 (behaviour bump, v0.49 grounds).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 22:31:16 -04:00
xarmian 79922d4bab feat: conventions checked on each comment alone, with the chip on its card (TASK-3119 U2b) (#1872)
* feat: conventions checked on each comment alone, with the chip on its card (TASK-3119 U2b)

A new decision set, conventions_comments (QuestionSet.PerComment): the
conventions questions asked about each of an item's last 10 live comments,
one comment per state (BuildCommentState: the item's title and collection,
and the comment, 16k-rune cap), rows keyed conv:<ref>@<comment-id>. Gate 2
measured this subject: 7 of 7 comment-borne breaks caught, 0 of 9 false, a
credential past the old 2k trail clip caught at 0.99.

- Asked vs current (lead ruling): only the newest 10 comments are asked or
  re-asked, but an answer stays current while its comment is unchanged,
  however many newer comments arrive. Edited, deleted or moved: not current.
  A title edit re-asks the window (the title is in the state, as measured).
- Idempotent per comment: a new comment costs one call, an item write none.
- Web: DecisionChips publishes per-comment chips from its existing read into
  a small store keyed by item; TimelineCommentCard shows 'Possibly breaks
  CONVE-N' on the comment (and on a reply) at p >= 0.9, full-reload link, no
  compliance state. Cleared when the item goes.
- CLAUDE.md: the third set.

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

* fix(web): a late decisions response cannot repopulate cleared comment chips (TASK-3119 U2b)

codex r1.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 01:43:43 -04:00
xarmian 4532eecffd feat(decision): conventions ask about the item without its comment trail, 12 questions a call (TASK-3119 U2a) (#1871)
* chore(eval): TASK-3119 U2 gates in one command; decision.BuildCommentState

-gates runs three passes through the production question path:
A  U0's population on the U2a item state (links in, trail out), gated on
   precision not dropping below U0's on any convention that fired;
B  comment-borne breaks, each comment asked alone with its item's title
   and collection, gated on precision >= 0.9;
C  a measurement of 12+ questions in one call (MaxPerCall 12).

BuildCommentState is the comment subject's one serialisation, shared by
the harness now and the runner in U2b. Comments are clipped at the body
bound (16k runes), not the trail's 2k.

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

* feat(decision): the conventions set asks about the item without its comment trail, 12 questions a call (TASK-3119 U2a)

The conventions state carried the last 10 comments, clipped at 2,000 runes
each: on docapp that was most of every call's input (15.7k tokens for one
write across 3 calls), 720 items had a clipped comment, and a break past the
clip was invisible anyway. Comments get their own subject in U2b.

- QuestionSet.NoTrail leaves the trail out; the conventions set sets it. The
  state is exactly what the U2 gates measured (recent_trail [], links in):
  precision held at 100% on every convention that fires, recall rose (1286
  40->80%, 2693 70->90%), 0 truncated states, ~954 input tokens per call.
- MaxPerCall 5 -> 12, from measure C: up to 13 questions in one call gave
  identical precision and recall, 0 of 116 answers moved by more than 0.2.
- A comment write no longer changes this set's state, so it costs no call.
- Every conventions answer computed before this reads not-current until the
  item's next write (the state bytes changed): a fleet-wide re-ask, by design.
- CLAUDE.md: the registered sets (it still said attention was the only one,
  since U1a) and the per-set state.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 00:01:26 -04:00
xarmian cc1aa6902c feat(decision): log each evaluation's spend (TASK-3119) (#1861)
* feat(decision): log each evaluation's spend (TASK-3119)

One info line per evaluation that reached the provider: set, workspace,
item ref and id, questions, calls, input and output tokens, truncated,
outcome (stored|failed). Counts and identifiers only: no question, answer,
item text or error message. A failed call's tokens are counted too.

The dogfood box's /metrics is loopback-only and :7777 binds the LAN IP, so
the server log is where its spend is readable.

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

* test(decision): a liveness stop between split calls still logs the call made (TASK-3119)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 13:43:42 -04:00
xarmian deec62235e feat(decision): executable conventions, the server half (TASK-3119 U1a) (#1859)
* wip(decision): conventions set (TASK-3119 U1a)

* chore(eval): executable-conventions eval through the production question path (TASK-3119 U0)

Measures, before any chip exists, how often the decision provider says a
hand-labelled item breaks a convention at the 0.9 threshold: per
convention precision and recall (all cases and real-only), the false
'Breaks' rate of a behaviour convention, and the calls and tokens spent.
State is decision.BuildItemState, the question a decision.Noul worded as
the U1 contract says, the model decision.DefaultModel.

The hand labels were posted to TASK-3119 before any score existed; the
trail comment carrying them is dropped from every state, and the dry run
refuses a state that still carries them. Reads TYPESAFE_API_KEY only.

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

* chore(eval): -links and -only for the conventions eval (TASK-3119 U1a)

-links builds each state as the conventions set does, with the item's
links (from the dump: the parent link only; blocking links are not in
pad item show's JSON). -only runs one convention. Used for the lead's
U1a gate: CONVE-1286 rerun with links.

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

* fix(decision): liveness before EACH split call (TASK-3119 U1a, codex r1)

A split set makes several provider calls; the liveness check ran once
before them, so an item or workspace deleted between calls was still
sent in the later ones. It now runs immediately before every call. A
test deletes the item from inside the first call and the second is not
sent; checking only once fails it.

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

* fix(decision): decision_check has no default; allow-list the eval's decode (TASK-3119 U1a)

A default "on" was written into every new convention: imports gained a
decision_check field (artifact import parity) and an activated built-in
no longer matched its seed (TASK-3462 would read every one as diverged).
Absent already reads as on, so the field carries no default, in the
template and both migrations.

The conventions eval's readJSON is a raw decode the reserved-field guard
counts; allow-listed with its reason, like the attention harness.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-07 12:42:32 -04:00
xarmian 6fdd8816f3 feat(playbook): match free text to an active playbook via the decision provider (TASK-3120) (#1429)
* feat(server): POST /playbooks/match — typed-decision Choice over active playbooks (TASK-3120)

PLAN-3114 unit 5. A synchronous, read-only endpoint: given free text, asks
the workspace's typed-decision provider which active (non-draft,
non-deprecated) playbook it best matches, with a reserved "none" option so
unrelated text gets an honest answer instead of a forced pick — and, as a
side effect, keeps a workspace with exactly one active playbook askable
(decision.Question.Validate needs >=2 Choice options).

- decision.Runner gains a nil-safe Provider() accessor: this endpoint needs
  a plain synchronous Ask, not the owed-jobs pipeline Evaluate drives.
- No provider configured -> 404 decision_provider_unavailable (callers fall
  back to slug/trigger routing). A provider error or an out-of-set answer
  the provider was never offered -> 502 decision_provider_error, distinct
  from the 404 case so a caller can tell "try again" from "not configured".
  Zero active playbooks -> 200 with a stated reason, no provider call.
  Over the provider's 255-option ceiling (including "none") -> refused
  rather than silently dropping playbooks and answering from an
  incomplete set.
- listPlaybookItems is factored out of handleListPlaybooks so list and
  match share one visibility-scoped item source and can't drift on which
  playbooks a caller can see.
- 30s timeout on the synchronous provider call, tighter than the
  provider's own worst-case retry budget (~330s) since an HTTP caller
  blocks on this one directly, unlike the async decision tick.
- content_state_population_test.go's BUG-3033 sweep gets a new ruling: the
  PlaybookSummary call here feeds the provider prompt only, never the
  response, so there's no reader-facing text to mark.

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

* feat(cli): pad playbook match "<text>" (TASK-3120)

CLI + client-method half of PLAN-3114 unit 5 — POST /playbooks/match.
Table output prints the matched ref/title (or "none"), confidence, model,
and a probabilities table sorted descending; --format json passes the
response through unmodified. Read-only, side-effect-free.

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

* docs(skill): route to playbook match as rung 3, offer-only (TASK-3120)

PLAN-3114 unit 5's skill half. Exact invocation-slug match and trigger
match stay rungs 1-2; match is consulted only when the user is plainly
asking for a procedure/workflow and both of those missed — never on
ordinary conversation, since every call spends provider money. Any match
returned is offered, never auto-run, at any confidence: TASK-3137's replay
of this same provider's attention question set found no validated
threshold, so nothing here invents one for this question either. A 404
(no provider configured), a "none" choice, or any other error falls back
silently to today's slug/trigger-only behavior.

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

* chore(mcp): de-indent padItemToolDescription's continuation lines (TASK-3120)

Whitespace-only — git diff -w on this file is empty. The description's
continuation lines carried 18 leading spaces purely for visual alignment
under each action's "  name — " bullet in the Go source; that padding is
emitted verbatim into the tools/list JSON, where it conveys nothing an
LLM reading the tool description gets any value from.

De-indented by exactly 14 spaces on every line that had >= 18 (all 116 of
them, uniformly, since the file carries only three indentation levels:
0, 2, 18 — no deeper nesting to preserve a relative offset for).
Continuations now land at 4 (bullet's 2 + 2), so a reader can still tell
which action a line belongs to; a 1-space collapse would have inverted
that (continuation LESS indented than its own bullet).

tools/list: 55290 -> 53666 bytes (-1624). initialize: unchanged at 24530
(it carries no tool descriptions). Frees headroom against the 55296
budget for the pad_playbook.match action landing in the next commit —
budget constants themselves are untouched, per standing instruction.

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

* feat(mcp): pad_playbook.match action, ToolSurfaceVersion 0.39 -> 0.41 (TASK-3120)

PLAN-3114 unit 5's MCP half, landing on top of the whitespace-only
catalog_item.go trim (previous commit) so this stays within the
tools/list budget with room to spare.

- catalog_playbook.go: new `text` param, new `match` action
  (passThrough — the CLI's positional arg is named "text", matching
  this tool's param, so no custom shaping is needed) plus a
  description bullet in padPlaybookToolDescription.
- tool_surface.go: `match` marked read-only (nothing stored, no job
  enqueued).
- dispatch_http_routes.go: `playbook match` routeSpec (POST, flat
  {text} body) so the remote /mcp transport can dispatch it in-process.
- version.go: ToolSurfaceVersion 0.39 -> 0.41. 0.40 is reserved for
  PR #1427 (the 0.32/0.33 precedent — a gap is cheaper than a
  collision). 0.41's own entry: pure ADDITIVE bump, same disposition
  as 0.13/0.28/0.11 — nothing existing moved.
- instructions.md, README.md, CLAUDE.md: version headings + the
  pad_playbook bullet/table-row/changelog-sentence updated per the
  drift tests (TestInstructionsMDCoversEveryCatalogAction,
  TestReadmeTableCoversEveryCatalogAction, the two version-match
  tests). instructions.md's addition is intentionally terse ("`match`
  finds a playbook from text.") — it's embedded in the `initialize`
  handshake payload, which has its own 24576-byte budget and had only
  46 bytes of headroom; a fuller explanation lives in the tools/list
  param/action docs instead, which have budget to spare after the trim.
- catalog_readonly_test.go, dispatch_http_parity_test.go: new
  `pad_playbook.match` entries in three hand-built fixture maps
  (expected cmdPath ×2, a hand-rolled cmdhelp stub, and the shared
  parity-drive fixture input) so the existing bijection/parity/dispatch
  guards actually exercise the new action instead of skipping it.

Bytes measured (go test ./internal/mcp -run Budget -v):
  tools/list:  55290 (baseline) -> 53666 (after the trim commit) ->
               54805 (after this commit) — 491 bytes of headroom left
               against the 55296 budget.
  initialize:  24530 (baseline, unchanged by the trim) -> 24574 (after
               this commit) — 2 bytes of headroom against the 24576
               budget. Flagged in the implementer report: this is now
               as tight as tools/list was before the trim, and nothing
               in this unit addresses it.

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

* fix(server,decision): don't leak provider errors, classify oversized requests, fix none's wording (TASK-3120)

Lead's shallow-pass findings on cd7a41ed, applied after the MCP commit.

F1 (disclosure): the provider-error path answered with err.Error()
verbatim. typesafe.go's client surfaces the provider's raw 4xx body in
that string (APIError.Error()), which is the INSTANCE's own account
state with typesafe.ai — key validity, quota, billing wording — not
anything about the caller's request. This endpoint requires only
viewer access, so any workspace member could read it. Now logged
server-side in full (slog, workspace + err) and answered with a fixed
message plus a coarse details.reason ("timeout" for a context
deadline, "upstream" otherwise) via a new writeDecisionProviderError
helper. Test constructs a fake error containing a secret-looking
string and asserts it never reaches the response body.

F2 (misclassification): the request budget is 32000 tokens
(~80000 chars). At a couple hundred active playbooks with realistic
summaries, the Choice question's options alone can exceed it before
any state is even added — or leave no room for the input text. Both
were plain fmt.Errorf with no sentinel, so they fell into the generic
"provider failed" 502 path, telling a caller "try again" for a
request that will never fit no matter how many times it's retried.
decision/typesafe.go gains ErrRequestTooLarge (wrapped with %w onto
both pre-send refusals; existing message text unchanged, just
appended-to) alongside the existing ErrMaxTokensExceeded (the
provider's own after-the-fact refusal of a request fitState let
through) — both now map to a distinct 400 match_request_too_large.
New decision-package unit test builds a realistic 254-option Choice
(title + near-cap summary + trigger + slug per option, mirroring what
playbook-match could plausibly send) and confirms fitState refuses it
before the fake HTTP handler is ever called; server-side table test
covers both sentinels through the fake provider.

F3 (nit): the "none" option's description read "...listed above",
but Options is an unordered map — nothing puts them "above" it.
Reworded to "...any of the other options' procedures"; the pinned
exact-prompt test updated to match.

Also (lead's separate MCP-round instruction): decision.go's
maxChoiceOptions is now exported as MaxChoiceOptions, and the
server's over-limit refusal reads that constant instead of a copied
`255` literal, so the two ceilings cannot drift apart.

go test ./internal/decision/... ./internal/mcp/... ./internal/server/...
./cmd/pad/... ./internal/cli/... — all green. golangci-lint run ./...
— 0 issues. Full `go test ./...` run separately; final line in the
report.

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

* chore(mcp): cut pad_playbook.match's tools/list text to the approved +292 variant (TASK-3120)

The lead's shape-(a) approval was for the measured "+292 documented"
prototype, not the fuller wording ae38353c actually shipped
(+1139 bytes) — the trim commit exists to pay for the approved
action text, not to fund arbitrary new prose. Cut the `text` param to
one sentence (the 404-fallback advice lives once, in the action
bullet, not repeated in the param) and the `match` action bullet down
to four lines at consistent bullet+2 continuation indentation,
matching the rest of padPlaybookToolDescription's style (the previous
wording had drifted to 20-space continuation on its last two lines).

tools/list: 53666 (post-trim baseline) -> 54023 (+357, close to the
~300 target — wording differs from the original prototype's exact
text, so an exact match isn't expected). initialize: unchanged at
24574 (this file's bullet is tools/list-only; instructions.md is a
separate file, untouched here).

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

* fix(server): don't overclaim writeDecisionProviderError's timeout reason (TASK-3120)

The doc comment said "timeout" covered a cancelled caller request too,
but context.Canceled is a distinct error from context.DeadlineExceeded
and errors.Is(err, context.DeadlineExceeded) does not match it — that
case actually falls through to "upstream", contradicting what the
comment claimed. Narrowed the comment to deadline-only (the preferred
fix per review): a cancelled caller's own request also never reads
this response body, so the distinction was moot as well as wrong.
Code was already correct; only the comment overclaimed.

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

* fix(cli,skill): pad playbook match needs -- before dash-led text (TASK-3120)

Codex round 2 finding on f31d5d64. Text starting with "-" (a dash-led
sentence, or literal text like "-ship it") is parsed as a flag by
cobra's default scanner, on both the CLI and stdio MCP (which shells
out to this same command). Pre-existing and class-wide — `pad item
search "-x"` fails the same way — so the general stdio fix across
every command is filed as BUG-3142 rather than done here; BuildCLIArgs
is untouched.

This command's own help now shows the workaround: an Example section
with `pad playbook match -- "-ship it"`. skills/pad/SKILL.md's rung-3
routing entry calls it the same way, with a one-line reason, and notes
the MCP form is unaffected (a structured `text` param, not a CLI
token).

New CLI test asserts BOTH directions: `playbook match "-ship it"`
(no --) fails at cobra's flag parser and never reaches the server;
`playbook match -- "-ship it"` succeeds and the server receives the
text with its leading dash intact. Ran both legs — see the test.

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

* fix(skill): local stdio MCP is not exempt from the leading-dash flag parse (TASK-3120)

Codex round 4 finding. The rung-3 routing entry claimed the MCP form of
match was "unaffected" by BUG-3142, but that's only true for remote
/mcp, which keeps `text` a structured param. Local stdio MCP shells
out to `pad playbook match`, turning `text` into a CLI positional via
BuildCLIArgs (dispatch.go:435) — the same flag parser, the same bug.
Corrected: remote /mcp is safe as-is; local stdio MCP still hits it and
should prefer the CLI's `--` form when the text starts with `-`.

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

* fix(cmdhelp): the example validator's flag scan must stop at "--" (TASK-3120)

CI red on #1429 (a2a5a853), Go/Go-PG/Nix all failing the same way:
TestRealPadTree_ExampleDriftValidator flagged the new `pad playbook
match -- "-ship it"` example's own dash-led TEXT ARGUMENT as an
unknown flag `--ship it`. Cobra stops flag parsing at a lone "--" —
that's the whole point of the token — but validateExample's scan
treated "--" as just another flag-shaped token: TrimLeft reduced it
to an empty name, which fell into the "skip, keep scanning" branch
instead of stopping the scan. Everything after "--" kept getting
flag-checked, so the terminated example's own positional text was
misread as a flag.

Fixed by checking `t == "--"` BEFORE the dash-stripping and breaking
the scan there, per review. A lone "-" (the stdin convention, e.g.
`pad item import -`) is a different token — it still reaches the
dash-stripping, still reduces to an empty name, and still just
`continue`s past — so it does not terminate the scan.

Three new unit tests in internal/cmdhelp cover all three legs: (a) a
dash-led positional after "--" produces no finding, (b) an unknown
flag BEFORE "--" is still caught — the break cannot hide drift ahead
of the terminator, and (c) a lone "-" does not stop the scan (an
unknown flag after it is still caught). Manually re-ran (a) with the
break removed to confirm it goes red without the fix, then restored
the real fix — ran, not just described: FAIL "a dash-led positional
after -- should not be reported as an unknown flag" without the break,
PASS with it.

The playbook match example itself is unchanged.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 11:28:57 -04:00
xarmian 442dcbdc6a feat(decision): instance-admin settings for the decision provider (TASK-3121) (#1426)
console/admin/settings gains a Decision provider section with a provider select,
an enable toggle, a write-only API key stored encrypted, a model pin, and the
privacy line. It is backed by GET/PUT /api/v1/admin/decision-provider.

- The admin setting is a third source to decision.ResolveNamed:
  config file < admin setting < environment, per field. Resolve is now
  implemented on ResolveNamed, so the page's "set by environment" comes from
  the same pass that builds the provider.
- Changing the setting rebuilds the provider and swaps the runner in place.
  The tick and the write doors' enqueue resolver both follow the swap, so no
  restart is needed. A swap cancels any pass in flight, so a disabled provider
  is not called again; the resolver is installed before the runner is
  published; and no tick can start after Stop().
- The key is encrypted through the PAD_ENCRYPTION_KEY path
  (store.SetSecretPlatformSetting). It is never returned by any read, masked
  or otherwise, and it is not writable through /admin/settings. The store
  refuses a secret write without an encryption key (`pad server` never
  reaches that state), and it refuses to READ a secret row that is not
  ciphertext.
- A PUT to a field the environment sets is refused with 409
  set_by_environment. On Pad Cloud the environment is the only source and PUT
  answers 403 managed_by_operator.
- `pad server info` still resolves only the file and the environment on the
  CLI host. It now says so, in an additive `sources` JSON member and in its
  human line.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-22 07:34:33 -04:00
xarmian 1681be74f5 feat(decision): content-aware attention on the dashboard and item page (TASK-3118) (#1421)
* feat(decision): attention question set on the dashboard (TASK-3118)

Registers the `attention` set (needs_human_decision and blocked verbatim
from the day-73 eval, plus waiting_on_external) through a production
registry. A new QuestionSet.Eligible gate, checked by the runner at
evaluation time, keeps terminal items and system collections from being
sent. The dashboard reads the latest answers to the current questions in
one batch query: needs_human >= 0.7 becomes a "needs_human" attention entry,
and blocked >= 0.7 joins the blocked section only where the dependency
graph is silent. A failing provider marks attention.decisions degraded.

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

* feat(web): attention chips on the item page, dashboard icons (TASK-3118)

DecisionChips fetches the item's decisions itself (the item object is
also fed by projections that do not carry them) and shows only CURRENT
attention answers, emphasised at the dashboard's 0.7 threshold. It renders
nothing without a provider. A request token keeps a switched-away item's
late response from painting. It is listed in the BUG-3095 child
population, and stubbed in the ItemDetail identity harness like its
self-fetching siblings.

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

* test(server): threshold edge and below-threshold blocked legs (TASK-3118)

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

* docs: attention set in CLAUDE.md; registry comment no longer says empty (TASK-3118)

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

* fix(decision): dashboard attention applies the item page's currency rule (TASK-3118)

Codex round 1: the dashboard read the latest answer without checking it
against the item's present state, so an item edited or commented since
its last evaluation kept a stale "needs a human" entry, while the item
page, which hides non-current answers, showed nothing. WorkspaceFlags now
returns only CURRENT answers at or above the threshold. It does one state
read per flagged item, so the cost is bounded by the flagged items rather
than the workspace. Also adds a leg for an item made terminal by a
collection schema edit, which changes no item state; mutant M11 survived
without that leg.

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

* fix(decision): an answer is current only while its set still applies (TASK-3118)

Codex round 2: a collection schema edit can make an item terminal without
changing its state hash. /decisions then kept calling its attention
answers current, and the item page kept showing chips the dashboard had
dropped. Evaluate, Decisions and WorkspaceFlags now share one appliesNow
rule, and the dashboard's separate done-skip is gone.

Declined from the same round: a TOCTOU between the dashboard's state read
and its response. An edit landing after the read is reflected on the next
load, which holds for every read in the handler.

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

* fix(store): renumber the decisions workspace index to 092/069 after #1419 took 091/068 (TASK-3118)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 23:55:54 -04:00
xarmian 304673faa1 feat(decision): typed-decision storage and async runner (TASK-3117) (#1417)
* feat(store): item_decisions + decision_jobs schema (TASK-3117)

Answers keyed on (item_id, question_set, question_key, state_hash) with
item_seq kept for the audit; a private per-(item, question_set) job queue
with generation-based coalescing and lease reclaim, in place of the
public event outbox. NUL census: three columns excluded with reasons,
fifteen id/timestamp/enum columns baselined.

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

* feat(decision): job queue store layer + Runner with state-hash idempotency (TASK-3117)

Item create, item update and comment create enqueue a coalesced job per
applicable question set in their own transaction; nothing waits on the
network. The Runner builds one serialised state (title, collection,
fields, bounded body, last RecentTrailWindow comments), hashes those
exact bytes, and sends the same bytes as json.RawMessage.

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

* test(decision): queue, runner and read pins; move enqueues (TASK-3117)

Move is a door (ruling 3, keyed on the target collection's sets). The
three system fan-out rewrites are named as non-doors at their sites.
Tests pin: each door enqueues and coalesces; nil resolver writes no row;
lease reclaim; mid-evaluation re-enqueue survives completion; backoff;
latest-per-key read; hashed bytes == wire bytes; a comment re-evaluates
at an unchanged seq; reorder / parent link / role assignment move seq
but do not re-evaluate; current flips false after a comment.

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

* feat(server): decision tick, read API, decisions on item GET (TASK-3117)

Boot builds the provider from one shared resolver (also used by
`pad server info`); a nil provider means no resolver, no job rows, no
tick. GET /items/{ref}/decisions returns the latest answer per question
with current=true only when computed from the item's present state; the
single-item GET and the agent view carry `decisions` when non-empty. A
shutdown releases in-flight claims without counting a failed attempt.

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

* test(decision): cancelled pass releases; agent view carries decisions (TASK-3117)

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

* feat(store): comment edit/delete enqueue; mid-call cancel test (TASK-3117)

Comment edit (when the body changed) and comment delete change the
hashed trail, so they are doors under ruling 3's rule. Adds the
mid-call shutdown test that mutant M20 showed was missing, and the
decisions endpoint to CLAUDE.md.

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

* fix(decision): never send an item in a soft-deleted workspace or collection (TASK-3117)

GetItem filters only the item's own soft delete, so a job owed for an
item whose workspace or collection was soft-deleted would have sent that
content to the third-party provider during the restore window. The
runner now checks three-way liveness (the reminder tick's rule) before
building state, and drops such a job.

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

* fix(decision): drop is generation-matched; inapplicable sets are not asked (TASK-3117)

Codex round 2. P1: DropDecisionJob deleted the row whatever its
generation, so a write that landed while a run was hitting its retry
limit (or a gone item) was lost; it now deletes only the claimed
generation and otherwise releases, as CompleteDecisionJob does. P2: a
job owed before a move still named the old collection's scoped set;
Evaluate re-checks applicability against the item's current collection
and the job is dropped.

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

* fix(store): idempotency counts only the asked question keys (TASK-3117)

Codex round 3. The completeness check counted every stored key at the
state, so a RENAMED key kept the total equal and the set read complete;
the new key was never asked at an unchanged state. The comment claiming
over-counting was only possible in the safe direction was wrong and is
replaced.

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

* fix(decision): claim one job at a time; restore enqueues; recheck before ask (TASK-3117)

Codex round 4. Claiming a whole pass up front let the last job wait out
its lease behind earlier provider calls (worst case ~330s per job) and be
re-claimed elsewhere; jobs are now claimed one at a time and the lease
comment carries the arithmetic. Restore is a door: a job dropped while
the item was deleted left it stale with nothing scheduled. Liveness is
re-checked as the last statement before the send, with the residual
window stated. Collection rename / field migration not enqueuing is the
ruled fan-out gap and stays.

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

* fix(decision): hold jobs of a soft-deleted workspace for its restore (TASK-3117)

Codex round 5: a job processed while its workspace was soft-deleted was
dropped, and a workspace restore (a fan-out, so it enqueues nothing)
never brought the work back. Such jobs are now held: the claim scan
skips them and they resume on restore; a purge cascades them away.
Collections have no restore path and still drop. Liveness is one guard,
the last statement before the send; the earlier copy in State was
redundant (mutant M26 survived its removal) and is removed.

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

* fix(decision): answers are keyed on question+model; outages never drop work (TASK-3117)

Codex round 6. An answer is now current only for the question definition
and model that produced it (question_hash, per key): rewording a question
or re-pinning the model at an unchanged item state re-asks. Jobs are
dropped only after repeated PERMANENT refusals (a 4xx other than 429);
transient failures back off to an hourly cap and are never dropped, so a
provider outage loses no owed evaluation. Adds the RunOnce-level test for
a workspace deleted mid-job (mutant M35 survived without it). The empty
production registry is the contract ("ships the rail with an empty
registry"), not a defect.

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

* docs(store): name workspace import as a non-door for decisions (TASK-3117)

Codex round 7. Import is the one item-creation path that bypasses
createItemTxWithID; it stays off the decision queue for the same
fan-out reason SPEC-3 keeps it off events/1. Named at the site and in
CLAUDE.md, and raised to the lead as an open policy question.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 20:46:41 -04:00
xarmian 096584c349 feat(decision): pluggable typed-decision provider with a typesafe.ai backend (TASK-3116) (#1411)
* feat(decision): pluggable typed-decision provider with a typesafe.ai backend

Unit 1 of PLAN-3114 (TASK-3116). Foundation only — no product behaviour, no
storage, no runner, no call sites.

internal/decision exposes three primitives (Choice, Score, Noul) answered in
one round trip, a pinned-model typesafe.ai backend, and one configuration
resolver. Unconfigured returns a nil Provider and no error, so every later
caller branches on nil and behaves exactly as today.

Three wire-shape facts are pinned to measurements rather than to the vendor
docs, which are silent or wrong on each. Receipts are on TASK-3116's trail.

- A Noul answer carries NO confidence and NO probabilities. Confidence is
  therefore a *float64: 0.0 is not a neutral stand-in for absent, it is the
  most extreme value the scale has, and a caller following the vendor's own
  recommended banding would read every Noul answer as minimum confidence and
  route all of them to a human.

- Score is the probability-weighted MEAN over level indices, not a level
  index. Measured: probabilities {"0":0.86,"1":0.14} returns score 0.14.
  Answer.TopLevel() is the correct way to ask which level was picked;
  int-casting Score gives the wrong level for any distribution whose mass
  does not sit on index 0. Score's request criteria is an ordered ARRAY and
  its probability keys are stringified level INDICES, where Choice's are
  option NAMES — same field, two vocabularies, decided by Kind.

- An over-budget request is refused with HTTP 400 and
  {"detail":{"error_type":"max_tokens_exceeded"}}, not the 422 the task
  contract predicted. It is matchable with errors.Is(err,
  ErrMaxTokensExceeded) rather than by matching prose. 422 keeps its own
  passthrough, with the body verbatim because the vendor's validation
  messages name the offending question.

The token budget is measured, not cited: bisecting on the API's own
usage.input_tokens, 32,476 tokens was accepted and ~33,600 refused, and the
budget covers state AND questions together. maxRequestTokens is 32,000, under
the measured accepted figure. Truncation is a conservative char-based bound
and says so — it cannot be a guarantee for pathological input, which is why
the provider's own refusal is surfaced as a typed error instead of swallowed.

Config resolution has exactly ONE reader so that TASK-3121's instance-admin
setting adds a SOURCE rather than a second reader. The environment is passed
last because it overrides the admin setting (ruled day 73). That is why
ProviderNone exists: with an empty value meaning "this source has no
opinion", an environment that wants to disable what an admin enabled must say
so with a value. internal/config carries the config-FILE fields only and
documents why its env block deliberately does not read these three variables.

`pad server info` reports config.decision_provider as {name, model}, always an
object with name "none" when unconfigured rather than alternating between an
object and a bare string. It sits under `config` because that is what it
describes — this host's resolved settings; that command does not inspect
remote server internals, so against a remote server it is the CLI host's view.

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

* test(decision): sweep the truncation cut offset; pin the measured constants; add a live-provider leg

Three additions, each closing a hole an instrument found rather than one I
reasoned my way to.

The rune-boundary assertion moves out of the truncation test and into
TestFitStateCutsOnRuneBoundaries, which sweeps the cut OFFSET by varying the
questions' size. A mutant deleting the rune-boundary loop entirely SURVIVED the
original single-offset assertion: that one offset happened to land on a
boundary, so the green could not go red. Eight offsets over two-byte runes now
land mid-rune several times and the mutant dies.

TestBudgetConstantsStayInsideTheMeasurement guards maxRequestTokens and
charsPerToken, whose correctness is a fact about the live provider that no
behavioural test can check. Without it, raising either to "use more of the
window" gives a green suite and a production 400.

TestLiveProviderAllThreePrimitives calls the real API, skipped unless
DECISION_LIVE=1, so CI and `make test` never reach it. It exists because the
httptest fake cannot answer its question: the fake replays bodies I wrote and
asserts shapes I chose, so an encoder and a fake sharing the same wrong field
name agree with each other and both pass. Run live, it confirms choice
probabilities are keyed by option name, score's legend echoes the levels sent,
score really is the probability-weighted mean of the level indices (asserted
against the live numbers, not a recording), usage.input_tokens decodes, and —
the assertion the whole pointer design rests on — a noul answer carries NO
confidence on the live wire.

Mutation matrix, after re-expressing the two mutants that did not compile
(a BUILD-FAIL is not a kill): 21 mutants, 21 DETECTED, each by its own NAMED
expected test, against a baseline stated green first. Harness restores by file
copy and verifies the restore before the next mutant.

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

* wip(decision): round-1 review fixes — six findings fixed, matrix not yet re-run

WIP stand-down commit (Dave, day 73: account at its usage limit). Not pushed,
no PR. Gates and the mutation matrix have NOT been re-run against these fixes.

Six P2 findings from review round 1 (backend: opencode/gpt-5.6-luna), each
confirmed mechanically before accepting:

1. The backoff ignored context cancellation, so a cancelled Ask waited out the
   full wait before a doomed retry. The sleep hook now takes a context and
   returns its error; production installs ctxSleep (timer vs ctx.Done()).
2. A body read that failed partway was discarded, so a caller could not tell an
   absent detail.error_type from an unread one. APIError.BodyReadErr keeps it.
3. Exhausted retries wrapped a *transientError, which is not an *APIError, so
   errors.As could not reach the status. Verified false in a standalone
   program; transientError now has Unwrap.
4. Truncation was sized on RAW string length while encoding/json escapes <, >
   and & to six-byte sequences — measured expansion exactly 6.00x — so a state
   of '<' passed the budget check and then drew max_tokens_exceeded. The cut is
   now sized on marshalled length, shrinking by the observed expansion ratio.
5. An answer whose kind differed from its question's was accepted, and fromWire
   then left the requested kind's member at a plausible zero: a noul question
   answered with a score read as Noul == 0.0, a confident "false" the provider
   never said. Ask now refuses a kind mismatch.
6. TopLevel ordered only on the parsed index, so distinct keys sharing an index
   ("1" and "01") or several unparseable keys sharing the sentinel were
   tie-broken by randomised map iteration order. The sort is now a total order.

Two things found while fixing, not by the reviewer:
- runeBoundaryAtOrBefore indexed s[len(s)] and would panic; it returns early now.
- The first cancellation test never reached the backoff it claimed to test:
  cancelling during the response makes http.Client.Do fail, so Ask returned
  from the transport branch having never slept. Split into two instruments —
  one pinning that Ask honours an interrupted wait, one pinning that the wait
  installed in production is interruptible.

State: internal/decision tests pass (go test exit 0, run before the stand-down).
NOT done: re-running make test / lint / test-pg, and the 29-mutant matrix, which
was killed mid-run. It had mutated typesafe.go in place; the tree was restored
from the harness backup and all eight fix sites verified present before this
commit.

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

* test(decision): make two mutants die by their own tests

M17 (missing-answer check removed) survived: the answer-kind check also
refuses a missing answer, since it decodes with an empty Kind, and also
names the key — so the test could not tell which check fired. It now pins
the "no answer" diagnosis, which is the difference a caller sees.

M23 (uncancellable ctxSleep) was only caught by go test's global timeout,
which panics the whole binary after 90s. The newTypesafe leg now runs
under its own 2s deadline and fails as a named test.

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

* fix(decision): review round 2 — check every answer against its question

Round 1 added the answer KIND check; round 2 found the rest of that
population. An answer is now refused if it is missing a member its kind
requires, names a choice or a level key that was not sent (including
non-canonical keys like "01"), or carries a score, noul, confidence or
probability outside its range. Pointers on the wire answer make an absent
member distinguishable from a zero one. Unsolicited extra answers are
dropped: the result is one entry per question key.

Also:
- The envelope reservation is the marshalled request with an empty state,
  not a fixed 512 bytes, so a long model name cannot push a fitted request
  over budget; the first cut leaves room for the quotes, so an unescaped
  state fits exactly rather than 5% short.
- APIError unwraps to BodyReadErr, so errors.Is sees a partial read.
- server info: a test through collectServerInfo and printServerInfo, the
  two doors the direct-call tests could not see.
- Prose made true: APIError's doc (it is also reachable through the retry
  wrapper, and printing the body is measured safe), fitState and
  marshalledLen (bytes are a heuristic for tokens, not a bound), Legend,
  TopLevel on hand-built answers, and "one round trip".

Declined, reasons on TASK-3116's trail: key-character limits (a live probe
accepted "q/na me\n" as a key); a score-level ceiling (none documented,
none measured — the provider's own refusal still surfaces); body-in-Error()
leaking state (three refusal shapes probed, none echoes the request); and
Close errors on an already-read body.

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

* test(decision): widen the rune-boundary sweep to 64 offsets x three rune widths

The round-2 quote-room change made a mid-rune first cut fail the size
check on its own (json.Marshal turns the lone lead byte into a six-byte
�), so the 8-offset sweep stopped catching a deleted boundary loop.
Measured with the loop deleted: invalid UTF-8 escaped at 18/64, 29/64 and
43/64 offsets for 2-, 3- and 4-byte runes; the old sweep caught none.

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

* fix(decision): review round 3 — no empty state below two bytes of room

With 0 or 1 bytes left for the state, fitState returned "" — which still
marshals to two bytes, so the request went one or two over budget. Below
two bytes it now refuses, the same as below zero. A sweep walks the room
across that edge one byte at a time and holds the budget at every step.

Also: runeBoundaryAtOrBefore's doc no longer claims to produce valid UTF-8
from invalid input (json.Marshal rewrites invalid bytes on any send, cut or
not, and marshalledLen measures the rewritten form); the server-info test
prints a report whose values the environment does not hold, so a printer
that re-resolved the environment would fail.

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

* fix(decision): review round 4 — the two-byte refusal is for string states only

Round 3 put the "no room below two bytes" refusal before the string/
structured split, so a structured state as small as `0` (one byte) was
refused although it fits, and the message claimed the questions alone
exceeded the budget, which was not true at 0 or 1 bytes left. It now sits
after the split, applies only to a string that does not fit, and says what
it means. The quote-edge sweep gains a structured leg.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 17:31:25 -04:00