@jini-ai/cms 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,30 @@ import { ContentTypeNotFoundError, ForbiddenError, InvalidFieldKindError, Invali
2
2
  import { resolveFieldIndexTransition, validateIdentifierGrammar } from "./index-provisioning.js";
3
3
  import { isContentTypeFieldKind, isIndexableFieldKind, } from "./types.js";
4
4
  /** Reserved forever for the legacy `posts` table — an operator Collection can never take these keys. */
5
+ /**
6
+ * Field-name guards shared by register and update, in order: every name passes the identifier
7
+ * grammar (`InvalidFieldNameGrammarError`), THEN no name appears twice
8
+ * (`VALIDATION_ERROR(fields_duplicate_name)`). Duplicates are refused because entry values are
9
+ * stored keyed by field name and the index-transition diff builds a name-keyed map, so two defs
10
+ * sharing a name leave one of them unaddressable (last one wins). Returns `null` when both pass.
11
+ *
12
+ * @complexity O(f) in the number of submitted fields.
13
+ */
14
+ function fieldNameError(fields) {
15
+ for (const field of fields) {
16
+ if (!validateIdentifierGrammar(field.name)) {
17
+ return new InvalidFieldNameGrammarError(`field name '${field.name}' fails the identifier grammar gate`);
18
+ }
19
+ }
20
+ const seen = new Set();
21
+ for (const field of fields) {
22
+ if (seen.has(field.name)) {
23
+ return new ValidationError(`field name '${field.name}' appears more than once`, "fields_duplicate_name");
24
+ }
25
+ seen.add(field.name);
26
+ }
27
+ return null;
28
+ }
5
29
  const RESERVED_CONTENT_TYPE_KEYS = new Set(["post", "page"]);
6
30
  /** Per-type cap on `queryable` fields (queryable-index sprawl mitigation). */
7
31
  const QUERYABLE_FIELD_CAP = 20;
@@ -34,12 +58,10 @@ export async function registerContentType(required) {
34
58
  if (RESERVED_CONTENT_TYPE_KEYS.has(input.key)) {
35
59
  return { ok: false, error: new ReservedContentTypeKeyError(`key '${input.key}' is permanently reserved for the legacy 'posts' table`) };
36
60
  }
37
- // Guard 3: field-name grammar (every field, before any kind/cap check).
38
- for (const field of input.fields) {
39
- if (!validateIdentifierGrammar(field.name)) {
40
- return { ok: false, error: new InvalidFieldNameGrammarError(`field name '${field.name}' fails the identifier grammar gate`) };
41
- }
42
- }
61
+ // Guard 3: field-name grammar, then uniqueness (every field, before any kind/cap check).
62
+ const nameErrorOnRegister = fieldNameError(input.fields);
63
+ if (nameErrorOnRegister)
64
+ return { ok: false, error: nameErrorOnRegister };
43
65
  // Guard 4: field-kind, closed enum.
44
66
  for (const field of input.fields) {
45
67
  if (!isContentTypeFieldKind(field.kind)) {
@@ -125,11 +147,9 @@ export async function updateContentTypeFields(required) {
125
147
  if (input.fields.length === 0) {
126
148
  return { ok: false, error: new ValidationError(`content type '${input.key}' update submitted an empty fields array`, "fields_empty") };
127
149
  }
128
- for (const field of input.fields) {
129
- if (!validateIdentifierGrammar(field.name)) {
130
- return { ok: false, error: new InvalidFieldNameGrammarError(`field name '${field.name}' fails the identifier grammar gate`) };
131
- }
132
- }
150
+ const nameErrorOnUpdate = fieldNameError(input.fields);
151
+ if (nameErrorOnUpdate)
152
+ return { ok: false, error: nameErrorOnUpdate };
133
153
  for (const field of input.fields) {
134
154
  if (!isContentTypeFieldKind(field.kind)) {
135
155
  return { ok: false, error: new InvalidFieldKindError(`field '${field.name}' has kind '${field.kind}', not one of the closed field-kind enum`) };
@@ -0,0 +1,55 @@
1
+ import { ToolInputError } from "@jini-ai/core";
2
+ /**
3
+ * @file The write-side counterpart to `not-trashed.ts`'s read-side `notTrashed()` filter (Tovu
4
+ * `features/trash/not-trashed.ts`).
5
+ *
6
+ * Purpose:
7
+ * A recurring defect across this codebase's writers: a mutation loads its row with a `findById`
8
+ * (or equivalent) that returns trashed and tombstoned rows just as readily as live ones, and never
9
+ * asks whether the row it found is actually live before saving over it. Fixed per writer, that is
10
+ * an unbounded number of near-identical patches, each one easy to forget on the next new writer.
11
+ * This module is the one shared check every writer calls right after its own load, so the check
12
+ * exists exactly once.
13
+ *
14
+ * Why it lives in `@jini-ai/cms/core` rather than in a Tovu module or a Jini domain package:
15
+ * Tovu's writers (pages, SEO, redirects) and Jini's own domain writers (media, content-types,
16
+ * taxonomy) both need it, Jini cannot import anything Tovu owns, and duplicating the check per side
17
+ * reintroduces the drift this module exists to remove. `core` is the one place both reach.
18
+ *
19
+ * Why `EntityNotLiveError` extends `ToolInputError` rather than a plain `Error`: the daemon's tool
20
+ * executor already treats `ToolInputError` as caller-facing (see `registration-kit.ts`'s doc
21
+ * comment), so every domain's agent tool surfaces this error's message to the caller with no
22
+ * per-domain allowlist entry, and every HTTP route that already maps `ToolInputError`-family errors
23
+ * picks it up for free. A per-domain rule added to `contracts/core/model-facing-tool-errors.ts`
24
+ * instead would be exactly the "correct primitive, unwired call site" defect this codebase keeps
25
+ * hitting — the check would exist but a caller could still fail to wire it in.
26
+ *
27
+ * The message is built only from a fixed string, the entity kind and the caller's own id (never
28
+ * from row content), so it satisfies `contracts/core/model-facing-tool-errors.ts`'s safety rule for
29
+ * a caller-facing message with no redaction step.
30
+ */
31
+ /** A record's liveness as far as this guard is concerned. `"live"` never throws. */
32
+ export type EntityLiveness = "live" | "trashed" | "tombstoned";
33
+ /**
34
+ * Thrown by {@link assertEntityLive} for a non-live entity. `code` is the stable machine-readable
35
+ * discriminant; `entityType`/`entityId`/`state` are the values the message was built from, kept on
36
+ * the instance so a caller that wants to branch on them (rather than parse the message) can.
37
+ */
38
+ export declare class EntityNotLiveError extends ToolInputError {
39
+ readonly entityType: string;
40
+ readonly entityId: string;
41
+ readonly state: "trashed" | "tombstoned";
42
+ readonly code: "ENTITY_IN_TRASH" | "ENTITY_TOMBSTONED";
43
+ constructor(entityType: string, entityId: string, state: "trashed" | "tombstoned");
44
+ }
45
+ /**
46
+ * Throws {@link EntityNotLiveError} unless `required.state` is `"live"`. Call this immediately
47
+ * after a writer's own load, before any mutation of the loaded row.
48
+ * @complexity O(1).
49
+ */
50
+ export declare function assertEntityLive(required: {
51
+ entityType: string;
52
+ entityId: string;
53
+ state: EntityLiveness;
54
+ }): void;
55
+ //# sourceMappingURL=entity-liveness.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { ToolInputError } from "@jini-ai/core";
2
+ /**
3
+ * Thrown by {@link assertEntityLive} for a non-live entity. `code` is the stable machine-readable
4
+ * discriminant; `entityType`/`entityId`/`state` are the values the message was built from, kept on
5
+ * the instance so a caller that wants to branch on them (rather than parse the message) can.
6
+ */
7
+ export class EntityNotLiveError extends ToolInputError {
8
+ entityType;
9
+ entityId;
10
+ state;
11
+ code;
12
+ constructor(entityType, entityId, state) {
13
+ super(buildEntityNotLiveMessage(entityType, entityId, state));
14
+ this.entityType = entityType;
15
+ this.entityId = entityId;
16
+ this.state = state;
17
+ this.name = "EntityNotLiveError";
18
+ this.code = state === "trashed" ? "ENTITY_IN_TRASH" : "ENTITY_TOMBSTONED";
19
+ }
20
+ }
21
+ /** @complexity O(1). Pure string formatting; the two messages are tested byte-for-byte. */
22
+ function buildEntityNotLiveMessage(entityType, entityId, state) {
23
+ if (state === "trashed") {
24
+ return `ENTITY_IN_TRASH: ${entityType} '${entityId}' is in the Trash. Restore it from the Trash before changing it.`;
25
+ }
26
+ return `ENTITY_TOMBSTONED: ${entityType} '${entityId}' was permanently deleted and can't be changed.`;
27
+ }
28
+ /**
29
+ * Throws {@link EntityNotLiveError} unless `required.state` is `"live"`. Call this immediately
30
+ * after a writer's own load, before any mutation of the loaded row.
31
+ * @complexity O(1).
32
+ */
33
+ export function assertEntityLive(required) {
34
+ if (required.state === "live")
35
+ return;
36
+ throw new EntityNotLiveError(required.entityType, required.entityId, required.state);
37
+ }
38
+ //# sourceMappingURL=entity-liveness.js.map
@@ -41,5 +41,6 @@
41
41
  export type { UUID, ISODateTime, JsonPrimitive, JsonValue, JsonObject, JsonArray, DomainEvent, EventBusPort, OutboxRecord, OutboxPort, ClockPort, IdGeneratorPort, } from './ports.js';
42
42
  export type { ChangeSetStatus, ChangeSetOperation, ChangeSetRecord, ChangeSetItemRecord, ChangeSetWithItems, ChangeSetRepoPort, } from './commands/change-set.js';
43
43
  export * from './commands/command.js';
44
+ export * from './entity-liveness.js';
44
45
  export * from './tools/registration-kit.js';
45
46
  //# sourceMappingURL=index.d.ts.map
@@ -39,5 +39,6 @@
39
39
  * behind a registration call the host makes, not behind a kernel barrel.
40
40
  */
41
41
  export * from './commands/command.js';
42
+ export * from './entity-liveness.js';
42
43
  export * from './tools/registration-kit.js';
43
44
  //# sourceMappingURL=index.js.map
@@ -91,22 +91,56 @@ export type DerivedRiskByToolId = ReadonlyMap<string, AgentToolSideEffect>;
91
91
  */
92
92
  export declare const AGENT_TOOL_PRINCIPAL_KIND: "agent";
93
93
  /**
94
- * Actor-class rules that cannot be honored while no confirmation transport is wired.
94
+ * Actor-class rules that need a human confirmer.
95
95
  *
96
96
  * `confirmer-must-equal-own-delegatedBy` means "a human must confirm this, and the confirmer must
97
- * be the principal who delegated". A host can only deliver that by building `ToolExecutor` with an
98
- * `ExecutionDelegate`. Without one, `descriptor.requiresConfirmation` would park the execution on a
99
- * promise only `resumeConfirmation` can settle — and nothing would call it. The park is also unbounded, because `descriptor.timeoutMs`'s timer is armed only AFTER the
100
- * confirmation await.
97
+ * be the principal who delegated". The kit never delivers that through `descriptor.requiresConfirmation`:
98
+ * without a host `ExecutionDelegate`, that flag parks the execution on a promise only
99
+ * `resumeConfirmation` can settle, and nothing would call it (unbounded, too, because
100
+ * `descriptor.timeoutMs`'s timer is armed only AFTER the confirmation await).
101
101
  *
102
- * So a tool carrying this rule must not be wired at all. Today none is — `collections_execute_cleanup`,
103
- * `database_execute_migrate_forward`, and `backup_execute_restore` all carry it and all three are
104
- * declared unwired — which makes this guard a statement of the invariant rather than a fix: a
105
- * future edit wiring any of them fails the build instead of silently shipping a tool whose stated
106
- * human-confirmation requirement is unenforceable. When a confirmation transport does land, this
107
- * constant is the deliberate place to relax.
102
+ * So a tool carrying this rule is wirable only when its handler was built with
103
+ * {@link humanConfirmedHandler}, which asks the human through the host's own confirmation transport
104
+ * and runs the action only on an explicit yes. Any other handler fails the build.
108
105
  */
109
106
  export declare const ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT: Set<string>;
107
+ /**
108
+ * The human who confirmed, as {@link humanConfirmedHandler} hands it to `run`: always the run's own
109
+ * delegating principal (`ctx.principal.id`, see {@link AGENT_TOOL_PRINCIPAL_KIND}), always
110
+ * `kind: "user"`. The only thing a `run` step may pass to a confirm step as its confirmer.
111
+ */
112
+ export interface HumanConfirmer {
113
+ readonly id: string;
114
+ readonly kind: "user";
115
+ }
116
+ /** What the host's transport reports back. Only exactly `confirmed: true` counts as a yes. */
117
+ export type HumanConfirmationAnswer = {
118
+ confirmed: true;
119
+ } | {
120
+ confirmed: false;
121
+ result: unknown;
122
+ };
123
+ /**
124
+ * Builds the one kind of handler a `confirmer-must-equal-own-delegatedBy` tool may be wired with
125
+ * (see {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}).
126
+ *
127
+ * The order is fixed here, not left to each domain: `prepare` (validate, pre-authorize, derive what
128
+ * the human will see) → `askHuman` (the host's transport, e.g. an MCP-UI dialog) → `run`, only when
129
+ * the answer is exactly `{ confirmed: true }`. The decision never comes from `ctx.input`, so nothing
130
+ * the model sends can stand in for the human. `run` gets the confirmer as the delegating human.
131
+ *
132
+ * @param steps.prepare - Reads input and derives what the human is asked about. May throw.
133
+ * @param steps.askHuman - Asks the human through the host's transport. Throws when no human can be
134
+ * asked; a no-answer returns `{ confirmed: false, result }` and `result` is the tool's result.
135
+ * @param steps.run - Performs the action on a yes.
136
+ * @returns A handler {@link assertToolIsWirable} accepts for a tool carrying the rule.
137
+ * @complexity O(1) plus the three steps.
138
+ */
139
+ export declare function humanConfirmedHandler<TPrepared>(steps: {
140
+ prepare: (ctx: Parameters<ToolHandler>[0]) => Promise<TPrepared>;
141
+ askHuman: (ctx: Parameters<ToolHandler>[0], prepared: TPrepared) => Promise<HumanConfirmationAnswer>;
142
+ run: (ctx: Parameters<ToolHandler>[0], prepared: TPrepared, confirmer: HumanConfirmer) => Promise<unknown>;
143
+ }): ToolHandler;
110
144
  /**
111
145
  * Indexes a domain catalog by tool id — the single lookup used for descriptors, risk metadata, and
112
146
  * schema-bearing error messages.
@@ -220,8 +254,8 @@ export declare function decorateWithSchema(params: {
220
254
  /**
221
255
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
222
256
  * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
223
- * whose id that layer has not classified at all, or whose `actorClassRule` needs a confirmation
224
- * transport that does not exist.
257
+ * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
258
+ * its handler does not ask for.
225
259
  *
226
260
  * Throws rather than warning, and at registration time rather than call time: a metadata
227
261
  * inconsistency is a developer error that should stop the daemon booting, not a runtime condition
@@ -230,7 +264,10 @@ export declare function decorateWithSchema(params: {
230
264
  * @param params.toolId - The wired tool id.
231
265
  * @param params.catalogEntry - Its `agent-tools.ts` entry, the declared side of the comparison.
232
266
  * @param params.derivedRisk - The wiring layer's independent classification, the derived side.
233
- * @throws {Error} If the tool is unclassified, misclassified, or needs missing confirmation support.
267
+ * @param params.handler - The handler being wired; checked only for a tool carrying a rule in
268
+ * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
269
+ * @throws {Error} If the tool is unclassified, misclassified, or needs a human confirmer its handler
270
+ * does not ask for.
234
271
  * @complexity O(1) — two map/set lookups.
235
272
  * @overallScore 100
236
273
  */
@@ -238,6 +275,7 @@ export declare function assertToolIsWirable(params: {
238
275
  toolId: string;
239
276
  catalogEntry: WirableToolDefinition;
240
277
  derivedRisk: DerivedRiskByToolId;
278
+ handler?: ToolHandler;
241
279
  }): void;
242
280
  /**
243
281
  * Turns one domain's handler map into `ToolRegistration`s, applying every build-time gate the
@@ -258,9 +296,9 @@ export declare function assertToolIsWirable(params: {
258
296
  * the same rule, and a `ToolPolicy`-only check would be bypassable by any future non-tool caller of
259
297
  * the same domain function, which is precisely why the chokepoint owns the gate.
260
298
  *
261
- * `descriptor.requiresConfirmation` is deliberately never set — see
262
- * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT} for why that omission is safe rather
263
- * than a hole.
299
+ * `descriptor.requiresConfirmation` is deliberately never set — a tool that needs a human confirm
300
+ * asks inside its own {@link humanConfirmedHandler}; see
301
+ * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
264
302
  *
265
303
  * @param spec.domain - Human-readable domain name, used only in failure messages.
266
304
  * @param spec.catalogModule - Path of the catalog file, named in the drift message so the failure
@@ -48,22 +48,47 @@ import { ForbiddenError } from "../commands/command.js";
48
48
  // and Forms' `CommandActor.kind` param without a cast at either call site.
49
49
  export const AGENT_TOOL_PRINCIPAL_KIND = "agent";
50
50
  /**
51
- * Actor-class rules that cannot be honored while no confirmation transport is wired.
51
+ * Actor-class rules that need a human confirmer.
52
52
  *
53
53
  * `confirmer-must-equal-own-delegatedBy` means "a human must confirm this, and the confirmer must
54
- * be the principal who delegated". A host can only deliver that by building `ToolExecutor` with an
55
- * `ExecutionDelegate`. Without one, `descriptor.requiresConfirmation` would park the execution on a
56
- * promise only `resumeConfirmation` can settle — and nothing would call it. The park is also unbounded, because `descriptor.timeoutMs`'s timer is armed only AFTER the
57
- * confirmation await.
54
+ * be the principal who delegated". The kit never delivers that through `descriptor.requiresConfirmation`:
55
+ * without a host `ExecutionDelegate`, that flag parks the execution on a promise only
56
+ * `resumeConfirmation` can settle, and nothing would call it (unbounded, too, because
57
+ * `descriptor.timeoutMs`'s timer is armed only AFTER the confirmation await).
58
58
  *
59
- * So a tool carrying this rule must not be wired at all. Today none is — `collections_execute_cleanup`,
60
- * `database_execute_migrate_forward`, and `backup_execute_restore` all carry it and all three are
61
- * declared unwired — which makes this guard a statement of the invariant rather than a fix: a
62
- * future edit wiring any of them fails the build instead of silently shipping a tool whose stated
63
- * human-confirmation requirement is unenforceable. When a confirmation transport does land, this
64
- * constant is the deliberate place to relax.
59
+ * So a tool carrying this rule is wirable only when its handler was built with
60
+ * {@link humanConfirmedHandler}, which asks the human through the host's own confirmation transport
61
+ * and runs the action only on an explicit yes. Any other handler fails the build.
65
62
  */
66
63
  export const ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT = new Set(["confirmer-must-equal-own-delegatedBy"]);
64
+ const HUMAN_CONFIRMED_HANDLERS = new WeakSet();
65
+ /**
66
+ * Builds the one kind of handler a `confirmer-must-equal-own-delegatedBy` tool may be wired with
67
+ * (see {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}).
68
+ *
69
+ * The order is fixed here, not left to each domain: `prepare` (validate, pre-authorize, derive what
70
+ * the human will see) → `askHuman` (the host's transport, e.g. an MCP-UI dialog) → `run`, only when
71
+ * the answer is exactly `{ confirmed: true }`. The decision never comes from `ctx.input`, so nothing
72
+ * the model sends can stand in for the human. `run` gets the confirmer as the delegating human.
73
+ *
74
+ * @param steps.prepare - Reads input and derives what the human is asked about. May throw.
75
+ * @param steps.askHuman - Asks the human through the host's transport. Throws when no human can be
76
+ * asked; a no-answer returns `{ confirmed: false, result }` and `result` is the tool's result.
77
+ * @param steps.run - Performs the action on a yes.
78
+ * @returns A handler {@link assertToolIsWirable} accepts for a tool carrying the rule.
79
+ * @complexity O(1) plus the three steps.
80
+ */
81
+ export function humanConfirmedHandler(steps) {
82
+ const handler = async (ctx) => {
83
+ const prepared = await steps.prepare(ctx);
84
+ const answer = await steps.askHuman(ctx, prepared);
85
+ if (answer.confirmed !== true)
86
+ return answer.result;
87
+ return steps.run(ctx, prepared, { id: ctx.principal.id, kind: "user" });
88
+ };
89
+ HUMAN_CONFIRMED_HANDLERS.add(handler);
90
+ return handler;
91
+ }
67
92
  /**
68
93
  * Indexes a domain catalog by tool id — the single lookup used for descriptors, risk metadata, and
69
94
  * schema-bearing error messages.
@@ -256,8 +281,8 @@ export function decorateWithSchema(params) {
256
281
  /**
257
282
  * Build-time gate on a single tool's risk metadata: refuses to wire a tool whose declared
258
283
  * `sideEffects` disagrees with the wiring layer's own {@link DerivedRiskByToolId} classification,
259
- * whose id that layer has not classified at all, or whose `actorClassRule` needs a confirmation
260
- * transport that does not exist.
284
+ * whose id that layer has not classified at all, or whose `actorClassRule` needs a human confirmer
285
+ * its handler does not ask for.
261
286
  *
262
287
  * Throws rather than warning, and at registration time rather than call time: a metadata
263
288
  * inconsistency is a developer error that should stop the daemon booting, not a runtime condition
@@ -266,7 +291,10 @@ export function decorateWithSchema(params) {
266
291
  * @param params.toolId - The wired tool id.
267
292
  * @param params.catalogEntry - Its `agent-tools.ts` entry, the declared side of the comparison.
268
293
  * @param params.derivedRisk - The wiring layer's independent classification, the derived side.
269
- * @throws {Error} If the tool is unclassified, misclassified, or needs missing confirmation support.
294
+ * @param params.handler - The handler being wired; checked only for a tool carrying a rule in
295
+ * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
296
+ * @throws {Error} If the tool is unclassified, misclassified, or needs a human confirmer its handler
297
+ * does not ask for.
270
298
  * @complexity O(1) — two map/set lookups.
271
299
  * @overallScore 100
272
300
  */
@@ -279,8 +307,9 @@ export function assertToolIsWirable(params) {
279
307
  if (derived !== catalogEntry.sideEffects) {
280
308
  throw new Error(`tool-registrations: '${toolId}' declares sideEffects '${catalogEntry.sideEffects}' but this layer derives '${derived}' from what its handler calls — reconcile the two rather than trusting the declaration`);
281
309
  }
282
- if (catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule)) {
283
- throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which requires a human-confirmation transport this host has not wired — leave it unwired until one exists (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
310
+ const needsHuman = catalogEntry.actorClassRule && ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT.has(catalogEntry.actorClassRule);
311
+ if (needsHuman && !(params.handler && HUMAN_CONFIRMED_HANDLERS.has(params.handler))) {
312
+ throw new Error(`tool-registrations: '${toolId}' declares actorClassRule '${catalogEntry.actorClassRule}', which needs a human confirmer — build its handler with humanConfirmedHandler so a human answers through the host's confirmation transport (see ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT)`);
284
313
  }
285
314
  }
286
315
  /**
@@ -302,9 +331,9 @@ export function assertToolIsWirable(params) {
302
331
  * the same rule, and a `ToolPolicy`-only check would be bypassable by any future non-tool caller of
303
332
  * the same domain function, which is precisely why the chokepoint owns the gate.
304
333
  *
305
- * `descriptor.requiresConfirmation` is deliberately never set — see
306
- * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT} for why that omission is safe rather
307
- * than a hole.
334
+ * `descriptor.requiresConfirmation` is deliberately never set — a tool that needs a human confirm
335
+ * asks inside its own {@link humanConfirmedHandler}; see
336
+ * {@link ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT}.
308
337
  *
309
338
  * @param spec.domain - Human-readable domain name, used only in failure messages.
310
339
  * @param spec.catalogModule - Path of the catalog file, named in the drift message so the failure
@@ -326,7 +355,7 @@ export function buildDomainRegistrations(spec) {
326
355
  if (!catalogEntry) {
327
356
  throw new Error(`tool-registrations: ${spec.domain} catalog has no entry named '${id}' — ${spec.catalogModule} drifted`);
328
357
  }
329
- assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk });
358
+ assertToolIsWirable({ toolId: id, catalogEntry, derivedRisk: spec.derivedRisk, handler });
330
359
  if (!catalogEntry.inputSchema) {
331
360
  throw new Error(`tool-registrations: wired tool '${id}' publishes no inputSchema — add one to its entry in ${spec.catalogModule} so the model gets a contract, or leave the tool unwired`);
332
361
  }
@@ -26,18 +26,12 @@
26
26
  * - `confirm()` structurally refuses an agent: `gateway.ts`'s own body — "if (principalKind ===
27
27
  * 'agent') throw new ForbiddenError('agent principals may not confirm a gated mutation', ...)"
28
28
  * — before any permission check even runs. No `taxonomy_confirm_merge_term`-equivalent tool
29
- * exists in this catalog, and no description below may even imply that step (mirrors
30
- * `features/recovery/agent-tools.ts`'s own discipline for `backup_execute_restore`: "the
31
- * human confirmation step is exactly the one act the gated-mutations gateway reserves for
32
- * kind='user'/api_key principals").
33
- * - `execute()` needs a confirmation token a human already minted through the admin UI's own
34
- * ceremony (`server/routes/admin/taxonomy/merge-term.ts`'s `/merge/confirm` route) — the same
35
- * `confirmer-must-equal-own-delegatedBy` actor-class rule `database_execute_migrate_forward`/
36
- * `backup_execute_restore` carry, which `assistant/tool-registration-kit.ts`'s
37
- * `ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT` refuses to build regardless of this
38
- * catalog's own choice, because the host's `ToolExecutor` has no confirmation transport wired.
39
- * `taxonomy_execute_merge_term` is declared here (so the exclusion is a structural build-time
40
- * refusal, not just an absent handler) but is NEVER wired by `tool-registrations.ts`.
29
+ * exists in this catalog.
30
+ * - `taxonomy_execute_merge_term` (2026-09-24) asks the human in chat before it runs: the host
31
+ * wires it with the kit's `humanConfirmedHandler`, which shows a confirm dialog and only on the
32
+ * human's own click confirms as that human (`kind='user'`) and executes as the agent acting for
33
+ * them — the `confirmer-must-equal-own-delegatedBy` rule. The model never sees or supplies a
34
+ * token. A merge deletes the source term's assignments with no Trash or undo, so it is gated.
41
35
  *
42
36
  * Naming: `taxonomy_*`, matching this package's own name — distinct from `features/content-types`'
43
37
  * `collections_*` prefix (a different pairing) and from `features/entries`' own
@@ -66,7 +60,7 @@ export interface AgentToolDefinition {
66
60
  /**
67
61
  * JSON Schema for this tool's `input`, published to the model via `ToolDescriptor.inputSchema`
68
62
  * (`assistant/tool-registration-kit.ts`'s `buildDomainRegistrations`, which refuses to wire any
69
- * tool lacking one). Optional — `taxonomy_execute_merge_term` is never wired, so it carries none.
63
+ * tool lacking one).
70
64
  */
71
65
  inputSchema?: Readonly<Record<string, unknown>>;
72
66
  }
@@ -26,18 +26,12 @@
26
26
  * - `confirm()` structurally refuses an agent: `gateway.ts`'s own body — "if (principalKind ===
27
27
  * 'agent') throw new ForbiddenError('agent principals may not confirm a gated mutation', ...)"
28
28
  * — before any permission check even runs. No `taxonomy_confirm_merge_term`-equivalent tool
29
- * exists in this catalog, and no description below may even imply that step (mirrors
30
- * `features/recovery/agent-tools.ts`'s own discipline for `backup_execute_restore`: "the
31
- * human confirmation step is exactly the one act the gated-mutations gateway reserves for
32
- * kind='user'/api_key principals").
33
- * - `execute()` needs a confirmation token a human already minted through the admin UI's own
34
- * ceremony (`server/routes/admin/taxonomy/merge-term.ts`'s `/merge/confirm` route) — the same
35
- * `confirmer-must-equal-own-delegatedBy` actor-class rule `database_execute_migrate_forward`/
36
- * `backup_execute_restore` carry, which `assistant/tool-registration-kit.ts`'s
37
- * `ACTOR_CLASS_RULES_REQUIRING_CONFIRMATION_TRANSPORT` refuses to build regardless of this
38
- * catalog's own choice, because the host's `ToolExecutor` has no confirmation transport wired.
39
- * `taxonomy_execute_merge_term` is declared here (so the exclusion is a structural build-time
40
- * refusal, not just an absent handler) but is NEVER wired by `tool-registrations.ts`.
29
+ * exists in this catalog.
30
+ * - `taxonomy_execute_merge_term` (2026-09-24) asks the human in chat before it runs: the host
31
+ * wires it with the kit's `humanConfirmedHandler`, which shows a confirm dialog and only on the
32
+ * human's own click confirms as that human (`kind='user'`) and executes as the agent acting for
33
+ * them — the `confirmer-must-equal-own-delegatedBy` rule. The model never sees or supplies a
34
+ * token. A merge deletes the source term's assignments with no Trash or undo, so it is gated.
41
35
  *
42
36
  * Naming: `taxonomy_*`, matching this package's own name — distinct from `features/content-types`'
43
37
  * `collections_*` prefix (a different pairing) and from `features/entries`' own
@@ -172,9 +166,7 @@ export const taxonomyAgentToolCatalog = [
172
166
  description: "Previews merging one term into another: recomputes and returns the current overlap-loss disclosure (how many pieces of content are " +
173
167
  "already assigned to BOTH terms, whose duplicate assignment would be silently lost by the merge's own dedup step) plus a planId/planHash " +
174
168
  "a human can use in the admin UI's own merge confirmation ceremony. Read-only — performs no merge. Rejects immediately if fromTermId " +
175
- "equals intoTermId, before computing anything. This tool can only PLAN a merge; actually executing one requires a human to confirm " +
176
- "through the admin UI — no tool in this catalog can do that step (by design: merges are destructive and irreversible for the merged-away " +
177
- "term's own identity).",
169
+ "equals intoTermId, before computing anything. To actually merge, call taxonomy_execute_merge_term, which asks the user to confirm first.",
178
170
  sideEffects: "none",
179
171
  authorization: { permission: "admin.taxonomy.manage" },
180
172
  inputSchema: {
@@ -188,13 +180,22 @@ export const taxonomyAgentToolCatalog = [
188
180
  },
189
181
  },
190
182
  {
191
- // EXCLUDED BY DESIGN, never wired: see this file's header. Token-gated; `core/gated-mutations`'s
192
- // own confirm() step structurally refuses an agent principal, and no confirmation transport
193
- // exists for an agent to be handed a human-minted token through anyway.
183
+ // Asks the human first — see this file's header.
194
184
  name: "taxonomy_execute_merge_term",
195
- description: "Executes a previously confirmed term merge using a human-minted confirmation token. NEVER agent-callable — see file header.",
185
+ description: "Merges one term into another: every piece of content tagged with fromTermId is re-tagged with intoTermId, and fromTermId is " +
186
+ "deprecated. Shows the user a confirm dialog first and only merges if they confirm. The merge can't be undone. Call " +
187
+ "taxonomy_plan_merge_term first to see how many items are affected.",
196
188
  sideEffects: "mutates-durable-state",
197
189
  authorization: { permission: "admin.taxonomy.manage" },
190
+ inputSchema: {
191
+ type: "object",
192
+ additionalProperties: false,
193
+ required: ["fromTermId", "intoTermId"],
194
+ properties: {
195
+ fromTermId: { ...TERM_ID_SCHEMA, description: "The term to merge away. It is deprecated after the merge." },
196
+ intoTermId: { ...TERM_ID_SCHEMA, description: "The term that receives every assignment." },
197
+ },
198
+ },
198
199
  actorClassRule: "confirmer-must-equal-own-delegatedBy",
199
200
  },
200
201
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/cms",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound persistence adapters in `/server`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {