@company-semantics/contracts 51.1.0 → 51.2.0

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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Walking the ladder — the READ half of the contract the anchor writes
3
+ * (ADR-CONTRACTS-116, ADR-CONTRACTS-127).
4
+ *
5
+ * WHY THE INTERPRETATION IS PUBLISHED AND NOT ONLY THE SHAPE. The backend
6
+ * validates an anchor's shape once, at the route boundary, then stores opaque
7
+ * jsonb it never interprets. So the client that WROTE the anchor and the client
8
+ * that later RESOLVES it must agree about what these fields MEAN with no
9
+ * server-side arbiter between them — and a disagreement produces no error at
10
+ * all. The write succeeds, the read succeeds, and the highlight lands on the
11
+ * wrong sentence. Publishing the schema bounded that risk; publishing the
12
+ * resolver closes it, because a second implementation can now be an IMPORT
13
+ * rather than a second description.
14
+ *
15
+ * EACH RUNG EITHER ANSWERS OR DECLINES, AND A RUNG THAT CANNOT VOUCH FOR ITS
16
+ * ANSWER MUST DECLINE. The whole design turns on refusing rather than guessing,
17
+ * because the wrong answer looks exactly like the right one. Under-anchoring is
18
+ * the deliberate error direction: a comment shown as unanchored is legible and
19
+ * recoverable, while a comment shown against the wrong sentence reads as a
20
+ * considered objection to a paragraph its author never saw.
21
+ *
22
+ * NO YJS HERE, DELIBERATELY. Everything below is a pure function of strings and
23
+ * indices. Rung 1 — decoding a `Y.RelativePosition` — needs `yjs`, which the
24
+ * vocabulary-guard forbids this package from importing, and it is the ONE rung
25
+ * that needs a live replica anyway. A consumer that holds a `Y.Text` composes
26
+ * it on top: it tries its own rung 1 and falls through to
27
+ * {@link resolveAnchorFromText}, which is total and never throws. That is also
28
+ * what lets a comment resolve on a card with no session, no stream and no CRDT
29
+ * replica — and what lets the server resolve one at all.
30
+ *
31
+ * All arithmetic is in UTF-16 code units (plain JavaScript string coordinates),
32
+ * like every other offset on the comment path. No Unicode normalization happens
33
+ * anywhere here: normalising before measuring changes the length and
34
+ * desynchronises every offset after the first composed character.
35
+ */
36
+ import { ANCHOR_CONTEXT_CHARS } from "./anchor-constants";
37
+ import type { CommentAnchor } from "./anchor";
38
+
39
+ /**
40
+ * Where an anchor landed.
41
+ *
42
+ * `document` is not a degraded `resolved` and `orphaned` is not an error — they
43
+ * are three distinct outcomes, and collapsing any pair of them loses the
44
+ * distinction the comment rail draws on.
45
+ *
46
+ * `rung` stays `1 | 2` even though this module can only ever emit `2`: the
47
+ * consumer that composes the Yjs rung on top returns `rung: 1` into this same
48
+ * type, and narrowing it here would force that consumer to declare a second,
49
+ * divergent result type — which is the two-independent-descriptions problem
50
+ * this domain exists to prevent.
51
+ */
52
+ export type AnchorResolution =
53
+ | { status: "resolved"; start: number; end: number; rung: 1 | 2 }
54
+ | { status: "document" }
55
+ | { status: "orphaned" };
56
+
57
+ /** Whether a needle occurs in a haystack exactly once, and where. */
58
+ export type Occurrence =
59
+ | { kind: "absent" }
60
+ | { kind: "unique"; index: number }
61
+ | { kind: "ambiguous" };
62
+
63
+ /**
64
+ * Whether `text` still reads `leftContext` immediately before `point` and
65
+ * `rightContext` immediately after it.
66
+ *
67
+ * The two length guards are the whole reason this is a function rather than two
68
+ * inline slices: `slice` reads a negative start from the END of the string and
69
+ * clamps a long end, so a context that CANNOT fit around `point` would be
70
+ * compared against an unrelated substring instead of failing. NOT FITTING IS A
71
+ * MISMATCH.
72
+ *
73
+ * EXPORTED because the acceptance path re-asks this question as a second,
74
+ * independent assertion before it writes the body (see `./verify`). A private
75
+ * copy there would be two descriptions of "are the contexts still there" — and
76
+ * the copy that forgot a length guard would compare against an unrelated
77
+ * substring and answer `true`, which is the one wrong answer that mutates the
78
+ * document.
79
+ */
80
+ export function contextsMatchAt(
81
+ text: string,
82
+ point: number,
83
+ leftContext: string,
84
+ rightContext: string,
85
+ ): boolean {
86
+ if (point < 0 || point > text.length) return false;
87
+ if (point < leftContext.length) return false;
88
+ if (point + rightContext.length > text.length) return false;
89
+ return (
90
+ text.slice(point - leftContext.length, point) === leftContext &&
91
+ text.slice(point, point + rightContext.length) === rightContext
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Whether `needle` occurs in `haystack` exactly once, and where.
97
+ *
98
+ * Overlapping occurrences COUNT — the second search resumes at `first + 1`, not
99
+ * at `first + needle.length`. A quote that overlaps itself (`"aa"` in `"aaa"`)
100
+ * is no less ambiguous for the overlap, and the conservative count is the one
101
+ * that errs toward orphaning.
102
+ *
103
+ * An EMPTY needle is `ambiguous`, never `unique`: it matches at every position,
104
+ * which is a fact about the search and not evidence about the point.
105
+ */
106
+ export function findSoleOccurrence(
107
+ haystack: string,
108
+ needle: string,
109
+ ): Occurrence {
110
+ if (needle.length === 0) return { kind: "ambiguous" };
111
+ const first = haystack.indexOf(needle);
112
+ if (first === -1) return { kind: "absent" };
113
+ if (haystack.indexOf(needle, first + 1) !== -1) return { kind: "ambiguous" };
114
+ return { kind: "unique", index: first };
115
+ }
116
+
117
+ const HIGH_SURROGATE_MIN = 0xd800;
118
+ const HIGH_SURROGATE_MAX = 0xdbff;
119
+ const LOW_SURROGATE_MIN = 0xdc00;
120
+ const LOW_SURROGATE_MAX = 0xdfff;
121
+
122
+ /**
123
+ * The context preceding `index`, never opening on half a character.
124
+ *
125
+ * A fixed-width slice can land between a surrogate pair, and the lone surrogate
126
+ * that results is ill-formed once the anchor is encoded for the wire. Trimming
127
+ * costs one code unit of context and changes nothing about matching — the
128
+ * document contains the same code units either way.
129
+ *
130
+ * Slices {@link ANCHOR_CONTEXT_CHARS}, the CAPTURE width, which is deliberately
131
+ * narrower than what the schema ACCEPTS. This is a constructor helper: nothing
132
+ * in the ladder below calls it, and it is published here so the construction
133
+ * half (`./create`) and the resolution half agree about the same string work.
134
+ */
135
+ export function contextBefore(text: string, index: number): string {
136
+ let from = Math.max(0, index - ANCHOR_CONTEXT_CHARS);
137
+ const code = text.charCodeAt(from);
138
+ if (from > 0 && code >= LOW_SURROGATE_MIN && code <= LOW_SURROGATE_MAX) {
139
+ from += 1;
140
+ }
141
+ return text.slice(from, index);
142
+ }
143
+
144
+ /** The context following `index`, never closing on half a character. */
145
+ export function contextAfter(text: string, index: number): string {
146
+ let to = Math.min(text.length, index + ANCHOR_CONTEXT_CHARS);
147
+ if (to > index && to < text.length) {
148
+ const code = text.charCodeAt(to - 1);
149
+ if (code >= HIGH_SURROGATE_MIN && code <= HIGH_SURROGATE_MAX) to -= 1;
150
+ }
151
+ return text.slice(index, to);
152
+ }
153
+
154
+ /**
155
+ * Rung 2 for the text anchor — the context-qualified search, then the bare
156
+ * quote. Both refuse ambiguity; neither ever settles for a first match.
157
+ */
158
+ function resolveByQuote(
159
+ anchor: Extract<CommentAnchor, { type: "text" }>,
160
+ text: string,
161
+ ): AnchorResolution {
162
+ const context = anchor.prefix + anchor.quote + anchor.suffix;
163
+ if (context !== anchor.quote) {
164
+ const qualified = findSoleOccurrence(text, context);
165
+ // Two context-qualified matches mean the context FAILED to identify a
166
+ // candidate. Binding to the first would be the guess this ladder exists to
167
+ // forbid, and falling back to the bare quote here would only re-ask a
168
+ // question already known to be ambiguous.
169
+ if (qualified.kind === "ambiguous") return { status: "orphaned" };
170
+ if (qualified.kind === "unique") {
171
+ const start = qualified.index + anchor.prefix.length;
172
+ return {
173
+ status: "resolved",
174
+ start,
175
+ end: start + anchor.quote.length,
176
+ rung: 2,
177
+ };
178
+ }
179
+ // Absent: the surrounding sentence was edited but the quote itself may have
180
+ // survived. Fall through.
181
+ }
182
+
183
+ const bare = findSoleOccurrence(text, anchor.quote);
184
+ if (bare.kind !== "unique") return { status: "orphaned" };
185
+ return {
186
+ status: "resolved",
187
+ start: bare.index,
188
+ end: bare.index + anchor.quote.length,
189
+ rung: 2,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Rung 2 for the zero-width variant — find the seam the two contexts spell out
195
+ * when the caret between them is closed up, and resolve ONLY if it is unique.
196
+ *
197
+ * A repeated seam is the insertion anchor's version of a repeated quote, and it
198
+ * gets the same answer: `orphaned`. "Insert at the first `item done`" is the
199
+ * shortcut this rung exists to forbid — in a list of identical rows it is wrong
200
+ * two times out of three, and it never looks wrong.
201
+ */
202
+ function resolveInsertionBySeam(
203
+ anchor: Extract<CommentAnchor, { type: "text-insertion" }>,
204
+ text: string,
205
+ ): AnchorResolution {
206
+ const { leftContext, rightContext } = anchor;
207
+ const seam = leftContext + rightContext;
208
+
209
+ if (seam === "") {
210
+ // Only authorable on an EMPTY document, where 0 is the one point there is.
211
+ // Once the document has text that anchor says nothing about WHERE among it
212
+ // the caret belongs — and an empty needle matches everywhere, which is a
213
+ // fact about the search and not evidence about the point.
214
+ //
215
+ // Not redundant with `findSoleOccurrence`'s empty-needle refusal below:
216
+ // this branch is reached first, and it is the one that makes "the only
217
+ // point an empty document has" resolvable at all.
218
+ return text.length === 0
219
+ ? { status: "resolved", start: 0, end: 0, rung: 2 }
220
+ : { status: "orphaned" };
221
+ }
222
+
223
+ const seen = findSoleOccurrence(text, seam);
224
+ if (seen.kind !== "unique") return { status: "orphaned" };
225
+ const point = seen.index + leftContext.length;
226
+ return { status: "resolved", start: point, end: point, rung: 2 };
227
+ }
228
+
229
+ /**
230
+ * Resolve `anchor` against the current `text`, using nothing but the text.
231
+ *
232
+ * TOTAL AND NEVER THROWS: every input produces one of the three outcomes, which
233
+ * is what lets a consumer use it as the floor of its own ladder without a
234
+ * try/catch or a null check.
235
+ *
236
+ * `text` is the coordinate space the caller will paint into — the live
237
+ * `Y.Text`'s contents where a session is attached, the materialized document
238
+ * body where it is not. A consumer that holds a `Y.Text` tries its own
239
+ * relative-position rung FIRST and calls this when that rung declines; the
240
+ * geometry both paths produce must be identical, or two people looking at the
241
+ * same document disagree about where a comment sits.
242
+ *
243
+ * The three outcomes, by variant:
244
+ * - `document` — the thread hangs off the subject AS A WHOLE. It draws no
245
+ * highlight, but it is correctly anchored; reporting it as `orphaned` would
246
+ * put it behind the rail's "this text is gone" affordance.
247
+ * - `text` — {@link resolveByQuote}: the context-qualified string first, the
248
+ * bare quote as the fallback, ambiguity orphaned at BOTH levels.
249
+ * - `text-insertion` — {@link resolveInsertionBySeam}: the unique
250
+ * `leftContext`+`rightContext` seam, and nothing else. Always zero-width.
251
+ */
252
+ export function resolveAnchorFromText(
253
+ anchor: CommentAnchor,
254
+ text: string,
255
+ ): AnchorResolution {
256
+ if (anchor.type === "document") return { status: "document" };
257
+ if (anchor.type === "text-insertion") {
258
+ return resolveInsertionBySeam(anchor, text);
259
+ }
260
+ return resolveByQuote(anchor, text);
261
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The two questions asked BEFORE the body is written — is the passage still
3
+ * what the anchor says it is, and what edit does this op make of that range?
4
+ * (ADR-CONTRACTS-122, ADR-CONTRACTS-127.)
5
+ *
6
+ * WHY THESE ARE PUBLISHED AT ALL. Both are total functions of their arguments —
7
+ * no `Y.Text`, no clock, no lease, nothing touching a sanctioned-mutation
8
+ * window. The app's own applier records that as the property that makes them
9
+ * "the only part of the applier that can be lifted out of it": the depth
10
+ * counter and the lease have to stay in one module, because an exported pair
11
+ * that opened and closed that window would be a generic bypass. What is left
12
+ * once they are removed is exactly this file — and once it is an import rather
13
+ * than a second description, a NON-BROWSER applier (the server, a comment pass)
14
+ * becomes possible without a second answer to "did this suggestion still apply".
15
+ *
16
+ * THE APPLIER MUST NOT TRUST THE FINDER. {@link anchorStillReads} is a SECOND
17
+ * assertion, independent of {@link resolveAnchorFromText} by construction: it
18
+ * reads the anchor's own stored evidence against the live text and takes the
19
+ * resolution only as a position. That a rung already checked something like it
20
+ * does not make it redundant — the rung answers "where does this land", this
21
+ * answers "is what lands there still the thing", and a consumer that composes
22
+ * its own Yjs rung on top supplies a position this module never saw.
23
+ *
24
+ * All arithmetic is in UTF-16 code units, like every other offset on the
25
+ * comment path, and no Unicode normalization happens here.
26
+ */
27
+ import type { CommentAnchor } from "./anchor";
28
+ import { contextsMatchAt, type AnchorResolution } from "./resolve";
29
+ import type { Suggestion } from "./suggestion";
30
+
31
+ /**
32
+ * A resolution that actually named a range.
33
+ *
34
+ * Narrowed from {@link AnchorResolution} rather than restated, so a consumer
35
+ * composing the Yjs rung on top hands its own `rung: 1` answer straight in.
36
+ */
37
+ type Resolved = Extract<AnchorResolution, { status: "resolved" }>;
38
+
39
+ /** One text mutation: delete `deleteLength` at `at`, then insert `insert`. */
40
+ export interface PlannedEdit {
41
+ readonly at: number;
42
+ readonly deleteLength: number;
43
+ readonly insert: string;
44
+ }
45
+
46
+ /**
47
+ * Does the resolved place still hold what the anchor says it holds?
48
+ *
49
+ * By variant:
50
+ * - `text` — the live slice must still be the stored `quote`, exactly. Not a
51
+ * prefix of it, not a normalized form of it.
52
+ * - `text-insertion` — the resolution must still be ZERO-WIDTH, and both
53
+ * contexts must still meet at that point ({@link contextsMatchAt}, imported
54
+ * rather than re-derived: a private copy that forgot a length guard would
55
+ * compare against an unrelated substring and answer `true`, which is the
56
+ * one wrong answer that mutates the document).
57
+ * - `document` — `false`. A whole-document anchor makes no claim about a
58
+ * range, so it can never vouch for one. Unreachable through the shipped
59
+ * ladder, which answers `{ status: "document" }` for that variant and never
60
+ * a `resolved` — but a consumer's own rung can hand one in, and the refusal
61
+ * is what makes that harmless.
62
+ */
63
+ export function anchorStillReads(
64
+ anchor: CommentAnchor,
65
+ text: string,
66
+ resolution: Resolved,
67
+ ): boolean {
68
+ if (anchor.type === "text") {
69
+ return text.slice(resolution.start, resolution.end) === anchor.quote;
70
+ }
71
+ if (anchor.type === "text-insertion") {
72
+ return (
73
+ resolution.start === resolution.end &&
74
+ contextsMatchAt(
75
+ text,
76
+ resolution.start,
77
+ anchor.leftContext,
78
+ anchor.rightContext,
79
+ )
80
+ );
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * What this op does to that range — or `null` when it would do nothing.
87
+ *
88
+ * Derived from the CANONICAL suggestion payload, never from a caller's
89
+ * restatement of it: that seam is where "accept this suggestion" would quietly
90
+ * become "apply something like it".
91
+ *
92
+ * A NO-OP IS A REFUSAL, NOT A CHEAP SUCCESS. An `insert` carrying no text, or a
93
+ * `delete` over a collapsed range, would run a transaction that writes a
94
+ * receipt and changes not one character — the thread would go to `accepted`
95
+ * over a document that never received the proposal, which is the invariant pair
96
+ * read backwards. Both shapes mean the payload and the anchor disagree about
97
+ * what kind of edit this is, and that disagreement is not this module's to
98
+ * settle.
99
+ *
100
+ * `insert` lands at `end`, matching where the redline draws the proposed text,
101
+ * so the diff the user accepted and the edit they get agree about the seam.
102
+ *
103
+ * The switch is exhaustive over {@link Suggestion}'s `op` with NO default arm:
104
+ * a fourth op must arrive as a type error here rather than be absorbed as a
105
+ * silent `undefined` return.
106
+ */
107
+ export function plannedEdit(
108
+ suggestion: Suggestion,
109
+ resolution: Resolved,
110
+ ): PlannedEdit | null {
111
+ const insert = suggestion.insertedText ?? "";
112
+ const span = resolution.end - resolution.start;
113
+
114
+ switch (suggestion.op) {
115
+ case "insert":
116
+ return insert === ""
117
+ ? null
118
+ : { at: resolution.end, deleteLength: 0, insert };
119
+ case "delete":
120
+ return span === 0
121
+ ? null
122
+ : { at: resolution.start, deleteLength: span, insert: "" };
123
+ case "replace":
124
+ return span === 0 || insert === ""
125
+ ? null
126
+ : { at: resolution.start, deleteLength: span, insert };
127
+ }
128
+ }
@@ -4,6 +4,8 @@ import {
4
4
  getExecutionKindDefinition,
5
5
  isValidExecutionKind,
6
6
  } from "../registry.js";
7
+ import type { ExecutionKind } from "../kinds.js";
8
+ import { CONFIRMATION_LABELS } from "../../message-parts/confirmation.js";
7
9
 
8
10
  describe("EXECUTION_KINDS golden snapshot", () => {
9
11
  it("exact values are frozen", () => {
@@ -199,10 +201,97 @@ describe("EXECUTION_KINDS golden snapshot", () => {
199
201
  templateId: "companyMd.ingest",
200
202
  },
201
203
  },
204
+ "companyMd.commentPass": {
205
+ kind: "companyMd.commentPass",
206
+ domain: "data",
207
+ display: {
208
+ label: "Apply Comment Pass",
209
+ pastTenseLabel: "Comment pass applied",
210
+ icon: "pencil",
211
+ },
212
+ governance: {
213
+ visibility: "user",
214
+ requiresAdmin: false,
215
+ },
216
+ ui: {
217
+ showInAdmin: false,
218
+ showInTimeline: true,
219
+ confirmBeforeRun: true,
220
+ },
221
+ explanation: {
222
+ templateId: "companyMd.commentPass",
223
+ },
224
+ },
225
+ "semantic.transform": {
226
+ kind: "semantic.transform",
227
+ domain: "organization",
228
+ display: {
229
+ label: "Apply Reporting Transformation",
230
+ pastTenseLabel: "Reporting transformation applied",
231
+ icon: "pencil",
232
+ },
233
+ governance: {
234
+ visibility: "user",
235
+ requiresAdmin: false,
236
+ },
237
+ ui: {
238
+ showInAdmin: false,
239
+ showInTimeline: true,
240
+ confirmBeforeRun: false,
241
+ },
242
+ explanation: {
243
+ templateId: "semantic.transform",
244
+ },
245
+ },
246
+ "companyMd.importContextDoc": {
247
+ kind: "companyMd.importContextDoc",
248
+ domain: "data",
249
+ display: {
250
+ label: "Import Context Document",
251
+ pastTenseLabel: "Context document imported",
252
+ icon: "pencil",
253
+ },
254
+ governance: {
255
+ visibility: "user",
256
+ requiresAdmin: false,
257
+ },
258
+ ui: {
259
+ showInAdmin: false,
260
+ showInTimeline: true,
261
+ confirmBeforeRun: false,
262
+ },
263
+ explanation: {
264
+ templateId: "companyMd.importContextDoc",
265
+ },
266
+ },
202
267
  });
203
268
  });
204
269
  });
205
270
 
271
+ describe("ExecutionKind exhaustiveness", () => {
272
+ // `satisfies Record<ExecutionKind, ...>` catches a MISSING key at compile
273
+ // time, but only a runtime set comparison catches an EXTRA one that no union
274
+ // member claims — a Record with a surplus key still satisfies the type.
275
+ it("the registry and the confirmation labels cover exactly the same kinds", () => {
276
+ expect(Object.keys(EXECUTION_KINDS).sort()).toStrictEqual(
277
+ Object.keys(CONFIRMATION_LABELS).sort(),
278
+ );
279
+ });
280
+
281
+ it("every kind the backend dispatches is a union member, not a call-site cast", () => {
282
+ // These three previously ran while cast to ExecutionKind at their call
283
+ // sites, which left the registry incomplete over what actually executes.
284
+ for (const kind of [
285
+ "companyMd.commentPass",
286
+ "semantic.transform",
287
+ "companyMd.importContextDoc",
288
+ ]) {
289
+ expect(isValidExecutionKind(kind)).toBe(true);
290
+ expect(CONFIRMATION_LABELS[kind as ExecutionKind]).toBeTruthy();
291
+ }
292
+ });
293
+ });
294
+
206
295
  describe("registry structural invariants", () => {
207
296
  it("every registry key matches its definition.kind field", () => {
208
297
  for (const [key, def] of Object.entries(EXECUTION_KINDS)) {
@@ -18,9 +18,20 @@
18
18
  * - domain: integration, policy, data, system
19
19
  * - verb: action being performed (connect, disconnect, etc.)
20
20
  *
21
+ * This union is the authority over what may run: every kind that a backend
22
+ * executor dispatches MUST be a member here. A kind cast in at its call site
23
+ * is invisible to every reader of the registry — ExecutionSummaryService drops
24
+ * such executions from timelines, and the query enums cannot filter for them.
25
+ *
21
26
  * New kinds MUST be added to:
22
27
  * 1. This union type
23
28
  * 2. EXECUTION_KINDS registry in registry.ts
29
+ * 3. CONFIRMATION_LABELS in message-parts/confirmation.ts
30
+ *
31
+ * Both (2) and (3) are exhaustive Records over this union, so omitting either
32
+ * is a typecheck failure rather than a checklist item.
33
+ *
34
+ * @see decisions/ADR-CONTRACTS-128-execution-kind-union-is-exhaustive.md
24
35
  */
25
36
  export type ExecutionKind =
26
37
  | "integration.connect"
@@ -31,4 +42,13 @@ export type ExecutionKind =
31
42
  | "data.scope"
32
43
  | "system.cleanup"
33
44
  | "member.changeManager"
34
- | "companyMd.ingest";
45
+ | "companyMd.ingest"
46
+ /** A reviewed comment pass over one company.md doc: validated range edits plus
47
+ * thread dispositions, applied in one transaction after one confirmation. */
48
+ | "companyMd.commentPass"
49
+ /** Already RUNS today, previously cast at the call site. Applies a frozen
50
+ * bundle of fact mutations to the org chart's reporting edges. */
51
+ | "semantic.transform"
52
+ /** Already RUNS today, previously cast at the call site. Creates a context
53
+ * doc from submitted text and links it under a destination doc. */
54
+ | "companyMd.importContextDoc";
@@ -225,6 +225,79 @@ export const EXECUTION_KINDS = {
225
225
  templateId: "companyMd.ingest",
226
226
  },
227
227
  },
228
+ "companyMd.commentPass": {
229
+ kind: "companyMd.commentPass",
230
+ domain: "data",
231
+ display: {
232
+ label: "Apply Comment Pass",
233
+ pastTenseLabel: "Comment pass applied",
234
+ icon: "pencil",
235
+ },
236
+ governance: {
237
+ visibility: "user",
238
+ requiresAdmin: false,
239
+ },
240
+ ui: {
241
+ showInAdmin: false,
242
+ showInTimeline: true,
243
+ // The pass mutates the document body; the reviewer confirms once and the
244
+ // whole bundle of range edits and dispositions lands in one transaction.
245
+ confirmBeforeRun: true,
246
+ },
247
+ explanation: {
248
+ templateId: "companyMd.commentPass",
249
+ },
250
+ },
251
+ "semantic.transform": {
252
+ kind: "semantic.transform",
253
+ domain: "organization",
254
+ display: {
255
+ label: "Apply Reporting Transformation",
256
+ pastTenseLabel: "Reporting transformation applied",
257
+ icon: "pencil",
258
+ },
259
+ governance: {
260
+ // Authority is decided in-operation by the reporting write seam, not by
261
+ // this flag — the same shape member.changeManager already has.
262
+ visibility: "user",
263
+ requiresAdmin: false,
264
+ // The frozen bundle is applied atomically; reversal is not a single-edge
265
+ // restore, so no kind reverses this one.
266
+ },
267
+ ui: {
268
+ showInAdmin: false,
269
+ showInTimeline: true,
270
+ // The confirmed preview IS the deliberate action — no second confirmation.
271
+ confirmBeforeRun: false,
272
+ },
273
+ explanation: {
274
+ templateId: "semantic.transform",
275
+ },
276
+ },
277
+ "companyMd.importContextDoc": {
278
+ kind: "companyMd.importContextDoc",
279
+ domain: "data",
280
+ display: {
281
+ label: "Import Context Document",
282
+ pastTenseLabel: "Context document imported",
283
+ icon: "pencil",
284
+ },
285
+ governance: {
286
+ visibility: "user",
287
+ requiresAdmin: false,
288
+ // Reversal is an executor undo window over the created doc, not a
289
+ // separately governed ExecutionKind, so reversibleBy stays unset.
290
+ },
291
+ ui: {
292
+ showInAdmin: false,
293
+ showInTimeline: true,
294
+ // The filled interactive-task form IS the deliberate action.
295
+ confirmBeforeRun: false,
296
+ },
297
+ explanation: {
298
+ templateId: "companyMd.importContextDoc",
299
+ },
300
+ },
228
301
  } as const satisfies Record<ExecutionKind, ExecutionKindDefinition>;
229
302
 
230
303
  // =============================================================================