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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout
Sep 1, 2026
Merged

Assignment fan-out — one piece of work becomes N independent tasks#33
os-warren merged 1 commit into
mainfrom
claude/issue-6-assignment-fanout

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#6

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.

Verified on e3cbb28. All four gates green on that commit (see Gates below).

The three PM assumptions, all verified against engine source

AssumptionVerdict
A record_change flow can detect a transition into status = 'dispatched', not merely observe the stateTrue — but deliberately not used. See below.
A loop node with config: { collection, iteratorVariable, body: { nodes, edges } } can create a record per iterationTrue.LoopConfigSchema carries exactly those keys; loop-node.ts runs the body via runRegion per item in the shared variable scope, and the body is an arbitrary sub-graph, so create_record inside it is supported.
Field.user({ multiple: true }) on assignees gives the loop an iterable arrayTrue. It authors { type: 'user', reference: 'sys_user', multiple: true } and stores an array of sys_user ids. interpolate() returns the raw value when the whole string is a single token, so '{record.assignees}' resolves to the array itself, not its toString.

Two premises that did not survive contact — please read

1. The trigger does not bind where the issue implies

FlowSchema is .strict() and carries neitherobjectnortrigger. Writing either is a parse error, and the schema says where to go instead:

object is not a Flow field — a record-change flow binds its object on the START node's config ({ objectName, triggerType, condition }), not at the flow top level.

The engine's resolveTriggerBinding reads exactly those three keys off the start node, and reads objectName only (object is a load-time alias for the CRUD nodes, not for the trigger). Confirmed independently by the validator resolving record.needs_collection against duly_assignment — it could only know the bound object from that start-node config.

Worth knowing: the CLI's own objectstack generate scaffold still emits the rejected shape — top-level trigger: { type, object, events: ['after_insert', …] } and nodes with name/next instead of the required label. That scaffold cannot pass validate against 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.previous is documented for exactly this (status == "done" && previous.status != "done") and seedRunVariables binds 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)previous is bound to null on an insert, and CEL field access through a null root aborts the predicate. evaluateCondition never swallows that to false; it throws (ADR-0032 §1c). So previous.status != "dispatched" would fail every assignment born directly as dispatched — an import, a REST create, a seed.

record.status cannot fail the same way: the record-change trigger runs materializeDeclaredFields over both CEL roots, so every declared field of duly_assignment is present, at worst as null.

So: triggerType: 'record-after-write' (the afterInsert + afterUpdate union) with condition: 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 neither duty nor period_key. As the issue says, it will not protect us, so the guard is explicit. Each loop iteration reads duly_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 on subject. 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_collection adds 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_task are declared variables with defaultValue: null. get_record returns early without setting its outputVariable when 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

  • No status or progress field is written. task_count is a Field.summary computed on read; nothing here writes back to duly_assignment at all. A test asserts every write node targets duly_task.
  • period_key is not set — the key is absent from both create_record nodes, not set to ''.
  • The assigner gets nothing unless needs_collection is true; the default out-edge routes straight past that branch.
  • No display text is hard-coded. The follow-up task reuses {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:

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)
BUILD_EXIT=0 ✓ Build complete (291ms) · Logic: 1 Flows

Reverse verification — three ablations, each confirmed on disk before measuring

  1. record. → bare status. Mutation on disk (injected 1, removed 0) → validateexit 0, still passed. validate deliberately does not flag bare identifiers inside a flow (collectBoundRecordReads: "Deliberately NEVER a bare identifier"). So a clean validate is 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.
  2. record.needs_collectionrecord.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.
  3. Guard filter { assignment, owner }{ assignment } (the per-assignment bug). Mutation on disk → pnpm testexit 1, one failure, the guard filters per OWNER, not per assignment; validate stayed exit 0. The test is the only thing standing between this repo and that bug.

Each ablation script carried a trap … EXIT INT TERM restore; the tree was verified clean after each.

Files

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

objectstack.config.ts untouched — the barrel was already wired on main. No changeset: this repo has no .changeset/.


Generated by Claude Code

…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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:07
@os-warren
os-warren merged commit cc1d1d8 into mainSep 1, 2026
1 check passed
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.
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant

@os-warren