Skip to content

Do not stamp last_update_at on the shared-payload bulk path - #92

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-78-bulk-last-update-stamp
Sep 1, 2026
Merged

os-warren merged 1 commit into
mainfrom
claude/issue-78-bulk-last-update-stamp

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #78

Implements the decision in the triage comment: on the per-row dispatch path
(ctx.dispatch.mode === 'per-row'), task.hook.ts does not stamp
last_update_at at all. Same detection as #39's guard, opposite response —
#39 refuses because a wrong completed_at corrupts a historical fact, while
here the honest answer is to write nothing rather than write one row's truth
onto all of them.

The defect, reproduced before the change

The batch the card describes — one row that genuinely changes its note, one
row that already holds that exact note — measured against the booted engine on
0557a57, hook unchanged:

FAIL test/task-hook.test.ts > last_update_at on a predicate write > does NOT advance the clock of a row that changed nothing
AssertionError: row A's edit must not quiet row B's stagnation clock:
  expected '2026-09-01T11:05:04.243Z' to be '2026-09-01T11:05:04.237Z'

Four of the six new assertions were red before the three-line change and all
six are green after.

Why writing nothing is safe, not merely convenient

Stated in the hook's module header, because it is only safe while it stays
true. Stagnation is defined over open work: duly_stagnation filters
status IN ('open','in_progress') on every measure, and the "Not moving" lens
does the same. The two bulk actions this product ships — complete
(status: 'done') and skip (status: 'skipped') — move every row they touch
out of that set, so a row leaving a bulk write with a stale clock is one no
stagnation query will evaluate again. "Bulk completion would look like
stagnation" describes a state that cannot occur.

The real deliverable: test/bulk-stagnation-premise.test.ts

That premise is a fact about bulkActionDefs, not about the hook, and a bulk
"set note" or bulk reassign would falsify it silently — the symptom is a frozen
clock, not an error. The guard reads the real metadata on both sides rather
than restating it:

  • The stagnation set is read out of duly_stagnation's own measure filters
    and out of the lens's own filter, found structurally (any duly_task view
    filtering on last_update_at), and the two readings are required to agree. A
    set hand-copied into the test would have kept agreeing with itself after
    someone widened the real one. The guard then uses their union, which is the
    fail-safe direction if they ever diverge.
  • The bulk actions are read out of dulyViews, filtered to views bound to
    duly_task, so a def added on any view is inspected without editing this
    file. Each must be an operation: 'update' with a literal status in its
    patch, that status must be a declared duly_task option, it must be
    outside the stagnation set, and no param may put status back in the
    caller's hands (params merge over the static patch). A def with no status
    fails: its rows keep the status they had.
  • A vacuity check pins that the walk actually reaches
    src/views/task.view.ts — a rename that made taskBulkDefs() return nothing
    would otherwise leave the guard passing on an empty list.

Ablation — the guard was proven able to fail, both legs mutation-confirmed
on disk and restored by an EXIT INT TERM trap:

Mutation Confirmed on disk Result
add a bulk set note def, no status in patch injected marker ×1, bulk defs 2→3 5 red — "writes no status, so its rows keep the one they had — including in_progress/open"
bulk complete rewritten to status: 'in_progress' status: 'done' ×1→0, status: 'in_progress' ×0→1 5 red — "leaves its rows in the stagnation set ('in_progress')"

Five failures per leg because the shared bulkActions array is offered on five
views. Tree restored byte-identically afterwards (git diff HEAD empty).

Both boundaries held, checked rather than assumed

  • Single-record writes are unchanged. The skip turns on
    dispatch.mode === 'per-row', not on the event, so mode: 'record' keeps
    the row-conditional stamp. Pinned by a new assertion that a by-id note edit
    still advances the clock — it is what would go red if the skip were written
    as "never stamp on update".
  • The seed's mode: 'update' backdating pass still works.
    test/seed-history.test.ts run explicitly: 45 passed alongside
    task-actions. It depends on beforeUpdate refusing to stamp, and this
    change can only make the hook stamp less, never more.

No changeset: this repo has no changeset mechanism. The four gates are the
whole contract.

Gates

All four green on 69c3c8e, the final commit, in one run:

✓ Validation passed (441ms)
tsc --noEmit                      → no output
Test Files  26 passed (26)
     Tests  662 passed (662)
✓ Build complete (587ms)

The one warning is the documented, expected hierarchy-security capability
notice that AGENTS.md says not to silence.

Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Reviewed — merging. The guard is the thing, and it is built the right way round.

I asked for a guard pinned against the real bulkActionDefs rather than a hand-copied list of status values, because a guard that restates the assumption is not a guard. This goes further than I asked, and the extra is the good part:

  • the stagnation set is read out of duly_stagnation's own measure filters;
  • the lens is found by shape — any duly_task view filtering on last_update_at — not by name, so renaming "Not moving" cannot orphan the check;
  • the two readings must agree with each other, so a change to either the dataset or the lens that pulls them apart fails here rather than silently making one of them the wrong population;
  • the bulk defs are walked out of dulyViews, so a def added to any view is covered without anyone remembering to update a list;
  • and there are explicit non-vacuity assertions — "no status filter found on duly_stagnation — this guard would be vacuous", "no view filters on last_update_at — the lens has gone missing". A guard that can quietly become a no-op is the failure mode this repo keeps rediscovering; saying so in the assertion message is the cheapest possible defence against it.

Requiring the patch status to be literal and declared, with no status param able to override it, closes the obvious hole — a bulk def that takes the status from user input would otherwise sail past.

Two ablation legs, both from the committed state so the restore was a real recovery point: injecting a bulk set note def with no status, and rewriting bulk complete to patch: { status: 'in_progress' }. Each turned the guard red with a message naming the offending def, five failures per leg because the shared bulkActions array is offered on five views. Mutations confirmed on disk in both directions before any reading. That is the guard doing exactly the job it was written for.

Red-first on the hook itself was the card's own batch, and the failure message is the card's own sentence:

row A's edit must not quiet row B's stagnation clock:
expected '…11:05:04.243Z' to be '…11:05:04.237Z'

Both boundaries checked rather than assumed, which is what I asked for and is easy to skip: seed-history and task-actions re-run green (45 passed) — the seed's mode: 'update' backdating pass depends on beforeUpdate refusing to stamp, and this change can only make the hook stamp less — and a new by-id assertion pins that a single-record note edit still advances the clock. That second one is well chosen: it is precisely the assertion that goes red if someone later simplifies this into "never stamp on update".

Gates, re-run by me on the head: validate 0, typecheck 0, test 0 — Test Files 26 passed, Tests 662 passed — build 0, and the branch already carries current main. Also worth noting the report checked for a handler-lowering warning and found none: after #24, that is the right reflex on any change to a hook.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 11:15
@os-warren
os-warren merged commit 1fbae3e into main Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A bulk write stamps last_update_at on rows that changed nothing — one row's edit quiets the stagnation signal for its whole batch

1 participant