@jacebenson/jsn 4.3.8-dev.46 → 4.3.8-dev.48

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
@@ -44,6 +44,96 @@ private files and offers a foreground console, not native service registration.
44
44
  See [direct MID installation](docs/mid-direct-install.md) for security boundaries,
45
45
  lifecycle commands and the untested Windows host phases. Docker remains available.
46
46
 
47
+ ## Customization reports
48
+
49
+ ```sh
50
+ jsn customizations capture --label before-upgrade --customer-scope x_acme_app
51
+ jsn customizations list
52
+ jsn customizations show <run_id>
53
+ jsn customizations compare <baseline_id> <new_id> --json
54
+ jsn customizations show <run_id> --get data.coverage
55
+ ```
56
+
57
+ Capture data access uses Table API GET requests only. Captures never run
58
+ background scripts or create, update or delete instance records. Standard
59
+ authentication still applies, including OAuth refresh, local refreshed-credential
60
+ storage and session/GCK bootstrap, which can make authentication POST requests.
61
+ Expired credentials without a usable refresh token require login. Reports are private local JSON files in
62
+ `$JSN_DATA_HOME/customizations`, defaulting to `~/.jsn/customizations`. List,
63
+ show and compare work offline. Interactive terminals offer local report pickers;
64
+ pass explicit IDs with JSON, pipes or `--get`.
65
+
66
+ The bounded inventory joins visible `sys_metadata`, `sys_scope` and
67
+ `sys_upgrade_history_log` records. `ootb_modifications` contains artifacts
68
+ with historical skipped-upgrade evidence, not a verified current OOTB diff.
69
+ Because the broad `sys_metadata` read is an arbitrary `sys_id`-ordered slice
70
+ (994 of 621972 rows on a large instance), it is reported as a **bounded sample,
71
+ not an inventory**: it only ever describes the rows the page budget reached.
72
+ The skipped-upgrade artifacts are inventoried separately and completely for the
73
+ observed history: after the filtered upgrade-history read, every observed
74
+ `sys_update_name` is resolved with chunked GET-only
75
+ `sys_update_nameIN<chunk of names>` lookups on `sys_metadata`, so an artifact
76
+ with skipped-upgrade evidence appears in `ootb_modifications` even when it falls
77
+ far outside the sample. `coverage.upgrade_evidence` reports the skipped rows
78
+ observed, the distinct update names, how many resolved by direct lookup, the
79
+ unresolved name list and any failed lookup chunks.
80
+ `customer_app_artifacts` contains only scopes explicitly declared with repeatable
81
+ `--customer-scope` values or sys_ids. The instance verifies the scope identity,
82
+ not its ownership. Installed/vendor scopes are never automatically called
83
+ customer-built. Other visible artifacts remain `unclassified`, and both those
84
+ categories cover only what the bounded sample read.
85
+
86
+ Each item reports identity, scope, evidence, unknown risk with rationale and a
87
+ review recommendation. Historical OOTB findings include the known skipped-upgrade
88
+ impact and explicit unknown baseline, runtime impact and dependencies. No
89
+ reversion is performed or recommended as safe without further investigation.
90
+
91
+ Defaults are 100 records per page and 10 pages per source. Adjust `--page-size`
92
+ and `--max-pages` within the documented limits. Source coverage compares the rows
93
+ fetched with the Stats API count for the same query and reports both. A page
94
+ shorter than the requested size never implies exhaustion; `exhausted` is reported
95
+ only when the fetched rows account for the available total, and an unavailable
96
+ count is reported as unknown rather than implied completeness. Other statuses
97
+ distinguish missing fields, unstable pagination and read failures. Both the count
98
+ and the rows reflect visible ACL-filtered records, so hidden rows cannot be ruled
99
+ out and reports remain `incomplete`. An empty category does not prove that no
100
+ customizations exist. A 2xx table body without a `result` array is treated as an
101
+ unavailable source, never as an empty table.
102
+
103
+ Comparisons show newly observed, no-longer-observed and changed metadata/evidence.
104
+ They ignore local capture IDs, labels, timestamps and enumeration order.
105
+ They do not prove record creation/deletion or detect source-body changes without
106
+ captured metadata changes. Changed entries render the concrete before/after field
107
+ values, and additions/removals list the artifact's name, table, scope, category
108
+ and evidence basis. When either report has an unavailable or empty source, the
109
+ comparison status is `unreliable` and those differences are labelled as missing
110
+ coverage instead of mass creation or deletion. Compare only reports from the same
111
+ instance.
112
+
113
+ Provider contract: [ServiceNow Customizations Unchanged](https://www.servicenow.com/docs/r/xanadu/platform-administration/customizations-unchanged-related-list.html)
114
+ explains that the `Skipped` disposition preserves customizations. Other skip
115
+ reasons are not treated as equivalent evidence. This command cannot establish a
116
+ trusted baseline from that history alone.
117
+
118
+ The verified provider join is `sys_upgrade_history_log.file_name` to
119
+ `sys_metadata.sys_update_name`, not the log's nonexistent `name` field. Capture
120
+ uses raw values from `sysparm_display_value=all`: disposition `4` or `104`
121
+ means Skipped or Skipped (second pass). Resolution `reviewed_reverted` excludes
122
+ that history row; a present empty resolution is distinct from a missing field.
123
+ The upgrade-history read is filtered server-side to those dispositions so a
124
+ bounded capture spends its pages on preserved-customization evidence instead of
125
+ arbitrary history rows, and `unmatched_upgrade_records` counts only that filtered
126
+ population, not all upgrade history. The filtered history is the unit of the
127
+ inventory claim: every observed `file_name` is then resolved against visible
128
+ `sys_metadata` with chunked GET-only `sys_update_nameIN<chunk>` lookups, so
129
+ skipped artifacts are inventoried from the history instead of from the arbitrary
130
+ `sys_id`-ordered metadata sample. `unmatched_upgrade_records` and
131
+ `coverage.upgrade_evidence.unresolved_update_names` describe only names no
132
+ visible metadata row matched; a failed lookup chunk is reported rather than
133
+ treated as an absent artifact.
134
+ These values were checked against the PDI dictionary, choices and actual joined
135
+ rows. Display labels are not used for classification.
136
+
47
137
  ## Quick Start
48
138
 
49
139
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.8-dev.46",
3
+ "version": "4.3.8-dev.48",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,6 +73,42 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
73
73
  | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
74
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
75
75
 
76
+ ### Customization evidence
77
+
78
+ ```sh
79
+ jsn customizations capture --label review --customer-scope x_acme_app --json
80
+ jsn customizations list --json
81
+ jsn customizations show <run_id> --get data.categories
82
+ jsn customizations compare <baseline_id> <new_id> --json
83
+ ```
84
+
85
+ Capture performs bounded GET-only table reads and saves local reports. It never
86
+ evals or creates, updates or deletes instance records. Standard authentication
87
+ may refresh OAuth tokens, store refreshed credentials locally and bootstrap a
88
+ session/GCK using authentication POSTs. List/show/compare are offline.
89
+ Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
90
+ never infer customer ownership from a non-global scope. The declaration is
91
+ caller evidence, not independent publisher verification. OOTB findings reflect
92
+ historical upgrade skips, not a current verified baseline deviation. The upgrade
93
+ history is read server-side for the skip dispositions and every observed skipped
94
+ `file_name` is resolved against `sys_metadata` by chunked
95
+ `sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
96
+ skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
97
+ slice and is reported as a bounded sample, not an inventory, describing only the
98
+ unclassified and declared-customer categories. Unresolved skipped names and
99
+ failed lookup chunks stay explicit rather than becoming absent artifacts, and a
100
+ resolved artifact can lack a scope name when its scope record was outside the
101
+ bounded `sys_scope` read. Missing
102
+ ACL access, pagination limits and unmatched evidence remain explicit. All
103
+ reports are incomplete; no findings is not proof of no customizations.
104
+ Comparisons cover captured metadata/evidence, not source payloads. Removals mean
105
+ no longer observed, not proven deletion. Reversion impact and dependencies
106
+ remain unknown until separately investigated. Never recommend automatic reversion.
107
+
108
+ Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
109
+ `4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
110
+ excludes that history row. Use raw values, not display labels or other skip reasons.
111
+
76
112
  ### Complete an update set
77
113
 
78
114
  ```bash
package/src/cli.js CHANGED
@@ -70,6 +70,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
70
70
  import { atfCmd } from './commands/atf.js';
71
71
  import { approvalsCmd } from './commands/approvals.js';
72
72
  import { perfCmd } from './commands/perf.js';
73
+ import { customizationsCmd } from './commands/customizations.js';
73
74
  import { midserversCmd } from './commands/midservers.js';
74
75
  import { diffCmd } from './commands/diff.js';
75
76
  import { graphqlCmd } from './commands/graphql.js';
@@ -406,6 +407,7 @@ export function buildCLI() {
406
407
  .command(atfCmd(wrap))
407
408
  .command(approvalsCmd(wrap))
408
409
  .command(perfCmd(wrap))
410
+ .command(customizationsCmd(wrap))
409
411
  .command(midserversCmd(wrap))
410
412
  .command(diffCmd(wrap))
411
413
  .command(graphqlCmd(wrap))
@@ -0,0 +1,70 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { errUsageHint, errUsage } from '../errors.js';
3
+ import { canUsePicker } from '../helpers.js';
4
+ import { paginatedSearch } from '../paginated-search.js';
5
+ import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
6
+
7
+ // Only capture needs an instance. Saved reports never need network access.
8
+ declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
9
+ const listHint = { action: 'list', cmd: 'jsn customizations list', description: 'Browse saved customization captures' };
10
+ const showHint = id => ({ action: 'show', cmd: `jsn customizations show ${id}`, description: 'Review this saved capture' });
11
+ const compareHint = id => ({ action: 'compare', cmd: `jsn customizations compare ${id} OTHER_RUN_ID`, description: 'Compare captured metadata and evidence' });
12
+ function requireReport(id) {
13
+ const report = getReport(id);
14
+ if (!report) throw errUsageHint(`Unknown customization report: ${id}`, 'Run `jsn customizations list` to find saved captures.');
15
+ return report;
16
+ }
17
+ async function pickReport(app, argv, exclude = null, message = 'Select a customization capture') {
18
+ if (!canUsePicker(app, argv)) throw errUsageHint('A report ID is required when prompts are unavailable.', 'Run `jsn customizations list`, then pass the run ID explicitly.');
19
+ const runs = listReports({ limit: 1000 }).filter(run => run.run_id !== exclude);
20
+ if (!runs.length) throw errUsage('No saved customization captures available');
21
+ const picked = await paginatedSearch({ message, totalCount: runs.length, pageSize: 10, source: async (term = '') => runs.filter(run => `${run.run_id} ${run.label} ${run.instance}`.toLowerCase().includes(term.toLowerCase())).map(run => ({ name: `${run.run_id} ${run.label || '(unlabeled)'} ${run.instance}`, value: run })) });
22
+ return picked?.value ? requireReport(picked.value.run_id) : null;
23
+ }
24
+ function show(app, report) {
25
+ app.ok({ ...report, _formatted: formatReport(report) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
26
+ }
27
+ export function customizationsCmd(wrap) {
28
+ return {
29
+ command: 'customizations [subcommand]',
30
+ describe: 'Capture and compare read-only customization evidence locally',
31
+ builder: y => y
32
+ .command({ command: 'capture', describe: 'Capture bounded read-only metadata and historical upgrade evidence',
33
+ builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
34
+ .option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
35
+ .option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
36
+ .option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' }),
37
+ handler: wrap(async (argv, app) => {
38
+ app.requireInstance();
39
+ if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
40
+ const customerScopes = Array.isArray(argv.customerScope) ? argv.customerScope : argv.customerScope ? [argv.customerScope] : [];
41
+ const report = await captureReport({ sdk: app.sdk, instance: app.getEffectiveInstance(), profile: app.session?.profileName || '', label: argv.label || '', customerScopes, pageSize: argv.pageSize, maxPages: argv.maxPages });
42
+ show(app, report);
43
+ }),
44
+ })
45
+ .command({ command: 'list', aliases: ['ls'], describe: 'List saved local captures',
46
+ builder: b => b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Maximum captures to list' }),
47
+ handler: wrap(async (argv, app) => {
48
+ if (canUsePicker(app, argv)) { const report = await pickReport(app, argv); if (report) show(app, report); return; }
49
+ const runs = listReports({ limit: argv.limit });
50
+ app.ok({ runs, count: runs.length, _formatted: formatReportList(runs) }, { summary: `${runs.length} customization capture(s)`, breadcrumbs: [listHint, ...(runs[0] ? [showHint(runs[0].run_id)] : [])] });
51
+ }),
52
+ })
53
+ .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', handler: wrap(async (argv, app) => {
54
+ const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
55
+ if (report) show(app, report);
56
+ }) })
57
+ .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
58
+ if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
59
+ if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
60
+ const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
61
+ if (!baseline) return;
62
+ const newer = argv.new ? requireReport(argv.new) : await pickReport(app, argv, baseline.run_id, 'Select the new capture');
63
+ if (!newer) return;
64
+ if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
65
+ const result = compareReports(baseline, newer);
66
+ app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
67
+ }) }),
68
+ handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
69
+ };
70
+ }
@@ -0,0 +1,463 @@
1
+ // Read-only metadata inventory. Upgrade skips are historical evidence, not a
2
+ // current baseline diff. Scope ownership must be declared, never inferred.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { getVersion } from './commands/version.js';
8
+
9
+ // Raw provider choices: 4 = Skipped, 104 = Skipped (second pass). Other skip
10
+ // dispositions do not establish a preserved customization, so the upgrade
11
+ // history read is filtered server-side and a bounded budget spends its pages
12
+ // on preserved-customization evidence instead of arbitrary history rows.
13
+ export const SKIP_DISPOSITIONS = ['4', '104'];
14
+ const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
15
+ const SOURCES = {
16
+ sys_scope: { fields: 'sys_id,name,scope,sys_class_name', query: 'ORDERBYsys_id', countQuery: '', kind: 'inventory read' },
17
+ sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
18
+ sys_upgrade_history_log: { fields: 'sys_id,file_name,disposition,resolution_status,sys_updated_on', query: `${SKIP_QUERY}^ORDERBYsys_id`, countQuery: SKIP_QUERY, kind: 'inventory read' },
19
+ };
20
+ // The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
21
+ // that can hold hundreds of thousands of rows, so it can only ever be a bounded
22
+ // sample of the instance. Skipped-upgrade artifacts are therefore resolved
23
+ // directly by the update names the filtered history read observed, so every
24
+ // skipped artifact visible on the instance reaches the inventory.
25
+ export const RESOLUTION_SOURCE = 'sys_metadata_name_resolution';
26
+ export const RESOLUTION_CHUNK_SIZE = 25;
27
+ const RESOLUTION_MAX_PAGES = 3;
28
+ const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
29
+ const text = v => String(value(v));
30
+
31
+ // How many rows are available for the same query? The Stats API count is the
32
+ // only evidence that lets a capture claim completeness; without it coverage
33
+ // must stay unknown rather than implied by a page that happened to end.
34
+ async function availableCount(sdk, table, countQuery, fetched) {
35
+ if (typeof sdk.aggregateCount !== 'function') return null;
36
+ let count;
37
+ try {
38
+ count = await sdk.aggregateCount(table, countQuery);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (!Number.isFinite(count) || count < 0) return null;
43
+ // A zero count with rows in hand means the count is unusable, not that the
44
+ // table is empty.
45
+ if (count === 0 && fetched > 0) return null;
46
+ return Math.floor(count);
47
+ }
48
+
49
+ // A per-query count with no zero/short-page guessing: a count of 0 for a name
50
+ // chunk means no visible row matched that chunk, which is real evidence.
51
+ async function statsCount(sdk, table, query) {
52
+ if (typeof sdk.aggregateCount !== 'function') return null;
53
+ try {
54
+ const count = await sdk.aggregateCount(table, query);
55
+ return Number.isFinite(count) && count >= 0 ? Math.floor(count) : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ async function readSource(sdk, table, { fields, query, countQuery, kind }, pageSize, maxPages) {
62
+ const rows = [];
63
+ const seen = new Set();
64
+ const coverage = {
65
+ status: 'limited', fetched: 0, available: null, pages: 0, duplicate_rows: 0, missing_identity: 0,
66
+ query, ...(kind ? { kind } : {}), reason: 'Page limit reached; remaining records unknown.',
67
+ };
68
+ // The strict seam rejects a 2xx body without a result array, so a malformed
69
+ // envelope becomes an unavailable source instead of an empty inventory.
70
+ const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
71
+ let failure = '';
72
+ try {
73
+ for (let page = 0; page < maxPages; page++) {
74
+ const batch = await listFn(table, {
75
+ sysparm_fields: fields, sysparm_query: query,
76
+ sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
77
+ sysparm_limit: String(pageSize), sysparm_offset: String(page * pageSize),
78
+ }, { readOnly: true });
79
+ coverage.pages++;
80
+ if (!Array.isArray(batch)) throw new Error('Invalid table response');
81
+ for (const row of batch) {
82
+ const id = text(row.sys_id);
83
+ if (!id) { coverage.missing_identity++; continue; }
84
+ if (seen.has(id)) { coverage.duplicate_rows++; continue; }
85
+ seen.add(id);
86
+ rows.push(row);
87
+ const required = table === 'sys_metadata' ? ['sys_class_name', 'sys_scope', 'sys_update_name'] : table === 'sys_scope' ? ['scope'] : ['file_name', 'disposition', 'resolution_status'];
88
+ if (required.some(field => !Object.hasOwn(row, field))) coverage.missing_identity++;
89
+ }
90
+ coverage.fetched = rows.length;
91
+ // A short page is not exhaustion: ServiceNow returns short offset pages
92
+ // on large tables. Keep reading within the page budget.
93
+ if (batch.length === 0) break;
94
+ }
95
+ } catch (error) {
96
+ failure = String(error?.message || error);
97
+ }
98
+ if (failure) {
99
+ coverage.status = /401|403|permission|forbidden|access denied/i.test(failure) ? 'permission_denied' : 'unavailable';
100
+ coverage.reason = 'Table read failed; partial results retained. Authentication, ACLs, table availability or network access may limit coverage.';
101
+ return { rows, coverage };
102
+ }
103
+ coverage.available = await availableCount(sdk, table, countQuery, rows.length);
104
+ const duplicateNote = coverage.duplicate_rows
105
+ ? ` Pagination returned ${coverage.duplicate_rows} repeated identit${coverage.duplicate_rows === 1 ? 'y' : 'ies'}; offset paging on large tables can overlap or skip.`
106
+ : '';
107
+ if (coverage.missing_identity) {
108
+ coverage.status = 'missing_fields';
109
+ coverage.reason = 'Some records omitted identity or classification fields; field ACLs or malformed responses may hide evidence.';
110
+ } else if (coverage.available !== null && coverage.fetched >= coverage.available) {
111
+ coverage.status = 'exhausted';
112
+ coverage.reason = `Read all ${coverage.available} rows the Stats API reports for this query. Both the count and the rows reflect visible ACL-filtered records.`;
113
+ } else if (coverage.available !== null) {
114
+ coverage.status = 'limited';
115
+ coverage.reason = `Read ${coverage.fetched} of ${coverage.available} rows the Stats API reports for this query; ${coverage.available - coverage.fetched} row(s) were not read within the page budget.`;
116
+ } else if (coverage.duplicate_rows) {
117
+ coverage.status = 'unstable';
118
+ coverage.reason = 'Repeated identities across pages; pagination may be ignored or records changed during capture. The Stats API count for this query was unavailable, so completeness is unknown.';
119
+ } else {
120
+ coverage.status = 'limited';
121
+ coverage.reason = 'The Stats API count for this query was unavailable, so visible-result completeness cannot be claimed.';
122
+ }
123
+ coverage.reason += duplicateNote;
124
+ return { rows, coverage };
125
+ }
126
+
127
+ // Resolve observed skipped-upgrade update names against visible sys_metadata
128
+ // with chunked GET-only `sys_update_nameIN<chunk>` queries. The broad sample
129
+ // cannot be relied on for this: it is an arbitrary sys_id-ordered slice, so an
130
+ // inventory claim needs the artifacts the history read actually named.
131
+ // Read failures are reported per chunk instead of being hidden behind the
132
+ // sample, and completeness stays anchored to the Stats API count for the exact
133
+ // name queries used, never to a page that happened to end.
134
+ async function resolveUpdateNames(sdk, names) {
135
+ const requested = [...new Set(names)].sort();
136
+ const rows = [];
137
+ const seen = new Set();
138
+ const resolved = new Set();
139
+ const coverage = {
140
+ kind: 'targeted name resolution', status: 'limited', fetched: 0, available: null, pages: 0, chunks: 0,
141
+ query: 'sys_update_nameIN<observed skipped update names>', requested_names: requested.length,
142
+ resolved_names: 0, unresolved_names: 0, failed_chunks: 0, reason: '',
143
+ };
144
+ if (!requested.length) {
145
+ coverage.query = '';
146
+ coverage.reason = 'No skipped upgrade rows were observed, so no update names required resolution.';
147
+ return { rows, resolved, coverage };
148
+ }
149
+ const listFn = typeof sdk.listStrict === 'function' ? sdk.listStrict.bind(sdk) : sdk.list.bind(sdk);
150
+ const failures = [];
151
+ let countSum = 0;
152
+ let countKnown = true;
153
+ for (let start = 0; start < requested.length; start += RESOLUTION_CHUNK_SIZE) {
154
+ const chunk = requested.slice(start, start + RESOLUTION_CHUNK_SIZE);
155
+ const limit = Math.max(chunk.length, 1);
156
+ const query = `sys_update_nameIN${chunk.join(',')}`;
157
+ coverage.chunks++;
158
+ try {
159
+ for (let page = 0; page < RESOLUTION_MAX_PAGES; page++) {
160
+ const batch = await listFn('sys_metadata', {
161
+ sysparm_fields: SOURCES.sys_metadata.fields, sysparm_query: `${query}^ORDERBYsys_id`,
162
+ sysparm_display_value: 'all', sysparm_exclude_reference_link: 'true',
163
+ sysparm_limit: String(limit), sysparm_offset: String(page * limit),
164
+ }, { readOnly: true });
165
+ coverage.pages++;
166
+ if (!Array.isArray(batch)) throw new Error('Invalid table response');
167
+ for (const row of batch) {
168
+ const updateName = text(row.sys_update_name);
169
+ if (updateName && chunk.includes(updateName)) resolved.add(updateName);
170
+ const id = text(row.sys_id);
171
+ if (!id || seen.has(id)) continue;
172
+ seen.add(id);
173
+ rows.push(row);
174
+ }
175
+ coverage.fetched = rows.length;
176
+ if (batch.length === 0) break;
177
+ }
178
+ } catch (error) {
179
+ failures.push(String(error?.message || error));
180
+ }
181
+ const counted = await statsCount(sdk, 'sys_metadata', query);
182
+ if (counted === null) countKnown = false;
183
+ else countSum += counted;
184
+ }
185
+ coverage.available = countKnown ? countSum : null;
186
+ coverage.resolved_names = resolved.size;
187
+ coverage.unresolved_names = requested.length - resolved.size;
188
+ if (failures.length) {
189
+ coverage.status = failures.some(message => /401|403|permission|forbidden|access denied/i.test(message)) ? 'permission_denied' : 'unavailable';
190
+ coverage.failed_chunks = failures.length;
191
+ coverage.reason = `${failures.length} of ${coverage.chunks} update-name lookup chunk(s) failed; rows resolved by the remaining chunks were retained, but some observed skipped artifacts may be missing from this inventory. ACL visibility or a read error may limit this source.`;
192
+ } else if (coverage.available !== null && coverage.fetched >= coverage.available) {
193
+ coverage.status = 'exhausted';
194
+ coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names in ${coverage.chunks} chunk(s) and read all ${coverage.available} row(s) the Stats API reports for those name queries.`;
195
+ } else if (coverage.available !== null) {
196
+ coverage.status = 'limited';
197
+ coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names and read ${coverage.fetched} of ${coverage.available} row(s) the Stats API reports for those name queries; the per-chunk page budget was reached.`;
198
+ } else {
199
+ coverage.status = 'limited';
200
+ coverage.reason = `Resolved ${coverage.resolved_names} of ${requested.length} observed skipped update names; the Stats API count for those name queries was unavailable, so lookup completeness cannot be claimed.`;
201
+ }
202
+ return { rows, resolved, coverage };
203
+ }
204
+
205
+ export const CUSTOMIZATION_SCHEMA_VERSION = 1;
206
+ export function getCustomizationsDir() {
207
+ return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'customizations');
208
+ }
209
+
210
+ function reportFile(id, dir) {
211
+ if (!/^[a-zA-Z0-9_-]+$/.test(String(id))) throw new Error('Invalid customization report ID');
212
+ return path.join(dir, `${id}.json`);
213
+ }
214
+ function validateReport(report) {
215
+ if (report?.capture_schema_version !== CUSTOMIZATION_SCHEMA_VERSION || !report.instance || !report.categories || !report.coverage) throw new Error('Unsupported or invalid customization report schema');
216
+ for (const category of ['ootb_modifications', 'customer_app_artifacts', 'unclassified']) {
217
+ if (!Array.isArray(report.categories[category])) throw new Error('Invalid customization report categories');
218
+ }
219
+ return report;
220
+ }
221
+
222
+ export async function captureReport({ sdk, instance, profile = '', label = '', customerScopes = [], pageSize = 100, maxPages = 10, dir = getCustomizationsDir() }) {
223
+ if (!instance) throw new Error('An authenticated instance is required for customization capture');
224
+ const start_time = new Date().toISOString();
225
+ const capture = await captureCustomizations({ sdk, customerScopes, pageSize, maxPages });
226
+ const report = { run_id: crypto.randomUUID(), instance, profile, label, start_time, finish_time: new Date().toISOString(), jsn_version: getVersion(), capture_schema_version: CUSTOMIZATION_SCHEMA_VERSION, command_options: { customerScopes, pageSize, maxPages }, ...capture };
227
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
228
+ fs.writeFileSync(reportFile(report.run_id, dir), JSON.stringify(report, null, 2), { mode: 0o600, flag: 'wx' });
229
+ return report;
230
+ }
231
+
232
+ export function getReport(id, { dir = getCustomizationsDir() } = {}) {
233
+ try { return validateReport(JSON.parse(fs.readFileSync(reportFile(id, dir), 'utf8'))); }
234
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
235
+ }
236
+ export function listReports({ limit = 50, dir = getCustomizationsDir() } = {}) {
237
+ if (!fs.existsSync(dir)) return [];
238
+ return fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => {
239
+ const report = getReport(name.slice(0, -5), { dir });
240
+ return { run_id: report.run_id, instance: report.instance, profile: report.profile, label: report.label, start_time: report.start_time, status: report.status };
241
+ }).sort((a, b) => b.start_time.localeCompare(a.start_time) || a.run_id.localeCompare(b.run_id)).slice(0, Math.max(1, Math.min(1000, Number(limit) || 50)));
242
+ }
243
+
244
+ // Object key order and evidence enumeration order are not artifact changes.
245
+ function canonical(value) {
246
+ if (Array.isArray(value)) return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
247
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
248
+ return value;
249
+ }
250
+ function inventory(report) {
251
+ return new Map(Object.entries(report.categories).flatMap(([category, items]) => items.map(item => [`${item.identity.table}:${item.identity.sys_id}`, { category, ...item }])));
252
+ }
253
+
254
+ // Sources that cannot be read are not empty sources. A comparison built on top
255
+ // of one must say so instead of presenting missing coverage as real removals.
256
+ function impairedSources(coverage) {
257
+ const sources = coverage?.sources || {};
258
+ return Object.entries(sources).filter(([, source]) => {
259
+ if (source.status === 'unavailable' || source.status === 'permission_denied') return true;
260
+ return source.fetched === 0 && source.available === null;
261
+ }).map(([table, source]) => `${table} (${source.status})`);
262
+ }
263
+
264
+ export function compareReports(baseline, newer) {
265
+ validateReport(baseline); validateReport(newer);
266
+ if (baseline.instance !== newer.instance) throw new Error('Customization reports must describe the same instance');
267
+ const a = inventory(baseline), b = inventory(newer);
268
+ const additions = [], removals = [], changes = [];
269
+ for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
270
+ if (!a.has(id)) additions.push({ id, availability: 'not_observed_in_baseline', item: b.get(id) });
271
+ else if (!b.has(id)) removals.push({ id, availability: 'not_observed_in_new', item: a.get(id) });
272
+ else if (JSON.stringify(canonical(a.get(id))) !== JSON.stringify(canonical(b.get(id)))) changes.push({ id, baseline: a.get(id), new: b.get(id) });
273
+ }
274
+ const impaired = { baseline: impairedSources(baseline.coverage), new: impairedSources(newer.coverage) };
275
+ const unreliable = impaired.baseline.length > 0 || impaired.new.length > 0;
276
+ const unknowns = ['Additions/removals mean newly observed/no longer observed, not proven creation/deletion. ACLs, limits and concurrent changes can affect visibility.', 'Only captured metadata and evidence are compared, not full artifact content or OOTB baselines.', 'Ownership declarations, scope visibility and evidence coverage can cause category changes without an artifact change.'];
277
+ if (unreliable) unknowns.push(`Unavailable or empty sources make observed additions/removals unreliable: baseline [${impaired.baseline.join(', ') || 'none'}], new [${impaired.new.join(', ') || 'none'}]. They may reflect missing coverage rather than a real change and must not be read as mass creation or deletion.`);
278
+ return { baseline_run_id: baseline.run_id, new_run_id: newer.run_id, instance: baseline.instance, status: unreliable ? 'unreliable' : 'incomplete', additions, removals, changes,
279
+ impaired_sources: impaired,
280
+ coverage: { baseline: baseline.coverage, new: newer.coverage },
281
+ unknowns };
282
+ }
283
+
284
+ export function formatReport(report) {
285
+ const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
286
+ for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}${coverage.kind ? ` [${coverage.kind}]` : ''}: ${coverage.status}, ${coverage.fetched} fetched of ${coverage.available === null || coverage.available === undefined ? 'unknown' : coverage.available} available. ${coverage.reason}`);
287
+ for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
288
+ if (report.coverage.unmatched_customer_scopes.length) lines.push(`Unmatched customer declarations: ${report.coverage.unmatched_customer_scopes.join(', ')}`);
289
+ const upgrade = report.coverage.upgrade_evidence;
290
+ if (upgrade) {
291
+ lines.push(`Observed skipped upgrade rows: ${upgrade.skipped_rows_observed}; distinct update names: ${upgrade.update_names_observed}; resolved by direct name lookup: ${upgrade.resolved_by_lookup}; unresolved: ${upgrade.unresolved_update_names.length}${upgrade.lookup_failures ? `; failed lookup chunks: ${upgrade.lookup_failures}` : ''}`);
292
+ if (upgrade.unresolved_update_names.length) lines.push(`Unresolved skipped update names (not visible in sys_metadata): ${upgrade.unresolved_update_names.slice(0, 20).join(', ')}${upgrade.unresolved_update_names.length > 20 ? `, ... and ${upgrade.unresolved_update_names.length - 20} more` : ''}`);
293
+ }
294
+ lines.push(`Unmatched upgrade history records: ${report.coverage.unmatched_upgrade_records}`);
295
+ for (const [category, items] of Object.entries(report.categories)) {
296
+ lines.push('', `${category.toUpperCase()} (${items.length})`);
297
+ for (const item of items) {
298
+ lines.push(`${item.identity.table}:${item.identity.sys_id} ${item.identity.name}`, ` Scope: ${item.scope.scope || item.scope.sys_id || 'unknown'}`, ` Risk: ${item.risk.rating}. ${item.risk.rationale}`);
299
+ for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
300
+ if (item.reversion) {
301
+ for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
302
+ lines.push(` Known dependencies: ${item.reversion.known_dependencies.join(', ') || 'not established'}`);
303
+ for (const unknown of item.reversion.unknowns) lines.push(` Reversion unknown: ${unknown}`);
304
+ }
305
+ for (const unknown of item.unknowns) lines.push(` Unknown: ${unknown}`);
306
+ lines.push(` Recommendation: ${item.recommendation}`);
307
+ }
308
+ }
309
+ return lines.join('\n') + '\n';
310
+ }
311
+ export function formatReportList(runs) {
312
+ return ['RUN ID STATUS LABEL INSTANCE', ...runs.map(run => `${run.run_id} ${run.status} ${run.label || '(unlabeled)'} ${run.instance}`), ...(runs.length ? [] : ['(no customization captures)'])].join('\n') + '\n';
313
+ }
314
+
315
+ // Flatten a captured item into comparable leaf fields so a comparison can show
316
+ // what actually changed instead of only asserting that something did.
317
+ function flattenFields(source, prefix = '', out = {}) {
318
+ const current = prefix || 'value';
319
+ if (source === null || source === undefined) { out[current] = ''; return out; }
320
+ if (Array.isArray(source)) {
321
+ if (!source.length) { out[current] = '(none)'; return out; }
322
+ source.forEach((entry, index) => flattenFields(entry, `${prefix}[${index}]`, out));
323
+ return out;
324
+ }
325
+ if (typeof source === 'object') {
326
+ const keys = Object.keys(source).sort();
327
+ if (!keys.length) { out[current] = '(none)'; return out; }
328
+ for (const key of keys) flattenFields(source[key], prefix ? `${prefix}.${key}` : key, out);
329
+ return out;
330
+ }
331
+ out[current] = String(source);
332
+ return out;
333
+ }
334
+ function fieldChanges(before, after) {
335
+ const a = flattenFields(canonical(before));
336
+ const b = flattenFields(canonical(after));
337
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()
338
+ .filter(field => a[field] !== b[field])
339
+ .map(field => ({ field, before: Object.hasOwn(a, field) ? a[field] : '(absent)', after: Object.hasOwn(b, field) ? b[field] : '(absent)' }));
340
+ }
341
+ function describeComparedItem(item) {
342
+ const identity = item?.identity || {};
343
+ const scope = item?.scope || {};
344
+ return [
345
+ `Artifact: ${identity.table || 'unknown'}:${identity.sys_id || 'unknown'} "${identity.name || identity.update_name || 'unnamed'}"`,
346
+ `Category: ${item?.category || 'unknown'}`,
347
+ `Scope: ${scope.scope || scope.sys_id || 'unknown'}${scope.name ? ` (${scope.name})` : ''}`,
348
+ `Evidence: ${(item?.evidence || []).map(evidence => `${evidence.source}:${evidence.sys_id}`).join(', ') || 'none captured'}`,
349
+ `Risk: ${item?.risk?.rating || 'unknown'}`,
350
+ `Recommendation: ${item?.recommendation || 'not recorded'}`,
351
+ ];
352
+ }
353
+
354
+ export function formatReportComparison(result) {
355
+ const lines = [`Customization comparison: ${result.status}`, `Baseline: ${result.baseline_run_id}`, `New: ${result.new_run_id}`, ...result.unknowns];
356
+ const impaired = result.impaired_sources || { baseline: [], new: [] };
357
+ if (impaired.baseline.length) lines.push(`Unreliable baseline sources: ${impaired.baseline.join(', ')}`);
358
+ if (impaired.new.length) lines.push(`Unreliable new sources: ${impaired.new.join(', ')}`);
359
+ for (const kind of ['additions', 'removals', 'changes']) {
360
+ lines.push('', `${kind.toUpperCase()} (${result[kind].length})`);
361
+ for (const entry of result[kind]) {
362
+ if (kind === 'changes') {
363
+ lines.push(`${entry.id}: ${entry.availability || 'captured metadata/evidence changed'}`);
364
+ const fields = fieldChanges(entry.baseline, entry.new);
365
+ for (const field of fields) lines.push(` ${field.field}: "${field.before}" -> "${field.after}"`);
366
+ if (!fields.length) lines.push(' No compared field differs; evidence ordering or another unrendered value changed.');
367
+ } else {
368
+ lines.push(`${entry.id}: ${entry.availability}`);
369
+ for (const detail of describeComparedItem(entry.item)) lines.push(` ${detail}`);
370
+ }
371
+ }
372
+ }
373
+ for (const side of ['baseline', 'new']) for (const [table, source] of Object.entries(result.coverage[side].sources)) lines.push(`${side} ${table}${source.kind ? ` [${source.kind}]` : ''}: ${source.status}, ${source.fetched} fetched of ${source.available === null || source.available === undefined ? 'unknown' : source.available} available. ${source.reason}`);
374
+ return lines.join('\n') + '\n';
375
+ }
376
+
377
+ export async function captureCustomizations({ sdk, customerScopes = [], pageSize = 100, maxPages = 10 }) {
378
+ if (!sdk) throw new Error('An authenticated SDK is required');
379
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 1000 || !Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100) throw new Error('pageSize must be 1..1000 and maxPages 1..100');
380
+ const sources = {};
381
+ const data = {};
382
+ for (const [table, source] of Object.entries(SOURCES)) {
383
+ const result = await readSource(sdk, table, source, pageSize, maxPages);
384
+ sources[table] = result.coverage;
385
+ data[table] = result.rows;
386
+ }
387
+ // The broad sys_metadata read is a bounded sample, so the artifacts the
388
+ // filtered history read actually named are looked up directly. The source is
389
+ // only registered when there is something to resolve, so a report with no
390
+ // skipped rows has no phantom empty source.
391
+ const observedNames = data.sys_upgrade_history_log.map(row => text(row.file_name)).filter(Boolean);
392
+ const resolution = await resolveUpdateNames(sdk, observedNames);
393
+ if (observedNames.length) {
394
+ sources[RESOLUTION_SOURCE] = resolution.coverage;
395
+ const sampled = new Set(data.sys_metadata.map(row => text(row.sys_id)));
396
+ data.sys_metadata = [...data.sys_metadata, ...resolution.rows.filter(row => !sampled.has(text(row.sys_id)))];
397
+ }
398
+ const scopes = new Map(data.sys_scope.map(row => [text(row.sys_id), row]));
399
+ const declared = new Set(customerScopes);
400
+ const logs = new Map();
401
+ for (const row of data.sys_upgrade_history_log) {
402
+ const name = text(row.file_name);
403
+ if (!logs.has(name)) logs.set(name, []);
404
+ logs.get(name).push(row);
405
+ }
406
+ const categories = { ootb_modifications: [], customer_app_artifacts: [], unclassified: [] };
407
+ for (const row of data.sys_metadata) {
408
+ const scopeId = text(row.sys_scope);
409
+ const scope = scopes.get(scopeId);
410
+ const scopeName = text(scope?.scope);
411
+ const identity = { sys_id: text(row.sys_id), table: text(row.sys_class_name) || 'unknown', name: text(row.sys_name) || text(row.sys_update_name) || text(row.sys_id), update_name: text(row.sys_update_name) };
412
+ const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
413
+ const item = {
414
+ identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,
415
+ risk: { rating: 'unknown', rationale: 'No trusted current OOTB baseline, runtime usage or dependency analysis was captured.' },
416
+ recommendation: 'Review ownership, current configuration and tests before changing this artifact.',
417
+ unknowns: ['Current content and behavioral differences were not captured.', 'Metadata timestamps can change without a behavioral change.'],
418
+ };
419
+ const history = (logs.get(identity.update_name) || []).sort((a, b) => text(b.sys_updated_on).localeCompare(text(a.sys_updated_on)) || text(a.sys_id).localeCompare(text(b.sys_id)));
420
+ // Raw provider choices: 4 = Skipped, 104 = Skipped (second pass).
421
+ // The read is already filtered server-side; this check stays because a
422
+ // server filter is never trusted to be a classification.
423
+ const skipped = history.filter(log => Object.hasOwn(log, 'resolution_status') && SKIP_DISPOSITIONS.includes(text(log.disposition)) && text(log.resolution_status) !== 'reviewed_reverted');
424
+ // A declaration is caller evidence, not platform/vendor provenance. Keep it
425
+ // separate from OOTB findings and record the uncertainty explicitly.
426
+ if (scope && scopeId !== 'global' && scopeName !== 'global' && (declared.has(scopeName) || declared.has(scopeId))) {
427
+ evidence.push({ source: 'capture_options', sys_id: scopeId, basis: 'Customer ownership declared by caller; scope identity verified in sys_scope.' });
428
+ item.unknowns.push('Publisher/customer ownership is not independently verified.');
429
+ item.recommendation = 'Maintain as a declared customer application artifact; review with its owner, not as an OOTB deviation.';
430
+ categories.customer_app_artifacts.push(item);
431
+ } else if (identity.update_name && skipped.length) {
432
+ for (const log of skipped) evidence.push({ source: 'sys_upgrade_history_log', sys_id: text(log.sys_id), basis: 'Upgrade disposition skipped preserves a customization of a vendor-delivered artifact.', disposition: text(log.disposition), resolution_status: text(log.resolution_status) || null });
433
+ item.unknowns.push('Historical upgrade skips do not prove the customization still differs from OOTB. Later reversion or merge may have occurred.');
434
+ item.reversion = {
435
+ known_impact: ['The recorded upgrade did not overwrite the customization.'],
436
+ known_dependencies: [],
437
+ unknowns: ['Current OOTB baseline and exact changes are unavailable.', 'Runtime impact and dependencies of reverting have not been established. An empty dependency list does not mean none exist.'],
438
+ };
439
+ item.recommendation = 'Retain pending review. Compare the current artifact with a trusted baseline, inspect callers and regression-test in non-production before any reversion.';
440
+ categories.ootb_modifications.push(item);
441
+ } else {
442
+ item.unknowns.push('Origin is unclassified. A non-global scope or an update timestamp is not proof of customer ownership or an OOTB modification.');
443
+ categories.unclassified.push(item);
444
+ }
445
+ }
446
+ const visibleNames = new Set(data.sys_metadata.map(row => text(row.sys_update_name)).filter(Boolean));
447
+ const unresolvedNames = [...new Set(observedNames)].filter(name => !visibleNames.has(name)).sort();
448
+ return {
449
+ status: 'incomplete', categories,
450
+ coverage: { complete: false, sources,
451
+ upgrade_evidence: {
452
+ skipped_rows_observed: data.sys_upgrade_history_log.length,
453
+ update_names_observed: new Set(observedNames).size,
454
+ resolved_by_lookup: observedNames.length ? resolution.coverage.resolved_names : 0,
455
+ unresolved_update_names: unresolvedNames,
456
+ lookup_failures: observedNames.length ? resolution.coverage.failed_chunks : 0,
457
+ },
458
+ unmatched_upgrade_records: data.sys_upgrade_history_log.filter(row => !visibleNames.has(text(row.file_name))).length,
459
+ unmatched_customer_scopes: customerScopes.filter(id => !data.sys_scope.some(row => text(row.sys_id) === id || text(row.scope) === id)),
460
+ limitations: ['Inventory covers visible sys_metadata and upgrade history only, not every customization mechanism.', `Upgrade history is read server-side for raw dispositions ${SKIP_DISPOSITIONS.join(' and ')} only; unmatched upgrade counts describe that filtered population, not all upgrade history.`, 'Coverage compares rows fetched with the Stats API count for the same query; a short page never implies exhaustion and an unavailable count leaves completeness unknown.', `The broad sys_metadata source is a bounded sample of an arbitrary sys_id-ordered slice, not an inventory, so unclassified and declared-customer categories only describe sampled artifacts. Skipped-upgrade artifacts are separate: they are resolved by the update names the filtered history read observed (${RESOLUTION_SOURCE}).`, 'Name resolution only looks up artifacts with skipped-upgrade evidence, so customizations with no upgrade-skip history are not discovered by it.', 'A resolved artifact can show a scope sys_id without a scope name when that scope record was outside the bounded sys_scope read.', 'Row/field ACL filtering and concurrent changes cannot be ruled out.', 'No current baseline, source payloads or dependency graph captured. Comparisons detect captured metadata/evidence changes only.', 'OOTB category means historical skipped-upgrade evidence, not a verified current deviation. Customer scopes are caller declarations.'],
461
+ },
462
+ };
463
+ }
package/src/sdk.js CHANGED
@@ -94,7 +94,8 @@ export class SDKClient {
94
94
  }
