Skip to content

Commit a4540ae

Browse files
os-warrenclaude
andauthored
Add catalog apply and sync: instantiate a position's duties onto a person (#34)
* Catalog instantiation: apply and sync a position's duty catalog Adds two object-less actions and their handlers: - duly_catalog_apply — instantiate every active duly_catalog_item for a position onto one or more people. Idempotent on (catalog_item, owner): a second apply creates nothing and reports the skips. - duly_catalog_sync — replay catalog CADENCE edits (frequency, due_anchor, due_offset_days, lead_days, grace_days) onto derived duties. Never touches owner, status, timezone or the effective_* window; never touches a duty whose source is 'self'; reports duties from deactivated catalog items rather than deleting them. The handler↔declaration wiring is asserted in tests because no author-time gate covers it: an unregistered handler renders, is clickable, and 404s at call time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p * Write pairKey's separator as an escape, not a raw NUL byte The separator itself is right: a NUL cannot occur in a record id, so the composite key cannot collide the way plain concatenation can - ('ab','c') and ('a','bc') would otherwise produce the same key and silently skip a duty that was never created. Encoding it as a literal 0x00 in the source was the defect. It made git treat catalog.handlers.ts as binary (Bin 0 -> 17555 bytes, no diff, no blame, no review for the life of the file), left the separator invisible in an editor, and would be dropped silently on copy-paste - degrading the key back to plain concatenation with no error. Now written as the \u0000 escape, so the file stays ASCII while the runtime value is unchanged. pairKey is exported and the collision property is pinned in tests, since the reason for the separator is not obvious and would otherwise be "simplified" away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc1d1d8 commit a4540ae

5 files changed

Lines changed: 1195 additions & 2 deletions

File tree

‎src/actions/catalog.actions.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineAction } from '@objectstack/spec';
4+
5+
import {
6+
CATALOG_APPLY_ACTION,
7+
CATALOG_SYNC_ACTION,
8+
} from './catalog.handlers.js';
9+
10+
/**
11+
* Catalog instantiation — the onboarding path.
12+
*
13+
* Customers arrive with their catalog already written, usually as a
14+
* spreadsheet. Taking a position has to mean "apply the list", not "hand-type
15+
* 26 duties", or the rollout dies in week one.
16+
*
17+
* ── Why these are OBJECT-LESS and headless ────────────────────────────────
18+
* Neither action operates on a record: `duly_catalog_apply` reads a whole
19+
* position's worth of `duly_catalog_item` rows and writes `duly_duty` rows for
20+
* several people at once. That makes it a GLOBAL action — no `objectName` — and
21+
* an object-less action in protocol 17 has no UI home to declare.
22+
* `global_nav` was removed from `ACTION_LOCATIONS` in @objectstack/spec 17
23+
* (#6888, ADR-0049 enforce-or-remove): the console's ⌘K palette reads no action
24+
* metadata, so the location never rendered. Every surviving location
25+
* (`list_toolbar`, `list_item`, `record_*`) is bound to an object.
26+
*
27+
* So `locations: []` is the honest declaration the spec itself prescribes for
28+
* this case — it keeps the param contract, the capability gate and the audit
29+
* trail, and the action is invoked over the platform action route
30+
* (`POST /api/v1/actions/global/duly_catalog_apply`) or MCP rather than from a
31+
* button. Declaring a location a renderer does not serve would be the
32+
* ADR-0078 declares-renders-does-nothing shape.
33+
*
34+
* ── Why `type: 'script'` with a `target` and no `body` ────────────────────
35+
* The cadence maths and the idempotency probe are real code with real tests,
36+
* not a sandboxed L1/L2 snippet. `target` names the handler registered from
37+
* `src/actions/register-handlers.ts`; a `script` action with neither `body`
38+
* nor `target` is rejected at author time precisely because it would otherwise
39+
* render, be clickable, and 404 at call time.
40+
*/
41+
42+
/**
43+
* `duly_catalog_apply` — instantiate a position's catalog onto people.
44+
*
45+
* `position_code` is free text on purpose: a customer can load their catalog on
46+
* day one, before positions are modelled in the platform, so this deliberately
47+
* does NOT pick from `sys_position` and does NOT require a `sys_user_position`
48+
* row to exist for the selected people.
49+
*/
50+
export const CatalogApplyAction = defineAction({
51+
name: CATALOG_APPLY_ACTION,
52+
label: 'Apply role catalog',
53+
// Dialog copy, not the confirm prompt: an action that collects params and
54+
// also sets `confirmText` shows two dialogs for one decision (#7278). The
55+
// question is asked here, and the user's own Confirm sends it.
56+
description:
57+
'Create the duties this position owes for each person selected. Runs again safely — anyone who already has a duty from a catalog item is skipped, not duplicated.',
58+
icon: 'user-plus',
59+
type: 'script',
60+
target: CATALOG_APPLY_ACTION,
61+
locations: [],
62+
variant: 'primary',
63+
params: [
64+
{
65+
name: 'position_code',
66+
label: 'Position',
67+
type: 'text',
68+
required: true,
69+
placeholder: 'plant_compliance_officer',
70+
helpText:
71+
'Matches duly_catalog_item.position_code exactly. Free text — the position does not have to be modelled in the platform yet.',
72+
},
73+
{
74+
name: 'users',
75+
label: 'People',
76+
type: 'user',
77+
multiple: true,
78+
required: true,
79+
helpText: 'Each person gets their own copy of every active duty in this position\'s catalog.',
80+
},
81+
],
82+
});
83+
84+
/**
85+
* `duly_catalog_sync` — replay catalog cadence edits onto instantiated duties.
86+
*
87+
* Cadence only (`frequency`, `due_anchor`, `due_offset_days`, `lead_days`,
88+
* `grace_days`). `owner`, `status`, `timezone` and the `effective_*` window are
89+
* local decisions the catalog has no business overwriting, and a retired
90+
* catalog item is REPORTED rather than acted on — deleting someone's duties
91+
* because a template was deactivated is a decision for a human.
92+
*
93+
* `position_code` is optional here and narrows the sweep. Sync rewrites
94+
* authored cadence, so being able to run it for one position instead of the
95+
* whole org is the difference between a correction and an incident.
96+
*/
97+
export const CatalogSyncAction = defineAction({
98+
name: CATALOG_SYNC_ACTION,
99+
label: 'Sync duties from catalog',
100+
description:
101+
'Replay cadence changes from the role catalog onto the duties created from it. Owner, status, timezone and the effective window are left alone; duties from a deactivated catalog item are reported, never deleted.',
102+
icon: 'refresh-cw',
103+
type: 'script',
104+
target: CATALOG_SYNC_ACTION,
105+
locations: [],
106+
params: [
107+
{
108+
name: 'position_code',
109+
label: 'Position',
110+
type: 'text',
111+
required: false,
112+
placeholder: 'plant_compliance_officer',
113+
helpText: 'Limit the sync to one position. Leave blank to sync every catalog-sourced duty.',
114+
},
115+
],
116+
});

0 commit comments

Comments
 (0)