toga-ai 1.0.488 → 1.0.489

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.
@@ -41,6 +41,26 @@
41
41
  "timeout": 3000
42
42
  }
43
43
  ]
44
+ },
45
+ {
46
+ "matcher": "Write|Edit|MultiEdit",
47
+ "hooks": [
48
+ {
49
+ "type": "command",
50
+ "command": "node \".claude/hooks/toga/dbchanges2-cluster-isolation.js\"",
51
+ "timeout": 3000
52
+ }
53
+ ]
54
+ },
55
+ {
56
+ "matcher": "Write|Edit|MultiEdit",
57
+ "hooks": [
58
+ {
59
+ "type": "command",
60
+ "command": "node \".claude/hooks/toga/dbchanges2-record-ids.js\"",
61
+ "timeout": 3000
62
+ }
63
+ ]
44
64
  }
45
65
  ],
46
66
  "PostToolUse": [
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-29
9
+ updated: 2026-07-31
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -43,6 +43,9 @@ UI config) — **not just schema**; a schema-only blank produces non-functional
43
43
  `Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any
44
44
  `OtherDatabase.Table` reference is unrunnable in production even though it works locally
45
45
  (see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook).
46
+ **`Core.Records` and `Core.RecordFields` are the only two tables platform-wide with
47
+ team-maintained `id`s** — ask the developer for the next value before inserting (never
48
+ `AUTO_INCREMENT`), and hardcode those `id`s wherever they are referenced, foreign keys included.
46
49
 
47
50
  ## File naming convention (the execution contract)
48
51
 
@@ -189,6 +192,61 @@ literals are stripped first, and table **aliases** (`rf.id`, `sibling.roleId`) a
189
192
  > is **not** subject to it. If the hook ever misfires on legitimate SQL, `DBCHANGES2_ISOLATION_DISABLED=1`
190
193
  > is an escape hatch **for false positives only** — never to land a cross-database query.
191
194
 
195
+ ## `Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the platform
196
+
197
+ These **two tables, and only these two**, have **team-maintained primary keys**. Their `id`
198
+ values are treated as **stable, platform-wide constants**: the team tracks the next available
199
+ value **in the developer chat**, and every environment carries the same `id` for the same record
200
+ / field. Nothing else on the platform may have a hardcoded `id`.
201
+
202
+ This is what makes the *Database isolation* rule above practical — it is the reason a
203
+ `Client_<Tenant>` migration never needs to read `Core` to find a `recordFieldId`.
204
+
205
+ ### Inserting into them — ASK FIRST, never rely on AUTO_INCREMENT
206
+
207
+ **When a change inserts into `Core.Records` or `Core.RecordFields`, stop and ask the developer
208
+ for the next `id` value(s)**, then write them as explicit literals:
209
+
210
+ ```sql
211
+ # Core/2026-07-31a - Add entitlements serviceAddressId field.sql
212
+ # id values 4187, 4188 assigned by the team (developer chat) — do NOT let AUTO_INCREMENT pick.
213
+ INSERT INTO RecordFields (id, uuid, recordId, `field`)
214
+ VALUES (4188, '<pre-generated v4 uuid>', 219, 'serviceAddressId');
215
+ ```
216
+
217
+ **Never omit `id` and let MySQL's `AUTO_INCREMENT` assign it.** The column's `AUTO_INCREMENT`
218
+ definition still exists, but it is **not the source of truth** — allowing it to assign a value
219
+ lets environments drift apart, and every hardcoded reference to that `id` (in other databases,
220
+ in PHP, in other migrations) then points at the wrong row or nothing at all. There is no way to
221
+ detect this from inside the migration; it simply produces silently wrong ACL/field wiring.
222
+
223
+ Because the `id`s must be reserved by a human, **this cannot be guessed or derived** — asking is
224
+ mandatory, not a courtesy. Record the assigned values in a comment at the top of the file so the
225
+ next reader knows they were allocated, not invented.
226
+
227
+ ### Referencing them — hardcode the `id`, including foreign keys
228
+
229
+ Anywhere a query references one of these rows — **and anywhere a foreign key points at them**
230
+ (`recordId`, `recordFieldId`, and their equivalents) — **hardcode the numeric `id`**. Do not look
231
+ it up:
232
+
233
+ ```sql
234
+ # Client_Compass/2026-07-31a - Grant serviceAddress field write.sql
235
+ # recordFieldId 4188 = Core.RecordFields entitlements.serviceAddressId (team-assigned constant)
236
+ INSERT INTO AclFieldPermissions (uuid, recordFieldId, roleId, isWritable)
237
+ VALUES ('<pre-generated v4 uuid>', 4188, 3, 1);
238
+ ```
239
+
240
+ This applies equally to SQL embedded in PHP in `worker2` / `api2` / `_underscore` — a hardcoded
241
+ `recordFieldId` is correct and preferred there too.
242
+
243
+ > **Always comment what the number is.** A bare `4188` is unreadable and unverifiable six months
244
+ > later. Name the record/field it refers to, as above.
245
+
246
+ Note that the `uuid` on these rows is **also** stable and environment-consistent, so
247
+ `WHERE uuid = '<literal>'` is a valid alternative when a readable key is preferred — but the
248
+ `id` is the team-maintained one, and it is what foreign keys store.
249
+
192
250
  ## `_modules` — reusable, opt-in change-sets
