Skip to content

Raise the grace ceiling to 30 days and the overdue lookback to 31 - #94

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-89-grace-days-ceiling
Sep 1, 2026
Merged

os-warren merged 1 commit into
mainfrom
claude/issue-89-grace-days-ceiling

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes #89

Implements the triage decision on the card: no, 14 is not the product answer — grace_days max goes to 30 and OVERDUE_LOOKBACK_DAYS to 31, the two stay coupled, and the demo catalog's Contractor induction refresh goes back to the graceDays: 21 its author wrote.

What changed

Site Before After
src/flows/reminders.flow.ts · OVERDUE_LOOKBACK_DAYS 15 31
src/objects/duty.object.ts · grace_days.max 14 30
src/objects/catalog-item.object.ts · grace_days.max (mirrored) 14 30
src/data/demo-catalog.ts · Contractor induction refresh graceDays: 14 graceDays: 21
test/reminders.test.ts · withinDays -15 -31
test/cadence-number-constraints.test.ts · BOUNDS.grace_days max: 14 max: 30

The coupling test is unchanged in kind — it still derives the lookback from the flow, reads max off both objects and asserts graceMax === lookback - 1, so it fails if any one of the three moves alone. Only the values it holds together moved.

The shape, which the card said matters more than the number

The three numbers are hand-maintained in files that cannot see each other, and only a test couples them — which is exactly why the original 21-against-15 mismatch stayed invisible until #82 went looking. Each site now states the invariant lookback >= max_grace + 1, names the other two sites and their file paths, and says what breaks in each direction:

  • raising the ceiling without the lookback puts the silent never-escalated hole back;
  • raising the lookback without the ceiling merely wastes sweep;
  • a laxer bound on duly_catalog_item is the same hole reached one object earlier, because applyCatalogHandler copies the field onto every duty it creates — a ceiling that disagrees between the two objects is an apply that refuses partway through a fan-out.

The cost of the longer window is written down where the number lives, in the terms the triage used: what scales is the still-open tasks inside the window (the sweep filter is LIVE_TASK), not the task count, because a task leaves that set the moment it is completed.

Verified, not inferred

Both edges were measured against a booted kernel driving the real TimeRelativeTrigger sweep and the real flow, not derived from the arithmetic. The rig mounted AutomationServicePlugin, registered a capturing messaging service (the service the notify node emits through), inserted a duty with grace_days: 30 and a task 31 days past due, and ran one sweep at the real clock.

1 · A 30-day grace really is escalated. The engine's own run summary for the day-one task:

[automation] run flow=duly_task_overdue_owner_escalation status=completed
  selected=1 acted=1 skipped=3 gate=read_duty->end:1

acted=1, and the notification that came out of it:

{ "topic": "duly.task_overdue", "audience": ["u_grace30"], "severity": "warning",
  "payload": { "title": "Induction refresh — day one past grace 30",
               "body": "Past due since 2026-08-01." },
  "source": { "object": "duly_task", "id": "duly_task-…" } }

2 · The control that makes it falsifiable. The identical record under the old -15 window:

OLD-LOOKBACK SWEEP MATCHED: []
OLD-LOOKBACK EMITTED     : []

It is not merely un-notified — it never enters the sweep at all, which is the silent inertness the card is about, and it is the lookback move that fixes it.

3 · The gate is still day equality, not "anything in the window". A sibling task 30 days past due on the same duty was swept in the same run and did not notify:

selected=1 acted=0 skipped=3 gate=read_duty->notify_owner:1   (edge e_day_one skipped)

4 · 31 is still refused at write time, on both objects, with the max_value envelope #82's tests assert:

ValidationError: Grace (days) must be ≤ 30
fields[0]: { field: 'grace_days', code: 'max_value', constraint: { max: 30 } }

Measured on duly_duty and on duly_catalog_item; grace_days: 30 and the demo's 21 are both accepted on the catalog item.

5 · The demo seed writes the 21 row cleanly. test/seed.test.ts opts the demo seed in and drives the real SeedLoaderService; the full run logs zero Failed to write lines, where the pre-#89 ceiling produced [SeedLoader] Failed to write duly_catalog_item record #11 … Grace (days) must be ≤ 14.

The rig itself is deliberately not committed. Mounting the trigger and automation plugins by hand inside a test asserts the test's wiring rather than the app's — the false green test/dispatch-wiring.test.ts and test/trigger-capability.test.ts are both written to avoid. The durable guard for this change is the coupling assertion in test/reminders.test.ts, which is where it already lived.

