@bongos/core 1.19.772 → 1.19.774

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.
Files changed (97) hide show
  1. package/.bongos-core.json +204 -79
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +4 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.772'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.774'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -45,6 +45,12 @@
45
45
  // // it may be enabled where anyone can sign up. src/modules.js
46
46
  // // refuses the enablement when the join door is `open` and no
47
47
  // // payer is configured.
48
+ // "declarativeSeams": { // OPTIONAL. Per-entry exemptions for the seam-wiring
49
+ // "pollers": ["box-lifecycle"] // guard (task 1003819): "this contributes entry ships no
50
+ // }, // artifact ON PURPOSE". Every key must be a key the module
51
+ // // actually declares in `contributes`, and every entry must be
52
+ // // one it actually lists there — a typo'd exemption is an error,
53
+ // // never a silent pass.
48
54
  // "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
49
55
  // "consumes": ["grade"], // OPTIONAL. seam ports this module resolves (required capabilities).
50
56
  // "prerequisites": { "modules": ["economy"] }, // OPTIONAL. other modules that must be enabled.
@@ -117,6 +123,30 @@ const isWebSurface = (v) =>
117
123
  isObj(v) && isWebSurfaceDir(v) && (v.apexRoot === true ? !('host' in v) : isStr(v.host));
118
124
  const isWebSurfaceArray = (v) => Array.isArray(v) && v.every(isWebSurface);
119
125
 
126
+ // The entries a module declares for one `contributes` seam, normalised to a list.
127
+ // `migrations: true` is the one implicit entry (the modules/<key>/migrations/ dir);
128
+ // every array seam yields its own entries; an empty array declares nothing.
129
+ //
130
+ // SINGLE SOURCE, deliberately. This pair lives here rather than in the seam-wiring
131
+ // guard that also needs it (scripts/gds/seam-wiring-guard.js, task 1003819), because
132
+ // "what entries does this contributes block declare, and what is each one called" is
133
+ // schema knowledge — and two copies would be exactly the second list that drifts,
134
+ // with the drift landing on the guard's exemption cross-check.
135
+ function declaredEntries(seam, value) {
136
+ if (seam === 'migrations') return value === true ? [true] : (Array.isArray(value) ? value : []);
137
+ return Array.isArray(value) ? value : [];
138
+ }
139
+
140
+ // A declared entry's stable display name — what a declarativeSeams exemption names.
141
+ // webSurfaces entries are objects and `migrations: true` is implicit, so neither is
142
+ // its own label; everything else is already a string.
143
+ function entryLabel(seam, entry) {
144
+ if (seam === 'migrations' && entry === true) return 'migrations/';
145
+ if (seam !== 'webSurfaces') return String(entry);
146
+ if (!isObj(entry)) return String(entry);
147
+ return entry.apexRoot === true ? `apexRoot:${entry.dir}` : `${entry.host}(${entry.dir})`;
148
+ }
149
+
120
150
  function validateManifest(obj) {
121
151
  const errors = [];
122
152
  const E = (msg) => errors.push(msg);
@@ -206,6 +236,54 @@ function validateManifest(obj) {
206
236
  }
207
237
  }
208
238
 
239
+ // --- optional declarativeSeams (task 1003819) ---
240
+ // The exemption list for the seam-wiring guard: "this contributes entry ships no
241
+ // artifact on purpose". Some declarations are legitimately declarative-only —
242
+ // dev-box's `box-lifecycle` poller runs on the control plane, not in this process,
243
+ // and its `boxes` uiSection ships no hall widget — so the guard needs a way to say
244
+ // so without a blanket skip that would hide the real cases it exists to catch.
245
+ //
246
+ // Validated HERE, against `contributes`, because this is the one place that holds
247
+ // both: an exemption naming a seam the module does not declare, or an entry it does
248
+ // not list, is an ERROR rather than a no-op. A typo'd exemption that quietly
249
+ // validated would exempt nothing while reading as though it exempted something —
250
+ // the failure mode the closed key sets on `contributes`/`maintenance`/`spend` exist
251
+ // to prevent, in the direction that hides a defect. The guard itself (the on-disk
252
+ // artifact check, and whether an exemption has gone STALE because the artifact
253
+ // now exists) lives in scripts/gds/seam-wiring-guard.js, which needs the filesystem.
254
+ if ('declarativeSeams' in obj) {
255
+ const ds = obj.declarativeSeams;
256
+ if (!isObj(ds)) {
257
+ E('declarativeSeams: must be an object of { "<contributes seam>": ["<entry>", …] }');
258
+ } else {
259
+ const contributes = isObj(obj.contributes) ? obj.contributes : {};
260
+ for (const [seam, entries] of Object.entries(ds)) {
261
+ if (!ALLOWED_CONTRIBUTES.includes(seam)) {
262
+ E(`declarativeSeams.${seam}: unknown seam (allowed: ${ALLOWED_CONTRIBUTES.join(', ')})`);
263
+ continue;
264
+ }
265
+ if (!isStrArray(entries)) {
266
+ E(`declarativeSeams.${seam}: must be an array of non-empty strings`);
267
+ continue;
268
+ }
269
+ if (!(seam in contributes)) {
270
+ E(`declarativeSeams.${seam}: the module does not declare contributes.${seam} — an exemption for a seam it never contributes exempts nothing`);
271
+ continue;
272
+ }
273
+ // Compared against the LABELS, not the raw values, so the check spans every
274
+ // seam — webSurfaces declares objects and `migrations: true` is implicit, and
275
+ // an earlier cut that matched raw strings silently skipped both, letting
276
+ // `declarativeSeams: { webSurfaces: ['bogus'] }` validate clean.
277
+ const labels = declaredEntries(seam, contributes[seam]).map((e) => entryLabel(seam, e));
278
+ for (const entry of entries) {
279
+ if (!labels.includes(entry)) {
280
+ E(`declarativeSeams.${seam}: "${entry}" is not in contributes.${seam} (${labels.join(', ') || 'empty'}) — an exemption must name an entry the module actually declares`);
281
+ }
282
+ }
283
+ }
284
+ }
285
+ }
286
+
209
287
  // --- optional spend declaration (task 1003884) ---
210
288
  if ('spend' in obj) {
211
289
  const sp = obj.spend;
@@ -255,6 +333,6 @@ function assertValidManifest(obj, label = 'module.json') {
255
333
  }
256
334
 
257
335
  module.exports = {
258
- validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
336
+ validateManifest, assertValidManifest, declaredEntries, entryLabel, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
259
337
  MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
260
338
  };
@@ -227,14 +227,17 @@ test('mounting the projects routes registers the `project.catalog` port', async
227
227
  assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
228
228
  });
229
229
 
230
- test('the port declares the catalog write, one presence read, and nothing else', () => {
231
- // Two verbs, and the reason for the ceiling is unchanged: a consumer maps its own row onto
230
+ test('the port declares the catalog write, one presence read, the disconnect delete, and nothing else', () => {
231
+ // Three verbs, and the reason for the ceiling is unchanged: a consumer maps its own row onto
232
232
  // the entry shape and asks about ORIGINS, so the catalog never learns another module's
233
233
  // schema and no module gets a general-purpose handle on these tables. `catalogued` arrived
234
234
  // with the backfill (task 1003581) because healing history without a presence read means
235
- // re-writing every catalog row at every boot. Anything wider than these two is a new
236
- // decision, and this list is where it has to be made.
237
- assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'publish']);
235
+ // re-writing every catalog row at every boot. `forget` arrived with "Disconnect from Bongos
236
+ // entirely" (task 1003249, ADR 0290): a released name and address cannot leave a hidden row
237
+ // behind, and it takes the same {clientId, origin, ownerGithubId} a publish carries, under
238
+ // the same two claim rules. Anything wider is a new decision, and this list is where it has
239
+ // to be made.
240
+ assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'forget', 'publish']);
238
241
  });
239
242
 
240
243
  test('the module manifest DECLARES the port it registers', async () => {
package/tests/fitness.mjs CHANGED
@@ -421,6 +421,28 @@ test('module-migrations-additive actually CATCHES destructive + non-namespaced D
421
421
  // but a BARE drop constraint (no matching re-add) is STILL destructive
422
422
  assert.ok(fitness.moduleMigrationViolations('dev-box', 'ALTER TABLE dev_box_runs DROP CONSTRAINT dev_box_runs_shape_check;').some((v) => /destructive/i.test(v)), 'a bare DROP CONSTRAINT (no re-add) must still be flagged');
423
423
 
424
+ // an ACKNOWLEDGED contract-phase column drop (task 1003092, ADR 0177's expand-then-
425
+ // contract) is the ONE destructive shape allowed: the file carries the same
426
+ // `-- forward-unsafe:` pragma lint-migrations.js requires, the statement is exactly
427
+ // one DROP COLUMN, and the table is the module's own.
428
+ const PRAGMA = '-- forward-unsafe: drop-column on dev_box_runs — the contract phase of a two-step rename\n';
429
+ const contract = fitness.moduleMigrationViolations('dev-box',
430
+ PRAGMA + 'ALTER TABLE dev_box_runs DROP COLUMN IF EXISTS note;\nALTER TABLE dev_box_runs ALTER COLUMN note2 SET NOT NULL;');
431
+ assert.deepEqual(contract, [], 'an acknowledged own-table DROP COLUMN must pass: ' + contract.join('; '));
432
+ // the pragma is per-STATEMENT permission for that one shape, not a per-file switch
433
+ assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'DROP TABLE dev_box_runs;').some((v) => /destructive/i.test(v)), 'DROP TABLE stays flagged even under the pragma');
434
+ assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'TRUNCATE dev_box_runs;').some((v) => /destructive/i.test(v)), 'TRUNCATE stays flagged even under the pragma');
435
+ assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE dev_box_runs DROP CONSTRAINT dev_box_runs_shape_check;').some((v) => /destructive/i.test(v)), 'a bare DROP CONSTRAINT stays flagged even under the pragma');
436
+ assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE dev_box_runs DROP COLUMN note, DROP COLUMN note2;').some((v) => /destructive/i.test(v)), 'a multi-clause drop is not the one allowed shape');
437
+ // and a column drop on a table OUTSIDE the namespace is refused twice over
438
+ const foreign = fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE builders DROP COLUMN note;');
439
+ assert.ok(foreign.some((v) => /destructive/i.test(v)), 'a core-table DROP COLUMN is still destructive under the pragma');
440
+ assert.ok(foreign.some((v) => /namespace/i.test(v)), 'and still a namespace violation');
441
+ // the real contract migration is the shape's first user — it must pass the REAL check
442
+ assert.equal(fitness.moduleMigrationViolations('platform-identity',
443
+ fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'migrations', 'platform_identity_021_drop_legacy_role.sql'), 'utf8')).length, 0,
444
+ 'platform_identity_021 is the acknowledged contract phase and must be clean');
445
+
424
446
  // a created object outside the <key>_ namespace is rejected
425
447
  assert.ok(fitness.moduleMigrationViolations('dev-box', 'CREATE TABLE runs (id int);').some((v) => /namespace/i.test(v)), 'a table outside dev_box_ must be flagged');
426
448
  // a module migration altering a CORE table is rejected (reach core via the seam)
@@ -18,7 +18,8 @@ import { createRequire } from 'node:module';
18
18
  const require = createRequire(import.meta.url);
19
19
  const gsc = require('../src/bongos/goal-scope-check.js');
20
20
  const { touchesOverlap } = require('../src/bongos/path-match.js');
21
- const { globsForModules, isModuleKey, protectedModules } = require('../src/bongos/module-scope-map.js');
21
+ const { PROTECTED_GLOBS: protectedGlobs } = require('../src/bongos/permission-path-check.js');
22
+ const { globsForModules, isModuleKey, protectedModules, SHARED_GLOBS, SHARED_GLOBS_RAW } = require('../src/bongos/module-scope-map.js');
22
23
 
23
24
  let passed = 0;
24
25
  let failed = 0;
@@ -102,6 +103,67 @@ t('the subset does NOT use symmetric touchesOverlap (which would leak the broad
102
103
  'goal-scope-check must REJECT the broad touch despite the symmetric overlap');
103
104
  });
104
105
 
106
+ // ---------- the shared floor: tests/ is inside EVERY wall (task 1003655) ----------
107
+ //
108
+ // `tests/` belongs to no module, and every real task adds a test — so before the
109
+ // shared floor existed the create gate refused any task that honestly declared its
110
+ // own test file, whichever goal it was filed under (idea 1000816). These cases pin
111
+ // both halves: the floor is reachable from any scope, and it did not become a hole.
112
+
113
+ t('shared floor: a test file passes under a goal scoped to an unrelated module', () => {
114
+ const r = gsc.check(['art-pipeline'], ['tests/idea_routing.mjs'], 'metic');
115
+ assert.equal(r.ok, true, r.reason);
116
+ // and it is NOT because art-pipeline owns it — no module does.
117
+ assert.equal(gsc.isCovered('tests/idea_routing.mjs', globsForModules(['art-pipeline'])), false);
118
+ });
119
+
120
+ t('shared floor: sub-Metic too — the floor is scope, not rank (tests/ is unprotected)', () => {
121
+ assert.equal(gsc.check(['game'], ['tests/seats.mjs'], 'thetes').ok, true);
122
+ });
123
+
124
+ t('shared floor: the realistic mixed task — module code PLUS its own test', () => {
125
+ const r = gsc.check(['ideas'], ['modules/ideas/inbox.js', 'tests/idea_routing.mjs'], 'metic');
126
+ assert.equal(r.ok, true, r.reason);
127
+ });
128
+
129
+ t('shared floor is reported separately and NEVER folded into goal_globs', () => {
130
+ // check()'s unknown_scope guard tests goal_globs.length === 0; folding the
131
+ // always-present shared globs in would give a garbage scope a non-empty
132
+ // territory and slip it past that fail-closed edge.
133
+ const s = gsc.scopeSubset(['art-pipeline'], ['tests/x.mjs']);
134
+ assert.equal(s.ok, true);
135
+ assert.deepEqual(s.shared_globs, ['tests/']);
136
+ assert.ok(!s.goal_globs.includes('tests/'), 'goal_globs must stay module-resolved only');
137
+ const garbage = gsc.scopeSubset(['not-a-real-module'], ['tests/x.mjs']);
138
+ assert.equal(garbage.goal_globs.length, 0, 'an unknown scope still resolves to zero territory');
139
+ });
140
+
141
+ t('shared floor does not defeat the other fail-closed edges', () => {
142
+ assert.equal(gsc.check(['not-a-real-module'], ['tests/x.mjs'], 'metic').code, 'unknown_scope');
143
+ assert.equal(gsc.check([], ['tests/x.mjs'], 'metic').code, 'empty_scope');
144
+ assert.equal(gsc.check(['art-pipeline'], [], 'metic').code, 'empty_touches');
145
+ });
146
+
147
+ t('shared floor is NARROW: it opens tests/ and nothing else', () => {
148
+ assert.deepEqual([...SHARED_GLOBS], ['tests/']);
149
+ // the sibling roots that DO have owners must still be walled to their owner
150
+ assert.equal(gsc.check(['art-pipeline'], ['.claude/skills/status/SKILL.md'], 'metic').code, 'out_of_scope');
151
+ assert.equal(gsc.check(['art-pipeline'], ['docs/adr/0288-x.md'], 'metic').code, 'out_of_scope');
152
+ assert.equal(gsc.check(['art-pipeline'], ['src/bongos/auth.js'], 'metic').code, 'out_of_scope');
153
+ // …and a lookalike sibling dir is not swept in by the name prefix
154
+ assert.equal(gsc.check(['art-pipeline'], ['tests-e2e/x.mjs'], 'metic').code, 'out_of_scope');
155
+ });
156
+
157
+ t('shared floor is DERIVED fail-closed: a protected glob could never take effect', () => {
158
+ // The exported list filters its own literal against PROTECTED_GLOBS, so a future
159
+ // edit adding e.g. 'migrations/' would drop out rather than widen every goal's
160
+ // wall into the trust core. Today the raw literal is already clean.
161
+ assert.deepEqual([...SHARED_GLOBS], SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], protectedGlobs)));
162
+ for (const g of SHARED_GLOBS) {
163
+ assert.equal(touchesOverlap([g], protectedGlobs), false, `shared glob ${g} overlaps the protected core`);
164
+ }
165
+ });
166
+
105
167
  // ---------- fail-closed edges ----------
