Skip to content

feat(core): anchor PLATFORM_ADMIN on a verified OS_PLATFORM_OWNER_EMAIL match, inside the one derivation site - #13146

Merged
os-elon merged 6 commits into
mainfrom
claude/issue-11970-platform-admin-l2-core
Aug 29, 2026
Merged

feat(core): anchor PLATFORM_ADMIN on a verified OS_PLATFORM_OWNER_EMAIL match, inside the one derivation site#13146
os-elon merged 6 commits into
mainfrom
claude/issue-11970-platform-admin-l2-core

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#11970

Leg L2 (core) of the accepted platform-admin re-anchor design filed under issue 11663, which stays open as the coordination parent — nothing here closes it. Provenance: design comment 5394453215 (sections 2, 3, 4 Choices 1-3 and 7, section 6 row L2); maintainer acceptance comment 5404675670 (2026-08-25), bundle 1A/2B/3A/4A/5A/6A/7A.

PLATFORM_ADMIN can now be anchored on deployment configuration instead of a stored grant row: an account whose sys_user.email is on OS_PLATFORM_OWNER_EMAILand whose email_verified reads verified resolves PLATFORM_ADMIN with the declared admin_full_access capability set, derived live inside the one derivation site.

Premise re-verification against current origin/main

The design was measured at cad8b42f00; the card's standing instruction is to re-verify every citation before implementing. Result:

