Skip to content

A hand-created duty defaults to source: 'catalog', so a self-declared duty is born scoreable #50

Description

@os-warren

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. catalog and assigned duties are governed: the organisation put them there, so on-time rates over them mean something. self duties 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 (catalog via duly_catalog_apply, assigned via the assignment fan-out) are the ones that must be stated by the producer that knows. Both producers already set source explicitly, 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 source supplied is self
  • duly_catalog_apply still produces catalog, the assignment fan-out still produces assigned (both already assert this in test/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

  1. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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.source defaults 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 to duly_catalog_sync, which rewrites cadence fields on anything whose source is 'catalog' — so a hand-authored duty could have its frequency silently overwritten from a catalog item it never came from.

    Why self is the correct default rather than "no default"

    Every path that legitimately produces a governed duty sets source explicitly:

    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 get catalog from the apply action regardless.

    Scope

    1. src/objects/duty.object.ts — move default: true from the catalog option to the self option.
    2. A test pinning it: the default source on duly_duty is self, and both governed values are reachable only by explicit assignment.
    3. Check the seed (Demo seed data — the product working on first boot #7, not yet landed) and 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

  2. self-assigned this
    on Sep 1, 2026
  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Claiming this — session session_01SqkTcrxUFci7nqXdbBSe2p, branch claude/issue-50-source-default.


    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    <!-- 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

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    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 — both create_record nodes in the fan-out target duly_task, and no path in the app creates a duly_duty from an assignment.

    So the conclusion held, but one of the two clauses I built it on was about the wrong object. The duly_catalog_apply half was right, and it is on its own sufficient: the only remaining path to a duly_duty with no explicit source is a hand-created one.

    #55 goes into this PR rather than a separate card. duly_task.source carries the identical catalog default — I confirmed it on main. src/objects/task.object.ts is 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.source onto the task, the fan-out writes it directly, and — the path that actually matters — duly_member holds allowCreate: true on duly_task with nothing stamping source on a hand-created one. If any producer turns out to be riding on the default, that is the real finding.


    Generated by Claude Code

  6. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    <!-- 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

  7. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    ACCEPT — PM review of #54, closing #50 and #55. Round 4.

    Gates re-run with origin/main merged (now carrying #46): all four EXIT=0, 375 tests. CI verify success. Confirmed on the branch: self is the default on both duly_duty.source and duly_task.source, and catalog/assigned carry 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.ts writes source: duty.source explicitly onto every draft; the fan-out writes 'assigned' on both create_record nodes. Neither was riding on the default. The path that actually reaches it is the one you identified: duly_member holds allowCreate: true on duly_task with 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions