Uh oh!
There was an error while loading. Please reload this page.
fix(core): scope the legacy platform-admin deprecation pointer to walled postures - #13719
Conversation
…led postures The request-side #11663 P5 notice fired regardless of tenancy posture, so a `single` rig — the DEFAULT posture — was told once per process that its first-user-promoted `admin_full_access` grant row "is removed in a later release" and to re-anchor through `OS_PLATFORM_OWNER_EMAIL`. Both halves are false there: under the ruled Choice 4A that row is the rig's permanent anchor, and the `single` promotion is pinned never to consult that variable. Gate the notice on `postureEnforcesWall(resolveTenancyPosture())` — the same predicate, from the same module, that the boot-side detector in `bootstrap-platform-admin.ts` already reads. The condition is nested INSIDE the existing `else if (hasPlatformAdminGrant)` arm so no arm of the standing derivation changes shape. Pins both directions: walled rigs still emit, `single` rigs do not, and standing is measured identical across postures in all four arms of the derivation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…e import line
`authz-store-unavailable.test.ts` pinned the exact text
`import { isMissingTableError } from '@objectstack/types';`, which also pinned
something the #13279 ruling never decided: that this is the ONLY symbol core
takes from that module. Adding `resolveTenancyPosture` to the same statement
reddened it without touching the predicate, the classifier, or the dependency
edge the ruling is about.
The positive assertion now matches the binding list inside that statement
(`[^}]*` cannot cross a closing brace). Measured against counterexamples: it
still refuses the wrong module, a locally re-spelled predicate, and a near-miss
identifier. Both negative assertions are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9897dddc73436edc5059ecaec01ff5170cbbf7b && git checkout b9897dddc73436edc5059ecaec01ff5170cbbf7b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4a176451ea96f01297388ae3c6fcd20d0526ce36 82d10f1aa11615fe8ad7bb9e4751005a403b315c && git checkout -B drift-repro 4a176451ea96f01297388ae3c6fcd20d0526ce36 && git merge --no-ff 82d10f1aa11615fe8ad7bb9e4751005a403b315c
node scripts/docs-audit/affected-docs.mjs --json 4a176451ea96f01297388ae3c6fcd20d0526ce36
|
os-steve
commented
Aug 31, 2026
CI 已收敛 — 32 项检查全部完成,零失败(2 项 skipped)。Draft 已翻 ready,入队。
合并本 PR 关闭 #13667;⛔ 不关 #13515,⛔ 不关 #11979 —— Choice 4B 的分叉未动,而本修法在该分叉的两条读法下都成立。 Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Closes#13667
singleis the default tenancy posture (packages/types/src/env.ts,resolveTenancyPosture()endsreturn resolveMultiOrgEnabled() ? 'isolated' : 'single';).On such a rig
bootstrapPlatformAdminmints the org-lessadmin_full_accessgrant row topromote the first human user, and that promotion — row included — is pinned as correct and
permanent under ruled Choice 4A.
The request-side deprecation pointer at
resolve-authz-context.ts§6b-config carried noposture gate, so those rigs were told once per process that their anchor "is removed in a
later release" and to re-anchor through
OS_PLATFORM_OWNER_EMAIL— advice whose secondhalf is false today under every currently-live reading of the Choice 4B fork: under 4A
the row is that rig's permanent anchor, and under 4B the advice only becomes true once 4B
actually lands. The
singlepromotion is pinned never to consult that variable(
bootstrap-platform-admin-walled-owner.test.ts:395), so an operator who follows theadvice today gets nothing.
The boot-side detector already gets this right and says so in its own comment
(
bootstrap-platform-admin.ts§2,POSTURE-KEYED (#11184 ruling 2026-08-23)). This makesthe request side match it. The boot side is not touched.
The change: one condition
} else if (hasPlatformAdminGrant) { // [#11663 P5] Standing rests on the LEGACY grant row alone. ... - reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });+ if (postureEnforcesWall(resolveTenancyPosture())) {+ reportLegacyPlatformAdminGrant({ userId, email: userRow?.email });+ } }postureEnforcesWallwas already imported and already in use at:338.resolveTenancyPosturejoins the existing@objectstack/typesimport — the same module,the same function and the same requested-posture (not effective-posture) source the
boot-side detector reads, so a deployment that asked for a wall stays inside the migration
window even while running degraded.
Standing is unchanged — all four arms, measured not asserted
The condition is nested inside the existing
else ifbody, so no arm of theif (configConfersPlatformAdmin) / else if (hasPlatformAdminGrant)chain changes shape:both branch conditions are byte-identical, and the arm's only statement was already a
voidcall that grants nothing. Nothing reads a return value;hasPlatformAdminGrant,grants.permissions,grants.systemPermissions,grants.positionsandgrants.postureare untouched on every path.
That is proved rather than argued, in
resolve-authz-context.platform-admin-config.test.ts: each of the four(configConfersPlatformAdmin, hasPlatformAdminGrant)truth-table corners is resolved onceunder
singleand once underisolated, and the two envelopes must be deep-equal.singlevsisolatedA companion control asserts the four corners are genuinely distinct (arms 1-3 resolve
PLATFORM_ADMIN, arm 4 resolvesMEMBER), so the matrix above cannot pass by being fourcopies of one answer.
The "never adds a query" property, re-measured
The in-place comment claims the notice never adds a query. That claim is re-measured here
rather than cited, because this card is what put a new call into that branch:
resolveTenancyPosture()asksprocess.env, never the engine. The recorded readmultiset (
{ object, where }perfind) is deep-equal across the gate's two answers.sys_userread of its own. WithseedEmailandseedPermissions: ['ai_seat']supplied — so nothing else in the resolution needs the row— the walled arm still emits, with zero
sys_userreads, quoting the genericplaceholder instead of an address it would have had to issue a read to learn. That is
exactly what "the row is read only if it was already loaded" means, and the gate does not
move it.
resolve-authz-context.batch-equivalence.test.ts— the pinned query-multiset goldens —runs green unchanged (part of the 100/100 below).
An earlier draft of the first pin asserted zero
sys_userreads on the unseeded fixtureand went red: that fixture reads the row for
grants.emailand theai_seatsynthesis,neither of which is this branch. The pin was corrected to measure the property rather than
a coincidence.
Both directions are pinned
A one-sided pin would let a later edit switch the migration window's loudness off for
everyone and stay green — the loudness PR #13666 spent a full reverse-verification round
protecting.
groupandisolated, once per process, naming theholder and the config line, standing still
PLATFORM_ADMIN.singlerigs do not: explicitsingle, and — the arm that covers most rigs in thefield —
OS_TENANCY_POSTUREandOS_MULTI_ORG_ENABLEDboth unset, which is what anunconfigured deployment looks like.
single+ a config anchor is silent for a different reason (standing nolonger rests on the row, so the
else ifnever runs), which keeps the silence above frombeing satisfiable by a fixture that quietly stopped resolving through the legacy row.
Ablation transcript
Implementation committed first, then the one condition reverted in the working tree.
The mutated subject is imported by relative path inside its own package, so vitest
transforms the source directly — no
dist/leg, and no rebuild can mask the mutation.The predicted direction, stated before the run, held exactly: the three failures are all on
the
singleside, and the failure text is the defect itself, reproduced verbatim —Everything that must survive did survive under the ablation: the walled-side pin, the
config-anchor control, the query-multiset measurement, and the whole four-arm
standing-invariance suite. That is the other half of the control — it shows the mutation
moves the notice and nothing else.
Restore verified by state, not by exit code:
A first ablation attempt aborted at its own precondition check: 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 the run rather than measuring a tree
it had not confirmed; the marker was re-anchored to the gate statement and the run
above is the one that measured anything.
One collateral repair, disclosed
authz-store-unavailable.test.tspinned the exact textimport { isMissingTableError } from '@objectstack/types';. That also pinned something the#13279 ruling never decided — that this is the only symbol core takes from that module — so
adding
resolveTenancyPostureto the same statement reddened it without touching thepredicate, the classifier, or the dependency edge the pin is about. The positive assertion
now matches the binding list inside that statement; both negative assertions are unchanged.
Measured against counterexamples, the widened form still refuses the wrong module, a
locally re-spelled predicate, and a near-miss identifier.
Verification
All measurements below were taken on
82d10f1aa, the head of this branch.@objectstack/corefull suiteplugin-securityboot-side control (untouched file)check:type-check-debt(ratchet, needs the built closure)@objectstack/coredirect tsc countpnpm lint(repo-wideeslint . --no-inline-config)check:authz-resolver(the family this file's own path derives)node scripts/check-test-completeness.mjsexits 3 = PREREQUISITE NOT MET = NOT MEASURED— it grades a saved
turbo run testlog and the derived family names it with no argument.Recorded as not measured, which its own output instructs; it is neither a pass nor a red.
Gate families were re-derived with
node 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 set was identical before and after the fourth file joined the diff.
Release-notes input is the changeset (
patch,@objectstack/core) — this changes emittedoperator-facing log behaviour.
content/docs/releases/is untouched.Deliberately out of scope
singlecarve-out clause is thenatural place to re-read this.
singleposture — first-user promotion becomes development-only fallback #11979, the Choice 4B fork, does not close here either and is not a blocker: the adviceis false for
singletoday under both live readings, and if 4B ever lands it must returnto this site anyway, since it changes what
singlereads.Generated by Claude Code
Generated by Claude Code