@bongos/core 1.19.508 → 1.19.509

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.
@@ -1453,5 +1453,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1453
1453
  landed since 1.19.506 with no explicit bump. run 33788726591. (task 1002620)
1454
1454
  1.19.508 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1455
1455
  landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
1456
+ 1.19.509 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1457
+ landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
1456
1458
  ---------------------------------------------------------------------------
1457
1459
  ```
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
17
- "provides": ["platform-identity.hub"],
17
+ "provides": ["platform-identity.hub", "project.publishGate"],
18
18
  "consumes": []
19
19
  }
@@ -300,38 +300,18 @@ const PROJECT_TEMPLATE_WALK = ['blob-tint', 'pair-gold-wash', 'bead-obsidian-pla
300
300
  // `private` and let two sources of truth for one question drift apart.
301
301
  const PROJECT_VISIBILITIES = ['public', 'private', 'stealth'];
302
302
 
303
- // The publish gate is IDENTITY ONLY (ADR 0182 D4, owner interview on R16): a name and a
304
- // description. Not tagline, art, modules, governance, or a completed interview — the bar
305
- // exists to keep empty husks off the map, not to enforce completeness.
306
- const PUBLISH_GATE_FIELDS = ['name', 'description'];
307
-
308
- // Is this project on the map at all? Returns the verdict AND what is missing, because
309
- // ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
310
- // project that cannot be published and is not told why is indistinguishable from a
311
- // broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
312
- function projectPublishability(project) {
313
- const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
314
- return { publishable: missing.length === 0, missing };
315
- }
316
-
317
- // The boolean alone, for predicates that do not care which field is absent.
318
- function projectIsPublishable(project) {
319
- return projectPublishability(project).publishable;
320
- }
321
-
322
- // The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
323
- // refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
324
- // qualifies the columns for a joined query (e.g. publishableSql('p')).
325
- function publishableSql(alias) {
326
- // The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
327
- // pass a literal, but a helper that builds SQL should refuse anything that is not a
328
- // bare identifier rather than trust every future caller to remember that.
329
- if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
330
- throw new Error('publishableSql: alias must be a bare SQL identifier');
331
- }
332
- const q = alias ? `${alias}.` : '';
333
- return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
334
- }
303
+ // The publish gate — the derived "is this project on the map at all" bar — lives in
304
+ // its own file (ADR 0182 D4; the account-visibility.js precedent). Every symbol is
305
+ // re-exported below, so this module's public surface is exactly what it was before
306
+ // the split and no caller has to know about it.
307
+ const {
308
+ PUBLISH_GATE_FIELDS,
309
+ projectPublishability,
310
+ projectIsPublishable,
311
+ publishableSql,
312
+ missingPhrase,
313
+ publishGatePort,
314
+ } = require('./publish-gate');
335
315
 
336
316
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
337
317
  // federated project is catalogued, unfeatured) and by the Archon create/curate route.
@@ -412,14 +392,26 @@ async function listAllProjects({ pool = defaultPool } = {}) {
412
392
  return rows.map((r) => ({ ...r, missing: projectPublishability(r).missing }));
413
393
  }
414
394
 
415
- // Archon-only: flip the Explore feature flag on a catalog row.
395
+ // Archon-only: flip the Explore feature flag — BOUNDED BY THE PUBLISH GATE (ADR 0182
396
+ // D3, task 1002335). Featuring may rank among projects ALREADY on the map, never put
397
+ // one there. The bound rides the UPDATE's own WHERE, not a read-then-write, so a
398
+ // description cleared mid-flight cannot slip a husk on; un-featuring is never blocked,
399
+ // since an already-featured husk must stay removable. Answers a VERDICT rather than a
400
+ // row — { project } | { blocked, missing } | { notFound } — because a bare null
401
+ // collapsed "the gate refused" into "no such row", and a curator is owed each.
416
402
  async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
403
+ const on = !!featured;
417
404
  const { rows } = await pool.query(
418
405
  `UPDATE platform_identity_projects SET featured = $2, updated_at = now()
419
- WHERE id = $1 RETURNING id, origin, name, featured, status`,
420
- [id, !!featured],
406
+ WHERE id = $1 AND ($2 = false OR ${publishableSql()})
407
+ RETURNING id, origin, name, featured, status`,
408
+ [id, on],
421
409
  );
422
- return rows[0] || null;
410
+ if (rows[0]) return { project: rows[0] };
411
+ // Re-read only to tell the two failures apart — and, when it was the gate, to name
412
+ // the identity field that is still missing (D4: never merely refuse).
413
+ const project = await getProject(id, { pool });
414
+ return project ? { blocked: true, missing: project.missing } : { notFound: true };
423
415
  }
424
416
 
425
417
  // -------------------------------------------------------------------------
@@ -1435,6 +1427,8 @@ module.exports = {
1435
1427
  projectPublishability,
1436
1428
  projectIsPublishable,
1437
1429
  publishableSql,
1430
+ missingPhrase,
1431
+ publishGatePort,
1438
1432
  upsertProject,
1439
1433
  PROJECT_TYPES,
1440
1434
  PROJECT_TEMPLATES,
@@ -0,0 +1,103 @@
1
+ // modules/platform-identity/publish-gate.js — THE project publish gate, published
2
+ // in all three forms it is asked in, so no two of them can drift (ADR 0182 D4; the
3
+ // account-visibility.js precedent, which took this file's shape for the same reason).
4
+ //
5
+ // WHAT IT ANSWERS: "is this project on the public map at all?" — and, when the answer
6
+ // is no, WHICH identity field is absent. The bar is IDENTITY ONLY: a `name` and a
7
+ // `description`. Not tagline, art, modules, governance, or a completed interview. It
8
+ // exists to keep empty husks off the map, never to enforce completeness — a higher bar
9
+ // would make skipping the optional project-detail step (task 1002334) a dead end
10
+ // rather than the supported default the goal explicitly wants.
11
+ //
12
+ // "Dark matter" — off the map and un-joinable — is the ABSENCE of a met bar, derived
13
+ // here and never stored. A stored fourth visibility value would compete with `private`
14
+ // and give one question two sources of truth that can drift apart (ADR 0182 D1).
15
+ //
16
+ // THE THREE FORMS, and why they live together:
17
+ // • projectPublishability(row) — the verdict + `missing`, for JS callers
18
+ // • publishableSql(alias) — the identical predicate as SQL, for the feed
19
+ // • publishGatePort — the kernel seam port, for another module
20
+ // A caller that reached only one of these could not have drifted; a caller that
21
+ // re-implements a two-field rule inevitably does. That is the whole point of the file.
22
+ //
23
+ // WHY ITS OWN FILE. Same reason account-visibility.js is: platform-identity.js sits at
24
+ // the file-size ratchet (scripts/gds/fitness-ratchets.js), and R16 (task 1002335) added
25
+ // the port + the phrase. platform-identity.js re-exports every symbol here, so the
26
+ // module's public surface is unchanged and no caller has to know about the split.
27
+ //
28
+ // WHAT IT IS NOT. Not the join door: `visibility ∈ (public|private|stealth)` is a
29
+ // stored, owner-set policy and all three of its values are VISIBLE (ADR 0182 D2). Not
30
+ // curation: `featured` may rank among projects already on the map, never put one there
31
+ // (D3) — setProjectFeatured enforces that bound with publishableSql(). Not authority.
32
+ 'use strict';
33
+
34
+ // The two fields, and only these two (ADR 0182 D4, owner interview on R16).
35
+ const PUBLISH_GATE_FIELDS = Object.freeze(['name', 'description']);
36
+
37
+ // Is this project on the map at all? Returns the verdict AND what is missing, because
38
+ // ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
39
+ // project that cannot be published and is not told why is indistinguishable from a
40
+ // broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
41
+ function projectPublishability(project) {
42
+ const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
43
+ return { publishable: missing.length === 0, missing };
44
+ }
45
+
46
+ // The boolean alone, for predicates that do not care which field is absent.
47
+ function projectIsPublishable(project) {
48
+ return projectPublishability(project).publishable;
49
+ }
50
+
51
+ // The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
52
+ // refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
53
+ // qualifies the columns for a joined query (e.g. publishableSql('p')).
54
+ function publishableSql(alias) {
55
+ // The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
56
+ // pass a literal, but a helper that builds SQL should refuse anything that is not a
57
+ // bare identifier rather than trust every future caller to remember that.
58
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
59
+ throw new Error('publishableSql: alias must be a bare SQL identifier');
60
+ }
61
+ const q = alias ? `${alias}.` : '';
62
+ return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
63
+ }
64
+
65
+ // What is missing, as a PHRASE, so every surface that refuses says it the same way
66
+ // (task 1002335). ADR 0182 D4 requires the gate to NAME what is absent, and a list
67
+ // rendered ad hoc at each call site is how two surfaces end up telling one owner two
68
+ // different stories about one rule. ['description'] → "no description";
69
+ // ['name','description'] → "no name and no description". PURE.
70
+ function missingPhrase(missing) {
71
+ const list = (missing || []).filter(Boolean);
72
+ if (!list.length) return '';
73
+ if (list.length === 1) return `no ${list[0]}`;
74
+ return `no ${list.slice(0, -1).join(', no ')} and no ${list[list.length - 1]}`;
75
+ }
76
+
77
+ // The gate as a KERNEL SEAM PORT — `project.publishGate` (R16, task 1002335),
78
+ // registered by routes/projects.js and declared in module.json.
79
+ //
80
+ // The module one-way rule (ADR 0083) forbids `provisioning` from importing this file,
81
+ // and provisioning is the plane an owner actually stands on BEFORE their project is
82
+ // ever catalogued here — so without a port it would have to keep its own copy of the
83
+ // rule, which is exactly the drift publishableSql() exists to prevent. One definition,
84
+ // reached through the kernel switchboard.
85
+ //
86
+ // `evaluate` is deliberately tolerant of a FOREIGN row shape: a consumer maps its own
87
+ // columns onto { name, description } — provisioning's `slug` IS its project's name —
88
+ // so the gate never learns another module's schema. `fields` is published so a consumer
89
+ // can render the bar without hardcoding it.
90
+ const publishGatePort = Object.freeze({
91
+ fields: PUBLISH_GATE_FIELDS,
92
+ evaluate: (project) => projectPublishability(project),
93
+ missingPhrase,
94
+ });
95
+
96
+ module.exports = {
97
+ PUBLISH_GATE_FIELDS,
98
+ projectPublishability,
99
+ projectIsPublishable,
100
+ publishableSql,
101
+ missingPhrase,
102
+ publishGatePort,
103
+ };
@@ -24,7 +24,23 @@ function hostOfOrigin(origin) {
24
24
  try { return new URL(String(origin)).hostname.replace(/^www\./, '').toLowerCase(); } catch { return ''; }
25
25
  }
