Skip to content

fix(core): scope the legacy platform-admin deprecation pointer to walled postures - #13719

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13667-single-posture-deprecation-pointer
Aug 31, 2026
Merged

fix(core): scope the legacy platform-admin deprecation pointer to walled postures#13719
os-steve merged 2 commits into
mainfrom
claude/issue-13667-single-posture-deprecation-pointer

Conversation

@claude

@claude claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #13667

single is the default tenancy posture (packages/types/src/env.ts,
resolveTenancyPosture() ends return resolveMultiOrgEnabled() ? 'isolated' : 'single';).
On such a rig bootstrapPlatformAdmin mints the org-less admin_full_access grant row to
promote 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 no
posture 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 second
half 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 single promotion is pinned never to consult that variable
(bootstrap-platform-admin-walled-owner.test.ts:395), so an operator who follows the
advice 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 makes
the 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 });
+    }
   }

postureEnforcesWall was already imported and already in use at :338.
resolveTenancyPosture joins the existing @objectstack/types import — 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 if body, so no arm of the
if (configConfersPlatformAdmin) / else if (hasPlatformAdminGrant) chain changes shape:
both branch conditions are byte-identical, and the arm's only statement was already a
void call that grants nothing. Nothing reads a return value; hasPlatformAdminGrant,
grants.permissions, grants.systemPermissions, grants.positions and grants.posture
are 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 once
under single and once under isolated, and the two envelopes must be deep-equal.

arm config legacy grant envelope single vs isolated
config + legacy grant yes yes identical
config only yes no identical
legacy grant only (the gated arm) no yes identical
neither no no identical

A companion control asserts the four corners are genuinely distinct (arms 1-3 resolve
PLATFORM_ADMIN, arm 4 resolves MEMBER), so the matrix above cannot pass by being four
copies 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() asks process.env, never the engine. The recorded read
    multiset ({ object, where } per find) is deep-equal across the gate's two answers.
  • The notice still costs no sys_user read of its own. With seedEmail and
    seedPermissions: ['ai_seat'] supplied — so nothing else in the resolution needs the row
    — the walled arm still emits, with zero sys_user reads, quoting the generic
    placeholder 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_user reads on the unseeded fixture
and went red: that fixture reads the row for grants.email and the ai_seat synthesis,
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.

  • Walled rigs still hear it: group and isolated, once per process, naming the
    holder and the config line, standing still PLATFORM_ADMIN.
  • single rigs do not: explicit single, and — the arm that covers most rigs in the
    field — OS_TENANCY_POSTURE and OS_MULTI_ORG_ENABLED both unset, which is what an
    unconfigured deployment looks like.
  • Control: single + a config anchor is silent for a different reason (standing no
    longer rests on the row, so the else if never runs), which keeps the silence above from
    being 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.

HEAD blob   : 239f5d205337eecda27c47e9d632b17c3e9e4511
before blob : 239f5d205337eecda27c47e9d632b17c3e9e4511   (tree is at HEAD before mutating)
marker before (gate STATEMENT) : 1   (expect 1)
MUTATION WRITTEN
after blob  : 244df60bb372516731304813a411e254088bcf95   (blob moved)
marker after (gate STATEMENT)  : 0   (expect 0)
marker after (call site)       : 1   (the call survives; only its gate is gone)
=== MUTATION CONFIRMED ON DISK ===

Tests  3 failed | 26 passed (29)
  x a `single` rig is SILENT - and keeps the identical PLATFORM_ADMIN standing
  x the DEFAULT posture is silent too - an unconfigured deployment resolves `single`
  x ...and the notice still costs no sys_user read of its own

The predicted direction, stated before the run, held exactly: the three failures are all on
the single side, and the failure text is the defect itself, reproduced verbatim —

[authz] user usr_1 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. ...

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:

git diff HEAD --name-only : (empty)
blob now                  : 239f5d205337eecda27c47e9d632b17c3e9e4511
HEAD blob                 : 239f5d205337eecda27c47e9d632b17c3e9e4511
gate statement present    : 1

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.ts pinned the exact text
import { 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 resolveTenancyPosture to the same statement reddened it without touching the
predicate, 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.

what result
@objectstack/core full suite 46 files, 1146 tests passed
targeted security suites (8 files, incl. batch-equivalence goldens) 229 passed
post-restore re-run of the three touched suites 100 passed
plugin-security boot-side control (untouched file) 2 files, 29 passed
gate families re-derived from the actual 4-path diff 36 commands, 35 green
check:type-check-debt (ratchet, needs the built closure) OK - 29 entries, 1542 raw errors, none above recorded
@objectstack/core direct tsc count 98, equal to its ledger entry; none of the 98 is in either file this PR touches
pnpm lint (repo-wide eslint . --no-inline-config) green
check:authz-resolver (the family this file's own path derives) green

node scripts/check-test-completeness.mjs exits 3 = PREREQUISITE NOT MET = NOT MEASURED
— it grades a saved turbo run test log 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/objectstack
after 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 emitted
operator-facing log behaviour. content/docs/releases/ is untouched.

Deliberately out of scope

Generated by Claude Code


Generated by Claude Code

claude added 2 commits August 31, 2026 07:08
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/environment-variables.mdx (via resolveUserAuthzGrants (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via resolveUserAuthzGrants (symbol))
  • content/docs/releases/v17.mdx (via resolveUserAuthzGrants (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4a176451ea96f01297388ae3c6fcd20d0526ce36packageMentionDocs.

Which tree this was computed on

This run read content/docs from b9897dddc73436edc5059ecaec01ff5170cbbf7b — the merge of head 82d10f1aa11615fe8ad7bb9e4751005a403b315c into base 4a176451ea96f01297388ae3c6fcd20d0526ce36, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4a176451ea96f01297388ae3c6fcd20d0526ce36 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

CI 已收敛 — 32 项检查全部完成,零失败(2 项 skipped)。Draft 已翻 ready,入队。

domain:services PM seat #6021,R9。完整验收在 #13667 的评论里,这里只记 CI 结论。

Temporal Conformance (live PG + MySQL) 在本 PR 上 绿。⇒ 连同 #13666(绿)与 #13724(绿)对 #13685(红),这是第三个数据点,确证 #13688 的诊断:那两个 sql-driver-13056 MySQL-cell 测试在 vitest 默认 5000ms 下随 runner 负载失败,与 diff 无关。⛔ 本 PR 未动用那唯一一次允许的重跑,也不需要。

合并本 PR 关闭 #13667;⛔ 不关 #13515,⛔ 不关 #11979 —— Choice 4B 的分叉未动,而本修法在该分叉的两条读法下都成立。


Generated by Claude Code

@os-steve
os-steve added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 0a8ebf3 Aug 31, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-13667-single-posture-deprecation-pointer branch August 31, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] The #11663 P5 legacy-grant deprecation pointer fires on single-posture rigs, where ruled Choice 4A says the row is NOT going away

2 participants