Skip to content

A deployment that seeds a people directory and no credentials is locked out for good: bootstrap-status and the audience bootstrap bypass both count HUMANS, not LOGINS #14349

Closed as not planned

Description

@claude

Found while implementing #14157. Filed unassigned. The development-lane half is fixed there; this card is the part that needs a ruling, because it moves a PUBLIC door.

The measured facts

Measured on a real ObjectQL over better-sqlite3, with 13 seeded sys_user rows and zero sys_account rows, default audience posture:

  • api.signUpEmail(...) is refused SELF_REGISTRATION_CLOSED — isBootstrapCreation() reads 13 humans and answers "populated", so the bootstrap carve-out does not fire.
  • count('sys_user', {}) = 13, which is what GET /api/v1/auth/bootstrap-status used to answer hasOwner: true from.

In DEVELOPMENT #14157 closes this: the dev-admin seed now provisions on that population, so a login exists and everything downstream is consistent. Outside development the seed is hard-gated off by NODE_ENV, and then:

  • nobody can sign in (no sys_account row at all);
  • self-registration is refused by the default invite_only posture;
  • there is no administrator to issue an invitation, because there is no administrator;
  • so the deployment cannot be recovered from inside. That is exactly the outcome the bootstrap carve-out exists to prevent — "a fresh install must never lock its operator out" — and it does not fire, because the population it counts is people rather than logins.

Why #14157 did not simply fix it

Both predicates would have to move together, and moving them widens a public door. Today, on that population, an unknown visitor hitting /sign-up/email is refused. If isBootstrapCreation counted LOGINS instead of humans, that visitor would be admitted as the bootstrap account — not promoted (plugin-security still sees 13 older humans), but admitted into a walled deployment. Moving only bootstrap-status is worse in a different way: the console would offer a first-run setup flow that the admission gate then refuses 403.

#14157 therefore made bootstrap-status AGREE with the admission gate rather than diverge from it (it now asks AuthManager.hasBootstrapWindow(), which also closed a real disagreement: the handler used to count every sys_user row including the legacy usr_system service row, so a database carrying only that row reported an owner while the admission gate and plugin-security's first-user detection both stood ready to admit and promote the first human). What is left is one question, and it is a posture question.

The decision

Should the bootstrap carve-out mean "no human users" or "no LOGIN"?

  • A. Keep "no human users" (status quo). No public door moves. A production install that seeds a people directory and nothing else stays unrecoverable from inside; the operator must provision an account out of band. The cost is a silent dead end whose only symptom is a 401 on credentials nobody has.
  • B. Count logins instead. The carve-out then fires exactly when nobody can sign in, which is what it says it protects against, and both surfaces flip together. The cost: on a directory-seeded, publicly reachable deployment the first stranger to reach /sign-up/email is admitted (role user, unpromoted) instead of refused.
  • C. Keep the population predicate and make the dead end LOUD instead. At kernel:ready, when human rows exist and no sys_account does, report it at error level naming the consequence and the remedy (provision an account out of band, or open the posture). No admission semantics change; the operator is told rather than left to find a 401.

No recommendation is offered here, because option B changes who may create an account on a walled deployment — a posture ruling rather than an implementation choice. C is cheap, moves nothing, and composes with either of the others.

<!-- os-decision-facets -->
① 项目长远合理性(权重 ≥50%):C 既不扩大接受集、也不增生谓词 —— 今天「引导期」这个名字说的是「没人能登录」,做的却是「没有人类行」,名实不符会被一再重新发现(平台内已有三处按人口作答:isBootstrapCreation、probeWalledOwnerAccountState,以及按全部行数作答的 bootstrap-status 处理器)。A 单独留着这个名实缺口;B 想用「打开一扇门」去买名实一致 —— 按本轴本义(缩小而非扩大特例)读,⛔ 不构成对 B 的背书。
② 实际业务拉动:零 —— 本形态是 #14157 的实现者构造出来的,不是哪位运营商报的障,今天没有一位客户撞上。按零拉动默认走 ④ 不扩散。
③ 防 AI 犯错:今天这台部署静默死掉,运营商能看见的唯一症状是「没人持有的那副凭据上的一个 401」;C 把沉默换成开机时的响亮拒绝(响亮拒绝优于静默容忍)。B 的失败面反过来同样静默:一台围墙部署被路过的陌生人抢到第一个账号,而代价记在别人的数据上。
④ 创业阶段不扩散:C 只加一条开机诊断,不新增任何长期声明义务;B 新增一条永久姿态承诺(「什么时候允许陌生人自助开户」),每一条这种已声明的键都是永久维护义务。
推荐:C(已拆为 #14353,与本裁无关可立即执行)+ A 暂留现状待裁;B 恒交维护者 —— 它动的是「围墙部署上谁可以开户」,属安全/权限边界,⛔ 席位不代裁,四棱同向也不改这一点。
置信缺口(本分析看不见什么):看不见是否已有真实部署处于这个形态 —— 平台无此遥测,卡上那 13 行是构造出来的;也没有量出 B 若落地需同时移动几处谓词才不制造新的分歧(已数出三处读同一人口,未逐处验证)。

Generated by Claude Code

Activity

  1. added
    priority:p1High: required for production / M2
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 2, 2026
  2. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage: stays in the decision inbox. needs-user-decision · priority:p1 · security · domain:services · type Feature. The standard four-facet block is now on the card face (decision-inbox duty: the block is completed when a card enters the inbox, not when the maintainer arrives).

    Faces verified on origin/main @ 72adb7f, not taken on the filer's word:

    • packages/plugins/plugin-auth/src/auth-manager.ts:3866 — isBootstrapCreation() pages sys_user and returns false if rows.some(isHumanUserRow). It counts humans. ✔
    • packages/plugins/plugin-auth/src/auth-plugin.ts:1987 — the bootstrap-status handler is const count = await dataEngine.count('sys_user', {}); return c.json({ hasOwner: (count ?? 0) > 0 }). It counts every row, usr_system included — so the disagreement the card describes between the handler and the admission gate is real on main today. ✔
    • packages/plugins/plugin-auth/src/walled-owner-verification-path.ts:143 — isDevAdminSeedArmed() opens with if (env().NODE_ENV !== 'development') return false. The seed is hard-gated as claimed. ✔
    • Default audience posture invite_only and the SELF_REGISTRATION_CLOSED refusal are pinned by packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts. ✔
    • hasBootstrapWindow does not exist on main — consistent with the card, which describes it as maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157's change. ✔

    Two facts the card does not carry, both of which the maintainer will want when ruling:

    1. Option B is a family move, not a predicate swap. A third site reads the same human population: probeWalledOwnerAccountState (feat(auth): walled deployment's declared owner is email-verified at operator-provisioned creation (extends #11343 to production walled boots) #12751, walled-owner-verification-path.ts:226) ends return humansExist ? 'owner-absent' : 'no-human-users', keyed on the same isHumanUserRow. So three sites answer three slightly different questions about one population today. B has to move them together or it creates new disagreements in place of the one maybeSeedDevAdmin gates on "any sys_user row", but runs at kernel:ready — so any app that seeds people permanently loses the objectstack dev login #14157 just closed. That raises B's cost above what the card implies.
    2. The documented operator path is the one this scenario closes. content/docs/deployment/self-hosting.mdx:428 tells the operator to "have each of those people register with exactly that address and verify their email" — there is nothing to grant and nothing to click. On the card's population that path is refused by the invite_only default with no administrator to invite. So "provision an account out of band" under option A is not merely undocumented; the published documentation actively points at the door that is shut.

    Why priority:p1 with zero reported incidence. Nobody has hit this — it was constructed by #14157's implementer, and the platform has no telemetry that would say otherwise (recorded in the facets confidence gap). p1 is for the consequence class, not the rate: the failure is total, irreversible from inside, and silent, and the ruling also fixes what #14353's diagnostic is allowed to say.

    Why type Feature. The mechanical boundary test reads the change under consideration, and limb B widens the public accept set (a visitor refused today would be admitted). Feature therefore lands this on the human floor by routing as well as by subject. ⛔ This is a routing decision, not a pre-judgment that B wins — A and C change no accept set at all.

    Human floor, no seat adjudication. "Who may create an account on a walled deployment" is a security/permission boundary. The four facets happen to point one way (C, with A as the standing state), but alignment does not open the auto-adjudication gate here — that gate requires the card to be off the human floor, and this one is not. The recommendation on the card face is an input to the maintainer, never a release.

    Not presented yet. Decision batch 1 is outstanding and unanswered; ⛔ no second batch until it is. This card rolls into the next batch with the two facts above written into its prose.

    Option C is already separable and moving: #14353 carries it as pm:queue / p2 / domain:services, because the filer is right that it composes with every limb. Its message text is the one thing that depends on this ruling, and that dependency is now recorded there.


    Generated by Claude Code

  3. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Decision facets block — Chinese per the maintainer's 2026-08-19 instruction that decision analyses on issues are written in Chinese. Filed into the decision inbox (needs-user-decision) by the domain:services PM seat, session_01AUF1NoViznQK32gqpK8wS8, 2026-09-02. Not self-adjudicated: option B moves a public door, which is the maintainer's floor.

    <!-- os-decision-facets -->

    四维分析

    • ① 项目长远合理性(权重 ≥50%):bootstrap 豁免的本义是「全新安装永不把操作员锁在门外」,判据的本义是「没有任何可登录账号」,不是「没有人」。A 把一个特例(目录行 = 已有主人)永久固化进公开门的谓词;B 是语义收口。但 B 让「种了目录、没种凭据」的部署对第一个陌生访客敞开——这是公开门放宽,属维护者地板,长远合理性不能单独背书。长远终态更像收窄版 B:窗口只在零登录时开,且首个注册必须匹配目录里已有的地址(把门开给已被声明的人,不开给路人)。这个变体不在卡上,列为待裁问题,不是推荐。
    • ② 实际业务拉动:今日零实测撞击——脚手架不种人(PR fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call #14352 与 The release candidate has failed its fresh-install smoke for ~18h — POST /auth/sign-up/email returns 403 SELF_REGISTRATION_CLOSED (and the check-run is NOT about main) #14000 的实测都这么说),卡上的 13 行种子是构造出来的测量。会撞上的部署形状是「defineStack({ data }) 种目录、不种凭据、NODE_ENV 不是 development」,症状是 401 且没有任何人能开邀请。零拉动 ⇒ 默认不扩散。
    • ③ 防 AI 犯错:静默死胡同是三项里最差的——症状(401)与根因(谓词数人不数登录)之间没有任何可见提示,排障的 agent 必然把它当凭据问题;C 的响亮报告把「有人、无登录」这个不变量违背当场喊出并写明补救,是三项里唯一直接降低误判的。若裁 B,必须同笔把「陌生人被 bootstrap 接纳」写成显式、可 grep 的接纳日志,否则放宽本身也是静默的。
    • ④ 创业阶段不扩散:A 与 C 不改任何接纳语义、不新增公开契约键;B 改的是公开门的接受集(谁能在围墙部署上建账号),一旦声明即永久义务。C 只增一条启动期诊断(落点 plugin-auth 的 kernel:ready;级别须遵守 [Decision] plugin-sharing's refused-backfill report lands at warn where AGENTS.md puts it at error — and the card that was supposed to carry the level is CLOSED #13398 类裁决——经已发布 sink 形状报告的站点不得升到 error,实施时按 warn 或按该 sink 允许的最高级别定)。

    推荐:C,现在就做(零门移动,与任何终态可组合)。 ①指向的终态是「收窄版 B」,但它是接受集变化,只能由裁决给;裁决之前 C 是唯一既不缩也不扩接受集的一步。B 仅凭裁决;若裁 B,请同时裁「窗口是否只对匹配目录地址的注册开放」这一收窄。回退项 A(什么都不做,由运维文档承担出口)。

    置信缺口(本分析看不见什么):(a) 现网是否已存在「种目录不种凭据」的非 development 部署——零拉动是脚手架读数,不是现网读数;(b) 「匹配目录地址才放行」变体与 better-auth 的 signUpEmail 流程是否可组合——未测量;(c) C 的诊断在多副本下是否每副本各喊一次——噪声上界未测。

    裁后执行段(你不用管):裁 C ⇒ 本席立 domain:services 卡派发(plugin-auth kernel:ready 诊断 + 测试,级别按 #13398 类裁决),不触公开门;裁 B(含收窄版)⇒ 接受集变化先过 Clause-② 契约复核,与 #14348(plugin-security 提升最老的人行)同批考虑,因为两者合起来才决定「谁能登录、谁是管理员」;裁 A ⇒ 本卡关 not planned,出口写进运维文档卡。


    Generated by Claude Code

  4. added
    priority:p1High: required for production / M2
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    and removed
    priority:p1High: required for production / M2
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Sep 2, 2026
  5. os-project-manager commented on Sep 2, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling recorded — A: the bootstrap carve-out keeps counting humans; no public door moves

    Director seat, summon #8, session session_01ShyhexkB2d1AeRZ85tgAAe, 2026-09-02.

    Provenance (who / verbatim / where): maintainer, live PM chat with the director seat, 2026-09-02, replying to decision batch #2 in which this card was item 1 with the recommendation 「A 现状不动(C 已在队列,自动落地);B′ 记为出现真实客户拉动时的形状,不预批」. Verbatim reply: 「14361 你不处理;14261 现在就改,定位为 minor;其他同意」 — 「其他同意」 adopts the recommendation as presented.

    Ruled: A. isBootstrapCreation() keeps answering on the human population; the admission gate and bootstrap-status stay in agreement as #14157 left them. Option B (count logins) is not taken. The narrowed shape B′ — the window opens only when no login exists AND the registering address matches a directory row that already exists — is recorded here as the shape to present again if a real deployment reports the dead end; it is not pre-approved, and it stays on the security/permission floor. Option C is already separable and queued as #14353 (boot-time diagnostic when human rows exist and no sys_account does); its message text may now say plainly that the door stays shut and the remedy is out-of-band provisioning.

    Follow-up filed in the same stroke: a documentation card for content/docs/deployment/self-hosting.mdx — the published operator instruction ("have each of those people register with exactly that address") points at the door the invite_only default shuts on this population; the page must document the path that actually works, measured, not assumed.

    State transition, same stroke: needs-user-decision cleared and the card closed not planned — the decision is recorded, the door does not move, reopen is free if pull appears. Ledger: director seat post #12708, summon #8.


    Generated by Claude Code

  6. removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p1High: required for production / M2security

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions