@bongos/core 1.19.63 → 1.19.65

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 (36) hide show
  1. package/.bongos-core.json +46 -41
  2. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +4 -0
  3. package/docs/adr/0166-module-maintenance-and-deprecation-policy.md +91 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/modules-contract.md +23 -1
  6. package/modules/autonomy/module.json +1 -0
  7. package/modules/builder-settings/module.json +1 -0
  8. package/modules/dev-box/module.json +1 -0
  9. package/modules/discord/module.json +1 -0
  10. package/modules/economy/module.json +1 -0
  11. package/modules/governance/module.json +1 -0
  12. package/modules/grading/module.json +1 -0
  13. package/modules/hall-ui/module.json +1 -0
  14. package/modules/hall-ui/public/modules.js +17 -1
  15. package/modules/ideas/module.json +1 -0
  16. package/modules/lifecycle/db.js +35 -3
  17. package/modules/lifecycle/module.json +1 -0
  18. package/modules/lifecycle/routes/claims.js +14 -8
  19. package/modules/memory/module.json +1 -0
  20. package/modules/onboarding/module.json +1 -0
  21. package/modules/platform-identity/module.json +1 -0
  22. package/modules/provisioning/module.json +1 -0
  23. package/modules/security/module.json +1 -0
  24. package/modules/sessions/module.json +1 -0
  25. package/modules/status-ui/module.json +1 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/scripts/gds/module.js +103 -9
  29. package/scripts/gds/upgrade.js +50 -5
  30. package/src/bongos/routes/modules.js +4 -0
  31. package/src/module-api.js +5 -1
  32. package/src/module-loader/manifest-schema.js +69 -2
  33. package/tests/module_cli.mjs +126 -0
  34. package/tests/module_manifest.mjs +65 -0
  35. package/tests/one_active_claim_per_session.mjs +74 -0
  36. package/tests/upgrade.mjs +94 -0
@@ -4,6 +4,7 @@
4
4
  "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["inbox", "blockers"] },
8
9
  "provides": ["ideas.capture"],
9
10
  "consumes": []
@@ -335,6 +335,30 @@ function singleRungPromotionError(oldRank, newRank) {
335
335
  };
336
336
  }
337
337
 
338
+ // foldWorktreeName — task 1002522. The ONE normalization of an incoming
339
+ // worktree_name, applied at every ingest point so the app guard
340
+ // (shouldRefuseSessionClaim) and its DB backstop (core_204's index, keyed on
341
+ // (creator_session_id, COALESCE(worktree_name, ''))) can never disagree about
342
+ // which claims share a working tree.
343
+ //
344
+ // The index folds NULL to '' — so to Postgres an EMPTY-string name IS the
345
+ // worktree-less group. The guard compared raw values, where '' !== null: it
346
+ // waved through a ''-named claim beside a worktree-less one, and the INSERT
347
+ // then failed the backstop with a 23505 the caller never earned. Whitespace-only
348
+ // is the same case wearing a coat (`.claude/worktrees/<blank>` is the repo root,
349
+ // not a tree), so it folds too, and surrounding whitespace is trimmed rather
350
+ // than stored — an untrimmed name would key the index differently from the
351
+ // trimmed one the guard compared.
352
+ //
353
+ // Fold ONCE, at ingest: claimTask/claimTasksBatch normalize before the INSERT,
354
+ // so every stored name is already trimmed-or-NULL and the guard's comparison of
355
+ // stored rows is exact. Pure + exported for DB-free tests.
356
+ function foldWorktreeName(name) {
357
+ if (name == null) return null;
358
+ const trimmed = String(name).trim();
359
+ return trimmed === '' ? null : trimmed;
360
+ }
361
+
338
362
  // disambiguateWorktreeName — V3.R51 (#270). Two simultaneously-ACTIVE claims
339
363
  // must never share a worktree_name: `.claude/worktrees/<name>` is a filesystem
340
364
  // path, and two live builders pointing at the same one would stomp each other.
@@ -2762,7 +2786,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2762
2786
  // which is what the overnight builder relies on. We hold FOR UPDATE on the
2763
2787
  // task row already; reading the active names inside the same txn is good
2764
2788
  // enough for the disambiguation to be stable for this insert.
2765
- let finalWorktreeName = worktreeName ?? null;
2789
+ // task 1002522: fold ''/whitespace → NULL before anything keys on the name.
2790
+ // core_204's index folds them together anyway (COALESCE(worktree_name, ''));
2791
+ // normalizing here means the stored row matches what the guard compared.
2792
+ let finalWorktreeName = foldWorktreeName(worktreeName);
2766
2793
  if (finalWorktreeName) {
2767
2794
  const { rows: activeNameRows } = await client.query(
2768
2795
  `SELECT worktree_name FROM claims
@@ -3121,8 +3148,12 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
3121
3148
  );
3122
3149
  reservedMigrationNumber = bumpRows[0].allocated;
3123
3150
  }
3124
- const worktreeName = worktreePrefix
3125
- ? `${worktreePrefix}-${t.id}`
3151
+ // task 1002522: fold the prefix the same way a single claim's name is
3152
+ // folded, so a blank/whitespace prefix yields worktree-less rows rather
3153
+ // than names like " -1234" that the index would key as distinct trees.
3154
+ const foldedPrefix = foldWorktreeName(worktreePrefix);
3155
+ const worktreeName = foldedPrefix
3156
+ ? `${foldedPrefix}-${t.id}`
3126
3157
  : null;
3127
3158
  const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
3128
3159
  try {
@@ -5546,6 +5577,7 @@ module.exports = {
5546
5577
  countShippedTasksForBuilder,
5547
5578
  singleRungPromotionError,
5548
5579
  disambiguateWorktreeName,
5580
+ foldWorktreeName,
5549
5581
  updateTaskParallelSafe,
5550
5582
  updateTaskSecuritySensitive,
5551
5583
  updateTaskAutomationTag,
@@ -4,6 +4,7 @@
4
4
  "description": "The build state machine — tasks · claims · versions · done-when criteria · goals · dependencies · the claim→ship→grade→credit lifecycle · override requests · the ship/confirm/promote orchestration · github-push + merge-lease + conflict-resolve + the publish reconciler. The LAST and most-coupled core domain carved out of the GDS core in BV1.R86 (ADR 0093 §1,§3, 02-module-map §4 — kept whole, never split). default-on; every instance runs the lifecycle. Registers the `lifecycle` kernel PORT (createTask · VOTABLE_TASK_STATUSES · classifyTaskKind · tallyPeerVotes + the work-tracking reads) that core read routes + the ideas/discord/autonomy modules resolve. As a transaction-participant it RESOLVES the `reward` + `onboarding` ports inside its claim/ship/confirm txns — but via resolveOptional (graceful, like sessions→reward), so consumes is empty: a vanilla instance without those modules degrades the credit/onboarding writes to best-effort no-ops, it does not fail.",
5
5
  "coreVersion": "^1.12.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": {
8
9
  "routes": ["tasks", "claims", "versions", "done-when", "goals", "dependencies", "gate-approvals", "analytics", "lifecycle"],
9
10
  "pollers": ["publish-reconciler"]
@@ -80,13 +80,17 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
80
80
  return { refuse: false, inFlightTaskId: null, inFlightTitle: null, reason: null };
81
81
  }
82
82
  const claims = Array.isArray(activeClaimsForSession) ? activeClaimsForSession : [];
83
- const incomingTree = worktreeName == null ? null : worktreeName;
84
- // Two claims share a WORKING TREE iff their worktree_names match, or both are
85
- // null (a worktree-less clone — the single branch genuinely commingles).
86
- const sameWorkingTree = (name) => {
87
- const t = name == null ? null : name;
88
- return t === incomingTree;
89
- };
83
+ // task 1002522: fold through db.foldWorktreeName — the SAME normalization the
84
+ // INSERT applies — so this decision keys trees exactly the way core_204's
85
+ // backstop index does (it folds NULL and '' into one group via
86
+ // COALESCE(worktree_name, '')). Comparing raw values let a ''-named claim slip
87
+ // past the guard and then trip the index, refusing a claim on a task that was
88
+ // never taken.
89
+ const incomingTree = db.foldWorktreeName(worktreeName);
90
+ // Two claims share a WORKING TREE iff their folded worktree_names match — which
91
+ // includes both being worktree-less (a plain clone, whose single branch
92
+ // genuinely commingles).
93
+ const sameWorkingTree = (name) => db.foldWorktreeName(name) === incomingTree;
90
94
  // Defence in depth: only count claims that belong to THIS session, are genuinely
91
95
  // session-bound (a NULL-session row must never trip the block), AND would land in
92
96
  // the SAME working tree as the incoming claim.
@@ -122,7 +126,9 @@ async function gateOneActiveClaimPerSession(req, res, next) {
122
126
  // The incoming claim's working tree — the hazard boundary the guard keys on. A
123
127
  // second claim in a DIFFERENT worktree is safe (different branch); only a second
124
128
  // claim into the SAME tree (or off one worktree-less clone) commingles.
125
- const worktreeName = req.body?.worktree_name ?? null;
129
+ // Folded (task 1002522) so the guard, the refusal message, and the row the
130
+ // INSERT will actually write all speak about the same tree.
131
+ const worktreeName = db.foldWorktreeName(req.body?.worktree_name);
126
132
  let activeClaimsForSession;
127
133
  try {
128
134
  activeClaimsForSession = await db.getActiveClaimsForSession(creatorSessionId);
@@ -4,6 +4,7 @@
4
4
  "description": "Per-builder session memory, the lexical recall/search retrieval layer (ADR 0060), and durable learnings. A core-domain module carved out of the GDS core in BV1.R72 (ADR 0091); default-on because every instance needs the build system's memory.",
5
5
  "coreVersion": "^1.5.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": {
8
9
  "routes": ["memory", "search", "learnings"]
9
10
  },
@@ -4,6 +4,7 @@
4
4
  "description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
5
5
  "coreVersion": "^1.9.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["onboarding"] },
8
9
  "provides": ["onboarding"],
9
10
  "consumes": []
@@ -8,6 +8,7 @@
8
8
  "origin": "Cloud Bongos platform (cloudbongos.com)",
9
9
  "license": "AGPL-3.0",
10
10
  "maintainer": "Cloud Bongos maintainers",
11
+ "maintenance": { "status": "core-maintained" },
11
12
  "contributes": {
12
13
  "routes": ["sso", "projects", "my-projects", "scouting"],
13
14
  "migrations": true,
@@ -8,6 +8,7 @@
8
8
  "origin": "Off the Boats (the first Cloud Bongos instance, amazonprimea.com)",
9
9
  "license": "AGPL-3.0",
10
10
  "maintainer": "Off the Boats maintainers",
11
+ "maintenance": { "status": "core-maintained" },
11
12
  "contributes": {
12
13
  "routes": ["provisioning"],
13
14
  "migrations": true
@@ -4,6 +4,7 @@
4
4
  "description": "The red-team / vulnerability-report intake and the bounty payout system (rank-gated Metic+): the /builder-redteam skill files a report, the Archon dashboard adjudicates (confirm/dispute/fix/link-fix), and the env-aware bounty table sets the per-severity drachmae payout. A core-domain module carved out of the GDS core in BV1.R79 (ADR 0093 §1; 02-module-map §5 — security-the-FEATURE, NOT security-the-property, which stays kernel). default-on because every instance can take red-team reports. Registers the `security` kernel PORT the core /public/bounty-table resolves for the payout schedule. Also owns the scan-before-install surface (goal 1000055): the artifact-scan engine (quarantine/manifest/mirror) and the security_tool_scans record — POST Metic+, GET every rank.",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["reports", "scans"], "migrations": true },
8
9
  "provides": ["security"],
9
10
  "consumes": []
@@ -4,6 +4,7 @@
4
4
  "description": "The session-records corpus + its data plane (POST /sessions upload, the Archon-gated cross-builder /sessions/search + /sessions/:id deep-dive), the BFG 'read-the-room' cross-builder scorer (ADR 0026 §6B), and the session-pulse / stale-claim heartbeat helpers. A core-domain module carved out of the GDS core in BV1.R82 (ADR 0093 §1); default-on because every instance records its builders' sessions for the BFG. Like the memory module, sessions registers no port — core never calls back into it: the ship-time digest write is an HTTP POST to the module's own /sessions route, and the only outward dependency is the economy `reward` kernel PORT it resolves (session token reward + sumCreditsForTasks) at upload time.",
5
5
  "coreVersion": "^1.10.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": { "routes": ["sessions"], "pollers": ["session-reward-reconcile"] },
8
9
  "provides": [],
9
10
  "consumes": []
@@ -4,6 +4,7 @@
4
4
  "description": "The public build-status dashboard served at status.<apex> (status.amazonprimea.com) — the live up/down banner, version-progress rollups, cost summary, recent ships, leaderboard, and the ecosystem fingerprint, all polling the read-only /api/gds/public/* surface every 60s. A WEB-SURFACE core-domain module carved out of the GDS core in BV1.R84 (ADR 0093 §1; 02-module-map §6 — a web surface is first-class). default-on: a Cloud Bongos instance ships its status page like it ships its hall. Registers no port and contributes no routes — it declares a webSurface (host prefix + static dir) the loader hands serve-internal, which serves the module's public/ dir on the matching host exactly as it served public-status/ before. The public read endpoints it renders stay SHARED core in routes/public.js (the hall + the off-box mirror read them too).",
5
5
  "coreVersion": "^1.11.0",
6
6
  "default": true,
7
+ "maintenance": { "status": "core-maintained" },
7
8
  "contributes": {
8
9
  "webSurfaces": [
9
10
  { "host": "status.", "dir": "public" }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.63",
3
+ "version": "1.19.65",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.63",
9
+ "version": "1.19.65",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.63",
3
+ "version": "1.19.65",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -34,7 +34,10 @@
34
34
  // because a brand-new module is never on the ALLOWLIST until it is
35
35
  // accepted (§6); that omission must not read as "denied content." Also
36
36
  // requires the manifest to declare an AGPL-3.0 license (inbound = outbound,
37
- // ADR 0107 §4 — no CLA). On a pass, `--sign-off "Name <email>"` records a
37
+ // ADR 0107 §4 — no CLA) and a `maintenance` posture (ADR 0107 §7 / ADR
38
+ // 0166 — core-maintained, or maintained by a named maintainer; a module
39
+ // already deprecated/orphaned cannot be submitted INTO the catalog).
40
+ // On a pass, `--sign-off "Name <email>"` records a
38
41
  // lightweight DCO-style affirmation (own work, licensed AGPL-3.0 — never a
39
42
  // copyright-assignment CLA) to modules/<key>/.upstream-signoff.json.
40
43
  //
@@ -54,7 +57,8 @@ const path = require('node:path');
54
57
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
55
58
  const MODULES_DIR = path.join(REPO_ROOT, 'modules');
56
59
 
57
- const { validateManifest, assertValidManifest, KEY_RE } = require('../../src/module-loader/manifest-schema');
60
+ const { validateManifest, assertValidManifest, KEY_RE, SUNSET_STATUSES } = require('../../src/module-loader/manifest-schema');
61
+ const { parseVersion, cmp } = require('../../src/module-loader/semver');
58
62
  const loader = require('../../src/module-loader/loader');
59
63
  const { resolveModules, MODULE_REGISTRY, NEUTRAL_PATH, INSTANCE_PATH } = require('../../src/modules');
60
64
  // The public-mirror publish boundary (ADR 0098) — REUSED, never re-typed, for
@@ -115,6 +119,11 @@ function buildManifest(key, { core = coreVersion() } = {}) {
115
119
  origin: 'TODO: the instance/project this module was built in',
116
120
  license: 'AGPL-3.0',
117
121
  maintainer: 'TODO: who to credit/contact if this module is upstreamed',
122
+ // Upkeep posture (ADR 0107 §7 / ADR 0166). Scaffolded as `maintained`
123
+ // because a brand-new module's author IS its maintainer — the honest
124
+ // default. Change it to `core-maintained` only if the core team has agreed
125
+ // to own it, and to `deprecated`/`orphaned` (with a path) when that changes.
126
+ maintenance: { status: 'maintained' },
118
127
  contributes: { routes: [key] },
119
128
  };
120
129
  }
@@ -191,13 +200,51 @@ kernel seams (\`api.registerProvider\` / \`api.resolve\` / \`api.on\` / \`api.em
191
200
  `;
192
201
  }
193
202
 
203
+ // Has the core advanced past a deprecated module's declared removal version?
204
+ // `removeAfter: "1.12.0"` means "may be deleted once core is ABOVE 1.12.0".
205
+ function isPastRemoveAfter(maintenance, core) {
206
+ const after = maintenance && maintenance.removeAfter;
207
+ const a = parseVersion(after);
208
+ const c = parseVersion(core);
209
+ return !!(a && c && cmp(c, a) > 0);
210
+ }
211
+
212
+ // One line of "who do I chase about this?" for a row, from its maintenance block
213
+ // (ADR 0166). Shared by cmdUpgrade and upgrade.js's real-bump pre-flight so both
214
+ // surfaces say the same thing about the same module.
215
+ function formatMaintenanceHint(row = {}) {
216
+ const m = row.maintenance;
217
+ if (!m || !m.status) {
218
+ return 'no maintenance declared — nobody is on the hook for this module (ADR 0166: declare a `maintenance` block)';
219
+ }
220
+ const parts = [`maintenance: ${m.status}`];
221
+ if (m.status === 'maintained') parts.push(`maintainer ${row.maintainer || '(unnamed!)'}`);
222
+ if (m.status === 'core-maintained') parts.push('core-maintained — fixing this is the core\'s job');
223
+ if (m.successor) parts.push(`superseded by "${m.successor}"`);
224
+ if (m.removeAfter) parts.push(`removable after core ${m.removeAfter}`);
225
+ if (m.note) parts.push(m.note);
226
+ return parts.join('; ');
227
+ }
228
+
194
229
  // Decide compatibility from rows (pure — tests drive it with injected rows).
195
- // A row: { key, coreVersion, enabled, satisfied }. BLOCKING = an enabled module
196
- // that does not satisfy the core (fail-closed). A disabled mismatch is a warning.
230
+ // A row: { key, coreVersion, enabled, satisfied, maintenance?, maintainer? }.
231
+ //
232
+ // BLOCKING = an enabled module that does not satisfy the core (fail-closed).
233
+ // WARNINGS = a DISABLED mismatch. These are not cosmetic: an accepted catalog
234
+ // module ships `default: false` (ADR 0107 §6), so EVERY rotted catalog entry
235
+ // lands in this bucket. Dropping warnings on the floor is exactly how "the
236
+ // catalog fills with silently-broken entries" happens (ADR 0107 §7) — every
237
+ // caller must surface them, even though none of them block.
238
+ // DEPRECATIONS = modules whose own manifest says they are on the way out;
239
+ // EXPIRED = deprecated modules the core has already advanced past `removeAfter`
240
+ // (they are due for deletion, not just a label).
197
241
  function evaluateUpgrade({ rows = [], errors = [], core } = {}) {
198
242
  const blocking = rows.filter((r) => r.enabled && !r.satisfied);
199
243
  const warnings = rows.filter((r) => !r.enabled && !r.satisfied);
200
- return { ok: blocking.length === 0, blocking, warnings, rows, errors, core };
244
+ const deprecations = rows.filter((r) => r.maintenance && SUNSET_STATUSES.includes(r.maintenance.status));
245
+ const expired = deprecations.filter((r) => isPastRemoveAfter(r.maintenance, core));
246
+ const undeclared = rows.filter((r) => !r.maintenance || !r.maintenance.status);
247
+ return { ok: blocking.length === 0, blocking, warnings, deprecations, expired, undeclared, rows, errors, core };
201
248
  }
202
249
 
203
250
  // ---------------------------------------------------------------------------
@@ -261,12 +308,20 @@ function gatherUpgrade({ modulesDir = MODULES_DIR, core = coreVersion(), env = p
261
308
  try { enabled = resolveModules({ neutral, instance, env, envPrefix: prefix, registry }); }
262
309
  catch { /* a malformed config shouldn't crash the pre-check; treat all as disabled */ }
263
310
 
311
+ // Maintenance posture travels with the row (ADR 0166) so both the pre-check
312
+ // printout and the real bump can say WHO owns a module that no longer fits —
313
+ // an incompatibility with no owner named is the orphaning failure itself.
314
+ const upkeep = (manifest) => ({
315
+ maintenance: (manifest && manifest.maintenance) || null,
316
+ maintainer: (manifest && manifest.maintainer) || null,
317
+ });
318
+
264
319
  const rows = [];
265
320
  for (const { key, manifest } of loaded) {
266
- rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true });
321
+ rows.push({ key, coreVersion: manifest.coreVersion, enabled: enabled[key] === true, satisfied: true, ...upkeep(manifest) });
267
322
  }
268
323
  for (const { key, manifest, reason } of skippedFull) {
269
- rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason });
324
+ rows.push({ key, coreVersion: manifest ? manifest.coreVersion : null, enabled: enabled[key] === true, satisfied: false, reason, ...upkeep(manifest) });
270
325
  }
