@bongos/core 1.19.108 → 1.19.109

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.108",
6
- "core_contract": "1.19.108",
7
- "source_commit": "bafad9a6b64800aa591f105f14a5a67cf39a54d9",
5
+ "core_version": "1.19.109",
6
+ "core_contract": "1.19.109",
7
+ "source_commit": "9e8a3444891adec77e52aecda57d21cd47e0e408",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-14T13:14:28.346Z",
9
+ "built_at": "2026-08-14T14:23:16.828Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 301,
12
+ "docs_redacted": 302,
13
13
  "agent_docs_stubbed": 21,
14
14
  "functional_verbatim": 1236,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1558,
20
- "tree_sha256": "7130b0e01712f13175140a4ddc52041177b8f660fab0029699212a445ebf59ad",
19
+ "file_count": 1559,
20
+ "tree_sha256": "68310c0ca0be28e5eff0b8b223ba78bf5edbd24e7dd79018d6ac283656eaeef1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1324,10 +1324,15 @@
1324
1324
  "mode": "0000644",
1325
1325
  "sha256": "fe1c4b38b9b3eef60ecd00ecc35eb6086690350a022d2c376d1432c95c9452f7"
1326
1326
  },
1327
+ {
1328
+ "path": "docs/adr/0170-featured-projection-honest-facts.md",
1329
+ "mode": "0000644",
1330
+ "sha256": "165324f1fe4fe9a532b5a068a2713bd127d1d9e5926d3964869808ee1af1db47"
1331
+ },
1327
1332
  {
1328
1333
  "path": "docs/adr/README.md",
1329
1334
  "mode": "0000644",
1330
- "sha256": "529d997cf39669fe80bf0fb9a02c546adbb6ba139d4f8dad8de4f59932c47a95"
1335
+ "sha256": "f9bba0779b60b4a9f3014e63d49f4d5518439021f02cbab2e08d02d574edd108"
1331
1336
  },
