Repository navigation
Description
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 todulyFlowsinsrc/flows/index.tstest/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_collectionis set - set
period_keyto anything
Acceptance
- dispatching to 5 assignees creates exactly 5 tasks, each with one owner and
source: 'assigned' task_countreads 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: falsecreates no assigner task;truecreates exactly one- flow conditions use
record.<field>;pnpm validatepasses
Gates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.
Activity
os-warren commentedon Sep 1, 2026
Claim: PM loop round 1
Session:
session_01SqkTcrxUFci7nqXdbBSe2pBranch:
claude/issue-6-assignment-fanoutWorktree:
duly-issue-6Domain: 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: opusClause-②: 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 atpm:blocked, so this card ownssrc/flows/for the round.Generated by Claude Code
os-warren commentedon Sep 1, 2026
{ "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
os-warren commentedon Sep 1, 2026
os-dev-report
{ "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
os-warren commentedon Sep 1, 2026
ACCEPT — PM review of #33, round 1.
Checklist: draft, base
main,Fixes #6first line, 3 files inside the declared surface,objectstack.config.tsuntouched, no NUL bytes, no changeset. CIverifysuccessone3cbb28. Gates re-run locally withorigin/mainmerged in (so measured against the landing state, with #1 and #3 present): all fourEXIT=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, whileVALIDATE 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
Ptag — 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 behindrecord.`, 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 validatenow 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:
FlowSchemais.strict()and its own error names the location. Not a choice.The honest reporting of the test limitation — 28 tests pin authored metadata, not runtime behaviour, because the
automationprovider 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:
sourceshould 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