Uh oh!
There was an error while loading. Please reload this page.
Give duly_catalog_apply a UI home: object-bound twin on the catalog list toolbar - #59
Merged
Merged
Conversation
…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
marked this pull request as ready for review
September 1, 2026 07:46
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#27
duly_catalog_applyis 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_navwas retired in protocol 17 and every surviving action location is object-bound, so an object-less action was reachable only overPOST /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 toduly_catalog_item, placed on itslist_toolbar, wired to the sameapplyCatalogHandlerfunction reference. The global action stays registered and headless — it is what REST and MCP use. Noai.exposed.The input shape I measured, and which I chose
The adjudication flagged one thing it could not verify: whether a
list_toolbaraction can expressposition_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:ActionSchemahas no refinement couplinglocationstoparams. This exact declaration —objectName+list_toolbar+text+user/multiple— parses clean.packages/core/src/actions/ActionRunner.tsopens the param dialog wheneverparamsis a non-empty array, before dispatch, with no location gate.resolveActionParamscarriesmultiplethrough its inline branch →paramToFieldmapsuseronto the user widget with it →UserFielddelegates toLookupField, whose multi-select is the picker.{ position_code, users: [...] }, passes the spec's ownvalidateActionParams(ADR-0104 D2) — and a scalar inusersis refused withinvalid_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
_selectedIdsinstead ofposition_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').actionsis["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
params,requiredPermissions,description,icon,variantandtypecannot 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 requiringusers.defineActiondeep-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'saction:barfilters 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.defineStackaccepts two actions sharing onenamewithout 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 validatepasses green. Both legs were run from a committed tree, each with atrap … EXIT INT TERMrestore, and each mutation confirmed on disk before anything was read.Ablation 1 — delete the twin's
registerActionline (marker count1 → 0on disk,git diff --stat= 1 deletion):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):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 packageexportsmap,pnpm validateloadsobjectstack.config.tsfrom source, and the kernel-booting suites already pinartifactPathat a nonexistent file for this reason.Gates
All four green at
06416fb, the final commit, re-run after it:Bundling 2 handlersis unchanged frommain(verified against a pristineorigin/maintree) — that count isdulyFunctions, not action handlers, which are wired throughonEnable.pnpm testwas also re-run withdist/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):
[action-governance]reports every OBJECT-LESS declared action as "registered handler with NO declaration — REFUSED at dispatch", and the route contradicts it objectstack#14123 — startup[action-governance]reports every object-less declared action as "registered handler with NO declaration — REFUSED at dispatch", and the REST route contradicts it. Pre-existing onmain(reproduced on a pristineorigin/maintree):global:duly_catalog_applyandglobal:duly_catalog_syncare both named in that warning on every boot. Root cause measured — the audit sources declarations frommeta.listObjects()+meta.loadMany('action'), andloadMany('action')returns[], so only object-BOUND actions survive it; the route resolves the same actions viaql.registry.getItem('action', name)and dispatches fine. The warning's second remedy — "drop the registration" — would have broken this app's onboarding path. Worth knowing for Document the MCP surface — ask the system what is late #21: the REST route in Object-less ("global") actions have no UI home in protocol 17 — the onboarding flow is API-only #27's premise does work.defineStackaccepts two actions sharing onenamewithout a word — including two GLOBAL ones, where the<object>:<name>handler map then silently shadows one objectstack#14124 —defineStackaccepts two actions sharing onename, including two global ones, where the object-colon-name handler map then collapses to one key and whichever handler registers second silently shadows the other. This is why the twin has its own name.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.ts— two name constants and oneregisterActionline. 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"), andregisterCatalogActionHandlersis 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.tswas not touched.nav_catalogalready 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.runActionis 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 atlist_toolbaron 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.tsand the handler bodies: untouched. No changesets in this repo.Generated by Claude Code