@bongos/core 1.19.116 → 1.19.117

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.116",
6
- "core_contract": "1.19.116",
7
- "source_commit": "be42420efe0538b351e5eed4a355a47e28b430f5",
5
+ "core_version": "1.19.117",
6
+ "core_contract": "1.19.117",
7
+ "source_commit": "195266c548d657ee6924d36607d548a0e712cbf9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-14T18:14:13.252Z",
9
+ "built_at": "2026-08-14T18:51:25.372Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 302,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1236,
14
+ "functional_verbatim": 1238,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1559,
20
- "tree_sha256": "8ac36ba3efd387f4c72fecb2fde8b6d68ae8c016c5e8336c2f78a24abb5791e1",
19
+ "file_count": 1561,
20
+ "tree_sha256": "e8cb405106a8092556a788698759b8a579439a080653ec3d5a5db66d16ec8e6b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "e7bc974c5ea756d1c197c1c8e52b902049af23e335149ff76e1ade248eb623d1"
370
+ "sha256": "167b7dcd701d2a23eec6dec515778e84cb262d95c070a5a749da642e5aada701"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1337,12 +1337,12 @@
1337
1337
  {
1338
1338
  "path": "docs/api-reference.md",
1339
1339
  "mode": "0000644",
1340
- "sha256": "1a4562c2e0575f8505a22851c63f1cc3ecfd485ae5de6e725810786f692e6457"
1340
+ "sha256": "06954a3b30e235ef61a316d4f7414a2eaf2c0f2d3fe1063698e800f3655d1675"
1341
1341
  },
1342
1342
  {
1343
1343
  "path": "docs/api/openapi.json",
1344
1344
  "mode": "0000644",
1345
- "sha256": "3b5917da91883faf736528e7ba5600356f06056228a7c81c7d5e025762253699"
1345
+ "sha256": "3910ea26d65b5afc883d4b3488d605ee5786bda41ef86e280b5a180d6f6fa586"
1346
1346
  },
1347
1347
  {
1348
1348
  "path": "docs/architecture.md",
@@ -3744,6 +3744,11 @@
3744
3744
  "mode": "0000644",
3745
3745
  "sha256": "730de845214d3159cceb779ec3f20cddf2f6662bf2ada787d1c9cec483273afa"
3746
3746
  },