193
251
 
194
252
  Some change-sets aren't applied to every client — only to clients that use a given **module**
@@ -295,6 +353,12 @@ its own header.)
295
353
  foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the
296
354
  work into one file per database folder. See *Database isolation* above — enforced by the
297
355
  `dbchanges2-cluster-isolation` hook.
356
+ 9. **Inserting into `Core.Records` or `Core.RecordFields`? ASK the developer for the next `id`.**
357
+ These are the **only two tables on the platform with team-maintained primary keys** (tracked in
358
+ the developer chat). Write the assigned `id` as an explicit literal — **never** let
359
+ `AUTO_INCREMENT` assign it. Conversely, **always hardcode** these `id`s where they are
360
+ referenced, including in foreign keys (`recordId`, `recordFieldId`) from other databases. See
361
+ *`Core.Records` / `Core.RecordFields`* above.
298
362
 
299
363
  ## Bulk data loads — batch, and stage large sets in a temp table
300
364
 
@@ -447,7 +511,16 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
447
511
  tables that `_Model_*` classes map to.
448
512
 
449
513
  ## Change history
450
- - 2026-07-29 — **Added *Database isolationnever query across databases* (HARD RULE) + rule #8.**
514
+ - 2026-07-31 — **Added *`Core.Records` / `Core.RecordFields` the only hardcoded `id`s on the
515
+ platform* + rule #9.** These two tables are the **only** ones platform-wide whose `id` is a
516
+ team-maintained constant: the next available value is tracked **in the developer chat**, so a
517
+ migration inserting into them must **ask the developer for the `id`** and write it as an explicit
518
+ literal — `AUTO_INCREMENT` must never assign it (silent per-environment drift breaks every
519
+ hardcoded reference). Conversely these `id`s **should** be hardcoded wherever referenced,
520
+ including foreign keys (`recordId`, `recordFieldId`) from client databases and in PHP query
521
+ strings. This is what makes *Database isolation* workable — no client migration needs to read
522
+ `Core` to resolve a `recordFieldId`. Enforced by the `dbchanges2-record-ids` hook. (jcardinal)
523
+ - 2026-07-31 — **Added *Database isolation — never query across databases* (HARD RULE) + rule #8.**
451
524
  A `dbchanges2` `.sql` file may only reference tables in the one database its folder targets, and
452
525
  must reference them unqualified; fan-out folders (`Client/`, `Logs_Client/`, `_modules/`) permit
453
526
  no database qualifier at all. Rationale: in production `Core`, `Client_<Tenant>`,
@@ -137,6 +137,30 @@ Enforced mechanically by the `PreToolUse` hook
137
137
  examples, and the approved rewrites: `2.0/apps/dbchanges2/architecture.md` → *Database
138
138
  isolation*. **Applies to `dbchanges2` only — the 1.0 `dbchanges` repo is exempt.**
139
139
 