26
26
 
27
+ // Register this module's publish-gate seam (R16, task 1002335; the
28
+ // registerProvisioningSeams precedent). Idempotent — the loader calls the factory
29
+ // once at boot, but tests instantiate the router repeatedly, and a port is
30
+ // single-provider so a second registration throws.
31
+ //
32
+ // • PROVIDES port `project.publishGate` → { fields, evaluate(project) }.
33
+ // Lets provisioning — the plane an owner stands on before their project is
34
+ // catalogued here — ask THIS module's gate whether a project is on the map,
35
+ // without importing it (the ADR 0083 one-way rule) and without keeping a
36
+ // second copy of the rule that could drift from publishableSql().
37
+ function registerPublishGateSeam() {
38
+ if (api.hasProvider('project.publishGate')) return;
39
+ api.registerProvider('project.publishGate', pi.publishGatePort);
40
+ }
41
+
27
42
  module.exports = function projectsRoutes() {
43
+ registerPublishGateSeam();
28
44
  const router = express.Router();
29
45
 
30
46
  // rank: metic+archon — the full curation list (includes unfeatured + owner attribution).
@@ -99,15 +115,26 @@ module.exports = function projectsRoutes() {
99
115
  }
100
116
  });
101
117
 
102
- // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate).
118
+ // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
119
+ // BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
120
+ // projects already on the map; it is not a way ONTO it, so featuring a dark-matter
121
+ // row is refused — and the refusal names the identity field that is missing rather
122
+ // than merely saying no (D4). Un-featuring is never refused.
103
123
  router.patch('/projects/:id/featured', requireBuilder, requirePermission('project.curate'), async (req, res) => {
104
124
  const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
105
125
  if (id === null) return;
106
126
  if (validateOrRespond(req, res, { featured: { required: true, type: 'boolean' } })) return;
107
127
  try {
108
- const project = await pi.setProjectFeatured(id, req.body.featured, { pool });
109
- if (!project) return res.fail('project_not_found', 404);
110
- res.json({ project });
128
+ const out = await pi.setProjectFeatured(id, req.body.featured, { pool });
129
+ if (out.notFound) return res.fail('project_not_found', 404);
130
+ if (out.blocked) {
131
+ return res.fail('not_publishable', {
132
+ status: 409,
133
+ message: `This project is not on the public map yet, so it cannot be featured — it still has ${pi.missingPhrase(out.missing)}. Featuring promotes a project that is already published; it cannot publish one.`,
134
+ details: { missing: out.missing },
135
+ });
136
+ }
137
+ res.json({ project: out.project });
111
138
  } catch (err) {
112
139
  console.error('[platform-identity] PATCH /projects/:id/featured', err);
113
140
  res.fail('feature_toggle_failed', { status: 500, message: 'internal error' });
@@ -286,12 +286,17 @@ await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only
286
286
  assert.doesNotMatch(sel, /updated_at/, 'updated_at is curation noise — never projected');
287
287
  });
288
288
 
289
- await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
289
+ await ta('setProjectFeatured flips the flag (Archon curation) — and answers with a VERDICT', async () => {
290
290
  const pool = mockPool([{ rows: [{ id: '1', featured: true }] }]);
291
291
  const out = await P.setProjectFeatured(1, true, { pool });
292
- assert.equal(out.featured, true);
292
+ assert.equal(out.project.featured, true);
293
293
  assert.match(pool.calls[0].text, /UPDATE platform_identity_projects SET featured = \$2/);
294
294
  assert.deepEqual(pool.calls[0].params, [1, true]);
295
+ // Bounded by the publish gate since task 1002335 (ADR 0182 D3): featuring may
296
+ // promote among projects already on the map, never put one there. The full
297
+ // refusal contract — blocked vs notFound, and the un-feature escape — lives in
298
+ // tests/publish_gate.mjs.
299
+ assert.ok(pool.calls[0].text.includes(P.publishableSql()), 'the gate rides in the WHERE');
295
300
  });
296
301
 
297
302
  console.log('\nproject visibility: the stored door + the DERIVED publish gate (BV1.R04, ADR 0182):');
@@ -262,12 +262,48 @@ function registerProvisioningSeams() {
262
262
  api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
263
263
  }
264
264
 
265
+ // Is this project on the public map, and if not, what is it still missing?
266
+ // (R16, task 1002335 — the surface R15 pointed at by name.)
267
+ //
268
+ // The rule itself is ADR 0182 D4's and lives in `platform-identity`; this module
269
+ // reaches it through the kernel port `project.publishGate` rather than keeping a
270
+ // copy, because a second copy of a two-field rule is exactly the drift that
271
+ // module's single SQL definition exists to prevent (ADR 0083 forbids the import).
272
+ //
273
+ // resolveOptional, not resolve, and NOT declared in `consumes` (the lifecycle →
274
+ // reward precedent): platform-identity is hub-only (`default: false`), and the
275
+ // public map is a HUB concept — an instance running provisioning without it has no
276
+ // map to be on. The honest answer there is "no verdict" (null), never a locally
277
+ // invented one, and every surface below hides the section rather than guessing.
278
+ //
279
+ // The MAPPING is the one part this module owns: an instance's `slug` IS its
280
+ // project's name on this plane — there is no `name` column and a slug can never be
281
+ // empty — so the only identity field this plane can be missing is the description
282
+ // the optional About step (task 1002334) asks for.
283
+ function publishVerdict(row) {
284
+ const gate = api.resolveOptional('project.publishGate');
285
+ if (!gate || !row) return null;
286
+ const { publishable, missing } = gate.evaluate({ name: row.slug, description: row.description });
287
+ // The phrase travels with the verdict so the owner-facing surfaces and the hub's
288
+ // own refusal cannot word one rule two different ways.
289
+ return { publishable, missing, missing_phrase: gate.missingPhrase(missing) };
290
+ }
291
+
292
+ // The owner-facing projection every instance read returns: the allow-listed row
293
+ // plus the derived publish verdict. One helper so a new read cannot ship the row
294
+ // without the verdict and leave an owner unable to see why they are dark matter.
295
+ function ownerInstance(row) {
296
+ return { ...provisioning.publicInstance(row), publish: publishVerdict(row) };
297
+ }
298
+
265
299
  // Project an instance row for the fleet view — the owner identity + the same
266
- // allow-listed public projection an owner sees.
300
+ // projection an owner sees, verdict included: "why is this project not on the map"
301
+ // is a support question, and an operator who has to re-derive the answer will
302
+ // eventually derive a different one.
267
303
  function fleetInstance(row) {
268
304
  return {
269
305
  owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
270
- ...provisioning.publicInstance(row),
306
+ ...ownerInstance(row),
271
307
  };
272
308
  }
273
309
 
@@ -575,7 +611,7 @@ module.exports = function provisioningRoutes() {
575
611
  };
576
612
  }
