@tenonhq/dovetail-servicenow 0.0.48 → 0.0.49

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.
package/README.md CHANGED
@@ -1151,7 +1151,11 @@ console.log(formatLayoutResult("form layout", result));
1151
1151
  `dove-sn mcp` runs a self-contained MCP stdio server exposing the tools to
1152
1152
  Claude Code and agents: `create_view`, `set_list_layout`, `set_form_layout`,
1153
1153
  `set_related_lists`, `add_choices_to_field`, the schema verbs `create_table` /
1154
- `add_column` / `add_index` (a single-column unique index via `sys_dictionary.unique`,
1154
+ `add_column` (same-scope by default; `crossScope: true` with `scope` adds a column OWNED
1155
+ by another app — a Journey field on an Automate table, element `<scope>_<name>` —
1156
+ guarded by `sys_db_object.alter_access` and the update set's scope, with the stored
1157
+ element and `sys_scope` read back; the CLI flag is `--cross-scope`) /
1158
+ `add_index` (a single-column unique index via `sys_dictionary.unique`,
1155
1159
  read back from the `v_db_index` view - uniqueness enforcement is always reported
1156
1160
  unverified) / `index_list` (read-only: a table's database indexes from `v_db_index`,
1157
1161
  the only index read surface - `sys_index` is API-level-ACL 403 and `sys_index_column`
package/dist/cli.js CHANGED
@@ -1345,9 +1345,10 @@ async function runCreateTable(flags) {
1345
1345
  * --table x_cadso_journey --label URL --type url
1346
1346
  * [--name url] [--max-length 1024] [--reference <table>]
1347
1347
  * [--mandatory] [--default <value>] [--dependent-on-field <element>]
1348
- * [--scope x_cadso_journey] [--update-set <sys_id>]
1348
+ * [--scope x_cadso_journey] [--cross-scope] [--update-set <sys_id>]
1349
1349
  * [--from-json <spec.json>] [--dry-run] [--debug] [--json]
1350
- * --update-set is required unless --dry-run.
1350
+ * --update-set is required unless --dry-run. --cross-scope opts in to a column
1351
+ * OWNED by --scope when that differs from the table's scope.
1351
1352
  */
1352
1353
  async function runAddColumn(flags) {
1353
1354
  var spec = {};
@@ -1384,6 +1385,9 @@ async function runAddColumn(flags) {
1384
1385
  var scope = flags.scope || spec.scope;
1385
1386
  if (scope)
1386
1387
  params.scope = scope;
1388
+ if (flags["cross-scope"] === "true" || spec.crossScope === true) {
1389
+ params.crossScope = true;
1390
+ }
1387
1391
  var us = flags["update-set"] || spec.updateSetSysId;
1388
1392
  if (us)
1389
1393
  params.updateSetSysId = us;
package/dist/cliUsage.js CHANGED
@@ -200,7 +200,15 @@ exports.VERB_USAGE = {
200
200
  value: "<element>",
201
201
  note: "Sibling column a document_id resolves against (its table_name column); must already exist.",
202
202
  },
203
- { flag: "scope", value: "<x_scope>", note: "Owning app scope when it cannot be inferred from the table." },
203
+ {
204
+ flag: "scope",
205
+ value: "<x_scope>",
206
+ note: "Owning app scope. Must match the table's scope unless --cross-scope is passed.",
207
+ },
208
+ {
209
+ flag: "cross-scope",
210
+ note: "Opt in to a column OWNED by --scope, which differs from the table's scope (element becomes <scope>_<name>; the table must allow new fields; --update-set must be in --scope).",
211
+ },
204
212
  { flag: "update-set", value: "<sys_id>", note: "REQUIRED on the live path (only --dry-run works without one)." },
205
213
  DRY_RUN_FLAG,
206
214
  DEBUG_FLAG,
@@ -209,7 +217,10 @@ exports.VERB_USAGE = {
209
217
  gate: "dry-run-flag",
210
218
  gateNote: "--update-set is required unless --dry-run.",
211
219
  example: "dove-sn add-column --table x_cadso_journey --label URL --type url --max-length 1024 --update-set <sys_id>",
212
- notes: ["Exit 2 when the write landed but the read-back does not show the column."],
220
+ notes: [
221
+ "Exit 2 when the write landed but the read-back does not show the column.",
222
+ "Cross-scope: dove-sn add-column --table x_cadso_automate_email_batch --label 'Instance Step' --name instance_step --type reference --reference x_cadso_journey_instance_step --scope x_cadso_journey --cross-scope --update-set <journey set>",
223
+ ],
213
224
  },
214
225
  "set-column": {
215
226
  summary: "Update an EXISTING column's schema (label/mandatory/default/read-only/max-length/dependent-on-field), then verify",
@@ -440,7 +440,13 @@ function buildDescriptors(deps = {}) {
440
440
  "table NAME; element is derived from label unless column.name is given. dependent_on_field names " +
441
441
  "the sibling column a document_id column resolves against (its table_name column) — it must " +
442
442
  "already exist on the table, is verified on the read-back, and is refused when absent. " +
443
- "updateSetSysId is required on the live path. dryRun:true returns the plan with no writes.",
443
+ "scope must match the table's scope unless crossScope:true, which opts in to a column OWNED " +
444
+ "by scope on another app's table (the platform's cross-scope field: element becomes " +
445
+ "<scope>_<name>, the dictionary row and its update-set capture land in scope); the table " +
446
+ "must allow new fields from other scopes (sys_db_object.alter_access) and updateSetSysId " +
447
+ "must belong to scope — both checked on dryRun too, and the stored element + sys_scope are " +
448
+ "read back. updateSetSysId is required on the live path. dryRun:true returns the plan with " +
449
+ "no writes.",
444
450
  shape: schemas_1.addColumnSchema.shape,
445
451
  handler: async function (args) {
446
452
  var p = schemas_1.addColumnSchema.parse(args);
@@ -458,6 +464,7 @@ function buildDescriptors(deps = {}) {
458
464
  table: p.table,
459
465
  column: p.column,
460
466
  scope: p.scope,
467
+ crossScope: p.crossScope,
461
468
  updateSetSysId: p.updateSetSysId,
462
469
  dryRun: p.dryRun,
463
470
  debug: p.debug,
@@ -1890,6 +1890,7 @@ export declare var addColumnSchema: z.ZodObject<{
1890
1890
  dependent_on_field?: string | undefined;
1891
1891
  }>;
1892
1892
  scope: z.ZodOptional<z.ZodString>;
1893
+ crossScope: z.ZodOptional<z.ZodBoolean>;
1893
1894
  updateSetSysId: z.ZodOptional<z.ZodString>;
1894
1895
  dryRun: z.ZodOptional<z.ZodBoolean>;
1895
1896
  debug: z.ZodOptional<z.ZodBoolean>;
@@ -1909,6 +1910,7 @@ export declare var addColumnSchema: z.ZodObject<{
1909
1910
  debug?: boolean | undefined;
1910
1911
  updateSetSysId?: string | undefined;
1911
1912
  scope?: string | undefined;
1913
+ crossScope?: boolean | undefined;
1912
1914
  }, {
1913
1915
  table: string;
1914
1916
  column: {
@@ -1925,6 +1927,7 @@ export declare var addColumnSchema: z.ZodObject<{
1925
1927
  debug?: boolean | undefined;
1926
1928
  updateSetSysId?: string | undefined;
1927
1929
  scope?: string | undefined;
1930
+ crossScope?: boolean | undefined;
1928
1931
  }>;
1929
1932
  export declare var addIndexSchema: z.ZodObject<{
1930
1933
  table: z.ZodString;
@@ -364,6 +364,10 @@ exports.addColumnSchema = zod_1.z.object({
364
364
  table: zod_1.z.string().min(1),
365
365
  column: exports.columnSpecSchema,
366
366
  scope: zod_1.z.string().optional(),
367
+ // Explicit opt-in to a column OWNED by `scope` when that differs from the table's
368
+ // scope (a Journey field on an Automate table). Without it a mismatched scope is
369
+ // refused — on dryRun and live alike — because that is the classic wrong-scope slip.
370
+ crossScope: zod_1.z.boolean().optional(),
367
371
  updateSetSysId: zod_1.z.string().optional(),
368
372
  dryRun: zod_1.z.boolean().optional(),
369
373
  debug: zod_1.z.boolean().optional(),
@@ -16,8 +16,23 @@
16
16
  *
17
17
  * The scope arg passed to `createRecord` is the scope NAME (e.g. "x_cadso_core"),
18
18
  * not the sys_scope sys_id — the server-side changeScope matches on the scope name.
19
- * A column always belongs to its table's scope, so the name is resolved from the
20
- * table; an explicit `scope` override must match it.
19
+ * By default a column belongs to its table's scope, so the name is resolved from the
20
+ * table and an explicit `scope` override must match it.
21
+ *
22
+ * CROSS-SCOPE COLUMNS (`crossScope: true`). ServiceNow lets one app add a column to
23
+ * another app's table — Studio with app = Journey adding a field to an Automate table
24
+ * — and prefixes the element with the OWNING scope: `x_cadso_journey_instance_step`
25
+ * on `x_cadso_automate_email_batch`, dictionary row owned by x_cadso_journey. This is
26
+ * the column-ownership rule for Tenon's layered apps, so it is supported here as an
27
+ * explicit opt-in: `scope` names the column's scope, `crossScope: true` acknowledges it
28
+ * differs from the table's. The insert runs through the same scope-aware op switched
29
+ * to the COLUMN's scope, so the dictionary row and its update-set capture both land
30
+ * in the column's scope. Guards, run on dry-run and live alike: the override scope
31
+ * must exist; the table must allow new fields from other scopes
32
+ * (`sys_db_object.alter_access`); the update set must belong to the column's scope.
33
+ * The element is sent already prefixed (`<scope>_<name>`; an already-prefixed name
34
+ * is accepted as-is) and the element ServiceNow actually stored is read back and
35
+ * reported, together with a `sys_scope` assertion on the read-back row.
21
36
  *
22
37
  * SIZING THE PHYSICAL COLUMN. A max_length carried on the INSERT sets the dictionary
23
38
  * row but NOT the column ServiceNow actually builds — it materialises at the platform
@@ -46,8 +61,19 @@ export interface AddColumnParams {
46
61
  table: string;
47
62
  /** The single column to add. `name` (the element) is optional; derived from label when omitted. */
48
63
  column: ColumnSpec;
49
- /** Scope name or sys_scope sys_id. Must match the table's own scope (a column lives there). */
64
+ /**
65
+ * Scope name or sys_scope sys_id. Must match the table's own scope (a column lives
66
+ * there) — unless `crossScope` is true, in which case it names the scope that will OWN
67
+ * the column (e.g. "x_cadso_journey" for a Journey column on an Automate table).
68
+ */
50
69
  scope?: string;
70
+ /**
71
+ * Explicit opt-in to a column owned by a different scope than its table. Requires
72
+ * `scope`. The element is prefixed with the owning scope (`x_cadso_journey_<name>`),
73
+ * the table must allow new fields from other scopes (sys_db_object.alter_access),
74
+ * and the update set must belong to the column's scope.
75
+ */
76
+ crossScope?: boolean;
51
77
  /** Update set sys_id to capture the insert into. REQUIRED on the live path (dry-run doesn't need it). */
52
78
  updateSetSysId?: string;
53
79
  /** Emit diagnostic detail in the result note. */
@@ -67,6 +93,11 @@ export interface AddColumnResult {
67
93
  label: string;
68
94
  /** Resolved ServiceNow internal_type (friendly -> internal). */
69
95
  internalType: string;
96
+ /**
97
+ * Scope NAME that owns the column ("" on a network-free dry-run). Equals the table's
98
+ * scope unless `crossScope` was used, in which case it is the override scope.
99
+ */
100
+ scope: string;
70
101
  /** sys_id of the sys_dictionary row (the insert's, or the existing row's on "skipped"). */
71
102
  columnSysId: string;
72
103
  /** Update set the write was captured into ("" on dry-run). */
@@ -17,8 +17,23 @@
17
17
  *
18
18
  * The scope arg passed to `createRecord` is the scope NAME (e.g. "x_cadso_core"),
19
19
  * not the sys_scope sys_id — the server-side changeScope matches on the scope name.
20
- * A column always belongs to its table's scope, so the name is resolved from the
21
- * table; an explicit `scope` override must match it.
20
+ * By default a column belongs to its table's scope, so the name is resolved from the
21
+ * table and an explicit `scope` override must match it.
22
+ *
23
+ * CROSS-SCOPE COLUMNS (`crossScope: true`). ServiceNow lets one app add a column to
24
+ * another app's table — Studio with app = Journey adding a field to an Automate table
25
+ * — and prefixes the element with the OWNING scope: `x_cadso_journey_instance_step`
26
+ * on `x_cadso_automate_email_batch`, dictionary row owned by x_cadso_journey. This is
27
+ * the column-ownership rule for Tenon's layered apps, so it is supported here as an
28
+ * explicit opt-in: `scope` names the column's scope, `crossScope: true` acknowledges it
29
+ * differs from the table's. The insert runs through the same scope-aware op switched
30
+ * to the COLUMN's scope, so the dictionary row and its update-set capture both land
31
+ * in the column's scope. Guards, run on dry-run and live alike: the override scope
32
+ * must exist; the table must allow new fields from other scopes
33
+ * (`sys_db_object.alter_access`); the update set must belong to the column's scope.
34
+ * The element is sent already prefixed (`<scope>_<name>`; an already-prefixed name
35
+ * is accepted as-is) and the element ServiceNow actually stored is read back and
36
+ * reported, together with a `sys_scope` assertion on the read-back row.
22
37
  *
23
38
  * SIZING THE PHYSICAL COLUMN. A max_length carried on the INSERT sets the dictionary
24
39
  * row but NOT the column ServiceNow actually builds — it materialises at the platform
@@ -60,6 +75,7 @@ var READ_BACK_FIELDS = [
60
75
  "internal_type",
61
76
  "max_length",
62
77
  "dependent_on_field",
78
+ "sys_scope",
63
79
  ];
64
80
  /** Patch max_length on a sys_dictionary row, captured in the given update set. */
65
81
  async function setMaxLength(client, columnSysId, updateSetSysId, length) {
@@ -121,10 +137,10 @@ function validate(params) {
121
137
  if (!params.column || typeof params.column !== "object")
122
138
  throw new Error("add-column: column is required.");
123
139
  }
124
- /** Resolve the table by name or sys_id; returns its name, sys_id, and sys_scope sys_id. */
140
+ /** Resolve the table by name or sys_id; returns its name, sys_id, scope, and alter_access. */
125
141
  async function resolveTable(client, table) {
126
142
  var query = SYS_ID.test(table) ? "sys_id=" + table : "name=" + table;
127
- var rows = await client.table.query("sys_db_object", query, { limit: 1, fields: ["sys_id", "name", "sys_scope"] });
143
+ var rows = await client.table.query("sys_db_object", query, { limit: 1, fields: ["sys_id", "name", "sys_scope", "alter_access"] });
128
144
  if (rows.length === 0) {
129
145
  throw new Error("add-column: table '" + table + "' not found in sys_db_object.");
130
146
  }
@@ -132,6 +148,126 @@ async function resolveTable(client, table) {
132
148
  name: (0, setField_1.fieldToString)(rows[0].name) || table,
133
149
  sysId: (0, setField_1.fieldToString)(rows[0].sys_id),
134
150
  scopeSysId: (0, setField_1.fieldToString)(rows[0].sys_scope),
151
+ alterAccess: (0, setField_1.fieldToString)(rows[0].alter_access),
152
+ };
153
+ }
154
+ /** Resolve a scope by NAME or sys_id to both; empty strings when not found. */
155
+ async function resolveScope(client, scope) {
156
+ var query = SYS_ID.test(scope) ? "sys_id=" + scope : "scope=" + scope;
157
+ var rows = await client.table.query("sys_scope", query, { limit: 1, fields: ["sys_id", "scope"] });
158
+ if (rows.length === 0)
159
+ return { name: "", sysId: "" };
160
+ return {
161
+ name: (0, setField_1.fieldToString)(rows[0].scope),
162
+ sysId: (0, setField_1.fieldToString)(rows[0].sys_id),
163
+ };
164
+ }
165
+ /** Resolve an update set's owning application (sys_scope sys_id); "" when not found. */
166
+ async function resolveUpdateSetScope(client, updateSetSysId) {
167
+ var rows = await client.table.query("sys_update_set", "sys_id=" + updateSetSysId, { limit: 1, fields: ["sys_id", "name", "application"] });
168
+ if (rows.length === 0)
169
+ return { found: false, applicationSysId: "", name: "" };
170
+ return {
171
+ found: true,
172
+ applicationSysId: (0, setField_1.fieldToString)(rows[0].application),
173
+ name: (0, setField_1.fieldToString)(rows[0].name),
174
+ };
175
+ }
176
+ /**
177
+ * Resolve the table + decide which scope owns the column, running every scope guard.
178
+ * Shared by the dry-run and live paths so a request that would fail live fails the
179
+ * dry-run the same way — a dry-run that skips the guards is worse than none.
180
+ */
181
+ async function planColumnScope(client, params, element) {
182
+ var resolved = await resolveTable(client, params.table);
183
+ var tableScopeName = await resolveScopeName(client, resolved.scopeSysId);
184
+ if (!tableScopeName) {
185
+ throw new Error("add-column: could not resolve the scope name for table '" +
186
+ resolved.name +
187
+ "' (sys_scope " +
188
+ (resolved.scopeSysId || "(none)") +
189
+ ") — needed to scope the insert correctly.");
190
+ }
191
+ var override = params.scope ? params.scope.trim() : "";
192
+ var wantsCross = params.crossScope === true;
193
+ if (wantsCross && !override) {
194
+ throw new Error("add-column: crossScope requires --scope naming the scope that will OWN the column " +
195
+ "(e.g. x_cadso_journey for a Journey column on an Automate table).");
196
+ }
197
+ var sameScope = !override ||
198
+ override === tableScopeName ||
199
+ override === resolved.scopeSysId;
200
+ if (sameScope) {
201
+ return {
202
+ resolved: resolved,
203
+ scopeName: tableScopeName,
204
+ scopeSysId: resolved.scopeSysId,
205
+ crossScope: false,
206
+ element: element,
207
+ };
208
+ }
209
+ // The override names a scope other than the table's. Without the explicit opt-in
210
+ // this is the classic wrong-scope mistake, so refuse — on dry-run and live alike.
211
+ if (!wantsCross) {
212
+ throw new Error("add-column: --scope '" +
213
+ override +
214
+ "' does not match table '" +
215
+ resolved.name +
216
+ "' scope '" +
217
+ tableScopeName +
218
+ "' — a column lives in its table's scope. Omit --scope or set it to '" +
219
+ tableScopeName +
220
+ "'. To add a column OWNED by '" +
221
+ override +
222
+ "' (a cross-scope field, element prefixed '" +
223
+ override +
224
+ "_'), pass --cross-scope.");
225
+ }
226
+ var owner = await resolveScope(client, override);
227
+ if (!owner.name || !owner.sysId) {
228
+ throw new Error("add-column: cross-scope owner '" +
229
+ override +
230
+ "' was not found in sys_scope — pass the scope name (x_cadso_journey) or its sys_id.");
231
+ }
232
+ if (resolved.alterAccess !== "true") {
233
+ throw new Error("add-column: table '" +
234
+ resolved.name +
235
+ "' does not allow new fields from other scopes (sys_db_object.alter_access is '" +
236
+ (resolved.alterAccess || "(empty)") +
237
+ "'). Enable 'Allow new fields' on the table in its own scope, or add the column " +
238
+ "in scope '" +
239
+ tableScopeName +
240
+ "' instead.");
241
+ }
242
+ if (params.updateSetSysId && params.updateSetSysId.trim()) {
243
+ var us = await resolveUpdateSetScope(client, params.updateSetSysId.trim());
244
+ if (!us.found) {
245
+ throw new Error("add-column: update set '" +
246
+ params.updateSetSysId +
247
+ "' was not found in sys_update_set.");
248
+ }
249
+ if (us.applicationSysId !== owner.sysId) {
250
+ throw new Error("add-column: update set '" +
251
+ (us.name || params.updateSetSysId) +
252
+ "' does not belong to the column's scope '" +
253
+ owner.name +
254
+ "' — a cross-scope column is captured in an update set of the scope that OWNS " +
255
+ "it, not the table's. Pass an update set in '" +
256
+ owner.name +
257
+ "'.");
258
+ }
259
+ }
260
+ // ServiceNow stores a cross-scope element as <owner>_<name>. Send it already
261
+ // prefixed so the pre-check, the insert, and the read-back all agree on the one
262
+ // element; an already-prefixed name is accepted as-is.
263
+ var prefix = owner.name + "_";
264
+ var prefixed = element.indexOf(prefix) === 0 ? element : prefix + element;
265
+ return {
266
+ resolved: resolved,
267
+ scopeName: owner.name,
268
+ scopeSysId: owner.sysId,
269
+ crossScope: true,
270
+ element: prefixed,
135
271
  };
136
272
  }
137
273
  /** Resolve a sys_scope sys_id to its scope NAME (e.g. "x_cadso_core"). */
@@ -170,23 +306,47 @@ async function addColumn(params) {
170
306
  "' names the column being added — a column cannot depend on itself.");
171
307
  }
172
308
  if (params.dryRun) {
309
+ // A plain dry-run (no scope named) is pure + deterministic — no network — so it
310
+ // can plan without an instance. Once a scope IS named, the plan depends on the
311
+ // instance (does the table allow it? does the update set match?), so the dry-run
312
+ // runs the SAME guards as the live path and fails the same way. It used to skip
313
+ // them, so a cross-scope request dry-ran clean and only failed live.
314
+ var hasScopeAsk = (params.scope !== undefined && params.scope.trim() !== "") ||
315
+ params.crossScope === true;
316
+ var planned;
317
+ if (hasScopeAsk)
318
+ planned = await planColumnScope(client, params, element);
319
+ var planElement = planned ? planned.element : element;
320
+ var planTable = planned ? planned.resolved.name : params.table;
321
+ var planTableSysId = planned
322
+ ? planned.resolved.sysId
323
+ : SYS_ID.test(params.table)
324
+ ? params.table
325
+ : "";
173
326
  return {
174
327
  status: "dry-run",
175
- table: params.table,
176
- tableSysId: SYS_ID.test(params.table) ? params.table : "",
177
- element: element,
328
+ table: planTable,
329
+ tableSysId: planTableSysId,
330
+ element: planElement,
178
331
  label: col.label,
179
332
  internalType: col.type,
333
+ scope: planned ? planned.scopeName : "",
180
334
  columnSysId: "",
181
335
  updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
182
336
  verified: false,
183
337
  note: "dry-run: no write. Would add column '" +
184
- element +
338
+ planElement +
185
339
  "' (" +
186
340
  col.type +
187
341
  ") to '" +
188
- params.table +
189
- "' via a scope-aware sys_dictionary insert, captured into update set " +
342
+ planTable +
343
+ "'" +
344
+ (planned && planned.crossScope
345
+ ? " OWNED BY scope '" +
346
+ planned.scopeName +
347
+ "' (cross-scope: the table allows new fields and the update set is in that scope)"
348
+ : "") +
349
+ " via a scope-aware sys_dictionary insert, captured into update set " +
190
350
  (params.updateSetSysId ? params.updateSetSysId : "(none provided)") +
191
351
  (wantDependent
192
352
  ? ", dependent on column '" +
@@ -203,31 +363,13 @@ async function addColumn(params) {
203
363
  throw new Error("add-column: updateSetSysId is required on the live path so the sys_dictionary " +
204
364
  "insert is captured in a known update set (dry-run does not need one).");
205
365
  }
206
- var resolved = await resolveTable(client, params.table);
207
- var scopeName = await resolveScopeName(client, resolved.scopeSysId);
208
- if (!scopeName) {
209
- throw new Error("add-column: could not resolve the scope name for table '" +
210
- resolved.name +
211
- "' (sys_scope " +
212
- (resolved.scopeSysId || "(none)") +
213
- ") — needed to scope the insert correctly.");
214
- }
215
- // A column lives in its table's scope. An explicit override must match it (by name
216
- // or sys_id); we never write a column into a scope other than its table's.
217
- if (params.scope && params.scope.trim()) {
218
- var override = params.scope.trim();
219
- if (override !== scopeName && override !== resolved.scopeSysId) {
220
- throw new Error("add-column: --scope '" +
221
- override +
222
- "' does not match table '" +
223
- resolved.name +
224
- "' scope '" +
225
- scopeName +
226
- "' — a column must live in its table's scope. Omit --scope or set it to '" +
227
- scopeName +
228
- "'.");
229
- }
230
- }
366
+ // Resolve the table and decide which scope OWNS the column — the table's by default,
367
+ // the override's under crossScope. Every scope guard runs inside planColumnScope, the
368
+ // same code the dry-run ran, so nothing can pass dry-run and fail here on scope.
369
+ var plan = await planColumnScope(client, params, element);
370
+ var resolved = plan.resolved;
371
+ var scopeName = plan.scopeName;
372
+ element = plan.element;
231
373
  // max_length is deliberately NOT sent on the insert — see the sizing step below.
232
374
  // Reference (and date) columns carry no max_length at all.
233
375
  var wantLength = col.type === "reference" ? "" : col.maxLength;
@@ -289,6 +431,7 @@ async function addColumn(params) {
289
431
  element: existingElement,
290
432
  label: col.label,
291
433
  internalType: existingType || col.type,
434
+ scope: scopeName,
292
435
  columnSysId: existingSysId,
293
436
  updateSetSysId: params.updateSetSysId,
294
437
  verified: drift.length === 0,
@@ -320,7 +463,10 @@ async function addColumn(params) {
320
463
  mandatory: params.column.mandatory === true ? "true" : "false",
321
464
  default_value: typeof params.column.default === "string" ? params.column.default : "",
322
465
  active: "true",
323
- sys_scope: resolved.scopeSysId,
466
+ // The COLUMN's owner — the table's scope by default, the override's under
467
+ // crossScope. The createRecord `scope` below is switched to the same owner so the
468
+ // dictionary row and its update-set capture agree on who owns the column.
469
+ sys_scope: plan.scopeSysId,
324
470
  };
325
471
  // Reference columns carry the target table NAME (not a sys_id) in `reference`.
326
472
  if (col.reference)
@@ -341,11 +487,11 @@ async function addColumn(params) {
341
487
  }
342
488
  catch (e) {
343
489
  return failure(resolved, col, element, params.updateSetSysId, "sys_dictionary insert failed: " +
344
- (e && e.message ? e.message : String(e)));
490
+ (e && e.message ? e.message : String(e)), undefined, scopeName);
345
491
  }
346
492
  var columnSysId = (0, setField_1.fieldToString)(created && created.sys_id);
347
493
  if (!columnSysId) {
348
- return failure(resolved, col, element, params.updateSetSysId, "createRecord returned no sys_id — the insert may not have landed; check the instance.");
494
+ return failure(resolved, col, element, params.updateSetSysId, "createRecord returned no sys_id — the insert may not have landed; check the instance.", undefined, scopeName);
349
495
  }
350
496
  // SIZE THE PHYSICAL COLUMN. A max_length carried on the INSERT sets the dictionary
351
497
  // row but NOT the column ServiceNow actually builds — it materialises at the platform
@@ -389,7 +535,7 @@ async function addColumn(params) {
389
535
  " was created, but sizing/verifying it failed: " +
390
536
  (e && e.message ? e.message : String(e)) +
391
537
  " — the column EXISTS but may not be the size it was declared, so treat it as " +
392
- "unsafe to write to until it is checked on the instance.", columnSysId);
538
+ "unsafe to write to until it is checked on the instance.", columnSysId, scopeName);
393
539
  }
394
540
  // The read-back proves THIS insert landed (by sys_id, not element — ServiceNow can
395
541
  // normalise the element server-side) AND that the column is the size it was asked to
@@ -402,6 +548,23 @@ async function addColumn(params) {
402
548
  var readBackDependent = verified
403
549
  ? (0, setField_1.fieldToString)(rows[0].dependent_on_field)
404
550
  : "";
551
+ var readBackScope = verified ? (0, setField_1.fieldToString)(rows[0].sys_scope) : "";
552
+ // Ownership is part of the column's identity: a cross-scope row that reads back in
553
+ // the TABLE's scope was prefixed for nothing and will ship in the wrong update set.
554
+ // Asserted whenever the instance reports a scope (a read-back that omits it, as in
555
+ // older stubs, is not evidence either way).
556
+ if (verified && readBackScope && readBackScope !== plan.scopeSysId) {
557
+ return failure(resolved, col, element, params.updateSetSysId, "column '" +
558
+ actualElement +
559
+ "' materialised but sys_scope read back as '" +
560
+ readBackScope +
561
+ "', not the requested '" +
562
+ plan.scopeSysId +
563
+ "' (" +
564
+ scopeName +
565
+ ") — the column is owned by the wrong app and its capture will not promote with " +
566
+ "the right scope. Reconcile it on the instance before writing to it.", columnSysId, scopeName);
567
+ }
405
568
  if (verified && wantLength && readBackLength !== wantLength) {
406
569
  return failure(resolved, col, element, params.updateSetSysId, "column '" +
407
570
  actualElement +
@@ -411,7 +574,7 @@ async function addColumn(params) {
411
574
  wantLength +
412
575
  "' — the physical column is NOT the size it was declared, so values over the " +
413
576
  "real limit would be silently truncated. Fix the column on the instance before " +
414
- "writing to it.", columnSysId);
577
+ "writing to it.", columnSysId, scopeName);
415
578
  }
416
579
  if (verified && wantDependent && readBackDependent !== wantDependent) {
417
580
  return failure(resolved, col, element, params.updateSetSysId, "column '" +
@@ -421,13 +584,13 @@ async function addColumn(params) {
421
584
  ", not the requested '" +
422
585
  wantDependent +
423
586
  "' — a document_id with no dependency resolves against nothing. Set it on " +
424
- "the instance (set-column --dependent-on-field) before writing to the column.", columnSysId);
587
+ "the instance (set-column --dependent-on-field) before writing to the column.", columnSysId, scopeName);
425
588
  }
426
589
  if (!verified) {
427
590
  return failure(resolved, col, element, params.updateSetSysId, "createRecord returned sys_id " +
428
591
  columnSysId +
429
592
  " but no sys_dictionary row was found on read-back — the column may not have " +
430
- "materialised; check the instance.", columnSysId);
593
+ "materialised; check the instance.", columnSysId, scopeName);
431
594
  }
432
595
  // Same trust-the-read-back rule as max_length: a column that reads back a different
433
596
  // internal_type than was requested is NOT the column that was asked for. Refuse to
@@ -441,7 +604,7 @@ async function addColumn(params) {
441
604
  "', not the requested '" +
442
605
  col.type +
443
606
  "' — the column that exists is not the column that was asked for. Reconcile it " +
444
- "on the instance before writing to it.", columnSysId);
607
+ "on the instance before writing to it.", columnSysId, scopeName);
445
608
  }
446
609
  var note = "Added column '" +
447
610
  actualElement +
@@ -450,6 +613,9 @@ async function addColumn(params) {
450
613
  ") to " +
451
614
  resolved.name +
452
615
  (wantDependent ? ", dependent on '" + wantDependent + "'" : "") +
616
+ (plan.crossScope
617
+ ? " owned by scope '" + scopeName + "' (cross-scope field)"
618
+ : "") +
453
619
  " — verified present in sys_dictionary, captured into update set " +
454
620
  params.updateSetSysId +
455
621
  (actualElement !== element
@@ -467,7 +633,11 @@ async function addColumn(params) {
467
633
  " scopeName=" +
468
634
  scopeName +
469
635
  " sys_scope=" +
470
- resolved.scopeSysId +
636
+ plan.scopeSysId +
637
+ " crossScope=" +
638
+ String(plan.crossScope) +
639
+ " readBackScope=" +
640
+ (readBackScope || "(none)") +
471
641
  " readBackType=" +
472
642
  (readBackType || "(none)") +
473
643
  "]";
@@ -479,6 +649,7 @@ async function addColumn(params) {
479
649
  element: actualElement,
480
650
  label: col.label,
481
651
  internalType: col.type,
652
+ scope: scopeName,
482
653
  columnSysId: columnSysId,
483
654
  updateSetSysId: params.updateSetSysId,
484
655
  verified: true,
@@ -486,7 +657,7 @@ async function addColumn(params) {
486
657
  };
487
658
  }
488
659
  /** Build a `failed` result (shared by the insert-threw and read-back-empty paths). */
489
- function failure(resolved, col, element, updateSetSysId, note, columnSysId) {
660
+ function failure(resolved, col, element, updateSetSysId, note, columnSysId, scope) {
490
661
  return {
491
662
  status: "failed",
492
663
  table: resolved.name,
@@ -494,6 +665,7 @@ function failure(resolved, col, element, updateSetSysId, note, columnSysId) {
494
665
  element: element,
495
666
  label: col.label,
496
667
  internalType: col.type,
668
+ scope: scope ? scope : "",
497
669
  columnSysId: columnSysId ? columnSysId : "",
498
670
  updateSetSysId: updateSetSysId,
499
671
  verified: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },