Skip to content

Commit 308afc8

Browse files
committed
Merge origin/main into claude/issue-19268-liveness-walk-seam
Second gate-list refresh: dispatch-gates flagged the previous head as 2 commits behind with lint.yml and package.json changed in the range, i.e. the family LIST itself was derived from stale copies. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
2 parents dc126ff + d00692f commit 308afc8

8 files changed

Lines changed: 1338 additions & 6 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
**The permission-set seeder's unowned refusal now moves a counter.** `PermissionSeedOutcome` gains a required `skippedUnowned`, incremented on the `!packageId` branch of `upsertPackagePermissionSet` — the branch that refuses to materialize a declared set with no resolvable owner (#18571).
6+
7+
⛔ **The refusal itself is unchanged.** A `managed_by:'package'` row with no `package_id` makes uninstall undefined, which is exactly the ADR-0086 D3 ambiguity the branch exists to prevent. This card adds a channel, not a verdict.
8+
9+
#18564 gave that refusal its author-visible line. What it left is the programmatic half: the outcome came back with all six counters at zero, so a caller that reads no log at all — a boot report, a Setup surface, a test — could not tell a pass that refused a declaration from a pass with nothing to do. Measured against the sibling axis, which has counted the same refusal at the same boundary since #4967:
10+
11+
```
12+
axis unowned refusal counter moved author-visible line
13+
capability skippedUnowned 1 1
14+
permission set (before) — none declared — 0 1
15+
permission set (after) skippedUnowned 1 1
16+
```
17+
18+
**Required, not optional** — like `skippedForeign` and unlike `deleted`. An absent key on a *refusal* count reads exactly like a pass with nothing to refuse, which is the defect restated. All four construction sites initialize it (`bootstrapDeclaredPermissions`, `upsertPackagePermissionSet`, `upsertEnvPermissionSet`, `retirePermissionSetRecord`), so every door that returns this outcome answers the question.
19+
20+
**Both doors onto the branch increment it.** The boot catalog loop aggregates it alongside the five counters it already forwarded; the ADR-0086 P2 publish materializer passes no collector and returns its own outcome, so a fix wired only into the aggregation would have left that caller as silent as before.
21+
22+
**The accounting closes.** `seeded + updated + unchanged + skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is now the number of named declarations a pass read — pinned by a conservation test modelled on the capability axis'. Before this counter that sum was short by every unowned declaration. (Pre-existing caveat, unchanged and outside this change: a write the engine rejects increments no counter; it is reported through `SeedWriteRefusals`.)
23+
24+
New published surface on `@objectstack/plugin-security`: the `skippedUnowned` member of the barrel-exported `PermissionSeedOutcome`. Reading an outcome is unaffected — the member only adds a number to read.
25+
26+
The sweep for construction sites covered this repository, the pinned `objectui` checkout and the downstream app repositories available to it, and found none outside the package. That is what was measured, and it cannot speak for a consumer outside those trees. So: if you construct a `PermissionSeedOutcome` yourself — a test double standing in for the seeder is the shape that does — add `skippedUnowned: 0`. Nothing else changes.

‎.claude/settings.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
"Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)",
5353
"Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)",
5454
"Bash(node --use-env-proxy scripts/pm/label-write.mjs *)",
55+
"Bash(node scripts/pm/post-stamped.mjs *)",
56+
"Bash(node scripts/pm/label-write.mjs *)",
57+
"Bash(node scripts/pm/close-cards.mjs *)",
58+
"Bash(node --use-env-proxy scripts/pm/close-cards.mjs *)",
5559
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/issues/* *)",
5660
"Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/issues/* *)"
5761
],

‎.github/workflows/lint.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,29 @@ jobs:
13071307
- name: Label-write helper self-test
13081308
run: pnpm check:pm-label-write
13091309

1310+
# Card-closure helper self-test (#19469) — same family and the same split
1311+
# as the two steps above, and it drives BOTH of them: `close-cards.mjs` is
1312+
# the three-step closure (comment · label · close) as one named command,
1313+
# so a seat's batch closure is one allow-listed prefix instead of a loop
1314+
# the session's write classifier judges act by act. The LIVE path posts
1315+
# comments, strips labels and closes cards, so it is emphatically not a
1316+
# thing CI runs against the board; the offline half is all CI can hold.
1317+
#
1318+
# What the self-test instruments is the set of rules a clean tree cannot
1319+
# exercise, every one of which is silent when it breaks: the skip matrix
1320+
# (not open · assigned · `pm:retriage` · a pm-state that is not EXACTLY
1321+
# the expected one · an open PR cross-referencing the card); that an
1322+
# unreadable card or timeline STOPS the run rather than being read as a
1323+
# "no"; that a 200 whose body does not read back `closed`, or reads back
1324+
# another `state_reason`, is not a close; and — the one this tool exists
1325+
# for — that a comment which lands whose label write then fails STOPS at
1326+
# that card, naming it and which of the three writes landed, instead of
1327+
# turning one half-state into a page of them. Weakening any of them leaves
1328+
# every ordinary invocation just as green, which is the shape
1329+
# `check:self-test-wired` requires this step for.
1330+
- name: Card-closure helper self-test
1331+
run: pnpm check:pm-close-cards
1332+
13101333
# Board-snapshot archiver self-test (#17390) — same family and the same
13111334
# split as every step around it. The LIVE run reads the whole board over
13121335
# REST and commits the result to an orphan branch, so it is not a thing

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test",
8989
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
9090
"check:pm-label-write": "node scripts/pm/label-write.mjs --self-test",
91+
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",
9192
"check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test",
9293
"check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test",
9394
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PERMISSION_SET_DECLARATION_UNOWNED,
1414
PERMISSION_SET_ROWS_UNREADABLE,
1515
} from './seed-refusal-diagnostics.js';
16+
import { permissionSetRowFields } from './permission-set-projection.js';
1617

1718
/** [#18091] Seeded from this file, for the class pin at the bottom. */
1819
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -395,3 +396,107 @@ describe('[#18091] the two remaining permission-set refusals reach the author',
395396
expect(source).toContain("options.logger?.info?.(");
396397
});
397398
});
399+
400+
// ───────────────────────────────────────────────────────────────────────────
401+
// [#18571] The unowned refusal's PROGRAMMATIC half. #18564 gave this branch an
402+
// author-visible line; the outcome still came back with every counter at zero,
403+
// so a caller that reads no log at all could not tell a pass that refused a
404+
// declaration from a pass with nothing to do. The capability axis has counted
405+
// the same refusal at the same ADR-0086 D3 boundary since #4967.
406+
// ───────────────────────────────────────────────────────────────────────────
407+
408+
/** Silent sink — these tests assert the COUNTER, and #18091 owns the line. */
409+
const mute = () => ({ info: () => {}, warn: () => {} });
410+
411+
describe('[#18571] the unowned permission-set refusal moves a counter', () => {
412+
it('BOOT-LOOP DOOR: an unowned declaration increments skippedUnowned', async () => {
413+
const ql = makeQl([declaredSet({ _packageId: undefined })]);
414+
415+
const r = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() });
416+
417+
expect(r.skippedUnowned).toBe(1);
418+
// ⛔ The refusal itself is UNCHANGED — an unowned `managed_by:'package'`
419+
// row is the ADR-0086 D3 ambiguity this branch exists to prevent.
420+
expect(ql.rows).toHaveLength(0);
421+
expect(r.seeded + r.updated + r.unchanged).toBe(0);
422+
// ⛔ And it lands in its OWN counter, never borrowed from a sibling: an
423+
// unowned declaration is not a foreign row and not an env-authored one.
424+
expect(r.skippedForeign).toBe(0);
425+
expect(r.skippedEnvAuthored).toBe(0);
426+
expect(r.unreadable).toBe(0);
427+
});
428+
429+
it('PUBLISH DOOR (ADR-0086 P2): the materializer increments it too', async () => {
430+
// ⚠️ The second door onto the same branch, and the reason testing one is
431+
// testing half: the publish materializer upserts ONE set and passes no
432+
// collector, so a fix wired into the boot loop's aggregation alone would
433+
// leave this caller's outcome exactly as silent as before.
434+
const ql = makeQl();
435+
436+
const r = await upsertPackagePermissionSet(ql, declaredSet({ _packageId: undefined }), null, mute());
437+
438+
expect(r.skippedUnowned).toBe(1);
439+
expect(ql.rows).toHaveLength(0);
440+
expect(r.seeded).toBe(0);
441+
});
442+
443+
it('CONTROL: a pass that refuses nothing leaves it at 0 through both doors', async () => {
444+
// ⛔ The discriminating half. Without it, a counter incremented on every
445+
// declaration would satisfy both assertions above.
446+
const boot = await bootstrapDeclaredPermissions(makeQl([declaredSet()]), undefined, { logger: mute() });
447+
expect(boot).toMatchObject({ seeded: 1, skippedUnowned: 0 });
448+
449+
const publish = await upsertPackagePermissionSet(makeQl(), declaredSet(), 'com.example.crm', mute());
450+
expect(publish).toMatchObject({ seeded: 1, skippedUnowned: 0 });
451+
});
452+
453+
it('⭐ CONSERVATION: every named declaration lands in exactly one counter', async () => {
454+
// Modelled on the capability axis' `materializedNames reconciles with the
455+
// outcome counters`. The point is not the six numbers — it is that they
456+
// SUM to the input, which is precisely what an uncounted refusal broke.
457+
const body = (name: string, over: Record<string, any> = {}) =>
458+
declaredSet({ name, _packageId: 'com.a', ...over });
459+
const sets = [
460+
body('crm_new'), // → seeded
461+
body('crm_own', { label: 'Fresh' }), // → updated
462+
body('crm_same'), // → unchanged
463+
body('crm_env'), // → skippedEnvAuthored
464+
body('crm_foreign'), // → skippedForeign
465+
declaredSet({ name: 'crm_orphan', _packageId: undefined }), // → skippedUnowned
466+
];
467+
const ql = makeQl(sets);
468+
// Its own row, stale — the stored label is not what the declaration says.
469+
ql.rows.push({
470+
id: 'ps_own', name: 'crm_own', managed_by: 'package', package_id: 'com.a',
471+
...permissionSetRowFields(body('crm_own', { label: 'Stale' })),
472+
});
473+
// Its own row, already converged — `recordDiffersFromBody` calls it equal,
474+
// so no UPDATE is issued and the name lands in `unchanged`, not `updated`.
475+
ql.rows.push({
476+
id: 'ps_same', name: 'crm_same', managed_by: 'package', package_id: 'com.a',
477+
...permissionSetRowFields(body('crm_same')),
478+
});
479+
ql.rows.push({ id: 'ps_env', name: 'crm_env', managed_by: 'user', object_permissions: '{"kept":true}' });
480+
ql.rows.push({ id: 'ps_far', name: 'crm_foreign', managed_by: 'package', package_id: 'com.z', object_permissions: '{}' });
481+
482+
const out = await bootstrapDeclaredPermissions(ql, undefined, { logger: mute() });
483+
484+
expect(out).toMatchObject({
485+
seeded: 1, updated: 1, unchanged: 1,
486+
skippedEnvAuthored: 1, skippedForeign: 1, skippedUnowned: 1,
487+
unreadable: 0,
488+
});
489+
// ⚠️ The sum is taken over EVERY counter the outcome declares, so a future
490+
// counter added without a home here fails this pin instead of hiding in
491+
// a hand-picked subset — the failure mode `skippedUnowned` itself was.
492+
const counted = out.seeded + out.updated + out.unchanged
493+
+ out.skippedEnvAuthored + out.skippedForeign + out.skippedUnowned + out.unreadable;
494+
expect(counted).toBe(sets.length);
495+
// …and the refusal that wrote nothing really wrote nothing: five inputs
496+
// reached a row, the sixth did not.
497+
expect(ql.rows.map((r) => r.name).sort()).toEqual(
498+
['crm_env', 'crm_foreign', 'crm_new', 'crm_own', 'crm_same'],
499+
);
500+
expect(ql.rows.some((r) => r.name === 'crm_orphan')).toBe(false);
501+
});
502+
});

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,18 +229,23 @@ export async function upsertPackagePermissionSet(
229229
collisions?: PermissionSetNameCollisionDiagnostic[];
230230
},
231231
): Promise<PermissionSeedOutcome> {
232-
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
232+
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 };
233233
if (!ps?.name) return out;
234234
// A `managed_by:'package'` row without a `package_id` would make uninstall
235235
// undefined again — the exact ambiguity ADR-0086 D3 exists to remove — so a
236236
// set with no resolvable owner is skipped rather than materialized unowned.
237237
if (!packageId) {
238+
out.skippedUnowned += 1;
238239
// [#18091] ⛔ The refusal is unchanged — an unowned row would re-create the
239240
// ADR-0086 D3 ambiguity. What changed is that it arrives. Measured on the
240241
// pre-fix tree with no logger, through BOTH doors that reach this branch
241242
// (the boot loop and the ADR-0086 P2 publish materializer, which passes no
242-
// collector): author-visible console lines = 0, and this branch moves no
243-
// counter either, so the outcome said nothing about it.
243+
// collector): author-visible console lines = 0.
244+
//
245+
// [#18571] And a counter, because the LINE is not the programmatic half: a
246+
// caller that reads no log at all — a boot report, a test — asked the
247+
// outcome and got six zeros back, indistinguishable from a pass with
248+
// nothing to do. Both doors above increment it; the boot loop forwards it.
244249
//
245250
// Its own wording, because the consequence is not the capability axis':
246251
// the declared set stays runtime-enforced and only the RECORD is missing.
@@ -374,7 +379,7 @@ export async function bootstrapDeclaredPermissions(
374379
metadataService: any,
375380
options: SeedOptions = {},
376381
): Promise<PermissionSeedOutcome> {
377-
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
382+
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 };
378383
if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out;
379384

380385
let sets: any[] = readDeclared(ql, 'permission');
@@ -419,6 +424,7 @@ export async function bootstrapDeclaredPermissions(
419424
out.unreadable += r.unreadable;
420425
out.skippedEnvAuthored += r.skippedEnvAuthored;
421426
out.skippedForeign += r.skippedForeign;
427+
out.skippedUnowned += r.skippedUnowned;
422428
}
423429

424430
if (organizationId) {

‎packages/plugins/plugin-security/src/permission-set-projection.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,28 @@ export interface PermissionSeedOutcome {
246246
unreadable: number;
247247
skippedEnvAuthored: number;
248248
skippedForeign: number;
249+
/**
250+
* [#18571] Declarations refused for want of an owning package
251+
* (`_packageId`/`packageId` both absent), so no `managed_by:'package'` row
252+
* was written — the ADR-0086 D3 ambiguity the refusal exists to prevent.
253+
*
254+
* ⚠️ Required, not optional: it is a REFUSAL count, and an absent key on a
255+
* pass that refused reads exactly like a pass that had nothing to refuse.
256+
* Every door that returns this outcome initializes it, so a caller may add
257+
* it into the accounting unconditionally — `seeded + updated + unchanged +
258+
* skippedEnvAuthored + skippedForeign + skippedUnowned + unreadable` is the
259+
* number of named declarations this pass read. (Pre-existing caveat,
260+
* unchanged: a write the engine REJECTS increments no counter — `tryInsert`
261+
* answers `null` and `tryUpdate` `false`, and the refusal travels on
262+
* `SeedWriteRefusals` instead.) Before this counter existed that sum was
263+
* short by every unowned declaration, and the outcome said nothing at all
264+
* about them.
265+
*
266+
* Named for its `CapabilitySeedOutcome.skippedUnowned` sibling on the
267+
* capability axis, which counts the same refusal at the same ADR-0086 D3
268+
* boundary — ⛔ not a new vocabulary for one seeder.
269+
*/
270+
skippedUnowned: number;
249271
/** Records retired because their definition was deleted from metadata. */
250272
deleted?: number;
251273
/**
@@ -566,7 +588,7 @@ export async function upsertEnvPermissionSet(
566588
existing?: ExistingByNameIndex;
567589
},
568590
): Promise<PermissionSeedOutcome> {
569-
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
591+
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0 };
570592
if (!ql || typeof ql.find !== 'function' || !ps?.name) return out;
571593

572594
// [ADR-0094] `customized` marks a PACKAGE-owned row that an env overlay is
@@ -749,7 +771,7 @@ async function retirePermissionSetRecord(
749771
name: string,
750772
logger?: ProjectionLogger,
751773
): Promise<PermissionSeedOutcome> {
752-
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, deleted: 0 };
774+
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0, skippedUnowned: 0, deleted: 0 };
753775
const existing = (await tryFind(ql, 'sys_permission_set', { name }, 1))[0];
754776
if (!existing?.id) return out;
755777
if (existing.managed_by === 'package') {

0 commit comments

Comments
 (0)