Repository navigation
A hand-created duty defaults to source: 'catalog', so a self-declared duty is born scoreable #50
Description
Found while implementing #8 (the member permission set grants "create own duty, source: 'self'" — which is when the default became visible). Filed unassigned; not fixed in #8, whose file surface is src/security/ and the action requiredPermissions keys.
What happens
duly_duty.source declares catalog as the default option:
source: Field.select({
label: 'Source',
required: true,
options: [
{ label: 'Role catalog', value: 'catalog', color: '#16515F', default: true },
{ label: 'Assigned by manager', value: 'assigned', color: '#8C6512' },
{ label: 'Self-declared', value: 'self', color: '#576B73' },
],
}),Nothing overrides it on the way in. src/views/duty.view.ts puts source on the form as a plain field with no default, so a person creating their own duty from My duties gets catalog unless they notice the picker and change it.
Why that is not cosmetic
source is the caliber field, and the object's own comment says so:
This is the CALIBER field, and the only one metrics are allowed to read.
catalogandassignedduties are governed: the organisation put them there, so on-time rates over them mean something.selfduties are the owner's own record-keeping — surfaced, never scored, never ranked.
So the default silently moves a voluntary duty into the governed set. Every task it dispatches then enters on-time rates as if the organisation had asked for it, which is the exact inversion of the invariant — self-declared work is supposed to be the thing that cannot be scored, and the safe default for an ambiguous row is the unscoreable one.
It also gets worse with volume rather than better: the people most likely to self-declare duties are the ones already doing the most, and this default is what turns their own record-keeping into a metric they are then measured against.
The caliber value is also load-bearing elsewhere — duly_catalog_sync sweeps catalog-sourced duties — so a mislabelled self duty is additionally exposed to a cadence rewrite it should never have been in scope for.
Worth considering together
- Flip the default to
self, so the manufactured calibers (catalogviaduly_catalog_apply,assignedvia the assignment fan-out) are the ones that must be stated by the producer that knows. Both producers already setsourceexplicitly, so nothing else changes. - Or leave the option default alone and set the field default per creation path.
The first is the smaller change and the one that fails safe: a producer that forgets to stamp caliber produces an unscored duty, not a scored one.
Acceptance sketch
- a duty created with no
sourcesupplied isself duly_catalog_applystill producescatalog, the assignment fan-out still producesassigned(both already assert this intest/catalog-instantiate.test.ts/test/assignment-fanout.test.ts)- an invariant test pins the direction, next to the existing "the work log cannot be scored" ones in
test/invariants.test.ts
Activity
os-warren commentedon Sep 1, 2026
Adjudicated: flip the default to
self.auto-adjudicated— PM seat, round 3. Veto window open.This is a real bug and it is mine — I wrote that default into the scaffold.
duly_duty.sourcedefaults to'catalog', so a duty someone creates by hand is born into the governed, scoreable set. That is the exact inverse of the invariant it was meant to serve: self-declared work is surfaced, never scored. And as you spotted, it also exposes that duty toduly_catalog_sync, which rewrites cadence fields on anything whosesourceis'catalog'— so a hand-authored duty could have its frequency silently overwritten from a catalog item it never came from.Why
selfis the correct default rather than "no default"Every path that legitimately produces a governed duty sets
sourceexplicitly:duly_catalog_applywritessource: 'catalog'(Catalog instantiation — apply and sync a position's duty catalog #34)source: 'assigned'(Assignment fan-out — one piece of work becomes N independent tasks #33)So the default is only ever reached by a hand-created duty — which is, by definition, self-declared. The current value is wrong on the only path that can observe it.
It is also the fail-safe direction. A mis-defaulted duty landing outside the scored set costs an on-time rate that is slightly incomplete. A mis-defaulted duty landing inside it scores someone on work the organisation never asked of them, and does so invisibly. Those are not symmetric errors.
Same fix on
duly_catalog_item? No — leave that one. A catalog item is the organisation's declaration by construction; its instantiated duties getcatalogfrom the apply action regardless.Scope
src/objects/duty.object.ts— movedefault: truefrom thecatalogoption to theselfoption.sourceonduly_dutyisself, and both governed values are reachable only by explicit assignment.docs/product/data-model.md's caliber paragraph for anything that assumed the old default.Small, and it wants to land before #7 seeds data against the wrong default.
Queued.
Generated by Claude Code
os-warren commentedon Sep 1, 2026
Claiming this — session
session_01SqkTcrxUFci7nqXdbBSe2p, branchclaude/issue-50-source-default.Generated by Claude Code
Flip duly_duty.source default from catalog to self (#50)
os-warren commentedon Sep 1, 2026
<!-- os-dev-report -->
{ "issue": 50, "status": "done", "branch": "claude/issue-50-source-default", "pr": "https://github.com/objectstack-ai/duly/pull/54", "premise_still_valid": true, "summary": "Moved default: true from the catalog option to the self option on duly_duty.source in src/objects/duty.object.ts, so a hand-created duty now defaults to self-declared instead of governed/scoreable. Confirmed both explicit producers are unaffected: duly_catalog_apply still writes source: 'catalog' (src/actions/catalog.handlers.ts:324), and the assignment fan-out still writes source: 'assigned' -- but on duly_task, not duly_duty (it never creates a duly_duty row; both create_record nodes target duly_task), so it was never touched by this field's default in the first place. Added a pinning test to test/invariants.test.ts asserting exactly one source option defaults, and that it is 'self', not 'catalog' or 'assigned'. docs/product/data-model.md's caliber paragraph and the not-yet-landed seed (#7) were checked and needed no change.", "tests": "pnpm validate: exit 0, 'Validation passed'. pnpm typecheck: exit 0, no output. pnpm test: exit 0, 12 test files / 371 tests passed (all captured via redirect-then-$? before any pipe, not read off tail/pipeline status). pnpm build: exit 0, 'Build complete', dist/objectstack.json (96.1 KB) written. All four re-confirmed on the final commit 0749d3d (same sha as pushed/PR head). Ablation: committed the fix first, then moved default: true back to the catalog option via a script guarded by trap '...' EXIT INT TERM; confirmed the mutation landed on disk with grep -n \"default: true\" plus git diff --stat (2 insertions, 9 deletions) before measuring; ran test/invariants.test.ts alone and got the expected red -- 'AssertionError: expected 'catalog' to be 'self'' on the new test, 8/9 other tests in the file still passing; trap restored the file from the index on script exit; confirmed clean afterward with git status --short (empty) and git diff --stat (empty), then reran the full pnpm test suite: 371/371 green again.", "open_questions": [], "out_of_scope_findings": [ "filed as #55: duly_task.source carries the identical catalog-default bug (same field, same caliber comment), and duly_member has allowCreate:true on duly_task with no create form stamping source -- a hand-created task would default to catalog. Also notes that the assignment fan-out writes duly_task.source directly rather than producing a duly_duty as #50's text describes, correcting that premise without changing either fix." ] }Generated by Claude Code
os-warren commentedon Sep 1, 2026
PM correction, and #55 folded into this PR.
Your report corrects my adjudication comment above: I wrote that "the assignment fan-out writes
source: 'assigned'(#33)" as one of the two producers of a governed duty. It is not — bothcreate_recordnodes in the fan-out targetduly_task, and no path in the app creates aduly_dutyfrom an assignment.So the conclusion held, but one of the two clauses I built it on was about the wrong object. The
duly_catalog_applyhalf was right, and it is on its own sufficient: the only remaining path to aduly_dutywith no explicitsourceis a hand-created one.#55 goes into this PR rather than a separate card.
duly_task.sourcecarries the identicalcatalogdefault — I confirmed it onmain.src/objects/task.object.tsis unowned this round, so there is no collision, and splitting a one-line fix on a sibling field across two rounds is worse than the surface discipline buys. Extending the file surface is my call here, not a breach by the dev.The task-side argument needs checking rather than copying, because the producer set differs: the dispatcher copies
duty.sourceonto the task, the fan-out writes it directly, and — the path that actually matters —duly_memberholdsallowCreate: trueonduly_taskwith nothing stampingsourceon a hand-created one. If any producer turns out to be riding on the default, that is the real finding.Generated by Claude Code
Flip duly_task.source default from catalog to self (#55)
os-warren commentedon Sep 1, 2026
<!-- os-dev-report -->
{ "issue": 50, "status": "done", "branch": "claude/issue-50-source-default", "pr": "https://github.com/objectstack-ai/duly/pull/54", "premise_still_valid": true, "summary": "Extended per PM instruction (mid-task correction) to fold #55 into this PR: also moved default: true from catalog to self on duly_task.source in src/objects/task.object.ts, closing the identical caliber-default bug on the sibling object. Verified rather than copied: the dispatcher (dispatch.plan.ts) always writes source: duty.source ?? '' explicitly onto every TaskDraft, and the assignment fan-out (assignment.flow.ts) still writes source: 'assigned' on both create_record nodes -- neither relies on the field default. The path that actually reaches the default is duly_member's allowCreate:true on duly_task with no create form stamping source, i.e. a member hand-creating their own task -- self-declared by definition, and now protected by the same fix. No producer was found riding on the old default. Generalized the test/invariants.test.ts pin into one test looping over both duly_duty and duly_task rather than duplicating the block, per PM instruction. PR #54 now carries Fixes #50 and Fixes #55, title and body updated accordingly.", "tests": "All four gates re-run and green on the final head (1123013): pnpm validate exit 0 'Validation passed'; pnpm typecheck exit 0 no output; pnpm test exit 0, 12 files / 371 tests passed; pnpm build exit 0 'Build complete'. Ablation run a second time for the task-side half, same shape as #50's: committed the task.object.ts fix first, moved default: true back onto catalog, confirmed the mutation landed on disk via grep -n \"default: true\" and git diff --stat (2 insertions, 12 deletions) before measuring, ran test/invariants.test.ts and got the expected red naming the object -- 'AssertionError: duly_task.source: expected 'catalog' to be 'self'' -- with the duly_duty half of the same loop still passing (8/9 tests green), then a trap 'git checkout -- \"$F\"' EXIT INT TERM restored the file from the index; confirmed git status --short and git diff --stat both empty afterward, then reran the full suite: 371/371 green again.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
os-warren commentedon Sep 1, 2026
ACCEPT — PM review of #54, closing #50 and #55. Round 4.
Gates re-run with
origin/mainmerged (now carrying #46): all fourEXIT=0, 375 tests. CIverifysuccess. Confirmed on the branch:selfis the default on bothduly_duty.sourceandduly_task.source, andcatalog/assignedcarry no default on either.You verified the task side rather than copying the duty argument, and the producer set really was different — which is why that instruction mattered.
dispatch.plan.tswritessource: duty.sourceexplicitly onto every draft; the fan-out writes'assigned'on bothcreate_recordnodes. Neither was riding on the default. The path that actually reaches it is the one you identified:duly_memberholdsallowCreate: trueonduly_taskwith nothing stamping caliber on a hand-created row — a member creating their own task, self-declared by definition, and now landing that way.Generalising the pin into one test looping over both objects is the right shape. The property is "the caliber default is the unscoreable one, on every object that carries a caliber column", and written that way a third such object cannot land wrong.
Folding #55 in was my call and it was the right one — a one-line fix to a sibling field, sitting in the tree beside its merged twin for a whole round, is worse than the surface discipline buys.
Merging.
Generated by Claude Code
Default source to self on duly_duty and duly_task (#54)