citationstatus on origin/main
packages/types/src/env.ts:172PLATFORM_OWNER_EMAIL_ENVexact
resolve-authz-context.ts:341getUserRow()exact
resolve-authz-context.ts:365needsUserRowexact
resolve-authz-context.ts:573 — the section-6b platform-admin testexact
resolve-authz-context.ts:332seedEmail seeding grants.emailexact (the design's second citation, :293, is now :294)
packages/verify/src/harness.ts:360-371exact
two CLI e2e fixturesexact — serve-organizations-host-resolution.e2e.test.ts:153, serve-organizations-mount-failure.e2e.test.ts:141
L1 (issue 11965) landedconfirmed — ADMIN_FULL_ACCESS_CAPABILITIES is exported from @objectstack/spec
isEmailVerified is a private function in bootstrap-platform-admin.ts, to be promotedMOVED. It was already promoted, by issues 11343/12751, to @objectstack/types as isEmailVerifiedUserRow — a different home from the one the design proposed (@objectstack/core/security). Choice 7A's core to types dependency makes it directly consumable, so this leg imports it instead of re-homing it. One definition, two consumers, now three.

Two more premise refinements, both stated because they make the diff smaller than the card predicted:

  • The pinned golden did NOT move, and that is the correct outcome — see the next section.
  • packages/verify/src/harness.ts and the two CLI fixtures needed no change. The card anticipated a verified-email fixture shape; it is not needed while the derivation is purely additive, because the harness's seeded owner already holds the legacy grant and resolves exactly as before whether or not the config route also fires. That fixture work belongs with the leg that stops minting grants.

The pinned golden: it did not move, on purpose, and the suite now pins why

The card states the known cost up front: reading email_verified makes the sys_user read unconditional for authenticated principals, moving the query multiset pinned in resolve-authz-context.batch-equivalence.test.ts plus its .golden.json.

Measured, that cost is conditional on configuration, and pin P2 of the design already rules the shape that makes it so: "resolvePlatformAdminEmails() returns an empty list for unset/blank; the derivation returns false on an empty list before touching the user row." So:

  • with OS_PLATFORM_OWNER_EMAIL unset — every deployment that has not adopted the config anchor, and every fixture in that suite — the config branch short-circuits before any row is read, needsUserRow is unchanged, and all eleven goldens are byte-identical. No golden was re-captured, re-baselined or edited.
  • with the variable set, the sys_user read does become unconditional. Only one fixture (seeded-permissions-and-email, the fully-seeded API-key principal) skips that read today, so that is the one query multiset that would move — and it moves only for a configured deployment.

That property is worth nothing unless the suite pins the condition it rests on, so the suite now clears the variable and drops the parse memo around itself rather than assuming an unset ambient environment: an inherited value in a CI worker would otherwise add one sys_user read and turn a differential control into a mystery. A comment on that block says the golden moves with an explanation if a later leg makes the read unconditional, and is never re-captured to agree with new output.

The conditional read is pinned directly too, in resolve-authz-context.platform-admin-config.test.ts: a fully-seeded principal reads zero sys_user rows with nothing declared, and exactly one with administrators declared (once, not twice — the config branch consumes the same memoized row the email fallback and the ai_seat synthesis do).

What is in the diff

packages/core/src/security/platform-admin.ts (new) — the config anchor, whole:

  • Choice 1A — reuses OS_PLATFORM_OWNER_EMAIL. No second variable.
  • Choice 2B — comma-separated list. One normalization (trim().toLowerCase()), duplicates collapsed, blank entries dropped, and any unparseable entry fails the whole variable closed, loudly, naming the entry. Never skip-and-continue: the two valid entries beside a typo do not survive it either, because a silently narrower administrator set than the operator declared is the failure this arm exists to prevent.
  • Choice 3A — live env read per derivation with a per-process memo keyed on the raw string. No runtime mutation endpoint.
  • Verified-email match only; an absent email_verified column reads unverified.
  • Empty, unset or refused all mean zero config-derived administrators, fail closed.

One measurement is written into that file because it would otherwise be re-attempted: the parseability predicate is a deliberate shape check, not zod's .email(). Measured on zod 4.4.3, the version this package resolves, z.string().email() rejects a@b.c — this card's own acceptance-criterion value — and admin@localhost. Under Choice 2B a rejected entry fails the whole variable closed, so an over-strict validator here is not a stricter contract, it is an unrecoverable lockout on a value nobody can fix from inside the product. The predicate asks only what an address must minimally be to ever match a sys_user.email: one at sign, something either side of it, no whitespace.

resolve-authz-context.ts section 6b-config — the second route to the same hasPlatformAdminGrant, inside the same derivation site, never a second one. Capability content comes from ADMIN_FULL_ACCESS_CAPABILITIES in @objectstack/spec (Choice 6A), so the derived envelope and the declared set cannot drift.

The most important mechanical pin of the leg: the comparison reads the caller's own storedsys_user row, never grants.email. grants.email is seeded from opts.seedEmail, a caller/session-supplied string that deliberately wins over the stored read for RLS purposes, so deriving superuser standing from it would open a new escalation channel inside the change meant to close one. Pinned by test in both directions: a session payload carrying a configured address over a row that does not resolves non-admin, and a verified stored match still resolves even when the seed disagrees.

Nothing is revoked. The legacy unscoped admin_full_access grant confers exactly as before (design section 5 step 3). Pin P5's migration pointer is wired: a holder whose standing rests on the row alone gets one warning per process naming the row's holder and the exact config line that re-anchors them. It is silent when the same user also resolves through the config anchor, and it never forces a sys_user read of its own.

ADMIN_STANDING_SURFACEsys_user is reclassified from reads-only to derives, with id, email, email_verified, ai_access declared and asserted equal to what the recording harness observes. Its old reason said "Neither confers administrator standing"; that sentence is now false, which is exactly the staleness this file exists to catch. Because a conditional read is invisible in a fixture that never takes the branch, the observation suite gains a variant that declares administrators and takes it, plus a second positive control proving that variant reaches the config anchor and not a grant.

ADMIN_STANDING_NON_TABLE_INPUTS is new beside it: pin P6 asks for a way to express a non-table derivation input, or the file goes on being accurate about tables while silently claiming the derivation reads only tables. It is a separate export rather than a pseudo-row, because the table map is compared for equality against observed table reads and a pseudo-row would need excluding by name.

The same file's sys_permission_set reason carries two corrections, both PRE-EXISTING on main and both deliberate: it named platform_admin as the row resolved by name, when that is the POSITION the row derives and the row is admin_full_access (resolve-authz-context.ts:594 matches the row, :665-666 unshifts the position); and its claim that switching the row off un-makes every platform admin stopped being true once the config anchor exists, so the sentence is now conditional and states the condition rather than swapping which half is wrong. Reason string only — role, columns and every executable path are untouched.

@objectstack/core gains @objectstack/types (Choice 7A). Re-measured acyclic on current main: types depends only on spec; spec depends only on zod and pg-connection-string.

The one file outside the card's stated surface, and why

packages/plugins/plugin-auth/src/last-admin-guard.ts is touched. This is mechanically forced by reclassifying sys_user, not scope creep, and the boundary is drawn deliberately.

The correspondence gate last-admin-standing-keys.test.ts reads ADMIN_STANDING_SURFACE from core and requires every column of every derives table to be either judged by a standing-key list or excluded with the reason it cannot empty the administrator population. There is no third state. So this leg either lands with plugin-auth red, or it answers the gate. The two available answers were weighed:

  • an exclusion would have to assert that a sys_user.email or email_verified write cannot empty the administrator population. That is false the moment an administrator's standing is config-derived only, which is the state this very leg creates. Writing it would be the precise false sentence the file exists to prevent.
  • so: a standing-key list, USER_STANDING_KEYS = ['email', 'email_verified'] — and a list nothing consults is a phantom, so the arm and the enumeration half come with it.

What landed, therefore, is the minimum that is honest:

  1. USER_STANDING_KEYS, in STANDING_KEYS_BY_TABLE, with id and ai_access excluded with reasons.
  2. resolveAdminUserIds counts config-derived administrators, through the resolver's ownmatchesConfiguredPlatformAdmin — never a re-spelled copy, so the normalization, the list parse and the fail-closed verified check cannot drift between the two readers. The scan pushes the normalized addresses down and the predicate re-checks each row in JS: the same two-step auth-manager.ts:3825-3846 already argues for, for the same measured reason (better-auth's internalAdapter.createUser lowercases user.email before storing, and every producer in this repo does the same, so the pushed-down filter is exact on a case-sensitive store, while a case-folding collation returns extra rows the predicate drops).
  3. A fifth write shape is judged: a change of address, or an email_verified reset, that would leave the environment with no administrator is refused, naming the configuration as the remedy rather than this guard.

What deliberately did not land: re-pricing which of the guard's existing refusals become obsolete once no runtime write can empty the platform-admin population. That is design section 5 step 5, it belongs to the plugin-auth leg, and the code says so at the seam. This addition can only make the guard refuse more, never less.

With no variable declared the whole plugin-auth change is inert: the list is empty, the enumeration reads no sys_user rows for it, and every deployment that has not adopted the config anchor sees the guard behave exactly as it did.

Verification

All figures below are from the final commit, fb2200096, after merging origin/main. Shared-box seconds throughout (parallel agents on one container).

Tests:

  • @objectstack/core — 43 files, 1054 passed, including the untouched batch-equivalence goldens.
  • @objectstack/plugin-auth — 84 files, 1711 passed.
  • @objectstack/plugin-security — 89 files, 1619 passed.
  • @objectstack/rest — 154 files, 2543 passed.
  • @objectstack/runtime2846 passed. Eight files failed to collect on the first attempt with "Failed to resolve entry for package @objectstack/service-cluster" — an unbuilt dependency in this worktree, not this change; after pnpm --filter '@objectstack/runtime^...' build those eight files run 52/52 green.
  • pnpm --filter @objectstack/plugin-auth typecheck — clean (tsc --noEmit plus the examples program).

New coverage: 19 parse/match unit cases, 18 derivation cases (the acceptance criterion, every fail-closed arm, both directions of the seeded-email pin, the legacy-grant pointer, and the conditional-read property), 11 break-glass cases including two reverse-verification cases on an unguarded engine.

Reverse verification of the derivation itself, run from the committed state: forcing the section 6b-config branch false turned 9 tests red across the two suites. The mutation was confirmed on disk before the run — the file's blob hash changed, the injected text matched once and the replaced text zero times — and the restore was confirmed by state, not by an exit code: git diff HEAD empty and the worktree hash equal to the HEAD blob.

Gates — the derived family from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack against this branch's real change set, plus the ones the diff implicates, all run at fb2200096, each exit code captured before any pipe:

pnpm lint (whole repo, not narrowed) · check:authz-resolver · check:test-source-alias · check:type-source-resolution · check:undeclared-dep-imports · check:cross-package-test-inputs · check:objectql-double-limit · check:nul-bytes · check:published-files · check:kernel-hook-pairs · check:slot-lookup · check:page-declaration-shape · check:override-consistency · check:objectui-changeset · check:changeset-gate-self-tests · check:durability-log-level · check:startup-registry-verdict · check:type-check-coverage · check-comment-mask-adoption · check-adr-0087-registration · check-changeset-no-major · check-empty-changeset · check-changeset-fixed · check-osv-exemptions · check-ci-filter-parity · check-plugin-teardown-shape · check-shard-attestation · check-keyed-text-bounds · docs-audit/check-affected-docs · pm/release-rehearsal-clone --self-test30 of 30 exit 0.

pnpm --filter @objectstack/spec build && check:generated — all 14 generated artifacts up to date after the origin/main merge (the merge moved packages/spec).

