Uh oh!
There was an error while loading. Please reload this page.
Assignment fan-out — one piece of work becomes N independent tasks - #33
Merged
Conversation
…gnee
A `record_change` flow on `duly_assignment` that turns a dispatched
assignment into N `duly_task` rows, one owner each, plus — only when
`needs_collection` is ticked — one follow-up task for the assigner.
The gate is the dispatched STATE, not the transition into it. The
platform can see a transition (`AutomationContext.previous` is bound on
every run), but making the gate depend on it breaks two things: adding a
sixth assignee to an already-dispatched assignment moves no status, so a
transition gate would create zero tasks where the acceptance criteria say
one; and `previous` is bound to `null` on an insert, where CEL field
access through a null root throws rather than yielding false, faulting the
flow on every assignment born directly as dispatched.
Idempotency is therefore carried entirely by an explicit guard, and the
guard is per OWNER: each iteration reads `duly_task where { assignment,
owner }` and creates only on a miss. The `(duty, owner, period_key)`
unique index cannot help — a fan-out task sets neither `duty` nor
`period_key`. The guard deliberately does not key on `subject`, which
would mint a duplicate for every owner as soon as somebody edits the
assignment's subject and re-saves.
Nothing here writes back to the assignment: `task_count` is a
`Field.summary` computed on read and maintained by nobody.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2pos-warren
marked this pull request as ready for review
September 1, 2026 04:07
Uh oh!
There was an error while loading. Please reload this page.
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
A hand-created duty was born into the governed, scoreable set because the source select's default option was 'catalog'. Every path that legitimately produces a governed duty already stamps source explicitly (duly_catalog_apply writes 'catalog' per #34; the assignment fan-out writes 'assigned' on duly_task per #33), so the field default was only ever reached by a hand-created duty, which is by definition self-declared. Moves default: true from the catalog option to the self option on duly_duty.source. Adds a test pinning the direction next to the existing invariant tests: the default is self, and both governed values (catalog, assigned) are reachable only by explicit assignment, never as a fallback.
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
* Flip duly_duty.source default from catalog to self (#50) A hand-created duty was born into the governed, scoreable set because the source select's default option was 'catalog'. Every path that legitimately produces a governed duty already stamps source explicitly (duly_catalog_apply writes 'catalog' per #34; the assignment fan-out writes 'assigned' on duly_task per #33), so the field default was only ever reached by a hand-created duty, which is by definition self-declared. Moves default: true from the catalog option to the self option on duly_duty.source. Adds a test pinning the direction next to the existing invariant tests: the default is self, and both governed values (catalog, assigned) are reachable only by explicit assignment, never as a fallback. * Flip duly_task.source default from catalog to self (#55) Same defaulting bug as #50, on the sibling caliber column. Verified before changing rather than copying: both manufactured producers already stamp source explicitly and do not rely on the field default -- the dispatcher copies duty.source onto every dispatched task (dispatch.plan.ts), and the assignment fan-out writes 'assigned' directly on both create_record nodes (assignment.flow.ts). The path that actually reaches the default is duly_member's allowCreate: true on duly_task with no create form stamping source -- a member hand-creating their own task, which is self-declared by definition. Generalizes the #50 pinning test in test/invariants.test.ts to assert the caliber-defaults-to-self property on both duly_duty and duly_task instead of duplicating the block, per PM extension of #50's file surface to include this sibling field.
This was referenced Sep 1, 2026
os-warren pushed a commit
that referenced
this pull request
Sep 1, 2026
`objectstack.config.ts` declared `requires: ['automation', 'hierarchy-security']`. `automation` gives the app a flow ENGINE; it registers no TRIGGER. Every flow in the app was therefore inert: the assignment fan-out (#33) never fanned out and the three reminder sweeps (#70) never swept. Measured on @objectstack/cli 17.2.0, `PORT=3117 pnpm start`: before: Plugins: 35 loaded Flows: 4 flow(s) 0 bound to triggers + one "declares a '<type>' trigger but is NOT bound" warning per flow after: Plugins: 39 loaded (RecordChangeTriggerPlugin, ScheduleTriggerPlugin, TimeRelativeTriggerPlugin, ApiTriggerPlugin) Flows: 4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api) no unbound warnings; boot diagnostics 9 -> 5 One token covers all four kinds: `triggers` is the only trigger entry in `PLATFORM_CAPABILITY_TOKENS`, and the CLI keys it to @objectstack/trigger-record-change plus extras for the schedule, time-relative and api plugins. No second declaration is needed. `validate`, `typecheck`, `test` and `build` all exited 0 with every flow unbound, so `test/trigger-capability.test.ts` pins the invariant: it goes red if `triggers` is dropped while any flow declares a trigger, and it re-derives its assumptions (token spelling, one-token coverage, the flow `type` vocabulary) from the platform's own tables rather than restating them. It is a labelled stopgap over an author-time platform gap, filed as objectstack-ai/objectstack#14153, and is meant to be deleted when that lands. Part of #68
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
`objectstack.config.ts` declared `requires: ['automation', 'hierarchy-security']`. `automation` gives the app a flow ENGINE; it registers no TRIGGER. Every flow in the app was therefore inert: the assignment fan-out (#33) never fanned out and the three reminder sweeps (#70) never swept. Measured on @objectstack/cli 17.2.0, `PORT=3117 pnpm start`: before: Plugins: 35 loaded Flows: 4 flow(s) 0 bound to triggers + one "declares a '<type>' trigger but is NOT bound" warning per flow after: Plugins: 39 loaded (RecordChangeTriggerPlugin, ScheduleTriggerPlugin, TimeRelativeTriggerPlugin, ApiTriggerPlugin) Flows: 4 flow(s) 4 bound to triggers (record_change, schedule, time_relative, api) no unbound warnings; boot diagnostics 9 -> 5 One token covers all four kinds: `triggers` is the only trigger entry in `PLATFORM_CAPABILITY_TOKENS`, and the CLI keys it to @objectstack/trigger-record-change plus extras for the schedule, time-relative and api plugins. No second declaration is needed. `validate`, `typecheck`, `test` and `build` all exited 0 with every flow unbound, so `test/trigger-capability.test.ts` pins the invariant: it goes red if `triggers` is dropped while any flow declares a trigger, and it re-derives its assumptions (token spelling, one-token coverage, the flow `type` vocabulary) from the platform's own tables rather than restating them. It is a labelled stopgap over an author-time platform gap, filed as objectstack-ai/objectstack#14153, and is meant to be deleted when that lands. Part of #68 Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#6
A
record_changeflow onduly_assignmentthat turns a dispatched assignment into Nduly_taskrows — one owner each — plus, only whenneeds_collectionis ticked, one follow-up task for the assigner.Verified on
e3cbb28. All four gates green on that commit (see Gates below).The three PM assumptions, all verified against engine source
record_changeflow can detect a transition intostatus = 'dispatched', not merely observe the stateloopnode withconfig: { collection, iteratorVariable, body: { nodes, edges } }can create a record per iterationLoopConfigSchemacarries exactly those keys;loop-node.tsruns the body viarunRegionper item in the shared variable scope, and the body is an arbitrary sub-graph, socreate_recordinside it is supported.Field.user({ multiple: true })onassigneesgives the loop an iterable array{ type: 'user', reference: 'sys_user', multiple: true }and stores an array ofsys_userids.interpolate()returns the raw value when the whole string is a single token, so'{record.assignees}'resolves to the array itself, not itstoString.Two premises that did not survive contact — please read
1. The trigger does not bind where the issue implies
FlowSchemais.strict()and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:The engine's
resolveTriggerBindingreads exactly those three keys off thestartnode, and readsobjectNameonly (objectis a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolvingrecord.needs_collectionagainstduly_assignment— it could only know the bound object from that start-node config.Worth knowing: the CLI's own
objectstack generatescaffold still emits the rejected shape — top-leveltrigger: { type, object, events: ['after_insert', …] }and nodes withname/nextinstead of the requiredlabel. That scaffold cannot passvalidateagainst 17.2.0. Reported separately for routing; nothing to do in this repo.2. The gate is the dispatched STATE, not the transition into it
The dispatch note asked me to test this rather than paper over it, so, in full.
The platform can see a transition.
AutomationContext.previousis documented for exactly this (status == "done" && previous.status != "done") andseedRunVariablesbinds it on every run. The design still must not depend on it, for two independent reasons:It breaks the sixth assignee. Adding a name to an already-dispatched assignment moves no status. A transition gate fires zero times and creates zero tasks, where the acceptance criteria say exactly one. Criterion 3 ("re-saving creates 0 more") would also pass vacuously — for the wrong reason.
It faults the flow on insert.
variables.set('previous', context?.previous ?? null)—previousis bound tonullon an insert, and CEL field access through a null root aborts the predicate.evaluateConditionnever swallows that tofalse; it throws (ADR-0032 §1c). Soprevious.status != "dispatched"would fail every assignment born directly asdispatched— an import, a REST create, a seed.record.statuscannot fail the same way: the record-change trigger runsmaterializeDeclaredFieldsover both CEL roots, so every declared field ofduly_assignmentis present, at worst asnull.So:
triggerType: 'record-after-write'(theafterInsert+afterUpdateunion) withcondition: record.status == "dispatched". Idempotency is carried entirely by the guard below, which had to exist anyway.Idempotency — explicit, and per OWNER
The
(duty, owner, period_key)unique index does not constrain these rows: a fan-out task sets neitherdutynorperiod_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration readsduly_task where { assignment, owner }and creates only on a miss.Per owner, not per assignment — a per-assignment guard finds the first of the five tasks and creates nothing for a sixth assignee. That exact bug is ablated in the test below.
The guard keys on
(assignment, owner)and deliberately not onsubject. A subject-aware guard would mint a duplicate task for every owner the moment somebody edits the assignment's subject and re-saves — a worse failure than the one it would prevent.One consequence, stated so it is a decision and not an accident: an assigner who is also one of the assignees already owns a task on this assignment, so
needs_collectionadds no second one for them. That reads as correct to me — one task per owner per assignment is the invariant, and a manager does not need two rows for one piece of work — but it is a product call, so flagging it.existing_task/existing_assigner_taskare declared variables withdefaultValue: null.get_recordreturns early without setting itsoutputVariablewhen no data engine is registered, and an unbound name aborts a strict-CEL predicate rather than yielding false; a declared default removes the unbound state, which is the platform's own stated remedy.What this deliberately does not do
task_countis aField.summarycomputed on read; nothing here writes back toduly_assignmentat all. A test asserts every write node targetsduly_task.period_keyis not set — the key is absent from bothcreate_recordnodes, not set to''.needs_collectionis true; the default out-edge routes straight past that branch.{record.subject}rather than an inlined literal, since English is the source language and authored labels belong in a bundle.Gates
Run under the shared verify lock on
e3cbb28, worktree clean:Reverse verification — three ablations, each confirmed on disk before measuring
record.→ barestatus. Mutation on disk (injected 1, removed 0) →validateexit 0, still passed.validatedeliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a cleanvalidateis not evidence on this point — the dispatch note's expectation here was inverted. Filed as No gate catches a bare field reference in a flow predicate — and AGENTS.md claims there is one #29 with the measurement.record.needs_collection→record.needs_colection. Mutation on disk →validateexit 1, located:unknown field `needs_colection` on `duly_assignment` — did you mean `needs_collection`?This is the gate that is real, and it proves the green run above is a measurement rather than a vacuous pass.{ assignment, owner }→{ assignment }(the per-assignment bug). Mutation on disk →pnpm testexit 1, one failure,the guard filters per OWNER, not per assignment;validatestayed exit 0. The test is the only thing standing between this repo and that bug.Each ablation script carried a
trap … EXIT INT TERMrestore; the tree was verified clean after each.Files
src/flows/assignment.flow.ts(new)src/flows/index.ts— added todulyFlowstest/assignment-fanout.test.ts(new, 28 tests)objectstack.config.tsuntouched — the barrel was already wired onmain. No changeset: this repo has no.changeset/.Generated by Claude Code