271
326
  rows.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
272
327
  return { rows, errors, core };
@@ -320,6 +375,29 @@ function checkModulePublishability(key, { modulesDir = MODULES_DIR } = {}) {
320
375
  'AGPL-3.0 (inbound = outbound, ADR 0107 §4)',
321
376
  });
322
377
  }
378
+
379
+ // ADR 0107 §7 / ADR 0166 — the maintenance gate. Acceptance into the shared
380
+ // catalog is a commitment to keep the module true as the core advances, so
381
+ // the module must SAY who is committing before it can be filed for review.
382
+ // The shape is already schema-validated above (a `maintained` module must
383
+ // name a maintainer; a sunset one must state its path); what this adds is
384
+ // that the block cannot simply be absent. Refusing here — at submit time —
385
+ // is what keeps the catalog from filling with entries nobody owns.
386
+ const maint = manifestRaw.maintenance;
387
+ if (!maint || !maint.status) {
388
+ findings.push({
389
+ kind: 'maintenance',
390
+ detail: 'module.json declares no `maintenance.status` — an upstreamed module must state its upkeep ' +
391
+ 'posture (ADR 0107 §7): "core-maintained" (the core team owns it) or "maintained" with a named ' +
392
+ '`maintainer`. A module nobody owns rots silently as the core advances.',
393
+ });
394
+ } else if (SUNSET_STATUSES.includes(maint.status)) {
395
+ findings.push({
396
+ kind: 'maintenance',
397
+ detail: `module.json declares maintenance.status "${maint.status}" — a module already on its way out ` +
398
+ 'cannot be submitted INTO the catalog. Upstream a maintained module, or its successor.',
399
+ });
400
+ }
323
401
  }
