Skip to content

Assignment fan-out — one piece of work becomes N independent tasks #6

Closed

Description

@os-warren

Assigning is the only write a manager makes in this product. Everything about this flow should protect that.

Files you own

  • src/flows/assignment.flow.ts (new), added to dulyFlows in src/flows/index.ts
  • test/assignment-fanout.test.ts (new)

The flow

type: 'record_change' on duly_assignment, firing when status transitions to 'dispatched'. Runs runAs: 'system' — the assignees are not the actor.

A loop node over record.assignees creating one duly_task per name:

Task field Value
subject the assignment's subject
owner the iterated assignee
business_unit that assignee's unit
assignment the assignment record
source 'assigned'
due_date the assignment's due_date
visible_from same as due_date — an assignment has no lead time to spread
period_key empty. An assignment has no period; the unique dispatch index does not apply to it
status 'open'

Then, only if record.needs_collection is true, create one further task owned by the assigner ("follow up once everyone is in"). A manager who assigns work must not automatically inherit a to-do list from having assigned it — that is the opt-in.

Why N tasks and not one shared task

Five names on one record produces something everybody can see and nobody owns, and it is the most reliable way a task tool starts being ignored. Five records, one owner each, each updated only by its owner. The manager's "3 of 5" is a rollup over the children (duly_assignment.task_count already exists as a Field.summary) — computed on read, maintained by nobody.

Idempotency

Re-saving a dispatched assignment must not fan out again. Guard on existing duly_task rows for the assignment. Adding a name to assignees on an already-dispatched assignment should create only the missing task — check per assignee, not per assignment.

Note the unique index on duly_task covers (duty, owner, period_key), which is all null/empty here, so it will not protect you. The guard has to be explicit.

Do not

  • create a status-rollup field the flow writes into
  • give the assigner a task unless needs_collection is set
  • set period_key to anything

Acceptance

  • dispatching to 5 assignees creates exactly 5 tasks, each with one owner and source: 'assigned'
  • task_count reads 5 without anything having written it
  • re-saving the dispatched assignment creates 0 more
  • adding a 6th assignee and re-dispatching creates exactly 1
  • needs_collection: false creates no assigner task; true creates exactly one
  • flow conditions use record.<field>; pnpm validate passes

Gates

pnpm validate && pnpm typecheck && pnpm test && pnpm build.

Activity

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

    @os-warren
    CollaboratorAuthor

    Claim: PM loop round 1
    Session: session_01SqkTcrxUFci7nqXdbBSe2p
    Branch: claude/issue-6-assignment-fanout
    Worktree: duly-issue-6
    Domain: n/a (single-package app repo; no domain:* lanes in this repo)
    File surface: src/flows/, test/assignment-fanout.test.ts (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — application metadata only; no protocol contract surface touched.
    Serial constraints cleared: batch 1 is #1 / #3 / #5 / #6, file surfaces disjoint. #11 (the other src/flows/ card) is held at pm:blocked, so this card owns src/flows/ for the round.


    Generated by Claude Code

  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor
    {
      "issue": 6,
      "status": "done",
      "branch": "claude/issue-6-assignment-fanout",
      "pr": "https://github.com/objectstack-ai/duly/pull/33",
      "premise_still_valid": true,
      "summary": "Implemented duly_assignment_fanout: a record_change flow, runAs system, status active, gated on record.status == \"dispatched\", looping over {record.assignees} to create one duly_task per assignee (owner = the iterated assignee, source 'assigned', status 'open', visible_from = due_date, business_unit denormalised from the assignee's sys_user.primary_business_unit_id, period_key NOT written), plus one assigner task reachable only behind record.needs_collection == true. Two dispatch premises were corrected by measurement, both documented in the PR body. (1) The trigger does NOT bind at the flow top level: FlowSchema is .strict() and rejects both `object` and `trigger`; the engine's resolveTriggerBinding reads { objectName, triggerType, condition } off the START node's config, and reads objectName only. (2) 'pnpm validate rejects a bare field reference' is FALSE for flow predicates — measured, ablation 1 below. The core design decision: the gate is dispatched STATE, not the transition into it. Idempotency is carried entirely by an explicit PER-OWNER guard (get_record on duly_task where { assignment, owner }, create only on a miss), which is what the acceptance criteria actually require. Two consequences flagged for adjudication in open_questions. Scope held: objectstack.config.ts untouched, only src/flows/ + test/assignment-fanout.test.ts written.",
      "tests": "All four gates run under scripts/pm/os-verify-lock.sh on the FINAL commit e3cbb28 (git rev-parse --short HEAD; worktree clean; exit codes captured before any pipe). VALIDATE_EXIT=0 ('✓ Validation passed (299ms)', 'Logic: 1 Flows'). TYPECHECK_EXIT=0 (tsc --noEmit, no output). TEST_EXIT=0 ('Test Files 2 passed (2)', 'Tests 36 passed (36)' — 8 pre-existing + 28 new). BUILD_EXIT=0 ('✓ Build complete (291ms)', 'Logic: 1 Flows'). No repo-level narrowing: `pnpm validate|typecheck|test|build` ARE the whole farm for this single-package app, all four ran unabridged. THREE ABLATIONS, each with a trap '<restore>' EXIT INT TERM, each mutation confirmed on disk by grep -cF of BOTH the injected and the removed text before measuring, and the tree verified clean after. (1) start condition `record.status` -> bare `status`: on disk injected=1 removed=0, ABLATION_VALIDATE_EXIT=0 — validate STILL PASSED. Expected direction was 'turns red'; the observed direction is 'stays green', which falsifies the dispatch premise. Root cause is deliberate, in packages/lint/src/validate-expressions.ts collectBoundRecordReads: 'Deliberately NEVER a bare identifier: in a flattened flow scope a bare name may be a flow variable'. Filed as #29. (2) `record.needs_collection` -> `record.needs_colection`: on disk target=0 injected=1, ABLATION2_VALIDATE_EXIT=1, located finding 'flow duly_assignment_fanout · edge e_collection ... unknown field `needs_colection` on `duly_assignment` — did you mean `needs_collection`?'. This is the gate that IS real and it proves the green validate above is a measurement, not a vacuous pass; it also independently confirms the start-node objectName binding, since that is the only place the validator could have learned the bound object. (3) guard filter { assignment, owner } -> { assignment } (the exact per-assignment bug the issue warns about): on disk per-owner=0 per-assignment=1, ABLATION3_TEST_EXIT=1 with exactly one failure ('the guard filters per OWNER, not per assignment') while ABLATION3_VALIDATE_EXIT=0 — the test is the only gate standing between this repo and that bug. No engine-execution test was possible: the `automation` capability provider is not installed in this open-edition checkout (requires: ['automation'] resolves at boot), so the flow cannot be run locally and the 28 new tests pin authored metadata, not runtime behaviour. Every runtime claim in the PR body was instead read off engine source in /home/user/objectstack (service-automation 17.1.0 vs the 17.2.0 published here — minor version skew, noted).",
      "open_questions": [
        {
          "question": "An assigner who is also one of the assignees: the per-(assignment, owner) guard means needs_collection adds NO second task for them, because they already own one from the fan-out. Correct, or should the follow-up task be distinguishable from an assignee task?",
          "options": [
            "A — keep (assignment, owner) as the sole guard key. One task per owner per assignment. No new field, and the guard stays stable under a subject edit.",
            "B — add `subject` to the guard key and give the follow-up task its own subject. Distinguishes the two, but mints a DUPLICATE task for every owner the moment somebody edits the assignment's subject and re-saves — and the follow-up subject would be authored display text, which AGENTS.md rule 8 forbids inlining in a flow.",
            "C — add a field to duly_task marking a task as the collection follow-up. Cleanest semantics, but a new field on the core object and outside this card's file surface."
          ],
          "recommendation": "A, shipped. Real business need: a manager who assigned work to a group including themselves needs one row for their part of it, not two — B's duplicate-on-rename is a live data-corruption path, not a hypothetical. Long-term soundness: A is the only option with no second writer and no drift; B makes correctness depend on a mutable display string. AI-authorability: A is structurally hard to get wrong (the guard key is the identity), whereas B invites an author to 'fix' idempotency by widening the key and silently reintroduces duplicates. Startup scope: A adds nothing; C adds a declared field with no consumer yet. If the product later needs the two distinguishable, C is the honest way and deserves its own card."
        },
        {
          "question": "The assigner's follow-up task is written with source: 'assigned'. The issue specifies 'assigned' for the fan-out tasks but is silent on the assigner's. `source` is the caliber column metrics read.",
          "options": [
            "A — 'assigned'. The row came out of an assignment record.",
            "B — 'self'. The assigner opted into it themselves, and 'self' is documented as never entering an on-time rate or a comparison."
          ],
          "recommendation": "A, shipped, but this is a genuine product call and I would not defend it hard. It keeps the caliber column meaning 'where did this row come from' rather than 'who wanted it'. If the intent is that a manager's own follow-up must never appear in an on-time rate, B is right and it is a one-line change."
        },
        {
          "question": "Out of scope for this repo, needs routing: the CLI's `objectstack generate` flow scaffold emits a shape that FlowSchema 17.2.0 rejects — top-level `trigger: { type, object, events: ['after_insert', ...] }`, plus nodes carrying `name`/`next` instead of the required `label`. Neither the top-level trigger key nor that events vocabulary exists on the current surface. A generated flow cannot pass `objectstack validate`.",
          "options": [
            "A — file it in the framework repo (objectstack-ai/objectstack, packages/cli/src/commands/generate.ts), where the fix lands.",
            "B — leave it to the PM to route."
          ],
          "recommendation": "B for now — I did not file it. The dispatch scoped my finding-filing to objectstack-ai/duly, and filing into a framework repo I was not asked to touch on a guessed name seemed worse than reporting it. Happy to file it on a word from you."
        }
      ],
      "out_of_scope_findings": [
        "filed as #29: the `record.<field>` predicate rule has no gate for FLOW predicates, and AGENTS.md's documented failure mode is wrong for them — validate deliberately skips bare identifiers in flows (measured, exit 0), and a bare name in a flow does not evaluate to null but either resolves via the engine's record-flattening or throws (ADR-0032 §1c). Suggests a repo-level walk over dulyFlows including region bodies, plus an AGENTS.md wording fix, before #2/#7/#11 land."
      ]
    }

    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    os-dev-report

    Supersedes the previous comment: GitHub's body sanitizer ate its os-dev-report HTML-comment marker on write, along with two short angle-bracket fragments inside the JSON. This one carries the marker as literal text and no angle brackets. Read this one.

    {
      "issue": 6,
      "status": "done",
      "branch": "claude/issue-6-assignment-fanout",
      "pr": "https://github.com/objectstack-ai/duly/pull/33",
      "premise_still_valid": true,
      "summary": "Implemented duly_assignment_fanout: a record_change flow, runAs system, status active, gated on record.status == 'dispatched', looping over {record.assignees} to create one duly_task per assignee (owner = the iterated assignee, source 'assigned', status 'open', visible_from = due_date, business_unit denormalised from the assignee's sys_user.primary_business_unit_id, period_key NOT written), plus one assigner task reachable only behind record.needs_collection == true. Two dispatch premises were corrected by measurement, both documented in the PR body. (1) The trigger does NOT bind at the flow top level: FlowSchema is .strict() and rejects both `object` and `trigger`; the engine's resolveTriggerBinding reads { objectName, triggerType, condition } off the START node's config, and reads objectName only. (2) 'pnpm validate rejects a bare field reference' is FALSE for flow predicates - measured, ablation 1 below. The core design decision: the gate is dispatched STATE, not the transition into it. Idempotency is carried entirely by an explicit PER-OWNER guard (get_record on duly_task where { assignment, owner }, create only on a miss), which is what the acceptance criteria actually require. Two consequences flagged for adjudication in open_questions. Scope held: objectstack.config.ts untouched, only src/flows/ + test/assignment-fanout.test.ts written.",
      "tests": "All four gates run under scripts/pm/os-verify-lock.sh on the FINAL commit e3cbb28 (git rev-parse --short HEAD; worktree clean; every exit code captured by redirecting to a file BEFORE any pipe, never through tail). VALIDATE_EXIT=0, gate's own verdict line 'Validation passed (299ms)' plus 'Logic: 1 Flows'. TYPECHECK_EXIT=0 (tsc --noEmit, no output). TEST_EXIT=0, verdict lines 'Test Files 2 passed (2)' and 'Tests 36 passed (36)' - 8 pre-existing + 28 new. BUILD_EXIT=0, verdict line 'Build complete (291ms)' plus 'Logic: 1 Flows'. NO NARROWING DECLARED OR NEEDED: `pnpm validate|typecheck|test|build` ARE the whole gate farm for this single-package app; all four ran unabridged on the final commit. THREE ABLATIONS, each wrapped in a shell script carrying a restore trap on EXIT INT TERM, each mutation confirmed on disk by grep -cF of BOTH the injected AND the removed literal before any measurement, and the tree verified clean by git status after. (1) start condition record.status changed to bare status: on disk injected=1 removed=0; ABLATION_VALIDATE_EXIT=0, validate STILL PASSED. Predicted direction was 'turns red'; OBSERVED direction is 'stays green', which falsifies the dispatch premise rather than confirming it. Root cause is deliberate, in packages/lint/src/validate-expressions.ts collectBoundRecordReads: 'Deliberately NEVER a bare identifier: in a flattened flow scope a bare name may be a flow variable'. Filed as #29. (2) record.needs_collection changed to record.needs_colection: on disk target=0 injected=1; ABLATION2_VALIDATE_EXIT=1 with the gate's own located finding 'flow duly_assignment_fanout . edge e_collection ... unknown field `needs_colection` on `duly_assignment` - did you mean `needs_collection`?'. This is the gate that IS real; it proves the green validate above is a measurement and not a vacuous pass, and it independently confirms the start-node objectName binding, since that config is the only place the validator could have learned the bound object. (3) guard filter { assignment, owner } narrowed to { assignment } - the exact per-assignment bug the issue warns about: on disk per-owner=0 per-assignment=1; ABLATION3_TEST_EXIT=1 with exactly one failure, 'the guard filters per OWNER, not per assignment', while ABLATION3_VALIDATE_EXIT=0. The test is the only gate standing between this repo and that bug. LIMITATION, stated plainly: no engine-execution test was possible. The `automation` capability provider is not installed in this open-edition checkout (requires: ['automation'] resolves at boot), so the flow cannot be run locally; the 28 new tests pin AUTHORED METADATA, not runtime behaviour. Every runtime claim in the PR body was instead read off engine source in /home/user/objectstack - service-automation, trigger-record-change, formula/stdlib - which is at 17.1.0 against the 17.2.0 published here, a minor version skew worth knowing about.",
      "open_questions": [
        {
          "question": "An assigner who is also one of the assignees: the per-(assignment, owner) guard means needs_collection adds NO second task for them, because the fan-out already gave them one. Correct, or should the follow-up task be distinguishable from an assignee task?",
          "options": [
            "A - keep (assignment, owner) as the sole guard key. One task per owner per assignment. No new field, and the guard stays stable when the subject is edited.",
            "B - add subject to the guard key and give the follow-up its own subject. Distinguishes the two, but mints a DUPLICATE task for every owner the moment somebody edits the assignment's subject and re-saves; and the follow-up subject would be authored display text, which AGENTS.md rule 8 forbids inlining in a flow.",
            "C - add a field on duly_task marking a task as the collection follow-up. Cleanest semantics, but a new field on the core object and outside this card's file surface."
          ],
          "recommendation": "A, shipped. Real business need: a manager who assigned work to a group including themselves needs one row for their part of it, not two, and B's duplicate-on-rename is a live data-corruption path rather than a hypothetical. Long-term soundness: A has no second writer and nothing to drift; B makes correctness depend on a mutable display string. Hard to get wrong for an AI author: in A the guard key IS the identity, whereas B invites a future author to 'fix' idempotency by widening the key and silently reintroduces duplicates. Startup scope: A adds nothing, C adds a declared field with no consumer yet. If the product later needs the two distinguishable, C is the honest route and deserves its own card."
        },
        {
          "question": "The assigner's follow-up task is written with source: 'assigned'. The issue specifies 'assigned' for the fan-out tasks but is silent on the assigner's own. source is the caliber column that metrics read.",
          "options": [
            "A - 'assigned'. The row came out of an assignment record.",
            "B - 'self'. The assigner opted into it themselves, and 'self' is documented as never entering an on-time rate or a comparison."
          ],
          "recommendation": "A, shipped, but this is a genuine product call and I would not defend it hard. It keeps the caliber column meaning 'where did this row come from' rather than 'who wanted it'. If the intent is that a manager's own follow-up must never land in an on-time rate, B is right and it is a one-line change."
        },
        {
          "question": "Out of scope for this repo, needs routing. The CLI's `objectstack generate` flow scaffold emits a shape that FlowSchema 17.2.0 rejects: a top-level trigger key holding { type, object, events: ['after_insert', ...] }, plus nodes carrying name/next instead of the required label. Neither the top-level trigger key nor that events vocabulary exists on the current surface, so a generated flow cannot pass objectstack validate.",
          "options": [
            "A - file it in the framework repo (objectstack-ai/objectstack, packages/cli/src/commands/generate.ts), where the fix lands.",
            "B - leave it to the PM to route."
          ],
          "recommendation": "B for now, and I did NOT file it. The dispatch scoped my finding-filing to objectstack-ai/duly, and filing into a framework repo I was not asked to touch, on a guessed repo name, seemed worse than reporting it here. Say the word and I will file it."
        }
      ],
      "out_of_scope_findings": [
        "filed as #29: the 'predicates say record dot field, never a bare name' rule has NO gate for FLOW predicates, and AGENTS.md's documented failure mode is wrong for them. Validate deliberately skips bare identifiers inside a flow (measured, exit 0), and a bare name in a flow does not evaluate to null - it either resolves via the engine's record-flattening or throws (ADR-0032 section 1c). Suggests a repo-level walk over dulyFlows that includes region bodies, plus an AGENTS.md wording fix, before #2 / #7 / #11 land."
      ]
    }

    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    ACCEPT — PM review of #33, round 1.

    Checklist: draft, base main, Fixes #6 first line, 3 files inside the declared surface, objectstack.config.ts untouched, no NUL bytes, no changeset. CI verify success on e3cbb28. Gates re-run locally with origin/main merged in (so measured against the landing state, with #1 and #3 present): all four EXIT=0, 144 tests, Logic: 1 Flows.

    Two independent ablations, mine not yours.

    The guard. Narrowed filter: { assignment, owner } to { assignment } — the exact per-assignment bug the issue warns about. Result: TEST EXIT=1, exactly one failure, × the guard filters per OWNER, not per assignment, while VALIDATE EXIT=0. So that test is the only thing standing between this product and a five-way duplicate on a re-save, and it earns its place.

    Your #29 claim, because it contradicts what I told you. My first attempt at this missed its anchor — the source uses double quotes inside the P tag — and validated an unmutated file. Worth stating plainly: had I not asserted the anchor count, that miss would have read as a clean pass and I would have "confirmed" the opposite of the truth, which is the same failure mode your own ablation discipline is built to prevent.

    Redone correctly: condition: P\status == "dispatched"` (bare identifier) → **VALIDATE EXIT=0, clean**. Control at the same site, record.needs_colection → **EXIT=1**, with the located message naming the object. So the validator does bind the object and check fields behind record.`, and deliberately skips bare identifiers in flows because a bare name may be a flow variable.

    You are right and AGENTS.md is wrong. Rule 4 states "a bare reference is a silent runtime bug (pnpm validate now rejects it)". That is true for the surfaces it was written about and false for flow predicates, which is the surface #2, #7 and #11 are all about to author against. I repeated the false claim to you in the dispatch prompt. #29 is queued ahead of those cards and now also carries the AGENTS.md correction.

    On the two corrected premises. Both stand:

    • The trigger binds on the START node config, not the top level — FlowSchema is .strict() and its own error names the location. Not a choice.
    • Gating on state rather than transition is right, and the issue's own acceptance criteria settle it: criterion 4 requires that adding a sixth assignee create exactly one task. A transition gate creates zero, because no status moves on that save. The idempotency then rests entirely on the per-owner guard — which is why I ablated it rather than taking it on trust.

    The honest reporting of the test limitation — 28 tests pin authored metadata, not runtime behaviour, because the automation provider is absent from an open-edition checkout — is the right call and correctly scoped. Also noting the 17.1.0/17.2.0 skew between the framework source you read and the packages installed here.

    Open questions, adjudicated: Q1 → A (one task per owner per assignment; B's duplicate-on-rename is a live corruption path, and your reasoning is sound). Q2 → A, but filed as a follow-up: source should read "where did this row come from", and the assigner's follow-up did come from an assignment. If a manager's own follow-up ever needs to stay out of an on-time rate, the fix is the caliber filter, not the column. Q3 → file it; I am routing the CLI scaffold defect to the framework repo myself. Declining to file into a repo you were not scoped to, on a guessed name, was the correct restraint.

    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