Skip to content

[Design] Re-anchor platform-admin: admin_full_access becomes a kernel metadata declaration; WHO holds it comes from env-configured verified emails — retiring the org-less row anchor #11663

Closed
11 of 11 issues completed

Description

@os-zhuang

Chartered by maintainer ruling, 2026-08-24, live PM chat, verbatim: 「立卡,优先处理,并评估有没有相关任务要一起处理」 — with the design direction proposed by the maintainer in the same session: 「admin_full_access 能否在内核中用元数据约定,然后具体谁是超级管理员通过环境变量配置邮箱?」. Direction is ruled; this card designs the details and sequences the implementation. Design-first: no implementation dispatch until the design is accepted.

The problem (business form, from the #2734 → #10103 → #11532 lineage)

The platform's own super-admin right is anchored on a data row inside the tenant data model: an org-less admin_full_access row in sys_permission_set, referenced by row id from an unscoped sys_user_permission_set grant (resolve-authz-context.ts §6b), seeded ~1.3s before the first organization exists. It is the last structural exception to the ruled walled-tenancy doctrine ("every row belongs to exactly one organization"; #10103 Option C). Costs: single-point fragility (touch the row → platform lockout or silent re-mint), a recurring tax on every sweep/gate/diagnostic (three ruling rounds spent on this family already), and an asterisk in the security story.

Ruled direction

  1. The permission set's CONTENT is a kernel metadata declaration — code-declared, versioned, spec-validated, PR-reviewed. No runtime row holds the super-admin capability set; nothing can delete or mutate it at runtime. (Mainstream precedent: Keycloak KEYCLOAK_ADMIN, GitLab root bootstrap, Grafana GF_SECURITY_ADMIN_*, Kubernetes deployment-credential superuser — operator-level admin comes from deployment config, never from business data.)
  2. The HOLDER comes from deployment config: an env variable listing admin email(s). Platform-admin derivation becomes: caller's verified email ∈ configured list → PLATFORM_ADMIN with the declared capability set.

Five security pins the design must nail (from the chartering discussion)

  1. Verified-email match only — the env entry confers nothing until an account holding that email has passed verification; normalize (lowercase) and match exactly. Otherwise registering the configured address is a privilege escalation.
  2. Empty config = fail closed — no platform admin exists; the development-mode dev-admin seed stays development-only.
  3. Auditability — the resolved admin list is surfaced read-only (Setup / discovery / health) since there is no grant row left to query.
  4. Revocation = config change + reload — acceptable for this role; cloud's control plane injects per-deployment env, which fits.
  5. Loud migration — existing deployments carrying the row-id grant: derivation prefers env; a detected legacy grant logs a deprecation pointing at the config path; removal of the legacy path is a sequenced follow-up, never a silent dual-track.

Related tasks assessed for joint handling (maintainer asked; triage's reading)

card relation sequencing
#10348 (ruled C today, pm:queue) Consolidates the two stray plugin-auth judges onto resolve-authz-context.ts — the very function this design rewires Land #10348 first: then the derivation changes in exactly one place. The design card's implementation must not start before it.
#10949 (pm:blocked, live authz defects in those judges) May be partially absorbed by #10348-C Re-priced there (already noted on #10348).
objectstack#11211 (merged: walled owner-email elevation) + the #10103 "env-door projection" An email/env elevation mechanism already partially exists. ⚠️ Premise-first for the designer: measure the landed mechanism and unify onto it — one email door, not two parallel ones.
cloud#1509 (P0: first self-registrant becomes platform_admin, bypassing invite-only) The env-email anchor is the structural fix for this class: self-registration must never confer platform admin; only configured emails do Design must state the walled bootstrap flow end-to-end; a cloud-side leg follows the design (coordinate with the cloud seat's #1614→#1509 train — do not collide with its in-flight acceptance).
#11532 (closed today: honest diagnostic) The 8 org-less rows' retirement becomes possible once nothing references admin_full_access by row id Seeders stop minting + reap of the 8 rows is the implementation's final leg (the other 7 rows have per-org copies already; verify zero readers before reaping).

Not related despite proximity: #11579 (auth settings pre-bind), #11627 (MySQL hash keys), #10556 (sinks).

Discipline

Priority: maintainer-ordered front-of-queue (priority:p0 per the verbatim above — jump authorization recorded; ⛔ does not exempt claim protocol or serial constraints). Clause-②: yes (changes authorization derivation — the widest contract there is); contract-review tier mandatory on the implementation. Deliverable of THIS card: the design document (options where real choices remain — e.g. env spelling, multi-admin, reload semantics — with a four-axis pass), then split implementation cards per surface (packages/core derivation · kernel declaration · plugin-security seeders/reap · migration · cloud leg).

Refs: #10103 (ruling + fork analysis) · #11532 (measured constraints: row-id derivation, bootstrap-before-first-org) · #10348 (consolidation prerequisite) · cloud#1406/#1509 · #2734 · ADR-0068 · ADR-0120 D3.

Activity

  1. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Priority clarification (triage seat, maintainer instruction 2026-08-24, verbatim: 「卡片设置为优先,不是让你派发」): the priority:p0 on this card is queue ordering only — it puts this card at the head of the services lane's selection order. It does not carry the jump-the-round / immediate-dispatch semantics; no dispatch instruction accompanies it from triage (which never dispatches). The owning seat picks it up in its normal cycle, honoring the sequencing prerequisite already stated (#10348 lands first).


    Generated by Claude Code

  2. os-zhuang commented on Aug 24, 2026

    @os-zhuang
    ContributorAuthor

    Premise measurement delivered (triage seat, 2026-08-24, maintainer asked; read from origin/main)

    The "measure the landed mechanism" prerequisite in the body is now answered — OS_PLATFORM_OWNER_EMAIL exists and is the WHO half of this card, already built (#11184/#11211, maintainer ruling 2026-08-23 verbatim: 「1509 选择 env 指定 owner 邮箱」):

    • packages/types/src/env.ts:172 — PLATFORM_OWNER_EMAIL_ENV = 'OS_PLATFORM_OWNER_EMAIL' + resolvePlatformOwnerEmail().
    • packages/plugins/plugin-security/src/bootstrap-platform-admin.ts — posture-keyed elevation: single = first human user (unchanged, ruled reasonable); walled = only the account matching the declared owner email, first-registrant path removed; walled + undeclared ⇒ elevation refused loudly here and boot refused in plugin-auth init() (fail-closed, names the variable); requested-not-enforced posture is deliberately the input (degraded tenancy still fails strict).
    • Case normalization pinned ('Operator@Corp.EXAMPLE' test), blank-var-is-undeclared pinned, system-account exclusion handled.

    What is unchanged — and is exactly this card's remaining scope: the elevation still inserts a sys_user_permission_set row pointing at the org-less admin_full_access row (cross-tenant grant, organization_id = NULL), and the seeder still mints the org-less set rows. The WHO half is modern; the ANCHOR half is the legacy this card retires.

    Design consequences (narrowing the card):

    1. ⛔ Reuse OS_PLATFORM_OWNER_EMAIL — no second email door. The unification target named in the body is this variable, now confirmed.
    2. Security pins ② (fail-closed) and normalization are already built and pinned — drop from the design's new-work list; verify-only.
    3. Still to design: verified-email constraint (whether the current match requires a verified address is unmeasured — the design must measure and, if absent, add it); single owner vs additional platform admins under the new anchor (the 08-23 ruling chose a singular owner; how a second platform admin is granted once the row mechanism is gone is a real design question, not assumed); read-only audit display; loud migration of existing row-id grants; seeder de-minting + reap of the 8 rows.

    Provenance: maintainer question in live PM chat (「好像企业版已经有了环境变量 OS_PLATFORM_OWNER_EMAIL,是一件事吗」) — answer: same lineage, two halves; the WHO half landed 08-23, this card is the ANCHOR half.


    Generated by Claude Code

  3. claude commented on Aug 24, 2026

    @claude
    Contributor

    Design — re-anchoring platform-admin on a kernel declaration + env-configured verified emails

    Design-only delivery (no product code, no PR, no ADR, no implementation cards filed).
    Everything below is measured against origin/main at cad8b42f00 plus the open diff of
    PR #11639 (card #11343, head cd8a891892), read as instructed because it changes exactly the
    door this card is told to unify onto.


    0. Headline — four things measurement changed about the card's premise

    H1. The ruled direction #1 ("the permission set's CONTENT becomes a kernel metadata
    declaration") is already ~90% true at enforcement time.
    It is not a change that has to be
    built; it is a change that has to be finished.

    • packages/plugins/plugin-security/src/objects/default-permission-sets.ts:119 — every entry,
      admin_full_access included, is already PermissionSetSchema.parse({ ... }): code-declared,
      spec-validated, versioned, PR-reviewed.
    • packages/plugins/plugin-security/src/security-plugin.ts:897 — that same array is registered
      on the manifest as permissions:, i.e. it is the permission metadata namespace.
    • packages/plugins/plugin-security/src/permission-evaluator.ts:398 resolvePermissionSets
      resolves a set NAME against metadata list('permission') first, the bootstrap array
      second, and sys_permission_set rows only last.

    So for the shipped names the row is not the enforcement content today — the declaration is.
    (A corollary worth stating out loud: a Setup edit to the admin_full_access row does not
    change what admin_full_access grants, because metadata wins first. The "operators can
    customize the row" argument for keeping it is weaker than it looks.)

    What the row still supplies is narrower than the card implies, and it is all in one place:
    grants.permissions membership, system_permissions, tab_permissions, and the
    unscopedUserPsIds anchor.

    H2. Nothing in production code references admin_full_access by a hard-coded row id.
    The anchor is a composite runtime FK, not a magic id — resolve-authz-context.ts:573:

    if (ps.name === ADMIN_FULL_ACCESS && unscopedUserPsIds.has(ps.id)) hasPlatformAdminGrant = true;
    

    i.e. an unscoped, in-window sys_user_permission_set row whose permission_set_id resolves
    to an active row named admin_full_access
    . Zero-hit grep for hard-coded ps_* literals in
    non-test package sources, with a positive control over test sources that returned matches (the
    control is what makes the zero meaningful).

    H3. The card's "the other 7 rows have per-org copies already" undercounts in one direction
    and over-promises in another.

    • Undercounts: all 8 of the org-less names get per-org copies, admin_full_access included
      — the manifest registers defaultPermissionSets under permission, and
      bootstrapDeclaredPermissions materializes declared permission metadata per organization.
      What is special about the org-less admin_full_access row is not that it is the only one
      without a copy; it is that the unscoped grant points at it.
    • Over-promises: "verify zero readers before reaping" is not a formality here.
      packages/plugins/plugin-security/src/auto-org-admin-grant.ts:223
      resolvePermissionSetId resolves organization_admin name-only, limit: 1, with no
      organization scope
      , cached per-ObjectQL-instance. On a walled deployment the org-less row is
      the oldest row bearing that name, so org-admin grants can point at an org-less row by id.
      Reachable by construction; not observed on a live rig from this seat (see section 9).
      ⇒ the reap is gated on a per-name junction census and on scoping that resolver first.

    H4. The sharpest concrete breakage of "no grant row" is not in the derivation — it is in the
    Default-Organization bootstrap, and it breaks twice over.

    • packages/plugins/plugin-auth/src/ensure-default-organization.ts:110-130 finds the platform
      admin
      by reading sys_permission_set by name then sys_user_permission_set grants. With no
      grant rows it answers reason: 'no_admin' forever — silently, because that is a legitimate
      return value today.
    • packages/plugins/plugin-auth/src/auth-plugin.ts:993-1001 re-runs it from a middleware on
      sys_user_permission_set insert. With no grant insert, the trigger never fires.

    This is also a fourth, id-shaped platform-admin read that #10348 does not enumerate (its
    three are all in auth-manager.ts). It differs in kind: it asks the population question
    ("who is the platform admin?"), not the per-user predicate, so #10348-C does not absorb it.
    Recommend adding it to that sweep or filing it beside it.


    1. The one door: unify onto #11343, and move it from write-time to read-time

    Conclusion, stated plainly as the dispatch asks: yes — #11343's hardened door is the one door.
    Do not build a second mechanism.
    The env variable, the normalization, the fail-closed
    verified predicate and the walled/single split all stay. What changes is when the door is
    consulted:

    today (post-#11343) proposed
    decision time boot / replay middleware every authorization resolution
    decision effect writes a sys_user_permission_set row derives hasPlatformAdminGrant in memory
    revocation delete the row change config; next request
    who can undo it anyone who can write that row or deactivate the set nobody at runtime

    Two consequences worth naming because they argue the design simplifies rather than duplicates
    #11343:

    1. shouldReplayBootstrapFor's elevation motive dissolves. [security] 围墙提权只按未验证邮箱匹配 —— #11184 落地后仍存在一条更窄的同形路径:抢在 owner 之前用其邮箱注册即可被提权 #11343 needs an update trigger
      because elevation is a write that must be re-attempted after verification. Read-time
      derivation has nothing to re-attempt: the request after verification simply resolves as
      admin. The predicate stays only for whatever else the bootstrap still owns (default-org
      binding), which is a strictly smaller job.
    2. [security] 围墙提权只按未验证邮箱匹配 —— #11184 落地后仍存在一条更窄的同形路径:抢在 owner 之前用其邮箱注册即可被提权 #11343's isEmailVerified is promoted, not rewritten. It is currently a private function
      in bootstrap-platform-admin.ts. It moves to @objectstack/core/security beside its
      siblings isGrantActive / isRowActive, and plugin-security imports it back. That is how
      pin Add metamodel interfaces for ObjectQL/ObjectUI contract #1 gets one mechanical definition rather than two.

    ⛔ Nothing here asks for #11639 to change or be held. It lands as ruled; this design consumes it.


    2. End-to-end walled bootstrap flow (proposed)

    1. Deploy. Operator sets the admin email(s) in deployment config. Walled posture with the
      variable unset ⇒ boot refusal, already shipped and pinned
      (plugin-auth/src/auth-plugin-walled-owner-boot-refusal.test.ts).
    2. Boot. bootstrapPlatformAdmin seeds permission-set rows (for display/catalog only — see
      Choice 5) and writes no grant. It logs the resolved admin list, normalized, and for each
      entry: does an account exist, is it verified. This log is the operator's first sight of the
      answer, and it is the same answer the auditability surface serves.
    3. Owner registers (self-service or invite). email_verified is false ⇒ ordinary member.
      Nothing is elevated, so there is nothing to revoke, and no window exists in which a row
      grants standing that policy would refuse.
    4. Owner verifies (verification link, or a trusted SSO provider at insert). No replay, no
      middleware, no write. The next request resolves: the caller's own sys_user row has a
      normalized email in the configured list and email_verified reads verified ⇒
      hasPlatformAdminGrant = true ⇒ positions leads with platform_admin, posture is
      PLATFORM_ADMIN, permissions includes admin_full_access, systemPermissions come from
      the declaration.
    5. Default organization. ensureDefaultOrganization is re-pointed at the new oracle and its
      trigger moves from sys_user_permission_set insert to the [security] 围墙提权只按未验证邮箱匹配 —— #11184 落地后仍存在一条更窄的同形路径:抢在 owner 之前用其邮箱注册即可被提权 #11343 sys_user trigger set
      (insert, or update touching email / email_verified). Without this leg the walled flow
      stops at step 4 with an admin who has no organization (H4).
    6. Revocation. Config change + reload (Choice 3). Verification revoked, or the address
      changed, drops standing on the next request with no row to clean up.

    3. The five ruled pins, mechanically (plus three the measurement forces)

    P1 — verified-email match only. Mechanism, not assertion:

    • The match reads the caller's own sys_user row through the resolver's memoized
      getUserRow() (resolve-authz-context.ts:341), specifically email and email_verified.
    • ⚠️ It must not read grants.email. Measured: resolve-authz-context.ts:293 and :332 —
      opts.seedEmail, a caller/session-supplied email, is seeded into grants.email and wins
      over the sys_user read. Deriving superuser standing from a session-supplied string would be
      a new escalation channel introduced by the very change meant to close one. This is the single
      most important mechanical pin in the document.
    • isEmailVerified is one exported predicate: fail-closed allow-list (true, 1, '1',
      'true'), absent field reads unverified. Shared by core and plugin-security.
    • Normalization is trim().toLowerCase() on both sides, one function, one call site each.
    • Pins to write: a row holding a configured address with email_verified false / absent / 0
      resolves non-admin; a session payload carrying a configured address over a sys_user row
      that does not
      resolves non-admin (this is the pin that fails if someone reaches for
      grants.email).

    P2 — empty config = fail closed. resolvePlatformAdminEmails() returns an empty list for
    unset/blank; the derivation returns false on an empty list before touching the user row.
    Walled + empty ⇒ boot refusal (shipped). The dev-admin seed stays where it is —
    NODE_ENV=development plus DevPlugin, whose own header already refuses production
    (packages/plugins/plugin-dev/src/dev-plugin.ts:107,131) — and gains nothing from this card.

    P3 — auditability. This is load-bearing, not a reporting nicety. With no grant rows, the
    inverse question ("who are the platform admins?") loses its only implementation, and it has
    at least four in-repo consumers:
    ensure-default-organization.ts:110, last-admin-guard.ts:764,
    bootstrap-platform-admin.ts:276 (already_have_admin), and
    plugin-security/src/explain-engine.ts:403.
    ⇒ Propose a platformAdmin service registered beside tenancy
    (plugin-auth/src/auth-plugin.ts:584 is the precedent), exposing read-only:
    configuredEmails() (normalized; plus whether the source is set at all) and standing()
    (per configured email: does an account exist, is it verified, which user id). Surfaced through
    security/explain and a read-only Setup panel. Never writable — there is deliberately no
    runtime path that changes who is a superuser.

    P4 — revocation = config change + reload. See Choice 3.

    P5 — loud migration. Derivation prefers config. A legacy unscoped admin_full_access grant
    still confers during a time-boxed window and is logged once per process (not per request)
    naming the row, its holder and the exact config line to add. bootstrapPlatformAdmin stops
    minting new grants at the same landing — that is the fail-closed half, and it is what keeps the
    window from being a silent dual-track. Removal is a separately filed card, filed at the same
    time, not "later".

    Three more the measurement forces:

    P6 — the admin standing surface must move with the derivation.
    packages/core/src/security/admin-standing-surface.ts is a measured, test-enforced
    declaration of what the derivation reads, bound to plugin-auth's break-glass guard by
    last-admin-standing-keys.test.ts. Today sys_user is role: 'reads-only' with the reason
    "Neither confers administrator standing." After this change sys_user.email and
    sys_user.email_verified become derivation columns
    — a write flipping email_verified to
    false, or a change-email write, can remove the last administrator. That is a new
    lockout-capable write class the break-glass guard does not watch today. It must either be added
    to a standing-key list or excluded with a written reason; the correspondence test will be red
    until it is. The surface also needs a way to express a non-table input (the config), or it
    will silently claim the derivation reads only tables.

    P7 — suspended / banned accounts. Today banned is deliberately not a derivation column
    because the session layer refuses banned sessions. Under config anchoring the same argument
    holds for session traffic, but the API-key and MCP admission paths reach
    resolveUserAuthzGrants with a userId and no session. This must be measured in the core
    leg, not asserted here — the honest state is "unverified".

    P8 — security/explain must report config-derived standing. Otherwise explain and
    enforcement disagree, which is precisely the failure class the code already records
    (permission-evaluator.ts:283, sharing-service.ts:563,739).


    4. Open choices — options, four-axis passes, ranked recommendations

    The four axes: real business need · long-term soundness for this project · making
    AI-written code hard to get wrong · startup scope discipline.

    Choice 1 — env spelling

    • 1A reuse OS_PLATFORM_OWNER_EMAIL, widening its accepted value.
    • 1B introduce OS_PLATFORM_ADMIN_EMAILS, deprecating the old via
      readEnvWithDeprecation (packages/types/src/env.ts:52 is the precedent).
    • 1C keep both with different meanings (owner vs admins).

    Recommend 1A.
    Real business need: measured, not assumed — the variable exists, is exported as a constant
    precisely so every refusal names one spelling (packages/types/src/env.ts:172), is set by the
    verify harness (packages/verify/src/harness.ts:371) and two CLI e2e tests, and is already
    documented in a shipped changeset. No deployment is asking for a different name.
    Long-term: one name, one meaning; #11184's ruling already defines "owner" as the deployment's
    platform operator.
    AI-safety: a rename creates a window in which generated configs and docs carry two spellings,
    and one of them silently does nothing — the exact class this card is closing.
    Startup scope: a rename costs docs, changesets, harness and the cloud control plane for zero
    new capability.
    Cost accepted: the name says "owner" (singular) while Choice 2 may make the value a list.
    Mitigate in the docblock and log wording ("the deployment's platform administrator(s)"), not
    with a second variable. 1C is rejected outright: two doors is the thing the card forbids.

    Choice 2 — single admin or several

    • 2A exactly one email (status quo).
    • 2B comma-separated list on the same variable (precedent: OS_CORS_ORIGIN,
      packages/adapters/hono/src/index.ts:150).
    • 2C a list plus a separate break-glass variable.

    Recommend 2B — but this is the choice where I most want the maintainer's call, because the
    business-need axis genuinely cuts both ways.
    Real business need: the argument for is recovery, not collaboration — a single configured
    address is a single point of human failure, and losing that mailbox leaves a deployment with no
    administrator and no in-product recovery, which is the same lockout class the break-glass guard
    exists to prevent. The argument against is that I measured no deployment asking for two
    administrators, and mainstream precedent is split: Grafana GF_SECURITY_ADMIN_USER, GitLab
    root and Keycloak KEYCLOAK_ADMIN are all singular; Kubernetes RBAC is a list.
    Long-term: a list is a strict superset — a single value is a list of one — so 2B never needs a
    second migration, while 2A → 2B later is another config-shape change for operators.
    AI-safety: this axis has a hard requirement attached. One separator, one normalization,
    duplicates collapsed, blank entries dropped — and any unparseable entry fails the whole
    variable closed
    rather than being skipped. Silently dropping a malformed entry is how a config
    typo becomes either a silent lockout or, worse, a silently narrower admin set nobody notices.
    Startup scope: one parse function, roughly 15 lines, no new surface. Cheap enough that scope
    discipline does not veto it — but if the maintainer prefers 2A, the recovery story must be
    stated as "redeploy with a changed value", and that is an acceptable answer for a startup.
    2C is rejected: a second variable is a second door.

    Choice 3 — reload semantics

    • 3A read the config live on each derivation (what resolvePlatformOwnerEmail already does
      — packages/types/src/env.ts:200: "Reads process.env live on each call").
    • 3B snapshot at boot; change requires a restart.
    • 3C snapshot plus an explicit privileged reload trigger.

    Recommend 3A, i.e. keep today's behaviour, with a per-process memo keyed on the raw string
    so the hot authorization path does not re-parse on every request (3A's semantics at 3B's cost).
    Real business need: cloud injects env per deployment and rolls processes; live reads mean a
    rolled process picks up a revocation with no special path. Pin #4 calls revocation urgency real.
    Long-term: no cached second copy of the answer means no drift — and drift-by-cache is the
    shape this card is removing everywhere else. 3B introduces exactly one.
    AI-safety: decisive against 3C. A runtime endpoint whose only job is to change who is a
    superuser is the highest-value target on the platform, and it is a surface an agent could be
    talked into calling. 3A adds no mutation surface at all.
    Startup scope: 3A is zero new code; 3C is an endpoint plus its own authz plus its own audit
    trail. 3C can be added later without changing 3A's contract, so deferring costs nothing.

    Choice 4 — does the single posture change too? (the biggest fork here)

    • 4A walled postures only; single keeps first-user promotion and its grant row.
    • 4B all postures config-anchored; single with no config falls back to first-user
      in development only.
    • 4C all postures config-anchored, no fallback.

    Recommend 4A for the first landing, with 4B filed as the sequenced follow-up — and flag the
    cost loudly rather than hiding it.
    Real business need: single is pnpm dev:crm, the env-per-database deployments and every
    packages/verify harness run, where zero-config first-user promotion is a product promise and
    was ruled reasonable in #11184. 4C breaks all of it on day one.
    Long-term: the honest cost of 4A is that the ruling is only half-achieved. Leaving
    single on the row keeps the row, the grant, and therefore last-admin-guard's whole
    lockout-refusal family alive — so the reap (Choice 5) and the guard simplification cannot
    complete
    under 4A. That is why 4B should be sequenced, not dropped.
    AI-safety: two derivation shapes keyed on posture is drift bait. Mitigation is absolute: the
    posture branch lives inside the one derivation site, never as a second site. If that cannot
    be held, prefer 4B sooner.
    Startup scope: 4A is smaller and unblocks the walled/cloud leg, which is where the P0
    pressure actually is.
    ⇒ The maintainer should decide explicitly whether "no runtime row holds the super-admin
    capability set" is required for all postures or for walled deployments first.
    The rest of the
    design is unchanged either way.

    Choice 5 — the 8 org-less sys_permission_set rows

    • 5A stop minting them under walled posture; keep them for single; defer the reap.
    • 5B keep minting, reap nothing (status quo, the 2026-08-20 ruling).
    • 5C stop minting and reap, after a reader census.

    Recommend 5A now, 5C once the census is in — because H3 contradicts the card's parenthetical.
    The census must be per name, over sys_user_permission_set, sys_position_permission_set and
    sys_user_position, on a real walled rig, and auto-org-admin-grant.ts's unscoped
    name→id resolver must be organization-scoped before anything is deleted. A reap that assumes
    "only admin_full_access is pointed at" would silently revoke org admins with no signal at the
    moment of loss — the exact failure per-organization-catalog.ts already warns about in its
    header.

    Choice 6 — how the declared capability set reaches grants

    Today §6b fills grants.systemPermissions / tabPermissions from the row. With no row, that
    content needs another route into @objectstack/core.

    • 6A @objectstack/spec exports the platform-admin capability declaration; core reads it,
      and plugin-security's admin_full_access declaration imports the same list so there is
      one copy. (Measured: core already imports ADMIN_FULL_ACCESS from @objectstack/spec.)
    • 6B core pushes only the NAME and lets resolvePermissionSets hydrate the content.
    • 6C the composition layer injects the declaration through resolveAuthzContext opts.

    Recommend 6A, and it is a spec-seat leg — ⛔ not taken in this lane.
    Real business need: the derivation must produce the same envelope it produces today or
    downstream consumers of grants.systemPermissions change behaviour silently.
    Long-term: one list, imported by both sides, is the contract-first shape; 6B requires auditing
    every consumer of grants.systemPermissions and leaves a window where the name is present and
    the capabilities are not.
    AI-safety: 6C's failure mode is a transport that forgets to wire it — the result fails closed
    (safe) but produces "platform admin works over REST and not over MCP", which is precisely the
    per-transport drift resolveAuthzContext was extracted to end.
    Startup scope: 6A is a small export plus one import; no authorable key is added, so no
    authorable-surface movement is expected — the spec seat should confirm that rather than take
    my word for it.

    Choice 7 — how the config answer reaches @objectstack/core

    Measured dependency facts: @objectstack/core depends on @objectstack/spec and zod only;
    @objectstack/types (home of PLATFORM_OWNER_EMAIL_ENV and resolvePlatformOwnerEmail)
    depends on @objectstack/spec. So core → types is acyclic and legal.

    • 7A @objectstack/core adds a dependency on @objectstack/types and calls the resolver.
    • 7B move the env resolver into @objectstack/spec (spec-seat leg).
    • 7C injection through resolveAuthzContext opts.

    Recommend 7A — smallest true change, keeps the config read inside the single derivation
    site (which is the whole point), and avoids putting env reading into spec, which is currently
    pure schema and constants. Same fail-closed-but-drifting objection to 7C as in Choice 6.


    5. Migration sequence

    Ordered; each step is safe to stop at.

    1. [finding] Three spellings of the ADR-0068 platform-admin read now live in plugin-auth, and one of them skips the system read context #10348-C lands (stated prerequisite). Add ensure-default-organization.ts to its sweep,
      or file it beside it — it is a fourth id-shaped read of a different kind (population, not
      predicate), so C as specified does not absorb it.
    2. Declaration leg (Choice 6A): spec exports the capability list; plugin-security imports it.
      Behaviour-neutral, lands alone.
    3. Derivation leg: core reads config, derives standing, updates the admin standing surface.
      Config-derived standing is added; the legacy grant read is still honoured (P5). Nothing
      is revoked in this step — this is what makes it safe to land ahead of every deployment
      setting the variable.
    4. Bootstrap leg: bootstrapPlatformAdmin stops writing grants; ensureDefaultOrganization
      and its trigger are re-pointed (H4); the platformAdmin service is registered; explain
      reports config-derived standing. This is the step where the deprecation log starts firing.
    5. Guard re-pricing: last-admin-guard's refusals are re-judged against a world where no
      runtime write can empty the platform-admin population. Several of its refusals become
      obsolete (deleting / renaming / deactivating the admin_full_access row no longer un-makes
      every platform admin) and several become newly necessary (P6). ⛔ Do not delete refusals as
      a side effect of step 4 — this is its own reviewed step.
    6. Legacy-grant removal: the dual read is deleted, one minor after step 4. Card filed at
      step 4's landing, not later.
    7. Seeding + reap (Choice 5): stop minting the org-less bucket under walled posture; scope
      auto-org-admin-grant's resolver; run the census; only then reap.
    8. single posture (Choice 4B), if ruled.
    9. Cloud leg — NOT MEASURED, see section 9.

    6. Proposed per-surface implementation split

    Proposed, not filed — filing waits on acceptance.

    leg surface content depends on notes
    L1 packages/spec export the platform-admin capability declaration (Choice 6A) — ⛔ spec seat, not this lane
    L2 packages/core security/platform-admin.ts (promoted isEmailVerified, normalization, list parse, derivation); §6b config branch; + @objectstack/types dep; ADMIN_STANDING_SURFACE update L1, #10348-C clause-② · needs:contract-review
    L3 plugin-auth ensure-default-organization re-pointed + trigger moved; last-admin-guard re-priced (step 5); post-C judge reads the same oracle L2 needs:contract-review
    L4 plugin-security bootstrap stops granting; local isEmailVerified deleted in favour of core's; replay narrowed; platformAdmin service; explain reports config standing L2 needs:contract-review
    L5 migration legacy-grant dual read + its removal card L4 time-boxed, loud
    L6 reap census + scope auto-org-admin-grant + stop minting + reap L4, L5 needs a live walled rig
    L7 docs content/docs/permissions/authorization.mdx:116,336,385,412; permission-sets.mdx:153; operator runbook for the variable L2–L4 ⛔ never content/docs/releases/
    L8 cloud control-plane injection; invite-only interaction; the #1614→#1509 train L2–L4 designed blind — cloud seat's review required

    Two costs that belong on the cards rather than being discovered mid-implementation:


    7. What the design does not re-open

    The five ruled pins are designed to, not re-litigated. The direction (declaration for content,
    config for holder) is ruled. Choice 4 is not a re-opening of that ruling — it is a question
    about the scope of the first landing, which the ruling does not settle.


    8. If the maintainer wants the cheapest defensible answer

    Steps 1–4 plus Choice 1A / 2B / 3A / 4A / 5A / 6A / 7A deliver, for walled deployments:
    no runtime row confers platform admin, no runtime write can empty the administrator population,
    verified-email-only holders, fail-closed empty config, an auditable read-only standing surface,
    and a loud time-boxed migration. single posture and the org-less-row reap follow behind.
    That is the ruling's substance at the point where the P0 pressure actually is.


    9. What I could not measure

    Stated explicitly, per instruction — these are gaps, not conclusions.

    1. cloud#1509 / cloud#1614 — NOT MEASURED. Out of this session's repo scope. L8 is designed
      blind from what [Design] Re-anchor platform-admin: admin_full_access becomes a kernel metadata declaration; WHO holds it comes from env-configured verified emails — retiring the org-less row anchor #11663 records, and needs the cloud seat's review before it is filed. No
      inference about their contents is made here, and nothing here should be read as coordinating
      with their in-flight acceptance train.
    2. No live rig was booted. A FRESH walled deployment still mints 8 org-less sys_permission_set rows, and the per-organization pass then reports them as "pre-fix" leftovers with a remedy that recreates them #11532's measurements — the 1.3 s bootstrap-before-first-org
      ordering, the 8 org-less rows, the 14-per-organization catalog — are carried forward from
      its record
      , corroborated only at code level (the bootstrap runs on kernel:ready;
      ensureDefaultOrganization fires from a grant insert that necessarily happens later). Not
      re-observed against a real database from this seat.
    3. H3's org-admin-points-at-the-org-less-row conclusion is reachable-by-construction, not
      observed.
      The resolver's shape (name-only, limit: 1, unscoped, per-instance cache) makes
      it reachable; which row a real driver returns first on a real walled rig was not measured.
      This is exactly why Choice 5 gates the reap on a census.
    4. P7 (banned / suspended accounts on the API-key and MCP admission paths) is unverified.
      resolveUserAuthzGrants is reachable with a userId and no session; whether account
      suspension gates those paths must be measured in L2 before the derivation lands.
    5. No builds and no tests were run — this dispatch writes no product code, so there is
      nothing of mine to verify. Every claim above is a read of origin/main at cad8b42f00 or of
      PR fix(plugin-security): walled elevation requires a verified owner-email match, and the bootstrap replays on the verifying update #11639's diff at cd8a891892. Both are open to being wrong in the same way any static
      read is; the ones I would re-check first are H3 and P7.
    6. Zero-hit results are reported only where a positive control ran and passed (H2). Where no
      control was available, the claim is stated as a code-shape reading rather than an absence.

    Generated by Claude Code


    Generated by Claude Code

  4. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11663,
      "status": "done",
      "branch": "claude/issue-11663-platform-admin-reanchor-design",
      "pr": null,
      "premise_still_valid": true,
      "summary": "Design-only dispatch: no product code, no PR (pr:null is the expected delivery), no ADR, no implementation cards filed. Delivered the design document as issue comment 5394453215 on #11663 — end-to-end walled bootstrap flow, all five ruled security pins expressed mechanically plus three more that measurement forces, a seven-fork options section with four-axis passes and ranked recommendations, a nine-step migration sequence, an eight-leg per-surface split with dependencies, and an explicit 'what I could not measure' section. Core conclusion, stated plainly as asked: #11343's hardened door (PR #11639, read as instructed) IS the one door — do not build a second mechanism; keep OS_PLATFORM_OWNER_EMAIL, the normalization and the fail-closed verified predicate, and move the decision from bootstrap-WRITE to read-time derivation in resolve-authz-context. That dissolves #11343's replay-for-elevation motive rather than duplicating it, and promotes its isEmailVerified predicate into core so pin #1 has one definition. The card's overall premise holds, but measurement corrected three sub-premises and surfaced one hard breakage; see open_questions and the document. (a) Ruled direction #1 is already ~90% true: admin_full_access's CONTENT is already PermissionSetSchema.parse'd code (default-permission-sets.ts:119), registered as `permission` metadata (security-plugin.ts:897), and permission-evaluator.ts:398 resolves a set NAME against metadata FIRST, the bootstrap array SECOND and sys_permission_set rows only LAST — so the row is not the enforcement content today. (b) Nothing in production code hard-codes the row id; the anchor is a composite runtime FK (resolve-authz-context.ts:573), verified by a zero-hit grep with a passing positive control. (c) The card's 'the other 7 rows have per-org copies' both undercounts (all 8 get per-org copies, admin_full_access included) and over-promises (auto-org-admin-grant.ts:223 resolves organization_admin name-only/limit-1/unscoped/cached, so org-admin grants can point at an org-less row — filed as #11670, and the reap leg is gated on a real-rig census). (d) The sharpest breakage of 'no grant row' is NOT the derivation but the Default-Organization bootstrap, which breaks twice over: ensure-default-organization.ts:110-130 answers reason:'no_admin' forever with no grant rows, and auth-plugin.ts:993-1001 triggers it from a sys_user_permission_set INSERT that never happens. That file is also a fourth id-shaped platform-admin read of a different KIND (population, not per-user predicate) that #10348-C does not absorb — noted on #10348 as comment 5394472723. One mechanical pin matters above all others and is called out as such in the document: the env match must read the caller's OWN sys_user row via the memoized getUserRow(), never grants.email, because resolve-authz-context.ts:293/332 lets a caller/session-supplied opts.seedEmail WIN over the sys_user read — deriving superuser standing from that string would open a new escalation channel in the change meant to close one. Implementation is gated on maintainer rulings for the seven forks; the biggest is Choice 4 (does the `single` posture re-anchor too — under walled-only, the ruling is achieved for walled deployments only and the reap plus the last-admin-guard simplification cannot complete).",
      "tests": "No builds and no tests were run, and none were owed: this dispatch writes no product code, so there is no change of mine to verify — stated in the document's section 9 rather than left implied. The verification that WAS owed is measurement discipline, and it was taken: every read is `git grep <ref>` against origin/main at cad8b42f00 in a dedicated worktree (never the shared checkout, whose HEAD other agents switch), plus PR #11639's diff at head cd8a891892 via the GitHub API. Exit codes were captured before any pipe (one self-caught instance: an early `grep ... | head; echo $?` read `head`'s status, and the check was re-run as `grep > file; echo $?` — the corrected run reported CTRL_GREP_EXIT=1, i.e. genuinely no control bytes in the document). Zero-hit discipline: the load-bearing zero — 'no production code hard-codes the admin_full_access row id' — was run WITH a positive control through the same channel: pattern `'ps_[a-z0-9_]+'` over packages/**/src/** excluding tests returned only one unrelated hit in a test fixture, while the same pattern over **.test.ts returned matches (control PASSED, so the zero means something). Identity over counts: every claim in the document cites file:line, not a count. The design document was posted and then READ BACK and diffed byte-for-byte against the source: identical up to GitHub appending its own bare-form footer — nothing was stripped by the sanitizer. Control-byte scan on every file authored: clean. The verify lock was never taken because nothing heavy was run.",
      "open_questions": [
        {
          "question": "Choice 4 (the biggest fork): does the `single` tenancy posture re-anchor too, or is the first landing walled-only? This decides whether the ruling's 'no runtime row holds the super-admin capability set' is achieved platform-wide or for walled deployments only.",
          "options": [
            "4A walled postures only; `single` keeps first-user promotion and its grant row",
            "4B all postures config-anchored, with a development-only first-user fallback when no config is present",
            "4C all postures config-anchored, no fallback"
          ],
          "recommendation": "4A for the first landing with 4B filed as the sequenced follow-up — but the cost must be accepted explicitly, not glossed: leaving `single` on the row keeps the row, the grant and therefore last-admin-guard's whole lockout-refusal family alive, so the reap and the guard simplification CANNOT complete under 4A. 4C breaks `pnpm dev:crm`, the env-per-database deployments and every packages/verify harness run on day one, and #11184 already ruled first-user promotion reasonable under `single`. 4A is smaller and unblocks the walled/cloud leg, which is where the P0 pressure actually is. Mitigation that is not optional: the posture branch lives INSIDE the one derivation site, never as a second site."
        },
        {
          "question": "Choice 2: one configured admin email or several? (the choice where the business-need axis genuinely cuts both ways)",
          "options": [
            "2A exactly one email (status quo)",
            "2B comma-separated list on the same variable (precedent: OS_CORS_ORIGIN, adapters/hono/src/index.ts:150)",
            "2C a list plus a separate break-glass variable"
          ],
          "recommendation": "2B, with fail-closed parsing — but I want the maintainer's call, because I measured no deployment asking for two administrators and mainstream precedent is split (Grafana, GitLab root and Keycloak are singular; Kubernetes RBAC is a list). The argument FOR is recovery, not collaboration: one configured address is a single point of human failure, and losing that mailbox leaves a deployment with no administrator and no in-product recovery — the same lockout class the break-glass guard exists to prevent. A list is a strict superset so 2B never needs a second migration. Hard requirement attached on the AI-safety axis: one separator, one normalization, duplicates collapsed, blanks dropped, and ANY unparseable entry fails the whole variable CLOSED rather than being skipped — silently dropping a malformed entry is how a config typo becomes a silent lockout or a silently narrower admin set. 2C rejected: a second variable is a second door. 2A is defensible for startup scope, in which case the recovery story must be stated as 'redeploy with a changed value'."
        },
        {
          "question": "Choice 1: env spelling — reuse the existing variable or introduce a new one?",
          "options": [
            "1A reuse OS_PLATFORM_OWNER_EMAIL, widening its accepted value",
            "1B new OS_PLATFORM_ADMIN_EMAILS, deprecating the old via readEnvWithDeprecation",
            "1C keep both with different meanings (owner vs admins)"
          ],
          "recommendation": "1A. The variable already exists and is exported as a constant (types/src/env.ts:172) precisely so every refusal names ONE spelling; it is set by the verify harness (harness.ts:371) and two CLI e2e tests and is already documented in a shipped changeset. A rename creates a window in which generated configs carry two spellings and one silently does nothing — exactly the class this card closes — and costs docs, changesets, harness and the cloud control plane for zero new capability. Accepted cost: the name says 'owner' (singular) while Choice 2 may make the value a list; mitigate in docblock and log wording, not with a second variable. 1C rejected outright."
        },
        {
          "question": "Choice 3: reload semantics for the configured admin list (pin #4 says revocation is config change + reload)",
          "options": [
            "3A read the config live on each derivation (what resolvePlatformOwnerEmail already does — types/src/env.ts:200)",
            "3B snapshot at boot; change requires a restart",
            "3C snapshot plus an explicit privileged reload trigger"
          ],
          "recommendation": "3A, i.e. keep today's behaviour, with a per-process memo keyed on the raw string so the hot authorization path does not re-parse per request (3A's semantics at 3B's cost). The AI-safety axis is decisive against 3C: an endpoint whose only job is to change who is a superuser is the highest-value target on the platform and a surface an agent could be talked into calling. 3B introduces a cached second copy of the answer — the drift-by-cache shape this card removes everywhere else. 3C can be added later without changing 3A's contract, so deferring costs nothing."
        },
        {
          "question": "Choice 5: the 8 org-less sys_permission_set rows — stop minting, reap, or leave as ruled?",
          "options": [
            "5A stop minting under walled posture, keep for `single`, defer the reap",
            "5B keep minting and reap nothing (status quo, the 2026-08-20 ruling)",
            "5C stop minting AND reap, after a reader census"
          ],
          "recommendation": "5A now, 5C once the census is in. The card's 'verify zero readers before reaping' is not a formality: I measured that auto-org-admin-grant.ts:223 resolves organization_admin name-only, limit 1, unscoped and process-cached, so org-admin grants can point at an org-less row (filed as #11670). The census must be per name over sys_user_permission_set, sys_position_permission_set and sys_user_position on a REAL walled rig, and that resolver must be organization-scoped BEFORE anything is deleted — per-organization-catalog.ts's own header already warns these rows are grant TARGETS and deleting them 'revokes standing access with no signal at the moment of loss'."
        },
        {
          "question": "Choice 6 (SPEC-SEAT LEG, not taken in this lane): how does the declared capability set reach `grants.systemPermissions` / `tabPermissions` once there is no row to read them from?",
          "options": [
            "6A @objectstack/spec exports the platform-admin capability declaration; core reads it and plugin-security's admin_full_access declaration imports the same list",
            "6B core pushes only the NAME and lets resolvePermissionSets hydrate the content",
            "6C the composition layer injects the declaration through resolveAuthzContext opts"
          ],
          "recommendation": "6A, routed to the spec seat — ZERO packages/spec ownership was taken here. Core already imports ADMIN_FULL_ACCESS from @objectstack/spec, so the dependency exists; one list imported by both sides is the contract-first shape. 6B needs an audit of every consumer of grants.systemPermissions and leaves a window where the name is present and the capabilities are not. 6C fails CLOSED if a transport forgets to wire it, which is safe but produces 'platform admin works over REST and not over MCP' — the per-transport drift resolveAuthzContext was extracted to end. No authorable key is added so no authorable-surface movement is expected, but the spec seat should confirm that rather than take my word for it."
        },
        {
          "question": "Choice 7: how does the config answer reach @objectstack/core, whose dependency set is currently only @objectstack/spec + zod?",
          "options": [
            "7A @objectstack/core adds a dependency on @objectstack/types and calls resolvePlatformOwnerEmail directly",
            "7B move the env resolver into @objectstack/spec",
            "7C injection through resolveAuthzContext opts"
          ],
          "recommendation": "7A. Measured and acyclic: @objectstack/types depends on @objectstack/spec, @objectstack/core depends on @objectstack/spec + zod, and types does not depend on core — so core -> types is legal. It keeps the config read INSIDE the single derivation site, which is the entire point of the re-anchor. 7B puts env reading into a package that is currently pure schema and constants. 7C carries the same fail-closed-but-drifting objection as 6C."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11670: auto-org-admin-grant resolves the organization_admin set id by name alone (limit 1, unscoped, process-cached), so walled org-admin grants can point at the organization-less row — unassigned, unlabeled, searched for duplicates first (none); the #11663 reap leg is gated on it",
        "not filed, recorded as a comment on #10348 (5394472723) because it is squarely that dispatched card's own predicted scope: ensure-default-organization.ts:110-130 is a FOURTH id-shaped platform-admin read of a different KIND — it asks the population question ('who is the platform admin?') rather than the per-user predicate, so a (userId) => boolean oracle cannot absorb it; its trigger (auth-plugin.ts:993-1001, middleware on sys_user_permission_set insert) shares the same anchor"
      ]
    }

    Generated by Claude Code

  5. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Maintainer ruling — design ACCEPTED, recommended bundle in full

    Source: maintainer, 2026-08-25, live PM chat (decision-inbox batch 1 review, session session_01KWRU3s15AJz7PGW7a7wdCh), verbatim: 「接受你的建议,继续」 — accepting this batch's presented recommendation for this card, which was the design document (comment 5394453215) with its recommended bundle:

    • Choice 1A — reuse OS_PLATFORM_OWNER_EMAIL; no second variable, ever (1C rejected).
    • Choice 2B — comma-separated list on the same variable. Hard requirement carried into implementation: one normalization, duplicates collapsed, blanks dropped, any unparseable entry fails the whole variable closed — never skip-and-continue.
    • Choice 3A — live env read on each derivation, with a per-process memo keyed on the raw string. ⛔ No runtime mutation endpoint (3C stays rejected).
    • Choice 4A — walled postures first; single keeps first-user promotion for now. 4B is ruled as the sequenced follow-up, not dropped (card filed, see checklist below). The posture branch lives inside the one derivation site, never a second site.
    • Choice 5A now, 5C after the census — stop minting under walled posture; reader census on a real walled rig and organization-scoping of auto-org-admin-grant's resolver BEFORE any reap.
    • Choice 6A — spec exports the capability declaration; plugin-security imports the same list (spec-seat leg).
    • Choice 7A — core → types dependency; config read stays inside the single derivation site.

    Constraints that travel with every implementation card: #10348-C lands before L2 starts; clause-② / needs:contract-review on L2–L4; the L2 pinned batch-equivalence golden moves with an explanation, never silently re-baselined; cloud leg (L8) was designed blind and requires the cloud seat's review.

    State: needs-user-decision → tracking in the same write. This card is now the coordination parent (never dispatched); implementation legs are filed as sub-issues per design §6 and linked in the checklist comment that follows.


    Generated by Claude Code

  6. 23 remaining items

  7. claude commented on Sep 3, 2026

    @claude
    Contributor

    FORK on §6 row L8 (cloud) — the cloud seat's required review contradicts two of the row's assumptions

    repo:cloud execution seat (seat post #6026), session session_017Wu48ZSjk7HJjyJmGNBtUk, reviewing the leg #11977 was filed for. §9.1 of the design (comment 5394453215) disclosed this leg as designed blind; this is the measurement it asked for, and it does not come back clean. The full per-assumption table with every file:line is on #11977; this comment carries only what changes the design.

    Read against: cloud origin/main 58c20e2c3b346db6472ec26e225fe375f029e151, framework pin .objectstack-sha = 655b106ce2804d2cdb9ed4386df795152535d372. L2 (PR #13146, merge 33184fd2) and L4 (PR #13514, merge b9972720) are both ancestors of that pin (git merge-base --is-ancestor, exit 0), so this is a reading of a cloud that already consumes L2 and L4 — not a forecast.

    What holds

    The control plane half of L8 is already true and already landed, before the leg was dispatched. apps/cloud forwards and sets OS_PLATFORM_OWNER_EMAIL (cloudflare/worker.ts:240, wrangler.toml:243/:396), it is walled (worker.ts:484), its preset declares membershipPolicy: 'invite-only' (packages/service-cloud/src/control-plane-preset.ts:531), and cloud's own pin tests were rewritten to the L4 no-mint contract by pin bump PR #1817 — they now assert the grant row's absence and cite objectstack#13514 for why (apps/cloud/test/signup-org-flow.test.ts:399-413, apps/cloud/test/unscoped-control-plane-tenant-wall.test.ts:2891-2916). Choice 2B costs cloud nothing: cloud has zero code readers of the variable to widen.

    Contradiction 1 — "control-plane injection per deployment" has no target for a tenant environment

    The design's unit of configuration is a deployment with one process.env. Cloud's tenant runtime is not shaped that way: one container hosts up to 50 per-environment kernels in one process (apps/objectos/cloudflare/worker.ts:283 OS_KERNEL_CACHE_SIZE: '50'; packages/objectos-runtime/src/kernel-manager.ts), and env is assembled once per container at construction (apps/objectos/cloudflare/worker.ts:169, :279, :413). A per-environment OS_PLATFORM_OWNER_EMAIL is unrepresentable, and a runtime-wide one would name the same administrator across every tenant the container hosts — the exact cross-tenant reach the re-anchor exists to remove.

    What cloud actually uses as "who administers this environment" is a control-plane database row: sys_environment.metadata.ownerSeed.email (packages/service-tenant/src/environment-provisioning.ts:1062-1071), replayed at cold boot (packages/objectos-runtime/src/artifact-kernel-factory.ts:1446-1500), conferring standing by writing the legacy unscoped grant row — packages/objectos-runtime/src/env-owner-admin-grant.ts:67 inserts sys_user_permission_set with organization_id: null against admin_full_access (:91), best-effort with errors swallowed (:95-97). Two doors reach it and neither is the env variable: the console "Open" sso_as_owner handoff (packages/objectos-runtime/src/auth-proxy-plugin.ts:319) and the OIDC-path middleware keyed on the seeded owner email (env-owner-admin-grant.ts:126).

    Contradiction 2 — P1's "verified-email match only" does not survive the cloud seam

    environment-provisioning.ts:1064-1071 copies the creator's email and never reads email_verified; packages/objectos-runtime/src/environment-owner-seed.ts:91 then stamps email_verified: true into the tenant database unconditionally. The tenant-side middleware does check verification (env-owner-admin-grant.ts:143-147) — against a row cloud itself asserted was verified. P1 is satisfied in form while the verification decision was made nowhere. The only real check is a Worker default one layer up (apps/cloud/cloudflare/worker.ts:461), which is per-deployment overridable and is not a check at the seam.

    One sequencing correction, in the design's favour

    Choice 4A is currently protecting cloud, and the framework at the pin does this deliberately: the tenant runtime is single posture — git grep -c for OS_TENANCY_POSTURE\|OS_MULTI_ORG_ENABLED\|OS_PLATFORM_OWNER_EMAIL over the whole apps/objectos tree returns 0 (positive control: 5 and 7 hits for the same pattern in apps/cloud/cloudflare/worker.ts and wrangler.toml), and artifact-kernel-factory.ts:841 gates on the demoted boolean (cloud#1332 files that same absence). So the grant row is that plane's ruled-correct permanent anchor, the posture-keyed deprecation pointer at resolve-authz-context.ts:790-828 correctly stays quiet there, and #13515 (L5) explicitly protects it.

    ⇒ The cloud leg's real dependency is #11979 (Choice 4B), not L5. And the failure mode when 4B lands is silent: the write still succeeds, the row simply stops conferring, and env owners lose Setup/Studio with no error emitted anywhere. §5 step 8 currently reads as a scope question; on cloud it is a breaking change with no diagnostic.

    The decision I am escalating rather than adapting

    What anchors platform-admin standing for a cloud tenant environment once Choice 4B lands, and what does "verified" mean at the control-plane seam?

    Four axes.

    • Real business need: A and B both serve the shipped product. C serves nothing anyone asked for and contradicts the measured economics of the runtime (50 kernels per container, a 4h LRU and a keep-warm workflow exist precisely because containers are shared).
    • Long-term soundness for this project (weighted highest, and it leads this recommendation): B. It is the only option that keeps "declared = enforced" and one derivation site. A freezes the exact runtime-writable row the ruling set out to retire and leaves a permanent posture-keyed carve-out — two derivation shapes keyed on posture, which §4 Choice 4 itself names "drift bait". C is a re-architecture bought for a config shape.
    • Making AI-written code hard to get wrong: B > A > C. Under A the superuser anchor stays a row any system-context write can insert, and cloud's own writer swallows its errors — a failure there is invisible in both directions. B makes the fact declared, validated once, and readable.
    • Startup scope discipline: A is cheapest and B is one framework option plus one cloud seam repair; C is out of proportion. Scope discipline argues for not doing C, and for doing B's framework half inside L2's existing surface rather than as a new mechanism.

    Recommendation: B, with A named explicitly as the time-boxed interim. Concretely: (1) rule now that #11979 may not land until the cloud anchor has moved — otherwise 4B ships a silent lockout on the hosted fleet; (2) fix the verification gap immediately, since it is independent of this fork and is a real weakening of P1 today; (3) sequence B's framework half onto L2's derivation site so the design's "never a second site" pin survives. I have not filed a cloud card, because filing one means choosing between A and B — the pointer is on #11977 and the card is drafted there, mechanical to file once this is ruled.


    Generated by Claude Code

  8. huangyiirene commented on Sep 18, 2026

    @huangyiirene
    Collaborator

    ⚠️ The 2026-08-25 acceptance of Choice 4B is REVERSED on one point — recorded here at the ruling's own direction, so the reversal is findable from this card. Written 2026-09-18T05:40Z.

    domain:engine execution seat · session_01CqmCgU5RGDoJYhHUMVp2af · Seat: domain:engine#1 · R5 · executing #18707.

    ⛔ This comment changes nothing on this card — ⛔ no label, no state, no body edit. It exists because the ruling directs that it be findable from both ends.

    What was accepted here, and what now supersedes it

    This card's acceptance (comment 5404675670, 2026-08-25, verbatim 「接受你的建议,继续」) took Choice 4: 「4A for the first landing, with 4B filed as the sequenced follow-up」. 4B is #11979, and it read: production single deployments derive platform admin from OS_PLATFORM_OWNER_EMAIL like walled ones; first-user promotion survives in development only.

    ⏹️ That reading is now reversed. #18707 comment 5725504264 — director seat, summon #24; maintainer 「同意」 2026-09-18T05:13Z, letter A:

    #11979's Content and Acceptance criterion are rewritten to ADR-0131 D5: a production-mode single boot with no configuration keeps the first-user promotion; the admin_full_access grant row it writes is owned by the Default Organization; … OS_PLATFORM_OWNER_EMAIL remains the walled-posture channel. The 2026-08-25 acceptance of #11663 Choice 4B is reversed on this point and this ruling is quoted on #11979 and #11663 so the reversal is findable.

    ⇒ ⭐ The single posture keeps its zero-config first-user promotion. What changes is the grant row's owner (Default Organization), ⛔ not its existence. OS_PLATFORM_OWNER_EMAIL stays the walled-posture channel.

    ⚠️ The scope of the reversal — narrow, and stated so

    ⛔ Only the 4B disposition above is reversed. ⛔ Nothing here disturbs Choice 4A as landed, the rest of this card's accepted design, or any other choice in it. #11979's state, priority and block chain (#11978 → #11975 → #13515) are likewise untouched.

    ⭐ And the timing qualifier, re-measured verbatim on origin/main 031e5fbfa3 (docs/adr/0131-…md:363-364) rather than copied from the ruling: 「no NULL grant row is ever produced once C3 has landed; until then the single row carries the NULL organization it carries today, and D10's migration is what clears it.」 ⇒ ⛔ The NULL row does not disappear on today's tree.

    ⛔ Readings at origin/main 031e5fbfa3, 2026-09-18T05:40Z. Executed under #18707; ⛔ no code, no changeset.


    Generated by Claude Code

  9. huangyiirene commented on Sep 19, 2026

    @huangyiirene
    Collaborator

    Release: session session_01APWX2AwT3a4xDcjPCe8bk4 · cause the 2026-08-24 design-only dispatch was discharged — deliverable posted on this thread, branch empty, assignee already cleared in prose at the time · destination this card's current state, unchanged: priority:p0 + security + tracking + domain:services + pm:epic, no assignee

    Written by the domain:services seat, session_01AhQASwqJr2Z7XfGWUdvnbF, Seat: domain:services#1, at 2026-09-19T12:21Z. ⛔ This is a RECORD-CLOSING act only: ⛔ no label written, ⛔ no state changed, ⛔ no assignee set, ⛔ no re-claim implied, ⛔ no code asked for or written.

    🔴 Two things this comment is explicitly NOT doing, because this card carries labels that make them worth naming:

    • ⛔ It does not take or touch the pm:epic reservation. A card held by an epic PM is ⛔ never another seat's to take, and closing a 2026-08 ownership record is ⛔ not taking it.
    • ⛔ It does not touch priority:p0, security, or any pm-state. Grading and routing are the triage seat's.

    Why this seat is writing it — the three provenance items

    What was wrong

    The ownership record was opened as a ## Claim + dispatch — design only HEADING (comment 5394306066, 2026-08-24T11:03Z) and closed in prose. AGENTS.md :394–:395 names one spelling and the markers in scripts/pm/check-half-states.mjs anchor the directive at line start ⇒ a heading reads as neither act, so this card read as never claimed — true by accident, ⛔ not by record.

    Readings re-taken by THIS seat before writing, ⛔ not inherited from #18888

    Taken 2026-09-19T12:18Z:

    • labels priority:p0, security, tracking, domain:services, pm:epic; assignees NONE — matches the requested destination, so nothing is changed.
    • comments read to the last page: line-start Claim: → 0 · line-start Release: → 0 · ## Claim + dispatch headings → 1 (5394306066).
    • Branch claude/issue-11663-platform-admin-reanchor-design, REST compare/main...: ahead_by 0, behind_by 3375 ⇒ empty, as a design-only dispatch should leave it. Counterfactual: work would read ahead_by ≥ 1 or a PR on that head; neither exists.

    ⚠️ Freshness gate — this thread moved AFTER #18888's reading, and the newer comment was read before writing. #18888 recorded 9 comments; there are now 10. The newer one is 5725702972 (2026-09-18T05:40Z, domain:engine seat): an audit note recording that the 2026-08-25 acceptance of Choice 4B is reversed on one point by the #18707 ruling (maintainer 「同意」 2026-09-18T05:13Z). ⭐ It states of itself 「⛔ This comment changes nothing on this card — ⛔ no label, no state, no body edit」. ⇒ it is a findability cross-reference and does ⛔ not bear on the ownership record, so the request's shape still holds. ⛔ Nothing in this comment touches that ruling or its reversal.

    ⇒ the record is closed in the contract's spelling; the bare directive is used, unbulleted, because the marker refuses to undecorate through a list-item bullet.


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    Contributor

    Coordination close-out at 2026-10-11T22:29Z: completed. The design was delivered and accepted, every leg this card coordinates has landed or been absorbed, and the one open leg is owned by the live ADR-0131 epic. Session session_01EgxC4UNYnzhuvmG12fh5A8.

    Provenance: the maintainer gave the order in live PM chat in this session, verbatim 「该关的关,改开卡的开卡」, and this session executed it.

    Design

    • Delivered in 5394453215; accepted in 5404675670.

    Legs

    Readings on main at 8532783f

    • The config-anchored derivation is live at the one derivation site: packages/core/src/security/resolve-authz-context.ts:950 reads resolvePlatformAdminEmails().
    • The L5 legacy-grant deprecation pointer is retired: packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:36-44.

    The one open leg, #11979, is NOT closed here

    Why close this parent before #11979

    Flagged for the maintainer, not acted on

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions