Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/data/demo-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,17 @@ export const CATALOG_ITEMS: readonly DemoCatalogItem[] = [
// Same reasoning as the semi-annual audit above — a year's notice for a
// year's obligation, which is what lets it stagnate long before it is late.
leadDays: 120,
// Was 21, and 21 never worked: the overdue escalation fires on
// `due_date + grace_days + 1` and its sweep looks back 15 days, so day one
// for this item fell outside the window and it was never escalated — a
// live instance, in our own demo data, of the silent late-failure #82 is
// about. `duly_catalog_item.grace_days` now stops at 14 (the largest the
// sweep can honour) and this row would be refused at seed time.
graceDays: 14,
// 21 is what this item's author wrote, and it is now a value the product
// can actually honour. It could not be, once: the overdue escalation fires
// on `due_date + grace_days + 1` and the sweep looked back only 15 days,
// so day one for this item fell outside the window and it was never
// escalated — a live instance, in our own demo data, of the silent
// late-failure #82 is about. #82 cut it to 14 to fit that lookback; #89
// raised the pair instead (`OVERDUE_LOOKBACK_DAYS` 31, `grace_days` max
// 30), which is where the 21 comes back from. It earns its place in the
// demo precisely because it is the one row exercising a grace longer than
// a fortnight.
graceDays: 21,
description: 'Re-run the site induction for every contractor still holding a pass, and retire the passes nobody claimed.',
reference: 'Site Safety Standard SS-15 §3',
},
Expand Down
56 changes: 43 additions & 13 deletions src/flows/reminders.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,50 @@ const DUTY_GATE_FIELDS = ['id', 'grace_days', 'effective_from', 'effective_to']
* How far back the overdue sweep looks, and therefore the largest
* `grace_days` the day-one escalation can honour.
*
* A duty with grace ≥ 15 has its escalation day fall outside the swept window
* and is never escalated — silently. That used to be reachable: `grace_days`
* declared `min: 0` and no maximum, and the demo catalog shipped an item with
* 21. Since #82 the field declares `max: 14`, which is THIS number minus one,
* so every value the object accepts is a value this sweep can honour.
*
* The two are still two numbers in two files, coupled by nothing but this
* comment and `test/reminders.test.ts`, which reads both and refuses to let
* either move alone. Raising the field's ceiling means raising this. The
* alternative — an unbounded lookback — re-launches the flow every day for
* every task ever missed, which is the "ancient record re-alerting forever"
* the trigger's own `withinDays` doc warns about.
* ── THE OTHER HALF OF THIS NUMBER LIVES IN ANOTHER FILE ──────────────────
* The invariant is `lookback >= max_grace + 1`. The escalation fires on
* `due_date + grace_days + 1`, so a duty whose grace reaches this number has
* its day one fall outside the swept window and is never escalated —
* silently, with nothing anywhere reporting it. The other half of the pair:
*
* src/flows/reminders.flow.ts OVERDUE_LOOKBACK_DAYS = 31 ← here
* src/objects/duty.object.ts grace_days max: 30
* src/objects/catalog-item.object.ts grace_days max: 30 (mirrored, because
* catalog apply copies it onto every
* duty it creates)
*
* 30 = 31 − 1, so every value those objects accept is a value this sweep can
* honour. Nothing in the toolchain couples them — they are hand-maintained
* numbers in files that cannot see each other, and `test/reminders.test.ts`
* ("the lookback still covers the largest grace a duty can declare") reads all
* three and refuses to let any move alone. Lower this without lowering the
* ceiling and the silent hole is back; raise the ceiling without raising this
* and it is back the other way round. It was reachable once: `grace_days`
* declared no maximum at all and the demo catalog shipped a 21-day grace
* against a 15-day lookback, whose escalation had never once fired.
*
* ── Why 31 and not 15 (#89) ──────────────────────────────────────────────
* A 15-day lookback admits at most a fortnight of grace, and a fortnight was
* measurably too little: an annual contractor-induction duty with 21 days of
* grace is an ordinary configuration and it did not fit. Raising this is what
* the higher ceiling costs, and the cost is real — in RANGE mode (see the
* escalation flow below) the claim scope is the SWEEP day, so this flow is
* launched once a day for every task in the window and takes a ledger row each
* time, most of them ending at `end` without notifying.
*
* What actually scales, though, is not "every task ever missed": the sweep
* filter is `LIVE_TASK`, so only tasks still OPEN inside the window match, and
* a task leaves that set the moment it is completed. Doubling the window
* roughly doubles the open-overdue set under consideration, not the task
* count. That is the price of not refusing a month of grace.
*
* It stops at a month. An UNBOUNDED lookback would re-launch the flow every
* day for every task ever missed — the "ancient record re-alerting forever"
* the trigger's own `withinDays` doc warns about — and a grace longer than a
* month has stopped being grace and become a different due date, which is the
* duty's own cadence to express, not an ever-growing sweep window.
*/
const OVERDUE_LOOKBACK_DAYS = 15;
const OVERDUE_LOOKBACK_DAYS = 31;

/** `true` when the task names a duty whose row is worth reading. */
const HAS_DUTY = 'has(record.duty) && !isBlank(record.duty)';
Expand Down
14 changes: 12 additions & 2 deletions src/objects/catalog-item.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,23 @@ export const CatalogItem = ObjectSchema.create({
max: 366,
description: 'Whole days, up to a year. Only a recurring duty is dispatched with a lead window; blank (and forbidden) for standing and one-off.',
}),
// This ceiling is NOT free-standing, and it is the one of the three most
// easily changed in isolation. It mirrors `duly_duty.grace_days` (30),
// which is itself pinned to `OVERDUE_LOOKBACK_DAYS` (31) in
// `src/flows/reminders.flow.ts` by `lookback >= max_grace + 1`: the
// overdue escalation fires on `due_date + grace_days + 1`, so a grace at
// or above the lookback puts day one outside the swept window and the
// escalation never fires — silently. Three numbers, three files, coupled
// by nothing but `test/reminders.test.ts`. Raising this alone fans out
// duties the escalation cannot honour; lowering it alone refuses a
// template for a duty that is perfectly legal.
grace_days: Field.number({
label: 'Grace (days)',
defaultValue: F`record.form == "standing" ? null : 0`,
scale: 0,
min: 0,
max: 14,
description: 'Whole days, up to 14 — the overdue reminder sweeps 15 days back, so a longer grace would never fire. Meaningless for a standing duty, which never has a task; still applies to a one-off\'s.',
max: 30,
description: 'Whole days, up to 30 — the overdue reminder sweeps 31 days back, so a longer grace would never fire. Meaningless for a standing duty, which never has a task; still applies to a one-off\'s.',
}),

regulation_ref: Field.text({
Expand Down
45 changes: 35 additions & 10 deletions src/objects/duty.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export const Duty = ObjectSchema.create({
// constraint: { scale: 0, actual: 1 } }
//
// That is already product voice, so nothing is layered on top of it. The
// bounds read the same way — "Grace (days) must be ≤ 14".
// bounds read the same way — "Grace (days) must be ≤ 30".
//
// ── The three do NOT share a failure mode ─────────────────────────────
// Same declaration, three different routes to the period engine — measured
Expand Down Expand Up @@ -210,15 +210,40 @@ export const Duty = ObjectSchema.create({
// or a lead window reaching more than a year outside its own period is a
// typo, not a schedule, so ±366 / 0..366 is where they stop.
//
// `grace_days` stops at 14, and that number is NOT a taste call. The
// `grace_days` stops at 30, and that number is NOT a taste call. The
// overdue escalation fires on `due_date + grace_days + 1` and its sweep
// looks back `OVERDUE_LOOKBACK_DAYS` (15) days
// (`src/flows/reminders.flow.ts`), so a grace of 15 or more puts day one
// looks back `OVERDUE_LOOKBACK_DAYS` (31) days
// (`src/flows/reminders.flow.ts`), so a grace of 31 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; raising it means raising the
// lookback, and `test/reminders.test.ts` holds the two numbers together so
// neither can move alone.
// silent inertness this card is about, one flow over.
//
// ── THE OTHER HALF OF THIS NUMBER LIVES IN ANOTHER FILE ───────────────
// The invariant is `lookback >= max_grace + 1`, and it spans three files
// that cannot see each other:
//
// src/flows/reminders.flow.ts OVERDUE_LOOKBACK_DAYS = 31
// src/objects/duty.object.ts grace_days max: 30 ← you are here
// src/objects/catalog-item.object.ts grace_days max: 30 (mirrored,
// because catalog apply copies it onto
// every duty it creates)
//
// Nothing in the toolchain couples them; `test/reminders.test.ts` ("the
// lookback still covers the largest grace a duty can declare") reads all
// three and fails if any moves alone. Raise this ceiling and you MUST
// raise the lookback in the same commit — the failure mode of getting it
// wrong is not an error, it is an escalation that never fires and never
// says so. That is exactly how a 21-day grace sat in the demo catalog
// against a 15-day lookback, never escalating, until #82 went looking.
//
// ── Why 30 and not 14 (#89) ───────────────────────────────────────────
// 14 was the largest grace a 15-day lookback could honour, and it was
// measurably 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 had to be cut to fit. A limit that a plausible
// first configuration trips is a limit in the wrong place, so the lookback
// moved instead. It stops at 30 rather than going further because a grace
// longer than a month stops being grace and becomes a different due date,
// and the lookback is the half that is not free (see the flow).
due_offset_days: Field.number({
label: 'Offset (days, 0 = anchor day)',
defaultValue: F`record.form != "recurring" ? null : 0`,
Expand All @@ -242,8 +267,8 @@ export const Duty = ObjectSchema.create({
defaultValue: F`record.form == "standing" ? null : 0`,
scale: 0,
min: 0,
max: 14,
description: 'Days after the due date before an open task counts as late. Whole days, up to 14 — the overdue reminder sweeps 15 days back, so a longer grace would put day one outside the window and never fire at all. Meaningless for a standing duty, which never has a task; still applies to a one-off\'s.',
max: 30,
description: 'Days after the due date before an open task counts as late. Whole days, up to 30 — the overdue reminder sweeps 31 days back, so a longer grace would put day one outside the window and never fire at all. Meaningless for a standing duty, which never has a task; still applies to a one-off\'s.',
}),

// A global product cannot compute "the 5th of the month" without knowing
Expand Down
4 changes: 2 additions & 2 deletions test/cadence-number-constraints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const insertCatalogItem = (over: AnyRow) => data.insert('duly_catalog_item', cat
const BOUNDS = {
due_offset_days: { scale: 0, min: -366, max: 366 },
lead_days: { scale: 0, min: 0, max: 366 },
grace_days: { scale: 0, min: 0, max: 14 },
grace_days: { scale: 0, min: 0, max: 30 },
} as const;

type CadenceField = keyof typeof BOUNDS;
Expand Down Expand Up @@ -246,7 +246,7 @@ describe('every value still admitted is one the product can carry out', () => {
it.each([
['both extremes of the offset, with the maximum lead', { due_offset_days: -366, lead_days: 366 }],
['the other extreme', { due_offset_days: 366, lead_days: 0 }],
['the largest grace the sweep can honour', { grace_days: 14 }],
['the largest grace the sweep can honour', { grace_days: 30 }],
['zero everywhere', { due_offset_days: 0, lead_days: 0, grace_days: 0 }],
] as const)('accepts %s', async (_label, values) => {
const created: any = await insertDuty(values);
Expand Down
8 changes: 5 additions & 3 deletions test/reminders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe('reminder sweeps — the windows say what the card says', () => {
it('the overdue sweep is a BOUNDED past-due lookback', () => {
const tr = timeRelativeOf(OverdueOwnerEscalation as unknown as FlowLike);
expect(tr.dateField).toBe('due_date');
expect(tr.withinDays).toBe(-15);
expect(tr.withinDays).toBe(-31);
expect(Object.keys(tr)).not.toContain('offsetDays');
});

Expand All @@ -204,7 +204,9 @@ describe('reminder sweeps — the windows say what the card says', () => {
// pushes that day outside the swept window is never escalated — silently.
// Nothing but this assertion holds the two numbers together: they live in
// two files (`src/flows/reminders.flow.ts`, `src/objects/duty.object.ts`)
// and neither can see the other.
// and neither can see the other. The pair moved 14/15 → 30/31 in #89; the
// COUPLING is what is pinned here, not either value, so a future move is
// one edit to each file and one to this test.
const lookback = -Number(timeRelativeOf(OverdueOwnerEscalation as unknown as FlowLike).withinDays);
const graceMax = (Duty.fields.grace_days as { max?: number }).max;
expect(
Expand All @@ -215,7 +217,7 @@ describe('reminder sweeps — the windows say what the card says', () => {
// The direction the previous version of this test guarded — an UNBOUNDED
// grace — is now closed by declaration (#82), and this half is what keeps
// it closed. Deleting the field's `max` would put the silent case back:
// every value above 14 saves clean and is never escalated, which is not a
// every value above 30 saves clean and is never escalated, which is not a
// gap a reader of either file would notice.
expect(graceMax, 'grace_days lost its max — an unbounded grace is silently never escalated').toBe(
lookback - 1,
Expand Down
Loading