Files
pad/internal
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
..
2026-03-26 01:52:36 +00:00