Uh oh!
There was an error while loading. Please reload this page.
Refuse a fractional or absurd cadence number at write time, not at dispatch - #90
Conversation
…spatch `due_offset_days`, `lead_days` and `grace_days` on `duly_duty` (and their `duly_catalog_item` templates) declared no numeric constraints, and the engine's number validator enforces `min` / `max` / `scale` only when they are declared. So `due_offset_days: 1.5` saved clean, passed `pnpm validate` and rendered fine — then threw days later inside the nightly batch, recorded as `invalid_cadence` against the JOB rather than against the duty holding it. Declarative, not scripted (AGENTS.md rule 9): `scale: 0` plus bounds. The platform's refusal was measured before deciding a hand-written rule was needed, and it already names the field by its label, the limit and what arrived — "Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)" — so nothing is layered on top of it. The three fields do NOT share a failure mode, measured one at a time: due_offset_days: 1.5 `dueDateFor` throws; run `degraded`, no tasks lead_days: 2.5 throws one function over, through `addCalendarDays`, which negates — the message names -2.5, not 2.5 grace_days: 2.5 throws nowhere. Its only evaluating reader is the overdue escalation's CEL gate, which wraps it in `int()`: `int(2.5) == 2`, so it escalates on the day a grace of 2 would, silently, forever. `grace_days` stops at 14 rather than 366 because the overdue sweep looks back 15 days and fires on `due_date + grace_days + 1`: anything above 14 is a value the product silently cannot honour. The demo catalog shipped an item with 21, which had never once escalated; it is now 14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
os-warren
commented
Sep 1, 2026
Reviewed — merging. This card was dispatched with one instruction, and that instruction is where all the value came out.I told you not to assume
The third row is the one that matters. That is not a late failure, it is not a failure at all: a configured value quietly becoming a different value, permanently, with no signal anywhere. It is the worst case in the family and it was invisible until someone measured each field separately instead of reasoning from the shared declaration. The 14 ceiling is right, and I checked the derivation rather than taking it
And it found a live instance in our own demo data. Other things worth recording
Gates, re-run by me on the head merged with current #89 is the right question to have split out, and it is genuinely distinct from #52: this is about the value's ceiling, #52 is about Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#82
due_offset_days,lead_daysandgrace_daysdeclared no numeric constraints, and the engine's number validator enforcesmin/max/scaleonly when they are declared. Sodue_offset_days: 1.5saved clean, passedpnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded asinvalid_cadenceagainst the job rather than against the duty holding the bad value. Same failure shape as #24, one field over.The two open choices, as decided on the card
1.
scale: 0, not a sixth hand-written validation. AGENTS.md rule 9 —scaleis the platform's own declarative answer to exactly this, and it does something a rule cannot: the form renders a whole-number input, so prevention comes before the error message.The card left open whether the platform's refusal is readable enough to stand alone. Measured before deciding, on a real booted engine:
It names the field by its own label, the limit, and what arrived. The bounds read the same way —
Grace (days) must be ≤ 14,Lead time (days) must be ≥ 0. That is already product voice, so nothing is layered on top of it; a rule restating a declared bound is two sources of truth for one limit, and the hand-written one is always the one that drifts.test/cadence-number-constraints.test.tspins the absence of such a rule, not just the presence of the keys.2. Bounds, as proposed:
-366..366ondue_offset_days;lead_daysandgrace_dayskeepmin: 0and gain amax."Same declaration" is not "same behaviour" — what each field actually did
Measured one duty at a time, against the real engine and the real CEL evaluator, on 17.2.0. The three do not share a failure mode:
due_offset_days1.5dueDateForrefuses it,planForDutycatches it asinvalid_cadence, the run reportsdegradedand the duty produces no tasks.dueOffsetDays must be a whole number of days, received 1.5lead_days2.5visibleFromFor, reached throughaddCalendarDays, which negates its argument. So the operator readsleadDays must be a whole number of days, received -2.5for a duty on which nobody ever typed a negative number.grace_days2.5int(). Measured:int(2.5) == 2,int(2.9) == 2. A duty declaring 2.5 days of grace escalates on precisely the day one declaring 2 does, silently, forever.The absurd-value half is the same story:
due_offset_days: 9e9was accepted, and the card's guess thatperiod.ts'sMIN_YEAR/MAX_YEARguard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal isdueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.Why
grace_daysstops at 14 and not 366Not symmetry, and not taste. The overdue escalation fires on
due_date + grace_days + 1, and its sweep looks backOVERDUE_LOOKBACK_DAYS= 15 days. Any grace of 15 or more puts day one outside the swept window and the escalation never fires — the same silent inertness this card is about, one flow over. 14 is the largest grace the product can currently honour, so it is the only ceiling under which every accepted value works.test/reminders.test.tsalready carried the coupling and a deliberate tripwire (expect(graceMax, 'grace_days grew a max — re-read the coupling above').toBeUndefined()). It was written for this moment: the coupling has been read, and that half is inverted rather than deleted — it now asserts the max is declared and islookback - 1, so removing it puts the silent case back and turns the suite red.This exposed a live instance in our own demo data.
demo-catalog.tsshipped "Contractor induction refresh" withgraceDays: 21, whose escalation had therefore never once fired. It is now 14. Measured, with the old value restored: the seed loader refuses it outright —[SeedLoader] Failed to write duly_catalog_item record #11 (name=Contractor induction refresh): Grace (days) must be ≤ 14, and again for the duty the history seed derives from it. The loud version of what used to be silence.Whether the product wants a longer grace is a real question, and a lookback change is the price — filed as #89 for triage rather than decided here.
duly_catalog_itemgets the same box, to the digitapplyCatalogHandlercopies all three values onto every duty it creates, throughengine.insert, which validates. A catalog item carrying1.5is therefore not a quiet inconsistency: it is an apply that refuses partway through, having already created duties for the first N people, with a refusal namingduly_dutyrather than the catalog item the value actually lives on. Stopping it where the value is authored is the difference between one loud refusal on the row being edited and a half-finished fan-out.Tests
test/cadence-number-constraints.test.ts(new, 26 cases) boots a real engine, because a structural assertion (Duty.fields.x.scale === 0) proves a key is present, not that anything enforces it:1.5refused, asserted by envelope (code: 'VALIDATION_FAILED'plusfields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;max + 1andmin - 1refused asmax_value/min_valuewith the declared constraint echoed;-366,366,lead 366,grace 14, zeroes), because a bound that refuses a legal value is as wrong as one that admits an illegal one;planDispatchwith zeroinvalid_cadence. The declared box sits inside what the period engine can compute, so the defect is closed rather than moved.Reverse-verified (both restored under a trap, files confirmed byte-identical afterwards):
scale: 0lines → 7 red, including all three write refusals and the update path (predicted direction: red);max: 14→test/reminders.test.tsred withduly_duty.grace_days declares max undefined, which needs a lookback of at least NaN days; the sweep looks back 15.Gates
All four green on
d79440c, from a clean tree withdist/removed first:Exit codes captured directly, not through a pipe. The one
validatewarning ishierarchy-security— AGENTS.md §7 names it as this checkout's expected state.No changeset: this repo has no changeset mechanism (no
.changeset/, no@changesets/*dependency, no script, no mention in AGENTS.md). The four gates are the whole contract.Related: #24 (same defect class,
timezone), #89 (the grace ceiling as a product question), #52 (grace_daysand the analytics surfaces). None of those three is addressed here.Generated by Claude Code