Skip to content

Commit cf39b83

Browse files
fix(plugin-security): the five remaining seeder refusals reach the author (#18564)
Fixes #18091 The two declared-metadata seeders refuse to write in five more places, and every one of those refusals reported through the doubly-optional `logger?.warn?.(…)`, so a caller that injected no logger got no output at all. Each site now words its own consequence, stamps its own grep token and builds its own record, over ONE shared delivery rule. ## Caliber declared: the FULL SPELLING SWEEP — and the two readings reconcile The dispatch ruled the full sweep (every occurrence of the doubly-optional spelling in the two seeders) over the card's five refusal sites. Re-measured on current `origin/main` (`79a046f8`), ⛔ not carried forward: | file | raw `grep -c` | prose (in comments) | CALL SITES | |---|---|---|---| | `bootstrap-declared-capabilities.ts` | 4 | 1 | **3** | | `bootstrap-declared-permissions.ts` | 3 | 1 | **2** | | total | **7** | **2** | **5** | ⭐ The triage seat's 7 reproduces exactly. **The delta of 2 is not two more call sites — it is prose**: `bootstrap-declared-capabilities.ts:409` and `bootstrap-declared-permissions.ts:313` are comment lines in which #18023 and #17516 each QUOTE the spelling while explaining the defect they repaired. Once comments are excluded, the full-sweep caliber and the refusal-site caliber name the SAME five call sites on this tree. So this PR is the full sweep, and the full sweep is 5. After the change: **0 call sites in either file**, and the 2 prose lines are deliberately kept — they are the historical record of why the shape exists — and are now pinned as comments-only by a class pin that reds the moment a sixth call site appears. Control that the instrument was reading: bare `logger` occurrences in the two files, 18 and 16. ## The reading this card moves (P2), measured before anything was touched Each site driven with **no logger passed**, all five console channels spied, beside the two already-repaired axes as lit controls **in the same harness**: ``` counter author-visible lines site 1 curated platform capability skippedPlatform = 1 0 site 2 capability declaration unowned skippedUnowned = 1 0 site 3 capability rows unreadable unreadable = 1 0 site 4 permission set decl. unowned (no counter at all) 0 site 4b same, via the ADR-0086 P2 door (no counter at all) 0 site 5 permission set rows unreadable unreadable = 1 0 LIT capability_name_collision skippedForeign = 1 1 LIT permission_set_name_collision skippedForeign = 1 1 ``` Every zero is now a 1, with every counter unchanged. ⚠️ **Site 4 has a second door.** `upsertPackagePermissionSet` is also the ADR-0086 P2 publish materializer, which passes no collector — a repair that only lit the boot loop would have left that caller exactly as mute. It is pinned separately. ## ⛔ No skip changed Every skip is correct under ADR-0086 D4 (a package never writes into a foreign record) and ADR-0086 D3 (a package-managed row with no `package_id` makes uninstall undefined). Each test asserts the unchanged outcome beside the new line. ## Why five diagnostics and not a mechanical copy Applied, ⛔ not re-argued: the token, the record, the wording and the CONSEQUENCE are site-specific. The five consequences are genuinely different facts — - a curated-platform-name hijack still **resolves** (the curated pass owns the row), so nothing is denied and only the authored metadata and the ADR-0086 D3 provenance claim are lost; the remedy is rename-only, because unlike a package-to-package collision (ADR-0130 D1) a curated name is not co-ownable; - an unowned **capability** has three outcomes — an existing row, the back-compat derived placeholder, or nowhere at all — decided by what already stands in `sys_capability`. That three-way wording is preserved verbatim from #4967 Part 3; - an unowned **permission set** keeps every grant working, because the evaluator resolves declared sets through the metadata registry; only the RECORD is lost (Setup surface, provenance axis, uninstall); - an unreadable read compared nothing and wrote nothing, so nothing is lost and nothing arrived either — and what that costs differs again between the two tables. One generic "declaration skipped" sentence would send the first author hunting for a broken grant that is not broken. ## What IS shared, and why that is the point of the card Exactly one thing: WHERE the line goes. This shape had been repaired instance-by-instance twice, each repair restating the same two lines at its own call site. `reportThroughSink()` is now the single derivation, so a sixth site cannot re-earn the card. It also improves on both spellings it replaces: - `logger?.warn?.(…)` bought safety against a host sink that lies about its shape **with silence** — the defect; - `logger.warn(…)` would buy noise **with a throw** inside a seeding pass, the hazard `ProjectionLogger.warn` records; - the `typeof` guard buys neither, and keeps the receiver so a class-based host logger does not throw. Pinned on both axes. Reused, ⛔ not re-derived, per the #18088 precedent: `CollisionReportSink` is imported from the sibling module rather than re-declared. ⛔ `reportPermissionSetNameCollisions` is not threaded with an axis parameter — PR #18088 measured that it does not generalise. ⚠️ **The INFO channel keeps its outer `?.` deliberately.** A pass that did its work and refused nothing must stay silent on every console channel with no sink injected — that is the discriminating control #18023 landed, and both axes now carry one. The refusal channel is the one where silence was the defect. Asserted explicitly in the class pins. ## New published surface On the criterion the two existing collision diagnostics state and no wider: a refusal an **author** can cause has a second door by construction (`@objectstack/lint`, `os build` / `os validate`), and each of these is decidable from the declaration alone with no database. - `CAPABILITY_PLATFORM_NAME_REFUSED` + `capabilityPlatformNameRefusedDiagnostic()` + `reportCapabilityPlatformNameRefused()` + the record type - `CAPABILITY_DECLARATION_UNOWNED` + `capabilityDeclarationUnownedDiagnostic()` + `reportCapabilityDeclarationUnowned()` + the record type - `PERMISSION_SET_DECLARATION_UNOWNED` + `permissionSetDeclarationUnownedDiagnostic()` + `reportPermissionSetDeclarationUnowned()` + the record type ⛔ `reportThroughSink()` is **not** published either. It was on this list in the first revision and the contract review was right to refuse it: it is the delivery primitive, not a refusal an author can cause, and it has no second-door consumer — the cli door records that it deliberately does not consume a sink printer. This package's own `logSeedDurabilityFailure` states the rule for its class verbatim — *"Deliberately absent from the package's `index.ts`: this is an intra-package helper, not public API"*. And the shape is about 390 raw hits across 29 package directories, so a shared delivery rule's home would be a core package, not this plugin's entry. The function did not move: `seed-refusal-diagnostics.ts` imports it relatively and all five report sites keep working. Verified off the built surface — `require(dist/index.js).reportThroughSink` is `undefined` and it appears in no `export` statement in `dist/index.d.ts`, while the controls `reportCapabilityDeclarationUnowned` (a function) and `CAPABILITY_DECLARATION_UNOWNED` (its token) both read live. ⛔ The two unreadable-rows summaries are **not** published: an unreadable database is a runtime condition no compile-time door can raise, so they stay package-private for the reason `position_name_fold_grant` does. Clause-②: yes — re-derived from the delivered diff (9 value + 3 type exports reachable from the published entry). ## Verification All at `cec185ad3`. **Tests.** `pnpm --filter @objectstack/plugin-security test` — **113 files / 2196 tests passed**. `pnpm --filter @objectstack/plugin-security typecheck` — clean (`tsc --noEmit`, `tsconfig.scripts.json`, and `check:test-typecheck`: 0 files / 0 errors / 0 pinned signatures). **ABLATION A1 — delivery.** `reportThroughSink`'s body mutated back to the doubly-optional spelling. On-disk proof before reading any result: guard line count 1 → 0, mute line count 0 → 1, `git diff --numstat` 1/2. Result — **10 failed | 46 passed**, and the ten are exactly the no-logger and lying-host pins at all five sites plus the publish door: ``` × UNOWNED DECLARATION: prints with NO LOGGER INJECTED, and names the RECORD as what is lost × UNOWNED DECLARATION reaches the author through the ADR-0086 P2 PUBLISH door too × UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence × each site keeps its OWN sentence × a HOST SINK THAT LIES about its shape is reported to the console, never thrown at × CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost × UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence × UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence × each site keeps its OWN sentence × a HOST SINK THAT LIES about its shape is reported to the console, never thrown at ``` **ABLATION A2 — wording.** Site 1's distinguishing clauses replaced with site 2's, simulating exactly the generic-sentence regression that is forbidden here. On-disk proof: `CURATED PLATFORM capability` 1 → 0, `The name still resolves` 1 → 0, the borrowed clause 0 → 1. Result — **2 failed | 35 passed**, and the two are the per-site wording pins while every delivery pin stays green: ``` AssertionError: expected 'warn: [security] [capability_platform…' to contain 'CURATED PLATFORM capability' AssertionError: expected 'warn: [security] [capability_platform…' to contain 'The name still resolves' ``` Both legs restored from `HEAD` under a `trap … EXIT INT TERM` with absolute paths, verified by blob-hash equality and an empty `git status --porcelain` — ⛔ not by a step's exit code. **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` over the tool's own change set (8 paths vs merge base `79a046f8c`), each exit code captured to disk before any pipe, then reconciled with `--ran`: **63 derived, 60 run green, 3 NOT MEASURED, 0 UNRUN**. The 3 are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each exiting **3 = PREREQUISITE NOT MET** — all three read whole-repo build output, and the closure build (`turbo run build --filter='./packages/*' --filter='./packages/*/*'`) was killed at 520s by this container's foreground ceiling. ⛔ Not a pass and not a finding. CI builds that closure before the same steps. ⚠️ `check:cross-package-test-inputs` exited 1 locally and is **green for this diff** — measured, not argued. Its single finding is `packages/cli/test/init-created-files-summary.e2e.test.ts` descending `packages/spec/dist/`, neither of which this diff touches. Control: with this full diff in the tree and `packages/spec/dist` moved aside, the same gate exits **0**; moved back, **1**. The finding is a function of a locally-built spec `dist`, and CI's `Lint & Repo Gates` job runs `pnpm install --frozen-lockfile` with no build, so that directory does not exist where the gate runs. **Lint.** ⛔ Not narrowed — the whole population was measured: `eslint . --no-inline-config --format json`, **6804 files** (the count eslint itself reported, not an estimate), **0 errors / 0 warnings**. ## Acceptance notes ⛔ Out of scope, filed nowhere by this PR, recorded here: - **The same doubly-optional spelling is live on a sixth site outside the two seeders.** `packages/plugins/plugin-security/src/seed-name-lookup.ts:452` reports the batched existence read's own failure through `logger?.warn?.(…)`. Measured mute here: the site-3 and site-5 cases in this PR drive an unreadable database with no logger and see exactly ONE author-visible line — this card's summary — while that one stays silent; with a logger injected the same pass produces both. It is the same class, but it is outside this card's ruled caliber (the two seeders), so it is reported for the triage seat rather than ridden in. A pin in this PR asserts the count by filtering on this card's tokens rather than counting lines, precisely so that line's removal stays visible. - **The permission-set unowned refusal increments no counter at all.** Every other refusal path on both axes moves one (`skippedPlatform`, `skippedUnowned`, `skippedForeign`, `unreadable`); this branch returns an all-zero outcome, so a caller reading no log still cannot tell it happened. Widening it means editing `PermissionSeedOutcome` in `permission-set-projection.ts`, outside this card's declared file surface. Noted, ⛔ not filed. - **The two already-landed reporters still hand-spell the delivery rule.** `reportPermissionSetNameCollisions` and `reportCapabilityNameCollisions` each carry their own `if (logger) … else console.warn(…)`, which `reportThroughSink` now supersedes and which would additionally gain the lying-host guard. Routing them through it is behaviour-preserving, but both files are outside this card's declared file surface. Noted, ⛔ not filed — it is the remaining half of closing the class. - **Cardinality is preserved, ⛔ not reconsidered.** Sites 1, 2 and 4 keep one line per refused declaration, as they had; sites 3 and 5 keep one line per pass with the count, as they had. Making the per-declaration sites summarise once per pass (the shape #17516 and #18023 chose for their collisions) would change what an injected-logger host sees and is not what this card measured. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 922c755 commit cf39b83

8 files changed

Lines changed: 1018 additions & 34 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
**The five remaining seeder refusals now reach the author.** The two declared-metadata seeders refuse to write in five more places, and every one of them reported through `logger?.warn?.(…)` — optionally chained **twice**, so a caller that injected no logger got no output at all (#18091).
6+
7+
Measured on the pre-change tree, each site driven with **no logger passed** while all five console channels were spied, beside the two already-repaired axes as lit controls in the same harness:
8+
9+
```
10+
counter author-visible lines
11+
curated platform capability refused skippedPlatform = 1 0
12+
capability declaration unowned skippedUnowned = 1 0
13+
capability rows unreadable unreadable = 1 0
14+
permission set declaration unowned (no counter at all) 0
15+
permission set rows unreadable unreadable = 1 0
16+
LIT CONTROL capability_name_collision skippedForeign = 1 1
17+
LIT CONTROL permission_set_name_… skippedForeign = 1 1
18+
```
19+
20+
Every one of those zeros is now a 1, with the counters unchanged.
21+
22+
⛔ **No skip changed.** They are correct under ADR-0086 D4 (a package never writes into a foreign record) and ADR-0086 D3 (a package-managed row with no `package_id` makes uninstall undefined). The defect was only that the refusal never reached the author who caused it.
23+
24+
**Each site words its own consequence** — the reason a mechanical copy was rejected. A curated-platform-name hijack still *resolves* against the curated row, so nothing is denied and only the authored metadata and the provenance claim are lost; an unowned **capability** has three different outcomes depending on what already stands in `sys_capability`; an unowned **permission set** keeps every grant working (the evaluator resolves declared sets through the metadata registry) and loses only the *record* — the Setup surface, the provenance axis and uninstall; and an unreadable read compared nothing, so nothing is lost and nothing arrived either. One generic "declaration skipped" line would send the first author hunting for a broken grant that is not broken.
25+
26+
**What is shared is exactly one thing: where the line goes.** This shape had already been repaired one instance at a time twice, each repair restating the same two lines at its own call site. `reportThroughSink()` is now the single derivation, so a sixth refusal site cannot re-earn this card. It also improves on both spellings it replaces: a host sink that lies about its shape used to buy safety with silence (`logger?.warn?.(…)`) or noise with a throw (`logger.warn(…)`) — the `typeof` guard buys neither, and keeps the receiver so a class-based host logger does not throw.
27+
28+
New published surface on `@objectstack/plugin-security`, on the criterion the two existing collision diagnostics state and no wider — a refusal an **author** can cause has a second door by construction (`@objectstack/lint`, `os build` / `os validate`), and both of these are decidable from the declaration alone with no database:
29+
30+
- `CAPABILITY_PLATFORM_NAME_REFUSED` / `capabilityPlatformNameRefusedDiagnostic()` / `reportCapabilityPlatformNameRefused()` and the `CapabilityPlatformNameRefusedDiagnostic` record.
31+
- `CAPABILITY_DECLARATION_UNOWNED` / `capabilityDeclarationUnownedDiagnostic()` / `reportCapabilityDeclarationUnowned()` and the `CapabilityDeclarationUnownedDiagnostic` record.
32+
- `PERMISSION_SET_DECLARATION_UNOWNED` / `permissionSetDeclarationUnownedDiagnostic()` / `reportPermissionSetDeclarationUnowned()` and the `PermissionSetDeclarationUnownedDiagnostic` record.
33+
34+
⛔ The two unreadable-rows summaries are deliberately **not** published: an unreadable database is a runtime condition no compile-time door can raise, so they stay package-private for the reason `position_name_fold_grant` does.
35+
36+
⚠️ The end-of-pass `logger?.info?.(…)` summary in each seeder keeps its outer `?.` **deliberately**. A pass that did its work and refused nothing must stay silent on every console channel with no sink injected; routing a healthy boot's info line to the console would turn that control into noise and buy no author anything. The refusal channel is the one where silence was the defect.

‎packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts‎

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { readFileSync } from 'node:fs';
4+
import { dirname, resolve } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
37
import { describe, it, expect, vi } from 'vitest';
48
import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
59
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
610
import { CAPABILITY_NAME_COLLISION } from './capability-name-collision.js';
11+
import {
12+
CAPABILITY_DECLARATION_UNOWNED,
13+
CAPABILITY_PLATFORM_NAME_REFUSED,
14+
CAPABILITY_ROWS_UNREADABLE,
15+
} from './seed-refusal-diagnostics.js';
16+
17+
/** [#18091] Seeded from this file, for the class pin at the bottom. */
18+
const HERE = dirname(fileURLToPath(import.meta.url));
719

820
/** Minimal in-memory ql for sys_capability seeding with a registry stub. */
921
function makeQl(declared: any[] = []) {
@@ -705,3 +717,201 @@ describe('[#18023] a capability-name collision reaches the author', () => {
705717
expect(out.collisions).toBeUndefined();
706718
});
707719
});
720+
721+
// ───────────────────────────────────────────────────────────────────────────
722+
// [#18091] The three refusals #18023 left behind in this seeder. Same defect,
723+
// same reading: driven with NO logger the author-visible line count was 0 at
724+
// every one of them while the already-repaired collision path in the harness
725+
// above read 1. Each case below asserts THAT SITE'S OWN sentence, so a future
726+
// regression to one generic "declaration skipped" line is caught by name.
727+
// ───────────────────────────────────────────────────────────────────────────
728+
729+
/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */
730+
function unreadableQl(declared: any[]) {
731+
const ql = makeQl(declared);
732+
(ql as any).find = async () => { throw new Error('sys_capability is unreachable'); };
733+
return ql;
734+
}
735+
736+
describe('[#18091] the three remaining capability refusals reach the author', () => {
737+
it('CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost', async () => {
738+
const ql = makeQl([{ name: 'manage_users', label: 'Evil', description: 'Hijack.', _packageId: 'com.acme.evil' }]);
739+
740+
const cap = captureAllConsole();
741+
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
742+
try {
743+
out = await bootstrapDeclaredCapabilities(ql, null, {
744+
permissionSets: [{ name: 'acme_ops', systemPermissions: ['manage_users'] }],
745+
});
746+
} finally {
747+
cap.restore();
748+
}
749+
750+
// ── The reading this card moves: 0 → 1 ──────────────────────────────────
751+
expect(out.skippedPlatform).toBe(1);
752+
expect(cap.seen).toHaveLength(1);
753+
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
754+
expect(cap.seen[0]).toContain(CAPABILITY_PLATFORM_NAME_REFUSED);
755+
expect(cap.seen[0]).toContain('manage_users');
756+
expect(cap.seen[0]).toContain('com.acme.evil');
757+
// ── THIS site's consequence, not the foreign-owner one. The curated row
758+
// answers for the name, so nothing is denied — an author sent hunting
759+
// for a broken grant is the failure this wording prevents.
760+
expect(cap.seen[0]).toContain('CURATED PLATFORM capability');
761+
expect(cap.seen[0]).toContain('The name still resolves');
762+
expect(cap.seen[0]).toContain('acme_ops');
763+
// ⛔ And the remedy is rename-only: a curated name is not co-ownable, so
764+
// the ADR-0130 D1 escape the collision diagnostic offers must NOT appear.
765+
expect(cap.seen[0]).toContain('not co-ownable');
766+
767+
// ── ⛔ The refusal itself is UNCHANGED ───────────────────────────────────
768+
expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined();
769+
expect(out.seeded).toBe(0);
770+
// …and the name is still reported materialized: the curated pass owns it.
771+
expect(out.materializedNames).toEqual(['manage_users']);
772+
});
773+
774+
it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence', async () => {
775+
const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);
776+
777+
const cap = captureAllConsole();
778+
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
779+
try {
780+
out = await bootstrapDeclaredCapabilities(ql, null, {
781+
permissionSets: [{ name: 'showcase_ops', systemPermissions: ['showcase.export_data'] }],
782+
});
783+
} finally {
784+
cap.restore();
785+
}
786+
787+
expect(out.skippedUnowned).toBe(1);
788+
expect(cap.seen).toHaveLength(1);
789+
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
790+
expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
791+
expect(cap.seen[0]).toContain('has no owning package');
792+
// [#4967 Part 3] The grantor and the ACTUAL consequence, both preserved —
793+
// this arm is "a row will be derived", which is not the other two arms.
794+
expect(cap.seen[0]).toContain('showcase_ops');
795+
expect(cap.seen[0]).toContain('derived placeholder');
796+
expect(cap.seen[0]).not.toContain('materialized nowhere');
797+
// ⛔ Unchanged: no row is written for an unowned declaration.
798+
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined();
799+
});
800+
801+
it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => {
802+
const ql = unreadableQl([
803+
{ name: 'a.one', _packageId: 'com.a' },
804+
{ name: 'a.two', _packageId: 'com.a' },
805+
]);
806+
807+
const cap = captureAllConsole();
808+
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
809+
try {
810+
out = await bootstrapDeclaredCapabilities(ql, null);
811+
} finally {
812+
cap.restore();
813+
}
814+
815+
expect(out.unreadable).toBe(2);
816+
expect(cap.seen).toHaveLength(1);
817+
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
818+
expect(cap.seen[0]).toContain(CAPABILITY_ROWS_UNREADABLE);
819+
// The count, and the consequence "unreadable" alone does not state.
820+
expect(cap.seen[0]).toContain('2 of 2');
821+
expect(cap.seen[0]).toContain('keeps the stale value');
822+
expect(cap.seen[0]).toContain('nothing is lost');
823+
// ⛔ Unchanged: a name whose row could not be read is left ENTIRELY alone,
824+
// and stays out of `materializedNames` so the derivation gets its attempt.
825+
expect(ql.rows).toHaveLength(0);
826+
expect(out.materializedNames).toEqual([]);
827+
});
828+
829+
it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => {
830+
// The discriminating case for R3: two different refusals in ONE pass.
831+
const ql = makeQl([
832+
{ name: 'manage_users', _packageId: 'com.acme.evil' },
833+
{ name: 'orphan_cap' },
834+
]);
835+
836+
const cap = captureAllConsole();
837+
try {
838+
await bootstrapDeclaredCapabilities(ql, null);
839+
} finally {
840+
cap.restore();
841+
}
842+
843+
expect(cap.seen).toHaveLength(2);
844+
const curated = cap.seen.find((l) => l.includes(CAPABILITY_PLATFORM_NAME_REFUSED));
845+
const unowned = cap.seen.find((l) => l.includes(CAPABILITY_DECLARATION_UNOWNED));
846+
expect(curated).toBeDefined();
847+
expect(unowned).toBeDefined();
848+
// ⛔ Two tokens, two consequences. A generic sentence would make these two
849+
// assertions pass against ONE wording, so each names a phrase only its own
850+
// site can produce.
851+
expect(curated).toContain('The name still resolves');
852+
expect(curated).not.toContain('derived placeholder');
853+
expect(unowned).toContain('materialized nowhere');
854+
expect(unowned).not.toContain('CURATED PLATFORM capability');
855+
});
856+
857+
it('an INJECTED logger takes all three, and the console stays clean', async () => {
858+
const warn = vi.fn();
859+
const cap = captureAllConsole();
860+
try {
861+
await bootstrapDeclaredCapabilities(makeQl([{ name: 'manage_users', _packageId: 'com.a' }]), null, { logger: { warn } });
862+
await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: { warn } });
863+
await bootstrapDeclaredCapabilities(unreadableQl([{ name: 'a.one', _packageId: 'com.a' }]), null, { logger: { warn } });
864+
} finally {
865+
cap.restore();
866+
}
867+
868+
// ⚠️ FOUR, not three: the third pass ALSO trips the batched existence
869+
// oracle's own read-failure line, which is a different diagnostic in a
870+
// different module (`seed-name-lookup.ts`) and outside this card. Pinning
871+
// three here would have made that line's removal invisible; filtering by
872+
// this card's own tokens keeps the assertion about this card.
873+
expect(warn).toHaveBeenCalledTimes(4);
874+
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
875+
expect(events).toEqual([
876+
CAPABILITY_PLATFORM_NAME_REFUSED,
877+
CAPABILITY_DECLARATION_UNOWNED,
878+
CAPABILITY_ROWS_UNREADABLE,
879+
]);
880+
expect(cap.seen).toEqual([]);
881+
});
882+
883+
it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => {
884+
// ⚠️ `ProjectionLogger.warn` is non-optional, but the type cannot reach a
885+
// plain-JS embedder or a cast. The old `logger?.warn?.()` bought safety here
886+
// with silence; `if (logger) logger.warn()` would buy noise with a throw
887+
// inside a seeding pass. The `typeof` guard buys neither.
888+
const liar = { info: () => {} } as any;
889+
const cap = captureAllConsole();
890+
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
891+
try {
892+
out = await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: liar });
893+
} finally {
894+
cap.restore();
895+
}
896+
expect(out.skippedUnowned).toBe(1);
897+
expect(cap.seen).toHaveLength(1);
898+
expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
899+
});
900+
901+
it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => {
902+
// The reason this card exists: the shape was repaired one instance at a
903+
// time twice before. A grep that reds when a sixth call site appears is the
904+
// difference between a third instance repair and a class that is closed.
905+
const source = readFileSync(resolve(HERE, 'bootstrap-declared-capabilities.ts'), 'utf8');
906+
// Positive control — the pin is reading the file it thinks it is.
907+
expect(source).toContain('export async function bootstrapDeclaredCapabilities');
908+
const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.('));
909+
for (const line of hits) {
910+
expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true);
911+
}
912+
// ⚠️ The INFO channel keeps its outer `?.` deliberately and is NOT part of
913+
// this class: a pass that refused nothing must stay silent on every console
914+
// channel with no sink, which is the control above.
915+
expect(source).toContain("options.logger?.info?.(");
916+
});
917+
});

0 commit comments

Comments
 (0)