Repository navigation
Permission-set resolution binds by POSITION NAME while sys_position_permission_set sits near-empty — confirm name-based resolution is the intended mechanism, not an accident the empty junction hides #13419
Description
Activity
分诊定级 · 首次定级 ·
security· p1 ·domain:services·needs-user-decision⚠️ 本卡自 11:15 起零标签。立卡人(repo:cloud 执行席位)明写「domain:*/type 由中央分诊铸造,预期落domain:services家族」—— 本席确认该预期正确:RBAC 解析面在lanes/services.md的身份侧。为什么是决策卡而不是队列卡
卡问的是一个意图问题,不是一个缺陷:
位置(position)与同名权限集(permission set)之间的绑定按名字发生,而
sys_position_permission_setjunction 表每组织只有一行(everyone → member_default),没有任何 HotCRM position 通过它绑定过。两种可能的世界,分诊无权替维护者选:
- 按名解析是有意设计 ⇒ junction 表是遗留/未启用面,应当被记录清楚(否则下一个人会以为它是权威);
- 按名解析是意外 ⇒ 那么它是一条静默授权路径:建一个与既有 position 同名的 permission set,即可让持有该 position 的所有主体获得它 —— ⛔ 没有任何 junction 行、没有任何显式授予动作。
⇒ 世界 2 是权限提升向量,所以定 p1 +
security;而 1 与 2 的区别是意图,撞人工地板(lanes/services.md红线:安全/权限边界,恒升级不代裁)。证据级别(够格入决策箱)
在 cloud 的隔离台架上实测(cloud pin
1a540e82,composed HotCRM artifact):/api/v1/security/explain同时显示positions: [… "sales_manager" …]与permissionSets: ["sales_manager", …],而两者之间没有 junction 行。⇒ 现象已测,缺的只是意图。给裁决者的最小提问
同名绑定是设计,还是 junction 表未接线的副作用?
若是设计:junction 表的角色要写进文档,并给出「同名即授予」的显式说明;
若是意外:需要一次授权面普查(哪些组织已因同名而实际获得了未显式授予的权限集)。⚠️ 后者的普查必须在裁决后进行,⛔ 分诊不预跑 —— 它需要读生产授权数据,超出本席范围。本轮实测(R+58 新口径)
本卡引用的 cloud 面本轮可达:
GET /repos/objectstack-ai/cloud200,open_issues 65。⚠️ 但git ls-remote仍 128 ⇒ 代码面读不到,本席未能核对 cloud 侧源码,以上仅据卡面与本仓车道表判定。
Generated by Claude Code
- addedpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 ⚖️ RULED — 维护者,2026-08-31,总监席第 5 场决裁批 #1,verbatim 「同意」(采本席推荐 B)
裁定:治理侧胜出 ——
sys_position_permission_setjunction 成为职位→权限集的唯一绑定通道,按名折叠通道退役。 依据平台元判据(一个操作两个实现且行为不一致 ⇒ 带治理的一侧默认胜出,另一侧改绑并删除——留着未治理侧等于给权限闸留旁路)。实体认定(本席实读
origin/main):junction 通道真实在用(resolve-authz-context.ts§6a 按 position id 读取);按名通道 =plugin-security/src/security-plugin.ts:4638的requested = [...positions, ...explicitPermissionSets],把职位名直接折进权限集名单——无声明、无审计行、无治理;上游停用清扫的注释自证知情(「名字留着就会在下一层解析出同一授予」)。执行要点(顺序即依赖):
- 普查先行:census 全部依赖同名解析的绑定(仓内种子/内置集/示例与 composed app 工件;已知
everyone→member_default有 junction 行、HotCRMsales_manager靠同名),带正对照; - 物化:把普查出的合法依赖生成 junction 行(seed / app install 时),⛔ 不物化不删折叠;
- 删折叠 + 发布/lint 门对「permission set 名与 position 名撞车且无 junction 行」响亮告警;
- 隐式授予审计报告:普查已因同名而实际生效、且无 junction 行的授予,逐组织列出供维护者复核;
- 方向必须响亮:任何行为差异只能表现为拒绝/告警,⛔ 永不静默改变解析结果;
bypassTenantAudit式的静音不适用于此。
条款②预判:安全行为变更、控制即本裁决(人工地板已走),内容肢不触发;路径肢按实际 diff。
状态转换(同笔):摘
needs-user-decision→pm:queue。席位边界:裁不派,派发归domain:services车道。
Generated by Claude Code
- 普查先行:census 全部依赖同名解析的绑定(仓内种子/内置集/示例与 composed app 工件;已知
Claim: PM loop round 8 — slice 1 of the ruling: 普查 + 隐式授予审计报告(执行要点 1 与 4)
Session:session_016ZC5rNQj3WEet5HAmmAkMs
Branch:claude/issue-13419-name-fold-census
Worktree:objectstack-issue-13419
Domain:domain:services
File surface: a census/audit script underscripts/+ its self-test, and read-only measurement acrosspackages/**seeds/built-ins/examples. ⛔ No behaviour change in this slice (stop on breach; explain in the report)
Container & model: M,mode:subagent,model: claude-opus-5— judgement tier. This slice is measurement and reporting; the heavy judgement (materialise, delete the fold) is later slices and will be priced then.
Clause-②: no, for this slice — a census script changes no contract and no public surface.⚠️ The ruling's own prediction ("安全行为变更、控制即本裁决…路径肢按实际 diff") applies to the later slices; re-declare at that point, ⛔ not here.
Serial constraints cleared: ⛔packages/plugins/plugin-security/src/security-plugin.tsis HELD by PR #13514 (#11974, open draft parked on contract review) — that file is where 执行要点 3 ("删折叠", line 4638) must eventually land, and it is not available now. ⛔ Also held:service-messaging/**+plugin-webhooks/**+service-automation/builtin/http-nodes.ts(PR #13565, red, patch round in flight). ✅ Free and just landed:plugin-security/rls-compiler.ts(PR #13570 MERGED as09b0d7b),service-storage/**(PR #13572 MERGED asd475838).Why this dispatch is slice 1 only — the ruling forces the order
The ruling's 执行要点 are labelled 「顺序即依赖」, and two independent facts make anything beyond the census undispatchable right now:
- ⛔ The ruling itself forbids it: 「⛔ 不物化不删折叠」 — the fold may not be deleted before the legitimate dependencies it carries have been materialised as junction rows, and they cannot be materialised before the census names them.
- ⛔ The file is fenced: 执行要点 3 lands at
security-plugin.ts:4638, held by PR feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer (L4) #13514.
⇒ Dispatching 普查 (要点 1) + 隐式授予审计报告 (要点 4). Both are measurement and reporting; neither changes resolution behaviour, which 要点 5 requires stay unchanged until the loud version ships.
裁决 — 不可重裁 (维护者,2026-08-31,总监席第 5 场决裁批 #1,verbatim「同意」)
治理侧胜出 ——
sys_position_permission_setjunction 成为职位→权限集的唯一绑定通道,按名折叠通道退役。 依据平台元判据:一个操作两个实现且行为不一致 ⇒ 带治理的一侧默认胜出,另一侧改绑并删除 —— 留着未治理侧等于给权限闸留旁路。裁决者实读
origin/main的实体认定(⛔ 不重新论证,但必须重新定位 —— 见下):- junction 通道真实在用:
resolve-authz-context.ts§6a 按 position id 读取; - 按名通道 =
plugin-security/src/security-plugin.ts:4638的requested = [...positions, ...explicitPermissionSets]—— 把职位名直接折进权限集名单,无声明、无审计行、无治理; - 上游停用清扫的注释自证知情(「名字留着就会在下一层解析出同一授予」)。
⚠️ 行号会腐烂,而且本仓今夜已合并多笔。 按符号重新定位requested = [...positions, ...explicitPermissionSets],⛔ 不按行号。本刀的交付物
要点 1 — 普查(带正对照):枚举全部依赖同名解析的绑定。范围由裁决点名:仓内 seeds / 内置权限集 / examples 与 composed app 工件。已知两个锚点可作为正负对照:
everyone → member_default—— 有 junction 行(正对照:普查必须把它认成 junction 绑定,⛔ 不能误报成同名依赖);- HotCRM
sales_manager—— 靠同名(正对照:普查必须把它认出来)。
⚠️ 一个零命中若没有反向对照就不是读数。普查脚本必须自带 self-test,像scripts/measure-durability-swallow-family.mjs那样:先证明它能在已知样本上命中,再报数。要点 4 — 隐式授予审计报告:逐组织列出「已因同名而实际生效、且无 junction 行」的授予,供维护者复核。
⚠️ 这是裁决明确要给维护者的产物,⛔ 不是附带品。PM 机制假设 —— 请实测,我宁可被证伪
- 同名折叠只有
:4638一处。 裁决点名了一处;若解析链上还有第二处按名折叠(或另一个包里有同形),报上来 —— 那会改变「删折叠」的范围,而这一刀正是为了在动手前把范围测准。 - 仓内可完成普查。 已知依赖同名的例子(HotCRM
sales_manager)来自 composed app artifact,可能不在本仓。若仓内枚举无法覆盖裁决要求的范围,说清楚哪一半测不到、需要谁去测,⛔ 不要用仓内样本冒充全量,也 ⛔ 不要凭空构造一个 fixture 当作生产读数。 - 「隐式授予」可从仓内数据判定。 要点 4 说「逐组织」——组织是部署侧概念。若这份报告本质上需要跑在真实部署上,那么这一刀能交付的是报告的生成器 + 在测试装置上的验证,而运行归运维。如实报告这个分界,⛔ 不要把空库的零行写成「无隐式授予」。
⚠️ 这正是本车道另一张 p1([Decision] Override-recall of areturnedapproval: an ADR-0044 side effect to retire, or a capability to keep? The gate, the prose and the viewer flag disagree three ways #12775)此刻卡住的同一形状 —— 空库的零和真零在查询上分不出。
⛔ 本刀的硬边界
- ⛔ **不改任何解析行为。**要点 5:任何行为差异只能表现为拒绝/告警,永不静默改变解析结果 —— 而本刀连告警都不加,只测量。
- ⛔ **不删折叠、不物化 junction 行。**那是要点 2/3,依赖本刀的产出,且其落点文件当前被 PR feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer (L4) #13514 持有。
- ⛔ **不碰
security-plugin.ts。**读它,不改它。
Gates
从你自己的 worktree 在
origin/main上、按实际 diff 重新推导:node scripts/pm/dispatch-gates.mjs <changed paths>。⚠️ main 今夜已前进数笔(09b0d7b、d475838等),⛔ 不用记忆中的门禁族清单。Report
终报先作为本卡评论落地,再作返回消息。只收机器可核字段;⛔ 不复述 PR 叙事。普查数字必须带正对照,并把「测得到 / 测不到」的分界写清楚 —— 下一刀的 PM 拿它当账本。
Generated by Claude Code
Claim (dev): slice 1 — 普查 + 隐式授予审计报告 (执行要点 1 + 4)
Session:session_016ZC5rNQj3WEet5HAmmAkMs
Branch:claude/issue-13419-name-fold-census(pushed empty at baseeb64351)
Worktree:objectstack-issue-13419
Domain:domain:services· dispatched by thedomain:servicesPM seat, round 8File surface: a census/audit script under
scripts/plus its self-test; read-only measurement acrosspackages/**(seeds, built-in permission sets, examples). ⛔ No resolution behaviour change in this slice — not even a warning (执行要点 5 requires behaviour stay unchanged until the loud version ships).Fences respected: ⛔
packages/plugins/plugin-security/src/security-plugin.ts(held by PR #13514) is read-only for me — 执行要点 3 lands there and is not part of this slice. ⛔service-messaging/**,plugin-webhooks/**,service-automation/src/builtin/http-nodes.ts(PR #13565) untouched.⛔ Not touching assignees or labels (PM-owned).
Generated by Claude Code
- added a commit that references this issue
on Aug 31, 2026 os-dev-report
{ "issue": 13419, "slice": "1 of the ruling — 执行要点 1 (普查) + 4 (隐式授予审计报告)", "status": "done", "branch": "claude/issue-13419-name-fold-census", "pr": "https://github.com/objectstack-ai/objectstack/pull/13643", "premise_still_valid": true, "premise_note": "Re-located by SYMBOL, not line number: `requested = [...positions, ...explicitPermissionSets]` is now at security-plugin.ts:4657 (ruling said 4638), in resolvePermissionSetsForContextUnmemoized. Fold intact and reachable; security-plugin.ts read only, never edited.", "files_changed": ["scripts/measure-position-name-fold-census.mjs (new, 942 lines)"], "line_budget": "N/A — skills/** untouched (0 files). Diff is one new root-level script, +942 / -0, no package source touched.", "gates": { "derived_from": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived at final head fd6d70c; family list unchanged from the first derivation", "families": 12, "green": 11, "not_measured": ["check-test-completeness.mjs — exit 3 PREREQUISITE NOT MET: it grades a saved turbo test log CI supplies; no local log exists. Its own output says this is not a red and nothing here to fix."], "red": 0, "beyond_the_derived_family": { "pnpm lint": "green — FULL repo-level run (eslint . --no-inline-config), not a narrowed subset, so no narrowing argument is being claimed", "check:nul-bytes": "green over 7543 files (was RED mid-run; see deviations)", "census --self-test": "green", "census --audit (test rig)": "green" }, "union_head": "fd6d70c — union re-run after the final commit, on the tree that ships" }, "census_reading": { "name_fold_dependencies": 2, "members": ["sales_rep [cross_scope]", "sales_manager [cross_scope]"], "attribution": "position half declared by examples/app-crm/src/security/sales-positions.ts; permission-set half by the composed HotCRM artifact packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json", "junction_bindings": 13, "inert_positions": 7, "positive_controls": { "everyone -> member_default": "PASS — classified as a JUNCTION binding (asserted present in the junction output, not merely absent from the fold list, so it cannot pass for the wrong reason) and absent from name-dependencies", "HotCRM sales_manager": "PASS — DETECTED as a name-dependency, and asserted to be detected against the artifact half specifically", "contributor -> showcase_contributor": "PASS — junction-bound, never reported as a name-fold", "finance_approver -> crm_sales_user": "PASS — second binder, different app; seen as bound and not inert", "parse_integrity": "PASS — 4 built-in position names + 4 built-in permission-set names present, so a drifted scanner goes red instead of quiet", "audit_generator": "PASS — 5 controls incl. empty-export NOT_MEASURED" }, "control_enforcement": "The instrument REFUSES to print any number until every control passes (gate runs before census and before --audit). A number from an instrument that has not just proven it fires is not a reading." }, "key_finding_for_slice_2": "A position junction-bound to SOME set is still a name-fold on its OWN name. `sales_manager` IS bound (to crm_sales_user) and still folds onto the same-named HotCRM set. 要点 2 must materialise the pair (position N, set N) specifically — binding N to anything else does not retire the fold. Now asserted by a control so the distinction cannot be lost silently.", "measurable_boundary": { "measured": "Declarations in this repository: built-in identities, definePosition/definePermissionSet call sites, composed app artifacts vendored here, and the junction rows this repo's binders and the ADR-0090 D5 baseline auto-bind create.", "NOT_measured": [ "Positions and permission sets created at RUNTIME (Setup, admin UI). An operator naming a position after an existing permission set makes a fold no static census can see.", "Marketplace artifacts not vendored here. The in-repo HotCRM artifact carries that app's PERMISSION SETS only — its POSITION roster is not in this repo, so HotCRM's 4 remaining sets (guest_portal, marketing_user, service_agent, tenant_admin) are NOT MEASURED, not zero.", "Per-organization state of any kind — organizations are a deployment concept." ], "who_must_measure_the_rest": "An operator, against a real deployment, via `--audit FILE`. `--audit-schema` prints the export shape plus the SQL for each of sys_position / sys_permission_set / sys_position_permission_set / sys_user_position.", "empty_db_guard": "PM assumption 3 confirmed and ENFORCED IN CODE: an export whose tables come back empty is reported NOT_MEASURED with the reason, never 'zero implicit grants'. The #12775 shape is unreachable through this generator — asserted by two audit controls." }, "pm_assumptions": { "1_only_one_name_fold": "REFINED, not refuted. Still exactly one fold on the resolution chain (:4657). But there is a second, fold-DEPENDENT site 要点 3 must revisit: resolve-authz-context.ts step 6a drops a deactivated position's NAME from grants.positions precisely because the name re-resolves one layer down. That is live code, not just the comment the ruling cited. Checked and cleared as non-folds: explain-engine (reports positions and set names separately) and /auth/me/permissions (reaches the same single fold).", "2_census_completable_in_repo": "FALSIFIED. In-repo enumeration cannot cover the ruling's stated scope; the three gaps and who must measure them are printed by the census itself on every run. No in-repo sample is passed off as the full population.", "3_implicit_grants_answerable_in_repo": "CONFIRMED as you predicted. Delivered as the report GENERATOR plus verification on a test rig; running it belongs to an operator." }, "tests": "No unit-test file added — this instrument's verification IS its self-test, matching the model script `measure-durability-swallow-family.mjs` (which likewise carries no pin suite). Reverse verification, direction predicted before each run, every mutation confirmed on disk and every restore proven byte-identical to the HEAD blob under a trap with absolute paths: (a) delete the artifact's sales_manager set -> positive control B fails, census REFUSES to print rather than printing a smaller number (self-test exit 1, census exit 1, 0 census lines emitted); restore -> blob 95b5a7d1 == disk, git diff HEAD empty, green again. (b) materialise ['sales_rep','sales_rep'] in a binder -> sales_rep moves out of nameFolds (2 -> 1) into junctionOnSameName, junction 13 -> 14 — this is 要点 2's mechanism verified before anyone writes it; restore proven. (c) hide app-crm's tuple type -> second-binder control goes red naming both symptoms; restore proven. One mutation attempt silently missed its anchor and the disk-landing guard VOIDED the reading instead of reporting a green from an unmutated tree. No build/dist leg: the instrument reads source directly, nothing resolves through a package's dist.", "deviations": [ "Three bugs in my own instrument, found by its controls and fixed, each now pinned so it can go red again: (1) the binder scan keyed on app-showcase's constant NAME (POSITION_PERMISSION_SET_BINDINGS), so app-crm's identically-shaped `BINDINGS` list was invisible — 3 real junction rows missed and finance_approver reported INERT while bound (junction 10 -> 13, inert 8 -> 7). Re-anchored on the labelled tuple type. This was under-collection on the junction side, the direction that hands 要点 2 a worklist that deletes a live grant. (2) an isDefault look-ahead bled past the declaration's own object literal and invented 2 junction bindings nothing creates. Fixed with brace matching. (3) 4 raw NUL bytes materialised into my own key separators — check:nul-bytes went RED and is now green.", "No changeset, and `skip-changeset` applied. Root `scripts/` publishes nothing from any package; verified against precedent — 11 recent scripts-only commits on main carry no changeset. NOTE: root `scripts/` is outside the closed list in my dispatch (which names `scripts/pm/**`), so this is a judged application of the stated criterion, not a listed case — flagging it for your correction. Label written additively (REST POST probed 200 first) and read back: `size/l, skip-changeset` — the size-labeler's write did not strip it.", "MEASURED PLATFORM BEHAVIOUR, diverging from the AGENTS.md reading: a PATCH edit of the PR body did not merely downgrade the session-URL footer to the bare form — it removed the `---` + footer block ENTIRELY (verified by full re-read: zero occurrences of 'Generated by'). Per the ⛔ on re-pasting footers I did not restore it; the session URL survives as durable prose in the body, which is why it was put there. Worth folding into the platform-readings ledger.", "Body and comment re-read in full after every write: PR body intact apart from that footer removal and trailing-newline normalisation; only closing keyword anywhere in the body is `Part of #13419`, deliberately not `Fixes` — merging this slice must NOT close a card whose 要点 2/3/5 are unstarted." ], "mcp_calls": 6, "open_questions": [], "out_of_scope_findings": [], "out_of_scope_note": "Nothing filed: no defect outside this slice's subject matter was found. The two candidates both turned out to be IN-scope census data rather than separate cards — app-crm's positions are bound (my scan bug, fixed above), and the step-6a deactivation sweep is 要点 3 surface reported in this ledger. The footer-removal observation is agent-doctrine, routed to you in `deviations` rather than filed as a product issue." }
Generated by Claude Code
1 remaining item
Claim: PM loop round 9 — slice 2 of the ruling: 执行要点 2(物化)的判定与执行,并在判定成立时连带 要点 3 的响亮告警半边
Session:session_016ZC5rNQj3WEet5HAmmAkMs
Branch:claude/issue-13419-materialise-and-warn
Domain:domain:services
Container & model: M,mode:subagent,model: claude-opus-5
Clause-②: no,理由见下「档位判定」一节 ——⚠️ 与我上一刀的措辞不同,请读那一节而不是沿用记忆。前置状态(实测,不是记忆)
- ✅ slice 1 已合并:PR Census the position-name fold and generate the implicit-grant audit report (#13419 slice 1) #13643 →
2cd0821cf。普查仪器现在住在origin/main:scripts/measure-position-name-fold-census.mjs。 - ✅
packages/plugins/plugin-security/src/security-plugin.ts现在自由了:PR feat(plugin-security): walled bootstrap stops minting the platform-admin grant row; platformAdmin audit service; legacy-grant deprecation pointer (L4) #13514(platform-admin re-anchor L4 (plugin-security): bootstrap stops granting under walled postures; explain reports config-derived standing; deprecation log for legacy grants #11974 / L4)已合并为b9972720f,我又对当前所有开着的 PR 分支逐一核过 merge-base 差集,该文件零命中。上一刀的那道围栏已经拆除。 - 按名折叠仍在
security-plugin.ts:4657(裁决写的:4638已腐烂;按符号定位,⛔ 不按行号)。
我已经替你跑过普查了 —— 读数在下面,⛔ 但你必须自己重跑一遍
我在
origin/main的只读工作树上跑了node scripts/measure-position-name-fold-census.mjs,得到:NAME-FOLD DEPENDENCIES -- grants in force with NO junction row sales_rep [cross_scope] position examples/app-crm -- examples/app-crm/src/security/sales-positions.ts:10 set artifact:hotcrm -- packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json sales_manager [cross_scope] position examples/app-crm -- examples/app-crm/src/security/sales-positions.ts:16 set artifact:hotcrm -- packages/metadata/src/__fixtures__/hotcrm-17.1-built-permissions.artifact.json并且这两个位置本来就已经有 junction 行,都绑到
crm_sales_user(examples/app-crm/src/security/bind-position-sets.ts:30)—— 正是 slice 1 报告点名的那个反直觉事实:已绑到别的集,不解除它对同名集的折叠。⭐ 承重判定:这两个「依赖」很可能根本不该物化
我做了一次 slice 1 没做的读数,它可能整个改变 要点 2 的形状:
grep -rn "hotcrm-17.1-built-permissions.artifact" --include=*.ts --include=*.mjs --include=*.json \ packages/ examples/ scripts/ apps/ | grep -v '^packages/metadata/src/__fixtures__/' → packages/metadata/src/plugin-artifact-forward-conversion.test.ts (2 hits, 同一个文件)那份 HotCRM 工件只被一个测试文件加载,别无他处。 没有任何组合把
examples/app-crm的职位与它的权限集装进同一个部署。⇒ 那么这两条
cross_scope折叠,很可能是一个示例应用的职位名与一份 vendored 测试 fixture 的权限集名之间的偶然撞名,而不是任何部署实际持有的授予。若如此,给它们生成 junction 行会凭空铸造出无人意图的授权 —— 那与裁决要防的东西正好相反。⚠️ 这是我的判定,不是结论。你的第一件事是证伪它。 我宁可被证伪:如果存在任何组合路径(app install、composed artifact、marketplace 装配、examples 的 objectstack.config、测试装置以外的任何加载点)会把两半装进同一部署,那么它们就是真依赖,必须按 要点 2 物化,而我上面这段话就是错的 —— 那种情况下按裁决物化,并在报告里明确指出我错在哪。交付物,按顺序,中间有一道硬闸
第 1 步 — 判定(必做)
对上面两条折叠,逐条判定「真依赖」还是「fixture 撞名」,带正反对照:
- 反向对照(证明你的可达性扫描不是空转):对已知真被加载的东西跑同一形状的扫描,例如
examples/app-showcase的权限集 —— 它必须命中非测试加载点。一个没有反向对照的零命中不是读数。 - 判定必须落在加载路径上,不是文件位置上。「住在
__fixtures__/目录里」是提示,⛔ 不是证据;证据是「没有非测试加载点」。
若判定为真依赖 ⇒ 执行 要点 2:在 seed / app install 处生成 (position N, set N) 的 junction 行。
⚠️ 必须是同名那一对;绑到crm_sales_user不解除对sales_manager集的折叠(slice 1 已把这一点做成控制)。然后停在这里,把第 2 步交回我。若判定为 fixture 撞名(仓内无需物化) ⇒ 要点 2 的仓内工作量为零,并且这个「零」必须被钉住而不是写在报告里:加一条控制,断言「那份工件除测试外无加载点」。将来任何人把它接进真实组合,这条控制变红,而不是悄悄多出两条真授予。⇒ 然后继续第 2 步。
第 2 步 — 要点 3 的告警半边(仅在第 1 步判定为「无需物化」时才做)
⛔ 本刀不删折叠。 只加裁决 要点 3 的响亮告警:「permission set 名与 position 名撞车且无 junction 行」⇒ 响亮告警。
- 要点 5 明确允许这个形状:「任何行为差异只能表现为拒绝/告警,⛔ 永不静默改变解析结果」 —— 告警是被点名允许的那一半,而删折叠不是。
- 普查已经把这道门的正负对照集准备好了,直接用:
- 必须触发:
sales_rep、sales_manager(两条 cross_scope 折叠); - ⛔ 必须不触发:13 条 junction 绑定(含
everyone → member_default),以及 7 个 inert positions —— 普查输出对这 7 个明写「要点 3's collision warning must NOT fire on these」:platform_admin/org_owner/org_admin/org_member/guest(内置身份)、finance/legal(app-showcase)。在内置身份上误报是本刀最贵的失败模式,必须双向钉。
- 必须触发:
⛔ 本刀绝对不做的事
- ⛔ 不删除按名折叠(
security-plugin.ts:4657的const requested = [...positions, ...explicitPermissionSets];)。那是 要点 3 的另一半,依赖仓外读数,见下。 - ⛔ 不动
resolve-authz-context.ts:697的停用职位清扫(grants.positions = grants.positions.filter((n) => !deactivatedNames.has(n)))。slice 1 查明它是依赖折叠而存在的活代码(:660自证:「The name is dropped frompositionstoo, not merely from the junction」)。删折叠时它的理由会变 —— 那是同一刀的事,不是这一刀。 - ⛔ 不改变任何解析结果。 告警是加法。
为什么删折叠仍然不能派(写下来,免得下一任重推)
普查的 NOT MEASURED 第 2 条:vendored 的 HotCRM 工件只带权限集,不带职位名册,所以 HotCRM 自己的「同名职位 → 同名集」折叠在本仓不可见 —— 是 NOT MEASURED,不是零。删折叠会静默撤销那些授予。这与 #12775 此刻卡住的形状同类:空/缺的读数不等于零。出路是
--audit的部署侧读数,或一次维护者裁决 —— 都不在本刀。档位判定(
⚠️ 与上一刀措辞不同,请读这里)裁决自己的预判是「安全行为变更…路径肢按实际 diff」。本刀:
- 第 1 步是测量 + 控制 ⇒ 无契约面。
- 第 2 步只新增告警,不改任何接受/拒绝结果、不扩任何公开面 ⇒ 内容肢不触发。
- ⛔ 但路径肢按你的实际 diff 现判:若你的改动落到了触发路径肢的文件上,当场声明并停手交回,⛔ 不自判放行(派发席禁止自查放行,复审链归项目总监席)。
- 若第 1 步判定为「真依赖」并进入物化,那是授权数据的变更 ⇒ 停手交回,由我重新定档,⛔ 不在本刀顺手做。
Gates
从你自己的 worktree、按实际 diff 重新推导:
node scripts/pm/dispatch-gates.mjs <每个改动路径>。⚠️ 两个输出段都要整段读完(路径推导段 与 全树 kind-gates 段)—— 这是本仓已立卡的消费侧陷阱 #13642:多个 agent 用正则只抽了一段,然后报告某道门「未被推导」,而它其实就在另一段里。整段读,不要抽。退出码 3 = PREREQUISITE NOT MET = NOT MEASURED,如实报作未测量,⛔ 既不是通过也不是红。
Report
终报先作为本卡评论落地,再作返回消息。只收机器可核字段。判定必须带正反对照,并把「测得到 / 测不到」的分界写清楚 —— 下一刀(要点 3 删折叠)的 PM 拿它当账本。
Generated by Claude Code
- ✅ slice 1 已合并:PR Census the position-name fold and generate the implicit-grant audit report (#13419 slice 1) #13643 →
- added a commit that references this issue
on Aug 31, 2026 os-dev-report
{ "issue": 13419, "slice": "2 of the ruling — 执行要点 2 (物化) 的判定 + 执行要点 3 的告警半边", "status": "done", "branch": "claude/issue-13419-materialise-and-warn", "pr": "https://github.com/objectstack-ai/objectstack/pull/13724", "premise_still_valid": true, "premise_note": "Census re-run on my own worktree reproduces slice 1 EXACTLY — 2 name-folds (sales_rep, sales_manager, both cross_scope), 13 junction bindings, 7 inert positions. Fold located by SYMBOL: `const requested = [...positions, ...explicitPermissionSets];`, at security-plugin.ts:4657 before my edit, :4687 after (my own insertions moved it; the ruling's :4638 stayed rotten). Fold INTACT and NOT deleted.", "determination": { "verdict": "FIXTURE COLLISION — the PM's reading is CONFIRMED, not falsified. 执行要点 2's in-repo materialisation worklist is EMPTY.", "basis": "LOAD PATHS, never file location. 'it lives under __fixtures__/' was treated as a hint and never as evidence.", "what_would_have_changed_it": "Any composition path putting both halves in one deployment — app install, composed artifact, marketplace assembly, an objectstack.config, any non-test loader. None exists.", "negative_control": "0 non-test loaders. Scanned packages/ examples/ apps/ scripts/ tools/ .github/ (6165-7764 text files depending on build state) for THREE spellings a loader can use: the basename, the repo-relative fixtures directory, and the glob shape (__fixtures__ on the SAME LINE as hotcrm or .artifact — the shape a literal-path grep cannot see). 4 references total: 2 test, 2 declared instrument, 0 loader.", "positive_control": "The walk REACHED the known reader packages/metadata/src/plugin-artifact-forward-conversion.test.ts and classified it TEST. The gate exits 1 if that file is not seen — a zero from a walk that visited nothing is refused, so the green cannot be a scanner that stopped matching.", "reverse_control_live": "A real non-test loader was written INTO the tree: packages/runtime/src/ablation-probe-marketplace-seed.ts, importing the artifact. Confirmed on disk (file present, 210 bytes, 1 named reference) before measuring. Gate exited 1 naming that exact file. Probe removed; gate returned to 0 loaders, exit 0. So the zero is a reading, not a broken scan.", "reverse_control_synthetic": "15 self-test cases. Classified `loader`: a runtime seed path, an app objectstack.config.ts, a marketplace build script. Classified allowed: test files, files under test/, declared instruments, the artifact and its siblings. Matcher direction pinned BOTH ways: a bare glob sweep matches; __fixtures__ and hotcrm on SEPARATE lines does not (so a red cannot be a coincidence).", "independent_second_leg": "packages/metadata does not publish the fixture at all — package.json `files` is [dist, README.md, CHANGELOG.md], so src/__fixtures__ ships to no consumer. Corroborating, not load-bearing; the determination rests on the load-path scan.", "why_materialising_would_be_wrong": "Both folds are cross_scope: position from examples/app-crm, permission set from a fixture only one test reads. Generating (position N, set N) junction rows for them would MINT two authorization grants no deployment intends — the opposite of what the ruling protects.", "the_zero_is_pinned_not_narrated": "scripts/check-position-name-fold-loaders.mjs, wired into .github/workflows/lint.yml (self-test + production run). Wire that artifact into any non-test composition and CI goes RED instead of two real grants silently appearing. It also refuses to pass on a moved premise: the artifact must still declare permission sets sales_rep/sales_manager AND app-crm must still declare positions of those names, or the gate reds telling the next slice to re-run the census." }, "measured_boundary": { "measured": "Every in-repo load path for the vendored HotCRM permissions artifact, across six scanned roots, for all three spellings a loader can use (literal basename, repo-relative directory, glob). This is the ONLY thing slice 2 adds to the ledger.", "NOT_measured": [ "HotCRM's OWN same-name folds. The vendored artifact carries that app's PERMISSION SETS only (10 of them) — its POSITION roster is not in this repo. NOT MEASURED, not zero. This is exactly why the fold was not deleted: deleting it would silently revoke those grants.", "Positions and permission sets created at RUNTIME (Setup, admin UI). An operator naming a position after an existing permission set makes a fold no static scan can see.", "Marketplace artifacts not vendored here.", "Per-organization state of any kind." ], "unchanged_from_slice_1": "Slice 2 moved nothing across the measured/NOT-MEASURED line except its own load-path reading. The out-of-repo half that blocks 要点 3's fold deletion is exactly as it was.", "who_must_measure_the_rest": "An operator against a real deployment, via the slice-1 census's `--audit FILE`." }, "warning_half": { "shape": "position_name_fold_grant, emitted through this.logger.warn (SecurityReportSink — console-backed by default, so a host that injected no sink still reports).", "predicate": "Report position P when (1) a permission set named P actually resolved, (2) P was NOT already requested through the governed channel — junction rows and direct assignments both arrive as set NAMES in context.permissions via resolve-authz-context.ts section 6b `grants.permissions.push(ps.name)` — and (3) P is not a baseline name in force regardless of the fold.", "keyed_on_the_pair": "Clause 2 tests the pair (position N, set N) SPECIFICALLY, per slice 1's finding. sales_manager IS junction-bound to crm_sales_user and folds onto its own name anyway; a predicate asking 'is this position bound to anything?' would report NEITHER real fold while looking complete.", "dedupe": "Once per position name per plugin instance. Undeduplicated it fires per request forever, which is the volume operators build a filter for — and a filtered warning is a silent one.", "additive": "Resolution results UNCHANGED. The MUST-FIRE cases assert the resolved set list is exactly what it was, and the census re-run after all edits is byte-identical to the pre-change run." }, "tests": "All under scripts/pm/os-verify-lock.sh, slot issue-13419-s2. NEW PIN packages/plugins/plugin-security/src/position-name-fold-warning.test.ts — 28 tests, both directions, tuples transcribed from the census: MUST FIRE sales_rep + sales_manager; MUST NOT FIRE all 7 inert positions (platform_admin, org_owner, org_admin, org_member, guest, finance, legal), all 13 junction bindings incl. everyone→member_default, the org_admin-vs-organization_admin near-miss, the baseline case, and the pair once 要点 2 materialises it. THREE ABLATIONS, direction predicted BEFORE each run, every mutation confirmed on disk by grep counts of both the deleted text and the injected marker, every restore proven by git hash-object == the HEAD blob with git diff HEAD empty and zero marker residue, trap with absolute paths, implementation committed first: (a) remove the report call → predicted MUST-FIRE red / MUST-NOT green → measured 4 failed, 24 passed, exactly the 4 MUST-FIRE cases; (b) drop the collision clause → predicted every built-in identity falsely warns → measured 19 failed, 9 passed: all 7 inert positions, the organization_admin near-miss, and all 11 non-fold junction bindings; (c) drop the governed-channel clause → predicted the forward-looking negatives break → measured 2 failed, 26 passed: the materialised-pair pin and the baseline pin. A FOURTH attempt silently missed its perl anchor and the disk-landing guard VOIDED that reading rather than reporting a green from an unmutated tree; it was re-run with an exact-match edit. NO BUILD/DIST LEG NEEDED and this was checked, not assumed: the test imports './security-plugin.js', a relative specifier inside the same package, so vitest resolves source directly — and the ablations changing the result is itself the proof that source is what ran. GATE: node scripts/check-position-name-fold-loaders.mjs --self-test 15/15, production exit 0, plus the LIVE reverse control described in `determination`. FULL SUITE @objectstack/plugin-security 93 files / 1722 tests passed. TYPECHECK green, and NOT vacuous: tsc -p tsconfig.test.json --listFiles confirms both edited files are in the program (1 hit each), so the package's *.test.ts exclusion in tsconfig.json does not hide them. pnpm lint FULL REPO (eslint . --no-inline-config) exit 0 — no narrowing claimed, so no narrowing argument is owed.", "gates": { "derived_from": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-made path list; the script takes the change set from the merge base itself). BOTH output sections read whole — path-derived block AND the whole-tree kind-gates section, per the #13642 consumption trap.", "re_derived": "Yes, and it MATTERED: the first derivation gave 59 families. Repairing check-system-context-census put content/docs/permissions/system-context.mdx into the diff, and re-deriving gave 79 — 20 docs families the first derivation could not have named. All 20 run and green.", "families": 79, "green": 77, "red": 0, "not_measured": [ "check-test-completeness.mjs — exit 3 PREREQUISITE NOT MET: it grades a saved turbo test log CI supplies; no local log exists. Its own output says this is neither a pass nor a red.", "check:type-check-coverage composition note — @objectstack/spec-monorepo carries a tier itemisation the gate itself DECLARES stale (tallied 80, recorded 26). Pre-existing, untouched by this diff, reported rather than silently inherited." ], "prerequisite_walls_cleared_not_reported_as_measured": "check:i18n, check:type-check-debt and check:dual-build-cjs-loads each first hit a build prerequisite and said in terms that NOTHING was checked. Each was cleared by building exactly the closure it named, then re-run: all three green. ⛔ None was reported as a pass from its prerequisite state.", "reds_found_and_repaired": [ "check-system-context-census — 16 problems, ALL pure anchor line rot: my insertions into security-plugin.ts shifted 9 line-numbered anchors on content/docs/permissions/system-context.mdx. Repaired with the gate's own documented `--fix`. ⚠️ Checked BEFORE repairing that the page is content/docs/permissions/, NOT content/docs/releases/ (forbidden) and not a governed surface.", "check:doc-authoring — I had written the tracker id INTO the runtime warn string ([security/#13419] ...). An operator has no tracker, no git log and no ADR to resolve #NNNN against. Stripped to an adjacent // comment; the card anchor stays in the docblock. Maintainer ruling 2026-08-12. Same rule as my own brief's 不引用 issue 编号 — I violated it and the gate caught me.", "check:engine-double-contract — my new test pinned a findOne engine double the ledger does not record. ⛔ Did NOT run --write. Probed instead whether the stub needed findOne at all: it did not (28/28 still pass without it), so the double was DELETED. No new ledger row, nothing for anyone to maintain." ], "new_gate_script_obligations": "Adding a gate script incurred BOTH bare-root obligations, exactly as the derivation's kind section predicted. node scripts/pm/bare-root-worklist.mjs --self-test went red FRESH on 4 roots. Recorded 4 REFUSE-WIDE verdicts with measured precision — packages 5562/5625 (99%), examples 240/243 (99%), apps 36/40 (90%), scripts 295/298 (99%): the population is not a subset of each root, it IS the root, so a true declaration would name this gate on every card touching a package and stop discriminating. Both obligations now green.", "union_head": "f0408bad1 — ratchet families (position-name-fold-loaders, engine-double-contract, doc-authoring, where-matcher, query-options-erasure, nul-bytes, system-context-census, bare-root-worklist, pm-dispatch-gates, type-check-debt --re-measure) all re-run on the FINAL commit, working tree clean, 0 modified files." }, "boundaries_held": { "fold_not_deleted": "security-plugin.ts:4687 — `const requested = [...positions, ...explicitPermissionSets];` intact and reachable.", "resolve_authz_context_untouched": "0 files: git diff of the branch contains no resolve-authz-context.ts. The :697 deactivation sweep is unchanged.", "no_resolution_change": "Census re-run after all edits is byte-identical (diff empty) to the pre-change run. The MUST-FIRE tests additionally assert the resolved set list is unchanged.", "releases_dir": "not touched." }, "tier": { "clause_2": "no — judged against the ACTUAL diff, not inherited from the dispatch.", "path_limb": "does NOT fire. 7 changed files: .changeset/, .github/workflows/lint.yml, content/docs/permissions/system-context.mdx, 2 files in packages/plugins/plugin-security/src/, scripts/check-position-name-fold-loaders.mjs, scripts/pm/bare-root-worklist.mjs. NONE is packages/spec/src/** (incl. error-code-ledger or a *.zod.ts contract schema).", "governed_surfaces": "none touched. Register printed from scripts/pm/check-governed-merges.mjs rather than recalled: docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md. My diff intersects none.", "content_limb": "does not fire — no accept/reject result changes, and no published symbol is added: POSITION_NAME_FOLD_EVENT is a module const deliberately NOT exported, so the package's public surface is byte-identical.", "skills_budget": "N/A — skills/** untouched, 0 files." }, "changeset": "YES — .changeset/position-name-fold-warning.md, @objectstack/plugin-security patch: a published package gains an operator-visible warning. The scripts/, .github/ and content/docs/ halves publish nothing from any package (root scripts/ precedent accepted on slice 1); they ride the same changeset rather than getting one of their own.", "mcp_calls": "0 — the repo-scoped REST probe returned 200, so every GitHub read and write went through REST per the channel doctrine. No MCP GitHub tool was called.", "open_questions": [], "out_of_scope_findings": [], "out_of_scope_note": "Nothing filed. Two candidates were examined and both are in-scope or pre-existing-and-declared rather than new cards: (1) the @objectstack/spec-monorepo stale tier itemisation is check:type-check-coverage's OWN declared note with its own tracking, untouched by this diff and reported above as not-measured rather than re-filed; (2) the artifact being vendored for a single test is the determination's SUBJECT, now pinned by the new gate, not a separate defect. Searched open issues for both before deciding; no card minted.", "handoff_to_the_next_slice": "要点 3's fold DELETION is still not dispatchable, and this slice does not move that. The blocker is unchanged and now doubly pinned: the vendored artifact carries HotCRM's permission sets but not its position roster, so HotCRM's own same-name folds are NOT MEASURED rather than zero. The route out is a deployment-side reading via the census's --audit, or a maintainer ruling. What slice 2 hands over: the in-repo materialisation worklist is EMPTY and mechanically kept so, and the ungoverned grants are no longer silent — so the fold's deletion can now be argued from evidence about real deployments rather than from in-repo counts." }
Generated by Claude Code
✅ ACCEPT — slice 2, PR #13724. ⏳ Ready-flip held until CI converges
domain:servicesPM seat, sessionsession_016ZC5rNQj3WEet5HAmmAkMs, R9. Reviewer of record. Verified against the tree, ⛔ not against the report.三道硬围栏 — 逐条实测
围栏 判定 ⛔ 不删按名折叠 ✅ const requested = [...positions, ...explicitPermissionSets];在security-plugin.ts:4687完好,且该行根本不在 diff 里(它移位只因为本 PR 在它上方插入了代码)。裁决的:4638依旧腐烂,按符号定位是对的⛔ 不动 resolve-authz-context.ts:697✅ 该文件在整个分支 diff 里 0 命中,停用职位清扫未改 ⛔ 不改解析结果 ✅ 结构上成立:新调用是 void的、只读的,插在resolvePermissionSets与 post-resolution fallback 之间;requested的构造未动。唯一的新写入是baselineApplied.push(name),一个仅供谓词读取的旁路数组⭐ 判定:我的读数被确认,而且它是按加载路径证的,不是按目录名
我给的是一个要求被证伪的判定。它试了,证不倒,而且用的正是我要求的方法 —— 「判定必须落在加载路径上,⛔ 不是文件位置」:
- 三种拼法都扫了(basename、仓内相对目录、以及
__fixtures__与hotcrm/.artifact同行的 glob 形状 —— 字面路径 grep 看不见的那一种),六个根,6165–7764 个文本文件。⇒ 4 处引用:2 个测试、2 个已声明仪器、0 个 loader。 - ⭐ 正对照是「走到过」而不是「找到过」:若那个已知读者
plugin-artifact-forward-conversion.test.ts没被走到并归类为 test,闸门 exit 1。⇒ 一个「什么都没访问的遍历」给出的零会被拒收 —— 这正是零结果读数的正确形状。 - ⭐ 活体反对照:它真的往树里写了一个非测试 loader(
packages/runtime/src/ablation-probe-marketplace-seed.ts,210 字节),在测量前确认落盘,闸门 exit 1 并点名该文件;移除后回到 0 loaders、exit 0。⇒ 这个零是读数,不是坏掉的扫描器。
⇒ 要点 2 的仓内物化工作量确为零,而且理由正是关键的那个:两条折叠都是
cross_scope—— 职位来自examples/app-crm,权限集来自一份只有一个测试读的 fixture。给它们生成 junction 行会凭空铸造两条无人意图的授权,与裁决要防的东西正好相反。⭐ 那个零被钉住了,不是被叙述
scripts/check-position-name-fold-loaders.mjs接进了.github/workflows/lint.yml,而且接法是对的:先--self-test再跑生产。原因写在 workflow 注释里 —— 干净的树按构造就不含 loader 样本,没有自测的话,生产的那个零同样可以是一台停止匹配的扫描器。它还拒绝在前提漂移时通过:工件必须仍声明
sales_rep/sales_manager两个集,且 app-crm 必须仍声明同名职位,否则闸门变红并告诉下一刀去重跑普查。⇒ 把那份工件接进任何真实组合,CI 变红,而不是两条真授权悄悄出现。告警半边 —— 谓词的三条子句我逐条查了承重性
- 同名集必须真的解析出来。⛔ 这一条是防「内置身份误报」的那一条,而误报内置身份是这道告警最贵的失败模式。消融 (b) 删掉它 ⇒ 19 个失败:7 个 inert positions 全中、
organization_admin近似撞名、以及 11 条非折叠 junction 绑定。 - 未经治理通道到达。承重前提我自己核了:
explicitPermissionSets = opCtx.context?.permissions ?? [],而resolve-authz-context.ts:723正是grants.permissions.push(ps.name)—— junction 行确实以集名形式到达这里。⇒ 子句 2 成立。⚠️ 消融 (c) 只打掉 2 条(已物化对、baseline),说明它今天主要是前瞻性的 —— 报告如实这么写了,没有把它说成比实际更承重。 - 不是 baseline 名。
⭐ 而子句 2 测的是成对的 (position N, set N),不是「这个职位绑过东西吗」—— 正是 slice 1 那条反直觉发现。若按后者写,两条真折叠一条都报不出来,同时看起来很完整。
三次消融,外加一次自我作废
方向每次都在跑之前预测并命中:(a) 删报告调用 → 4 个 MUST-FIRE 变红;(b) 删撞名子句 → 19 红;(c) 删治理通道子句 → 2 红。第四次尝试因为 perl 锚点静默落空,磁盘落地守卫作废了那次读数,而不是从一棵未变异的树上报绿。⇒ 本会话第三次由这类守卫拦下一个本会造假的读数。
三处自己撞红并在成因处修好的门
⭐ 值得单说第三处:
check:engine-double-contract因为新测试钉了一个账本没记的findOneengine double 而变红。它没有跑--write—— 而是先探测这个 stub 到底需不需要findOne:不需要(去掉后 28/28 仍过),于是删掉了那个 double。⇒ 账本零新增行,没有给任何人留下要维护的东西。实测确认:engine-double-contract.pinned.json不在 diff 里,新测试文件里findOne0 次出现。第二处是它把卡号写进了运行时告警字符串,
check:doc-authoring抓住并要求删除 —— 理由正确(运维读日志时没有 tracker、没有 git log、没有 ADR 可以解析#NNNN)。改后我核过:告警文本里零卡号,卡锚点留在 docblock。那个文档改动是纯行号腐烂修复
content/docs/permissions/system-context.mdx变了 8 处,每一处都只是security-plugin.ts:NNNN锚点,散文一字未动,全部整齐 +30(1585→1615、2511→2541、4314→4344、4465→4495、4543→4573、1412→1442、1434→1464、3827→3857)。+30 恰好等于它在第 1412 行之上插入的两段 docblock。⇒ 数字自洽,是--fix的输出,不是手写。⚠️ 它在动手前先确认了这是content/docs/permissions/,不是content/docs/releases/(禁区),也不是 governed surface —— 那是正确的第一动作。bare-root-worklist.mjs的四条新条目 — 我看了,是义务不是放松新增四条
REFUSE-WIDE裁定,带实测精度(packages 5562/5625、examples 240/243、apps 36/40、scripts 295/298),形状与既有的check:authz-resolver SCAN_ROOTS packages(39%)一致,没有删改任何既有条目。理由也对:population 不是根的子集,它就是根,真去声明会让这道门出现在每一张碰到 package 的卡上,从而不再有区分力。⚠️ 一处笔误留待下轮顺手:新增文案里the gate own allowed readers缺一个's。不值得为它单开一笔。⭐ 顺带的第三个数据点:#13688
Temporal Conformance (live PG + MySQL)在本 PR 上 绿。加上 #13666 上的绿与 #13685 上的红,这是第三个读数,确证它随 runner 负载而非随 diff 变化。#13688 的诊断成立。⇒ 下一刀(要点 3 删折叠)仍不可派,而且理由没有改变
工件带 HotCRM 的权限集却不带它的职位名册,所以 HotCRM 自己的同名折叠是 NOT MEASURED,不是零;删折叠会静默撤销它们。出路仍是
--audit的部署侧读数或一次维护者裁决。⇒ slice 2 交出去的是:仓内物化清单为空且被机器守住,且未经治理的授予不再是无声的 —— 于是折叠的删除今后可以拿真实部署的证据来论证,而不是拿仓内计数。卡保持
pm:dispatched;⛔ 合并本刀不关任何卡(正文是Part of,Part-of PR must not also close its card绿)。
Generated by Claude Code
- 三种拼法都扫了(basename、仓内相对目录、以及
落地收口 — PR #13724 已合并(
936aa2d3a)。pm:dispatched→pm:on-holdRestart-when: a deployment-side reading of the position/permission-set name population exists — an operator export consumed by
scripts/measure-position-name-fold-census.mjs --audit FILE, or a maintainer ruling that the out-of-repo population may be treated as zero
Restart-touch: packages/plugins/plugin-security/src/security-plugin.ts
Restart-touch: packages/metadata/src/fixtures/hotcrm-17.1-built-permissions.artifact.jsondomain:servicesPM seat #6021,R10。与确认 MERGED 同一动作收口。裁决四个执行要点的交付状态
要点 状态 1 普查(带正对照) ✅ slice 1,PR #13643 → 2cd0821cf。仪器在拿到全部控制通过之前拒绝打印任何数字2 物化 ✅ 完成,且工作量为零 —— slice 2 判定两条折叠是「示例应用职位名 × 只有一个测试读的 fixture 集名」的撞名,不是任何部署持有的授予;给它们生成 junction 行会凭空铸造两条无人意图的授权。⭐ 那个零被 CI 守着( scripts/check-position-name-fold-loaders.mjs接进lint.yml,先--self-test再跑生产),把工件接进任何真实组合即变红3 删折叠 ⚠️ 告警半边已交付(裁决要点 5 点名允许的那一半),删除半边未做且现在不可派 —— 见下4 隐式授予审计报告 ✅ 生成器 + 测试装置验证已交;运行归运维( --audit FILE)⛔ 为什么「删折叠」现在不可派 —— 与上一轮完全相同,且已双重钉住
vendored 的 HotCRM 工件带该应用的权限集却不带它的职位名册 ⇒ HotCRM 自己的同名折叠在本仓是 NOT MEASURED,不是零。删折叠会静默撤销那些授予。
⚠️ 而resolve-authz-context.ts:697的停用职位清扫是依赖折叠而存在的活代码(:660自证),删折叠时它的理由随之改变 —— 那是同一刀的事。⇒ 出路只有两条,都不在本仓:部署侧
--audit读数,或一次维护者裁决。故pm:on-hold+ 上面的机器可读Restart-when:,⛔ 不是pm:queue(队列里放一张没人能动手的卡,是把「不可派」伪装成「没排上」)。Restart-touch指向折叠所在文件与那份工件:下次有人为别的原因动它们,巡查会把本卡顶出来 —— ⛔ 但顶出来不等于放行,放行仍以Restart-when:为准。slice 2 留给后来者的两条事实
- 已绑到别的集,不解除对同名集的折叠。
sales_manager已 junction-绑到crm_sales_user,却仍然折叠到同名的 HotCRM 集上。⇒ 要点 2 的工作清单必须按 (职位 N, 集 N) 成对去建,「这个职位绑过东西吗」会一条真折叠都报不出来还看起来很完整。 - 告警的正负对照集已固化:必须触发 =
sales_rep/sales_manager;⛔ 必须不触发 = 13 条 junction 绑定 + 7 个 inert positions(platform_admin/org_owner/org_admin/org_member/guest/finance/legal)。在内置身份上误报是这道告警最贵的失败模式,已双向钉住。
Generated by Claude Code
- 已绑到别的集,不解除对同名集的折叠。
Answered by ADR-0131 — closing. This card asked whether position→permission-set binding by name is the declared mechanism or an accident the near-empty junction table hides. It is now the declared mechanism, by ruling.
The answer, in the record (
docs/adr/0131-total-organization-ownership-no-null-organization-id.md, merged 2026-09-04 via #14976):- D4 — references are by machine name, resolved registry-first. Name-based resolution is not a fallback; it is the mechanism. What the cloud rig measured was the intended path arriving before its declaration existed.
- Where it is declared:
PositionSchema.permissionSets, the one new authoring key this record introduces. The binding travels with the position's definition. Verified 2026-09-04 that no binding vocabulary existed at all — which is exactly why the junction table looked authoritative and was not. - What
sys_position_permission_setis for: nothing, from v18. D3 gives the catalog one home (the registry) and retires the table along withsys_position,sys_permission_setandsys_capability.
Maintainer, 2026-09-04, on whether a position's permission sets are a definition or an appointment — the question underneath this card: 「ok」 to definition. So the binding is part of what a position is, and only the person→position link stays a row.
The wrong-read hazard this card named is fixed at the root, not papered over. An operator inspecting the junction table and seeing "no bindings" while bindings are in force cannot happen once the table does not exist and the binding is visible in the position's own definition. And an app that names a position differently from its permission set no longer silently gets no binding: the binding is written, not inferred.
Execution (v18 line, ⛔ blocked on #15193 until the maintainer opens it): #15196 (C2) converts every reader to the registry and adds the boot report for assignments whose name resolves nowhere; #15204 (C3) adds
PositionSchema.permissionSetstopackages/specand retires the four objects. Tracked on #15194.Closing as completed by ruling rather than by implementation — the question this card exists to answer has an authoritative answer now. ⛔ The implementation is not in this card's scope and is not to be done here.
- added a commit that references this issue
on Sep 10, 2026 - added a commit that references this issue
on Sep 17, 2026
Filed at destination by the repo:cloud execution seat (objectstack#6026, session
session_01EzWYDkwr6WEwDGhMH1Jzuq, R22) from the cloud#1628 verification run's handed-up observations. Filed unassigned and ungraded —domain:*/type are central triage's to mint (expected territory: the RBAC resolver,domain:servicesfamily).The observation (measured on cloud's walled rig at cloud pin
1a540e82, composed HotCRM artifact)sys_position_permission_setholds exactly one row per organization:everyone→member_default. No HotCRM position binds any HotCRM permission set through the junction table.sales_managerpermission set resolves onto a principal who holds thesales_managerposition: after a user→position assignment through Setup,/api/v1/security/explainshows bothpositions: [... "sales_manager" ...]andpermissionSets: ["sales_manager", ...]— with no junction row connecting them.⇒ Resolution appears to bind a position to a same-named permission set by name, and the junction table is nearly inert on this shape.
The question
Is name-based binding the declared mechanism (in which case: where is it declared, and what is
sys_position_permission_setfor — an override? an addition? dead?), or is it a fallback that the near-empty junction table is silently exercising as the primary path? Either way the current state invites a wrong read: an operator or an app author inspecting the junction table sees "no bindings" while bindings are in force, and an app that names a position differently from its permission set may get no binding at all with nothing saying so. Declared-vs-enforced discipline wants one authoritative answer.Re-check
On any walled rig with the composed HotCRM artifact: assign a user to
sales_managerthrough Setup, then compare/api/v1/security/explainfor that principal againstSELECT * FROM sys_position_permission_set. cloud'sscripts/dev-local/verify-position-surface.mjs(cloud PR #1760) automates the surrounding steps.