106
168
 
107
169
  t('empty-touches reject: a task with no touches[] cannot be scope-checked', () => {
@@ -127,7 +127,11 @@ test('a toggle is a role=switch with aria-checked, and the page script keeps dat
127
127
  assert.match(fn[0], /data-on="\$\{on \? '1' : '0'\}"/, 'and data-on stays, because every delegated click handler reads it');
128
128
  assert.match(fn[0], /sw__track[\s\S]*sw__knob/, 'drawn as a track and a knob');
129
129
  assert.doesNotMatch(settingsJs, /aria-pressed/, 'no aria-pressed toggles are left on the page');
130
- for (const fnName of ['renderSounds', 'renderRender', 'renderCrossProject', 'privacyRow']) {
130
+ // renderRender is deliberately absent: the Rendering panel stopped being a
131
+ // toggle in task 1002518 (four rungs, not on/off), so it became a radio group
132
+ // in settings-render.js — covered by the radio assertions and the wiring test
133
+ // below, not by the switch contract.
134
+ for (const fnName of ['renderSounds', 'renderCrossProject', 'privacyRow']) {
131
135
  const body = settingsJs.match(new RegExp(`function ${fnName}\\([\\s\\S]*?\\n \\}\\n`));
132
136
  assert.ok(body && /switchHtml\(/.test(body[0]), `${fnName} renders the switch`);
133
137
  }
@@ -199,6 +203,58 @@ test('the interaction panel stays out of settings.js, which is against the size
199
203
  assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
200
204
  });
201
205
 
206
+ // ---- the Rendering panel, carved out the same way (task 1002518) -----------
207
+ //
208
+ // Same two-file shape as the interaction panel above, and the same reason: the
209
+ // knob became a four-rung ladder with a DERIVED default, which is more UI than
210
+ // settings.js has room for. The extra fact this panel has to keep true is that
211
+ // its "default" row explains itself — the default is computed from another
212
+ // panel's answers, so a bare "default" tag would name a choice without naming
213
+ // who made it.
214
+
215
+ test('the rendering panel is wired end to end across its two files', () => {
216
+ const html = read('settings.html');
217
+ const panelJs = read('settings-render.js');
218
+
219
+ const section = html.match(/<section class="scroll" id="render-scroll"[^>]*>/);
220
+ assert.ok(section, 'the panel has a section');
221
+ assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
222
+ assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds');
223
+ assert.match(html, /<script src="\/builders\/settings-render\.js/, 'the page loads the panel script');
224
+
225
+ assert.match(panelJs, /\$\('#render-scroll'\)\.hidden = false/, 'the script reveals its own section');
226
+ assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
227
+
228
+ // settings.js keeps ONLY the anchor entry.
229
+ assert.match(settingsJs, /'rendering': 'render-scroll'/, 'the deep-link anchor stays in settings.js');
230
+ assert.doesNotMatch(settingsJs, /function renderRender\(/, 'the renderer moved out and must not come back inline');
231
+ assert.doesNotMatch(settingsJs, /loadRender\(\)/, 'and settings.js no longer calls it');
232
+ });
233
+
234
+ test('the rendering panel is a radio group, wired on change, and can clear the override', () => {
235
+ const panelJs = read('settings-render.js');
236
+ const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
237
+ assert.ok(render, 'render is a whole function');
238
+ assert.match(render[0], /<input class="pradio" type="radio" name="render-level"/, 'a real radio group');
239
+ assert.match(render[0], /<label class="prow prow--pick"/, 'the whole row is the radio\'s label');
240
+ const setup = panelJs.match(/async function setup\([\s\S]*?\n \}\n/);
241
+ assert.match(setup[0], /addEventListener\('change'/, 'a radio fires change, not click');
242
+ assert.match(setup[0], /closest\('\.pradio'\)/);
243
+ // The sentinel row must send null, not a level: pinning the rung that happens
244
+ // to be derived today would silently stop tracking the interaction profile.
245
+ assert.match(panelJs, /widgets: value === USE_DEFAULT \? null : value/,
246
+ 'choosing "match my profile" CLEARS the override rather than pinning a level');
247
+ });
248
+
249
+ test('the derived default explains itself, because it is computed elsewhere', () => {
250
+ // Unlike every other picker on the page, "default" here is not a constant —
251
+ // render-prefs.js derives it from the interaction profile. A row that said
252
+ // only "default" would name a choice the reader never made and cannot see.
253
+ const panelJs = read('settings-render.js');
254
+ assert.match(panelJs, /derived_reason/, 'the row states WHY the default landed where it did');
255
+ assert.match(panelJs, /derived_level/, 'and WHICH rung that is');
256
+ });
257
+
202
258
  test('a default value is named once, by the API, not twice by the row', () => {
203
259
  // The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
204
260
  // already ends every default value's LABEL with "(default)", and the row also
@@ -0,0 +1,105 @@
1
+ // tests/hub_disconnect_ui.mjs
2
+ //
3
+ // The manage page's "Disconnect from Bongos entirely" card (task 1003249, ADR 0290).
4
+ //
5
+ // "Take it offline" is reversible and says so; this is the owner's TRUE delete, so it lives in
6
+ // its own card after the ender — never folded into it, where the reversible control could read
7
+ // as the destructive one. What these pins hold:
8
+ // • the card is real markup, after "Take it offline", with its fold a SIBLING of the volatile
9
+ // slot (the #mDelWhat shape), itemising what is erased and what is kept, and saying it
10
+ // cannot be undone — with no wall-clock promise
11
+ // • the ask is the name typed back; the act re-checks it at click time and SENDS it, because
12
+ // the route checks it again (confirm_slug)
13
+ // • the minute tick never repaints over a half-typed name (a project-scoped guard), and an open
14
+ // run is named rather than offering a button the server would refuse
15
+ // • the copy that said nothing on this page erases a project was true until this card: it now
16
+ // scopes that promise to "Take it offline", and the fold points at the card
17
+ // • the design kit's stub answers the ask, so the probe drives it end to end
18
+ //
19
+ // Run: node tests/hub_disconnect_ui.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
29
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
30
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
31
+ const fn = (name) => (SCRIPT.match(new RegExp(`function ${name}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n \\}`)) || [, ''])[1];
32
+ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
33
+
34
+ test('the card is real markup, after "Take it offline", with the fold outside the volatile slot', () => {
35
+ const del = HUB.indexOf('id="mDelete"');
36
+ const card = (HUB.match(/<div class="card mSec" id="mDisconnect">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
37
+ assert.ok(card, '#mDisconnect must be extractable');
38
+ assert.ok(del > 0 && HUB.indexOf('id="mDisconnect"') > del, 'the true delete comes after the reversible ender');
39
+ assert.match(card, /<h2>Disconnect from Bongos entirely<\/h2>/);
40
+ const slot = card.indexOf('id="mDisc"'), fold = card.indexOf('id="mDiscWhat"');
41
+ assert.ok(slot > 0 && fold > slot, 'the fold is a sibling after the slot, so no re-render can drop it');
42
+ });
43
+
44
+ test('the fold itemises what is erased and what is kept, says it cannot be undone, and promises no time', () => {
45
+ const fold = (HUB.match(/<details class="finePrint" id="mDiscWhat">[\s\S]*?<\/details>/) || [''])[0];
46
+ assert.ok(fold);
47
+ assert.match(fold, /<summary>What this erases — and what it keeps<\/summary>/);
48
+ for (const erased of [/database/, /nightly backup/, /place on the map/, /members/, /name and web address are released/]) {
49
+ assert.match(fold, erased, `names what is erased: ${erased}`);
50
+ }
51
+ assert.match(fold, /<b>Kept:<\/b> your repository on GitHub/);
52
+ assert.match(fold, /GitHub sign-in app/, 'the one thing GitHub will not let the platform remove is named, with where to remove it');
53
+ assert.match(fold, /cannot be undone/);
54
+ assert.match(fold, /<b>Take it offline<\/b> above/, 'the reversible alternative is pointed at');
55
+ assert.doesNotMatch(fold, PROMISE);
56
+ });
57
+
58
+ test('the ask is the name typed back, and the act re-checks it and SENDS it for the server to check again', () => {
59
+ const ask = fn('askDisconnect');
60
+ assert.ok(ask, 'askDisconnect() must be extractable');
61
+ assert.match(ask, /mDiscAsking = inst\.slug;/);
62
+ assert.match(ask, /Type the project’s name to confirm/);
63
+ assert.match(ask, /id="mDiscName"/);
64
+ assert.match(ask, /data-disc-yes disabled/);
65
+ assert.match(ask, /data-disc-no/);
66
+ assert.match(ask, /cannot be undone/);
67
+ const act = fn('doDisconnect');
68
+ assert.match(act, /if \(!inst \|\| !discTypedMatches\(\)\) return;/, 'an enabled-LOOKING button cannot act');
69
+ assert.match(act, /'\/disconnect'/);
70
+ assert.match(act, /JSON\.stringify\(\{ confirm_slug: /, 'the typed name travels as confirm_slug');
71
+ assert.match(act, /loadManage\(true\)/, 'the page re-reads the project so the queued run shows');
72
+ });
73
+
74
+ test('the render guards a half-typed name per project, names an open run, and offers the button otherwise', () => {
75
+ const render = fn('renderDisconnect');
76
+ assert.ok(render, 'renderDisconnect() must be extractable');
77
+ assert.match(render, /if \(mDiscAsking && mDiscAsking === inst\.slug\) return;/);
78
+ assert.match(render, /open\.action === 'disconnect'/);
79
+ assert.match(render, /This project is being disconnected\./);
80
+ assert.match(render, /Disconnecting waits for the current /, 'another open run is named, no button the server would refuse');
81
+ assert.match(render, /data-disc-ask/);
82
+ assert.doesNotMatch(render, PROMISE);
83
+ assert.match(SCRIPT, /renderDelete\(inst\);\n[\s\S]{0,200}renderDisconnect\(inst\);/, 'rendered on the same fetch-free tick as the ender');
84
+ });
85
+
86
+ test('the ender no longer claims nothing on the page erases — it scopes that to itself and points at the card', () => {
87
+ const truth = fn('teardownTruth');
88
+ assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both shapes');
89
+ assert.doesNotMatch(HUB, /Nothing here erases a project/);
90
+ const delFold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
91
+ assert.doesNotMatch(delFold, /no permanent-delete button/i, 'the capability exists now');
92
+ assert.match(delFold, /<b>Disconnect from Bongos entirely<\/b> below/);
93
+ assert.match(fn('renderDelete'), /open_intent\.action === 'disconnect'/, 'a queued disconnect is not offered a teardown the server would refuse');
94
+ });
95
+
96
+ test('the design kit stub answers the ask, and a probe drives it end to end', () => {
97
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
98
+ assert.match(stub, /\(teardown\|restart\|domain\|disconnect\)/);
99
+ assert.match(stub, /disconnect: \{ queued: true, message: /);
100
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
101
+ const p = probes.find((x) => /disconnect/.test(x.name));
102
+ assert.ok(p, 'a disconnect probe exists');
103
+ assert.ok(p.steps.some((s) => s[0] === 'fill' && s[1] === '#mDiscName'));
104
+ assert.ok(p.steps.some((s) => s[0] === 'click' && s[1] === '[data-disc-yes]'));
105
+ });
@@ -89,6 +89,50 @@ t('falls back to the task row for title/value when the crumb lacks them', () =>
89
89
  assert.ok(/From task row/.test(env.html), 'title should come from the fresh task row');
90
90
  });
91
91
 
92
+ // ── the card-richness rung survives the async land (task 1002518) ────────────────────
93
+ //
94
+ // This is the path the ladder is easiest to lose on. The card a CI land renders is
95
+ // built minutes later, in another process, from the breadcrumb alone — so if the
96
+ // crumb does not carry the rung the shipping session resolved, the shipped card
97
+ // silently reverts to the default rendering and the builder's choice is honoured
98
+ // everywhere EXCEPT the one card that says the work actually landed.
99
+
100
+ t('a crumb carrying cardFlags renders at that rung — rich opens the summary', () => {
101
+ const flags = { level: 'rich', showCard: true, showWidget: true, expanded: true };
102
+ const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
103
+ assert.equal(env.widgets_enabled, true);
104
+ assert.match(env.html, /<div style="display:block;margin-top:0\.6rem/, 'the rich rung opens the summary on arrival');
105
+ });
106
+
107
+ t('cardFlags at a non-widget rung writes no widget, whatever the legacy boolean says', () => {
108
+ // widgetsEnabled:true is left on the crumb deliberately — cardFlags must WIN.
109
+ // A crumb written by this CLI carries both, so the precedence is what decides
110
+ // the render, not which field happens to be present.
111
+ const flags = { level: 'text', showCard: true, showWidget: false, expanded: false };
112
+ const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled: true, cardFlags: flags }, { status: 'shipped' });
113
+ assert.equal(env.widgets_enabled, false, 'the rung decides, not the legacy boolean');
114
+ assert.ok(env.markdown && /Shipped/.test(env.markdown), 'the monospace card still carries the land');
115
+ });
116
+
117
+ t('the standard rung leaves the summary folded — expanded is not assumed from showWidget', () => {
118
+ const flags = { level: 'standard', showCard: true, showWidget: true, expanded: false };
119
+ const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
120
+ assert.equal(env.widgets_enabled, true);
121
+ assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'folded, as the standard rung means');
122
+ });
123
+
124
+ t('a crumb WITHOUT cardFlags still reads the legacy boolean — older crumbs on disk', () => {
125
+ // A breadcrumb written before this shipped is sitting on someone's machine right
126
+ // now, and land-watch will read it. It must render exactly as it always did.
127
+ for (const widgetsEnabled of [true, false]) {
128
+ const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled, cardFlags: undefined }, { status: 'shipped' });
129
+ assert.equal(env.widgets_enabled, widgetsEnabled, `legacy crumb (${widgetsEnabled}) keeps its meaning`);
130
+ if (widgetsEnabled) {
131
+ assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'a legacy crumb never renders expanded');
132
+ }
133
+ }
134
+ });
135
+
92
136
  // ── watch() orchestration (injected deps — no disk, no network) ──────────────────────
93
137
  await ta('emits the NEWEST shipped card, keeps confirmed, drops the rest — one card per run', async () => {
94
138
  const removed = [];
@@ -91,45 +91,104 @@ for (const m of EXPAND_MIGRATIONS) {
91
91
  });
92
92
  }
93
93
 
94
- test('the value mapping is one shared function, not repeated inline', () => {
95
- // Route, sender and trigger must agree that 'builder' ⇄ 'member'; three copies of a
96
- // ternary is how they would stop agreeing.
97
- const pi = read('modules/platform-identity/platform-identity.js');
98
- assert.match(pi, /function membershipKindFromLegacy\(/);
99
- assert.match(pi, /function legacyRoleFromKind\(/);
100
- const sso = read('modules/platform-identity/routes/sso.js');
101
- assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
102
- 'the check-in translates the legacy field through the shared mapping');
94
+ test('the expand-phase trigger mapped the value pair in BOTH directions (history, still readable)', () => {
95
+ // 010 is history now — 021 dropped the trigger — but the expand migration stays
96
+ // in the tree (a from-scratch replay must still match), so its shape stays pinned:
97
+ // this is the trigger the no-DEFAULT rule above exists for.
103
98
  const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
104
- assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the same pair');
105
- assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/);
99
+ assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the legacy value in');
100
+ assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/, 'and the new value out');
106
101
  });
107
102
 
108
- test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
103
+ test('the sender writes ONE spelling, computing the kind once (the window is closed)', () => {
109
104
  // task 1003206: the auth flows are auth.js + auth-*.js now — read the family.
105
+ // During the window (task 1003090) this body carried BOTH spellings; task 1003092
106
+ // closed it. The legacy field must not come back: the hub schema is strict, so
107
+ // re-adding it would turn every federated check-in into a 400.
110
108
  const auth = authSource();
111
109
  const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
112
- assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
113
- assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
114
- 'and the legacy field too — an un-upgraded hub would otherwise drop the owner flag');
110
+ assert.match(body, /membership_kind: kind/, 'the field goes on the wire');
111
+ assert.ok(!/\brole:/.test(body),
112
+ 'the legacy field is gone from the wire — the hub no longer accepts it (task 1003092)');
115
113
  assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
116
- 'the kind is computed ONCE per check-in, not per field');
114
+ 'the kind is computed ONCE per check-in');
115
+ });
116
+
117
+ test('the translator functions are gone from the domain module (nothing left to translate)', () => {
118
+ const pi = read('modules/platform-identity/platform-identity.js');
119
+ assert.ok(!/membershipKindFromLegacy/.test(pi), 'membershipKindFromLegacy was the window; the window is closed');
120
+ assert.ok(!/legacyRoleFromKind/.test(pi), 'legacyRoleFromKind likewise');
121
+ const sso = read('modules/platform-identity/routes/sso.js');
122
+ assert.ok(!/membershipKindFromLegacy/.test(sso), 'the route no longer translates anything');
117
123
  });
118
124
 
119
- // ── THE CONTRACT PHASE — the test of the shim's own removal (task 1003089) ─────
125
+ // ── THE CONTRACT PHASES — the tests of the shims' own removal ──────────────────
120
126
  //
121
127
  // ADR 0174's Lineage lesson: a time-boxed shim outlives its release unless its
122
- // removal ships with the test that says it is gone. core_240 closed the GOAL
123
- // membership window — the trigger, the function and `goal_members.role` are all
124
- // dropped, and `membership_kind` carries NOT NULL + DEFAULT itself rather than
125
- // resting on a trigger surviving.
126
- //
127
- // The PROJECT membership half (platform_identity_010) is deliberately NOT here:
128
- // it is still inside its cross-version federation window, its contract phase is
129
- // task 1003092, and the expand-phase block above is what still guards it. Do not
130
- // merge the two — the whole point of the pair is that they close on different
131
- // schedules.
128
+ // removal ships with the test that says it is gone. The two windows closed on
129
+ // DIFFERENT schedules, which is the whole point of the pair:
130
+ // • core_240 (task 1003089) closed the GOAL membership window one release later;
131
+ // • platform_identity_021 (task 1003092) closed the PROJECT membership window
132
+ // only once every federated instance that can check in was past core 1.19.241,
133
+ // because its writer is another instance (ADR 0177 §"the window closed").
134
+ // In both, the trigger, the function and the legacy column are dropped, and
135
+ // `membership_kind` carries NOT NULL + DEFAULT itself rather than resting on a
136
+ // trigger surviving.
132
137
  const CONTRACT = 'migrations/core_240_goal_members_drop_legacy_role.sql';
138
+ const PI_CONTRACT = 'modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql';
139
+
140
+ test('platform_identity_021: the contract phase drops the trigger, the function AND the column', () => {
141
+ const sql = read(PI_CONTRACT);
142
+ assert.match(sql, /DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep\s+ON platform_identity_project_memberships/);
143
+ assert.match(sql, /DROP FUNCTION IF EXISTS platform_identity_membership_kind_lockstep\(\)/,
144
+ 'the FUNCTION outlives the trigger unless it is dropped by name');
145
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships DROP COLUMN IF EXISTS role/);
146
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET NOT NULL/,
147
+ 'the guarantee 010 deferred to step 2 arrives here, or the column never gets one');
148
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET DEFAULT 'member'/,
149
+ 'safe only NOW: a default while the legacy column was still written is the task-1003090 bug');
150
+ // The last backfill must carry the VALUE rename too — a stray legacy 'builder'
151
+ // must never land in membership_kind, whose CHECK does not admit it.
152
+ assert.match(sql, /CASE WHEN role = ''builder'' THEN ''member'' ELSE role END/,
153
+ 'the guarded backfill maps the craft name, not just copies the column');
154
+ const trigDrop = sql.indexOf('DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep');
155
+ const colDrop = sql.indexOf('DROP COLUMN IF EXISTS role');
156
+ assert.ok(trigDrop > -1 && trigDrop < colDrop,
157
+ 'the trigger goes BEFORE the column: plpgsql bodies are not dependency-tracked, so a survivor fails at the next write');
158
+ });
159
+
160
+ test('platform_identity_021: the forward-unsafe pragma is present, and its FIRST line carries the gating fact', () => {
161
+ const scan = lint.scanMigrationSql(read(PI_CONTRACT));
162
+ assert.deepEqual([...scan.unsafe].sort(), ['drop-column', 'set-not-null'],
163
+ 'the linter must still SEE both operations — an acknowledged migration is not an exempt one');
164
+ assert.ok(scan.acknowledged, 'a drop-column with no pragma is unacknowledged forward-unsafety');
165
+ // The one fact that makes THIS drop safe is not a hub release alone — it is that
166
+ // every federated sender is past the release carrying the expand phase.
167
+ assert.match(scan.ackReason, /1\.19\.241/,
168
+ 'the pragma must name the release the senders had to be past, not merely assert safety');
169
+ assert.match(scan.ackReason, /federated instance/i,
170
+ 'and say the condition was about the SENDERS — the cross-version window is what made this one different');
171
+ });
172
+
173
+ test('no platform-identity migration outside the three that own its lifecycle still names project_memberships.role', () => {
174
+ // Comments are stripped first: the history may DISCUSS the old column; this rule
175
+ // is about statements. The exemptions are the migrations that created the column,
176
+ // expanded away from it, and dropped it.
177
+ const OWNS_ITS_LIFECYCLE = new Set([
178
+ 'platform_identity_003_project_memberships.sql', // created it
179
+ 'platform_identity_010_membership_kind.sql', // expand: backfilled from it; the trigger kept it in step
180
+ 'platform_identity_021_drop_legacy_role.sql', // contract: dropped it
181
+ ]);
182
+ const dir = path.join(ROOT, 'modules', 'platform-identity', 'migrations');
183
+ const offenders = [];
184
+ for (const f of fs.readdirSync(dir)) {
185
+ if (!f.endsWith('.sql') || OWNS_ITS_LIFECYCLE.has(f)) continue;
186
+ const code = lint.stripSqlComments(fs.readFileSync(path.join(dir, f), 'utf8'));
187
+ if (/project_memberships/.test(code) && /\brole\b/.test(code)) offenders.push(f);
188
+ }
189
+ assert.deepEqual(offenders, [],
190
+ `these migrations touch project_memberships and still name the dropped column: ${offenders.join(', ')}`);
191
+ });
133
192
 
134
193
  test('core_240: the contract phase drops the trigger, the function AND the column', () => {
135
194
  const sql = read(CONTRACT);