From 78ee086a3c06cd7e28aa224ef261feccd5e8eb1c Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 08:12:59 +0000 Subject: [PATCH] Forbid a frequency (and the neighbouring cadence fields) on a standing duty A standing duty never dispatches, so a stamped frequency/due_anchor/ due_offset_days/lead_days/grace_days reads as though it runs on a schedule it never will. Makes each default conditional on `form` (CEL null-guard idiom) and adds the validation rules that refuse the meaningless combinations outright, on both duly_duty and duly_catalog_item so a wrong value on the catalog side is never replicated onto an instantiated duty. Fixes #61 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/objects/catalog-item.object.ts | 61 ++++- src/objects/duty.object.ts | 84 +++++- test/cadence-conditional-defaults.test.ts | 299 ++++++++++++++++++++++ test/dispatch.test.ts | 26 +- 4 files changed, 444 insertions(+), 26 deletions(-) create mode 100644 test/cadence-conditional-defaults.test.ts diff --git a/src/objects/catalog-item.object.ts b/src/objects/catalog-item.object.ts index 3cf2180..21fec27 100644 --- a/src/objects/catalog-item.object.ts +++ b/src/objects/catalog-item.object.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { F, P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -47,34 +48,53 @@ export const CatalogItem = ObjectSchema.create({ ], }), + // Every default below is CONDITIONAL on `form`, mirroring `duly_duty` + // field-for-field (#61 — #5's instantiation copies these verbatim onto + // every duty made from this item, so a wrong value here is replicated + // onto every person who takes the role). See `duty.object.ts`'s cadence + // block for the full reasoning on which forms lose which field. frequency: Field.select({ label: 'Frequency', options: [ { label: 'Daily', value: 'daily' }, { label: 'Weekly', value: 'weekly' }, { label: 'Fortnightly', value: 'fortnightly' }, - { label: 'Monthly', value: 'monthly', default: true }, + { label: 'Monthly', value: 'monthly' }, { label: 'Quarterly', value: 'quarterly' }, { label: 'Semi-annual', value: 'semiannual' }, { label: 'Annual', value: 'annual' }, ], + defaultValue: F`record.form == "standing" ? null : "monthly"`, + description: 'Required for a recurring duty. Forbidden for a standing duty — it never dispatches, so a frequency on it is meaningless (`standing_no_frequency`). Ignored for one-off, which is dispatched once, by hand.', }), due_anchor: Field.select({ label: 'Due date anchored to', options: [ - { label: 'Start of period', value: 'period_start', default: true }, + { label: 'Start of period', value: 'period_start' }, { label: 'End of period', value: 'period_end' }, ], + defaultValue: F`record.form != "recurring" ? null : "period_start"`, + description: 'Anchors the due date inside a period. Only a recurring duty has one; blank (and forbidden) for standing and one-off.', }), due_offset_days: Field.number({ label: 'Offset (days, 0 = anchor day)', - defaultValue: 0, - description: 'Days from the anchor day, which is offset 0. On "Start of period": 0 = the first day of the period, 4 = the fifth day. On "End of period": 0 = the last day of the period, -3 = three days before the last.', + defaultValue: F`record.form != "recurring" ? null : 0`, + description: 'Days from the anchor day, which is offset 0. On "Start of period": 0 = the first day of the period, 4 = the fifth day. On "End of period": 0 = the last day of the period, -3 = three days before the last. Only a recurring duty has a period to offset into; blank (and forbidden) for standing and one-off.', + }), + lead_days: Field.number({ + label: 'Lead time (days)', + defaultValue: F`record.form != "recurring" ? null : 7`, + min: 0, + description: 'Only a recurring duty is dispatched with a lead window; blank (and forbidden) for standing and one-off.', + }), + grace_days: Field.number({ + label: 'Grace (days)', + defaultValue: F`record.form == "standing" ? null : 0`, + min: 0, + description: 'Meaningless for a standing duty, which never has a task; still applies to a one-off\'s.', }), - lead_days: Field.number({ label: 'Lead time (days)', defaultValue: 7, min: 0 }), - grace_days: Field.number({ label: 'Grace (days)', defaultValue: 0, min: 0 }), regulation_ref: Field.text({ label: 'Reference', @@ -91,4 +111,33 @@ export const CatalogItem = ObjectSchema.create({ nameField: 'name', highlightFields: ['name', 'position_code', 'form', 'frequency'], + + // Mirrors the three new `duly_duty` rules (#61) — not its full validation + // set. `duly_duty`'s `recurring_needs_frequency` and `effective_window_ordered` + // are pre-existing gaps on THIS object (no `effective_*` fields exist here + // at all, and nothing currently requires a recurring item to carry a + // frequency); left alone as out of this issue's scope and filed separately. + validations: [ + { + name: 'standing_no_frequency', + type: 'script', + severity: 'error', + message: 'A standing duty never dispatches — a frequency on it is meaningless. Remove it.', + condition: P`record.form == "standing" && !isBlank(record.frequency)`, + }, + { + name: 'non_recurring_no_due_timing', + type: 'script', + severity: 'error', + message: 'Due anchor, due offset and lead time compute a period due date — only a recurring duty has one. Clear them for standing and one-off.', + condition: P`record.form != "recurring" && (!isBlank(record.due_anchor) || !isBlank(record.due_offset_days) || !isBlank(record.lead_days))`, + }, + { + name: 'standing_no_grace_days', + type: 'script', + severity: 'error', + message: 'Grace days measures lateness against a task\'s due date — a standing duty never has a task, so it never has one.', + condition: P`record.form == "standing" && !isBlank(record.grace_days)`, + }, + ], }); diff --git a/src/objects/duty.object.ts b/src/objects/duty.object.ts index 8aae6e9..c0bbdaf 100644 --- a/src/objects/duty.object.ts +++ b/src/objects/duty.object.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { P } from '@objectstack/spec'; +import { F, P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -102,46 +102,77 @@ export const Duty = ObjectSchema.create({ }), // ── Cadence ─────────────────────────────────────────────────────────── + // Every default below is CONDITIONAL on `form` (#61): a duty that never + // dispatches (`standing`) or dispatches once by hand (`one_off`) has no + // period, so a cadence field auto-filled anyway does not merely go + // unread — it reads back as though the duty runs on that schedule. The + // option-level `default: true` idiom (still used on `form` itself, three + // fields up) is UNCONDITIONAL, so the conditional half needs the CEL + // `defaultValue` slot instead: the blessed null-guard idiom + // (`cond ? value : null`, objectstack#3306) that `applyFieldDefaults` and + // `Field.formula` share one evaluator for. `form` is declared above every + // field below, so by the time each of these runs `record.form` is already + // resolved — from the payload, or from `form`'s own option default. + // + // Which forms lose which field is NOT uniform, and is decided by what + // `dispatch.plan.ts#planForDuty` actually reads, not by a blanket + // "non-recurring" rule: + // - `frequency` is scoped to `standing` ONLY, mirroring exactly the + // converse of `recurring_needs_frequency` below (the pair this issue + // completes). One-off's equally-meaningless frequency is a separate, + // narrower case this issue does not adjudicate. + // - `due_anchor` / `due_offset_days` / `lead_days` compute a PERIOD due + // date, which only a recurring duty has — `planForDuty` returns before + // reading any of the three for `standing` or `one_off`. Scoped to + // `form != "recurring"`. + // - `grace_days` measures lateness against a TASK's due date, and a + // one-off duty's task has a real one (set directly, not computed from + // an anchor) — see `duly_duty_health`'s intended `completed_at <= + // due_date + duty.grace_days` (objectstack#14104), which is not + // form-gated. Only `standing`, which never has a task, loses it. frequency: Field.select({ label: 'Frequency', options: [ { label: 'Daily', value: 'daily' }, { label: 'Weekly', value: 'weekly' }, { label: 'Fortnightly', value: 'fortnightly' }, - { label: 'Monthly', value: 'monthly', default: true }, + { label: 'Monthly', value: 'monthly' }, { label: 'Quarterly', value: 'quarterly' }, { label: 'Semi-annual', value: 'semiannual' }, { label: 'Annual', value: 'annual' }, ], - description: 'Required for recurring duties. Ignored for one-off and standing.', + defaultValue: F`record.form == "standing" ? null : "monthly"`, + description: 'Required for a recurring duty. Forbidden for a standing duty — it never dispatches, so a frequency on it is meaningless (`standing_no_frequency`). Ignored for one-off, which is dispatched once, by hand.', }), due_anchor: Field.select({ label: 'Due date anchored to', options: [ - { label: 'Start of period', value: 'period_start', default: true }, + { label: 'Start of period', value: 'period_start' }, { label: 'End of period', value: 'period_end' }, ], + defaultValue: F`record.form != "recurring" ? null : "period_start"`, + description: 'Anchors the due date inside a period. Only a recurring duty has one; blank (and forbidden) for standing and one-off.', }), due_offset_days: Field.number({ label: 'Offset (days, 0 = anchor day)', - defaultValue: 0, - description: 'Days from the anchor day, which is offset 0. On "Start of period": 0 = the first day of the period, 4 = the fifth day. On "End of period": 0 = the last day of the period, -3 = three days before the last.', + defaultValue: F`record.form != "recurring" ? null : 0`, + description: 'Days from the anchor day, which is offset 0. On "Start of period": 0 = the first day of the period, 4 = the fifth day. On "End of period": 0 = the last day of the period, -3 = three days before the last. Only a recurring duty has a period to offset into; blank (and forbidden) for standing and one-off.', }), lead_days: Field.number({ label: 'Lead time (days)', - defaultValue: 7, + defaultValue: F`record.form != "recurring" ? null : 7`, min: 0, - description: 'How far ahead of the due date the task appears in the owner\'s list. A task that shows up on its due date is a task that is already late.', + description: 'How far ahead of the due date the task appears in the owner\'s list. A task that shows up on its due date is a task that is already late. Only a recurring duty is dispatched with a lead window; blank (and forbidden) for standing and one-off.', }), grace_days: Field.number({ label: 'Grace (days)', - defaultValue: 0, + defaultValue: F`record.form == "standing" ? null : 0`, min: 0, - description: 'Days after the due date before an open task counts as late.', + description: 'Days after the due date before an open task counts as late. 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 @@ -205,6 +236,39 @@ export const Duty = ObjectSchema.create({ message: 'A recurring duty needs a frequency — otherwise nothing can dispatch it.', condition: P`record.form == "recurring" && isBlank(record.frequency)`, }, + { + // The converse of `recurring_needs_frequency` (#61). A standing duty + // NEVER dispatches — that is the whole point of the form — so a + // frequency on one is not just unused, it reads to a configurer as + // though the duty runs on that schedule. + name: 'standing_no_frequency', + type: 'script', + severity: 'error', + message: 'A standing duty never dispatches — a frequency on it is meaningless. Remove it.', + condition: P`record.form == "standing" && !isBlank(record.frequency)`, + }, + { + // `due_anchor` / `due_offset_days` / `lead_days` exist to compute a + // PERIOD due date (`dispatch.plan.ts#planForDuty`), and only a + // recurring duty has a period. Standing never dispatches; one-off's + // due date is set directly on the task, never derived from an anchor. + name: 'non_recurring_no_due_timing', + type: 'script', + severity: 'error', + message: 'Due anchor, due offset and lead time compute a period due date — only a recurring duty has one. Clear them for standing and one-off.', + condition: P`record.form != "recurring" && (!isBlank(record.due_anchor) || !isBlank(record.due_offset_days) || !isBlank(record.lead_days))`, + }, + { + // `grace_days` measures lateness against a TASK's due date. A one-off + // duty's task has a real one (set by hand, not computed), so grace + // still applies there — only `standing`, which never has a task at + // all, loses it. + name: 'standing_no_grace_days', + type: 'script', + severity: 'error', + message: 'Grace days measures lateness against a task\'s due date — a standing duty never has a task, so it never has one.', + condition: P`record.form == "standing" && !isBlank(record.grace_days)`, + }, { name: 'effective_window_ordered', type: 'script', diff --git a/test/cadence-conditional-defaults.test.ts b/test/cadence-conditional-defaults.test.ts new file mode 100644 index 0000000..3363242 --- /dev/null +++ b/test/cadence-conditional-defaults.test.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; + +import stack from '../objectstack.config.js'; +import { DEFAULT_DUE_ANCHOR, DEFAULT_DUE_OFFSET_DAYS, DEFAULT_LEAD_DAYS } from '../src/jobs/dispatch.plan.js'; + +/** + * #61 — "Standing / Monthly" reads as if a standing duty dispatches. + * + * `frequency`, `due_anchor`, `due_offset_days`, `lead_days` and `grace_days` + * are declared with CONDITIONAL defaults on both `duly_duty` and + * `duly_catalog_item` (`duty.object.ts` / `catalog-item.object.ts`), plus + * validation rules that refuse the meaningless combinations outright — B + * (hide it in the UI) was rejected in the issue precisely because it leaves + * the wrong value IN THE DATA, so this suite proves the DATA, never the + * rendering. + * + * ── Why this runs against a REAL booted engine, not a schema-structural pin + * A conditional default here is a CEL `defaultValue` (the blessed + * null-guard idiom `cond ? value : null`, objectstack#3306 — the same + * evaluator `Field.formula` uses). `pnpm validate` does not check a CEL + * `defaultValue`'s syntax OR behaviour at all: `field.zod.ts`'s authoring + * gate discriminates the shape and, for an expression envelope, returns + * unconditionally (`shape === 'expression' → return` — "a CEL result type is + * unknowable at parse time"). So a schema-structural assertion + * (`Duty.fields.x.defaultValue === literal`) would prove only that the KEY + * is present, never that the expression evaluates to the right thing for the + * right form. The only thing that proves that is inserting a row and + * reading it back — which is what every test below does. + * + * `test/dispatch.test.ts`'s "the cadence fallbacks are the object schema" + * block used to pin `due_anchor` / `due_offset_days` / `lead_days` + * structurally; that pin is superseded by the "still a recurring duty" + * tests here, which check the SAME constants against real inserted rows. + */ + +type AnyRow = Record; + +let kernel: { getService(name: string): unknown; shutdown?(): Promise } | undefined; +let data: { + find(o: string, q?: AnyRow, x?: AnyRow): Promise; + insert(o: string, d: AnyRow, x?: AnyRow): Promise; + update(o: string, d: AnyRow, x?: AnyRow): Promise; +}; + +beforeAll(async () => { + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // See task-hook.test.ts / dispatch.test.ts for why this must not resolve + // to a real path: a local `pnpm build` would make the suite report on the + // last BUILD instead of on `src/`. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + const k = new ObjectKernel(); + for (const plugin of plugins) await k.use(plugin); + await k.use(new AppPlugin(stack, undefined, { skipSeedData: true })); + await k.bootstrap(); + kernel = k as unknown as typeof kernel; + data = k.getService('data') as typeof data; +}, 180_000); + +afterAll(async () => { + await kernel?.shutdown?.(); +}); + +/** Assert a refusal by its ENVELOPE (ADR-0112), never by the bare fact that it threw. */ +async function refusal(promise: Promise): Promise<{ code: unknown; message: string }> { + try { + await promise; + } catch (error: any) { + return { code: error?.code, message: String(error?.message ?? '') }; + } + throw new Error('expected the write to be refused, but it resolved'); +} + +let seq = 0; + +const insertDuty = async (over: AnyRow): Promise => { + const created = await data.insert('duly_duty', { + name: `Duty ${++seq}`, + owner: `user_${seq}`, + source: 'self', + status: 'active', + ...over, + }); + return Array.isArray(created) ? created[0] : created; +}; + +const insertCatalogItem = async (over: AnyRow): Promise => { + const created = await data.insert('duly_catalog_item', { + name: `Item ${++seq}`, + position_code: 'test_position', + ...over, + }); + return Array.isArray(created) ? created[0] : created; +}; + +const readDuty = async (id: string): Promise => + (await data.find('duly_duty', { where: { id }, limit: 1 }))[0] as AnyRow; + +const CADENCE_MESSAGES = { + frequency: 'A standing duty never dispatches — a frequency on it is meaningless. Remove it.', + timing: + 'Due anchor, due offset and lead time compute a period due date — only a recurring duty has one. Clear them for standing and one-off.', + grace: "Grace days measures lateness against a task's due date — a standing duty never has a task, so it never has one.", +} as const; + +// ───────────────────────────────────────────────────────────────────────── +// duly_duty +// ───────────────────────────────────────────────────────────────────────── + +describe('duly_duty — conditional cadence defaults (#61)', () => { + it('a standing duty is inserted with no cadence field stamped at all', async () => { + const row = await insertDuty({ form: 'standing' }); + for (const field of ['frequency', 'due_anchor', 'due_offset_days', 'lead_days', 'grace_days']) { + expect(row[field] ?? null, field).toBeNull(); + } + }); + + it('a recurring duty is still stamped with every cadence default, unchanged', async () => { + const row = await insertDuty({ form: 'recurring' }); + expect(row.frequency).toBe('monthly'); + expect(row.due_anchor).toBe(DEFAULT_DUE_ANCHOR); + expect(row.due_offset_days).toBe(DEFAULT_DUE_OFFSET_DAYS); + expect(row.lead_days).toBe(DEFAULT_LEAD_DAYS); + expect(row.grace_days).toBe(0); + }); + + it('a one-off duty loses the period-timing fields but keeps frequency and grace_days defaults', async () => { + // Deliberately NOT the same as standing (see the cadence block comment in + // duty.object.ts): a one-off's due date is real, just not computed from a + // period anchor, so `frequency` (out of THIS issue's adjudicated scope) + // and `grace_days` (which measures lateness against that real due date) + // are left alone. + const row = await insertDuty({ form: 'one_off' }); + expect(row.due_anchor ?? null).toBeNull(); + expect(row.due_offset_days ?? null).toBeNull(); + expect(row.lead_days ?? null).toBeNull(); + expect(row.frequency).toBe('monthly'); + expect(row.grace_days).toBe(0); + }); + + it('a standing duty with every cadence field simply omitted is valid', async () => { + await expect(insertDuty({ form: 'standing' })).resolves.toBeTruthy(); + }); +}); + +describe('duly_duty — the new validations (#61)', () => { + it('refuses a frequency on a standing duty', async () => { + const { code, message } = await refusal(insertDuty({ form: 'standing', frequency: 'monthly' })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.frequency); + }); + + it.each(['due_anchor', 'due_offset_days', 'lead_days'] as const)( + 'refuses %s on a standing duty', + async (field) => { + const value = field === 'due_anchor' ? 'period_start' : 3; + const { code, message } = await refusal(insertDuty({ form: 'standing', [field]: value })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.timing); + }, + ); + + it.each(['due_anchor', 'due_offset_days', 'lead_days'] as const)( + 'refuses %s on a one-off duty too — it has no period to anchor into either', + async (field) => { + const value = field === 'due_anchor' ? 'period_end' : 2; + const { code, message } = await refusal(insertDuty({ form: 'one_off', [field]: value })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.timing); + }, + ); + + it('refuses grace_days on a standing duty', async () => { + const { code, message } = await refusal(insertDuty({ form: 'standing', grace_days: 3 })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.grace); + }); + + it('allows grace_days on a one-off duty — its task has a real due date to be late against', async () => { + const row = await insertDuty({ form: 'one_off', grace_days: 3 }); + expect(row.grace_days).toBe(3); + }); + + it('allows a recurring duty to state its own cadence explicitly', async () => { + const row = await insertDuty({ + form: 'recurring', + frequency: 'quarterly', + due_anchor: 'period_end', + due_offset_days: -3, + lead_days: 14, + grace_days: 2, + }); + expect(row.frequency).toBe('quarterly'); + expect(row.due_anchor).toBe('period_end'); + expect(row.due_offset_days).toBe(-3); + expect(row.lead_days).toBe(14); + expect(row.grace_days).toBe(2); + }); +}); + +describe('negative control — recurring_needs_frequency must still fire (#61 must not go vacuous)', () => { + it('an update that blanks frequency on a still-recurring duty is refused', async () => { + const created = await insertDuty({ form: 'recurring' }); + const { code, message } = await refusal(data.update('duly_duty', { id: created.id, frequency: null })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe('A recurring duty needs a frequency — otherwise nothing can dispatch it.'); + // And the row itself must be untouched by the refused write. + expect((await readDuty(String(created.id))).frequency).toBe('monthly'); + }); + + it('standing_no_frequency does not fire for a recurring duty, and vice versa', async () => { + // Both rules read `record.form`, on opposite literals — a rule that + // accidentally matched the other form's condition would either block + // every recurring duty or admit every standing one. + await expect(insertDuty({ form: 'recurring', frequency: 'weekly' })).resolves.toBeTruthy(); + await expect(insertDuty({ form: 'standing' })).resolves.toBeTruthy(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// duly_catalog_item — the same fix, mirrored (#61: #5's instantiation copies +// these fields verbatim onto every duty made from a catalog item) +// ───────────────────────────────────────────────────────────────────────── + +describe('duly_catalog_item — mirrors duly_duty field-for-field (#61)', () => { + it('a standing catalog item is inserted with no cadence field stamped at all', async () => { + const row = await insertCatalogItem({ form: 'standing' }); + for (const field of ['frequency', 'due_anchor', 'due_offset_days', 'lead_days', 'grace_days']) { + expect(row[field] ?? null, field).toBeNull(); + } + }); + + it('a recurring catalog item is still stamped with every cadence default', async () => { + const row = await insertCatalogItem({ form: 'recurring' }); + expect(row.frequency).toBe('monthly'); + expect(row.due_anchor).toBe(DEFAULT_DUE_ANCHOR); + expect(row.due_offset_days).toBe(DEFAULT_DUE_OFFSET_DAYS); + expect(row.lead_days).toBe(DEFAULT_LEAD_DAYS); + expect(row.grace_days).toBe(0); + }); + + it('a one-off catalog item keeps frequency and grace_days but loses the timing fields', async () => { + const row = await insertCatalogItem({ form: 'one_off' }); + expect(row.due_anchor ?? null).toBeNull(); + expect(row.due_offset_days ?? null).toBeNull(); + expect(row.lead_days ?? null).toBeNull(); + expect(row.frequency).toBe('monthly'); + expect(row.grace_days).toBe(0); + }); + + it('refuses a frequency on a standing catalog item', async () => { + const { code, message } = await refusal(insertCatalogItem({ form: 'standing', frequency: 'monthly' })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.frequency); + }); + + it('refuses due timing on a standing catalog item', async () => { + const { code, message } = await refusal(insertCatalogItem({ form: 'standing', due_offset_days: 1 })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.timing); + }); + + it('refuses grace_days on a standing catalog item', async () => { + const { code, message } = await refusal(insertCatalogItem({ form: 'standing', grace_days: 1 })); + expect(code).toBe('VALIDATION_FAILED'); + expect(message).toBe(CADENCE_MESSAGES.grace); + }); +}); + +describe('#5 instantiation: a blank catalog-side cadence stays blank on the duty it produces', () => { + it('copying a standing catalog item verbatim onto a duty still yields no cadence fields', async () => { + // `applyCatalogHandler` (catalog.handlers.ts) copies frequency/due_anchor/ + // due_offset_days/lead_days/grace_days from the catalog item onto the + // new duty VERBATIM — reproduced here with a direct insert rather than + // driving the handler, so this stays a fixture-free proof that the two + // objects' conditional defaults agree once the values in transit are + // null, which is what the handler will actually be carrying now that the + // catalog item itself is never allowed to hold them for a standing row. + const item = await insertCatalogItem({ form: 'standing' }); + const duty = await insertDuty({ + form: item.form, + frequency: item.frequency ?? null, + due_anchor: item.due_anchor ?? null, + due_offset_days: item.due_offset_days ?? null, + lead_days: item.lead_days ?? null, + grace_days: item.grace_days ?? null, + source: 'catalog', + catalog_item: String(item.id), + }); + for (const field of ['frequency', 'due_anchor', 'due_offset_days', 'lead_days', 'grace_days']) { + expect(duty[field] ?? null, field).toBeNull(); + } + }); +}); diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index b2a7b6b..b1ad48b 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -20,9 +20,6 @@ import { type DispatchEngine, } from '../src/jobs/dispatch.job.js'; import { - DEFAULT_DUE_ANCHOR, - DEFAULT_DUE_OFFSET_DAYS, - DEFAULT_LEAD_DAYS, DEFAULT_TIMEZONE, DISPATCH_DUTY_FIELDS, nextDispatchedPeriod, @@ -112,16 +109,25 @@ describe('wiring', () => { describe('the cadence fallbacks are the object schema, not a second opinion', () => { // The planner is pure and imports no metadata, so it restates `duly_duty`'s - // declared defaults. These assertions are what stop the two from drifting + // declared defaults. This assertion is what stops the two from drifting // into two answers — the same pin as DEFAULT_DUTY_TIMEZONE in the catalog // handlers. - const defaultOption = (field: { options?: Array<{ value: string; default?: boolean }> }) => - field.options?.find((o) => o.default)?.value; - it('timezone', () => expect(Duty.fields.timezone.defaultValue).toBe(DEFAULT_TIMEZONE)); - it('lead_days', () => expect(Duty.fields.lead_days.defaultValue).toBe(DEFAULT_LEAD_DAYS)); - it('due_offset_days', () => expect(Duty.fields.due_offset_days.defaultValue).toBe(DEFAULT_DUE_OFFSET_DAYS)); - it('due_anchor', () => expect(defaultOption(Duty.fields.due_anchor)).toBe(DEFAULT_DUE_ANCHOR)); + + // `lead_days` / `due_offset_days` / `due_anchor` no longer carry a plain + // literal default (#61): the value must be BLANK, not the cadence default, + // on a duty the planner never reads them for (standing, one-off) — see + // `duty.object.ts`'s cadence block. Each is now a CEL `defaultValue` (the + // blessed null-guard idiom, objectstack#3306), which `pnpm validate` + // accepts structurally and never evaluates (`field.zod.ts`'s authoring + // gate returns unconditionally on `shape === 'expression'`), so a + // STRUCTURAL pin here — reading `.defaultValue` off the schema — would + // prove nothing about whether the expression actually behaves. The real + // pin — that a RECURRING duty still gets exactly `DEFAULT_DUE_ANCHOR` / + // `DEFAULT_DUE_OFFSET_DAYS` / `DEFAULT_LEAD_DAYS`, against a booted engine + // that actually evaluates the CEL — lives in + // `test/cadence-conditional-defaults.test.ts`, alongside the standing/ + // one-off assertions that these come back blank. }); describe('the duty projection covers every field the planner reads', () => {