1332
1337
  {
1333
1338
  "path": "docs/api-reference.md",
@@ -3747,7 +3752,7 @@
3747
3752
  {
3748
3753
  "path": "modules/platform-identity/platform-identity.js",
3749
3754
  "mode": "0000644",
3750
- "sha256": "2b3b1fe16be0653d498452d560521ef8ad48e359f13a1f76227f9ed94ece6f2d"
3755
+ "sha256": "2a0ee2667f79cbe8eaeb1dfb47b0c518bd969e80a60e42e0f709fa83365f908e"
3751
3756
  },
3752
3757
  {
3753
3758
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3757,7 +3762,7 @@
3757
3762
  {
3758
3763
  "path": "modules/platform-identity/project-stats.js",
3759
3764
  "mode": "0000644",
3760
- "sha256": "6ce71a9bd387a2b79d4c57a6669f028a807ef88ff205f65c20c55ce3d9889214"
3765
+ "sha256": "f3d8b2ed8bc5a32dabcbdd628fd4a504a8d91e2e2ea282a2ad2c88083f88b4e8"
3761
3766
  },
3762
3767
  {
3763
3768
  "path": "modules/platform-identity/routes/my-projects.js",
@@ -3767,7 +3772,7 @@
3767
3772
  {
3768
3773
  "path": "modules/platform-identity/routes/projects.js",
3769
3774
  "mode": "0000644",
3770
- "sha256": "188583ffb979d305c57a8f0958806ee0bd813007df158d74e45a0d415a3ff6aa"
3775
+ "sha256": "3222e294691563874be9b19dda57fe879b29d0bd174edef06f6db0f483863b11"
3771
3776
  },
3772
3777
  {
3773
3778
  "path": "modules/platform-identity/routes/scouting.js",
@@ -3782,7 +3787,7 @@
3782
3787
  {
3783
3788
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3784
3789
  "mode": "0000644",
3785
- "sha256": "ead831bcf55ddf1adb247028be011d8a5a8020757e5e0e7ca49d633cecac91b0"
3790
+ "sha256": "7766d918d22a2d73f9275ab07d6273b66fc0cdcdc6d2952986e01bab265344d2"
3786
3791
  },
3787
3792
  {
3788
3793
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -4147,12 +4152,12 @@
4147
4152
  {
4148
4153
  "path": "package-lock.json",
4149
4154
  "mode": "0000644",
4150
- "sha256": "f205db41eab8846e667c61aad6739b8194db55326d38562f0e6a674ecb0cd8ce"
4155
+ "sha256": "0ad9780c25eeea40f124f750d54beeafdb191b1c6ae104754b482eae04766059"
4151
4156
  },
4152
4157
  {
4153
4158
  "path": "package.json",
4154
4159
  "mode": "0000644",
4155
- "sha256": "afc15cf17f555b46d2e616cea755061e6d6751e06b582ad9253ff9dc257a203d"
4160
+ "sha256": "0654f3a21beb49226093259215e4baaaeb1616230b7cb86223338d41b79cf546"
4156
4161
  },
4157
4162
  {
4158
4163
  "path": "public-docs/index.html",
@@ -4392,7 +4397,7 @@
4392
4397
  {
4393
4398
  "path": "public-landing/assets/cosmos.css",
4394
4399
  "mode": "0000644",
4395
- "sha256": "e5bfd01db8cca1cb28c2d5c2382a401a2ee882cb1a219ed5553d578f2e3952b6"
4400
+ "sha256": "caaae45a551f60c501f68d188b2163819fd93c4aff45e341c41098865e413c03"
4396
4401
  },
4397
4402
  {
4398
4403
  "path": "public-landing/buddha.png",
@@ -4412,7 +4417,7 @@
4412
4417
  {
4413
4418
  "path": "public-landing/projects.html",
4414
4419
  "mode": "0000644",
4415
- "sha256": "e6e951af6fc202a2d90accef69e32da7d8a3456b80a6fcdbce469274edb49bce"
4420
+ "sha256": "11d478f2f60c4cafba0eed25e4d56a561fc9d2aef4edb4c7a568040109caf191"
4416
4421
  },
4417
4422
  {
4418
4423
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5727,7 +5732,7 @@
5727
5732
  {
5728
5733
  "path": "src/module-api.js",
5729
5734
  "mode": "0000644",
5730
- "sha256": "309c33b07e1c5391726d8bf73d2c0d595cab3b9624244df0f7cd8fcbe068e3a5"
5735
+ "sha256": "5bda15ba1c10ee2642f8db5e3d1411eb4dfeefa1858eb74af2aa5f84b37d497f"
5731
5736
  },
5732
5737
  {
5733
5738
  "path": "src/module-loader/catalog.js",
@@ -6892,7 +6897,7 @@
6892
6897
  {
6893
6898
  "path": "tests/landing_page.mjs",
6894
6899
  "mode": "0000644",
6895
- "sha256": "be7364e749e00920e65f16ca685bee1e068d62adf9dc9ed0817381f2d0af4c7e"
6900
+ "sha256": "89a9d21ee1c5b00ee09de2d888873fa13c1b2b6d15ab05a6bfeb7e7a5d016a03"
6896
6901
  },
6897
6902
  {
6898
6903
  "path": "tests/lifecycle-card.mjs",
@@ -7247,12 +7252,12 @@
7247
7252
  {
7248
7253
  "path": "tests/project_stats.mjs",
7249
7254
  "mode": "0000644",
7250
- "sha256": "21bd2a2d0ebb234cd3b9770d126e77ce48f5c630fe7deee2585706d4eb072b53"
7255
+ "sha256": "6bd78c47b6dbbdd81f862df8afdc0b514e27f0ffb71d14161ac8feb19f9fbe19"
7251
7256
  },
7252
7257
  {
7253
7258
  "path": "tests/projects_hub.mjs",
7254
7259
  "mode": "0000644",
7255
- "sha256": "6ddec55050ca5864670d02caad6ede76e5e0753afb1e5636036b75d4185e9427"
7260
+ "sha256": "9a4de4c40bf9272143e3098c80fa8d8473ae2b441a0f8f6746c50431bfb12d9f"
7256
7261
  },
7257
7262
  {
7258
7263
  "path": "tests/provision.mjs",
@@ -0,0 +1,24 @@
1
+ # 0170 — The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-14
5
+ - **Task:** [#1002778](https://cloudbongos.com/builders#/task/1002778) (goal 1000061 — the front door and the project hub)
6
+ - **Extends:** the C6 honesty rule ([`docs/design/projects-hub-direction.md`](../design/projects-hub-direction.md) §7) and the self-report enrichment pattern of task 1002771 (`project-stats.js`).
7
+
8
+ ## Context
9
+
10
+ Task 1002778 enriches the hub's project card with real metadata. Its brief listed `updated_at` under "already available, no new plumbing: add it to the public projection", and offered two owner options: "resolve it to a real login + avatar via the project's own API, or omit the field until it can be answered." Implementation surfaced facts that changed both calls, and this ADR records the deviations so they are decisions, not drift.
11
+
12
+ ## Decision
13
+
14
+ **1. `created_at` joins the public `/projects/featured` projection, labelled "Listed" — never "Created".** The column is set once by `DEFAULT now()` on INSERT and never touched again: it is the moment the hub first catalogued the row, not when the project began (Example predates its row by months). The card wears the honest label, and the C6 card test bans the word "Created" outright.
15
+
16
+ **2. `updated_at` stays OUT of the projection, deviating from the task brief.** Reading the code showed the column is bumped by `upsertProject`'s ON CONFLICT branch — which fires on **every client re-registration and secret rotation** — and by `setProjectFeatured`, the Archon's own curation toggle. As a public "last updated" it would claim project activity while actually reporting curation noise: a project's timestamp would refresh because someone clicked "feature". Publishing it would be the same class of lie the em-dash rules exist to prevent. The projection test now pins the SELECT list itself (`updated_at` may sort, never project; `SELECT *` is banned — the route is CORS-open and rank:public, so a star projection would leak `owner_github_id` and `status`).
17
+
18
+ **3. The owner fact is the project's own archon row — `owner_github_id` stays unread.** The catalog's `owner_github_id` calls itself "advisory attribution (not an authority)" in its own migration, is NULL on most live rows, and the design contract forbids reading it. But the leaderboard payload `project-stats.js` already downloads for the builders count carries `rank`, `github_login`, and `avatar_url` per row — and this platform's own vocabulary defines the archon as the instance's owner (`selfRel()`: "Archon is the instance's owner"). So the enrichment surfaces the archon row's login + avatar: the project's own authority answering for itself, zero extra requests, the 2-fetch-per-origin pin intact. Values are type-checked and length-capped server-side (third-party payload), and the avatar URL is scheme-checked again client-side before it becomes an `<img>`.
19
+
20
+ ## Consequences
21
+
22
+ - Every card fact is something some authority actually asserted; unknowns hide rather than dash. A "last updated" fact can return later if a project's **own** API ever reports real activity recency (e.g. newest `shipped_at` from `/public/recent-shipped`) — that would be a third fetch and needs the test pin moved deliberately.
23
+ - On a multi-archon instance the top-credits archon is named; a zero-credit archon on a page-capped leaderboard can fall off, which renders as **no owner shown**, never a wrong one.
24
+ - The hub republishes a login the source project already serves unauthenticated at `/api/bongos/public/leaderboard` — aggregation, not new disclosure (grader-reviewed).
@@ -243,5 +243,6 @@ This keeps the decision history honest and traceable.
243
243
  | 0168 | [**The scaffold vendors the hub's RUNNING core (`npm pack` of the installed package); registry-default deferred to the go-public step** ([task 1002706](https://cloudbongos.com/builders#/task/1002706); gap 13 of the project-creation audit). Two collided facts: children were born pinned to a frozen `file:vendor/bongos-core-1.19.2.tgz` (installedCoreTarball reuses the hub's own file: pin — task 1002275 — and the hub's pin sat at 1.19.2), and the task-1002621 registry cutover (hub pin now `"@bongos/core": "1.19.56"`, verified on the box) made installedCoreTarball return null — dropping the scaffold into the package-core.js source rebuild that ABORTS on a consumer instance (empty core set → empty child repo, the 1002275 failure resurrected). Fix: a three-rung ladder — file:-pin reuse → NEW `installedCorePackDir()` + `npm pack` of the hub's installed core (child pins the SAME, CURRENT core, stays file:-pinned, owner needs no token) → source rebuild. The brief's "make registry the default now that the package is public" was verified FALSE (both npm scopes 404 anonymously; ADR 0134 keeps them private until go-public) — a registry-default child would be born UNINSTALLABLE for a tokenless owner. Registry-default is deferred to the go-public step (idea 1000700); the gap-13 "flip the box env" ops leg is explicitly a no-op until then; child freshness rides this ladder + the hub-pin lane (task 1002708). Rejected: registry-default now, dropping NPM_TOKEN, vendoring from the npm cacache.](<redacted>.md) | provisioning / distribution |
244
244
  | 0167 | [**The /create gate trust link defaults to the AGPL-3.0 license text, not the npm package page** ([task 1002705](https://cloudbongos.com/builders#/task/1002705); gap 1 of the project-creation audit). The gate's "open source" link pointed at a nonexistent repo (404); the task brief's suggested default — the npm package page — is ALSO dead for the gate's actual audience (signed-out visitors): `@cloudbongos/core` and `@bongos/core` both 404 anonymously (private scopes, ADR 0134), and the ADR 0099 public mirror stays dormant until ~2026-11. The license text is the one live, instance-neutral artifact behind the claim today. The browsable-code upgrade is the owner's filed choice (idea 1000700): public repo now (forfeits the ADR 0065 §4 redaction window) / public npm package / wait for the mirror. Rejected: the npm link (verified dead), flipping the repo public (not a builder's call), plain unlinked text (less information, same cost).](<redacted>.md) | public landing / governance |
245
245
  | 0169 | [**`bongos serve`: the `bongos dev` boot path, on a real host** ([task 1002398](https://cloudbongos.com/builders#/task/1002398), goal 1000052). [ADR 0149](<redacted>.md) gave an owner one command to stand an instance up on their laptop; "now put it on a server" had no equally good answer — Docker serves the *vanilla* brand on `:3000`, `bongos onboard` provisions a whole droplet through the control plane, and [`instance-deploy.md`](../recipes/instance-deploy.md) left the systemd unit as a hand-written prose step. The six steps (deps → Postgres → createdb → migrate → env → server → health) were already right for a host; only four *decisions inside them* were laptop-shaped. So: a `--server` **mode** on the same script (`bongos serve`), never a second boot path, so the two can't drift. (1) **Supervision** — a systemd unit (`Restart=always`, `WantedBy=multi-user.target`) instead of a pid file that dies on reboot, falling back to the pid-managed process where systemd is absent rather than refusing. (2) **Bind address, derived from the origin** — `https://…` means a proxy terminates TLS, so bind `127.0.0.1`; plain `http://` on a public host means nothing is in front, so bind `0.0.0.0`. The common misconfiguration (app publicly reachable on its raw port behind a proxy) becomes impossible by default. (3) **Peer-auth bootstrap** — a fresh `apt-get install postgresql` cluster has only the `postgres` role, so a deploy user's `createdb` fails with `role "deploy" does not exist`; that reads like a broken install and is really an unbootstrapped cluster, so we create the login role as the superuser. (4) **Health means the PUBLIC origin** — the loopback probe proves the app; DNS/proxy/TLS are where a host actually fails, so it probes the advertised origin too and names the missing layer (advisory — DNS may legitimately not be wired). Secrets go to a root-owned `0600` `EnvironmentFile`, never `Environment=` (`systemctl show` leaks those, and the laptop path's `buildEnv()` spreads the caller's whole shell). Every decision is a pure injectable in `dev-lib.js`, proved in `tests/dev_lib.mjs` with no host. Rejected: a separate `deploy.sh` (drift — the point is the *same* six steps), Docker-on-the-host (makes brand+port a compose override), always binding `0.0.0.0` (exposes the raw port), refusing without systemd (breaks containers/BSD).](<redacted>.md) | onboarding / deploy |
246
+ | 0170 | [**The featured projection tells time honestly: `created_at` in, `updated_at` out, owner from the project's own archon** ([task 1002778](https://cloudbongos.com/builders#/task/1002778), goal 1000061). The hub card's metadata pass added `created_at` to the public `/projects/featured` projection, labelled **Listed** — it is the moment the hub catalogued the row, never when the project began, and the C6 card test bans the word "Created". Two recorded deviations: (1) the task brief said `updated_at` was ready to add, but the column is bumped by every client re-registration AND the Archon's feature toggle, so as a public "last updated" it would report curation noise as project activity — it may sort, never project, and the test pins the SELECT list itself (`SELECT *` banned: the route is CORS-open rank:public and a star would leak `owner_github_id`/`status`). (2) The owner fact reads the project's OWN leaderboard archon row (login + avatar, type-checked and capped) from the payload project-stats already fetches — zero extra requests, the 2-fetch pin intact, the advisory `owner_github_id` column still unread per the design contract. Multi-archon: top-credits archon is named; a fallen-off-the-page archon renders as no owner shown, never a wrong one. Rejected: projecting `updated_at` as asked (it lies), reading `owner_github_id` (advisory, contract-banned), a third per-origin fetch for activity recency (moves the test pin for a fact nobody asked for yet).](<redacted>.md) | platform identity / public surface |
246
247
 
247
248
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -238,10 +238,14 @@ async function getProject(id, { pool = defaultPool } = {}) {
238
238
  }
239
239
 
240
240
  // The PUBLIC Explore listing — featured + active projects only, public projection
241
- // (no owner_github_id, no status). Newest first.
241
+ // (no owner_github_id, no status). Newest first. created_at is the LISTED date —
242
+ // when the hub first catalogued the row, not when the project began — and the
243
+ // card labels it "Listed" for exactly that reason (task 1002778). updated_at may
244
+ // sort but is never projected: feature toggles and client re-registration bump
245
+ // it, so as a public "last updated" it would lie.
242
246
  async function listFeaturedProjects({ pool = defaultPool } = {}) {
243
247
  const { rows } = await pool.query(
244
- `SELECT origin, name, tagline, art_url
248
+ `SELECT origin, name, tagline, art_url, created_at
245
249
  FROM platform_identity_projects
246
250
  WHERE featured = true AND status = 'active'
247
251
  ORDER BY updated_at DESC`,
@@ -136,8 +136,26 @@ async function fetchStats(origin) {
136
136
  ]);
137
137
 
138
138
  let builders = null;
139
+ let owner = null;
140
+ let ownerAvatar = null;
139
141
  if (lb.status === 'fulfilled' && Array.isArray(lb.value?.leaderboard)) {
140
- builders = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login)).length;
142
+ const people = lb.value.leaderboard.filter((b) => !isSystemAccount(b?.github_login));
143
+ builders = people.length;
144
+ // The archon row IS the instance's owner — the project's own authority
145
+ // answering for itself, from a payload already in hand (ADR 0170). The
146
+ // catalog's owner_github_id ("advisory attribution (not an authority)")
147
+ // stays unread. Caveat: the leaderboard is credits-ordered and page-capped,
148
+ // so a zero-credit archon on a very large instance can fall off it — that
149
+ // renders as no owner shown, never as a wrong one.
150
+ // The payload is third-party: accept only a plausible login string (GitHub
151
+ // caps logins at 39 chars; 64 leaves headroom without hosting a novel),
152
+ // and only an http(s) avatar URL — the client scheme-checks again before
153
+ // it becomes an <img>.
154
+ const arch = people.find((b) => b?.rank === 'archon');
155
+ const login = arch?.github_login;
156
+ owner = typeof login === 'string' && login ? login.slice(0, 64) : null;
157
+ const av = arch?.avatar_url;
158
+ ownerAvatar = owner && typeof av === 'string' && /^https?:\/\//.test(av) ? av.slice(0, 300) : null;
141
159
  }
142
160
 
143
161
  let tags = null;
@@ -149,7 +167,7 @@ async function fetchStats(origin) {
149
167
  .sort();
150
168
  }
151
169
 
152
- return { builders, tags };
170
+ return { builders, tags, owner, owner_avatar: ownerAvatar };
153
171
  }
154
172
 
155
173
  function refresh(origin) {
@@ -163,7 +181,7 @@ function refresh(origin) {
163
181
  // Remember the FAILURE for a full TTL too. Without this a permanently
164
182
  // unreachable project is re-fetched on every request and pays the timeout
165
183
  // every time — the exact way one dead project slows the whole page.
166
- const stats = { builders: null, tags: null };
184
+ const stats = { builders: null, tags: null, owner: null, owner_avatar: null };
167
185
  _cache.set(origin, { at: Date.now(), stats });
168
186
  return stats;
169
187
  })
@@ -197,7 +215,7 @@ async function getProjectStats(origins = []) {
197
215
  // null-prototype: an origin literally equal to '__proto__' would otherwise
198
216
  // retarget the object's prototype instead of adding a key
199
217
  const out = Object.create(null);
200
- for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null };
218
+ for (const origin of origins) out[origin] = _cache.get(origin)?.stats || { builders: null, tags: null, owner: null, owner_avatar: null };
201
219
  return out;
202
220
  }
203
221
 
@@ -119,6 +119,8 @@ module.exports = function projectsRoutes() {
119
119
  ...p,
120
120
  builders: stats[p.origin]?.builders ?? null,
121
121
  tags: stats[p.origin]?.tags ?? null,
122
+ owner: stats[p.origin]?.owner ?? null,
123
+ owner_avatar: stats[p.origin]?.owner_avatar ?? null,
122
124
  })),
123
125
  });
124
126
  } catch (err) {
@@ -198,12 +198,22 @@ await ta('upsertProject upserts on normalized origin; update preserves tagline/a
198
198
  });
199
199
 
200
200
  await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
201
- const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null }] }]);
201
+ const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
202
202
  const out = await P.listFeaturedProjects({ pool });
203
203
  assert.equal(out.length, 1);
204
204
  const c = pool.calls[0];
205
205
  assert.match(c.text, /WHERE featured = true AND status = 'active'/);
206
+ // Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
207
+ // not satisfy a projection guard, and SELECT * must never sneak owner/status
208
+ // onto a CORS-open rank:public route.
209
+ const sel = (c.text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
210
+ assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
211
+ assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
212
+ assert.match(sel, /created_at/, 'the LISTED date (when the row was catalogued) is public');
206
213
  assert.doesNotMatch(c.text, /owner_github_id/, 'public projection must not leak owner id');
214
+ // updated_at may sort (ORDER BY) but never project: it is bumped by feature
215
+ // toggles and client re-registration, so as a public fact it would lie.
216
+ assert.doesNotMatch(sel, /updated_at/, 'updated_at is curation noise — never projected');
207
217
  });
208
218
 
209
219
  await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.108",
3
+ "version": "1.19.109",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.108",
9
+ "version": "1.19.109",
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.108",
3
+ "version": "1.19.109",
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,6 +581,17 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
581
581
  border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);margin-bottom:18px;}
582
582
  :root[data-mode="dark"] .projCard .cardRel{color:var(--sun-lit);}
583
583
  @media (prefers-color-scheme: dark){:root:not([data-mode="light"]) .projCard .cardRel{color:var(--sun-lit);}}
584
+ /* the facts strip (task 1002778): a label/value grid, centered as a block but
585
+ read left-aligned. A hidden pair leaves no gap — the grid row collapses. */
586
+ .projCard .cardMeta{display:grid;grid-template-columns:auto auto;justify-content:center;
587
+ gap:6px 14px;margin-bottom:18px;text-align:left;}
588
+ .projCard .cardMeta dt{font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;
589
+ text-transform:uppercase;color:var(--ink-faint);align-self:center;}
590
+ .projCard .cardMeta dd{font-size:13.5px;color:var(--ink-soft);font-weight:600;}
591
+ .projCard .cardMeta .ownerAv{width:16px;height:16px;border-radius:50%;vertical-align:-3px;margin-right:6px;}
592
+ .projCard .cardMeta .tags{display:flex;gap:6px;flex-wrap:wrap;}
593
+ .projCard .cardMeta .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;
594
+ border-radius:999px;border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
584
595
  .projCard .cardActions{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;}
585
596
  .projCard .cardX{position:absolute;top:10px;right:12px;width:30px;height:30px;border-radius:50%;
586
597
  border:none;background:none;cursor:pointer;font-size:14px;color:var(--ink-faint);line-height:1;}
@@ -213,6 +213,19 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
213
213
  <h3 id="cardName"></h3>
214
214
  <p class="cardTag" id="cardTag"></p>
215
215
  <p class="cardRel" id="cardRel" hidden></p>
216
+ <!-- The facts strip. A definition list, never nested block wrappers, on
217
+ purpose: the C6 card test slices the card non-greedily up to the
218
+ first adjacent pair of closing wrapper tags, and a nested wrapper
219
+ would silently shorten what that test guards. A fact with no answer
220
+ hides, label and value together, because "Owner: dash" on the most
221
+ authoritative-looking element of the page reads as broken, not
222
+ honest (task 1002778). -->
223
+ <dl class="cardMeta" id="cardMeta" hidden>
224
+ <dt id="cardBuildersL" hidden>Builders</dt><dd id="cardBuilders" hidden></dd>
225
+ <dt id="cardRunsL" hidden>Runs</dt><dd id="cardRuns" hidden></dd>
226
+ <dt id="cardOwnerL" hidden>Owner</dt><dd id="cardOwner" hidden></dd>
227
+ <dt id="cardListedL" hidden>Listed</dt><dd id="cardListed" hidden></dd>
228
+ </dl>
216
229
  <div class="cardActions" id="cardActions"></div>
217
230
  </div>
218
231
  </div>
@@ -511,8 +524,10 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
511
524
  The public catalog: GET /projects/featured is the PUBLIC projection
512
525
  (featured + active only, no auth), which is what lets a signed-out visitor
513
526
  land on the map at all. The richer GET /projects needs project.curate and
514
- would 401 them. It carries origin/name/tagline/art_url and nothing else —
515
- so builders, type and tags render as "—" rather than as invented values.
527
+ would 401 them. It carries origin/name/tagline/art_url/created_at plus
528
+ the builders/tags/owner each project's OWN public API reports (task
529
+ 1002778) — anything a project could not report renders as a dash or hides,
530
+ never as an invented value.
516
531
  The orbs are placed on fixed slots: where things sit is this pass; how the
517
532
  sky moves is goal 1000031. ══ */
518
533
 
@@ -604,11 +619,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
604
619
  /* A tag is a module the project RUNS beyond the core set — a fact it declares
605
620
  about itself, not a guess about what it is. Capped so one heavily-moduled
606
621
  project cannot blow the column open; the overflow is counted, never hidden
607
- silently. null = we could not ask (em dash); [] = asked, runs nothing beyond
608
- core, which is a real answer and still reads as a dash in the cell. */
622
+ silently. null = we could not ask (em dash); [] = asked, runs nothing
623
+ beyond core — a real answer, voiced as "core" (the owner's call,
624
+ 2026-08-14) so a vanilla instance stops reading as a fetch failure. One
625
+ formatter, two views: the table cell and the card must never disagree. */
609
626
  var TAG_CAP = 3;
610
627
  function tagsHtml(tags) {
611
- if (!Array.isArray(tags) || !tags.length) return '—';
628
+ if (!Array.isArray(tags)) return '—';
629
+ if (!tags.length) return 'core';
612
630
  var shown = tags.slice(0, TAG_CAP).map(function (t) { return '<span>' + esc(t) + '</span>'; });
613
631
  if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
614
632
  return '<span class="tags">' + shown.join('') + '</span>';
@@ -791,11 +809,14 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
791
809
  2026-08-13. The card is layered OVER the links rather than replacing them,
792
810
  so ctrl/middle-click still opens a project's site directly.
793
811
 
794
- It states only what the public catalog actually carries — name, tagline,
795
- your relationship, a door — because /projects/featured selects four columns
796
- and cannot answer anything richer for a catalog row (task 1002744 owns that
797
- data path). A card that showed builders or liveness here would be inventing
798
- both, the same C6 lie the table's em dashes exist to avoid. ══ */
812
+ It states what the catalog and the project ITSELF can answer: name,
813
+ tagline, your relationship, a door — and, when known, builders, runs,
814
+ owner and the Listed date (task 1002778). Builders/runs/owner are each
815
+ project's own public API answering for itself (project-stats.js); Listed
816
+ is created_at, when the hub catalogued the row — NOT when the project
817
+ began, which is why the label is never "Created". Liveness stays out: a
818
+ catalog row is never probed, and a chip here would be the C6 lie the
819
+ table's em dashes exist to avoid. An unanswerable fact hides. ══ */
799
820
 
800
821
  var cardReturn = null; /* the element focus came from, to hand it back */
801
822
 
@@ -804,6 +825,26 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
804
825
  return null;
805
826
  }
806
827
 
828
+ /* The owner fact is a login and, when the project reported one, an avatar.
829
+ Both arrive from a third-party payload: the login is escaped, the avatar
830
+ URL scheme-checked (a javascript: URL must never become an img src). */
831
+ function ownerHtml(p) {
832
+ if (!p.owner) return '';
833
+ var av = safeHttpUrl(p.owner_avatar);
834
+ return (av ? '<img class="ownerAv" src="' + esc(av) + '" alt="" loading="lazy">' : '') +
835
+ '<span>' + esc(String(p.owner)) + '</span>';
836
+ }
837
+
838
+ /* A fact either has an answer or takes no space: value and label hide as a
839
+ pair. Every field is re-written on every open — closeCard() clears nothing,
840
+ so a stale value would otherwise survive into the next project's card. */
841
+ function cardFact(id, value, isHtml) {
842
+ var dd = $(id);
843
+ if (isHtml) dd.innerHTML = value || ''; else dd.textContent = value || '';
844
+ dd.hidden = $(id + 'L').hidden = !value;
845
+ return !!value;
846
+ }
847
+
807
848
  function openCard(p, rel, returnTo) {
808
849
  var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
809
850
  $('cardDot').style.setProperty('--acc', p.isSelf ? 'var(--accent)' : accentOf(p));
@@ -812,6 +853,16 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
812
853
  var label = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
813
854
  $('cardRel').textContent = label;
814
855
  $('cardRel').hidden = !label;
856
+ /* builders: 0 is a real answer ("nobody has joined yet"), null is "we could
857
+ not ask" and hides. runs: [] is a real answer too — the project declares
858
+ nothing beyond the core set, which the owner voiced as "core". */
859
+ var any = cardFact('cardBuilders', p.builders == null ? '' : String(p.builders));
860
+ any = cardFact('cardRuns', Array.isArray(p.tags) ? tagsHtml(p.tags) : '', true) || any;
861
+ /* when the rel chip already says "yours", naming the viewer as Owner is
862
+ noise — the fact answers strangers, not the person it describes */
863
+ any = cardFact('cardOwner', rel === 'owner' ? '' : ownerHtml(p), true) || any;
864
+ any = cardFact('cardListed', p.created_at ? listedDate(p.created_at) : '') || any;
865
+ $('cardMeta').hidden = !any;
815
866
  /* the platform's door is same-tab: / is this site, and a new tab for the
816
867
  page you are already on is a bug, not a courtesy */
817
868
  $('cardActions').innerHTML = href
@@ -909,6 +960,15 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
909
960
  if (!rows.length) return;
910
961
  var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
911
962
  $('selfBuilders').textContent = String(real.length);
963
+ /* the card reads these too: the same payload answers the platform's
964
+ builder count and its owner — the archon row — by the same rule the
965
+ server-side enrichment applies to every other project */
966
+ SELF_PROJECT.builders = real.length;
967
+ var arch = real.filter(function (b) { return b.rank === 'archon'; })[0];
968
+ if (arch && arch.github_login) {
969
+ SELF_PROJECT.owner = String(arch.github_login);
970
+ SELF_PROJECT.owner_avatar = arch.avatar_url || null;
971
+ }
912
972
  })
913
973
  .catch(function () { /* unreachable → the dash already in the markup stands */ });
914
974
  }
@@ -1092,6 +1152,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1092
1152
  function shortDate(iso) {
1093
1153
  return iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
1094
1154
  }
1155
+ /* the year matters on the card: catalogue entries sit years apart, and
1156
+ "Aug 13" with no year reads as recent activity, not a date of record */
1157
+ function listedDate(iso) {
1158
+ return iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
1159
+ }
1095
1160
  /* "since 14:02" today, "since Aug 7" earlier — for the unreachable chip */
1096
1161
  function clockOrDate(ts) {
1097
1162
  var d = new Date(ts);
package/src/module-api.js CHANGED
@@ -643,8 +643,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
643
643
  // landed since 1.19.106 with no explicit bump. run 31762781737. (task 1002620)
644
644
  // 1.19.108 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
645
645
  // landed since 1.19.107 with no explicit bump. run 31803763842. (task 1002620)
646
+ // 1.19.109 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
647
+ // landed since 1.19.108 with no explicit bump. run 31809188532. (task 1002620)
646
648
  // ---------------------------------------------------------------------------
647
- const CORE_VERSION = '1.19.108'; // CI auto-patch carrier (ADR 0161); see changelog above
649
+ const CORE_VERSION = '1.19.109'; // CI auto-patch carrier (ADR 0161); see changelog above
648
650
 
649
651
  // A namespaced logger so a module's log lines are attributable + consistent.
650
652
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -262,8 +262,9 @@ test('the proof band ships hidden so a failed or empty feed leaves no orphan hea
262
262
  const CODE = HTML.split('<script>').pop().replace(/\/\*[\s\S]*?\*\//g, '');
263
263
 
264
264
  test('the catalog claims no liveness the public feed cannot back', () => {
265
- // listFeaturedProjects projects {origin, name, tagline, art_url} — no status,
266
- // so a verification chip here would be a claim nothing checked.
265
+ // listFeaturedProjects projects {origin, name, tagline, art_url, created_at}
266
+ // plus self-reported stats — still no status, so a verification chip here
267
+ // would be a claim nothing checked.
267
268
  assert.doesNotMatch(CODE, /chip (live|maybe|down)/);
268
269
  assert.doesNotMatch(CODE, /ago/);
269
270
  });
@@ -58,6 +58,76 @@ test('system accounts are excluded — they are machines, not builders', async (
58
58
  assert.equal(out['https://8.8.8.8'].builders, 2, 'four rows, two of them machines');
59
59
  });
60
60
 
61
+ test("the owner is the project's own archon row, never the advisory catalog column", async () => {
62
+ // platform_identity_projects.owner_github_id is "advisory attribution (not an
63
+ // authority)" and stays out of every public projection. The project's own
64
+ // leaderboard — already fetched for the builders count — carries rank, and its
65
+ // archon row IS the instance's owner, reported by the one authority that knows.
66
+ stub({
67
+ 'https://8.8.8.8': {
68
+ leaderboard: [
69
+ { github_login: 'zed', rank: 'metic' },
70
+ { github_login: 'amy', rank: 'archon', avatar_url: 'https://avatars.example.com/amy.png' },
71
+ ],
72
+ enabled: {},
73
+ },
74
+ });
75
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
76
+ assert.equal(out['https://8.8.8.8'].owner, 'amy');
77
+ assert.equal(out['https://8.8.8.8'].owner_avatar, 'https://avatars.example.com/amy.png');
78
+ });
79
+
80
+ test('a non-http avatar from a third-party payload is dropped, the owner kept', async () => {
81
+ stub({
82
+ 'https://8.8.8.8': {
83
+ leaderboard: [{ github_login: 'amy', rank: 'archon', avatar_url: 'javascript:alert(1)' }],
84
+ enabled: {},
85
+ },
86
+ });
87
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
88
+ assert.equal(out['https://8.8.8.8'].owner, 'amy');
89
+ assert.equal(out['https://8.8.8.8'].owner_avatar, null);
90
+ });
91
+
92
+ test('no archon on the page means no owner claimed', async () => {
93
+ stub({
94
+ 'https://8.8.8.8': {
95
+ leaderboard: [{ github_login: 'zed', rank: 'metic' }],
96
+ enabled: {},
97
+ },
98
+ });
99
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
100
+ assert.equal(out['https://8.8.8.8'].owner, null, 'unknown must stay unknown, not guessed');
101
+ });
102
+
103
+ test('a machine account is never an owner, even wearing the archon rank', async () => {
104
+ stub({
105
+ 'https://8.8.8.8': {
106
+ leaderboard: [{ github_login: 'bfg-system', rank: 'archon' }],
107
+ enabled: {},
108
+ },
109
+ });
110
+ const out = await stats.getProjectStats(['https://8.8.8.8']);
111
+ assert.equal(out['https://8.8.8.8'].owner, null);
112
+ });
113
+
114
+ test('a project whose fetches reject answers a null owner', async () => {
115
+ // allSettled means a rejecting fetch still travels the fulfilled path —
116
+ // owner stays its initialized null, and null is what reaches the wire
117
+ globalThis.fetch = () => Promise.reject(new Error('down'));
118
+ const out = await stats.getProjectStats(['https://9.9.9.9']);
119
+ assert.equal(out['https://9.9.9.9'].owner, null);
120
+ });
121
+
122
+ test('every failure fallback carries the owner key — dead answers are null, never undefined', async () => {
123
+ // a bad-scheme origin makes publicUrl throw synchronously — the ONE path
124
+ // that reaches the cached-failure literal (a rejecting fetch never does)
125
+ const out = await stats.getProjectStats(['file:///etc/passwd']);
126
+ assert.ok('owner' in out['file:///etc/passwd'], 'the failure literal must carry the key');
127
+ assert.equal(out['file:///etc/passwd'].owner, null);
128
+ assert.ok('owner_avatar' in out['file:///etc/passwd'], 'both fallback literals share one shape');
129
+ });
130
+
61
131
  test('a tag is a NON-core module — core modules carry no information', async () => {
62
132
  // every instance runs the core set, so including it would print the same
63
133
  // fifteen tags on every row
@@ -166,8 +166,9 @@ test('catalog origins are scheme-checked before they become hrefs', () => {
166
166
  });
167
167
 
168
168
  test('liveness is never claimed for a catalog row the hub did not probe (C6)', () => {
169
- // the public feed carries origin/name/tagline/art_url and nothing else — a
170
- // chip saying "live" here would assert more than anything verified
169
+ // the public feed carries identity, the Listed date and self-reported stats
170
+ // (builders/tags/owner) — still no liveness, so a chip saying "live" here
171
+ // would assert more than anything verified
171
172
  assert.match(SCRIPT, /chip calm/, 'an unprobed catalog row gets the calm chip');
172
173
  assert.doesNotMatch(SCRIPT, /chip live[^']*'\s*\+\s*esc\(p\./,
173
174
  'no catalog row may be rendered as live');
@@ -360,12 +361,21 @@ test('focus enters the card and is handed back on close', () => {
360
361
  assert.match(SCRIPT, /cardReturn\.focus\(\)/);
361
362
  });
362
363
 
363
- test('the card claims nothing the public catalog cannot answer (C6)', () => {
364
+ test('the card claims only what the catalog and the project itself answer (C6)', () => {
364
365
  const card = (HUB.match(/<div class="cardScrim"[\s\S]*?<\/div>\s*<\/div>/) || [''])[0];
365
- // /projects/featured selects origin, name, tagline, art_url — and nothing else
366
- for (const invented of [/Builders/i, /\blive\b/i, /uptime/i, /\btags\b/i]) {
366
+ // /projects/featured serves origin, name, tagline, art_url, created_at, and the
367
+ // builders/tags/owner enrichment each project's OWN public API reports about
368
+ // itself (task 1002778). The card may state those. It still may not claim
369
+ // liveness nothing probed, and the catalogued date must never masquerade as a
370
+ // creation date — created_at is when the hub first heard of the project.
371
+ for (const invented of [/\blive\b/i, /uptime/i, /\bCreated\b/]) {
367
372
  assert.doesNotMatch(card, invented, `the card must not invent ${invented}`);
368
373
  }
374
+ for (const id of ['cardBuilders', 'cardRuns', 'cardOwner', 'cardListed']) {
375
+ assert.match(card, new RegExp(`id="${id}"`), `the ${id} fact must be real markup (C7)`);
376
+ }
377
+ assert.match(card, />Listed</, 'the catalogue date wears its honest label');
378
+ assert.doesNotMatch(card, /—/, 'an unanswerable card fact hides; it never dashes');
369
379
  });
370
380
 
371
381
  test('the row keeps its own Open link working underneath the card', () => {
@@ -421,7 +431,8 @@ test('Type left no styling behind, and the live columns kept theirs', () => {
421
431
  /* ── Real Builders + Runs (task 1002771) ────────────────────────────────────
422
432
  The columns came back only once something could fill them: each project's
423
433
  OWN public API. The distinction these pin is between "we could not ask"
424
- (null → em dash) and "we asked, the answer is none" (0 / []). ── */
434
+ (null → em dash) and "we asked, the answer is none" (0 builders; tags []
435
+ voiced as "core" since task 1002778 — the owner's call, 2026-08-14). ── */
425
436
 
426
437
  test('unknown and zero are rendered as different things', () => {
427
438
  // 0 builders is a real answer — "nobody has joined yet". null is not an
@@ -435,6 +446,33 @@ test('a project that cannot be reached degrades to a dash, not to a wrong number
435
446
  assert.ok(fn, 'tagsHtml() must exist');
436
447
  assert.match(fn, /Array\.isArray\(tags\)/, 'a non-array (null) must not be spread into the cell');
437
448
  assert.match(fn, /return '—'/);
449
+ // [] is a different fact from null and reads differently: the project WAS
450
+ // asked and declares nothing beyond the core set. One formatter serves the
451
+ // table cell and the card, so the two views cannot disagree (task 1002778).
452
+ assert.match(fn, /return 'core'/, 'an empty list is a real answer, never a dash');
453
+ });
454
+
455
+ test('each card fact is wired to its data, not just present in the markup', () => {
456
+ // Mutation-found gap: the C6 test pins the markup ids, but nothing bound
457
+ // them to openCard's data — the whole facts strip could be deleted from the
458
+ // JS with every test green. These pin the wire, one fact at a time.
459
+ assert.match(SCRIPT, /function cardFact/, 'the card fact writer must exist');
460
+ assert.match(SCRIPT, /dd\.hidden = \$\(id \+ 'L'\)\.hidden = !value/,
461
+ 'a fact with no answer hides label and value together — never a dash');
462
+ assert.match(SCRIPT, /cardFact\('cardBuilders', p\.builders == null \? '' : String\(p\.builders\)\)/,
463
+ 'builders: 0 is a real answer, null hides');
464
+ assert.match(SCRIPT, /cardFact\('cardRuns', Array\.isArray\(p\.tags\) \? tagsHtml\(p\.tags\) : ''/,
465
+ 'runs go through the one shared formatter');
466
+ assert.match(SCRIPT, /cardFact\('cardOwner', rel === 'owner' \? '' : ownerHtml\(p\), true\)/,
467
+ 'owner comes from the enrichment and yields to the rel chip saying "yours"');
468
+ const oh = (SCRIPT.match(/function ownerHtml\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
469
+ assert.ok(oh, 'ownerHtml() must exist');
470
+ assert.match(oh, /safeHttpUrl\(p\.owner_avatar\)/,
471
+ 'a third-party avatar URL is scheme-checked before it becomes an img src');
472
+ assert.match(oh, /esc\(String\(p\.owner\)\)/, 'the login is escaped');
473
+ assert.match(SCRIPT, /cardFact\('cardListed', p\.created_at \? listedDate\(p\.created_at\) : ''\)/,
474
+ 'listed is the catalogued date through the year-bearing formatter');
475
+ assert.match(SCRIPT, /year: 'numeric'/, 'a date of record carries its year');
438
476
  });
439
477
 
440
478
  test('the tag list is capped, and the overflow is counted rather than hidden', () => {
@@ -520,6 +558,15 @@ test('the table body is a sibling of the sky wrapper, not nested inside it', ()
520
558
  'the table must not sit inside the sky wrapper. open: ' + ancestors.join(' > '));
521
559
  });
522
560
 
561
+ test('the card metadata block lives inside the card, not a hideable sub-view', () => {
562
+ const ancestors = openAncestorsAt(HUB, 'id="cardMeta"');
563
+ assert.ok(ancestors.includes('projCard'), 'the facts belong to the card dialog');
564
+ for (const hidden of ['sub-sky-body', 'sub-table-body']) {
565
+ assert.ok(!ancestors.includes(hidden),
566
+ '#' + hidden + ' is toggled hidden — the facts cannot live inside it. open: ' + ancestors.join(' > '));
567
+ }
568
+ });
569
+
523
570
  /* ── The self row's Open goes somewhere (task 1002777) ──────────────────────
524
571
  Task 1002760's static self row shipped its action cell as href="#" with a
525
572
  `data-self-open` hook nobody ever wired. The row-click handler deliberately