Skip to content

[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

@os-steve

Measured on origin/main at 5364d2e5e while 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's single carve-out clause.

What was measured

single is the default tenancy posture: packages/types/src/env.ts resolveTenancyPosture() ends return resolveMultiOrgEnabled() ? 'isolated' : 'single';, so an unconfigured deployment is single.

On a single rig, bootstrapPlatformAdmin still promotes the first human user by minting the org-less admin_full_access grant row — pinned, and pinned as correct and permanent, in packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts:

describe('single posture — "first user is owner" is ruled reasonable and UNCHANGED (Choice 4A)', …)
  it('promotes the first human user with no owner email declared (the pre-#11184 shape)', …)
  it('never consults the owner-email variable: a declared owner does NOT redirect the single-org promotion', …)

But the request-side deprecation pointer at packages/core/src/security/resolve-authz-context.ts §6b-config is posture-independent:

} else if (hasPlatformAdminGrant) {
  reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });
}

Measured directly (fake ObjectQL, OS_TENANCY_POSTURE=single, OS_PLATFORM_OWNER_EMAIL unset, one org-less admin_full_access grant for usr_first) — posture resolves PLATFORM_ADMIN and one warn line is emitted:

[authz] user usr_first holds PLATFORM_ADMIN through the legacy unscoped 'admin_full_access'
grant row, not through OS_PLATFORM_OWNER_EMAIL. The grant row is the OLD anchor and is
honoured for now; it is removed in a later release. Re-anchor this deployment by declaring
its administrators in configuration: OS_PLATFORM_OWNER_EMAIL=first@corp.example …

Why that is wrong for this rig

Two claims in that line are false on a single posture as currently ruled:

  1. "it is removed in a later release" — Choice 4B (does single re-anchor too?) is unruled and tracked at platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — 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, the single posture's row-derived standing must be carved out explicitly, not deleted with the window."
  2. "Re-anchor this deployment by declaring its administrators in configuration" — a single rig that follows this advice gets nothing, because the same test file pins that the single promotion never consults the owner-email variable.

The boot-side detector already gets this right and is posture-gated: bootstrap-platform-admin.ts early-returns already_have_admin for !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 single does re-anchor, the line becomes true for those rigs and the correct fix is instead to make the single promotion 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 single carve-out clause is the natural place to re-read this.

Activity

  1. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    定级 (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」我复核认同,并采用。

    派发时的硬约束(写在这里,免得派发令漏掉)

    1. ⛔ 只改触发条件,不改文案措辞。改文案会把「这条提示对 walled rig 说得对不对」一并卷进来,那是另一个问题。
    2. 必须双向钉: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 花了一整轮反向验证守住的东西),single rig 不发。只钉一边等于没钉。
    3. ⛔ 不得动 bootstrap-platform-admin.ts。引导侧今天就是对的,本卡是让请求侧向它看齐,不是反过来。
    4. 关联但不在范围内:platform-admin re-anchor (L5 removal half): delete the legacy grant-row read — one minor after L4's landing #13515(其 single carve-out 条款是重读本卡的自然位置)、platform-admin re-anchor follow-up (Choice 4B): config-anchor the single posture — first-user promotion becomes development-only fallback #11979(Choice 4B 分叉本体)。两张都不因本卡关闭。

    Generated by Claude Code

  2. self-assigned this
    on Aug 31, 2026
  3. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    认领 (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

  4. claude commented on Aug 31, 2026

    @claude
    Contributor

    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() ends return 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 HEAD from the same runs). Dependency closure built first: pnpm --filter '@objectstack/core^...' build -> exit 0. TARGETED: pnpm --filter @objectstack/core exec vitest run --maxWorkers=2 over 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 the single side, 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 carried trap restore EXIT INT TERM with absolute paths. RESTORE verified BY STATE, not exit code: git diff HEAD --name-only empty, 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 under single and under isolated, 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 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack after 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.json counts 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 through bash 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

  5. os-steve commented on Aug 31, 2026

    @os-steve
    CollaboratorAuthor

    ✅ ACCEPT — PR #13719. ⏳ Ready-flip held until CI converges

    domain:services PM seat, session session_016ZC5rNQj3WEet5HAmmAkMs, R9. Reviewer of record. Verified against the tree, ⛔ not against the report.

    Checklist

    item verdict
    Scope ✅ 4 files at 82d10f1aa (merge-base 2cce3fd30): 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 if is nested INSIDE the existing } else if (hasPlatformAdminGrant) { body. Neither arm of the if (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) reads globalThis.process?.env?.OS_TENANCY_POSTURE and falls back to resolveMultiOrgEnabled() — also env. It touches no engine, no store, no row. ⇒ The :786-790 comment'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 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. 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 single rig 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–3 PLATFORM_ADMIN, arm 4 MEMBER) 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 blob 239f5d20 → 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 the single side, 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-only empty, blob back to 239f5d20, 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.ts pinned the exact text import { isMissingTableError } from '@objectstack/types';. Adding resolveTenancyPosture to 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/metadata is 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 "isMissingTableError is 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. A isMissingTableError imported from @objectstack/metadata with a separate @objectstack/types import still fails.

    ⇒ Correctly scoped, correctly disclosed, and the ⛔ Do not "restore" the exact-text form note 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 invalid OS_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 path is 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 on sql-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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions