@bongos/core 1.19.773 → 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 (90) hide show
  1. package/.bongos-core.json +189 -74
  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 +2 -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/ship-card.js +49 -13
  30. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  31. package/modules/platform-identity/platform-identity.js +8 -19
  32. package/modules/platform-identity/project-catalog.js +24 -0
  33. package/modules/platform-identity/routes/sso.js +11 -14
  34. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  35. package/modules/provisioning/capacity-gate.js +54 -0
  36. package/modules/provisioning/catalog-bridge.js +2 -0
  37. package/modules/provisioning/disconnect.js +35 -0
  38. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  39. package/modules/provisioning/module.json +1 -1
  40. package/modules/provisioning/provisioning.js +3 -3
  41. package/modules/provisioning/routes/disconnect.js +63 -0
  42. package/modules/provisioning/routes/provisioning.js +6 -6
  43. package/modules/public-landing/public/projects.html +116 -11
  44. package/modules/public-landing/public/projects.probes.json +3 -0
  45. package/modules/ui-design/kit/serve.js +3 -2
  46. package/modules/ui-design/module.json +1 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/scripts/gds/address-park-sweep.sh +78 -0
  50. package/scripts/gds/cli-lib.js +37 -0
  51. package/scripts/gds/fitness-migration-contraction.js +50 -0
  52. package/scripts/gds/fitness.js +18 -0
  53. package/scripts/gds/land-watch.js +8 -2
  54. package/scripts/gds/provision-config.js +4 -4
  55. package/scripts/gds/provision-disconnect.js +90 -0
  56. package/scripts/gds/provision-teardown.js +108 -0
  57. package/scripts/gds/provision.js +6 -65
  58. package/scripts/gds/seam-wiring-guard.js +273 -0
  59. package/scripts/gds/ship-finish.js +17 -10
  60. package/scripts/gds/ship-flow.js +5 -5
  61. package/scripts/gds/start.js +13 -7
  62. package/src/bongos/auth-admission.js +7 -8
  63. package/src/bongos/auth-config.js +3 -2
  64. package/src/bongos/goal-scope-check.js +23 -8
  65. package/src/bongos/module-scope-map.js +40 -0
  66. package/src/module-api.js +1 -1
  67. package/src/module-loader/manifest-schema.js +79 -1
  68. package/tests/catalog_only_client.mjs +8 -5
  69. package/tests/fitness.mjs +22 -0
  70. package/tests/goal_scope_check.mjs +63 -1
  71. package/tests/hall_settings_world.mjs +57 -1
  72. package/tests/hub_disconnect_ui.mjs +105 -0
  73. package/tests/land_watch.mjs +44 -0
  74. package/tests/membership_kind_lockstep.mjs +86 -27
  75. package/tests/module-scope-map.mjs +39 -0
  76. package/tests/module_exports_unique_keys.mjs +70 -0
  77. package/tests/module_manifest.mjs +85 -0
  78. package/tests/project_catalog_forget.mjs +83 -0
  79. package/tests/projects_hub.mjs +10 -4
  80. package/tests/projects_hub_pre_uat.mjs +5 -3
  81. package/tests/provision_disconnect.mjs +231 -0
  82. package/tests/provision_teardown_address.mjs +230 -0
  83. package/tests/provisioning_capacity_gate.mjs +192 -0
  84. package/tests/provisioning_disconnect.mjs +231 -0
  85. package/tests/provisioning_restart.mjs +5 -5
  86. package/tests/public_landing_projects.mjs +4 -3
  87. package/tests/render_prefs.mjs +152 -0
  88. package/tests/seam_wiring_guard.mjs +234 -0
  89. package/tests/ship_card.mjs +67 -1
  90. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
- const { cliClient, printOnboardingNudge, rewardPolicy } = require('./cli-lib');
13
+ const { cliClient, printOnboardingNudge, rewardPolicy, cardFlagsFromMe } = require('./cli-lib');
14
14
  const { closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
15
15
  const { syncMemoryOnShip } = require('./ship-memory-sync.js');
16
16
  const { uploadSessionOnShip } = require('./ship-session-upload.js');
@@ -264,17 +264,18 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
264
264
  commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
265
265
  }
266
266
 
267
- // Running drachmae total + the builder's render-widgets knob (task 1279),
268
- // both from one /me fetch (best-effort).
267
+ // Running drachmae total + the builder's card-richness level (task 1279's
268
+ // knob, a ladder since task 1002518), both from one /me fetch (best-effort).
269
269
  let total = null;
270
- let widgetsEnabled = true;
270
+ let cardFlags = cardFlagsFromMe(null);
271
271
  try {
272
272
  const me = noteServerReward(await (await cliClient()).me.getMe());
273
273
  if (me.ok && me.data) {
274
274
  if (me.data.builder) total = me.data.builder.total_credits;
275
- if (me.data.render) widgetsEnabled = me.data.render.widgets_enabled !== false;
275
+ cardFlags = cardFlagsFromMe(me);
276
276
  }
277
- } catch (_) { /* total + knob are optional; default widgets on */ }
277
+ } catch (_) { /* total + knob are optional; default to the standard rung */ }
278
+ const widgetsEnabled = cardFlags.showWidget;
278
279
 
279
280
  // Token count + estimated cost from the session usage. Split the tokens into
280
281
  // 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
@@ -328,9 +329,12 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
328
329
  // Print the monospace card + write the branded HTML for the in-feed renderer
329
330
  // (shared emitCard, task 1275). The skill reads the printed path and passes
330
331
  // the contents to the visualization tool — the agent authors nothing, so the
331
- // card is identical every ship. task 1279: when the builder has widgets off,
332
- // emitCard prints only the monospace card (no HTML written or relayed).
333
- shipCard.emitCard(model, taskId, { widgetsEnabled });
332
+ // card is identical every ship. task 1002518: the resolved richness level
333
+ // reaches emitCard as flags — at the plain rung no HTML is written or
334
+ // relayed, at the minimal rung not even the monospace card is printed (the
335
+ // guaranteed [otb-confirm] line always is), and at the rich rung the
336
+ // widget's summary arrives already open.
337
+ shipCard.emitCard(model, taskId, { flags: cardFlags });
334
338
 
335
339
  // task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
336
340
  // confirmed->shipped land happens minutes later, server-side — so the SHIPPED
@@ -338,7 +342,10 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
338
342
  // land-watch hook renders the shipped card on a later prompt once it lands.
339
343
  // Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
340
344
  if (postGradeStatus === 'confirmed') {
341
- try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardInputs }); }
345
+ // `cardFlags` carries the resolved rung so the later-rendered shipped card
346
+ // matches the one this session printed; `widgetsEnabled` stays beside it
347
+ // so a breadcrumb written by an older CLI still reads correctly.
348
+ try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardFlags, cardInputs }); }
342
349
  catch (_) { /* breadcrumb is best-effort */ }
343
350
  }
344
351
  } catch (_) {
@@ -8,7 +8,7 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
11
+ const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
12
12
  const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
13
13
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
14
  const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
@@ -342,10 +342,10 @@ async function shipMain() {
342
342
  removed,
343
343
  sandboxUrl: sandboxGate.url,
344
344
  });
345
- // task 1279: honour the builder's render-widgets knob (from the /me
346
- // fetched at the top of shipMain; default ON if absent/older server).
347
- const widgetsEnabled = me?.data?.render?.widgets_enabled !== false;
348
- shipCard.emitCard(model, taskId, { widgetsEnabled });
345
+ // task 1279 / 1002518: honour the builder's card-richness level (from the
346
+ // /me fetched at the top of shipMain; the standard rung if absent, and
347
+ // the boolean's old meaning against an older server).
348
+ shipCard.emitCard(model, taskId, { flags: cardFlagsFromMe(me) });
349
349
  } catch (_) { /* the review pause stands with or without the card */ }
350
350
  // #1061 polish: a sandbox review pause is a DELIBERATE, expected
351
351
  // checkpoint — not a failed ship. printReviewBlock already showed the URL
@@ -67,7 +67,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
67
67
  if (_fault) { console.error(_fault); process.exit(1); }
68
68
  }
69
69
 
70
- const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError } = require('./cli-lib');
70
+ const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
71
71
  const { contextPack, formatContextPackCompact } = require('./context-pack');
72
72
  const { depsFinding } = require('./preflight');
73
73
 
@@ -782,12 +782,18 @@ async function main() {
782
782
  }
783
783
 
784
784
  // ---------- widget (HTML for the app renderer) ----------
785
- // task 1279: the per-builder "render widgets" knob. When a builder turns
786
- // widgets off, even an explicit --widget request degrades to the markdown card
787
- // below — so no HTML is emitted, read, or re-relayed (a real token saving, not
788
- // just a visual change). Default ON; /me carries the resolved boolean. Headless
789
- // /cron callers don't pass --widget at all (no renderer), so they're unaffected.
790
- const widgetsPref = me.ok ? (me.data?.render?.widgets_enabled !== false) : true;
785
+ // task 1279 / 1002518: the per-builder card-richness level. Below the widget
786
+ // rung, even an explicit --widget request degrades to the markdown card below
787
+ // — so no HTML is emitted, read, or re-relayed (a real token saving, not just
788
+ // a visual change). /me carries the resolved level; an older instance carries
789
+ // only the boolean and cardFlagsFromMe reads either. Headless/cron callers
790
+ // don't pass --widget at all (no renderer), so they're unaffected.
791
+ //
792
+ // The `minimal` rung lands on the markdown card here rather than on nothing:
793
+ // the lifecycle card has a guaranteed one-line confirmation to fall back to,
794
+ // and this card — the list of what you can claim — has no such floor. Dropping
795
+ // it would leave a session opening with no answer to the question it asked.
796
+ const widgetsPref = me.ok ? cardFlagsFromMe(me).showWidget : true;
791
797
  if (widget && widgetsPref) {
792
798
  const myClaims = me.ok ? (me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : [])) : [];
