Uh oh!
There was an error while loading. Please reload this page.
Stamp lateness once: late_after at dispatch, completed_late at completion - #105
Merged
Conversation
…t completion Two write-once stamps make lateness a plain date comparison everywhere, so objectstack#14104 stops being a blocker rather than being resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The console's bulk complete posts one payload per record (updateMany), so the shared-payload guard costs the UI gesture nothing; and a measure `format` is a numeral pattern, not a keyword. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
#103's coverage gate went red on merge, exactly as it is built to: the two new field labels and the new widget had no bundle key, three dataset slots had no verdict, and the zh-CN dashboard description still explained why lateness was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
marked this pull request as ready for review
September 1, 2026 16:38
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#52
Fixes#48
Two write-once stamps on
duly_task, and the on-time rate stops being a query the grammar cannot express.late_after=due_date + grace_dayslate_after < {today}— a plain date filtercompleted_late=completed_at > late_afterBoth
readonly, hook- and planner-owned, never recomputed. objectstack#14104 stops being a blocker rather than getting resolved — withlate_afteron the row there is no column-to-column comparison left to make at query time.Write-once is the design, not an optimisation
An admin who widens a duty's grace from 3 days to 14 is correcting a configuration. They are not re-adjudicating last quarter's compliance record. So:
Pinned by
test/task-hook.test.ts› WRITE-ONCE — editing a duty's grace never rewrites history, which inserts a realduly_duty, produces the task through the real planner from that duty's own fields, then edits the duty through the engine and re-reads the task. Both stamps hold. The mechanism is that nothing recomputes them: no leg of the hook writeslate_afterexcept to fill a blank, and both columns arereadonlyso the boundary strips a caller's value.The cost is real and belongs to somebody: an admin who has just corrected a misconfigured grace will expect it to apply to open work.
duly_catalog_sync— which already exists to replay duty edits onto instantiated records — is where that belongs, it does not do it today, and thelateview's comment names it so the next person finds it. Not built here.A task with no
grace_daysstill gets a deadlineA null grace producing a null
late_afterwould be a task that can never be late on any surface — the same silent-wrong-answer class this card removes. The rule is absent grace reads as zero, which is not invented here: it is what the overdue escalation already does (has(grace_days) && !isBlank(grace_days) ? grace_days : 0,src/flows/reminders.flow.ts), so the two surfaces now agree by construction rather than by coincidence.Three producers, three answers, all tested:
due_date + duty.grace_days(DISPATCH_DUTY_FIELDSnow readsgrace_days— one line, as the decision comment predicted);dutyunset), a member's own task — gets the hook'slate_after = due_datefallback at insert, which is exactly what "no duty governs this row" means;due_dateat all → no deadline, and the row can never appear in a lateness lens. Asserted through a real query rather than by reading the column, because "a blank does not match a date filter" is the claim the view's comment makes.late_afteris the LAST day inside the window, so a zero-grace duty is late the morning after its due date — the same day one the escalation ladder fires on (due_date + grace + 1). is false on the LAST day of the window, not true pins the off-by-one that would otherwise give every duty a day less grace than it says.The shared-payload decision, made deliberately
completed_lateis read off this row'slate_after, so it is precisely the rewrite ADR-0058 Addendum II D3 puts outside the contract — worse thancompleted_at, because two rows in one batch can legitimately disagree. Ablating the guard measures what that costs, on a real engine:One dispatch decided the compliance record for both rows. Neither of the module header's two existing answers fits:
last_update_atgets — is safe there only because nothing reads that column on that path. Here the on-time rate reads exactly these rows, so a null verdict would be a hole in the flagship metric.So the guard is asymmetric, and the asymmetry is what makes the write sound: a row that would be stamped late refuses the write (
DULY_TASK_BULK_LATE_COMPLETION, 409, naming the task and the day its grace ran out); every row that survives its own guard computesfalse, which is the value every matched row would have written. Row-invariant by construction, not by luck — decided from each row alone, in any dispatch order, with no accumulator. A batch is wholly on time and stamped correctly, or refused. Bulk skip is untouched, and so is the clearing direction (nullis right for every row leavingdone).What that costs the console: nothing — measured, not assumed
The obvious fear is that ticking a week of work now fails whenever one row is late. It does not, and this is the one thing I would have got wrong by reasoning: the console's bulk action is not a predicate write. Recorded off the wire against a live
pnpm demo:One payload per record. A two-row selection of one late and one on-time task completed in a single gesture, with
completed_latelandingtrueandfalseon the right rows. The shared payload the guard is about is themulti: true+whereshape an import, a backfill, an MCP caller or a filtered REST update assembles — exactly the caller that never readssrc/views/task.view.ts, which is why the authority lives at the write and why this refusal deliberately has novisible-predicate half.Reverse-verified, not assumed green
Every new guard was ablated: the mutation was confirmed on disk by an anchored count before the reading was taken, each script carried an
EXIT/INT/TERMrestore trap, and the fix was committed first so restoring wasgit checkoutrather than a hope.late_afterfill removed>→>=(off by one)late_afteradded on updategrace_daysdropped from the demo's duty projectiondue_date, silentlyThat last one is the fixture's own tripwire: without it the demo would revert to the grace-free reading with every count unchanged and nothing erroring.
Browser verification —
pnpm demo, fresh seedThe Late lens now shows why it thinks a task is late.
late_afteris a column, not just a filter — the complaint behind #48 was a screen that would not show its reasoning. Row 1 is due Aug 3 and late after Aug 5: two days of grace, applied, visible.The dashboard carries the number the product is sold on. The paragraph explaining why lateness was absent is gone from the
description, replaced by what the rate is a rate of.Screenshots live on a throwaway branch (
claude/issue-52-shots, an orphan commit holding the two PNGs) so no binaries enter this diff. They were taken before themainmerge below and re-taken after it on a wiped database — both PNGs came back byte-identical (same blob hashes), so these are images of the tree this PR ships.Also measured live, through the REST boundary: a caller supplying
late_after: 2026-10-01on an insert had it stripped, and the row stored the hook's2026-08-29— the readonly column has one writer.What the screenshots cannot show: the seeded demo currently holds no task sitting inside its grace window (all four Late rows are weeks past due, so they are late under either definition). The grace is visible as the
Late aftercolumn and pinned in the unit tests; a fixture row engineered to sit in that window is not deterministic for a monthly duty against a moving calendar, so it is not faked here.The on-time tile reads
0.94, and the percent spellings are refuted rather than untriedformaton a measure is a numeral pattern, not a keyword. Measured in the browser against this demo's 0.94: no format →0.94;format: 'percent'→1(no%in the pattern, so it is not a percent at all — just zero decimals);format: '0.00'→0.94, which ships.'0.0%'would print94.0%and is deliberately not used: percent mode scales by a heuristic (value > -1 && value < 1 ? value * 100 : value) because a measure, unlike a field, cannot declare its scale — so a rate of exactly 1, a perfect month, renders as1.0%. Filed as #101.Findings filed, not fixed here
late_after = due_date— the dispatcher is the only producer that appliesgrace_days#100 — a task created by hand on a duty that grants grace (today, anyone_off) is stamped at zero grace, because the hook cannot read the duty: a lowered handlerbodyships without its module scope. The escalation waits, the Late lens does not — the same two-answers shape this card removes, surviving in the corner it does not reach. Not a regression: before this change the lens was grace-free for every task alike. The boundary is written into the hook where the next reader hits it.1.0%#101 — the percent-format gap above.What else moved, and why
AGENTS.mdrule 5 now carries the exception and its exact boundary: a value may be stored when it is written once, at the instant it becomes knowable, and never recomputed. The test is not "is it derivable" — everything here is — but "would a second write ever have to happen". Without that written down, the next agent reads these two columns as permission to store a genuinely drifting flag.test/dashboard.test.ts's "the numbers that must stay absent until 按期率算不出来 —— grace_days 现在没有任何东西读它,"逾期"在两个界面上含义不同 #52 decides what late means" was the right rule while the only expressible version ignored grace. It is replaced, not deleted: every lateness number a widget binds must trace back to the stamps, and no measure behind it may readdue_date— the grace-free window stays banned, now on the measure side as well as the widget side.test/datasets.test.ts's caliber walk reaches a derived measure through its operands instead of exempting it, soon_time_rateis governed exactly becausetasks_done_on_timeandtasks_doneare.mainmoved mid-task, and #103's gate caught this PRorigin/mainadvanced from75d086fto485c0c1(taking #99, #103 and #104). Merged in and re-run — and #103's i18n coverage gate went red on the merge, exactly as it is built to. Four separate findings, all of them real:duly_task.late_afterandcompleted_late— label and help, four keys with nozh-CNentry. Hand-translated, using the bundle's existing 宽限期 for grace so a reader can connect the two fields toduly_duty.grace_days.on_time_ratewidget's title and description — same, as 按期率, the card's own word.measures[].derived.op,measures[].derived.of[]andmeasures[].format. All classifiedmachine— an operator, other measures' names, and a numeral pattern. None is language.untranslatabledataset strings moved 26 → 29: three new measure labels, which still have no bundle key anywhere in the platform's schema.And one the gate could not see, because it compares keys rather than meaning: the
zh-CNdashboard description still carried the Chinese half of the paragraph this card deletes — 「逾期暂未展示…」, telling a Chinese deployment that lateness is missing, directly above the tile. Rewritten with the English one.Gates
All four green at
5c4d04a, the final commit, on the merged tree, with a clean worktree:The lowered hook body was read out of the artifact to confirm it is still one self-contained function — both inner helpers inline, no module-scope reference, so it does not fall back to the legacy bundled runtime.
No changeset: this repo has no changeset mechanism (no
.changeset/, no dependency, no script, no mention inAGENTS.md). The four gates are the whole contract.Generated by Claude Code