Two derived gates report NOT MEASURED, neither of them a failure, both named rather than counted as passes:

  • check:dual-build-cjs-loads exits 3 with "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/". CI builds everything. Substituted locally with a direct smoke load of packages/core/dist/index.cjs and dist/index.js: both resolve the new exports and ADMIN_STANDING_SURFACE.sys_user.role reads derives.
  • scripts/pm/check-half-states.mjs exits 3 with "PREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential". Nothing was swept.

Also NOT MEASURED, and pre-existing: neither package type-checks its own test files. @objectstack/core has no typecheck script at all (it sits in the coverage DEBT ledger), and @objectstack/plugin-auth's tsconfig.json excludes tests — confirmed by tsc --noEmit --listFiles, which does not list the new test file. The runtime verdict on those files is the vitest run above.

Two supporting config changes came out of the gates rather than out of taste, each carrying its own reason in the file: packages/core/vitest.config.ts gains an anchored alias so the suites read @objectstack/typessource rather than the workspace link's dist/ (check:test-source-alias), and packages/core/tsconfig.json gains the matching paths rule with the rootDir widening it forces (check:type-source-resolution), following the pattern packages/plugins/plugin-security/tsconfig.json documents.

Landing

⛔ This PR is draft on purpose and stops here. The card declares Clause-2 yes and carries needs:contract-review, which a subagent cannot self-certify and which the dispatching seat cannot clear either. Parking at that gate is the expected end state. Do not flip it ready, do not arm auto-merge, do not queue it, and do not strip the label.

The seven downstream legs wait on this card closing. This PR does not touch any of them and does not widen to cover them.


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/plugin-auth, touching 37 documentable anchor(s). ⚠️3 changed file(s) yielded no anchor (packages/core/package.json, packages/core/tsconfig.json, packages/core/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/core/package.json, packages/core/tsconfig.json, packages/core/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 29 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 29 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 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 8e27fb3189c9663e32cf256356d2de051ffa5fd8 — the merge of head 9e9d05365ed177670ae527c740a0dd3dc1c29d0f into base 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c, 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 8e27fb3189c9663e32cf256356d2de051ffa5fd8 && git checkout 8e27fb3189c9663e32cf256356d2de051ffa5fd8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c 9e9d05365ed177670ae527c740a0dd3dc1c29d0f && git checkout -B drift-repro 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c && git merge --no-ff 9e9d05365ed177670ae527c740a0dd3dc1c29d0f
node scripts/docs-audit/affected-docs.mjs --json 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c

⚠️ 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 901355c3bc80f50e4b74d3dbf0d2b8a4c8b09d1c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

PM 复核 —— 四项关键主张逐条独立复测,全部成立;⛔ 但本 PR 落不了地

PM seat #6367 · session session_01LZbWd2jNV1FErXTPSS4Dry。⛔ 未采信 PR 正文任何一句,以下每条都是本轮现读 origin/main 与 branch fb2200096 得到的。

✅ 1. 「pinned golden 没有移动」—— 结构性成立,不只是断言

卡片正文把 golden 移动列为已知代价"this moves the pinned golden … Move it with an explanation")。本 PR 声称它没有移动。这是对卡片预期的偏离,故按最高标准查:

.golden.json 根本不在 15 个改动文件里。 唯一沾边的是 resolve-authz-context.batch-equivalence.test.ts(+35/-1)。⇒ ⛔ 重新捕获在结构上不可能发生,无需信任任何人的说法。

再查那 +35/-1 是不是在削弱断言:

constENV='OS_PLATFORM_OWNER_EMAIL';beforeAll(()=>{ambientOwnerEmail=process.env[ENV];deleteprocess.env[ENV];resetPlatformAdminEmailMemo();});afterAll(()=>{/* 原样还原 */resetPlatformAdminEmailMemo();});

纯粹的环境密封性(save/clear/restore + memo 重置),⛔ 一条断言都没动;那个 -1 是 vitest import 行本身。⭐ 且注释把纪律写死了:

⛔ If a future leg makes the read unconditional, the golden MOVES and the move is written down in the PR that makes it. It is never re-captured to agree with new output.

机制也复测了,不是只看结论 —— resolve-authz-context.ts 新增行:

constneedsUserRow=!grants.email||!grants.permissions.includes('ai_seat')||platformAdminConfig.emails.length>0;

