Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude
, '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

Catalog instantiation — apply and sync a position's duty catalog - #34

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate
Sep 1, 2026
Merged

Catalog instantiation — apply and sync a position's duty catalog#34
os-warren merged 2 commits into
mainfrom
claude/issue-5-catalog-instantiate

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

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 active duly_catalog_item with the given position_code, create a duly_duty for each selected user. Content and cadence come from the item; owner from the selection; business_unit from the person's sys_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, timezone and the effective_* window are never written, because the patch is built from that five-field tuple rather than by diffing records. A duty whose source is 'self' is never touched. A duty whose catalog item has been deactivated is reported, never deleted. position_code is 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/to per 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 existing registerDulyActionHandlers; the function's shape is untouched so #4 can add its call beside it. objectstack.config.ts is not touched.

An action whose handler is not registered renders, is clickable, and 404s at call time, and pnpm validate cannot see it. Reverse-verified: with the registration call commented out, pnpm validate still exits 0 (✓ Validation passed) while pnpm test exits 1 on × every declared action has a registered handler. Restored byte-identically (clean git status).

The source !== 'catalog' guard in sync is re-applied in code, not left to the where clause — 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 filter and 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_id is real — a nullable lookup to sys_business_unit, defined in @objectstack/plugin-security (not platform-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_code is free text so a customer can load their catalog before positions are modelled, which makes "no sys_user_position row" 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_user declares no timezone or locale field, and ExecutionContext.timezone (the resolved tenant zone) is not propagated into an action handler's context — buildSession() carries userId/organizationId/positions/roles and no zone. So the ladder resolves to UTC. resolveDutyTimezone() is a named function with the ladder written out, deliberately not a ctx.user.timezone ?? … chain: that would be a tolerant consumer standing in for a producer that does not exist. Pinned against duly_duty.timezone's own defaultValue so 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_nav was retired from ACTION_LOCATIONS in spec 17 (#6888) and every surviving location is object-bound, so an object-less action's honest declaration is locations: [] (headless), invoked over POST /api/v1/actions/global/duly_catalog_apply or 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_code plus 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

Gates

All four green on 13feae8, the commit this PR points at:

pnpm validate ✓ Validation passed (UI: 2 Actions)
pnpm typecheck tsc --noEmit, no output
pnpm test Test Files 2 passed | Tests 36 passed (36)
pnpm build ✓ Build complete (dist/objectstack.json, 53.5 KB)

No changeset — this repo has none.

Generated by Claude Code


Generated by Claude Code

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_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:10
@os-warren
os-warren merged commit a4540ae 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.
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.

Catalog instantiation — apply a position's duty catalog to a person

2 participants

@os-warren@claude