Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/18091-seeder-refusal-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@objectstack/plugin-security": minor
---

**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).

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:

```
counter author-visible lines
curated platform capability refused skippedPlatform = 1 0
capability declaration unowned skippedUnowned = 1 0
capability rows unreadable unreadable = 1 0
permission set declaration unowned (no counter at all) 0
permission set rows unreadable unreadable = 1 0
LIT CONTROL capability_name_collision skippedForeign = 1 1
LIT CONTROL permission_set_name_… skippedForeign = 1 1
```

Every one of those zeros is now a 1, with the counters unchanged.

⛔ **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.

**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.

**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.

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:

- `CAPABILITY_PLATFORM_NAME_REFUSED` / `capabilityPlatformNameRefusedDiagnostic()` / `reportCapabilityPlatformNameRefused()` and the `CapabilityPlatformNameRefusedDiagnostic` record.
- `CAPABILITY_DECLARATION_UNOWNED` / `capabilityDeclarationUnownedDiagnostic()` / `reportCapabilityDeclarationUnowned()` and the `CapabilityDeclarationUnownedDiagnostic` record.
- `PERMISSION_SET_DECLARATION_UNOWNED` / `permissionSetDeclarationUnownedDiagnostic()` / `reportPermissionSetDeclarationUnowned()` and the `PermissionSetDeclarationUnownedDiagnostic` record.

⛔ 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.

⚠️ 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.
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { describe, it, expect, vi } from 'vitest';
import { bootstrapDeclaredCapabilities } from './bootstrap-declared-capabilities.js';
import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js';
import { CAPABILITY_NAME_COLLISION } from './capability-name-collision.js';
import {
CAPABILITY_DECLARATION_UNOWNED,
CAPABILITY_PLATFORM_NAME_REFUSED,
CAPABILITY_ROWS_UNREADABLE,
} from './seed-refusal-diagnostics.js';

/** [#18091] Seeded from this file, for the class pin at the bottom. */
const HERE = dirname(fileURLToPath(import.meta.url));

/** Minimal in-memory ql for sys_capability seeding with a registry stub. */
function makeQl(declared: any[] = []) {
Expand Down Expand Up @@ -705,3 +717,201 @@ describe('[#18023] a capability-name collision reaches the author', () => {
expect(out.collisions).toBeUndefined();
});
});

// ───────────────────────────────────────────────────────────────────────────
// [#18091] The three refusals #18023 left behind in this seeder. Same defect,
// same reading: driven with NO logger the author-visible line count was 0 at
// every one of them while the already-repaired collision path in the harness
// above read 1. Each case below asserts THAT SITE'S OWN sentence, so a future
// regression to one generic "declaration skipped" line is caught by name.
// ───────────────────────────────────────────────────────────────────────────

/** `makeQl` with a read that cannot answer — the `unreadable` branch's only entry. */
function unreadableQl(declared: any[]) {
const ql = makeQl(declared);
(ql as any).find = async () => { throw new Error('sys_capability is unreachable'); };
return ql;
}

describe('[#18091] the three remaining capability refusals reach the author', () => {
it('CURATED PLATFORM NAME: prints with NO LOGGER INJECTED, and names what is lost', async () => {
const ql = makeQl([{ name: 'manage_users', label: 'Evil', description: 'Hijack.', _packageId: 'com.acme.evil' }]);

const cap = captureAllConsole();
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
try {
out = await bootstrapDeclaredCapabilities(ql, null, {
permissionSets: [{ name: 'acme_ops', systemPermissions: ['manage_users'] }],
});
} finally {
cap.restore();
}

// ── The reading this card moves: 0 → 1 ──────────────────────────────────
expect(out.skippedPlatform).toBe(1);
expect(cap.seen).toHaveLength(1);
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
expect(cap.seen[0]).toContain(CAPABILITY_PLATFORM_NAME_REFUSED);
expect(cap.seen[0]).toContain('manage_users');
expect(cap.seen[0]).toContain('com.acme.evil');
// ── THIS site's consequence, not the foreign-owner one. The curated row
// answers for the name, so nothing is denied — an author sent hunting
// for a broken grant is the failure this wording prevents.
expect(cap.seen[0]).toContain('CURATED PLATFORM capability');
expect(cap.seen[0]).toContain('The name still resolves');
expect(cap.seen[0]).toContain('acme_ops');
// ⛔ And the remedy is rename-only: a curated name is not co-ownable, so
// the ADR-0130 D1 escape the collision diagnostic offers must NOT appear.
expect(cap.seen[0]).toContain('not co-ownable');

// ── ⛔ The refusal itself is UNCHANGED ───────────────────────────────────
expect(ql.rows.find((r) => r.name === 'manage_users')).toBeUndefined();
expect(out.seeded).toBe(0);
// …and the name is still reported materialized: the curated pass owns it.
expect(out.materializedNames).toEqual(['manage_users']);
});

it('UNOWNED DECLARATION: prints with NO LOGGER INJECTED, keeping its three-way consequence', async () => {
const ql = makeQl([{ name: 'showcase.export_data', label: 'Export Data' }]);

const cap = captureAllConsole();
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
try {
out = await bootstrapDeclaredCapabilities(ql, null, {
permissionSets: [{ name: 'showcase_ops', systemPermissions: ['showcase.export_data'] }],
});
} finally {
cap.restore();
}

expect(out.skippedUnowned).toBe(1);
expect(cap.seen).toHaveLength(1);
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
expect(cap.seen[0]).toContain('has no owning package');
// [#4967 Part 3] The grantor and the ACTUAL consequence, both preserved —
// this arm is "a row will be derived", which is not the other two arms.
expect(cap.seen[0]).toContain('showcase_ops');
expect(cap.seen[0]).toContain('derived placeholder');
expect(cap.seen[0]).not.toContain('materialized nowhere');
// ⛔ Unchanged: no row is written for an unowned declaration.
expect(ql.rows.find((r) => r.name === 'showcase.export_data')).toBeUndefined();
});

it('UNREADABLE ROWS: prints with NO LOGGER INJECTED, with the count and the consequence', async () => {
const ql = unreadableQl([
{ name: 'a.one', _packageId: 'com.a' },
{ name: 'a.two', _packageId: 'com.a' },
]);

const cap = captureAllConsole();
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
try {
out = await bootstrapDeclaredCapabilities(ql, null);
} finally {
cap.restore();
}

expect(out.unreadable).toBe(2);
expect(cap.seen).toHaveLength(1);
expect(cap.seen[0]!.startsWith('warn: ')).toBe(true);
expect(cap.seen[0]).toContain(CAPABILITY_ROWS_UNREADABLE);
// The count, and the consequence "unreadable" alone does not state.
expect(cap.seen[0]).toContain('2 of 2');
expect(cap.seen[0]).toContain('keeps the stale value');
expect(cap.seen[0]).toContain('nothing is lost');
// ⛔ Unchanged: a name whose row could not be read is left ENTIRELY alone,
// and stays out of `materializedNames` so the derivation gets its attempt.
expect(ql.rows).toHaveLength(0);
expect(out.materializedNames).toEqual([]);
});

it('⭐ each site keeps its OWN sentence — ⛔ never one generic refusal line', async () => {
// The discriminating case for R3: two different refusals in ONE pass.
const ql = makeQl([
{ name: 'manage_users', _packageId: 'com.acme.evil' },
{ name: 'orphan_cap' },
]);

const cap = captureAllConsole();
try {
await bootstrapDeclaredCapabilities(ql, null);
} finally {
cap.restore();
}

expect(cap.seen).toHaveLength(2);
const curated = cap.seen.find((l) => l.includes(CAPABILITY_PLATFORM_NAME_REFUSED));
const unowned = cap.seen.find((l) => l.includes(CAPABILITY_DECLARATION_UNOWNED));
expect(curated).toBeDefined();
expect(unowned).toBeDefined();
// ⛔ Two tokens, two consequences. A generic sentence would make these two
// assertions pass against ONE wording, so each names a phrase only its own
// site can produce.
expect(curated).toContain('The name still resolves');
expect(curated).not.toContain('derived placeholder');
expect(unowned).toContain('materialized nowhere');
expect(unowned).not.toContain('CURATED PLATFORM capability');
});

it('an INJECTED logger takes all three, and the console stays clean', async () => {
const warn = vi.fn();
const cap = captureAllConsole();
try {
await bootstrapDeclaredCapabilities(makeQl([{ name: 'manage_users', _packageId: 'com.a' }]), null, { logger: { warn } });
await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: { warn } });
await bootstrapDeclaredCapabilities(unreadableQl([{ name: 'a.one', _packageId: 'com.a' }]), null, { logger: { warn } });
} finally {
cap.restore();
}

// ⚠️ FOUR, not three: the third pass ALSO trips the batched existence
// oracle's own read-failure line, which is a different diagnostic in a
// different module (`seed-name-lookup.ts`) and outside this card. Pinning
// three here would have made that line's removal invisible; filtering by
// this card's own tokens keeps the assertion about this card.
expect(warn).toHaveBeenCalledTimes(4);
const events = warn.mock.calls.map((c) => (c[1] as any)?.event).filter(Boolean);
expect(events).toEqual([
CAPABILITY_PLATFORM_NAME_REFUSED,
CAPABILITY_DECLARATION_UNOWNED,
CAPABILITY_ROWS_UNREADABLE,
]);
expect(cap.seen).toEqual([]);
});

it('a HOST SINK THAT LIES about its shape is reported to the console, never thrown at', async () => {
// ⚠️ `ProjectionLogger.warn` is non-optional, but the type cannot reach a
// plain-JS embedder or a cast. The old `logger?.warn?.()` bought safety here
// with silence; `if (logger) logger.warn()` would buy noise with a throw
// inside a seeding pass. The `typeof` guard buys neither.
const liar = { info: () => {} } as any;
const cap = captureAllConsole();
let out: Awaited<ReturnType<typeof bootstrapDeclaredCapabilities>>;
try {
out = await bootstrapDeclaredCapabilities(makeQl([{ name: 'orphan_cap' }]), null, { logger: liar });
} finally {
cap.restore();
}
expect(out.skippedUnowned).toBe(1);
expect(cap.seen).toHaveLength(1);
expect(cap.seen[0]).toContain(CAPABILITY_DECLARATION_UNOWNED);
});

it('⛔ CLASS PIN: the doubly-optional warn survives in this seeder only as PROSE', async () => {
// The reason this card exists: the shape was repaired one instance at a
// time twice before. A grep that reds when a sixth call site appears is the
// difference between a third instance repair and a class that is closed.
const source = readFileSync(resolve(HERE, 'bootstrap-declared-capabilities.ts'), 'utf8');
// Positive control — the pin is reading the file it thinks it is.
expect(source).toContain('export async function bootstrapDeclaredCapabilities');
const hits = source.split('\n').filter((line) => line.includes('logger?.warn?.('));
for (const line of hits) {
expect(line.trimStart().startsWith('//') || line.trimStart().startsWith('*')).toBe(true);
}
// ⚠️ The INFO channel keeps its outer `?.` deliberately and is NOT part of
// this class: a pass that refused nothing must stay silent on every console
// channel with no sink, which is the control above.
expect(source).toContain("options.logger?.info?.(");
});
});
Loading
Loading