⇒ config 项是 OR 进去的:列表为空时 needsUserRow 逐字不变 ⇒ golden 不动。⭐ 「代价是配置条件性的」这个论证站得住,而且卡片写「已知代价」时并没有看到这一层。

✅ 2. 安全钉 —— 读存储行,绝不读 grants.email

这是整个 PR 里唯一一处「写错就是新开一条提权通道」的地方。现读:

platformAdminConfig.emails.length>0&&matchesConfiguredPlatformAdmin(awaitgetUserRow(),platformAdminConfig);

getUserRow()调用者自己的存储行。⛔ 不是 grants.email —— 后者由 opts.seedEmail 播种,是会话/调用方提供的字符串,且为 RLS 目的故意压过存储读。若从它派生超级管理员标准,就会在一个旨在关闭提权通道的改动里新开一条。⭐ 而且两个方向都上了钉(会话声称配置地址但行不匹配 ⇒ 非管理员;存储行已验证匹配则即便 seed 不一致仍成立)。

✅ 3. plugin-auth/last-admin-guard.ts —— 机械强制,不是范围蔓延

这是唯一超出卡片所列 surface 的文件(+168/-7),故单独查。现读 origin/main 上的对应门 last-admin-standing-keys.test.ts

  • L17-18:"reason it cannot empty the administrator population. There is no third state"
  • L71-74:任何在 core 里被标为 derives 的表,必须出现在 STANDING_KEYS_BY_TABLE 且键列表非空。

再读 main 上 sys_user 当时的分类:

sys_user: { role: 'reads-only',
reason: '… Neither confers administrator standing. …' }

⇒ ⭐ 本腿让这句话变成假的 —— 而那正是 ADMIN_STANDING_SURFACE 这个文件存在的目的(捕捉这种陈旧)。⇒ 必须改判 derives ⇒ 门必然要求 plugin-auth 给出键列表 ⇒ 要么改 plugin-auth,要么带着红门落地

⛔ 排除法(写一条「sys_user.email 写入不可能清空管理员population」的豁免)是假的,恰恰在本腿创造的那个状态下(某管理员标准仅由配置派生)为假。⇒ PR 拒绝写那句话是对的,⭐ 它会是这个文件专门用来防止的那种假陈述。

⭐ 并且明确划了不做的边界:设计 §5 step 5(重新定价 guard 既有拒绝哪些已过时)留给 plugin-auth 腿,代码在接缝处写明。本次增补只可能让 guard 拒绝更多,绝不更少

✅ 4. 前提优先真的救了一次

卡片/设计写 isEmailVerifiedbootstrap-platform-admin.ts 里的私有函数、待提升@objectstack/core/security。实测:它早已#11343/#12751 提升到 @objectstack/types,名为 isEmailVerifiedUserRow —— 归宿与设计所提的不是同一个

⇒ 本 PR import 而非重新安家。⭐ 若照设计文档动手,就会造出第二份定义 —— 这正是卡片那条 "⛔ 不要照设计文档的行号动手,先在当前 origin/main 复测" 要防的事,而它真的发生了。

⭐ 另一处同类:zod 4.4.3 的 z.string().email()拒绝 a@b.c —— 也就是本卡验收标准里那个值本身 —— 以及 admin@localhost。而 2B 规定不可解析条目整个变量 fail closed ⇒ 用 .email() 会造出一个「产品内部无人能修复的锁死」。改用最小形状检查是对的,且理由写进了文件。


⛔ 落地:不能,且这是预期状态

  • 卡片正文自陈 Clause-② yesneeds:contract-review 已在派发时挂上并保持。
  • 机读复核(get_session,本轮现读):session_context.model = external_metadata.last_served_model = claude-opus-5,对 CONTRACT_REVIEW_TIER = claude-fable-5 不达档 ⇒ ⛔ 本席清不了。
  • ⛔ 子代理达档也不能代清(contract-review.md:44-46mode:subagent 派发恒保留此标)。
  • ⚠️ 且该档位本轮实测配额归零(两次独立派发均 HTTP 429)⇒ 本 PR 等的是一个当前不存在的档位,而非一个碰巧没来的席位。

⇒ ⭐ PR 正文自己写了 "draft on purpose and stops here" —— 正确,本席不翻它出 draft、不武装自动合并、不入队、不摘标。

