Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

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

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui
Sep 1, 2026
Merged

Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar#59
os-warren merged 1 commit into
mainfrom
claude/issue-27-catalog-action-ui

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#27

duly_catalog_apply is the product's biggest adoption path — instantiate a customer's existing role catalog onto their people, instead of asking 400 people to hand-type their own duties — and it had no button. global_nav was retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only over POST /api/v1/actions/global/... or MCP. A pilot whose first step requires someone to write curl does not happen.

This adds the adjudicated object-bound twin: duly_catalog_apply_to_people, bound to duly_catalog_item, placed on its list_toolbar, wired to the same applyCatalogHandler function reference. The global action stays registered and headless — it is what REST and MCP use. No ai.exposed.

The input shape I measured, and which I chose

The adjudication flagged one thing it could not verify: whether a list_toolbar action can express position_codeplus a multi-user picker as its input, with the two-step form (select catalog rows → modal for the people) as the fallback. Measured first, four ways:

LayerMeasurement
Spec, author timeActionSchema has no refinement coupling locations to params. This exact declaration — objectName + list_toolbar + text + user/multiple — parses clean.
Renderer, param collectionobjectui packages/core/src/actions/ActionRunner.ts opens the param dialog whenever params is a non-empty array, before dispatch, with no location gate.
Renderer, the pickerresolveActionParams carries multiple through its inline branch → paramToField maps user onto the user widget with it → UserField delegates to LookupField, whose multi-select is the picker.
Dispatcher, submitThe bag the dialog produces, { position_code, users: [...] }, passes the spec's own validateActionParams (ADR-0104 D2) — and a scalar in users is refused with invalid_shape. Enforced, not merely declared.

So the one-step form ships. The fallback was not needed, and it would have cost more than UX: it needs the handler to read _selectedIds instead of position_code, which is a second implementation of the thing this action already does — the one thing the card forbade.

Corroborated at runtime: booting the stack the way the CLI does, ql.getSchema('duly_catalog_item').actions is ["duly_catalog_apply_to_people"] — the array the list toolbar filters by location, and the first rung the REST route resolves declarations from.

Why the twin is spread from the global rather than restated

exportconstCatalogApplyToPeopleAction=defineAction({
...CatalogApplyAction,name: CATALOG_APPLY_TO_PEOPLE_ACTION,objectName: CATALOG_ITEM_OBJECT,target: CATALOG_APPLY_TO_PEOPLE_ACTION,label: 'Apply to people',locations: ['list_toolbar'],});

params, requiredPermissions, description, icon, variant and type cannot drift from the global, because there is only one copy. That matters in a specific direction: the dispatcher validates the params of the action you called, so a twin that fell behind would 400 on a dialog the global route accepts — or, worse, quietly stop requiring users. defineAction deep-copies on parse (measured), so the two declarations share no mutable state.

requiredPermissions: ['duly.catalog.apply'] rides that spread deliberately. An object-bound action that skipped the capability its global twin requires is a bypass, not a convenience — objectui's action:bar filters its own set through the shared capability gate before placement, and the platform action route answers 403.

A distinct name, not a second declaration of duly_catalog_apply — and that is measured, not stylistic. defineStack accepts two actions sharing one name without a word (filed upstream, below).

The trap with no author-time gate — ablated

An action whose handler is not registered renders, is clickable, and fails at call time; pnpm validate passes green. Both legs were run from a committed tree, each with a trap … EXIT INT TERM restore, and each mutation confirmed on disk before anything was read.

Ablation 1 — delete the twin's registerAction line (marker count 1 → 0 on disk, git diff --stat = 1 deletion):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 6 Actions"
pnpm test → exit 1 3 failed | 379 passed
× catalog-action-placement › is wired under the key its dispatch reaches, and nowhere else
× catalog-action-placement › is wired to the SAME handler function reference as the global action
× catalog-instantiate › every declared script action has a handler under a key that can reach it

That is the trap reproduced exactly: validate stays green and still counts 6 actions, while the button would 404. Restore leg confirmed on disk (marker back to 1, tree clean).

Ablation 2 — delete the barrel entry (array-entry count 1 → 0, import/re-export left intact and asserted):