793
799
  // Private warnings (budget / onboarding next-stage) → stderr, so stdout
@@ -46,12 +46,13 @@ function membershipKind({ githubLogin } = {}) {
46
46
  // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
47
47
  // github_id builds on project <client_id>, coarse membership <membership_kind>".
48
48
  //
49
- // BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
50
- // HUB, a different instance on its own upgrade schedule: send only the new field and
51
- // an un-upgraded hub silently defaults every membership to non-owner (it would drop
52
- // the owner flag); send only the legacy field and an upgraded hub keeps translating
53
- // forever. Sending both is what makes the two ends independent — the removal task
54
- // drops `role` once the hub is past the window. Runs on every
49
+ // ONE SPELLING ON THE WIRE: `membership_kind` (task 1003090 renamed it; task 1003092
50
+ // closed the cross-version window). During the window this sender wrote BOTH
51
+ // spellings, because the receiver is the HUB — a different instance on its own
52
+ // upgrade schedule — and new-field-only against an un-upgraded hub would have
53
+ // silently dropped the owner flag. The hub has been past that release since core
54
+ // 1.19.241 and no longer accepts the legacy field (its schema is strict), so
55
+ // sending it again would turn every check-in into a 400. Runs on every
55
56
  // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
56
57
  // project still appears in the builder's My Projects with no hub browser session.
57
58
  // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
@@ -72,8 +73,6 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
72
73
  display_name: displayName ?? null,
73
74
  avatar_url: avatarUrl ?? null,
74
75
  membership_kind: kind,
75
- // LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
76
- role: kind === 'member' ? 'builder' : kind,
77
76
  }),
78
77
  });