95
95
 
96
96
  async request(endpoint, opts = {}) {
97
- const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
97
+ const { timeout = this.timeout, readOnly = false, touchLastSeen, ...requestOptions } = opts;
98
+ if (readOnly && (requestOptions.method || 'GET').toUpperCase() !== 'GET') throw new Error('Read-only SDK requests require GET');
98
99
  const controller = new AbortController();
99
100
  const timer = setTimeout(() => controller.abort(), timeout);
100
101
 
@@ -110,6 +111,8 @@ export class SDKClient {
110
111
  if (this.domain) {
111
112
  req.headers.set('X-Now-Domain', this.domain);
112
113
  }
114
+ // readOnly guards the data request, not standard authentication bootstrap.
115
+ // OAuth refresh and session/GCK negotiation are not instance-record writes.
113
116
  await this._setAuth(req);
114
117
 
115
118
  const resp = await fetch(req);
@@ -125,7 +128,10 @@ export class SDKClient {
125
128
 
126
129
  // Stamp last_seen on successful API call
127
130
  try {
128
- if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
131
+ // readOnly suppresses the credential last-seen write unconditionally: a
132
+ // caller passing touchLastSeen cannot override it (GLOSSARY contract).
133
+ const shouldTouch = readOnly ? false : (touchLastSeen ?? true);
134
+ if (shouldTouch && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
129
135
  this.authProvider.touchLastSeen(this.baseURL);
130
136
  }
131
137
  } catch { /* non-fatal */ }
@@ -204,6 +210,22 @@ export class SDKClient {
204
210
  return result?.result || [];
205
211
  }
206
212
 
213
+ // Strict Table API seam. `list` stays best-effort for legacy callers, but a
214
+ // 2xx response without a result array is a malformed envelope, not an empty
215
+ // table. Capture paths use this so an unexpected body becomes an unavailable
216
+ // source instead of a complete-looking empty inventory.
217
+ async listStrict(table, params = {}, opts = {}) {
218
+ const query = new URLSearchParams(params).toString();
219
+ const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
220
+ const response = await this.request(endpoint, { method: 'GET', ...opts });
221
+ if (!response || typeof response !== 'object' || !Array.isArray(response.result)) {
222
+ const error = new Error(`Malformed table response for ${table}: expected a JSON envelope with a result array`);
223
+ error.code = 'malformed_response';
224
+ throw error;
225
+ }
226
+ return response.result;
227
+ }
228
+
207
229
  async get(table, sysID) {
208
230
  const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
209
231
  const result = await this.request(endpoint, { method: 'GET' });