324
402
 
325
403
  // Classify every file AS IF it already lived at its canonical modules/<key>/
@@ -428,13 +506,28 @@ function cmdUpgrade(_args, { log = console.log, errlog = console.error, gather =
428
506
  const state = r.enabled ? 'enabled' : 'disabled';
429
507
  let note = '';
430
508
  if (!r.satisfied) {
509
+ // A DISABLED mismatch is harmless to THIS boot, but it is not harmless to
510
+ // the catalog — it is a rotted entry with an owner who needs telling
511
+ // (ADR 0166). Name that owner right here rather than shrugging at it.
431
512
  note = ` — coreVersion ${r.coreVersion || '(none)'} not satisfied by core ${verdict.core}` +
432
- (r.enabled ? ' — will NOT mount' : ' (disabled, harmless)');
513
+ (r.enabled ? ' — will NOT mount' : ' — disabled, so it does not block this boot, but it is BROKEN if enabled') +
514
+ `\n ↳ ${formatMaintenanceHint(r)}`;
433
515
  }
434
516
  log(` ${mark} ${r.key.padEnd(18)} ${String(r.coreVersion || '').padEnd(10)} ${state}${note}`);
435
517
  }
436
518
  for (const e of data.errors) errlog(` ! ${e.dir}: ${(e.errors || []).join('; ')}`);
437
519
 
520
+ // Deprecation roll-up (ADR 0166) — separate from compatibility: a module can
521
+ // satisfy the core perfectly and still be on its way out.
522
+ for (const d of verdict.deprecations) {
523
+ const overdue = verdict.expired.includes(d);
524
+ errlog(` ${overdue ? '✗' : '!'} ${d.key}: ${d.maintenance.status}${overdue ? ` — PAST removeAfter ${d.maintenance.removeAfter} (core is ${verdict.core}); it is due for removal` : ''}`);
525
+ errlog(` ↳ ${formatMaintenanceHint(d)}`);
526
+ }
527
+ if (verdict.undeclared.length) {
528
+ errlog(` ! ${verdict.undeclared.length} module(s) declare no \`maintenance\` block — nobody is on the hook for them as the core advances (ADR 0166): ${verdict.undeclared.map((r) => r.key).join(', ')}`);
529
+ }
530
+
438
531
  if (!verdict.ok) {
439
532
  errlog(`\nREFUSING: ${verdict.blocking.length} enabled module(s) incompatible with core ${verdict.core}: ${verdict.blocking.map((b) => b.key).join(', ')}.`);
440
533
  errlog(`Widen each module's coreVersion to a range that includes ${verdict.core} (or hold the core upgrade) before proceeding.`);
@@ -459,7 +552,7 @@ function cmdCheck(args, { log = console.log, errlog = console.error, check = che
459
552
 
460
553
  log(`bongos module check — upstreaming pre-check for "${key}"`);
461
554
  if (result.ok) {
462
- log(' ✓ AGPL/publish pre-check passed — no denied content, license is AGPL-3.0.');
555
+ log(' ✓ AGPL/publish pre-check passed — no denied content, license is AGPL-3.0, upkeep is declared.');
463
556
  } else {
464
557
  for (const f of result.findings) errlog(` ✗ ${f.kind}: ${f.detail}`);
465
558
  }
@@ -582,6 +675,7 @@ async function main(argv) {
582
675
  module.exports = {
583
676
  main, cmdNew, cmdUpgrade, cmdCheck, cmdSubmit, printHelp,
584
677
  scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
678
+ formatMaintenanceHint, isPastRemoveAfter,
585
679
  checkModulePublishability, recordSignOff, readSignOff, DCO_STATEMENT,
586
680
  listModuleFiles, packageModuleForSubmission,
587
681
  routeTemplate, claudeMdTemplate, titleize, camel, coreVersion,
@@ -141,18 +141,59 @@ function vendorTarball({ instanceDir, fromTgz, targetVersion }, fsImpl = fs) {
141
141
 
142
142
  // Lightweight module-compat pre-check reusing module.js against the TARGET core version. Best-effort:
143
143
  // a vanilla consumer has no modules/ (trivially OK); any failure downgrades to a warning, never blocks
144
- // the MVP bump. Returns { ok, blocking:[], note }.
144
+ // the MVP bump. Returns { ok, blocking:[], warnings:[], deprecations:[], expired:[], note }.
145
+ //
146
+ // WARNINGS ARE NOT OPTIONAL OUTPUT (ADR 0166 / task 1001773). This function used to return only
147
+ // `blocking`, so a DISABLED module whose coreVersion the target no longer satisfies vanished from the
148
+ // bump entirely. That is precisely the catalog-rot case ADR 0107 §7 names: an accepted catalog module
149
+ // ships `default: false` (§6), so EVERY rotted catalog entry is disabled by construction and every one
150
+ // of them was invisible here. The bump still doesn't BLOCK on them — a disabled module breaks nothing
151
+ // at boot — but it must SAY so, with the maintainer named, or the incompatibility is discovered by
152
+ // whoever next tries to enable it.
145
153
  function preflightModules({ instanceDir, targetVersion }, deps = {}) {
154
+ const empty = { blocking: [], warnings: [], deprecations: [], expired: [] };
146
155
  const modulesDir = path.join(instanceDir, 'modules');
147
- if (!fs.existsSync(modulesDir)) return { ok: true, blocking: [], note: 'no modules/ — nothing to pre-check' };
156
+ if (!fs.existsSync(modulesDir)) return { ok: true, ...empty, note: 'no modules/ — nothing to pre-check' };
148
157
  try {
149
158
  const mod = deps.moduleLib || require('./module');
150
159
  const data = mod.gatherUpgrade({ modulesDir, core: targetVersion });
151
160
  const verdict = mod.evaluateUpgrade(data);
152
- return { ok: verdict.ok, blocking: verdict.blocking || [], note: verdict.ok ? 'all enabled modules satisfy the target' : 'incompatible enabled module(s)' };
161
+ const rotted = (verdict.warnings || []).length;
162
+ const note = !verdict.ok
163
+ ? 'incompatible enabled module(s)'
164
+ : rotted
165
+ ? `all enabled modules satisfy the target, but ${rotted} disabled module(s) do NOT`
166
+ : 'all enabled modules satisfy the target';
167
+ return {
168
+ ok: verdict.ok,
169
+ blocking: verdict.blocking || [],
170
+ warnings: verdict.warnings || [],
171
+ deprecations: verdict.deprecations || [],
172
+ expired: verdict.expired || [],
173
+ note,
174
+ };
153
175
  } catch (e) {
154
- return { ok: true, blocking: [], note: `module pre-check skipped (${e.message})` };
176
+ return { ok: true, ...empty, note: `module pre-check skipped (${e.message})` };
177
+ }
178
+ }
179
+
180
+ // Print the non-blocking half of the module pre-check: rotted-but-disabled modules and
181
+ // self-declared deprecations, each with its maintenance hint so the reader knows who to chase.
182
+ // Split out of runUpgrade so it is testable without driving a whole bump.
183
+ function reportModulePreflight(mods, targetVersion, { log = console.log, err = console.error, hint } = {}) {
184
+ const fmt = hint || ((row) => { try { return require('./module').formatMaintenanceHint(row); } catch { return ''; } });
185
+ const lines = [];
186
+ for (const w of mods.warnings || []) {
187
+ lines.push(` ! module "${w.key}" is DISABLED here, so it does not block this bump — but its coreVersion ${w.coreVersion || '(none)'} is not satisfied by core ${targetVersion}: enabling it would silently fail to mount.`);
188
+ lines.push(` ↳ ${fmt(w)}`);
189
+ }
190
+ for (const d of mods.deprecations || []) {
191
+ const overdue = (mods.expired || []).includes(d);
192
+ lines.push(` ! module "${d.key}" is ${d.maintenance.status}${overdue ? ` and PAST its removeAfter ${d.maintenance.removeAfter} (core ${targetVersion}) — it is due for removal` : ''}.`);
193
+ lines.push(` ↳ ${fmt(d)}`);
155
194
  }
195
+ for (const l of lines) err(l);
196
+ return lines;
156
197
  }
157
198
 
158
199
  function npmInstall(instanceDir, run = spawnSync) {
@@ -582,6 +623,10 @@ async function runUpgrade(opts, deps = {}) {
582
623
  }
583
624
  const mods = preflightModules({ instanceDir, targetVersion }, deps);
584
625
  log(` ✓ pre-flight: ${mods.note}`);
626
+ // Surface the non-blocking findings (rotted-but-disabled + deprecated modules) BEFORE the
627
+ // blocking check returns, so they are reported on a refused bump too — a refusal is exactly
628
+ // when someone is reading this output (ADR 0166).
629
+ (deps.reportModulePreflight || reportModulePreflight)(mods, targetVersion, { log, err });
585
630
  if (!mods.ok) return { ok: false, error: `module pre-check failed: ${mods.blocking.map((b) => b.key).join(', ')} incompatible with core ${targetVersion}` };
586
631
 
587
632
  // 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
@@ -822,7 +867,7 @@ async function main(argv = process.argv.slice(2)) {
822
867
  module.exports = {
823
868
  runUpgrade, parseArgs, main, rollback,
824
869
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
825
- vendorTarball, gitTreeClean, preflightModules, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
870
+ vendorTarball, gitTreeClean, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth,
826
871
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
827
872
  CORE_PKG, ARTIFACT,
828
873
  };
@@ -99,6 +99,10 @@ function listModules() {
99
99
  origin: manifest.origin || null,
100
100
  license: manifest.license || null,
101
101
  maintainer: manifest.maintainer || null,
102
+ // Upkeep posture (ADR 0107 §7 / ADR 0166) — the catalog's job is to show
103
+ // who keeps a module true as the core advances, so a deprecated or
104
+ // unowned entry reads as such instead of looking like any other module.
105
+ maintenance: manifest.maintenance || null,
102
106
  };
103
107
  }).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
104
108
  }
package/src/module-api.js CHANGED
@@ -553,8 +553,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
553
553
  // landed since 1.19.61 with no explicit bump. run 31552833331. (task 1002620)
554
554
  // 1.19.63 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
555
555
  // landed since 1.19.62 with no explicit bump. run 31553743979. (task 1002620)
556
+ // 1.19.64 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
557
+ // landed since 1.19.63 with no explicit bump. run 31560059785. (task 1002620)
558
+ // 1.19.65 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
559
+ // landed since 1.19.64 with no explicit bump. run 31562193292. (task 1002620)
556
560
  // ---------------------------------------------------------------------------
557
- const CORE_VERSION = '1.19.63'; // CI auto-patch carrier (ADR 0161); see changelog above
561
+ const CORE_VERSION = '1.19.65'; // CI auto-patch carrier (ADR 0161); see changelog above
558
562
 
559
563
  // A namespaced logger so a module's log lines are attributable + consistent.
560
564
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -18,6 +18,15 @@
18
18
  // "origin": "…", // OPTIONAL. Origin instance/project this module was built in.
19
19
  // "license": "…", // OPTIONAL. e.g. "AGPL-3.0" — the license this module is offered under.
20
20
  // "maintainer": "…", // OPTIONAL. Who to credit/contact if this module is upstreamed.
21
+ // "maintenance": { // OPTIONAL. Upkeep posture (ADR 0107 §7 / ADR 0166) — who keeps
22
+ // "status": "maintained", // REQUIRED in-block: core-maintained | maintained | deprecated | orphaned.
23
+ // "since": "1.8.0", // OPTIONAL. Core version the status took effect.
24
+ // "removeAfter": "1.12.0", // OPTIONAL. Core version after which it may be deleted.
25
+ // "successor": "econ-v2", // OPTIONAL. Module key that replaces it.
26
+ // "note": "…" // OPTIONAL. The deprecation path in one sentence.
27
+ // }, // this module true as the core advances, and what happens if
28
+ // // nobody does. `maintained` needs a named `maintainer`;
29
+ // // `deprecated`/`orphaned` need a `successor` or a `note`.
21
30
  // "contributes": { // REQUIRED object (may be empty). What the module mounts:
22
31
  // "routes": ["box"], // route-factory keys (mounted behind kernel-composed auth)
23
32
  // "rooms": ["WorldRoom"], // Colyseus room classes (realtime seam)
@@ -40,7 +49,7 @@
40
49
  // validateManifest(obj) -> { valid, errors[], manifest } — pure, no I/O.
41
50
  // assertValidManifest(obj) throws on the first failure (loader convenience).
42
51
 
43
- const { isValidRange } = require('./semver');
52
+ const { isValidRange, parseVersion } = require('./semver');
44
53
 
45
54
  const KEY_RE = /^[a-z][a-z0-9-]*$/;
46
55
 
@@ -54,6 +63,23 @@ const ALLOWED_CONTRIBUTES = [...ARRAY_OF_STRING_KEYS, 'webSurfaces', 'migrations
54
63
  // Top-level attribution fields (ADR 0107 §2) — optional, portable strings.
55
64
  const ATTRIBUTION_KEYS = ['author', 'origin', 'license', 'maintainer'];
56
65
 
66
+ // The `maintenance` block (ADR 0107 §7, decided in ADR 0166). A module pinned to
67
+ // an old coreVersion rots as the core advances, so every module states — in the
68
+ // manifest, where the upgrade pre-check can read it — WHO keeps it true and what
69
+ // happens if nobody does. The four statuses are the whole policy:
70
+ // core-maintained the core team owns it; a core change that breaks it is the
71
+ // core's problem to fix before shipping.
72
+ // maintained a named `maintainer` owns it (required — an unnamed owner
73
+ // IS the orphaning failure this field exists to prevent).
74
+ // deprecated on the way out; needs a path (`successor` and/or `note`),
75
+ // optionally a `removeAfter` core version.
76
+ // orphaned nobody owns it. Still needs a `note`/`successor` so the
77
+ // next reader knows the situation, not just the label.
78
+ const MAINTENANCE_STATUSES = ['core-maintained', 'maintained', 'deprecated', 'orphaned'];
79
+ const MAINTENANCE_KEYS = ['status', 'since', 'removeAfter', 'successor', 'note'];
80
+ // Statuses that describe a module on its way out — these owe a deprecation path.
81
+ const SUNSET_STATUSES = ['deprecated', 'orphaned'];
82
+
57
83
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
58
84
  const isStr = (v) => typeof v === 'string' && v.length > 0;
59
85
  const isStrArray = (v) => Array.isArray(v) && v.every(isStr);
@@ -93,6 +119,44 @@ function validateManifest(obj) {
93
119
  if (k in obj && !isStr(obj[k])) E(`${k}: must be a non-empty string when present`);
94
120
  }
95
121
 
122
+ // --- optional maintenance block (ADR 0107 §7 / ADR 0166) ---
123
+ // Closed key set (like contributes) so a typo'd "removeafter" fails loud rather
124
+ // than silently declaring no removal date at all.
125
+ if ('maintenance' in obj) {
126
+ const m = obj.maintenance;
127
+ if (!isObj(m)) {
128
+ E('maintenance: must be an object (e.g. { "status": "maintained" })');
129
+ } else {
130
+ for (const k of Object.keys(m)) {
131
+ if (!MAINTENANCE_KEYS.includes(k)) E(`maintenance.${k}: unknown key (allowed: ${MAINTENANCE_KEYS.join(', ')})`);
132
+ }
133
+ if (!isStr(m.status)) E(`maintenance.status: required — one of ${MAINTENANCE_STATUSES.join(', ')}`);
134
+ else if (!MAINTENANCE_STATUSES.includes(m.status)) {
135
+ E(`maintenance.status: "${m.status}" is not one of ${MAINTENANCE_STATUSES.join(', ')}`);
136
+ }
137
+ for (const k of ['since', 'removeAfter']) {
138
+ if (k in m && !parseVersion(m[k])) E(`maintenance.${k}: must be an exact core version "X.Y.Z" when present`);
139
+ }
140
+ if ('successor' in m && (!isStr(m.successor) || !KEY_RE.test(m.successor))) {
141
+ E('successor: must be a module key (lowercase kebab-case) when present');
142
+ }
143
+ if ('note' in m && !isStr(m.note)) E('maintenance.note: must be a non-empty string when present');
144
+
145
+ // The two rules that make the block load-bearing rather than decorative:
146
+ // a "maintained" module must NAME its maintainer, and a module on its way
147
+ // out must state the path out. Everything else above is shape-checking.
148
+ if (m.status === 'maintained' && !isStr(obj.maintainer)) {
149
+ E('maintenance.status "maintained" requires a named top-level `maintainer` (ADR 0166) — use "orphaned" if nobody owns it');
150
+ }
151
+ if (SUNSET_STATUSES.includes(m.status) && !isStr(m.successor) && !isStr(m.note)) {
152
+ E(`maintenance.status "${m.status}" requires a deprecation path — a "successor" module key and/or a "note" (ADR 0166)`);
153
+ }
154
+ if ('removeAfter' in m && !SUNSET_STATUSES.includes(m.status)) {
155
+ E(`maintenance.removeAfter only applies to a ${SUNSET_STATUSES.join('/')} module (status is "${m.status}")`);
156
+ }
157
+ }
158
+ }
159
+
96
160
  // --- contributes (required object) ---
97
161
  if (!isObj(obj.contributes)) {
98
162
  E('contributes: required object (use {} if the module contributes nothing)');
@@ -152,4 +216,7 @@ function assertValidManifest(obj, label = 'module.json') {
152
216
  return obj;
153
217
  }
154
218
 
155
- module.exports = { validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS };
219
+ module.exports = {
220
+ validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
221
+ MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
222
+ };