Skip to content

Commit c19db3b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18070-authz-resolver-false-green
2 parents 22576a4 + c54d8d6 commit c19db3b

12 files changed

Lines changed: 698 additions & 13 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
---
4+
5+
A **permission-set name collision now reaches the author**. When a package declares a permission set whose name a *different* package already owns, `bootstrapDeclaredPermissions` refuses to write into that row — correct under ADR-0086 D4, and unchanged — but the refusal is no longer invisible (#17516).
6+
7+
Measured on the pre-change tree, with a collision seeded and **no logger passed**:
8+
9+
```
10+
skippedForeign = 1 (the entire declared set was dropped)
11+
author-visible console lines = 0 (log, info, warn, error, debug — all five)
12+
diagnostic records on outcome = undefined
13+
```
14+
15+
The branch reported through `logger?.warn?.(…)` — optionally chained **twice** — so a caller that passed no logger produced no output at all, and a package's whole declared permission set vanished with one internal counter incremented. The comment there said *"refuse loudly"*; nothing about it was loud. Same case after the change:
16+
17+
```
18+
skippedForeign = 1 (unchanged — the skip is not what was wrong)
19+
author-visible console lines = 1 warn: [security] [permission_set_name_collision] …
20+
diagnostic records on outcome = 1 { name, declaredBy, ownedBy, message, fix }
21+
```
22+
23+
- **It prints with no sink injected.** `reportPermissionSetNameCollisions` falls back to `console.warn`, per the #10556 ruling that silent-by-declaration is rejected — an injected host sink still replaces it rather than printing beside it. The call keeps the receiver (a property-access call, never a detached `logger.warn ?? console.warn`), so a class-based host sink does not throw.
24+
- **The refusal is also readable without a log.** `PermissionSeedOutcome` gains an optional `collisions` array carrying one diagnostic per dropped set — absent, never `[]`, when the pass hit none. A counter with no record is what made the drop undiagnosable.
25+
- **One derivation, so two doors cannot drift.** `permissionSetNameIsForeign`, `permissionSetNameCollisionDiagnostic` and `formatPermissionSetNameCollisionDiagnostic` are exported from the package entry so a compile-time door consumes them rather than re-deriving the predicate or re-spelling the wording — the shape #14553 established for `navigationContributions`. ⚠️ Only the **runtime** door ships here; the compile-time door (`os build` / `os validate`) lives in another package and is not part of this change.
26+
- **A stable, greppable token**, `permission_set_name_collision`, is stamped as `event` on every report. It is a snake_case data value, not an ADR-0112 error code: it is never routed to `error.code` and never reaches a wire refusal, the same discrimination the sibling `position_name_fold_grant` token already makes in this package.
27+
- **The branch comment's premise is corrected.** It claimed package-namespaced object api names make set-name collisions a packaging bug rather than a merge case. **ADR-0130 D1 falsifies that** — N packages may co-own one namespace — so a collision is a legal configuration that gets *more* common, not an error that should never happen. The diagnostic's `fix` text names both legal resolutions.
28+
29+
**No wire byte moves and no skip changes.** The foreign row is still never written; `skippedForeign` still counts it; the ADR-0086 P2 publish materializer still returns its existing `permission set name is owned by another package` failure text. A non-colliding pass stays completely silent on all five console channels, asserted over a pass that really does seed and re-seed.

.claude/agents/os-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ model: opus
4747
- 其余 ⛔ 不立卡:观察、死代码、未演练漂移、抛光、风格、文档 nit、命名。
4848
- 它们进 PR `## Acceptance notes`,报告 `out_of_scope_findings``noted, not filed: …`,席位 ACCEPT 读。
4949
-`noted, not filed` 前先答哪一个 PR 或人会碰到这个文件;答不出就写明「承接者:无」。
50-
- 先搜再立:关键词 + 文件路径扫 open issues;并行 dev 同一小时立的卡只有这一搜能看见
50+
- 立卡者不查重,只在卡面附 3–5 个查重词;查重归分诊席,⛔ 不扫 open issues、不拉板
5151
- 通道先探后选:同容器先测一条 repo-scoped REST 读;通 ⇒ 走 REST 列表端点 + 本地 grep。
5252
- 通道对照表见 `.claude/skills/pm-dispatch/references/rest-channel.md`,其 ✓ 按座位实测。
5353
- 403 ⇒ 改用一次定向 MCP `search_issues`,并在报告申报换道。

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
103103
| issue 上的信号 | 含义 |
104104
|---|---|
105105
| open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外);`pm:queue` 卡恒无 assignee,有即半态 |
106-
| assignee 已设 | 已认领/在飞,不是你的就永不碰;离手恒走释放 —— 四因 = 改路由、前提证伪、弃飞无接管、跨车道移交,去向 = 新标签态或车道;⛔ 不静默摘 assignee |
106+
| assignee 已设 | 已认领/在飞,不是你的就永不碰;离手恒走释放 —— 三因 = 改路由(限未派发)、前提证伪、弃飞无接管,去向 = 新标签态或车道;在飞卡跟到 MERGED,⛔ 不静默摘 assignee |
107107
| `pm:dispatched` | 已派发(派发评论记轮次),恒带 assignee;与摘 `pm:queue` 同一次标签写入成对落地 |
108108
| `needs-user-decision` | 决定待做:永不派发、除代裁通道外永不代答;维护者的收件箱 |
109109
| `pm:on-hold` | 决定已做且答案是暂不做:不派发不催;仅当带机器可读 `Restart-when:` 行才合法 |
110110
| `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关闭时由解锁扫描放回;工已完、PR 被外部门禁卡住的同用本态 |
111111
| `pm:awaiting-maintainer` | 决定已做,只剩一次 GitHub 之外的人工动作:不派发不催;与其它 pm 状态标签互斥;入态恒带行首 `Maintainer-action:` 行(细则见 `references/state-machine.md`),无行即半态 |
112112
| `pm:blocking` | 有 open 下游依赖者(自 `Blocked-by:` 索引推导的缓存,⛔ 不手工挂);进选择全序 |
113-
| `pm:retriage` | 向分诊提问(改判、跨域 PR 指定车道、改路由、拆卡、裁 dev 报告留下的分叉),异议评论写明所求;与现行 `pm:*` 并存、⛔ 不摘原标;带本标签的 `pm:queue` 卡跳过派发 |
113+
| `pm:retriage` | 向分诊提问(改判、跨域 PR 指定车道、改路由、拆卡、裁 dev 报告留下的分叉),定车道与改路由限未派发卡,异议评论写明所求;与现行 `pm:*` 并存、⛔ 不摘原标;带本标签的 `pm:queue` 卡跳过派发 |
114114
| `finding` | 立卡三类内待首次定级,定级即离标;三类外关 not planned;不占队列不进收件箱 |
115115
| `target:<major>` | 发版阻塞:每个 backlog 恰好一个生产者 |
116116
| `pm:epic`(父单或 sub-issue) | 已由 epic PM 保留;其它 PM 永不取;⛔ 永不与 `pm:queue` 同挂 |

.claude/skills/pm-dispatch/references/lanes/ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- 凡触 `packages/spec` 一律转 `domain:spec` 座位,见 SKILL.md 〈多仓协调〉。
2323
- `scripts/pm/**` 单写手恒在 objectstack 侧 ⇒ 本席的工具需求走上游卡带 `Blocked-by:` 回链。
2424
- 受管面五项含仓根发布 `skills/**`,清单、判据与禁令见 objectui AGENTS.md §9 受管面段。
25-
-命中即整 PR 停在 draft 等人合,⛔ 不 ready、不入队、不 auto-merge、不自合、不留批准
25+
-命中即停 draft;⛔ 未获授权批准不 ready 不入队不自合、永不批准,获批后认领席落地
2626
- 未命中的 PR 按同节走合并队列落地;changeset 与版本纪律见 objectui AGENTS.md §9。
2727
- 半状态巡查在本仓有载体:定时 workflow 与 sweeper 皆在,自 objectstack 移植。
2828
- 锚 issue 未配置 ⇒ 写锚步骤 skip,发现只落 run summary,绿不等于已交付读锚腿。

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,16 @@ describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => {
105105
});
106106
expect(r.skippedForeign).toBe(1);
107107
expect(ql.rows[0].package_id).toBe('com.example.crm');
108-
expect(warns.some((w) => String(w.m).includes('owned by another package'))).toBe(true);
108+
// [#17516] Re-anchored from the old prose ('owned by another package') to
109+
// the stable token the report now stamps. The substance this pin asserts is
110+
// unchanged — the refusal is reported — but the token is what an operator
111+
// greps and what the sibling doors key on, so prose drift can no longer
112+
// quietly unpin it. The read-back half is asserted beside it: a counter
113+
// with no record is what made this drop invisible.
114+
expect(warns.some((w) => String(w.m).includes('permission_set_name_collision'))).toBe(true);
115+
expect(r.collisions).toEqual([
116+
expect.objectContaining({ name: 'crm_sales_rep', declaredBy: 'com.example.other', ownedBy: 'com.example.crm' }),
117+
]);
109118
});
110119

111120
it('skips a declared set with no resolvable owning package (warned, not seeded)', async () => {

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ import {
6868
reportSeedWriteRefusals,
6969
type SeedWriteRefusals,
7070
} from './per-organization-catalog.js';
71+
import {
72+
permissionSetNameCollisionDiagnostic,
73+
permissionSetNameIsForeign,
74+
reportPermissionSetNameCollisions,
75+
type PermissionSetNameCollisionDiagnostic,
76+
} from './permission-set-name-collision.js';
7177

7278
export type { PermissionSeedOutcome } from './permission-set-projection.js';
7379

@@ -206,6 +212,14 @@ export async function upsertPackagePermissionSet(
206212
* materialized nothing).
207213
*/
208214
refusals?: SeedWriteRefusals;
215+
/**
216+
* [#17516] Collects set-name collisions so the pass reports them ONCE
217+
* instead of a line per dropped set. Passed by the boot catalog loop; the
218+
* ADR-0086 P2 publish materializer passes nothing and the refusal is
219+
* reported at the branch instead — ⛔ never dropped, which is the whole
220+
* point of this card.
221+
*/
222+
collisions?: PermissionSetNameCollisionDiagnostic[];
209223
},
210224
): Promise<PermissionSeedOutcome> {
211225
const out: PermissionSeedOutcome = { seeded: 0, updated: 0, unchanged: 0, unreadable: 0, skippedEnvAuthored: 0, skippedForeign: 0 };
@@ -260,7 +274,7 @@ export async function upsertPackagePermissionSet(
260274
}
261275

262276
if (existing.managed_by === 'package') {
263-
if (existing.package_id === packageId) {
277+
if (!permissionSetNameIsForeign(existing.package_id, packageId)) {
264278
// Our own row — re-seed so the record always reflects the shipped/published
265279
// declaration (idempotent; covers version bumps without bookkeeping).
266280
//
@@ -283,13 +297,40 @@ export async function upsertPackagePermissionSet(
283297
out.updated += 1;
284298
}
285299
} else {
286-
// Package-namespaced object api names make set-name collisions a
287-
// packaging bug, not a merge case — refuse loudly (ADR-0086 D4:
288-
// a package never writes into a foreign record).
300+
// [#17516] The SKIP is unchanged and correct — ADR-0086 D4: a package
301+
// never writes into a foreign record. What changed is that it is no
302+
// longer invisible.
303+
//
304+
// ⚠️ The premise this branch used to state — "Package-namespaced object
305+
// api names make set-name collisions a packaging bug, not a merge case"
306+
// — is FALSIFIED by ADR-0130 D1, which lets N packages co-own one
307+
// namespace (the ADR records it under "What was NOT decided"). A
308+
// collision is therefore a legal configuration that gets MORE common as
309+
// co-ownership lands, not a packaging error that should never happen. So
310+
// the author reading it is the normal case, not the pathological one.
311+
//
312+
// ⛔ And the old line did not refuse loudly, whatever it claimed:
313+
// `logger?.warn?.(…)` is optionally chained TWICE, so a caller passing no
314+
// logger produced NO OUTPUT AT ALL and an entire declared permission set
315+
// disappeared with one counter moved. The report now goes through
316+
// `reportPermissionSetNameCollisions`, which prints with no sink injected
317+
// (#10556: silent-by-declaration is rejected), and the diagnostic RECORD
318+
// travels back on the outcome so a caller that reads no log at all — a
319+
// boot report, a test — can still ask what happened.
289320
out.skippedForeign += 1;
290-
logger?.warn?.('[security] permission set name owned by another package — skipped', {
291-
name: ps.name, declaredBy: packageId, ownedBy: existing.package_id,
321+
const diagnostic = permissionSetNameCollisionDiagnostic({
322+
name: String(ps.name),
323+
declaredBy: packageId,
324+
ownedBy: typeof existing.package_id === 'string' ? existing.package_id : null,
325+
...(organizationId ? { organizationId } : {}),
292326
});
327+
out.collisions = [diagnostic];
328+
// The boot loop collects and reports ONCE per pass. The ADR-0086 P2
329+
// publish materializer upserts a single set and passes no collector, so
330+
// it reports here — it has no pass to summarise, and inheriting the old
331+
// silence is the one outcome this card forbids.
332+
if (opts?.collisions) opts.collisions.push(diagnostic);
333+
else reportPermissionSetNameCollisions(logger, [diagnostic], organizationId);
293334
}
294335
return out;
295336
}
@@ -344,13 +385,15 @@ export async function bootstrapDeclaredPermissions(
344385
// One log per pass, not per refused row: a legacy platform-wide unique index
345386
// refuses EVERY declared permission set, and a line each would bury the remedy.
346387
const refusals = createSeedWriteRefusals();
388+
// [#17516] Declared sets dropped because another package owns the name.
389+
const collisions: PermissionSetNameCollisionDiagnostic[] = [];
347390

348391
for (const ps of sets) {
349392
if (!ps?.name) continue;
350393
// Registry provenance first (ADR-0010 `_packageId`), author-declared
351394
// spec `packageId` (ADR-0086 D3) as fallback.
352395
const packageId: string | undefined = ps._packageId ?? ps.packageId ?? undefined;
353-
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals });
396+
const r = await upsertPackagePermissionSet(ql, ps, packageId, options.logger, { existingByName, organizationId, residue, refusals, collisions });
354397
out.seeded += r.seeded;
355398
out.updated += r.updated;
356399
out.unchanged += r.unchanged;
@@ -370,6 +413,11 @@ export async function bootstrapDeclaredPermissions(
370413
}
371414
// Before the counts, so an operator reads WHY the count is zero beside it.
372415
reportSeedWriteRefusals(options.logger, refusals, organizationId);
416+
// [#17516] Said once per pass, and said even when no logger was injected —
417+
// the whole defect was that this refusal reached nobody. The records go back
418+
// on the outcome too, for a caller that reads no log at all.
419+
reportPermissionSetNameCollisions(options.logger, collisions, organizationId);
420+
if (collisions.length > 0) out.collisions = collisions;
373421
if (out.unreadable > 0) {
374422
// Said once, with the count: these sets were neither seeded nor reconciled
375423
// because the record could not be READ. Silence here would read exactly

packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,14 @@ describe('#10946 — a name declared twice in one batch keeps its loud refusal',
431431
expect(r.skippedForeign).toBe(1);
432432
expect(ql.rows).toHaveLength(1);
433433
expect(ql.rows[0].package_id).toBe('com.example.a');
434-
expect(warns.some((w) => w.includes('owned by another package'))).toBe(true);
434+
// [#17516] Re-anchored from the old prose to the stable token the report
435+
// stamps — the assertion's substance (the refusal is REPORTED, not merely
436+
// counted) is unchanged, and the record is asserted beside it so "loud"
437+
// means reaching a reader rather than moving a counter.
438+
expect(warns.some((w) => w.includes('permission_set_name_collision'))).toBe(true);
439+
expect(r.collisions).toEqual([
440+
expect.objectContaining({ name: 'shared_name', declaredBy: 'com.example.b', ownedBy: 'com.example.a' }),
441+
]);
435442
});
436443
});
437444

packages/plugins/plugin-security/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ export type {
5555
InvitationPlacementService,
5656
} from './invitation-placement.js';
5757
export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js';
58+
// [#17516] The set-name collision diagnostic. EXPORTED because its whole
59+
// purpose is to be the ONE derivation every door shares: the runtime door below
60+
// raises it today, and the compile-time door (`os build` / `os validate`, which
61+
// lives in another package) must consume these rather than re-deriving either
62+
// the predicate or the wording — that drift is what this card is about, one
63+
// layer up.
64+
export {
65+
PERMISSION_SET_NAME_COLLISION,
66+
formatPermissionSetNameCollisionDiagnostic,
67+
permissionSetNameCollisionDiagnostic,
68+
permissionSetNameIsForeign,
69+
reportPermissionSetNameCollisions,
70+
} from './permission-set-name-collision.js';
71+
export type {
72+
CollisionReportSink,
73+
PermissionSetNameCollisionDiagnostic,
74+
} from './permission-set-name-collision.js';
5875
// [ADR-0094] sys_permission_set pure-projection machinery.
5976
export {
6077
permissionSetRowFields,

0 commit comments

Comments
 (0)