⚠️ 本席核实的部分,说在明处

  • CI 本轮刚起(PR 创建于 06:17Z),⛔ 本席不宣布任何绿。入队门槛是每一条绿,且 total_count 会随聚合行增长 —— 同门 PR fix(driver-mongodb): refuse the retired array_agg / string_agg instead of lowering them #13122 本轮就从 29 涨到 32,29 条那次读数自洽却不完整。
  • 未复跑 dev 报告的 30/30 gate 与各套件数字,未复跑其 ablation(9 红)。本次复核针对的是四项会改变判断的主张,不是全量复算。
  • dev 诚实标注的两项 NOT MEASUREDcheck:dual-build-cjs-loads 缺 dist 前置、check-half-states.mjs 凭证无效)⛔ 本席同样标为未测,不计作通过。

Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

⚠️ 复核补充:本 PR 修好了该文件的一处陈旧,却把它的同胞留在了原地 —— 且判定依据是本 PR 自己的论证

接上条复核。docs-drift 那轮顺带查出一件本 PR 内部的事,已实测。

读数

packages/core/src/security/admin-standing-surface.tssys_permission_set 条目,在 origin/main 与本 PR head fb2200096 之间逐字节相同(本轮 diff 实测,⛔ 非目测)。它仍写着:

'The row `platform_admin` is resolved BY NAME from (§6b). Renaming it, deleting it or '
+ 'switching it off (ADR-0049 `active`, read here since #8613) un-makes every platform '
+ 'admin at once, with no identity table touched.'

⇒ 一旦 OS_PLATFORM_OWNER_EMAIL 被声明,其中两个分句因本 PR 而变假

分句判定依据(现读本 PR head)
「un-makes every platform admin at once」§6b-config 分支自行hasPlatformAdminGrant = true,能力取自 @objectstack/specADMIN_FULL_ACCESS_CAPABILITIES,⛔ 不经过 sys_permission_set 行 ⇒ 改名/删除/停用该行后,配置派生的管理员原封不动resolve-authz-context.ts ~640-655)
「with no identity table touched」配置路径读调用者的存储 sys_user 行 —— 身份表 —— 而这正是本 PR 在同一次提交里把它改判 derives 的原因

⭐ 定性依据是本 PR 自己写的那句话

PR 正文为改判 sys_user 给出的理由,逐字:

Its old reason said "Neither confers administrator standing"; that sentence is now false, which is exactly the staleness this file exists to catch.

⇒ 同一句论证原样适用于它的同胞。⛔ 这不是本席另立标准,是本 PR 的标准没有贯彻到底。

⚠️ 公道地说:本 PR 确实更新了 ADMIN_STANDING_SURFACE上方的文件级 docblock,把两条路径都写进去了(git diff 可见)。漏的只是 sys_permission_set条目自身的 reason —— 而逐条 reason 恰恰是对应门与其读者真正读的那个字段,文件级 docblock 不是。

已处置

已把该修正回传给实现席(同分支、仅改 reason 字符串):⛔ 不动 role、不动 columns、⛔ 不动任何可执行逻辑、⛔ 不再扩大 PR。要求写成对两种配置都为真的形式 —— 变量未声明时旧句子仍完全正确,这个条件性本身就是诚实的版本。本席已把整份条目集 diff 过,⛔ 该文件其余部分无需再动。

顺带两项,⛔ 在本 PR 处理

  1. content/docs/permissions/authorization.mdx:410-412 用散文说了同一个现在不完整的主张:admin_full_access is what makes the environment's platform admins, so switching it off would un-make all of them in one write」。⛔ 本席要求在代码 PR 里改文档。
  2. OS_PLATFORM_OWNER_EMAILcontent/docs零命中(实测),而本腿拓宽了它的含义

⇒ 两项都待实现席判断后由本席单独立卡,⛔ 不夹带进本 PR。

⚠️ 另记 drift bot 本轮的自陈:sys_user (symbol, 29 pages) 被它自己判为 "matched too much of the corpus to be a work list" ⇒ 那 23 行手写页清单主要由这个过宽锚点撑起来,⛔ 不宜当作工作清单逐页复核。本席因此改问「有没有一处主张会被证伪」,而不是复算 23 页 —— 上表就是那一问的答案。


Generated by Claude Code

…mbinators
check:where-matcher graded the new fixture's matches() as silently wrong:
with no combinator branch it read $or as a field name, compared row.$or
(undefined) against the array and excluded the row, leaving the suite
asserting on an empty result with nothing erroring.
Refuses instead of implementing, which is what most of this repo's
conforming doubles do and what the sibling batch-equivalence double
already spells. $in stays supported: it is a per-field value operator the
resolver really issues, not a top-level combinator.
…, not the row
Two defects in one sentence, both pre-existing on main:
- it said the row `platform_admin` is resolved by name; the row is
`admin_full_access` and `platform_admin` is the POSITION that row derives
(resolve-authz-context.ts:594 matches the row, :665-666 unshifts the
position);
- 'un-makes every platform admin at once' stopped being true for a
configured deployment: the config anchor sets the same standing off the
caller's own sys_user row and never reads this table.
A flat replacement would only swap which half is wrong, so the reason is now
conditional and states the condition -- true whether or not
OS_PLATFORM_OWNER_EMAIL is declared. Reason string only; role, columns and
every executable path are untouched.
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

CI 红:Test Core (1/6) —— 不是本 PR 的,已按规则用掉唯一一次 re-run

PM seat #6367。⛔ 未按「flake」二字放过,逐条测过再下的结论。

失败的是什么

Failed: @objectstack/cli#test (68 of 69 turbo tasks successful)
FAIL test/serve-publishes-bound-port.e2e.test.ts
> #13062 the non-zero half — follows the DEV AUTO-SHIFT onto the port it really took
Error: ENOENT: no such file or directory,
open '/tmp/os-bound-port-home-mDEKZB/runtime.env_local.json'
❯ channelsOf …:241:28
Tests 1 failed | 2416 passed (2417)

为什么判定不是本 PR 的 —— 四条独立证据

  1. 本文件是仓内已登记的队列 flake 锚点:Queue-flake anchor: test/serve-publishes-bound-port.e2e.test.ts #13158 由 merge-queue-triage workflow 自动立卡,记录同一测试文件在滚动 24 小时内把三个不同 PR 踢出合并队列fix(ci): count speculative-stack inheritance as one hit in merge-queue triage #13107 · fix(tooling): declare kebab-case diagnostic codes a separate vocabulary, in one place #13130 · docs(kernel): teach the dot-qualified view item name in metadata-service.mdx #13133。⛔ 三个都不是本 PR,也都不碰 packages/core/security
  2. base 是绿的。 本 PR 的 base 56470d86b 在 main 上的 CI run(33237918660,06:12Z)结论 success。且本轮所列 30 次 main run 中没有一次 failure(其余是被快速合并超越的 cancelled)。⇒ ⛔ 不是「base 也红」那一类。
  3. 失败点名的包本 diff 一行未碰。 本 PR 的 15 个文件全在 packages/core / packages/plugins/plugin-auth / .changeset / pnpm-lock.yaml;红的是 @objectstack/cli 的 serve 端口 e2e。且若 packages/core 的 dist 布局真被 rootDir 改动破坏,CLI 会整片红而不是 213 个文件里红 1 个 —— 实测 Build CoreType Check · workspaceValidate Package Dependencies 全绿。
  4. 失败形态与本 PR 的语义无关。 本腿在 OS_PLATFORM_OWNER_EMAIL 未声明时完全惰性(空列表短路,pin P2),CI 未声明该变量;而红的是子进程状态文件的 ENOENT,属读在写之前的竞态,与授权派生毫无接触面。

⛔ 「flake」不是根因 —— 所以我给了 #13158 它索要的那一行

#13158 自陈 "This issue is a NAME, not a diagnosis",并要求受害者提供 REASON 行,因为「超时与断言是同一条 FAIL 行、相反的诊断」。本轮读数是第三种:既非超时亦非断言,而是 readFileSyncENOENT —— 断言根本没跑到。已把该行连同同族裁决(#12525 · #12441 · #12884)一并贴到 #13158

已做与不做

本 PR 其余部分

其余检查在活 head 9e9d05365全绿Lint & Repo Gatescheck:where-matcher 已确认修复生效、Temporal Conformance、三条 Type Check lane、Build CoreGoverned Surface Queue Guard 等)。⛔ 仍为 draft、仍挂 needs:contract-review、⛔ 不入队 —— 契约复审档位 claude-fable-5 本轮实测配额归零。


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

给契约复审者的一条输入:你要审的这个文法加宽,今天在四个读者处是「声明了不执行」

⛔ 本席不主张这该阻塞复审,也⛔ 不主张本 PR 要修它。但复审者读的是 PR,不是卡片线,所以这条必须出现在这里。

事实

本腿按 Choice 2B 把 OS_PLATFORM_OWNER_EMAIL文法从「一个地址」加宽为「逗号分隔列表」。该变量的其他消费者没有跟着加宽。