140
+ ### `Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the platform
141
+
142
+ **These two tables, and only these two, have team-maintained primary keys.** Their `id` values are
143
+ **stable platform-wide constants**, identical in every environment, and the next available value is
144
+ tracked by the team **in the developer chat**. No other table on the platform may have a hardcoded
145
+ `id`.
146
+
147
+ **Inserting into them — ask first.** When a change inserts a row into `Core.Records` or
148
+ `Core.RecordFields`, **stop and ask the developer for the next `id`**, then write it as an explicit
149
+ literal. **Never let `AUTO_INCREMENT` assign it** — the column is still defined that way, but it is
150
+ not the source of truth, and a value it picks silently drifts between environments and invalidates
151
+ every hardcoded reference to that row. The `id` must be reserved by a human, so it cannot be
152
+ guessed or derived — asking is mandatory.
153
+
154
+ **Referencing them — hardcode the `id`.** Anywhere these rows are referenced, and anywhere a
155
+ **foreign key** points at them (`recordId`, `recordFieldId`, and equivalents), use the numeric
156
+ literal rather than a lookup — including in SQL embedded in PHP in `worker2` / `api2` /
157
+ `_underscore`. Always add a comment naming the record/field the number refers to; a bare `4188` is
158
+ unverifiable later.
159
+
160
+ This is also what makes the `dbchanges2` isolation rule above workable: a `Client_<Tenant>` change
161
+ never has to read `Core` to resolve a `recordFieldId`. Full detail and examples:
162
+ `2.0/apps/dbchanges2/architecture.md` → *`Core.Records` / `Core.RecordFields`*.
163
+
140
164
  ## Checking dependencies before touching shared code
141
165
 
142
166
  `dependsOn` in `knowledge/registry.json` means a repo extends or depends on another repo's classes. Before modifying a class in a dependency repo (e.g. `_underscore` core):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.488",
