diff --git a/src/objects/duty.object.ts b/src/objects/duty.object.ts index 6dedcb6..8aae6e9 100644 --- a/src/objects/duty.object.ts +++ b/src/objects/duty.object.ts @@ -83,9 +83,16 @@ export const Duty = ObjectSchema.create({ label: 'Source', required: true, options: [ - { label: 'Role catalog', value: 'catalog', color: '#16515F', default: true }, + { label: 'Role catalog', value: 'catalog', color: '#16515F' }, { label: 'Assigned by manager', value: 'assigned', color: '#8C6512' }, - { label: 'Self-declared', value: 'self', color: '#576B73' }, + // The default. Every path that legitimately produces a governed duty + // stamps `source` explicitly — `duly_catalog_apply` writes 'catalog' + // (#34), the assignment fan-out writes 'assigned' (#33) — so the + // default is only ever reached by a hand-created duty, which is by + // definition self-declared. Fail-safe direction: a producer that + // forgets to stamp caliber produces an unscored duty, not a scored + // one (#50). + { label: 'Self-declared', value: 'self', color: '#576B73', default: true }, ], }), diff --git a/src/objects/task.object.ts b/src/objects/task.object.ts index 85f7254..73a5cb6 100644 --- a/src/objects/task.object.ts +++ b/src/objects/task.object.ts @@ -66,9 +66,19 @@ export const Task = ObjectSchema.create({ label: 'Source', required: true, options: [ - { label: 'Role catalog', value: 'catalog', color: '#16515F', default: true }, + { label: 'Role catalog', value: 'catalog', color: '#16515F' }, { label: 'Assigned by manager', value: 'assigned', color: '#8C6512' }, - { label: 'Self-declared', value: 'self', color: '#576B73' }, + // The default. Both manufactured producers stamp this explicitly and + // do not rely on it: the dispatcher copies `duty.source` onto every + // dispatched task (`dispatch.plan.ts` — `source: duty.source ?? ''`, + // never omitted from the draft), and the assignment fan-out writes + // `source: 'assigned'` directly on both `create_record` nodes + // (`assignment.flow.ts`). The path that actually reaches this + // default is `duly_member`'s `allowCreate: true` on `duly_task` + // (`permission-sets.ts`) with no create form stamping `source` — a + // member hand-creating their own task, which is self-declared by + // definition (#55). + { label: 'Self-declared', value: 'self', color: '#576B73', default: true }, ], }), diff --git a/test/invariants.test.ts b/test/invariants.test.ts index fca20b1..72dad55 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -59,6 +59,33 @@ describe('product invariants', () => { } }); + it('a hand-created record is self-declared, not born governed (#50, #55)', () => { + // The safe default for an ambiguous caliber is the UNscoreable one, on + // every object that carries the column — not just the one that surfaced + // it first. A duty or task created with no `source` supplied must land + // as 'self' — never 'catalog' (which additionally exposes a duty to + // duly_catalog_sync's cadence rewrite for a catalog item it never came + // from) and never 'assigned'. + // + // Both governed calibers are reachable only when the producer that knows + // states them explicitly: on duly_duty, duly_catalog_apply writes + // 'catalog' (#34); on duly_task, the dispatcher copies `duty.source` + // (#43) and the assignment fan-out writes 'assigned' directly (#33). + // Neither may ride in on the field default — the default is reached only + // by a hand-created record, which is self-declared by definition. + for (const [name, schema] of [['duly_duty', Duty], ['duly_task', Task]] as const) { + const options = schema.fields.source.options ?? []; + const defaults = options.filter((o) => o.default); + expect(defaults, `${name}.source: exactly one option may claim the default`).toHaveLength(1); + expect(defaults[0]?.value, `${name}.source`).toBe('self'); + + for (const governed of ['catalog', 'assigned'] as const) { + const option = options.find((o) => o.value === governed); + expect(option?.default, `${name}.source: ${governed} must not be the default`).not.toBe(true); + } + } + }); + it('every object states its sharing model explicitly', () => { for (const schema of [Duty, Task, LogEntry, CatalogItem]) { expect(schema.sharingModel, `${schema.name} must state an OWD`).toBeTruthy();