3747
+ {
3748
+ "path": "modules/platform-identity/migrations/platform_identity_006_project_type.sql",
3749
+ "mode": "0000644",
3750
+ "sha256": "4885d452673c2d6170835c8d7f03c101847ddb313a69dfb66c8e640f875ecdc1"
3751
+ },
3747
3752
  {
3748
3753
  "path": "modules/platform-identity/module.json",
3749
3754
  "mode": "0000644",
@@ -3752,7 +3757,7 @@
3752
3757
  {
3753
3758
  "path": "modules/platform-identity/platform-identity.js",
3754
3759
  "mode": "0000644",
3755
- "sha256": "2a0ee2667f79cbe8eaeb1dfb47b0c518bd969e80a60e42e0f709fa83365f908e"
3760
+ "sha256": "c145e51c02f147b295287b9759d5f44ffc77a533908b151b69d13781d84bc433"
3756
3761
  },
3757
3762
  {
3758
3763
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3772,7 +3777,7 @@
3772
3777
  {
3773
3778
  "path": "modules/platform-identity/routes/projects.js",
3774
3779
  "mode": "0000644",
3775
- "sha256": "3222e294691563874be9b19dda57fe879b29d0bd174edef06f6db0f483863b11"
3780
+ "sha256": "a3e25369ad9edc6aed1a8a6997d6b6cb794dce14abb3605954d535525af1a93a"
3776
3781
  },
3777
3782
  {
3778
3783
  "path": "modules/platform-identity/routes/scouting.js",
@@ -3787,7 +3792,7 @@
3787
3792
  {
3788
3793
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3789
3794
  "mode": "0000644",
3790
- "sha256": "7766d918d22a2d73f9275ab07d6273b66fc0cdcdc6d2952986e01bab265344d2"
3795
+ "sha256": "13bd93a19ec0d53642a161a01e4ec2ced6930d96f455c5fcc72fbdc43f72e1ac"
3791
3796
  },
3792
3797
  {
3793
3798
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -3829,6 +3834,11 @@
3829
3834
  "mode": "0000644",
3830
3835
  "sha256": "49dee0b12551089acc0d7f218f1cba00b1377ba581770146734b2c2f71d3bf7c"
3831
3836
  },
3837
+ {
3838
+ "path": "modules/provisioning/migrations/provisioning_007_project_type.sql",
3839
+ "mode": "0000644",
3840
+ "sha256": "ddbc1f7e659e716fcbb3ba3ec6c1237756f46dbd895bcb27a1dc925a7c782e76"
3841
+ },
3832
3842
  {
3833
3843
  "path": "modules/provisioning/module.json",
3834
3844
  "mode": "0000644",
@@ -3847,7 +3857,7 @@
3847
3857
  {
3848
3858
  "path": "modules/provisioning/provisioning.js",
3849
3859
  "mode": "0000644",
3850
- "sha256": "0363fd616966ec413d3c16bea222622ae0f9cf59a456627946c7cbdb89b7885e"
3860
+ "sha256": "9163b2b0de80a648371d03f89e0da8ff234a1cd4f038a1279f6324ae0921e0f4"
3851
3861
  },
3852
3862
  {
3853
3863
  "path": "modules/provisioning/rate-limit.js",
@@ -3857,7 +3867,7 @@
3857
3867
  {
3858
3868
  "path": "modules/provisioning/routes/provisioning.js",
3859
3869
  "mode": "0000644",
3860
- "sha256": "1880a5b0812a32feb9dc313024b3a27b1403884316f17fbaad68909872e21535"
3870
+ "sha256": "232d5ce81dc2821b9b953c7596ab385b89e2d5193891735e3220021437fd06db"
3861
3871
  },
3862
3872
  {
3863
3873
  "path": "modules/provisioning/tests/liveness.mjs",
@@ -3867,7 +3877,7 @@
3867
3877
  {
3868
3878
  "path": "modules/provisioning/tests/provisioning.mjs",
3869
3879
  "mode": "0000644",
3870
- "sha256": "e23faf9471d7d984dc86601b157aeac0518f01f9c72076045b94368aa7490b61"
3880
+ "sha256": "4a5c817d88771a62ccb5d26aea865976b7246a253b2ad7b00e11e3be856586ef"
3871
3881
  },
3872
3882
  {
3873
3883
  "path": "modules/provisioning/ui/.gitkeep",
@@ -4152,12 +4162,12 @@
4152
4162
  {
4153
4163
  "path": "package-lock.json",
4154
4164
  "mode": "0000644",
4155
- "sha256": "87bcf76333165f80466bc093e13d0ad6313d63db0e8518b083adf18cfc4c373d"
4165
+ "sha256": "2d570f38602e9cb4ab3417d45f21d61ffb2b9cef5ba9ff9354350a95af8f6190"
4156
4166
  },
4157
4167
  {
4158
4168
  "path": "package.json",
4159
4169
  "mode": "0000644",
4160
- "sha256": "f7a325a5a15a160f72580f8af598cf302a68ec9fc04a03085dcdfa341c52dc89"
4170
+ "sha256": "b9a96ae403012d889fbae5d2bd481f76fa1bc2221e5e8c8a6bd49e7494759ee9"
4161
4171
  },
4162
4172
  {
4163
4173
  "path": "public-docs/index.html",
@@ -4397,7 +4407,7 @@
4397
4407
  {
4398
4408
  "path": "public-landing/assets/cosmos.css",
4399
4409
  "mode": "0000644",
4400
- "sha256": "b615a392fea969c2b0549476843f26d5185cabc6556036e1f3337aacdeac359e"
4410
+ "sha256": "bd0b17f7a21120a9e754c88206bd718fdda1fce568df15441272af0b9004c7cb"
4401
4411
  },
4402
4412
  {
4403
4413
  "path": "public-landing/buddha.png",
@@ -4412,7 +4422,7 @@
4412
4422
  {
4413
4423
  "path": "public-landing/projects.html",
4414
4424
  "mode": "0000644",
4415
- "sha256": "961850a566b9fa797036b9ac8b12b3f0eacf5cc6f5fbabbb6230dccd02e836b8"
4425
+ "sha256": "576bc8fe1e3379dd2efa6323f889b65d9c4cbb5164b5e80716a6eee045bad002"
4416
4426
  },
4417
4427
  {
4418
4428
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5067,7 +5077,7 @@
5067
5077
  {
5068
5078
  "path": "scripts/gds/provision.js",
5069
5079
  "mode": "0000644",
5070
- "sha256": "7ad384495370312a57f7c1f0dab4770888c83afd08b89a8dc27aebae408abfd1"
5080
+ "sha256": "a10501b65207bbd72f9fc039d4cf1ef0be7f85dc832e8e95e1b26aa966e507b8"
5071
5081
  },
5072
5082
  {
5073
5083
  "path": "scripts/gds/publish-credential-check.js",
@@ -5732,7 +5742,7 @@
5732
5742
  {
5733
5743
  "path": "src/module-api.js",
5734
5744
  "mode": "0000644",
5735
- "sha256": "298ca0ddbe3bcae48f5c360ca66aea09a798f317816cbbb9180142b12a7fb6ae"
5745
+ "sha256": "1c1a8e95f442958ff1476c19e6b68fd2af5475e365e3d5203656bfb2c63f3fa4"
5736
5746
  },
5737
5747
  {
5738
5748
  "path": "src/module-loader/catalog.js",
@@ -6237,7 +6247,7 @@
6237
6247
  {
6238
6248
  "path": "tests/cosmos_token_contrast.mjs",
6239
6249
  "mode": "0000644",
6240
- "sha256": "a133a1ed438dfb589b0e31ce2dacced2e38e1047411d85e0086d4c2ff8b88f16"
6250
+ "sha256": "257acb76f7b7e8bb925b005e8a7bad96d712f0a216201aacee11762a2a2ff328"
6241
6251
  },
6242
6252
  {
6243
6253
  "path": "tests/cost.mjs",
@@ -7257,7 +7267,7 @@
7257
7267
  {
7258
7268
  "path": "tests/projects_hub.mjs",
7259
7269
  "mode": "0000644",
7260
- "sha256": "8cd0d3f4ce68085f782fbd3e359039ad6d20198bc706481f165c39ee133cea3a"
7270
+ "sha256": "ee5c944f9e3bd3d618c977e76d4e2ba3e663ce59bf7833f9427a0191717ba57d"
7261
7271
  },
7262
7272
  {
7263
7273
  "path": "tests/provision.mjs",
@@ -244,7 +244,7 @@ export interface PostMyProjectsJoinRequest { origin: string }
244
244
  export interface PostMyProjectsJoinResponse { ok: boolean; status: unknown; project: unknown; message: unknown }
245
245
  export interface PostOverrideRequestsIdDecideRequest { decision: string; note?: string }
246
246
  export interface PostProjectsInviteRequest { client_id: string; github_login: string }
247
- export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string }
247
+ export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string; type?: string }
248
248
  export interface PostProjectsResponse { project: unknown }
249
249
  export interface PostProvisioningInstancesIdDomainRequest { domain: string }
250
250
  export interface PostProvisioningInstancesIdDomainResponse { ok: boolean; changed: boolean; queued: unknown; domain: unknown; message: unknown }
@@ -252,7 +252,7 @@ export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean;
252
252
  export interface PostProvisioningInstancesIdGithubAppRequest { account?: string }
253
253
  export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
254
254
  export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
255
- export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; tier?: string; onboard_mode?: string }
255
+ export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; tier?: string; onboard_mode?: string; type?: string }
256
256
  export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
257
257
  export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
258
258
  export interface PostSearchResponse { expand?: unknown; chunk?: unknown; query?: unknown; count?: unknown; total_full_tokens?: unknown; results?: unknown }
@@ -15676,6 +15676,9 @@
15676
15676
  "art_url": {
15677
15677
  "type": "string",
15678
15678
  "maxLength": 2048
15679
+ },
15680
+ "type": {
15681
+ "type": "string"
15679
15682
  }
15680
15683
  },
15681
15684
  "required": [
@@ -15830,6 +15833,9 @@
15830
15833
  "onboard_mode": {
15831
15834
  "type": "string",
15832
15835
  "maxLength": 20
15836
+ },
15837
+ "type": {
15838
+ "type": "string"
15833
15839
  }
15834
15840
  },
15835
15841
  "required": [
@@ -353,7 +353,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
353
353
  | Method | Path | Rank | Body | Description |
354
354
  |---|---|---|---|---|
355
355
  | GET | `/api/bongos/projects` | `metic+archon` | — | the full curation list (includes unfeatured + owner attribution). |
356
- | POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `art_url` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
356
+ | POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `art_url`, `type` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
357
357
  | PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate). |
358
358
  | GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
359
359
  | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
@@ -368,7 +368,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
368
368
  | GET | `/api/bongos/provisioning/github-app/status` | `public` | — | GET /provisioning/github-app/status?state=<> — the PUBLIC progress read the onboarding callback page polls (task 2096) to know when setup… |
369
369
  | GET | `/api/bongos/provisioning/health` | `public` | — | GET /provisioning/health — liveness + the core version this module mounted against. |
370
370
  | GET | `/api/bongos/provisioning/instances` | `any-builder` | — | GET /provisioning/instances — the caller's own instances + each open intent. |
371
- | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `tier`, `onboard_mode` | POST /provisioning/instances — request a new instance. |
371
+ | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `tier`, `onboard_mode`, `type` | POST /provisioning/instances — request a new instance. |
372
372
  | GET | `/api/bongos/provisioning/instances/:id` | `any-builder` | — | GET /provisioning/instances/:id — read one of the caller's own instances. |
373
373
  | POST | `/api/bongos/provisioning/instances/:id/domain` | `any-builder` | `domain` | POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domai… |
374
374
  | POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
@@ -0,0 +1,23 @@
1
+ -- platform_identity_006_project_type.sql — a project declares its Type (task 1002772).
2
+ --
3
+ -- Owner decision 2026-08-13: type is DECLARED at creation, never guessed from
4
+ -- module flags. Nullable on purpose: every existing row predates the field and
5
+ -- must not be back-filled with a guess — the hub renders an em dash for
6
+ -- "not declared", which is honest. The closed vocabulary is the owner's:
7
+ -- game / research / business / non-profit / not-sure ("not-sure" is the escape
8
+ -- hatch so nobody is forced into a wrong answer).
9
+ --
10
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5): a new nullable column
11
+ -- plus a CHECK that only constrains non-null values. No existing row changes.
12
+ -- Idempotent: safe to re-run.
13
+
14
+ BEGIN;
15
+
16
+ ALTER TABLE platform_identity_projects
17
+ ADD COLUMN IF NOT EXISTS type text;
18
+
19
+ ALTER TABLE platform_identity_projects DROP CONSTRAINT IF EXISTS platform_identity_projects_type_check;
20
+ ALTER TABLE platform_identity_projects ADD CONSTRAINT platform_identity_projects_type_check
21
+ CHECK (type IS NULL OR type IN ('game', 'research', 'business', 'non-profit', 'not-sure'));
22
+
23
+ COMMIT;
@@ -146,7 +146,7 @@ async function getAccount(githubId, { pool = defaultPool } = {}) {
146
146
  // (task #1002109) calls this with a freshly generated secret; the plaintext is
147
147
  // hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
148
148
  // the secret + redirect_uris without a duplicate row.
149
- async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId }, { pool = defaultPool } = {}) {
149
+ async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId, type }, { pool = defaultPool } = {}) {
150
150
  const secretHash = sha256hex(secret);
151
151
  const uris = Array.isArray(redirectUris) ? redirectUris : [];
152
152
  const { rows } = await pool.query(
@@ -167,7 +167,7 @@ async function registerClient({ clientId, secret, name, redirectUris, origin, ow
167
167
  // list the moment it federates (ADR 0141 §5). Best-effort — a catalog hiccup must
168
168
  // never fail client registration. Only when an origin is known.
169
169
  if (origin) {
170
- try { await upsertProject({ origin, name, ownerGithubId }, { pool }); }
170
+ try { await upsertProject({ origin, name, ownerGithubId, type }, { pool }); }
171
171
  catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
172
172
  }
173
173
  return rows[0];
@@ -207,30 +207,39 @@ async function getClientByOrigin(origin, { pool = defaultPool } = {}) {
207
207
  // Projects catalog + Explore (ADR 0141 §5)
208
208
  // -------------------------------------------------------------------------
209
209
 
210
+ // The closed Type vocabulary (task 1002772, owner decision 2026-08-13): a project
211
+ // DECLARES its type at creation — 'not-sure' is the escape hatch so nobody is
212
+ // forced into a wrong answer. Mirrored by the CHECK in migration 006 (and by
213
+ // provisioning's copy on its own instances table).
214
+ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
215
+
210
216
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
211
217
  // federated project is catalogued, unfeatured) and by the Archon create/curate route.
212
218
  // Never touches `featured` on update — that is Archon-only via setProjectFeatured.
213
- async function upsertProject({ origin, name, tagline, artUrl, ownerGithubId }, { pool = defaultPool } = {}) {
219
+ async function upsertProject({ origin, name, tagline, artUrl, ownerGithubId, type }, { pool = defaultPool } = {}) {
214
220
  const norm = String(origin || '').replace(/\/+$/, '');
215
221
  if (!norm) throw new Error('project origin required');
222
+ // off-vocabulary → null (the DB CHECK is the backstop; a declaration is never invented)
223
+ const declaredType = PROJECT_TYPES.includes(type) ? type : null;
216
224
  const { rows } = await pool.query(
217
- `INSERT INTO platform_identity_projects (origin, name, tagline, art_url, owner_github_id)
218
- VALUES ($1, $2, $3, $4, $5)
225
+ `INSERT INTO platform_identity_projects (origin, name, tagline, art_url, owner_github_id, type)
226
+ VALUES ($1, $2, $3, $4, $5, $6)
219
227
  ON CONFLICT (origin) DO UPDATE SET
220
228
  name = EXCLUDED.name,
221
229
  tagline = COALESCE(EXCLUDED.tagline, platform_identity_projects.tagline),
222
230
  art_url = COALESCE(EXCLUDED.art_url, platform_identity_projects.art_url),
223
231
  owner_github_id = COALESCE(EXCLUDED.owner_github_id, platform_identity_projects.owner_github_id),
232
+ type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
224
233
  updated_at = now()
225
- RETURNING id, origin, name, tagline, art_url, owner_github_id, status, featured`,
226
- [norm, name || norm, tagline ?? null, artUrl ?? null, ownerGithubId ?? null],
234
+ RETURNING id, origin, name, tagline, art_url, owner_github_id, type, status, featured`,
235
+ [norm, name || norm, tagline ?? null, artUrl ?? null, ownerGithubId ?? null, declaredType],
227
236
  );
228
237
  return rows[0];
229
238
  }
230
239
 
231
240
  async function getProject(id, { pool = defaultPool } = {}) {
232
241
  const { rows } = await pool.query(
233
- `SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured
242
+ `SELECT id, origin, name, tagline, art_url, owner_github_id, type, status, featured
234
243
  FROM platform_identity_projects WHERE id = $1`,
235
244
  [id],
236
245
  );
@@ -245,7 +254,7 @@ async function getProject(id, { pool = defaultPool } = {}) {
245
254
  // it, so as a public "last updated" it would lie.
246
255
  async function listFeaturedProjects({ pool = defaultPool } = {}) {
247
256
  const { rows } = await pool.query(
248
- `SELECT origin, name, tagline, art_url, created_at
257
+ `SELECT origin, name, tagline, art_url, type, created_at
249
258
  FROM platform_identity_projects
250
259
  WHERE featured = true AND status = 'active'
251
260
  ORDER BY updated_at DESC`,
@@ -256,7 +265,7 @@ async function listFeaturedProjects({ pool = defaultPool } = {}) {
256
265
  // The Archon curation list — every active project + its featured flag.
257
266
  async function listAllProjects({ pool = defaultPool } = {}) {
258
267
  const { rows } = await pool.query(
259
- `SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured, updated_at
268
+ `SELECT id, origin, name, tagline, art_url, owner_github_id, type, status, featured, updated_at
260
269
  FROM platform_identity_projects
261
270
  WHERE status = 'active'
262
271
  ORDER BY featured DESC, updated_at DESC`,
@@ -512,6 +521,7 @@ module.exports = {
512
521
  setClientStatus,
513
522
  // projects catalog + Explore
514
523
  upsertProject,
524
+ PROJECT_TYPES,
515
525
  getProject,
516
526
  listFeaturedProjects,
517
527
  listAllProjects,
@@ -42,6 +42,7 @@ module.exports = function projectsRoutes() {
42
42
  name: { required: true, type: 'string', maxLength: 200 },
43
43
  tagline: { type: 'string', maxLength: 400 },
44
44
  art_url: { type: 'string', maxLength: 2048 },
45
+ type: { type: 'string', enum: [...pi.PROJECT_TYPES] },
45
46
  })) return;
46
47
  try {
47
48
  const project = await pi.upsertProject({
@@ -49,6 +50,7 @@ module.exports = function projectsRoutes() {
49
50
  name: req.body.name,
50
51
  tagline: req.body.tagline,
51
52
  artUrl: req.body.art_url,
53
+ type: req.body.type,
52
54
  }, { pool });
53
55
  res.status(201).json({ project });
54
56
  } catch (err) {
@@ -197,6 +197,19 @@ await ta('upsertProject upserts on normalized origin; update preserves tagline/a
197
197
  assert.doesNotMatch(c.text, /featured\s*=/);
198
198
  });
199
199
 
200
+ await ta('upsertProject carries the declared type, COALESCE-preserved like tagline (task 1002772)', async () => {
201
+ const pool = mockPool([{ rows: [{ id: '1', origin: 'https://p', type: 'game' }] }]);
202
+ await P.upsertProject({ origin: 'https://p', name: 'Proj', type: 'game' }, { pool });
203
+ const c = pool.calls[0];
204
+ assert.match(c.text, /\btype\b/, 'the declared type joins the upsert');
205
+ assert.match(c.text, /type\s*=\s*COALESCE/, 'a later typeless upsert must not erase a declaration');
206
+ assert.ok(c.params.includes('game'), 'the type value is parameterized');
207
+ // an off-vocabulary value is dropped, not thrown — the DB CHECK is the backstop
208
+ const pool2 = mockPool([{ rows: [{ id: '2' }] }]);
209
+ await P.upsertProject({ origin: 'https://q', name: 'Q', type: 'spacefaring' }, { pool: pool2 });
210
+ assert.ok(!pool2.calls[0].params.includes('spacefaring'), 'unknown types never reach the SQL');
211
+ });
212
+
200
213
  await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
201
214
  const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
202
215
  const out = await P.listFeaturedProjects({ pool });
@@ -210,6 +223,7 @@ await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only
210
223
  assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
211
224
  assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
212
225
  assert.match(sel, /created_at/, 'the LISTED date (when the row was catalogued) is public');
226
+ assert.match(sel, /\btype\b/, 'the declared type is public — the hub table reads it (task 1002772)');
213
227
  assert.doesNotMatch(c.text, /owner_github_id/, 'public projection must not leak owner id');
214
228
  // updated_at may sort (ORDER BY) but never project: it is bumped by feature
215
229
  // toggles and client re-registration, so as a public fact it would lie.
@@ -0,0 +1,24 @@
1
+ -- provisioning_007_project_type.sql — the wizard's declared project Type rides
2
+ -- the provisioning request (task 1002772).
3
+ --
4
+ -- A standalone project never federates into the hub catalog at create time, so
5
+ -- the declaration made in the create wizard has to live SOMEWHERE real the
6
+ -- moment it is filed — this column is that home. The control-plane runner
7
+ -- carries it into platform_identity_projects whenever a catalog row
8
+ -- materializes (federation), so the answer is never thrown away. Vocabulary
9
+ -- mirrors platform_identity_006_project_type.sql — both CHECKs are the
10
+ -- backstop if the two route validators ever drift.
11
+ --
12
+ -- Additive + provisioning_-namespaced (ADR 0083 §5): a new nullable column plus
13
+ -- a CHECK that only constrains non-null values. Idempotent: safe to re-run.
14
+
15
+ BEGIN;
16
+
17
+ ALTER TABLE provisioning_instances
18
+ ADD COLUMN IF NOT EXISTS type text;
19
+
20
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_type_check;
21
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_type_check
22
+ CHECK (type IS NULL OR type IN ('game', 'research', 'business', 'non-profit', 'not-sure'));
23
+
24
+ COMMIT;
@@ -29,6 +29,12 @@ const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
29
29
  // provision.js ran the greenfield path unconditionally and adopting an existing repo
30
30
  // always failed as an unrelated-history non-fast-forward (task 1002562).
31
31
  const ONBOARD_MODES = ['greenfield', 'adopt'];
32
+
33
+ // The declared project Type (task 1002772) — the wizard's answer rides the
34
+ // provisioning request so it survives until a catalog row materializes.
35
+ // Mirrors platform-identity's PROJECT_TYPES; both tables carry a matching
36
+ // CHECK, so a drift between the copies fails loudly at insert time.
37
+ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
32
38
  // 'oauth-manifest' (task 2080) is queueable too — the GitHub-App-Manifest onboarding
33
39
  // conversion. Today the runner drains it by scanning provisioning_oauth_manifests
34
40
  // (drainOAuthManifests), not the intent queue, but it stays a valid action so the
@@ -146,6 +152,10 @@ function publicInstance(row) {
146
152
  // thing you need when diagnosing a standup (task 1002562). Coalesced for rows read
147
153
  // before provisioning_004 added the column.
148
154
  onboard_mode: row.onboard_mode || 'greenfield',
155
+ // The declared project Type (task 1002772) — the hub reads it off owned
156
+ // instances so a declaration shows before the catalog carries it. Nullable:
157
+ // rows predating provisioning_007, or a declined answer, project null.
158
+ type: row.type || null,
149
159
  // The verification overlay (task 1002696, redesign direction §3): what the
150
160
  // heartbeat last OBSERVED, which is NOT what `status` claims. null = never
151
161
  // probed — a different claim from last_verified_up=false, and the UI renders
@@ -359,22 +369,24 @@ async function listAllInstances(db, { status } = {}) {
359
369
  // re-fetches the existing row and returns { created:false } so the ROUTE can
360
370
  // decide (same owner → idempotent re-request; different owner → 409). Never
361
371
  // silently reassigns ownership.
362
- async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode }) {
372
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type }) {
363
373
  const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
364
374
  // Unknown/absent → 'greenfield', matching the column default. Falling back to the
365
375
  // SAFE mode matters: greenfield refuses to push over existing history (no --force
366
376
  // anywhere), whereas defaulting an unknown value to 'adopt' would put the layering
367
377
  // path on a repo nobody said was pre-existing.
368
378
  const mode = ONBOARD_MODES.includes(onboardMode) ? onboardMode : 'greenfield';
379
+ // Type is a declaration — off-vocabulary or absent stores null, never a guess.
380
+ const declaredType = PROJECT_TYPES.includes(type) ? type : null;
369
381
  const cost = costEstimateUsd({ hostingShape: shape, tier });
370
382
  try {
371
383
  const { rows } = await db.query(
372
384
  `INSERT INTO provisioning_instances
373
- (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode)
374
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
385
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type)
386
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
375
387
  RETURNING *`,
376
388
  [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
377
- dbName ?? null, port ?? null, tier ?? null, cost, mode]
389
+ dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType]
378
390
  );
379
391
  return { instance: rows[0], created: true };
380
392
  } catch (e) {
@@ -642,7 +654,7 @@ async function costLedger(db, { limit = 500 } = {}) {
642
654
 
643
655
  module.exports = {
644
656
  // constants
645
- INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, INTENT_ACTIONS, TEARDOWN_FROM,
657
+ INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
646
658
  PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
647
659
  // pure
648
660
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
@@ -313,6 +313,8 @@ module.exports = function provisioningRoutes() {
313
313
  domain: { type: 'string', maxLength: 253 },
314
314
  tier: { type: 'string', maxLength: 40 },
315
315
  onboard_mode: { type: 'string', maxLength: 20 },
316
+ // the declared project Type (task 1002772) — optional, closed vocabulary
317
+ type: { type: 'string', enum: [...provisioning.PROJECT_TYPES] },
316
318
  })) return;
317
319
  const body = req.body || {};
318
320
  const slug = String(body.slug || '').trim().toLowerCase();
@@ -339,6 +341,7 @@ module.exports = function provisioningRoutes() {
339
341
  ownerBuilderId: req.builder.id, slug,
340
342
  targetRef: body.target_ref, hostingShape: shape,
341
343
  domain: body.domain, tier: body.tier, onboardMode,
344
+ type: body.type,
342
345
  });
343
346
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
344
347
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
@@ -373,12 +376,23 @@ module.exports = function provisioningRoutes() {
373
376
  };
374
377
  }
375
378
  }
379
+ // Same honesty for a changed Type (task 1002772): a re-request keeps the
380
+ // original declaration, and unlike domain there is no mutation route yet —
381
+ // say so rather than silently dropping the new answer.
382
+ let typeIgnored;
383
+ if (!created && body.type && body.type !== instance.type) {
384
+ typeIgnored = {
385
+ requested: body.type, current: instance.type || null,
386
+ hint: "a re-request never changes an existing instance's declared type",
387
+ };
388
+ }
376
389
  res.status(created ? 201 : 200).json({
377
390
  instance: provisioning.publicInstance(instance),
378
391
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
379
392
  queued: intentCreated,
380
393
  created,
381
394
  ...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
395
+ ...(typeIgnored ? { type_ignored: typeIgnored } : {}),
382
396
  });
383
397
  } catch (err) {
384
398
  console.error('[provisioning] POST /provisioning/instances', err);
@@ -228,5 +228,12 @@ await ta('claimNextOAuthManifest flips code_received→converting, gated on an A
228
228
  assert.match(sql, /m\.status = 'code_received' AND i\.status = 'active'/);
229
229
  });
230
230
 
231
+ t('publicInstance projects the declared type — nullable, never silently dropped (task 1002772)', () => {
232
+ const p = P.publicInstance({ id: 1, slug: 'x', status: 'active', type: 'game' });
233
+ assert.equal(p.type, 'game', 'the allow-list projection must carry the declaration');
234
+ const q = P.publicInstance({ id: 2, slug: 'y', status: 'active' });
235
+ assert.equal(q.type, null, 'a row predating provisioning_007 projects null, not undefined');
236
+ });
237
+
231
238
  console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
232
239
  process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.116",
3
+ "version": "1.19.117",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.116",
9
+ "version": "1.19.117",
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.116",
3
+ "version": "1.19.117",
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",
@@ -581,7 +581,11 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
581
581
  .tbl .colStatus{width:1%;white-space:nowrap;}
582
582
  .tbl .colGo{width:1%;white-space:nowrap;padding-left:8px;}
583
583
  .tbl .colName{width:auto;}
584
- @media (max-width:780px){.tbl .colTags{display:none;}}
584
+ .tbl .colType{width:1%;white-space:nowrap;}
585
+ /* the declared-type tag (task 1002772) — its own vocabulary: not the relationship
586
+ .pill, not the liveness .chip. Two different facts never wear the same chrome. */
587
+ .typeTag{font-family:var(--font-mono);font-size:10.5px;font-weight:500;color:var(--ink-soft);border:1px solid var(--line);border-radius:999px;padding:2px 9px;white-space:nowrap;}
588
+ @media (max-width:780px){.tbl .colTags,.tbl .colType{display:none;}}
585
589
 
586
590
  /* ── the living sky (task 1002762): camera, spotlight, hover card ──
587
591
  The camera is ONE transform on .skyCam; orbs carry only their own physics
@@ -58,7 +58,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
58
58
  over one, so both modes and a re-brand carry it for free (C1/C2/C4).
59
59
  Naming: the wizard's field wrapper is .wizField — cosmos owns .field (the
60
60
  orb map) — and its done checklist is .humanSteps — cosmos owns .steps
61
- (the four-beat rail). Its inputs are scoped to the view so the glass
61
+ (the five-beat rail). Its inputs are scoped to the view so the glass
62
62
  search pills (#mapQ, #mineQ) keep their own look. */
63
63
  .panel{display:none;}
64
64
  .panel.on{display:block;animation:fadeStep .3s ease both;}
@@ -248,6 +248,22 @@ h1[tabindex="-1"]:focus{outline:none;}
248
248
  and faint ink only reaches 3.96:1 on it (task 1002749). */
249
249
  .ghrow .priv{margin-left:auto;font-size:11px;font-weight:600;color:var(--ink-soft);background:color-mix(in srgb,var(--ink) 8%,transparent);border-radius:5px;padding:1px 6px;}
250
250
 
251
+ /* the type declaration (task 1002772): five selectable pills + a description
252
+ card themed per type via a component-scoped custom property (C1 — never a
253
+ page-local root token). No dark rules of its own: every surface is a token
254
+ or a color-mix over one, so both modes carry it (C2/C4). */
255
+ .typeCards{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px;}
256
+ .tpill{cursor:pointer;font-family:var(--font-body);font-weight:700;font-size:13.5px;color:var(--ink-soft);
257
+ background:var(--bg-card);border:2px solid var(--line);border-radius:999px;padding:9px 16px;
258
+ transition:all .12s ease;}
259
+ .tpill:hover{border-color:var(--accent);color:var(--accent-deep);}
260
+ .tpill[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:#fff;}
261
+ .typeCard{border:2px solid color-mix(in srgb,var(--type-acc) 35%,var(--bg-card));border-radius:18px;
262
+ padding:16px 18px;margin-bottom:6px;
263
+ background:linear-gradient(180deg,color-mix(in srgb,var(--type-acc) 9%,var(--bg-card)) 0%,var(--bg-card) 100%);}
264
+ .typeCard b{display:block;font-family:var(--font-display);font-size:17px;color:color-mix(in srgb,var(--type-acc) 40%,var(--ink));margin-bottom:4px;}
265
+ .typeCard p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.55;}
266
+
251
267
  /* 840px matches cosmos's createSplit collapse, the breakpoint the old /create
252
268
  used (860) — the two-up grids and the bongo cell fold together */
253
269
  @media (max-width:840px){.row2,.choices{grid-template-columns:1fr;}}
@@ -384,6 +400,7 @@ h1[tabindex="-1"]:focus{outline:none;}
384
400
  <thead>
385
401
  <tr>
386
402
  <th class="colName" data-sort="name" aria-sort="ascending">Project</th>
403
+ <th class="colType">Type</th>
387
404
  <th class="right" data-sort="builders">Builders</th>
388
405
  <th class="colTags">Runs</th>
389
406
  <th class="right colStatus">Status</th>
@@ -400,6 +417,7 @@ h1[tabindex="-1"]:focus{outline:none;}
400
417
  <td class="colName"><span class="pname"><span class="pdot" style="--acc:var(--accent)"></span>
401
418
  <span><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><br>
402
419
  <span class="ptag">this platform</span></span></span></td>
420
+ <td class="colType">—</td>
403
421
  <td class="right num" id="selfBuilders">—</td>
404
422
  <td class="colTags" id="selfTags">—</td>
405
423
  <td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
@@ -439,6 +457,7 @@ h1[tabindex="-1"]:focus{outline:none;}
439
457
  authoritative-looking element of the page reads as broken, not
440
458
  honest (task 1002778). -->
441
459
  <dl class="cardMeta" id="cardMeta" hidden>
460
+ <dt id="cardTypeL" hidden>Type</dt><dd id="cardType" hidden></dd>
442
461
  <dt id="cardBuildersL" hidden>Builders</dt><dd id="cardBuilders" hidden></dd>
443
462
  <dt id="cardRunsL" hidden>Runs</dt><dd id="cardRuns" hidden></dd>
444
463
  <dt id="cardOwnerL" hidden>Owner</dt><dd id="cardOwner" hidden></dd>
@@ -507,7 +526,7 @@ h1[tabindex="-1"]:focus{outline:none;}
507
526
 
508
527
  <!-- ══════════════════ VIEW · NEW PROJECT ══════════════════════════════════
509
528
  The whole create wizard, absorbed from /create (task 1002763; direction
510
- §4 — that route now 302s here). Six panels behind the four-beat rail; the
529
+ §4 — that route now 302s here). Seven panels behind the five-beat rail; the
511
530
  wizard block in the script below drives them. Same trust boundary as
512
531
  ever: every button here only ENQUEUES a provisioning request (ADR 0111).
513
532
  The name panel carries a static `on` so a no-script visitor still reads
@@ -517,9 +536,10 @@ h1[tabindex="-1"]:focus{outline:none;}
517
536
  <div class="card">
518
537
  <ol class="steps" aria-label="Creation steps">
519
538
  <li class="now" data-step="1"><span class="snum">1</span><span class="slbl">Name</span></li>
520
- <li data-step="2"><span class="snum">2</span><span class="slbl">Home</span></li>
521
- <li data-step="3"><span class="snum">3</span><span class="slbl">Address</span></li>
522
- <li data-step="4"><span class="snum">4</span><span class="slbl">Create</span></li>
539
+ <li data-step="2"><span class="snum">2</span><span class="slbl">Type</span></li>
540
+ <li data-step="3"><span class="snum">3</span><span class="slbl">Home</span></li>
541
+ <li data-step="4"><span class="snum">4</span><span class="slbl">Address</span></li>
542
+ <li data-step="5"><span class="snum">5</span><span class="slbl">Create</span></li>
523
543
  </ol>
524
544
 
525
545
  <div class="createSplit">
@@ -566,11 +586,39 @@ h1[tabindex="-1"]:focus{outline:none;}
566
586
  and capture-resistant by design.</p>
567
587
  </section>
568
588
 
569
- <!-- step 2 · home (repo + mode) -->
589
+ <!-- step 2 · type (task 1002772) — a DECLARATION, closed vocabulary
590
+ (owner decision 2026-08-13): Game / Research / Business /
591
+ Non-Profit / Not Sure, selectable buttons with a themed
592
+ description card. Not Sure is the escape hatch — nobody is
593
+ forced into a wrong answer, and it can change later. -->
594
+ <section class="panel" id="panel-type" aria-labelledby="type-h">
595
+ <h1 id="type-h">What kind of project is it?</h1>
596
+ <p class="lede">Pick the shape that fits best — it labels the project on the map. Not sure is a fine answer.</p>
597
+ <form data-next="3">
598
+ <div class="typeCards" id="typeCards" role="group" aria-label="Project type">
599
+ <button type="button" class="tpill" data-type="game" aria-pressed="false">Game</button>
600
+ <button type="button" class="tpill" data-type="research" aria-pressed="false">Research</button>
601
+ <button type="button" class="tpill" data-type="business" aria-pressed="false">Business</button>
602
+ <button type="button" class="tpill" data-type="non-profit" aria-pressed="false">Non-Profit</button>
603
+ <button type="button" class="tpill" data-type="not-sure" aria-pressed="false">Not Sure</button>
604
+ </div>
605
+ <div class="typeCard" id="typeCard" hidden aria-live="polite">
606
+ <b id="typeCardName"></b>
607
+ <p id="typeCardDesc"></p>
608
+ </div>
609
+ <div class="hint" id="h-projtype">&nbsp;</div>
610
+ <div class="nav">
611
+ <button type="button" class="btn ghost" data-back="1">← Back</button>
612
+ <button type="submit" class="btn">Next →</button>
613
+ </div>
614
+ </form>
615
+ </section>
616
+
617
+ <!-- step 3 · home (repo + mode) -->
570
618
  <section class="panel" id="panel-home" aria-labelledby="home-h">
571
619
  <h1 id="home-h">Where the code lives</h1>
572
620
  <p class="lede">Every project is a real GitHub repository that you own. Start from a clean slate, or bring a repo you already have.</p>
573
- <form data-next="3">
621
+ <form data-next="4">
574
622
  <div class="choices" role="group" aria-label="Starting point">
575
623
  <button type="button" class="choice" id="c-green" aria-pressed="true">
576
624
  <!-- token-built, not platform orange — public-landing/ ships wholesale to
@@ -629,19 +677,19 @@ h1[tabindex="-1"]:focus{outline:none;}
629
677
  </div>
630
678
  <div class="hint" id="h-home">&nbsp;</div>
631
679
  <div class="nav">
632
- <button type="button" class="btn ghost" data-back="1">← Back</button>
680
+ <button type="button" class="btn ghost" data-back="2">← Back</button>
633
681
  <button type="submit" class="btn">Next →</button>
634
682
  </div>
635
683
  </form>
636
684
  </section>
637
685
 
638
- <!-- step 3 · address — a CONFIRMATION, not a form (task 1002704: every project
686
+ <!-- step 4 · address — a CONFIRMATION, not a form (task 1002704: every project
639
687
  gets its slug subdomain assigned up front; opt-out lives in the disclosure).
640
688
  The domainless default was a proven dead-end (QA walk, task 1002702). -->
641
689
  <section class="panel" id="panel-address" aria-labelledby="addr-h">
642
690
  <h1 id="addr-h">Its address in the sky</h1>
643
691
  <p class="lede">The handle names your project on the platform — and its address comes with it. Sign-in, sharing, everything just works from the moment it stands up.</p>
644
- <form data-next="4">
692
+ <form data-next="5">
645
693
  <label class="wizField"><span>Project handle</span>
646
694
  <input type="text" id="f-slug" placeholder="mercury" autocomplete="off" maxlength="40" spellcheck="false">
647
695
  <div class="hint" id="h-slug">&nbsp;</div>
@@ -663,13 +711,13 @@ h1[tabindex="-1"]:focus{outline:none;}
663
711
  </details>
664
712
  </div>
665
713
  <div class="nav">
666
- <button type="button" class="btn ghost" data-back="2">← Back</button>
714
+ <button type="button" class="btn ghost" data-back="3">← Back</button>
667
715
  <button type="submit" class="btn">Next →</button>
668
716
  </div>
669
717
  </form>
670
718
  </section>
671
719
 
672
- <!-- step 4 · review + create -->
720
+ <!-- step 5 · review + create -->
673
721
  <section class="panel" id="panel-review" aria-labelledby="rev-h">
674
722
  <h1 id="rev-h">Ready to roll</h1>
675
723
  <p class="lede">One tap files the request and the platform starts standing your project up — server, database, address. Here's the beat you're about to drop:</p>
@@ -677,7 +725,7 @@ h1[tabindex="-1"]:focus{outline:none;}
677
725
  <div class="alert" id="createAlert" role="alert"></div>
678
726
  <div class="nav" style="flex-direction:column;align-items:stretch;">
679
727
  <button type="button" class="btn big" id="doCreate">Create my project</button>
680
- <button type="button" class="btn ghost" data-back="3" style="align-self:flex-start;margin-top:10px;">← Back</button>
728
+ <button type="button" class="btn ghost" data-back="4" style="align-self:flex-start;margin-top:10px;">← Back</button>
681
729
  </div>
682
730
  <p class="finePrint">This files the request; the control plane does the real standup and nothing touches servers from this page. You'll get the few steps only you can do right after.</p>
683
731
  </section>
@@ -1029,6 +1077,25 @@ h1[tabindex="-1"]:focus{outline:none;}
1029
1077
  if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
1030
1078
  return '<span class="tags">' + shown.join('') + '</span>';
1031
1079
  }
1080
+ /* The declared-type vocabulary (task 1002772; owner decision 2026-08-13).
1081
+ One formatter for the table cell + the card, like tagsHtml: null means
1082
+ "not declared" and renders an em dash — never a guess. The per-type accent
1083
+ reuses the ORB_ACCENTS five-token palette (component-scoped, C1). */
1084
+ var TYPE_META = {
1085
+ 'game': { label: 'Game', acc: 'var(--accent)', desc: 'A world to play in. Agents build the world, the rules, and the play itself.' },
1086
+ 'research': { label: 'Research', acc: 'var(--sea)', desc: 'A question being worked. Agents gather the evidence, run the experiments, and write up what holds — the repo is the lab notebook.' },
1087
+ 'business': { label: 'Business', acc: 'var(--terracotta)', desc: 'A venture with customers. Agents build the product and the plumbing behind it.' },
1088
+ 'non-profit': { label: 'Non-Profit', acc: 'var(--gold-soft)', desc: 'A cause, not a profit line. Agents build for a mission and the community around it.' },
1089
+ 'not-sure': { label: 'Not Sure', acc: 'var(--rule-soft)', desc: 'Still finding its shape. Pick this now — nothing depends on it.' }
1090
+ };
1091
+ function typeLabel(t) {
1092
+ var m = TYPE_META[t];
1093
+ return m ? m.label : '';
1094
+ }
1095
+ function typeHtml(t) {
1096
+ var l = typeLabel(t);
1097
+ return l ? '<span class="typeTag">' + l + '</span>' : '—';
1098
+ }
1032
1099
  function matchesMapQuery(p, q) {
1033
1100
  if (!q) return true;
1034
1101
  /* an owned entry also answers by slug and address — the same fields the
@@ -1116,6 +1183,7 @@ h1[tabindex="-1"]:focus{outline:none;}
1116
1183
  unreachable — and an em dash says exactly that. 0 is a real answer and
1117
1184
  renders as 0, because "nobody has joined yet" is a different fact from
1118
1185
  "we could not ask". */
1186
+ '<td class="colType">' + typeHtml(p.type) + '</td>' +
1119
1187
  '<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
1120
1188
  '<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
1121
1189
  /* liveness on a CATALOG row is NOT probed by this surface — a chip
@@ -1146,6 +1214,9 @@ h1[tabindex="-1"]:focus{outline:none;}
1146
1214
  var h = hostOf(p.origin);
1147
1215
  catalogHosts[h] = 1;
1148
1216
  p._inst = ownedByHost[h] || null;
1217
+ /* the catalog may predate the declaration the owner filed with the
1218
+ create — their own instance row is a truthful source, never a guess */
1219
+ if (p.type == null && p._inst && p._inst.type) p.type = p._inst.type;
1149
1220
  });
1150
1221
  var browse = catalog.slice();
1151
1222
  ownedInstances.forEach(function (inst) {
@@ -1154,7 +1225,7 @@ h1[tabindex="-1"]:focus{outline:none;}
1154
1225
  browse.push({
1155
1226
  name: inst.slug, tagline: inst.domain || '',
1156
1227
  origin: inst.domain ? 'https://' + inst.domain : '',
1157
- builders: null, tags: null,
1228
+ builders: null, tags: null, type: inst.type || null,
1158
1229
  _inst: inst, _acc: ORB_ACCENTS[slugAccent(inst.slug)]
1159
1230
  });
1160
1231
  });
@@ -1282,7 +1353,8 @@ h1[tabindex="-1"]:focus{outline:none;}
1282
1353
  /* builders: 0 is a real answer ("nobody has joined yet"), null is "we could
1283
1354
  not ask" and hides. runs: [] is a real answer too — the project declares
1284
1355
  nothing beyond the core set, which the owner voiced as "core". */
1285
- var any = cardFact('cardBuilders', p.builders == null ? '' : String(p.builders));
1356
+ var any = cardFact('cardType', typeLabel(p.type));
1357
+ any = cardFact('cardBuilders', p.builders == null ? '' : String(p.builders)) || any;
1286
1358
  any = cardFact('cardRuns', Array.isArray(p.tags) ? tagsHtml(p.tags) : '', true) || any;
1287
1359
  /* when the rel chip already says "yours", naming the viewer as Owner is
1288
1360
  noise — the fact answers strangers, not the person it describes */
@@ -1929,9 +2001,13 @@ h1[tabindex="-1"]:focus{outline:none;}
1929
2001
  var root = $('view-new');
1930
2002
 
1931
2003
  /* ── state ── */
1932
- var DRAFT_KEY = 'cb-create-draft';
2004
+ /* -v2 since task 1002772: the step indices renumbered (Type joined as 2),
2005
+ so a draft written under the five-step numbering must be ignored, not
2006
+ reinterpreted — a stale step 4 would land on Address and skip the Type
2007
+ gate with the rail already marking it done. */
2008
+ var DRAFT_KEY = 'cb-create-draft-v2';
1933
2009
  var CREATED_KEY = 'cb-created-instance';
1934
- var state = { step: 0, name: '', mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
2010
+ var state = { step: 0, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
1935
2011
  try { Object.assign(state, JSON.parse(sessionStorage.getItem(DRAFT_KEY) || '{}')); } catch (e) {}
1936
2012
  var authKnown = false; /* flips true when the boot's /me settles, either way */
1937
2013
 
@@ -1945,7 +2021,7 @@ h1[tabindex="-1"]:focus{outline:none;}
1945
2021
  state.name = handedName.trim().slice(0, 80);
1946
2022
  state.slug = deriveSlug(state.name); state.slugTouched = false;
1947
2023
  state.repo = deriveRepo(state.name); state.repoTouched = false;
1948
- state.step = state.step >= 1 && state.step <= 4 ? state.step : 1;
2024
+ state.step = state.step >= 1 && state.step <= 5 ? state.step : 1;
1949
2025
  try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(state)); } catch (e) {}
1950
2026
  }
1951
2027
  } catch (e) {}
@@ -2044,8 +2120,10 @@ h1[tabindex="-1"]:focus{outline:none;}
2044
2120
  try { sessionStorage.setItem(DRAFT_KEY, JSON.stringify(state)); } catch (e) {}
2045
2121
  }
2046
2122
 
2047
- /* ── step navigation ── */
2048
- var panels = { 0: 'panel-gate', 1: 'panel-name', 2: 'panel-home', 3: 'panel-address', 4: 'panel-review', 5: 'panel-done' };
2123
+ /* ── step navigation ──
2124
+ Six steps since task 1002772: gate 0 · name 1 · type 2 · home 3 ·
2125
+ address 4 · review 5 · done 6 (the rail shows 1-5; done marks it all). */
2126
+ var panels = { 0: 'panel-gate', 1: 'panel-name', 2: 'panel-type', 3: 'panel-home', 4: 'panel-address', 5: 'panel-review', 6: 'panel-done' };
2049
2127
  function show(step, moveFocus) {
2050
2128
  state.step = step;
2051
2129
  Object.keys(panels).forEach(function (k) {
@@ -2054,11 +2132,11 @@ h1[tabindex="-1"]:focus{outline:none;}
2054
2132
  root.querySelectorAll('.steps li').forEach(function (li) {
2055
2133
  var n = Number(li.getAttribute('data-step'));
2056
2134
  li.classList.toggle('now', n === step);
2057
- li.classList.toggle('done', n < step || step === 5);
2135
+ li.classList.toggle('done', n < step || step === 6);
2058
2136
  });
2059
2137
  saveDraft();
2060
2138
  /* the done step takes the whole card — the bongo pair steps aside */
2061
- root.querySelector('.createSplit').classList.toggle('wizDone', step === 5);
2139
+ root.querySelector('.createSplit').classList.toggle('wizDone', step === 6);
2062
2140
  /* focus moves ONLY on a deliberate step (the hub rule setView already
2063
2141
  applies to this view: never on boot, never on a background settle) */
2064
2142
  if (moveFocus) {
@@ -2068,9 +2146,9 @@ h1[tabindex="-1"]:focus{outline:none;}
2068
2146
  /* the repo probe is authenticated — never fire it before /me settles
2069
2147
  (a pre-auth probe 401s for strangers and, worse, could seed the
2070
2148
  draft's owner with myLogin()'s placeholder) */
2071
- if (step === 2) { syncHome(); if (authKnown) ensureRepoAccess(); }
2072
- if (step === 3) renderAddrPanel();
2073
- if (step === 4) renderReview();
2149
+ if (step === 3) { syncHome(); if (authKnown) ensureRepoAccess(); }
2150
+ if (step === 4) renderAddrPanel();
2151
+ if (step === 5) renderReview();
2074
2152
  }
2075
2153
 
2076
2154
  /* ── field wiring ── */
@@ -2118,8 +2196,35 @@ h1[tabindex="-1"]:focus{outline:none;}
2118
2196
  $('c-adopt').addEventListener('click', function () { setMode('adopt'); });
2119
2197
  setMode(state.mode);
2120
2198
 
2199
+ /* ── the type declaration (task 1002772) ── selecting a pill fills the
2200
+ description card, themed per type via --type-acc (component-scoped, C1) */
2201
+ function paintType(t) {
2202
+ root.querySelectorAll('#typeCards .tpill').forEach(function (b) {
2203
+ b.setAttribute('aria-pressed', String(b.getAttribute('data-type') === t));
2204
+ });
2205
+ var card = $('typeCard');
2206
+ var m = t && TYPE_META[t];
2207
+ if (m) {
2208
+ card.hidden = false;
2209
+ card.style.setProperty('--type-acc', m.acc);
2210
+ $('typeCardName').textContent = m.label;
2211
+ $('typeCardDesc').textContent = m.desc;
2212
+ } else {
2213
+ card.hidden = true;
2214
+ }
2215
+ }
2216
+ root.querySelectorAll('#typeCards .tpill').forEach(function (b) {
2217
+ b.addEventListener('click', function () {
2218
+ state.projType = b.getAttribute('data-type');
2219
+ $('h-projtype').innerHTML = '&nbsp;'; $('h-projtype').className = 'hint';
2220
+ paintType(state.projType);
2221
+ saveDraft();
2222
+ });
2223
+ });
2224
+ paintType(state.projType || null);
2225
+
2121
2226
  /* ── GitHub-integrated repo picker (adopt) + auto-create (fresh) — task 1002099 / ADR 0143.
2122
- Progressive enhancement: probe GET /github/repos once we reach step 2.
2227
+ Progressive enhancement: probe GET /github/repos once we reach the Home step.
2123
2228
  200 → we hold repo access → show the picker (adopt) / name field (fresh);
2124
2229
  403 → offer to grant repo access (a ?repos=1 re-sign-in);
2125
2230
  503 (server can't store the token) or any error → the manual inputs.
@@ -2134,7 +2239,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2134
2239
 
2135
2240
  function connectRepos(e) {
2136
2241
  if (e) e.preventDefault();
2137
- /* Same-tab navigation so the sessionStorage draft round-trips back to step 2. */
2242
+ /* Same-tab navigation so the sessionStorage draft round-trips back to the Home step. */
2138
2243
  window.location.assign(API + '/auth/web/start?return=/projects%3Fview%3Dnew&repos=1');
2139
2244
  }
2140
2245
  $('ghConnectBtn').addEventListener('click', connectRepos);
@@ -2292,6 +2397,14 @@ h1[tabindex="-1"]:focus{outline:none;}
2292
2397
  return true;
2293
2398
  }
2294
2399
  if (step === 2) {
2400
+ if (!state.projType) {
2401
+ $('h-projtype').textContent = 'Pick one — Not Sure is a fine answer.';
2402
+ $('h-projtype').className = 'hint err';
2403
+ return false;
2404
+ }
2405
+ return true;
2406
+ }
2407
+ if (step === 3) {
2295
2408
  var homeErr = function (msg) { $('h-home').textContent = msg; $('h-home').className = 'hint err'; return false; };
2296
2409
  if (repoAccess === 'need-connect') return homeErr('Allow GitHub repo access to pick or create your repository — or choose “enter it manually”.');
2297
2410
  if (repoAccess === true && state.mode !== 'adopt') {
@@ -2306,7 +2419,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2306
2419
  $('h-home').innerHTML = '&nbsp;'; $('h-home').className = 'hint';
2307
2420
  return true;
2308
2421
  }
2309
- if (step === 3) {
2422
+ if (step === 4) {
2310
2423
  if (!validSlug(state.slug)) {
2311
2424
  $('h-slug').textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.';
2312
2425
  $('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
@@ -2347,6 +2460,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2347
2460
  cb.disabled = false; cb.textContent = 'Create my project';
2348
2461
  var rows = [
2349
2462
  ['Project', esc(state.name)],
2463
+ ['Type', typeLabel(state.projType) || 'Not declared'],
2350
2464
  ['Starting from', state.mode === 'adopt' ? 'A repo you already have' : 'A fresh, empty repo'],
2351
2465
  ['Repository', '<code>' + esc(state.owner + '/' + state.repo) + '</code>'],
2352
2466
  ['Handle', '<code>' + esc(state.slug) + '</code>'],
@@ -2415,6 +2529,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2415
2529
  headers: { 'Content-Type': 'application/json' },
2416
2530
  body: JSON.stringify({
2417
2531
  slug: state.slug,
2532
+ type: state.projType || undefined,
2418
2533
  hosting_shape: 'standalone',
2419
2534
  domain: resolvedDomain() || undefined,
2420
2535
  target_ref: state.owner + '/' + state.repo,
@@ -2444,7 +2559,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2444
2559
  sessionStorage.removeItem(DRAFT_KEY);
2445
2560
  } catch (e) {}
2446
2561
  renderDone();
2447
- show(5, true);
2562
+ show(6, true);
2448
2563
  startProgress(data.instance && data.instance.id);
2449
2564
  }).catch(function (err) {
2450
2565
  if (String(err && err.message) === 'signing in') return;
@@ -2452,7 +2567,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2452
2567
  if (err && err.gotoHome) {
2453
2568
  /* A repo-creation problem (name taken / reconnect) — bounce back to the Home
2454
2569
  step with the reason, so the user fixes it where the repo is chosen. */
2455
- show(2, true);
2570
+ show(3, true);
2456
2571
  $('h-home').textContent = err.homeMsg || 'Please revisit the repository step.';
2457
2572
  $('h-home').className = 'hint err';
2458
2573
  return;
@@ -2828,12 +2943,13 @@ h1[tabindex="-1"]:focus{outline:none;}
2828
2943
  live = { instStatus: null, manifest: null, manifestMsg: '' };
2829
2944
  lastStepsHtml = '';
2830
2945
  try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
2831
- state = { step: 1, name: '', mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
2946
+ state = { step: 1, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
2832
2947
  $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
2833
2948
  $('f-noaddr').checked = false;
2834
2949
  /* Re-seed the owner from the signed-in account for the next project. */
2835
2950
  if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
2836
2951
  $('h-name').innerHTML = '&nbsp;';
2952
+ paintType(null);
2837
2953
  setMode('greenfield');
2838
2954
  show(1, true);
2839
2955
  });
@@ -2858,15 +2974,15 @@ h1[tabindex="-1"]:focus{outline:none;}
2858
2974
  state.name = created.name || created.slug; state.slug = created.slug || '';
2859
2975
  state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
2860
2976
  renderDone();
2861
- show(5, !!focus);
2977
+ show(6, !!focus);
2862
2978
  startProgress(created.id);
2863
2979
  resumePolls();
2864
2980
  return;
2865
2981
  }
2866
2982
  /* before identity settles, never render past the name step: deeper steps
2867
- carry authenticated side effects (the step-2 repo probe), and onAuth()
2983
+ carry authenticated side effects (the Home-step repo probe), and onAuth()
2868
2984
  re-enters to the real draft step the moment /me answers */
2869
- var step = authKnown && state.step >= 1 && state.step <= 4 ? state.step : 1;
2985
+ var step = authKnown && state.step >= 1 && state.step <= 5 ? state.step : 1;
2870
2986
  if (step === 1) {
2871
2987
  show(1, false);
2872
2988
  if (focus) $('newName').focus();
@@ -1007,7 +1007,9 @@ async function federateInstance(inst, deps) {
1007
1007
  ownerGithubId = rows[0] ? rows[0].github_id : null;
1008
1008
  } catch { /* non-fatal */ }
1009
1009
  await platformIdentity.registerClient(
1010
- { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId },
1010
+ // type: the owner's declaration rides the instance row from the create
1011
+ // wizard (task 1002772) and lands on the catalog row here — never guessed.
1012
+ { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId, type: inst.type },
1011
1013
  { pool: db },
1012
1014
  );
1013
1015
  log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
package/src/module-api.js CHANGED
@@ -659,8 +659,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
659
659
  // landed since 1.19.114 with no explicit bump. run 31825824596. (task 1002620)
660
660
  // 1.19.116 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
661
661
  // landed since 1.19.115 with no explicit bump. run 31827642395. (task 1002620)
662
+ // 1.19.117 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
663
+ // landed since 1.19.116 with no explicit bump. run 31830559253. (task 1002620)
662
664
  // ---------------------------------------------------------------------------
663
- const CORE_VERSION = '1.19.116'; // CI auto-patch carrier (ADR 0161); see changelog above
665
+ const CORE_VERSION = '1.19.117'; // CI auto-patch carrier (ADR 0161); see changelog above
664
666
 
665
667
  // A namespaced logger so a module's log lines are attributable + consistent.
666
668
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -178,6 +178,13 @@ const PAIRS = [
178
178
  // alone do not cover them
179
179
  ['?view=new .ghrow .priv 11px', 'var(--ink-soft)', 'color-mix(in srgb,var(--ink) 8%,var(--bg-deep))', 4.5],
180
180
  ['--on-ok on an --ok fill', 'var(--on-ok)', 'var(--ok)', 4.5],
181
+ // the wizard's per-type description card (task 1002772): heading ink is a
182
+ // 40%-accent ink mix over the card's own 9%-accent tint — one row per type
183
+ ['typeCard heading · game', 'color-mix(in srgb,var(--accent) 40%,var(--ink))', 'color-mix(in srgb,var(--accent) 9%,var(--bg-card))', 4.5],
184
+ ['typeCard heading · research', 'color-mix(in srgb,var(--sea) 40%,var(--ink))', 'color-mix(in srgb,var(--sea) 9%,var(--bg-card))', 4.5],
185
+ ['typeCard heading · business', 'color-mix(in srgb,var(--terracotta) 40%,var(--ink))', 'color-mix(in srgb,var(--terracotta) 9%,var(--bg-card))', 4.5],
186
+ ['typeCard heading · non-profit', 'color-mix(in srgb,var(--gold-soft) 40%,var(--ink))', 'color-mix(in srgb,var(--gold-soft) 9%,var(--bg-card))', 4.5],
187
+ ['typeCard heading · not-sure', 'color-mix(in srgb,var(--rule-soft) 40%,var(--ink))', 'color-mix(in srgb,var(--rule-soft) 9%,var(--bg-card))', 4.5],
181
188
  ['?view=new input ::placeholder', 'var(--ink-faint)', CARD, 4.5],
182
189
  ['?view=new .ghlist .hint on deep', 'var(--ink-faint)', 'var(--bg-deep)', 4.5],
183
190
  ['--chrome-accent wordmark', 'var(--chrome-accent)', 'var(--page-bg)', 4.5],
@@ -228,11 +228,37 @@ test('/create is absorbed — the wizard lives here and the route only redirects
228
228
  const server = fs.readFileSync(path.join(ROOT, 'src', 'platform-server.js'), 'utf8');
229
229
  assert.match(server, /res\.redirect\(302, '\/projects\?view=new'/,
230
230
  '/create must 302 into the hub (302, not 301: the URL is an OAuth return target in the wild)');
231
- for (const id of ['panel-gate', 'panel-name', 'panel-home', 'panel-address', 'panel-review', 'panel-done']) {
231
+ for (const id of ['panel-gate', 'panel-name', 'panel-type', 'panel-home', 'panel-address', 'panel-review', 'panel-done']) {
232
232
  assert.match(HUB, new RegExp(`id="${id}"`), `the wizard's ${id} ships as real markup (C7)`);
233
233
  }
234
234
  });
235
235
 
236
+ test('the wizard step indices stay coupled — data-next lands one past its own panel (task 1002772)', () => {
237
+ // The whole renumbering turns on validateStep(next - 1): a form's data-next
238
+ // must sit inside panels[next - 1], or the validator guards the wrong step.
239
+ const pm = SCRIPT.match(/var panels = \{([^}]*)\}/);
240
+ assert.ok(pm, 'the panels map must exist');
241
+ const map = {};
242
+ for (const m of pm[1].matchAll(/(\d+):\s*'([a-z-]+)'/g)) map[Number(m[1])] = m[2];
243
+ const nexts = [...HUB.matchAll(/data-next="(\d+)"/g)];
244
+ assert.ok(nexts.length >= 4, 'every wizard form declares its next step');
245
+ for (const m of nexts) {
246
+ const before = HUB.slice(0, m.index);
247
+ const sec = before.lastIndexOf('id="panel-');
248
+ assert.ok(sec !== -1, 'a data-next form lives inside a panel');
249
+ const id = HUB.slice(sec + 4).match(/^(panel-[a-z]+)/)[1];
250
+ assert.equal(id, map[Number(m[1]) - 1],
251
+ `data-next="${m[1]}" must live in panels[${Number(m[1]) - 1}] — validateStep(next-1) guards that panel`);
252
+ }
253
+ for (const m of HUB.matchAll(/data-back="(\d+)"/g)) {
254
+ assert.ok(map[Number(m[1])], `data-back="${m[1]}" must point at a mapped panel`);
255
+ }
256
+ // the rail numbers every step except the gate and the done panel
257
+ const beats = [...HUB.matchAll(/data-step="(\d+)"/g)].map((x) => Number(x[1]));
258
+ assert.equal(Math.max.apply(null, beats), Object.keys(map).length - 2,
259
+ 'the rail shows every numbered step except gate and done');
260
+ });
261
+
236
262
  test('the absorbed wizard sheds its two URL landmines — /gate/ and the whole-query strip', () => {
237
263
  // create.html carried a document-level Escape that navigated to the retired
238
264
  // /gate/ (a 404 the href-only guard above cannot see: it was a
@@ -475,18 +501,21 @@ test('the row keeps its own Open link working underneath the card', () => {
475
501
  The mock's columns shipped whole — Project / Builders / Type / Tags / Status
476
502
  — but three of them rendered an em dash for every row, forever, because the
477
503
  public projection carries four fields and Type/Tags are not in the catalog
478
- schema at all. A header promising a column is a claim like any other. ── */
504
+ schema at all. A header promising a column is a claim like any other.
505
+ Builders and Runs came back with a source in task 1002771; Type came back in
506
+ task 1002772 when a project began DECLARING it at creation — a nullable
507
+ declared field, so an em dash now means "not declared", never a guess. ── */
479
508
 
480
- test('the table declares no column it has no source for', () => {
509
+ test('every table column has a source — Type is declared, never guessed', () => {
481
510
  const thead = (HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1];
482
- // Type is still absent: the owner's call is that a project DECLARES its type
483
- // at creation, so until that field exists the hub must not guess one.
484
- assert.doesNotMatch(thead, />Type</, 'Type has no source until a project declares it');
485
- // Builders and Runs came back in task 1002771 WITH a source behind them
511
+ assert.match(thead, />Type</, 'Type has a source now: the project declares it at creation (task 1002772)');
512
+ assert.match(SCRIPT, /typeHtml\(p\.type\)/, 'the Type cell must read the declared field through the one formatter');
486
513
  assert.match(thead, />Builders</);
487
514
  assert.match(thead, />Runs</);
488
515
  assert.match(SCRIPT, /p\.builders/, 'the Builders cell must read real data');
489
516
  assert.match(SCRIPT, /tagsHtml\(p\.tags\)/, 'the Runs cell must read real data');
517
+ // the wizard files the declaration with the create — the source end of the pipe
518
+ assert.match(SCRIPT, /type: state\.projType/, 'the create request carries the declared type');
490
519
  });
491
520
 
492
521
  test('header and body agree on the column count', () => {
@@ -509,11 +538,13 @@ test('no sort control is offered for a column that cannot sort', () => {
509
538
  }
510
539
  });
511
540
 
512
- test('Type left no styling behind, and the live columns kept theirs', () => {
513
- assert.ok(!COSMOS.includes('.colType'), '.colType is dead — the column is gone');
514
- for (const live of ['.tbl .num', '.tbl .tags', '.colTags']) {
515
- assert.ok(COSMOS.includes(live), `${live} styles a column that now renders real data`);
541
+ test('every live column keeps its styling — .colType included since task 1002772', () => {
542
+ for (const live of ['.tbl .num', '.tbl .tags', '.colTags', '.colType', '.typeTag']) {
543
+ assert.ok(COSMOS.includes(live), `${live} styles a column that renders real data`);
516
544
  }
545
+ // the type tag is its own vocabulary: not the relationship .pill, not the
546
+ // liveness .chip — two different facts must not wear the same chrome
547
+ assert.doesNotMatch(SCRIPT, /typeHtml[\s\S]{0,80}?class="pill"/, 'type must not wear the relationship pill');
517
548
  });
518
549
 
519
550
  /* ── Real Builders + Runs (task 1002771) ────────────────────────────────────