79
78
  } catch (err) {
@@ -135,8 +135,9 @@ function idpConfigured() {
135
135
  //
136
136
  // 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
137
137
  // `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
138
- // project membership says what it means. The legacy spelling still goes on the wire for
139
- // the cross-version window; see reportMembershipCheckIn.
138
+ // project membership says what it means. It is the only spelling on the wire — the
139
+ // cross-version window that carried the legacy one closed under task 1003092; see
140
+ // reportMembershipCheckIn.
140
141
 
141
142
  module.exports = {
142
143
  SCOPE,
@@ -11,8 +11,11 @@
11
11
  //
12
12
  // 1. SCOPE-SUBSET — every `touches[]` element must be CONTAINED WITHIN the
13
13
  // goal's territory (the union of its modules' globs, resolved via
14
- // module-scope-map G01). This bounds *everyone*, regardless of rank: a goal
15
- // member can only author work inside the goal's modules.
14
+ // module-scope-map G01) or in SHARED_GLOBS, the small set of roots no module
15
+ // can honestly own that every task legitimately writes (`tests/` — see the
16
+ // SHARED territory block in module-scope-map.js, task 1003655 / ADR 0288).
17
+ // This bounds *everyone*, regardless of rank: a goal member can only author
18
+ // work inside the goal's modules, plus that shared floor.
16
19
  //
17
20
  // 2. PROTECTED-PATH — a sub-Metic member (xenos/thetes) may not author a task
18
21
  // touching the permission/ship-grade/deploy/migration core, even when that
@@ -37,7 +40,7 @@
37
40
  'use strict';
38
41
 
39
42
  const { matchOne } = require('./path-match');
40
- const { globsForModules } = require('./module-scope-map');
43
+ const { globsForModules, SHARED_GLOBS } = require('./module-scope-map');
41
44
  const { checkPermissionPaths } = require('./permission-path-check');
42
45
 
43
46
  function clean(arr) {
@@ -54,13 +57,25 @@ function isCovered(touch, goalGlobs) {
54
57
  return matchOne(touch, goalGlobs || []) !== null;
55
58
  }
56
59
 
57
- // Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[] }.
58
- // `ok` is true iff every cleaned touch is covered by the goal's resolved globs.
59
- // An empty/unknown module set resolves to zero globs → nothing is covered.
60
+ // Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[], shared_globs[] }.
61
+ // `ok` is true iff every cleaned touch is covered by the goal's resolved globs OR
62
+ // by the shared floor. An empty/unknown module set resolves to zero goal globs.
63
+ //
64
+ // `goal_globs` stays the MODULE-resolved set only — the shared floor is reported
65
+ // separately and never folded in. check()'s unknown_scope guard tests
66
+ // `goal_globs.length === 0`, so mixing the always-present shared globs into it
67
+ // would make a goal scoped to nothing but garbage resolve to a non-empty
68
+ // territory and slip past that fail-closed edge.
60
69
  function scopeSubset(goalScopeModules, touches) {
61
70
  const goalGlobs = globsForModules(clean(goalScopeModules));
62
- const outOfScope = clean(touches).filter((p) => !isCovered(p, goalGlobs));
63
- return { ok: outOfScope.length === 0, out_of_scope: outOfScope, goal_globs: goalGlobs };
71
+ const outOfScope = clean(touches)
72
+ .filter((p) => !isCovered(p, goalGlobs) && !isCovered(p, SHARED_GLOBS));
73
+ return {
74
+ ok: outOfScope.length === 0,
75
+ out_of_scope: outOfScope,
76
+ goal_globs: goalGlobs,
77
+ shared_globs: [...SHARED_GLOBS],
78
+ };
64
79
  }
65
80
 
66
81
  // Wall 2 — the protected-path delegate. Thin wrapper over checkPermissionPaths
@@ -388,6 +388,44 @@ const MODULE_GLOBS = {
388
388
  ],
389
389
  };
390
390
 
391
+ // ---------------------------------------------------------------------------
392
+ // SHARED territory — the globs EVERY goal's wall implicitly includes (task 1003655).
393
+ //
394
+ // MODULE_GLOBS answers "who OWNS this path". Some paths have no honest owner: a
395
+ // shared root that every module writes into and none ships. `tests/` is the one
396
+ // that mattered — 751 flat suites covering all ~24 modules, owned by no key — so
397
+ // scopeSubset() denied EVERY touch under it. Since every real task adds a test,
398
+ // the create gate (POST /goals/:id/tasks) refused any task that honestly declared
399
+ // its own test file, whichever goal it was filed under. The wall only looked
400
+ // passable because callers under-declared their touches[] (idea 1000816).
401
+ //
402
+ // The two fixes we did NOT take, and why:
403
+ // • Give `tests/` to a key (devsecops, methodology). That is a FALSE ownership
404
+ // claim — an ideas-module test is the ideas module's work, not devsecops' —
405
+ // and it would hand one goal's members the whole suite while denying it to
406
+ // everyone else, which is worse than the gap.
407
+ // • Enumerate per-module test paths. The suites are flat and named by feature,
408
+ // not by module, and the glob format has no wildcards to carve with; the list
409
+ // would rot on the next test file.
410
+ //
411
+ // So `tests/` is SHARED, not owned: inside every goal's wall, outside every
412
+ // module's territory. That is sound because a test file is not a shipping
413
+ // surface — nothing imports it, so its blast radius on another module is zero —
414
+ // and the other two walls are untouched: claim collisions are still caught by
415
+ // touchesOverlap, and the protected-path wall (below) runs independently.
416
+ //
417
+ // FAIL-CLOSED, like `protected`: the exported list is DERIVED, not trusted. A
418
+ // shared glob is dropped if it overlaps PROTECTED_GLOBS, because a protected path
419
+ // in here would widen every goal's wall into the trust core in one line. Adding
420
+ // an entry is a deliberate design decision (ADR 0288) — the bar is "no module can
421
+ // honestly own it AND every task legitimately writes it", which today is `tests/`
422
+ // alone. Module-owned suites (modules/<key>/tests/) are already covered by their
423
+ // module's glob and do NOT belong here.
424
+ const SHARED_GLOBS_RAW = ['tests/'];
425
+ const SHARED_GLOBS = Object.freeze(
426
+ SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
427
+ );
428
+
391
429
  // A glob set is protected iff it overlaps the imported PROTECTED_GLOBS — the one
392
430
  // source of truth (permission-path-check.js). Prefix-aware both directions
393
431
  // (path-match.touchesOverlap), so e.g. a module owning 'src/bongos/routes/inbox.js'
@@ -451,6 +489,8 @@ function scopeIncludesProtected(keys) {
451
489
  module.exports = {
452
490
  MODULE_GLOBS,
453
491
  MODULE_SCOPE_MAP,
492
+ SHARED_GLOBS,
493
+ SHARED_GLOBS_RAW,
454
494
  isProtected,
455
495
  moduleKeys,
456
496
  isModuleKey,
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.773'; // 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