577
613
  res.status(created ? 201 : 200).json({
578
- instance: provisioning.publicInstance(instance),
614
+ instance: ownerInstance(instance),
579
615
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
580
616
  queued: intentCreated,
581
617
  created,
@@ -647,7 +683,7 @@ module.exports = function provisioningRoutes() {
647
683
  for (const row of rows) {
648
684
  const open = await provisioning.getOpenIntent(pool, row.id);
649
685
  instances.push({
650
- ...provisioning.publicInstance(row),
686
+ ...ownerInstance(row),
651
687
  open_intent: open ? { action: open.action, state: open.state } : null,
652
688
  });
653
689
  }
@@ -684,7 +720,7 @@ module.exports = function provisioningRoutes() {
684
720
  // panel recovers it from server state after a reload — not from sessionStorage.
685
721
  const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
686
722
  res.json({
687
- instance: provisioning.publicInstance(inst),
723
+ instance: ownerInstance(inst),
688
724
  open_intent: open ? { action: open.action, state: open.state } : null,
689
725
  github_app: provisioning.publicGithubApp(manifest),
690
726
  });
@@ -746,6 +782,11 @@ module.exports = function provisioningRoutes() {
746
782
  description: updated.description || null,
747
783
  detail: provisioning.projectDetail(updated),
748
784
  options: provisioning.PROJECT_DETAIL_VOCAB,
785
+ // The whole point of the step, answered in the same breath (R16, task
786
+ // 1002335): an owner who just wrote a description learns HERE that the
787
+ // project is now on the map, and one who cleared it learns it just left.
788
+ // Re-deriving it from a follow-up GET would let the two answers disagree.
789
+ publish: publishVerdict(updated),
749
790
  });
750
791
  } catch (err) {
751
792
  log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
@@ -1342,3 +1383,8 @@ module.exports = function provisioningRoutes() {
1342
1383
 
1343
1384
  // Re-export the seam registrar for tests that assert the port is registered.
1344
1385
  module.exports.registerProvisioningSeams = registerProvisioningSeams;
1386
+ // …and the publish-gate projection (task 1002335), so a test can pin the one
1387
+ // thing this module owns about the gate — the slug-is-the-name mapping — and
1388
+ // prove it answers with the REAL port rather than a copy of the rule.
1389
+ module.exports.publishVerdict = publishVerdict;
1390
+ module.exports.ownerInstance = ownerInstance;
@@ -54,6 +54,22 @@
54
54
  cosmos vocabulary, scoped to #view-manage there (task 1003345) ── */
55
55
  #view-manage .empty{text-align:left;}
56
56
  #view-manage .empty p{margin-bottom:0;}
57
+ /* the publish gate's one field (task 1002335). The description is prose, so it
58
+ is a textarea — and it wears a skin scoped to THIS view, because the wizard's
59
+ field rules are scoped to #view-new and borrowing them would couple the two.
60
+ Same grammar as those fields, though: one bottom hairline that takes ink on
61
+ focus, never a box — the manage page is cosmos vocabulary throughout. */
62
+ #view-manage textarea{
63
+ width:100%;font-family:var(--font-body);font-size:15.5px;font-weight:400;color:var(--ink);line-height:1.5;
64
+ background:none;border:0;border-bottom:1px solid var(--rule);border-radius:0;padding:8px 0;outline:none;
65
+ resize:vertical;min-height:64px;display:block;
66
+ transition:border-color .15s ease;
67
+ }
68
+ #view-manage textarea::placeholder{color:var(--ink-faint);font-weight:400;}
69
+ #view-manage textarea:focus{border-bottom-color:var(--ink);box-shadow:none;}
70
+ /* the verdict leads the card — it is the answer, not fine print */
71
+ #view-manage .pubVerdict{font-size:15px;font-weight:500;line-height:1.55;color:var(--ink-soft);margin-bottom:14px;}
72
+ #view-manage .pubVerdict b{font-weight:700;color:var(--ink);}
57
73
 
58
74
  /* The bongo pair on the New-project view. Component-scoped custom properties,
59
75
  never :root (C1). The drum head is SKIN — using --bg-card would turn it black
@@ -690,6 +706,29 @@ summary{min-height:24px;padding:3px 0;}
690
706
  <div class="actions" id="mActs" hidden></div>
691
707
  </div>
692
708
 
709
+ <!-- live since task 1002335 (BV1.R16): the PUBLISH GATE. A project is on
710
+ the public map if and only if it has a name and a description — that
711
+ is the whole bar (ADR 0182 D4), identity only, deliberately not a
712
+ completeness check. Until it is met the project is DARK MATTER: off
713
+ the map and un-joinable. This card is the "name what is missing" half
714
+ of that decision, because a project that cannot be published and is
715
+ not told why is indistinguishable from a broken one.
716
+ The verdict rendered here is the SERVER's (instance.publish, derived
717
+ from the one gate definition platform-identity owns and hands over
718
+ the kernel seam) — never re-derived in the browser, which is how two
719
+ surfaces start telling one owner two different stories about one rule.
720
+ Hidden outright when the server sent no verdict: a platform with no
721
+ public map has nothing to say here, and inventing a local rule to
722
+ fill the silence is exactly what the seam exists to prevent. -->
723
+ <div class="card mSec" id="mPublish" hidden>
724
+ <h2>On the public map</h2>
725
+ <div id="mPub"><p class="finePrint">Reading the project…</p></div>
726
+ <details class="finePrint" id="mPubWhat">
727
+ <summary>What puts a project on the map</summary>
728
+ <p>Two things, and only two: a name — this project already has one — and a description of what it is. Modules, artwork, a finished setup interview: none of them are required, because the bar is there to keep empty shells off the map, not to make you finish anything. Until it is met the project is not listed publicly and nobody can ask to join it; the project itself runs exactly the same either way. Adding a description puts it on the map, and clearing one takes it back off.</p>
729
+ </details>
730
+ </div>
731
+
693
732
  <!-- its address (task 1003355): the bring-your-own-domain control, moved
694
733
  off the list row when a row shrank to view and manage. Its home is
695
734
  here, under the ledger's address fact. The section hides itself where
@@ -4577,6 +4616,22 @@ summary{min-height:24px;padding:3px 0;}
4577
4616
  : '<a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) + '">Give it an address on its own page</a>.');
4578
4617
  }
4579
4618
 
4619
+ /* the publish gate on the LIST card (task 1002335, BV1.R16). Only the refusal
4620
+ earns a line here: being on the map is the expected state, and a card that
4621
+ announces it for every healthy project is noise — while a project that is
4622
+ invisible and never told why reads as broken (ADR 0182 D4). It names what is
4623
+ missing in the server's own words and points at the one page that fixes it.
4624
+ A torn-down project is silent: nothing about the public map is its problem
4625
+ while it is off. */
4626
+ function publishRow(inst) {
4627
+ var v = inst && inst.publish;
4628
+ if (!v || v.publishable) return '';
4629
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
4630
+ return 'Not on the public map yet — it has ' + esc(v.missing_phrase || 'no description') +
4631
+ '. <a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) +
4632
+ '">Say what it is</a> and it goes on.';
4633
+ }
4634
+
4580
4635
  function renderCard(inst) {
4581
4636
  /* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
4582
4637
  lookup goes through liFor(), which CSS-escapes — the two escapes are
@@ -4598,6 +4653,8 @@ summary{min-height:24px;padding:3px 0;}
4598
4653
  (meta.length ? '<div class="projMeta">' + meta.map(function (m) { return '<span>' + m + '</span>'; }).join('') + '</div>' : '');
4599
4654
  var addr = addressRow(inst);
4600
4655
  if (addr) body += '<div class="addr">' + addr + '</div>';
4656
+ var pub = publishRow(inst);
4657
+ if (pub) body += '<div class="projMsg">' + pub + '</div>';
4601
4658
  if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
4602
4659
  /* once a retry is queued the strip tells the story — the stale error
4603
4660
  paragraph would just contradict it (owner walk, 2026-08-12). Also
@@ -5111,6 +5168,11 @@ summary{min-height:24px;padding:3px 0;}
5111
5168
  the GitHub read: entry + tab return, never the in-flight tick */
5112
5169
  if (!keepRepo) loadVisibility(inst);
5113
5170
  if (!keepRepo) loadJoinability(inst);
5171
+ /* the publish gate (task 1002335) needs no second read — the verdict
5172
+ rode in on the instance itself. It sits behind the same keepRepo
5173
+ guard because its card holds a textarea the in-flight tick must
5174
+ never repaint out from under a typing owner. */
5175
+ if (!keepRepo) renderPublish(inst);
5114
5176
  })
5115
5177
  .catch(function (err) {
5116
5178
  var m = String(err && err.message);
@@ -5256,6 +5318,101 @@ summary{min-height:24px;padding:3px 0;}
5256
5318
  if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
5257
5319
  });
5258
5320
 
5321
+ /* ── on the public map (task 1002335, BV1.R16): the publish gate ──────────
5322
+ The verdict is the SERVER's — instance.publish, derived from the ONE gate
5323
+ definition platform-identity owns and publishes over the kernel seam (ADR
5324
+ 0182 D4). This page renders it and offers the one identity field that can
5325
+ be missing; it never re-derives the rule, because a browser-side copy is
5326
+ how the card and the server start disagreeing about whether a project is
5327
+ published. No verdict at all (an instance whose platform runs no public
5328
+ map) hides the card rather than guessing.
5329
+
5330
+ Rendered on ENTRY and after a save — deliberately NOT from renderManage,
5331
+ which the fetch-free minute tick and the in-flight watch both call: this
5332
+ card holds a textarea, and a repaint mid-sentence would eat what the owner
5333
+ is typing. Nothing the server does in the background changes the answer
5334
+ anyway; only this form does. ── */
5335
+
5336
+ function pubMsg(html) { $('mPub').innerHTML = html; }
5337
+
5338
+ function renderPublish(inst) {
5339
+ var v = inst && inst.publish;
5340
+ if (!v) { $('mPublish').hidden = true; return; }
5341
+ $('mPublish').hidden = false;
5342
+ var on = !!v.publishable;
5343
+ /* the refusal NAMES what is absent, in the server's own words (ADR 0182
5344
+ D4) — a project told only "no" is indistinguishable from a broken one */
5345
+ var verdict = on
5346
+ ? '<b>' + esc(inst.slug) + ' is on the public map.</b> People can find it and ask to join.'
5347
+ : '<b>' + esc(inst.slug) + ' is not on the public map yet</b> — it has ' +
5348
+ esc(v.missing_phrase || 'no description') +
5349
+ '. Nobody can find it there or ask to join until it does.';
5350
+ pubMsg('<p class="pubVerdict">' + verdict + '</p>' +
5351
+ '<label class="wizField" for="mPubDesc"><span>What is this project?' +
5352
+ ' <small>' + (on ? 'Change it any time.' : 'A sentence or two is plenty.') + '</small></span>' +
5353
+ '<textarea id="mPubDesc" maxlength="600" rows="3" placeholder="A weather simulator you can play with in the browser."></textarea></label>' +
5354
+ '<div class="actions"><button type="button" class="btn small" data-pub-save>' +
5355
+ (on ? 'Save' : 'Put it on the map') + '</button></div>' +
5356
+ '<div class="projMsg" id="mPubMsg" role="status" aria-live="polite"></div>');
5357
+ /* the owner's own prose is set as a PROPERTY, never interpolated into the
5358
+ markup above — what someone typed about their project is not HTML */
5359
+ $('mPubDesc').value = inst.description || '';
5360
+ }
5361
+
5362
+ /* Saving sends the description and takes the server's verdict WHOLE. An empty
5363
+ box is a real answer, not a no-op: it clears the description and the project
5364
+ leaves the map — the honest consequence of deleting half the identity gate,
5365
+ which is why the receipt says so instead of just "Saved." */
5366
+ function doPubSave() {
5367
+ var ta = $('mPubDesc');
5368
+ if (!manageInst || !ta) return;
5369
+ var box = $('mPubMsg');
5370
+ var btn = document.querySelector('#mPub [data-pub-save]');
5371
+ if (btn) btn.disabled = true;
5372
+ box.className = 'projMsg'; box.textContent = 'Saving…';
5373
+ var asked = manageId; /* a stale answer must never repaint a newer project */
5374
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
5375
+ method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5376
+ body: JSON.stringify({ description: ta.value })
5377
+ }).then(function (res) {
5378
+ return jsonOrEmpty(res).then(function (data) {
5379
+ if (asked !== manageId) return;
5380
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
5381
+ if (!res.ok) {
5382
+ var e = apiError(data, res.status);
5383
+ box.className = 'projMsg err';
5384
+ box.textContent = e.message || 'The description could not be saved — try again in a moment.';
5385
+ return;
5386
+ }
5387
+ manageInst.description = data.description || null;
5388
+ manageInst.publish = data.publish || manageInst.publish;
5389
+ renderPublish(manageInst);
5390
+ var m = $('mPubMsg'); /* the re-render replaced the box this closure held */
5391
+ m.className = 'projMsg ok';
5392
+ m.textContent = (manageInst.publish && manageInst.publish.publishable)
5393
+ ? 'Saved — it’s on the public map.'
5394
+ : 'Saved. With no description it stays off the public map.';
5395
+ /* the list card carries the same verdict, and the owner may go back */
5396
+ if (byId[manageInst.id]) {
5397
+ byId[manageInst.id].description = manageInst.description;
5398
+ byId[manageInst.id].publish = manageInst.publish;
5399
+ }
5400
+ });
5401
+ }).catch(function () {
5402
+ if (asked !== manageId) return;
5403
+ var b = $('mPubMsg');
5404
+ if (b) { b.className = 'projMsg err'; b.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
5405
+ }).then(function () {
5406
+ var b = document.querySelector('#mPub [data-pub-save]');
5407
+ if (b) b.disabled = false;
5408
+ });
5409
+ }
5410
+
5411
+ $('mPub').addEventListener('click', function (ev) {
5412
+ var t = ev.target.closest && ev.target.closest('button');
5413
+ if (t && t.hasAttribute('data-pub-save')) doPubSave();
5414
+ });
5415
+
5259
5416
  /* ── who can see it (task 1003043): the platform-visibility toggle. Two
5260
5417
  reads, kept apart on purpose: the CHOICE comes from the platform's own
5261
5418
  settings row (what the owner asked for), and the line under it reads what
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.508",
3
+ "version": "1.19.509",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.508",
9
+ "version": "1.19.509",
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.508",
3
+ "version": "1.19.509",
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",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.508'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.509'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');