Uh oh!
There was an error while loading. Please reload this page.
Catalog instantiation — apply and sync a position's duty catalog - #34
Merged
Conversation
Adds two object-less actions and their handlers: - duly_catalog_apply — instantiate every active duly_catalog_item for a position onto one or more people. Idempotent on (catalog_item, owner): a second apply creates nothing and reports the skips. - duly_catalog_sync — replay catalog CADENCE edits (frequency, due_anchor, due_offset_days, lead_days, grace_days) onto derived duties. Never touches owner, status, timezone or the effective_* window; never touches a duty whose source is 'self'; reports duties from deactivated catalog items rather than deleting them. The handler↔declaration wiring is asserted in tests because no author-time gate covers it: an unregistered handler renders, is clickable, and 404s at call time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
The separator itself is right: a NUL cannot occur in a record id, so the
composite key cannot collide the way plain concatenation can - ('ab','c')
and ('a','bc') would otherwise produce the same key and silently skip a
duty that was never created.
Encoding it as a literal 0x00 in the source was the defect. It made git
treat catalog.handlers.ts as binary (Bin 0 -> 17555 bytes, no diff, no
blame, no review for the life of the file), left the separator invisible
in an editor, and would be dropped silently on copy-paste - degrading the
key back to plain concatenation with no error.
Now written as the \u0000 escape, so the file stays ASCII while the
runtime value is unchanged. pairKey is exported and the collision property
is pinned in tests, since the reason for the separator is not obvious and
would otherwise be "simplified" away.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2pos-warren
marked this pull request as ready for review
September 1, 2026 04:10
Uh oh!
There was an error while loading. Please reload this page.
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
A hand-created duty was born into the governed, scoreable set because the source select's default option was 'catalog'. Every path that legitimately produces a governed duty already stamps source explicitly (duly_catalog_apply writes 'catalog' per #34; the assignment fan-out writes 'assigned' on duly_task per #33), so the field default was only ever reached by a hand-created duty, which is by definition self-declared. Moves default: true from the catalog option to the self option on duly_duty.source. Adds a test pinning the direction next to the existing invariant tests: the default is self, and both governed values (catalog, assigned) are reachable only by explicit assignment, never as a fallback.
os-warren added a commit
that referenced
this pull request
Sep 1, 2026
* Flip duly_duty.source default from catalog to self (#50) A hand-created duty was born into the governed, scoreable set because the source select's default option was 'catalog'. Every path that legitimately produces a governed duty already stamps source explicitly (duly_catalog_apply writes 'catalog' per #34; the assignment fan-out writes 'assigned' on duly_task per #33), so the field default was only ever reached by a hand-created duty, which is by definition self-declared. Moves default: true from the catalog option to the self option on duly_duty.source. Adds a test pinning the direction next to the existing invariant tests: the default is self, and both governed values (catalog, assigned) are reachable only by explicit assignment, never as a fallback. * Flip duly_task.source default from catalog to self (#55) Same defaulting bug as #50, on the sibling caliber column. Verified before changing rather than copying: both manufactured producers already stamp source explicitly and do not rely on the field default -- the dispatcher copies duty.source onto every dispatched task (dispatch.plan.ts), and the assignment fan-out writes 'assigned' directly on both create_record nodes (assignment.flow.ts). The path that actually reaches the default is duly_member's allowCreate: true on duly_task with no create form stamping source -- a member hand-creating their own task, which is self-declared by definition. Generalizes the #50 pinning test in test/invariants.test.ts to assert the caliber-defaults-to-self property on both duly_duty and duly_task instead of duplicating the block, per PM extension of #50's file surface to include this sibling field.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#5
Adds the onboarding path: apply a position's duty catalog to people, and replay catalog cadence edits onto the duties it produced.
What landed
duly_catalog_apply— for each activeduly_catalog_itemwith the givenposition_code, create aduly_dutyfor each selected user. Content and cadence come from the item;ownerfrom the selection;business_unitfrom the person'ssys_user_position.business_unit_id;source: 'catalog',catalog_item,status: 'active'.Idempotent on
(catalog_item, owner)— one probe for the whole run, plus a same-run guard so two identical items cannot both land on one person. A second apply creates nothing and reports the skips.duly_catalog_sync— replays cadence only:frequency,due_anchor,due_offset_days,lead_days,grace_days.owner,status,timezoneand theeffective_*window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whosesourceis'self'is never touched. A duty whose catalog item has been deactivated is reported, never deleted.position_codeis an optional narrowing — syncing rewrites authored cadence, so being able to run it for one position instead of the whole org is the difference between a correction and an incident.Both return a per-row summary; sync records
from/toper field, because it is destructive to authored cadence and has to be legible after the fact.Handler wiring — asserted, because nothing else can
registerCatalogActionHandlers(ql)is called inside the existingregisterDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it.objectstack.config.tsis not touched.An action whose handler is not registered renders, is clickable, and 404s at call time, and
pnpm validatecannot see it. Reverse-verified: with the registration call commented out,pnpm validatestill exits 0 (✓ Validation passed) whilepnpm testexits 1 on× every declared action has a registered handler. Restored byte-identically (cleangit status).The
source !== 'catalog'guard in sync is re-applied in code, not left to thewhereclause — a product invariant that lives only in a query is one lenient driver away from being untrue. Reverse-verified: removing the in-code guard reds× the source guard is in the code, not only in the query filterand nothing else, which is the point — the other self-duty test stays green because the query filter alone still looks correct there.Three things measured against the platform, two of which change the picture
sys_user_position.business_unit_idis real — a nullable lookup tosys_business_unit, defined in@objectstack/plugin-security(notplatform-objects). Used as declared. A person with no position row, or an unanchored one, gets a duty with no business unit rather than a null one:position_codeis free text so a customer can load their catalog before positions are modelled, which makes "nosys_user_positionrow" a normal day-one state that must not fail the apply.A duty's timezone has nothing to read it from → filed as #26.
sys_userdeclares no timezone or locale field, andExecutionContext.timezone(the resolved tenant zone) is not propagated into an action handler's context —buildSession()carriesuserId/organizationId/positions/rolesand no zone. So the ladder resolves toUTC.resolveDutyTimezone()is a named function with the ladder written out, deliberately not actx.user.timezone ?? …chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned againstduly_duty.timezone's owndefaultValueso the two cannot drift. This changes what #20's deployment docs have to say.A global action has no UI home in protocol 17 → filed as #27.
global_navwas retired fromACTION_LOCATIONSin spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration islocations: [](headless), invoked overPOST /api/v1/actions/global/duly_catalog_applyor MCP. There is no button. Reported rather than redesigned around; #27 lays out the options.The PM's assumption that a global action can take a
position_codeplus a multi-user picker holds — confirmed in the built artifact:{name: position_code, type: text, required: true}and{name: users, type: user, required: true, multiple: true}.Also filed
requiredPermissions—duly_catalog_applyis currently ungated #30 (sub-issue of Security model — positions, permission sets, sharing rules #8) — both actions are ungated;requiredPermissionsneeds theduly_admincapability Security model — positions, permission sets, sharing rules #8 creates.ctx.engineis the trusted, RLS-bypassing facade, so the invoke-time gate is the only boundary.duly_duty.timezoneaccepts any string — a typo'd IANA zone validates clean and fails at dispatch #24 —duly_catalog_itemcarries notimezonefield, so that validation has one home, not two.Gates
All four green on
13feae8, the commit this PR points at:No changeset — this repo has none.
Generated by Claude Code
Generated by Claude Code