pnpm validate → exit 0 "✓ Validation passed" "UI: 1 Apps 5 Views 5 Actions" ← quietly 5, not 6
pnpm test → exit 1 8 failed | 374 passed

AGENTS.md rule 2's failure, measured: dead metadata that type-checks and validates.

(A first attempt at ablation 2 was voided by its own on-disk check and re-run — the marker I picked also matched the import line, so the count went 2→1 rather than →0. The mutation had landed; the check was wrong. Reported here rather than silently re-run.)

No dist/ sat between the mutation and the reading: the tests import ../src/... by relative path, not through a package exports map, pnpm validate loads objectstack.config.ts from source, and the kernel-booting suites already pin artifactPath at a nonexistent file for this reason.

Gates

All four green at 06416fb, the final commit, re-run after it:

pnpm validate exit 0 ✓ Validation passed (370ms) UI: 1 Apps 5 Views 6 Actions
pnpm typecheck exit 0
pnpm test exit 0 Test Files 13 passed (13) Tests 382 passed (382)
pnpm build exit 0 ✓ Build complete (653ms) 6 Actions, 2 handlers bundled

Bundling 2 handlers is unchanged from main (verified against a pristine origin/main tree) — that count is dulyFunctions, not action handlers, which are wired through onEnable. pnpm test was also re-run with dist/ present and stayed at 382 passed.

Platform gaps filed upstream

Two, both found by measurement here, both reported rather than worked around (AGENTS.md rule 9):

Neither is fixed here.

A third, not filed: the GitHub body sanitizer does not merely strip a short angle-bracket fragment, it truncates the remainder of the stored body. The first version of this PR description was cut mid-sentence at the fragment that used to sit in the line above, silently dropping everything after it — including the whole File surface section below. Worth knowing wherever the repo's sanitizer guidance lives: the marker-eating half is documented, this half is sharper.

File surface

Beyond the surface named on the card, and flagged deliberately:

  • src/actions/catalog.handlers.tstwo name constants and one registerAction line. No handler body touched. The twin cannot be reachable without a registration entry (the card anticipates this: "if your twin needs its own registration entry, assert the wiring in a test and ablate it"), and registerCatalogActionHandlers is the catalog's registration point — splitting it across two files would contradict that function's own contract.
  • src/actions/index.ts — the barrel entry, required by AGENTS.md rule 2. Ablation 2 shows what its absence costs.

src/apps/duly.app.ts was not touched.nav_catalog already lands on the Role catalog list, so the toolbar button completes the path with no nav change; the adjudicated scope has three items and none of them is nav. One thing worth PM's attention, measured while I was there: ObjectNavItemSchema.runAction is a real declared slot — "auto-run this declared action once on arrival at the object's list surface" — and objectui arms it only for an action that renders at list_toolbar on that object (ObjectView.tsx, actionRendersAt(a, 'list_toolbar')). So this twin is exactly what would make a one-click "Apply role catalog" nav entry possible. Not taken: it is new declared surface with no pull yet, and it is a separate decision. It costs one line whenever that decision is made.

objectstack.config.ts, AGENTS.md, src/objects/duty.object.ts and the handler bodies: untouched. No changesets in this repo.

Generated by Claude Code

…ist toolbar
`duly_catalog_apply` is the product's onboarding path — instantiate a
customer's existing role catalog onto their people — and in protocol 17 it
had no button. `global_nav` was retired and every surviving action location
is object-bound, so an object-less action is reachable only over
`POST /api/v1/actions/global/...` or MCP.
Adds `duly_catalog_apply_to_people`: the SAME action, bound to
`duly_catalog_item` and placed on its `list_toolbar`, wired to the very same
`applyCatalogHandler` reference. Everything except the four keys placement
changes is spread from the global declaration, so the param contract and the
`duly.catalog.apply` capability gate cannot drift between the two.
The global action stays registered and headless — it is what REST and MCP
use. No `ai.exposed`.
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 07:46
@os-warren
os-warren merged commit de28918 into mainSep 1, 2026
1 check passed
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.

Object-less (&quot;global&quot;) actions have no UI home in protocol 17 — the onboarding flow is API-only

1 participant

@os-warren