Gates

All four green on 8ddef76, the commit at the head of this branch, run after the final commit:

pnpm validate   ✓ Validation passed (382ms)
pnpm typecheck  (no diagnostics)
pnpm test       Test Files  25 passed (25) · Tests  641 passed (641)
pnpm build      ✓ Build complete (615ms)

The one validate warning is the documented expected state of this checkout — hierarchy-security is provided by @objectstack/security-enterprise, deliberately not installed (AGENTS.md rule 7). No changeset: this repo has no changeset mechanism.


Generated by Claude Code

14 was the largest grace a 15-day lookback could honour, and it was too low:
the demo catalog's annual "Contractor induction refresh" was authored with 21
days of grace — an ordinary configuration for an annual duty — and #82 had to
cut it to 14 to fit. A limit a plausible first configuration trips is a limit
in the wrong place, so the lookback moves instead: OVERDUE_LOOKBACK_DAYS 31,
grace_days max 30 on duly_duty and on duly_catalog_item, and the demo row goes
back to the 21 its author meant.

The coupling is unchanged in kind, only in value. lookback >= max_grace + 1 is
now stated as an invariant at each of the three sites, naming the other two and
the file they live in, because the three numbers are hand-maintained in files
that cannot see each other and only test/reminders.test.ts holds them together
— which is exactly how the original 21-against-15 mismatch stayed invisible.

Measured rather than inferred, by driving the real time-relative sweep and the
real flow against a booted kernel: a duty with grace 30 whose task is 31 days
past due is swept and escalated, emitting one notification to the owner on
topic duly.task_overdue; the same record is not even swept under the old 15-day
window; a sibling task 30 days past due is swept but not notified, so the gate
is still day equality. 31 is refused at write time on both objects as
max_value, "Grace (days) must be ≤ 30".

Closes #89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p

Copy link
Copy Markdown
Collaborator Author

Reviewed — merging. The verification is what I asked for and better than I asked for.

I said: show the escalation firing at grace_days: 30, do not infer it from the arithmetic. You booted a real kernel with AutomationServicePlugin, registered a capturing messaging service, and drove the real TimeRelativeTrigger sweep and the real flow at the real clock. Four readings, and the second is the one that makes the other three mean something:

  1. Fires: status=completed selected=1 acted=1 skipped=3, notification {topic: duly.task_overdue, audience: [u_grace30], severity: warning} — one notification, to the owner, for that task.
  2. Falsifying control, and it fails in the right direction: the identical record under the old -15 window gives OLD-LOOKBACK SWEEP MATCHED: [] and EMITTED: []. Not "swept but not notified" — never enters the sweep at all. That is precisely the silent inertness this card exists to remove, demonstrated rather than argued.
  3. The gate is still day equality: a sibling task 30 days past due on the same duty was swept in the same run and gave acted=0, edge e_day_one skipped. So raising the window did not turn the escalation into "any overdue task in the last month" — the thing that would have quietly made this change a regression.
  4. Both objects refuse 31, by envelope: VALIDATION_FAILED, fields[0] {field: 'grace_days', code: 'max_value', constraint: {max: 30}}.

And the demo seed now logs zero Failed to write lines across the full run, where the old ceiling produced [SeedLoader] Failed to write duly_catalog_item record #11 … Grace (days) must be ≤ 14. The 21-day induction is back and it works — which was the point of the card.

Building the rig as a throwaway and not committing it was the right instinct: it proves a runtime fact for this change, and as a permanent test it would be a slow kernel boot pinning platform behaviour from outside.

The comments are the part that will still matter in six months. Three sites, hand-maintained, in files that cannot see each other, coupled only by a test — that is exactly the configuration that let the original 15-vs-21 mismatch sit unnoticed. Each site now naming the invariant (lookback >= max_grace + 1), the other two file paths, and what breaks in each direction is what makes the next change safe. test/reminders.test.ts unchanged in kind and only in value is the right shape too; and cadence-number-constraints.test.ts being parameterised on the bounds meant raising the ceiling moved the refusal case with no structural edit, which is a small sign #82 built it correctly.

Gates, re-run by me on the head merged with current main: validate 0, typecheck 0, test 0 — Test Files 26 passed, Tests 662 passed — build 0, and no Failed to write lines in the seed run.


Generated by Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

grace_days now stops at 14 because the overdue sweep looks back 15 — is 14 the product answer?

1 participant