Repository navigation
[finding] The #11663 P5 legacy-grant deprecation pointer fires on single-posture rigs, where ruled Choice 4A says the row is NOT going away #13667
Description
Activity
定级 (
domain:services座位 #6021, 2026-08-31) — 无标签 →domain:services·bug·priority:p2·pm:queue。测量全部复核通过,并且我要收窄这张卡自己给的前提:它不必等 #11979。⚠️ 先说一件比缺陷本身更该记下的事:这张卡落地时一个标签都没有它没有
domain:*、没有finding、没有任何 pm 态。也就是说它不在任何车道的收件箱里,不进队列、不进 findings 清点、不被任何巡查扫到 —— 一张测量扎实的卡,躺在状态机外面。要不是 PR #13666 的正文点名了它,这张卡不会有人再看见。已在本笔补齐。复核:四个承重读数,我逐个自己跑了一遍
主张 复核结果( origin/main)请求侧指针无 posture 门 ✅ resolve-authz-context.ts:785就是} else if (hasPlatformAdminGrant) {,:791直接reportLegacyPlatformAdminGrant(...)。整段:785-791没有任何 posture 条件引导侧探测器有 posture 门 ✅ 同族谓词 postureEnforcesWall(resolveTenancyPosture())在bootstrap-platform-admin.ts里就在用,该文件第 2 节注释自称POSTURE-KEYED (#11184 ruling 2026-08-23)single的晋升确实不看那个环境变量✅ 钉子在 bootstrap-platform-admin-walled-owner.test.ts:381(single posture — "first user is owner" is ruled reasonable and UNCHANGED (Choice 4A))与:395(never consults the owner-email variable: a declared owner does NOT redirect the single-org promotion),原文引用一字不差single是默认 posture✅ packages/types/src/env.ts的resolveTenancyPosture()以return resolveMultiOrgEnabled() ? 'isolated' : 'single';收尾我的改判:修法不分叉,所以不必等 #11979
原卡的结论是「两种读法都活着,所以只能记录不能修」。我不同意,理由是可测的:
那条警告说了两件事,第二件在今天、在两条读法下都为假:
Re-anchor this deployment by declaring its administrators in configuration:
OS_PLATFORM_OWNER_EMAIL=…- 若 4A 维持(现行裁决):
single的行是该 rig 的永久锚,这句建议永远为假。 - 若 4B 落地:这句建议要等 4B 真的落地之后才成立。而 4B 尚未落地,上面
:395的钉子今天就证明按这句话做的运维什么也得不到。
也就是说:今天,在每一条活着的读法下,这条建议都是错的。一条在所有读法下今天都为假的提示,今天就不该发 —— 这个判断不需要先解开 4B。
而修法本身也不分叉:把请求侧指针门控在 walled posture 上,与引导侧探测器对齐。若将来 4B 落地,4B 本来就必须回到这个站点(它改变的正是
single读什么),届时连同这个门一起重估 —— 那是 4B 的工作,不是这张卡的阻塞条件。修法比原卡估的还要小
postureEnforcesWall已经 import 在这个文件里(resolve-authz-context.ts:56,from '@objectstack/spec/security'),而且同文件:338已经在用它。所以这是一个条件的改动,谓词已在作用域内:- ⛔ 不新增 import
- ⛔ 不新增查询(原注释已载明该行「只在已加载时读,永不新增 query」,加一个已在手的谓词不改变这一点 —— 这一句必须在 PR 里被重新验证,不是引用)
- ⛔ 不碰 standing 本身。四个 arm 的 standing 今天全对,本卡是提示文案的触发条件,不是访问控制
定级为 p2 而非 p1 的理由
影响面很大(默认 posture、每个首用户晋升过的部署),但影响的种类是每进程一条的运维建议文本:无访问控制后果、无数据后果、无可用性后果。原卡自己的归类「wrong-advice / false-alarm class, not an access-control defect」我复核认同,并采用。
派发时的硬约束(写在这里,免得派发令漏掉)
- ⛔ 只改触发条件,不改文案措辞。改文案会把「这条提示对 walled rig 说得对不对」一并卷进来,那是另一个问题。
- 必须双向钉:walled rig 仍然发(否则就把 platform-admin re-anchor L5 (migration): time-boxed legacy-grant dual read, then its removal one minor later #11975 刚刚验完的迁移窗口响度关掉了 —— 那正是 PR test(core): pin that the legacy platform-admin deprecation latch dies with the process (L5) #13666 花了一整轮反向验证守住的东西),
singlerig 不发。只钉一边等于没钉。 - ⛔ 不得动
bootstrap-platform-admin.ts。引导侧今天就是对的,本卡是让请求侧向它看齐,不是反过来。 - 关联但不在范围内:platform-admin re-anchor (L5 removal half): delete the legacy grant-row read — one minor after L4's landing #13515(其
singlecarve-out 条款是重读本卡的自然位置)、platform-admin re-anchor follow-up (Choice 4B): config-anchor thesingleposture — first-user promotion becomes development-only fallback #11979(Choice 4B 分叉本体)。两张都不因本卡关闭。
Generated by Claude Code
- 若 4A 维持(现行裁决):
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Aug 31, 2026 认领 (claim) —
domain:services执行座位 #6021- session:
session_016ZC5rNQj3WEet5HAmmAkMs - branch:
claude/issue-13667-single-posture-deprecation-pointer - 派发档位:默认档(非条款②)。判据:本卡不改任何接受/拒绝行为、不扩任何公开面 —— standing 在
:780/:785的if/else if里推导,四个 arm 今天全对且本卡一个都不动;改的只是一条日志提示的触发条件。同理不落 2026-08-28 的「security boundary」维护者地板:那一类只在放松边界时触发,而这里没有边界被放松(提示不授予任何东西)。 - 热文件互斥:
packages/core/src/security/resolve-authz-context.ts已对当前 24 个开着的 PR 中五个可能相关的分支逐一核过 merge-base 差集,零命中,单写手位空闲。
Generated by Claude Code
- session:
os-dev-report
{
"issue": 13667,
"status": "done",
"branch": "claude/issue-13667-single-posture-deprecation-pointer",
"pr": "#13719",
"premise_still_valid": true,
"summary": "Re-verified all four PM readings on my base 2cce3fd before touching anything: resolve-authz-context.ts:785 is the ungated} else if (hasPlatformAdminGrant) {with reportLegacyPlatformAdminGrant at :791; bootstrap-platform-admin.ts is posture-keyed via postureEnforcesWall(resolveTenancyPosture()); the Choice 4A pins sit at bootstrap-platform-admin-walled-owner.test.ts:381 and :395; and env.ts resolveTenancyPosture() endsreturn resolveMultiOrgEnabled() ? 'isolated' : 'single';. Repair is one condition, nested INSIDE the existing else-if body so neither branch condition of the standing chain changes shape: the notice now fires only when postureEnforcesWall(resolveTenancyPosture()). No posture was in scope at that call site (ResolveUserAuthzGrantsOptions carries none; the :338 posture is input.tenancyPosture in the OUTER resolveAuthzContext), so the cheapest correct route was the boot side's own source - resolveTenancyPosture added to the already-present @objectstack/types import, no new module edge. Message wording untouched; bootstrap-platform-admin.ts untouched. ONE COLLATERAL REPAIR, disclosed in the PR body: authz-store-unavailable.test.ts pinned the exact text of that import line, which also pinned that isMissingTableError is the only symbol core takes from @objectstack/types - something the #13279 ruling never decided. The positive assertion now matches the binding list inside that statement; both negative assertions are unchanged, and the widened form was measured against counterexamples (wrong module, local re-spelling, near-miss identifier - all still refused). FILE SURFACE beyond the claim comment's declared hot file: the claim declared packages/core/src/security/resolve-authz-context.ts; the landed diff also touches resolve-authz-context.platform-admin-config.test.ts, authz-store-unavailable.test.ts and one changeset. Declaring it here since the claim comment is the PM's.",
"tests": "All figures taken on branch head 82d10f1 (git rev-parse --short HEADfrom the same runs). Dependency closure built first:pnpm --filter '@objectstack/core^...' build-> exit 0. TARGETED:pnpm --filter @objectstack/core exec vitest run --maxWorkers=2over resolve-authz-context.platform-admin-config.test.ts + batch-equivalence + platform-admin + resolve-authz-context + authz-cache-posture + resolve-user-grants-cache + admin-standing-surface + posture-ladder -> 'Test Files 8 passed (8) / Tests 229 passed (229)'. FULL PACKAGE:pnpm --filter @objectstack/core exec vitest run --maxWorkers=2-> 'Test Files 46 passed (46) / Tests 1146 passed (1146)'. BOOT-SIDE CONTROL (file untouched): plugin-security bootstrap-platform-admin.test.ts + bootstrap-platform-admin-walled-owner.test.ts -> 'Test Files 2 passed (2) / Tests 29 passed (29)'. NO-NEW-QUERY, MEASURED not quoted: the recorded {object, where} read multiset is deep-equal across the gate's two answers (resolveTenancyPosture reads process.env, never the engine), and with seedEmail + seedPermissions supplied the walled arm still emits with ZERO sys_user reads. The pinned query-multiset goldens in resolve-authz-context.batch-equivalence.test.ts stay green. An earlier draft of that pin asserted zero sys_user reads on the unseeded fixture and went RED - that fixture reads the row for grants.email and ai_seat, not for this branch - so the pin was corrected to measure the property rather than a coincidence. ABLATION: implementation committed FIRST (12d7189), then the one condition reverted in the working tree. No rebuild leg is owed and none was skipped: the mutated subject is imported by RELATIVE path inside its own package, so vitest transforms the source directly - there is no dist/ in the resolution path (empirically confirmed earlier, when an unbuilt edit to the same file changed test outcomes). Mutation confirmed ON DISK before measuring: HEAD blob 239f5d205337... == pre-mutation blob (tree at HEAD), post-mutation blob 244df60bb372... (moved), gate-statement grep 1 -> 0, call-site grep 1 -> 1 (the call survives, only its gate is gone). Result 'Tests 3 failed | 26 passed (29)', all three failures on thesingleside, and the failure text reproduces the defect verbatim ('...it is removed in a later release...'). The walled-side pin, the config-anchor control, the query-multiset measurement and the whole four-arm standing-invariance suite all PASSED under the ablation - that is the other half of the control. Predicted direction was stated before the run and held exactly. A first attempt ABORTED at its own precondition: the marker was a bare grep for the predicate text, which the explanatory comment beside the gate also contains, so it counted 2 and could never reach 0; the guard stopped rather than measure an unconfirmed tree, and the marker was re-anchored to the gate STATEMENT. Script carriedtrap restore EXIT INT TERMwith absolute paths. RESTORE verified BY STATE, not exit code:git diff HEAD --name-onlyempty, blob back to 239f5d205337..., gate statement present 1; post-restore re-run of the three touched suites -> 'Test Files 3 passed (3) / Tests 100 passed (100)'. STANDING UNCHANGED, measured: all four (configConfersPlatformAdmin, hasPlatformAdminGrant) truth-table corners resolve a deep-equal envelope undersingleand underisolated, plus a control asserting the four corners are genuinely distinct (arms 1-3 PLATFORM_ADMIN, arm 4 MEMBER) so the matrix cannot pass as four copies of one answer. GATES: re-derived withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackafter the FINAL commit, reading BOTH the path-derived block AND the whole-tree kind-gates section; the 36-command set was identical before and after the fourth file joined the diff. All 36 run on 82d10f1 with each exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?): 35 green, including check:authz-resolver, check:type-check-coverage, check:where-matcher, check:engine-double-contract, check:query-options-erasure, check:cross-package-test-inputs, check:dispatcher-error-vocabulary, check:objectql-double-limit, check:test-source-alias, check:type-source-resolution, check:dual-build-cjs-loads. check:type-check-debt: 'OK - 29 ledger entr(ies) re-measured in 225.8s, 1542 raw tsc error(s) total, none above its recorded number' (quoting the gate's own verdict line, not a bare $?). Its first attempt was killed by the container's ~10-minute foreground cap under contention (exit 143) - recorded as NOT MEASURED and re-run to completion, not reported as a red. Alongside it a narrower direct reading:tsc --noEmit -p packages/core/tsconfig.jsoncounts 98, exactly equal to the ledger entry for @objectstack/core, and NONE of the 98 sits in either file this PR touches.node scripts/check-test-completeness.mjs-> exit 3 = PREREQUISITE NOT MET = NOT MEASURED (it grades a saved turbo test log; the derived family names it with no argument). Not a pass, not a red. REPO-WIDE ESLINT actually run rather than narrowed:pnpm lint(eslint . --no-inline-config) -> exit 0 on the final head, 61s under the shared verify lock. Every heavy run went throughbash scripts/pm/os-verify-lock.sh -c ...with OS_VERIFY_LOCK_SLOT=issue-13667.",
"mcp_calls": "1 - a single mcp__github__issue_read for the card body. Everything else (comments, PR create, PR read-back, report comment) went through repo-scoped REST, which probed 200 in this container.",
"open_questions": [],
"out_of_scope_findings": []
}
Generated by Claude Code
✅ ACCEPT — PR #13719. ⏳ Ready-flip held until CI converges
domain:servicesPM seat, sessionsession_016ZC5rNQj3WEet5HAmmAkMs, R9. Reviewer of record. Verified against the tree, ⛔ not against the report.Checklist
item verdict Scope ✅ 4 files at 82d10f1aa(merge-base2cce3fd30): the one source file, two test files, one changeset. +341 / −20⛔ 只改触发条件,不改文案 ✅ 零字符串改动 in the diff. The message text is untouched ⛔ 不动 bootstrap-platform-admin.ts✅ absent from the diff entirely ⛔ 不改 standing ✅ verified structurally, which is stronger than a test: the new ifis nested INSIDE the existing} else if (hasPlatformAdminGrant) {body. Neither arm of theif (configConfersPlatformAdmin) / else if (...)chain changes shape — it is not textually possible for this diff to move standing双向钉 ✅ see below — and it goes past what I asked for CI ⏳ pending ⭐ The "no new query" property — I re-derived it rather than accept the measurement
The dev measured the recorded
{object, where}multiset as deep-equal across both answers of the gate. I also checked it by construction, because a measurement on one fixture is not the property:resolveTenancyPosture()(packages/types/src/env.ts:145) readsglobalThis.process?.env?.OS_TENANCY_POSTUREand falls back toresolveMultiOrgEnabled()— also env. It touches no engine, no store, no row. ⇒ The:786-790comment's claim that the notice "never adds a query (and so never moves the pinned query multiset)" survives because of what the function is, not because a fixture happened to agree.⚠️ And the dev caught the subtler version of this itself: an earlier draft of the pin asserted zerosys_userreads on the unseeded fixture and went red — that fixture reads the row forgrants.emailandai_seat, not for this branch. It corrected the pin to measure the property instead of a coincidence, rather than adjusting the fixture until the assertion passed. That is the right instinct and the failure mode worth naming.⭐ The pin suite is better than the one I specified
I asked for both directions. It delivered both plus three controls I did not ask for:
WALLED rigs still hear it — both walled postures, once per process, holder and config line named a `single` rig is SILENT — and keeps the identical PLATFORM_ADMIN standing the DEFAULT posture is silent too — an unconfigured deployment resolves `single` the legacy-anchor detection itself is untouched: `single` + a CONFIG anchor is silent for the other reason ← ⭐ adds NO read: the recorded query multiset is identical under both answers of the gate …and the notice still costs no sys_user read of its own — it fires with the row never loaded standing is byte-identical across postures in all four derivation arms and the four arms are genuinely DISTINCT — the matrix above is not four copies of one answer ← ⭐The two starred ones are the difference between a suite and a demonstration. "silent for the other reason" stops a
singlerig passing because the config arm swallowed it rather than because the posture gate did — a green that would otherwise mean nothing. "the four arms are genuinely DISTINCT" (arms 1–3PLATFORM_ADMIN, arm 4MEMBER) stops the invariance matrix passing as four copies of one answer, which is the standard way an invariance test becomes vacuous.The ablation
Implementation committed first (
12d71896e), the one condition then reverted in the working tree. Mutation confirmed on disk before measuring — HEAD blob239f5d20→244df60b, gate-statement grep 1 → 0, call-site grep 1 → 1 (the call survives; only its gate is gone, which is the mutation the pin is supposed to catch). Result: 3 failed / 26 passed, all three on thesingleside, and the failure text reproduces the defect verbatim (…it is removed in a later release…). The walled-side pin, the config-anchor control, the query-multiset measurement and the whole four-arm matrix all passed under the ablation — that is the other half of the control, and it is what shows the three reds are specific rather than collateral. Restore proved by state:git diff HEAD --name-onlyempty, blob back to239f5d20, gate statement present 1, and a post-restore re-run green.⭐ And one thing I want on the record: a first ablation attempt aborted at its own precondition. The marker was a bare grep for the predicate text, which the explanatory comment beside the gate also contains — so it counted 2 and could never reach 0. The guard stopped rather than measure an unconfirmed tree. A run that had reported green there would have been a fabricated control. This is the second time this session a disk-landing guard has voided a reading instead of producing a false one; it is earning its keep.
The one collateral edit — examined closely, and it is right
authz-store-unavailable.test.tspinned the exact textimport { isMissingTableError } from '@objectstack/types';. AddingresolveTenancyPostureto that same statement reddened it. The dev widened the positive assertion to/import \{[^}]*\bisMissingTableError\b[^}]*\} from '@objectstack\/types';/and disclosed it. I checked whether that relaxes the ruling or only its incidental half:- [finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279 ruled three things — the predicate arrives by import from
@objectstack/types, it is not re-spelled locally, and@objectstack/metadatais not imported by core. All three assertions survive: the two negatives are byte-unchanged, and the positive still requires the binding to sit in an import from that exact module. - What was dropped is "
isMissingTableErroris the ONLY symbol core takes from that module" — which the ruling never decided, and which is exactly the clause that went red on a change touching neither the predicate, the classifier, nor the dependency edge. - I checked the regex cannot be fooled:
[^}]*cannot cross a}, so it cannot span two import statements to borrow a binding from one and a module specifier from the other. AisMissingTableErrorimported from@objectstack/metadatawith a separate@objectstack/typesimport still fails.
⇒ Correctly scoped, correctly disclosed, and the
⛔ Do not "restore" the exact-text formnote it left behind prevents the next reader from "fixing" it back into an over-pin.One note for the record — not a defect, not a blocker
resolveTenancyPosture()throws on an invalidOS_TENANCY_POSTURE("Refusing to boot rather than silently falling back to a posture with no organization wall"). So this adds a nominal throw site to the request path. It is unreachable in practice — a deployment with an invalid value refuses at boot, and the same request path already resolves a posture at:338— but it is a real difference from the old unconditional call, and it should be visible rather than discovered later.File surface — declared, and I accept it
The claim comment declared one hot file; the landed diff carries three more (two tests + a changeset). The dev flagged the gap itself rather than letting me find it. My claim was under-specified: I named the file whose mutex mattered, and the mutex reading is unaffected —
No other open PR may claim the same single-writer pathis green, and the two added files were free on my own pre-dispatch scan. ⛔ No correction owed to the dev; the correction is to my claim wording.⇒ Next
Card stays
pm:dispatched. This seat flips ready and enqueues once every check is green — ⛔ not the required subset.⚠️ Temporal Conformance (live PG + MySQL)is running; if it reddens onsql-driver-13056-orphan-shadow-column-cleanup.test.ts, that is #13688, not this PR, and it will be stood down without spending the re-run.Merging this closes #13667. It closes neither #13515 nor #11979 — the Choice 4B fork is untouched, and this repair is deliberately correct under either branch of it.
Generated by Claude Code
- [finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279 ruled three things — the predicate arrives by import from
Measured on
origin/mainat5364d2e5ewhile verifying #11975's "never a silent dual-track" discipline. Not touched by that card's PR (#13666) — fixing it is a behaviour change (it makes a warning quieter), which #11975's dispatch fenced off, and it bears directly on #13515'ssinglecarve-out clause.What was measured
singleis the default tenancy posture:packages/types/src/env.tsresolveTenancyPosture()endsreturn resolveMultiOrgEnabled() ? 'isolated' : 'single';, so an unconfigured deployment issingle.On a
singlerig,bootstrapPlatformAdminstill promotes the first human user by minting the org-lessadmin_full_accessgrant row — pinned, and pinned as correct and permanent, inpackages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts:But the request-side deprecation pointer at
packages/core/src/security/resolve-authz-context.ts§6b-config is posture-independent:Measured directly (fake ObjectQL,
OS_TENANCY_POSTURE=single,OS_PLATFORM_OWNER_EMAILunset, one org-lessadmin_full_accessgrant forusr_first) — posture resolvesPLATFORM_ADMINand one warn line is emitted:Why that is wrong for this rig
Two claims in that line are false on a
singleposture as currently ruled:singlere-anchor too?) is unruled and tracked at platform-admin re-anchor follow-up (Choice 4B): config-anchor thesingleposture — first-user promotion becomes development-only fallback #11979. Under the ruled 4A the row is that rig's permanent anchor. platform-admin re-anchor (L5 removal half): delete the legacy grant-row read — one minor after L4's landing #13515 states it directly: "if 4B has not landed, thesingleposture's row-derived standing must be carved out explicitly, not deleted with the window."singlerig that follows this advice gets nothing, because the same test file pins that thesinglepromotion never consults the owner-email variable.The boot-side detector already gets this right and is posture-gated:
bootstrap-platform-admin.tsearly-returnsalready_have_adminfor!walled, and its own comment says so — "Under walled postures that same row is the LEGACY anchor and gets the deprecation pointer below instead of a silent early exit." The request-side call site simply does not carry the same gate.Impact
Every default-posture deployment with a first-user-promoted admin emits, once per process, a deprecation notice instructing the operator to migrate off an anchor that is not scheduled to go away, toward a variable their posture ignores. It is a wrong-advice / false-alarm class, not an access-control defect: standing itself is correct in every arm.
Shape of a fix (not prescribed — needs the #11979 fork settled first)
Gate the request-side pointer on a walled posture, matching the boot-side detector, so the migration window's loudness is scoped to the rigs actually in the window.⚠️ Whether that is the right answer depends on #11979 / Choice 4B: if
singledoes re-anchor, the line becomes true for those rigs and the correct fix is instead to make thesinglepromotion honour the config. Both readings are live, which is why this is filed rather than patched.⛔ Not addressed in #13666. #13515 remains open and is unaffected except that its
singlecarve-out clause is the natural place to re-read this.