3
+ "version": "1.0.489",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * dbchanges2-cluster-isolation.js — DETERMINISTIC enforcement of the dbchanges2
6
+ * single-database rule.
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * In production, the 2.0 databases are NOT on one server. Core, Client_<Tenant>,
11
+ * Archive_<Tenant>, Logs/Logs_<Tenant>, and Cache each live on an ENTIRELY
12
+ * SEPARATE cluster. A query that joins or sub-selects across those names is
13
+ * physically impossible in production — it cannot resolve the foreign schema.
14
+ *
15
+ * Locally and in non-prod every database happens to sit behind one endpoint, so a
16
+ * cross-database query runs fine there and the migration looks correct right up
17
+ * until it reaches production and dies. That silent local success is exactly why
18
+ * prose alone was not enough and this check is mechanical.
19
+ *
20
+ * THE RULE (hard, no exceptions)
21
+ * ------------------------------
22
+ * A .sql file in dbchanges2 may only reference tables in the ONE database its
23
+ * folder targets. References to that database are UNQUALIFIED. Any qualified
24
+ * `OtherDatabase.Table` reference is a violation — including in fan-out folders
25
+ * (Client/, Logs_Client/, _modules/), where the database name varies per tenant
26
+ * and therefore NOTHING may be qualified.
27
+ *
28
+ * Instead of reading a foreign database to resolve an id, use a hardcoded v4 UUID
29
+ * literal, a slug/natural key that exists inside the target database, or split the
30
+ * work into one file per database folder.
31
+ *
32
+ * SCOPE
33
+ * -----
34
+ * Only .sql files under a dbchanges2 checkout. The 1.0 `dbchanges` repo is
35
+ * explicitly NOT affected (its path is excluded). Every other write passes
36
+ * through untouched.
37
+ *
38
+ * Fail-open: any internal/parse error allows the write. A crashing guard must
39
+ * never brick editing.
40
+ *
41
+ * Escape hatch (false positives ONLY, not for writing cross-database SQL):
42
+ * DBCHANGES2_ISOLATION_DISABLED=1
43
+ */
44
+
45
+ const fs = require('fs');
46
+
47
+ /* ---------- input: read stdin JSON (CC standard), fall back to env ---------- */
48
+ function readPayload() {
49
+ let raw = '';
50
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
51
+ let data = {};
52
+ if (raw && raw.trim()) {
53
+ try { data = JSON.parse(raw); } catch (e) { data = {}; }
54
+ }
55
+ if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
56
+ if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
57
+ try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) {}
58
+ }
59
+ return data;
60
+ }
61
+
62
+ /* Known 2.0 database families. We match against this bounded set rather than a
63
+ * generic `x.y` pattern so ordinary table aliases (`rf.id`, `sibling.roleId`)
64
+ * never trip the check. */
65
+ const DB_FAMILIES = ['Core', 'Client', 'Logs', 'Archive', 'Cache', 'Team'];
66
+
67
+ /* Strip comments and string literals so a database name mentioned in prose or in
68
+ * a quoted value is not reported as a reference. Newlines are preserved so the
69
+ * reported line numbers still match the original file. */
70
+ function stripNonCode(sql) {
71
+ const blanked = (m) => m.replace(/[^\n]/g, ' ');
72
+ return sql
73
+ .replace(/\/\*[\s\S]*?\*\//g, blanked) // /* block comments */
74
+ .replace(/(^|[^\w$])#[^\n]*/g, (m) => m[0] === '#' ? blanked(m) : m[0] + blanked(m.slice(1)))
75
+ .replace(/--[ \t][^\n]*/g, blanked) // -- line comments (MySQL needs the space)
76
+ .replace(/'(?:\\.|''|[^'\\])*'/g, blanked) // 'string literals'
77
+ .replace(/"(?:\\.|""|[^"\\])*"/g, blanked); // "string literals"
78
+ }
79
+
80
+ /* Resolve which database a file targets from its folder, and whether that folder
81
+ * fans out across tenants (in which case no qualifier at all is permitted). */
82
+ function resolveTarget(filePath) {
83
+ const norm = filePath.replace(/\\/g, '/');
84
+ const m = norm.match(/\/dbchanges2\/(.+)$/i);
85
+ if (!m) return null;
86
+ const rel = m[1];
87
+ const seg = rel.split('/')[0];
88
+ if (!seg || seg === rel) return null; // file sits at repo root, no DB folder
89
+ if (/^_modules$/i.test(seg)) {
90
+ return { folder: seg, db: null, fanout: true, why: 'a module change-set applied inside each opted-in client database' };
91
+ }
92
+ if (/^Client$/i.test(seg)) {
93
+ return { folder: seg, db: null, fanout: true, why: 'fan-out to every Client_<Tenant> database' };
94
+ }
95
+ if (/^Logs_Client$/i.test(seg)) {
96
+ return { folder: seg, db: null, fanout: true, why: 'fan-out to every Logs_<Tenant> database' };
97
+ }
98
+ return { folder: seg, db: seg, fanout: false, why: 'the ' + seg + ' database only' };
99
+ }
100
+
101
+ /* Find every qualified reference to a known database family. */
102
+ function findQualifiedRefs(sql) {
103
+ const code = stripNonCode(sql);
104
+ const names = DB_FAMILIES.join('|');
105
+ // `Core`.`Records` / Core.Records / Client_Aig.SalesOrders / USE Core
106
+ const re = new RegExp(
107
+ '`?\\b((?:' + names + ')(?:_[A-Za-z0-9]+)?)`?\\s*\\.\\s*`?[A-Za-z_][A-Za-z0-9_]*`?',
108
+ 'g'
109
+ );
110
+ const useRe = new RegExp('\\bUSE\\s+`?((?:' + names + ')(?:_[A-Za-z0-9]+)?)`?', 'gi');
111
+ const found = [];
112
+ const push = (index, db, text) => {
113
+ const line = code.slice(0, index).split('\n').length;
114
+ found.push({ line, db, text: text.trim() });
115
+ };
116
+ let hit;
117
+ while ((hit = re.exec(code)) !== null) push(hit.index, hit[1], hit[0]);
118
+ while ((hit = useRe.exec(code)) !== null) push(hit.index, hit[1], hit[0]);
119
+ return found;
120
+ }
121
+
122
+ function main() {
123
+ if (process.env.DBCHANGES2_ISOLATION_DISABLED === '1') return;
124
+
125
+ const data = readPayload();
126
+ const tool = data.tool_name || '';
127
+ if (!/^(Write|Edit|MultiEdit)$/.test(tool)) return;
128
+
129
+ const input = data.tool_input || {};
130
+ const filePath = typeof input.file_path === 'string' ? input.file_path : '';
131
+ if (!/\.sql$/i.test(filePath)) return;
132
+
133
+ const target = resolveTarget(filePath);
134
+ if (!target) return; // not a dbchanges2 .sql file (1.0 dbchanges excluded)
135
+ if (/\/HISTORIC\//i.test(filePath.replace(/\\/g, '/'))) return; // archive, never executed
136
+
137
+ // Gather the SQL this call would introduce, across all three tool shapes.
138
+ let sql = '';
139
+ if (typeof input.content === 'string') sql += input.content + '\n';
140
+ if (typeof input.new_string === 'string') sql += input.new_string + '\n';
141
+ if (Array.isArray(input.edits)) {
142
+ for (const e of input.edits) {
143
+ if (e && typeof e.new_string === 'string') sql += e.new_string + '\n';
144
+ }
145
+ }
146
+ if (!sql.trim()) return;
147
+
148
+ const violations = findQualifiedRefs(sql).filter((v) => {
149
+ if (target.fanout) return true; // no qualifier is ever valid here
150
+ return v.db.toLowerCase() !== target.db.toLowerCase();
151
+ });
152
+ if (!violations.length) return;
153
+
154
+ const lines = [];
155
+ lines.push('BLOCKED by dbchanges2 database isolation — this SQL reads across a database boundary.');
156
+ lines.push('');
157
+ lines.push(' File: ' + filePath);
158
+ lines.push(' Folder: ' + target.folder + '/ → targets ' + target.why);
159
+ lines.push('');
160
+ lines.push(' Foreign reference' + (violations.length === 1 ? '' : 's') + ':');
161
+ for (const v of violations.slice(0, 20)) {
162
+ lines.push(' line ' + v.line + ': ' + v.text + ' ← ' + v.db + ' is a different database');
163
+ }
164
+ if (violations.length > 20) lines.push(' … and ' + (violations.length - 20) + ' more');
165
+ lines.push('');
166
+ lines.push('In PRODUCTION, Core / Client_<Tenant> / Archive_<Tenant> / Logs / Cache are on');
167
+ lines.push('ENTIRELY SEPARATE CLUSTERS. This query cannot resolve there — it only appears to');
168
+ lines.push('work locally and in non-prod, where every database shares one endpoint.');
169
+ lines.push('');
170
+ if (target.fanout) {
171
+ lines.push('This folder fans out across tenants, so the database name is not fixed:');
172
+ lines.push('reference tables UNQUALIFIED only — no database prefix at all.');
173
+ } else {
174
+ lines.push('Reference only tables in ' + target.db + ', and reference them UNQUALIFIED.');
175
+ }
176
+ lines.push('');
177
+ lines.push('Rewrite it without the cross-database read:');
178
+ lines.push(' • hardcode a pre-generated v4 UUID literal instead of SELECTing an id;');
179
+ lines.push(' • resolve rows by a slug / natural key that exists in the target database;');
180
+ lines.push(' • split the work into one file per database folder.');
181
+ lines.push('');
182
+ lines.push('See knowledge/2.0/apps/dbchanges2/architecture.md → "Database isolation".');
183
+ lines.push('(The 1.0 dbchanges repo is not subject to this rule.)');
184
+ // A PreToolUse deny (exit 2) surfaces its reason from STDERR — writing to stdout
185
+ // blocks the call but shows the agent nothing, so the explanation is lost.
186
+ process.stderr.write(lines.join('\n') + '\n');
187
+ process.exit(2);
188
+ }
189
+
190
+ try { main(); } catch (e) { /* fail-open */ }
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * dbchanges2-record-ids.js — DETERMINISTIC enforcement of the team-maintained
6
+ * primary keys on Core.Records and Core.RecordFields.
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * These two tables are the ONLY tables on the 2.0 platform whose `id` is a
11
+ * team-maintained constant. The next available value is tracked by the team in the
12
+ * developer chat, and the same row carries the same `id` in every environment —
13
+ * which is what lets every other database (and PHP query) hardcode `recordId` /
14
+ * `recordFieldId` instead of joining out to Core.
15
+ *
16
+ * If a migration omits `id` and lets MySQL's AUTO_INCREMENT assign one, the value
17
+ * differs per environment. Nothing fails at run time: the insert succeeds, and
18
+ * every hardcoded reference to that row silently points at the wrong record or at
19
+ * nothing. That is unrecoverable without a manual audit, so it has to be caught
20
+ * before the file is written.
21
+ *
22
+ * THE RULE
23
+ * --------
24
+ * An INSERT/REPLACE into Records or RecordFields must assign `id` explicitly.
25
+ * The developer must be ASKED for the value first — it cannot be derived, so this
26
+ * hook deliberately offers no way to satisfy it automatically.
27
+ *
28
+ * SCOPE
29
+ * -----
30
+ * .sql files under dbchanges2 whose folder targets Core (or which reference the
31
+ * tables Core-qualified). The 1.0 `dbchanges` repo is NOT affected. Sibling tables
32
+ * whose names merely END in RecordFields (CustomRecordFields, SectionRecordFields)
33
+ * are NOT covered — only the two exact tables.
34
+ *
35
+ * Fail-open on any internal error.
36
+ *
37
+ * Escape hatch (false positives ONLY): DBCHANGES2_RECORD_IDS_DISABLED=1
38
+ */
39
+
40
+ const fs = require('fs');
41
+
42
+ function readPayload() {
43
+ let raw = '';
44
+ try { raw = fs.readFileSync(0, 'utf8'); } catch (e) { /* no stdin */ }
45
+ let data = {};
46
+ if (raw && raw.trim()) {
47
+ try { data = JSON.parse(raw); } catch (e) { data = {}; }
48
+ }
49
+ if (!data.tool_name && process.env.CLAUDE_TOOL_NAME) data.tool_name = process.env.CLAUDE_TOOL_NAME;
50
+ if (!data.tool_input && process.env.CLAUDE_TOOL_INPUT) {
51
+ try { data.tool_input = JSON.parse(process.env.CLAUDE_TOOL_INPUT); } catch (e) {}
52
+ }
53
+ return data;
54
+ }
55
+
56
+ /* Blank out comments and string literals, preserving newlines so reported line
57
+ * numbers still line up with the original file. */
58
+ function stripNonCode(sql) {
59
+ const blanked = (m) => m.replace(/[^\n]/g, ' ');
60
+ return sql
61
+ .replace(/\/\*[\s\S]*?\*\//g, blanked)
62
+ .replace(/(^|[^\w$])#[^\n]*/g, (m) => (m[0] === '#' ? blanked(m) : m[0] + blanked(m.slice(1))))
63
+ .replace(/--[ \t][^\n]*/g, blanked)
64
+ .replace(/'(?:\\.|''|[^'\\])*'/g, blanked)
65
+ .replace(/"(?:\\.|""|[^"\\])*"/g, blanked);
66
+ }
67
+
68
+ /* Which database does this file's folder target? */
69
+ function targetDb(filePath) {
70
+ const norm = filePath.replace(/\\/g, '/');
71
+ const m = norm.match(/\/dbchanges2\/(.+)$/i);
72
+ if (!m) return null;
73
+ const seg = m[1].split('/')[0];
74
+ if (!seg || seg === m[1]) return null;
75
+ return seg;
76
+ }
77
+
78
+ /* Find INSERT/REPLACE statements targeting the two protected tables and report any
79
+ * that do not assign `id`. */
80
+ function findUnassignedIds(sql) {
81
+ const code = stripNonCode(sql);
82
+ // (?<![A-Za-z0-9_`]) prevents matching CustomRecordFields / SectionRecordFields.
83
+ const re = /\b(?:INSERT(?:\s+IGNORE)?|REPLACE)\s+INTO\s+(?:`?Core`?\s*\.\s*)?`?(?<![A-Za-z0-9_])(Records|RecordFields)`?/gi;
84
+ const found = [];
85
+ let hit;
86
+ while ((hit = re.exec(code)) !== null) {
87
+ const table = hit[1];
88
+ const after = code.slice(hit.index + hit[0].length, hit.index + hit[0].length + 2000);
89
+ let assignsId = false;
90
+
91
+ // Form A: INSERT INTO t (col, col, ...) VALUES/SELECT ...
92
+ const colList = after.match(/^\s*\(([\s\S]*?)\)/);
93
+ if (colList) {
94
+ assignsId = colList[1]
95
+ .split(',')
96
+ .map((c) => c.trim().replace(/`/g, '').toLowerCase())
97
+ .includes('id');
98
+ } else {
99
+ // Form B: INSERT INTO t SET id = 4188, ...
100
+ const setClause = after.match(/^\s*SET\b([\s\S]*?)(?:;|$)/i);
101
+ if (setClause) assignsId = /(^|[\s,`])id\s*=/i.test(setClause[1]);
102
+ // Form C: INSERT INTO t SELECT ... — no column list, cannot verify; treat as
103
+ // unassigned so it gets a human look.
104
+ }
105
+
106
+ if (!assignsId) {
107
+ found.push({
108
+ line: code.slice(0, hit.index).split('\n').length,
109
+ table,
110
+ text: hit[0].replace(/\s+/g, ' ').trim(),
111
+ });
112
+ }
113
+ }
114
+ return found;
115
+ }
116
+
117
+ function main() {
118
+ if (process.env.DBCHANGES2_RECORD_IDS_DISABLED === '1') return;
119
+
120
+ const data = readPayload();
121
+ if (!/^(Write|Edit|MultiEdit)$/.test(data.tool_name || '')) return;
122
+
123
+ const input = data.tool_input || {};
124
+ const filePath = typeof input.file_path === 'string' ? input.file_path : '';
125
+ if (!/\.sql$/i.test(filePath)) return;
126
+
127
+ const db = targetDb(filePath);
128
+ if (!db) return; // not a dbchanges2 .sql file
129
+ if (/\/HISTORIC\//i.test(filePath.replace(/\\/g, '/'))) return; // archive, never executed
130
+
131
+ let sql = '';
132
+ if (typeof input.content === 'string') sql += input.content + '\n';
133
+ if (typeof input.new_string === 'string') sql += input.new_string + '\n';
134
+ if (Array.isArray(input.edits)) {
135
+ for (const e of input.edits) {
136
+ if (e && typeof e.new_string === 'string') sql += e.new_string + '\n';
137
+ }
138
+ }
139
+ if (!sql.trim()) return;
140
+
141
+ // Records/RecordFields live in Core. Only inspect files that target Core, or that
142
+ // name the tables Core-qualified (the isolation hook handles the latter's legality).
143
+ if (!/^Core$/i.test(db) && !/`?Core`?\s*\.\s*`?(Records|RecordFields)\b/i.test(stripNonCode(sql))) return;
144
+
145
+ const violations = findUnassignedIds(sql);
146
+ if (!violations.length) return;
147
+
148
+ const out = [];
149
+ out.push('BLOCKED: insert into ' + violations.map((v) => v.table).filter((t, i, a) => a.indexOf(t) === i).join(' / ') +
150
+ ' without an explicit `id`.');
151
+ out.push('');
152
+ out.push(' File: ' + filePath);
153
+ for (const v of violations.slice(0, 20)) {
154
+ out.push(' line ' + v.line + ': ' + v.text + ' ← no `id` assigned');
155
+ }
156
+ if (violations.length > 20) out.push(' … and ' + (violations.length - 20) + ' more');
157
+ out.push('');
158
+ out.push('Core.Records and Core.RecordFields are the ONLY two tables on the platform with');
159
+ out.push('TEAM-MAINTAINED primary keys. The next available id is tracked by the team in the');
160
+ out.push('developer chat, and the same row must carry the same id in EVERY environment —');
161
+ out.push('that is what lets other databases hardcode recordId / recordFieldId instead of');
162
+ out.push('joining out to Core.');
163
+ out.push('');
164
+ out.push('Letting AUTO_INCREMENT assign the id fails silently: the insert succeeds, the id');
165
+ out.push('differs per environment, and every hardcoded reference to that row then points at');
166
+ out.push('the wrong record or at nothing.');
167
+ out.push('');
168
+ out.push('REQUIRED: ask the developer what the next id value(s) are, then write them as');
169
+ out.push('explicit literals and note in a comment that the team assigned them:');
170
+ out.push('');
171
+ out.push(' # id 4188 assigned by the team (developer chat)');
172
+ out.push(' INSERT INTO RecordFields (id, uuid, recordId, `field`)');
173
+ out.push(" VALUES (4188, '<v4 uuid literal>', 219, 'serviceAddressId');");
174
+ out.push('');
175
+ out.push('This value cannot be derived or guessed — you must ask.');
176
+ out.push('See knowledge/2.0/apps/dbchanges2/architecture.md → "Core.Records / Core.RecordFields".');
177
+ // exit 2 surfaces the reason from STDERR; stdout would block silently.
178
+ process.stderr.write(out.join('\n') + '\n');
179
+ process.exit(2);
180
+ }
181
+
182
+ try { main(); } catch (e) { /* fail-open */ }