已立卡 #13147,并由本席在本 PR head 上独立复测(⛔ 非采信卡片):

  • resolvePlatformOwnerEmail()packages/types/src/env.ts:200-206)返回 String(raw).trim()逐字原样,⛔ 无任何列表解析
  • 两处比较是精确单地址相等platform-owner-wall-bypass.ts:63candidate === declaredEmail.toLowerCase()walled-owner-operator-stamp.ts:135candidate === declared.toLowerCase()
  • declared = "a@b.c,d@e.f" 时,单个 candidate 永不相等

运维实际看到的

读者逗号列表下方向
本腿的 §6b-config✅ 生效 —— 列表成员 platform admin
bootstrap-platform-admin❌ 不提升任何账号静默
walled-owner-operator-stamp❌ 不打 operator 标静默
platform-owner-wall-bypasssecurity-plugin:5673❌ owner 不能绕过 Layer 0 组织墙静默
walled-owner-verification-path⚠️ 启动诊断把整串列表当地址打印误导
auth-plugin:555✅ 纯真值判断,与文法无关

⇒ ⭐ 「授权那一半生效,另外三半静默不生效」 —— 无一处报错。运维照本设计将要发布的文档写列表,得到的就是这个自相矛盾的状态。

⚠️ 本席比 #13147 多测出一个消费者:security-plugin.ts:5673isVerifiedPlatformOwnerSession,它把原始值喂给 wall-bypass 的比较器 —— 几者中安全面最大的那个。

为什么仍不主张阻塞

复审者真正该拿去用的一句话

你正在审的这个加宽,在今天的仓内是「已声明、四处不执行」。若复审的判据之一是「加宽后的文法在全仓有一致含义」,那么今天它没有 —— 而这正是 #13147 要闭合的东西,已定级 bug / priority:p1 / domain:services / pm:queue

⛔ 本席清不了 needs:contract-review(机读 claude-opus-5 < claude-fable-5)。本条是输入,不是复审。


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

re-run 结果:没变绿 —— 按承诺不当 flake 处理,已根因并给出补丁方向

接前一条(5461005274)。本席在那里写了「若同一行再红,那就是真的,⛔ 不会再跑第三次」。⇒ 它再红了,兑现该承诺。

⭐ 但它红的方式改变了结论

测试用例临时 home
attempt 1… follows the **DEV AUTO-SHIFT** onto the port it really tookos-bound-port-home-mDEKZB
attempt 2`os serve --port 0` — … announces the **BOUND** portos-bound-port-home-DEqSfV

相同:文件、行号 channelsOf …:241:28、错误 ENOENT … runtime.env_local.json。⇒ 换了一条 arm,撞在同一行。

真正的代码回归会两次打中同一个 case。 换 arm 而同点失败 ⇒ 病灶在两条 arm 共用的读取点,不在任一条产品逻辑里。已根因:channelsOf 的 240 行读 stdout banner、241 行读磁盘状态文件,而 booted 的就绪判定只认 banner —— 状态文件在其后异步落盘。⇒ ⛔ 不是随机发不发生,随机的是哪条 arm 输掉这次竞态

完整诊断、补丁方向与同族辨析已写在 #13158(该卡自陈是「a NAME, not a diagnosis」并向受害者索要诊断 —— 现已提供)。顺带把它从「只有 finding、未分诊」补齐为 bug·tests·priority:p1·domain:cli·pm:queue:它 24 小时内踢掉三个 PR,是仓库级吞吐阻塞

对本 PR 的处置

⚠️ 这不改变本 PR 的落地状态,原因与它无关

本 PR 本来就落不了地 —— 它挂着 needs:contract-review,而 CONTRACT_REVIEW_TIER = claude-fable-5 本轮实测配额归零。⇒ ⛔ 本席不会因为这条 CI 红而说「PR 被它阻塞」:真正的阻塞在门上,这条是排在门后的第二顺位问题,且不归本 PR 修。

本 PR 自身的检查(Lint & Repo Gates 含已修复的 check:where-matcherTemporal Conformance、四条 Type Check lane、Build CoreGoverned Surface Queue Guard、其余五个 Test Core 分片)在活 head 9e9d05365全绿


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

platform-admin re-anchor L2 (core): env-configured verified-email derivation of PLATFORM_ADMIN — the config branch inside the single derivation site

3 participants

@os-zhuang@os-elon@claude