mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
17 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 (
|
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 (
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|