Skip to content

Stamp lateness once: late_after at dispatch, completed_late at completion - #105

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-52-lateness-stamps
Sep 1, 2026
Merged

Stamp lateness once: late_after at dispatch, completed_late at completion#105
os-warren merged 4 commits into
mainfrom
claude/issue-52-lateness-stamps

Conversation

@os-warren

@os-warren os-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #52
Fixes #48

Two write-once stamps on duly_task, and the on-time rate stops being a query the grammar cannot express.

field written rule in force makes possible
late_after = due_date + grace_days at dispatch, by the planner the duty's grace at the moment the task was created the Late lens is late_after < {today} — a plain date filter
completed_late = completed_at > late_after at completion, by the existing hook the same, carried on the row the on-time rate is a count over a boolean

Both readonly, hook- and planner-owned, never recomputed. objectstack#14104 stops being a blocker rather than getting resolved — with late_after on 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:

  • a task already completed keeps its verdict;
  • a task already dispatched keeps the deadline it was born with.

Pinned by test/task-hook.test.tsWRITE-ONCE — editing a duty's grace never rewrites history, which inserts a real duly_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 writes late_after except to fill a blank, and both columns are readonly so 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 the late view's comment names it so the next person finds it. Not built here.

A task with no grace_days still gets a deadline

A null grace producing a null late_after would 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:

  • the dispatcher stamps due_date + duty.grace_days (DISPATCH_DUTY_FIELDS now reads grace_days — one line, as the decision comment predicted);
  • every other producer — the assignment fan-out (duty unset), a member's own task — gets the hook's late_after = due_date fallback at insert, which is exactly what "no duty governs this row" means;
  • no due_date at 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_after is 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_late is read off this row's late_after, so it is precisely the rewrite ADR-0058 Addendum II D3 puts outside the contract — worse than completed_at, because two rows in one batch can legitimately disagree. Ablating the guard measures what that costs, on a real engine:

measure on-time  late_after=2026-09-06  completed_late=true    ← wrong: five days of runway left
measure late     late_after=2026-08-27  completed_late=true

One dispatch decided the compliance record for both rows. Neither of the module header's two existing answers fits:

  • refuse everything would delete a feature the product is built around;
  • write nothing — the answer last_update_at gets — 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 computes false, 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 (null is right for every row leaving done).

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:

POST /api/v1/data/duly_task/updateMany
{"records":[{"id":"…","data":{"status":"done"}},{"id":"…","data":{"status":"done"}}],"options":{"continueOnError":true}}

One payload per record. A two-row selection of one late and one on-time task completed in a single gesture, with completed_late landing true and false on the right rows. The shared payload the guard is about is the multi: true + where shape an import, a backfill, an MCP caller or a filtered REST update assembles — exactly the caller that never reads src/views/task.view.ts, which is why the authority lives at the write and why this refusal deliberately has no visible-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/TERM restore trap, and the fix was committed first so restoring was git checkout rather than a hope.

ablation result
bulk late-completion guard disabled 4 red — the refusal, the all-or-nothing, the order-independence, the all-late batch
insert-leg late_after fill removed 1 red — a fan-out task born with no deadline
verdict >>= (off by one) 3 red — including the last-day-of-window case
a recompute of late_after added on update 8 red — including a completed task keeps its verdict
grace_days dropped from the demo's duty projection 1 red — every seeded deadline collapses onto due_date, silently

That 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 seed

The Late lens now shows why it thinks a task is late. late_after is 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.

Late view

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.

Duty health dashboard

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 the main merge 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-01 on an insert had it stripped, and the row stored the hook's 2026-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 after column 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 untried

format on 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 print 94.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 as 1.0%. Filed as #101.

Findings filed, not fixed here

What else moved, and why

  • AGENTS.md rule 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 read due_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, so on_time_rate is governed exactly because tasks_done_on_time and tasks_done are.
  • The demo seed feeds the catalog's grace into the planner, so the seeded history is what the real dispatcher would have produced: 149 completions, 15 of them late, and an on-time rate that is a number rather than 100% by construction.

main moved mid-task, and #103's gate caught this PR

origin/main advanced from 75d086f to 485c0c1 (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:

  1. duly_task.late_after and completed_late — label and help, four keys with no zh-CN entry. Hand-translated, using the bundle's existing 宽限期 for grace so a reader can connect the two fields to duly_duty.grace_days.
  2. The on_time_rate widget's title and description — same, as 按期率, the card's own word.
  3. Three dataset slots the walk had never seen: measures[].derived.op, measures[].derived.of[] and measures[].format. All classified machine — an operator, other measures' names, and a numeral pattern. None is language.
  4. The count pin for untranslatable dataset 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-CN dashboard 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:

validate=0  typecheck=0  test=0  build=0   @ 5c4d04a
  ✓ Validation passed (385ms)      (one expected warning: hierarchy-security, per AGENTS.md)
  Test Files  29 passed (29)
      Tests  743 passed (743)
  ✓ Build complete — dist/objectstack.json (172.8 KB)

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 in AGENTS.md). The four gates are the whole contract.


Generated by Claude Code

os-warren and others added 4 commits September 1, 2026 16:02
…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
os-warren marked this pull request as ready for review September 1, 2026 16:38
@os-warren
os-warren merged commit dd3c619 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

1 participant