Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Refuse a fractional or absurd cadence number at write time, not at dispatch - #90

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints
Sep 1, 2026
Merged

Refuse a fractional or absurd cadence number at write time, not at dispatch#90
os-warren merged 2 commits into
mainfrom
claude/issue-82-cadence-number-constraints

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#82

due_offset_days, lead_days and grace_days declared no numeric constraints, and the engine's number validator enforces min / max / scaleonly when they are declared. So due_offset_days: 1.5 saved clean, passed pnpm validate, rendered fine in the duty form — and then failed days later inside the nightly batch, recorded as invalid_cadence against 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 — scale is 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:

ValidationError: Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)
code: VALIDATION_FAILED
fields[0]: { field: 'due_offset_days', code: 'max_scale',
label: 'Offset (days, 0 = anchor day)',
constraint: { scale: 0, actual: 1 } }

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.ts pins the absence of such a rule, not just the presence of the keys.

2. Bounds, as proposed:-366..366 on due_offset_days; lead_days and grace_days keep min: 0 and gain a max.

"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:

fieldfractional valuewhat actually happened
due_offset_days1.5Throws.dueDateFor refuses it, planForDuty catches it as invalid_cadence, the run reports degraded and the duty produces no tasks. dueOffsetDays must be a whole number of days, received 1.5
lead_days2.5Throws — but one function over, and the message is wrong about the value. The first consumer is visibleFromFor, reached through addCalendarDays, which negates its argument. So the operator reads leadDays must be a whole number of days, received -2.5 for a duty on which nobody ever typed a negative number.
grace_days2.5Throws nowhere. Ever. It never reaches the period engine at all — its only evaluating reader is the overdue escalation's CEL gate, which wraps it in int(). 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: 9e9 was accepted, and the card's guess that period.ts's MIN_YEAR/MAX_YEAR guard would eventually catch it is not what happens — measured, the civil arithmetic goes to NaN first and the refusal is dueDate must be a YYYY-MM-DD calendar date, received 0NaN-NaN-NaN, which names neither the field nor anything the author typed.

Why grace_days stops at 14 and not 366

Not symmetry, and not taste. The overdue escalation fires on due_date + grace_days + 1, and its sweep looks back OVERDUE_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.ts already 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.ts shipped "Contractor induction refresh" with graceDays: 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_item gets the same box, to the digit

applyCatalogHandler copies all three values onto every duty it creates, through engine.insert, which validates. A catalog item carrying 1.5 is 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 naming duly_duty rather 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:

  • every field × both objects: 1.5 refused, asserted by envelope (code: 'VALIDATION_FAILED' plus fields[0].{field, code: 'max_scale', constraint}) — never by the bare fact that it threw, which would pass on any error at all;
  • max + 1 and min - 1 refused as max_value / min_value with the declared constraint echoed;
  • the update path refused too, not just insert — duly_catalog_item has no recurring_needs_frequency-equivalent validation #65's lesson was a rule that held on insert and did nothing on update;
  • the corners that must still be accepted (-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;
  • and the half that makes the bounds a contract rather than a guess: every corner of the declared box — 7 frequencies × 2 anchors × 2 zones × offset and lead extremes — planned through planDispatch with 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):

  • removed the three scale: 0 lines → 7 red, including all three write refusals and the update path (predicted direction: red);
  • removed max: 14test/reminders.test.ts red with duly_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 with dist/ removed first:

pnpm validate exit 0 ✓ Validation passed (542ms)
pnpm typecheck exit 0
pnpm test exit 0 Test Files 23 passed (23) · Tests 599 passed (599)
pnpm build exit 0 ✓ Build complete · Artifact: dist/objectstack.json (278.6 KB)

Exit codes captured directly, not through a pipe. The one validate warning is hierarchy-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_days and the analytics surfaces). None of those three is addressed here.


Generated by Claude Code

…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-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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 lead_days and grace_days behave like due_offset_days just because they share a declaration. They do not, and the differences are not cosmetic:

field2.5 / 1.5 does what
due_offset_daysthrows in dueDateForinvalid_cadence, run degraded
lead_daysthrows one function over, in visibleFromFor via addCalendarDays, which negates — so the message names -2.5, a value nobody typed
grace_daysthrows nowhere at all. Its only evaluating reader is the escalation's CEL gate, which wraps it in int(), and int(2.5) == 2 — so it silently escalates on the day a grace of 2 would, forever

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

reminders.flow.ts: the escalation fires on due_date + grace_days + 1, and its sweep uses withinDays: -OVERDUE_LOOKBACK_DAYS with OVERDUE_LOOKBACK_DAYS = 15. So a grace of 15 or more puts day one outside the window the sweep ever looks at, and the task is never escalated — silently. max: 14 is exactly the largest value this product can honour, not a taste call, and test/reminders.test.ts holding the two numbers together is what keeps it that way.

And it found a live instance in our own demo data.Contractor induction refresh shipped with graceDays: 21, so its escalation had never once fired. Ablation (3) — restoring 21 and watching the seed loader refuse with "Grace (days) must be at most 14" — is what turned a code comment into a measurement. A constraint that immediately catches a real defect in the data you already shipped is the best evidence a constraint can have.

Other things worth recording

  • My sub-premise was wrong and you said so.due_offset_days: 9e9 is not caught by period.ts's MIN_YEAR/MAX_YEAR guard — the civil arithmetic reaches NaN first and the refusal reads received 0NaN-NaN-NaN. The bounds are more load-bearing than my card claimed.
  • scale: 0 alone, no rule layered on top — after measuring that the platform's own refusal is already product voice ("Offset (days, 0 = anchor day) must have at most 0 decimal places (got 1)"). That is the order I asked for: measure, then decide, rather than assuming the platform message is poor.
  • Mirroring duly_catalog_item to the digit because applyCatalogHandler copies all three onto every duty through engine.insert, which validates — so a fractional value on the catalog row is an apply that refuses partway through a fan-out, naming duly_duty rather than the row the value actually lives on. Good reasoning about where the error surfaces, not just whether it does.
  • The corner sweep — 7 frequencies × 2 anchors × 2 zones × offset/lead extremes planned through planDispatch with zero invalid_cadence — is what makes the bounds a contract rather than a guess. Without it, -366..366 would be a number someone liked.
  • Ablation attempt (3)'s first run matched zero lines, was caught by the grep count, and produced no reading rather than a false green. Re-anchored and re-run.

Gates, re-run by me on the head merged with current main (this branch predates #87 and #88): validate 0, typecheck 0, test 0 — Test Files 25 passed, Tests 632 passedbuild 0. The demo seed still loads with the corrected graceDays.

#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 grace_days being unreadable by the analytics surfaces at all.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 10:55
@os-warren
os-warren merged commit 45a40b0 into mainSep 1, 2026
1 check passed
Sign up for freeto 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.

due_offset_days accepts a fractional value — same shape as #24, one field over: saves clean, throws at dispatch

1 participant

@os-warren