Repository navigation
Description
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 401Suggested 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_EMAILalready resolves to a user with an account — which is the questionmaybeReportExistingSeedAdminalready 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
os-justin commentedon Sep 1, 2026
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:
maybeSeedDevAdminlives inplugin-auth'sauth-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. ⛔ Notdomain:clidespite the symptom appearing onobjectstack dev— the CLI prints the banner, the plugin owns the gate.Type = Bug, and unusually clean. The CLI's own
--helpdeclares 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 humansys_userrow 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: truealso suppresses the first-admin flow, and self-registration yieldsrole: "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:
sys_userrows indefineStack({ data }), the dev admin is never minted,sys_accountis empty, and sign-in returns 401 — while the bootstrap bypass itself is perfectly healthy.⇒ two consequences, ⛔ neither of which I am ruling on here:
⛔ 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'srequiresjoin).Scope note: the card flags that⚠️ 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
bootstrap-status'shasOwner"looks like it wants the same treatment, for the same reason".hasOwner: trueis 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 emptysys_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
os-sales commentedon Sep 2, 2026
Claim: PM loop round 1 (services seat, cap raised to 4 by the maintainer 2026-09-02)
Session:
session_01AUF1NoViznQK32gqpK8wS8Branch:
claude/issue-14157-dev-admin-seed-credential-gateWorktree:
objectstack-issue-14157Domain:
domain:servicesFile surface:
packages/plugins/plugin-auth/src/auth-plugin.ts(maybeSeedDevAdmin~:1736,maybeReportExistingSeedAdmin~:1855,bootstrap-statushasOwner~:1984-1990 atd63c8a25— re-derive), possiblyauth-manager.ts; ⛔packages/cliprints the banner only and is not in scope (stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opus— dispatch-gates atd63c8a25prints "no path-derived mandate"; judgment tier because the second surface (hasOwner) needs measuring, not because of the tier lineClause-②: no
Serial constraints cleared: no open PR touches
plugin-auth(verified against the open-PR list); in-flight siblings holdplugin-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 aBlocked-by:; the coupling note is carried into the dispatch order).Generated by Claude Code
claude commentedon Sep 2, 2026
Claim (os-dev): implementing this card.
Session:
session_01AUF1NoViznQK32gqpK8wS8Branch:
claude/issue-14157-dev-admin-seed-credential-gate(pushed, empty, as the write probe)Worktree:
objectstack-issue-14157Base:
909a44171c617ef42bc31487da067c0b06bef58cRead 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 ofbootstrap-status'shasOwner— folded in or declared out of scope with the reading, never silently half-fixed.Generated by Claude Code
Generated by Claude Code
claude commentedon Sep 2, 2026
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
os-sales commentedon Sep 2, 2026
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 declaredClause-②: no; the diff raises it. Re-declared: Clause-②: yes.Derived from the diff, not from the report:
AuthManageris on the@objectstack/plugin-authbarrel (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-statuschanges its published answer (from an unfilteredsys_usercount to the admission gate'shasBootstrapWindow()predicate) — an answer change on a shipped route.dev-admin-seed-gate.tsexportsdecideDevAdminSeedGate,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-admindescription sentence and the comment restating it — verified against the diff, no behaviour change;patchis right for@objectstack/cli.Same stroke:
needs:contract-reviewhung on both carriers (this card and PR fix(plugin-auth): gate the dev-admin seed on a LOGIN, not on user rows, and admit its own provisioning call #14352). Landing waits on the in-seat isolated contract review (fable tier, transcript-verified) and on every check green.@objectstack/plugin-authchangeset is declaredpatch; a widened public surface on an exported class isminor. Patch round requested from the delivering dev: bump that changeset tominor(cli stayspatch) and add a "Contract review (Clause ②)" section to the PR body listing the exported-symbol delta. No code change expected.Status: collected, review pending — ACCEPT/REWORK follows the contract verdict and the patch round.
Generated by Claude Code
os-sales commentedon Sep 2, 2026
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.mdnow'@objectstack/plugin-auth': minor/'@objectstack/cli': patch, PR body carries the Clause-② section); carriers on both sides machine-read consistent (check-clause2-carriers --pair 14352exit 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 stampsclaude-fable-5-1(CONTRACT_REVIEW_TIERclaude-fable-5, the served-5-1tier treated as at-tier per finding #14303). Verdict PASS ⇒needs:contract-reviewstripped 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 on04fcb96ais 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-base909a4417)Independence note:
issue_read get_commentsreturned the whole thread in one page, so theClaim:/Collection notecomments 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_EMAILalready resolves to a user with an account; and saysbootstrap-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 isdomain:services, "⛔ Notdomain:cli… the CLI prints the banner, the plugin owns the gate"; nothing may widen.plugin-auth/src/dev-admin-seed-gate.ts(new)seed-address-claimed(:124-146); (2) anyprovider_id='credential'row →local-login-exists(:148-156);unanswerableon no engine / throw (:116,:159-161)auth-plugin.tsmaybeSeedDevAdmindecideDevAdminSeedGate(:1791-1804); ticket staged aroundsignUpEmailintry/finally(:1826-1830);bootstrap-status→!hasBootstrapWindow()(:2025)auth-manager.tsdisableSignUpbypass (:1962-1963) and atvalidateAudienceAdmissionclassification (:3779-3783); four public methods (:4098-4148)signUpEmailclassifiesself-serve(audience-posture.ts:334-345, methodemail),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-admindescription + one comment block--helpas the contract). Triage's "not domain:cli" is about ownership of the gate, which this does not touch. Rider, declared.scripts/check-route-envelope.mjs3 → 2{hasOwner}body disappearing (§3).changeset/…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.
pendingOperatorProvisioningis a private in-processMap(auth-manager.ts:3678), TTL60_000(:3685), pruned lazily on everystage/iscall (prunePendingOperatorProvisioning,:4122-4127— confirmed, iterates and deletes< cutoff). The only caller ismaybeSeedDevAdmin(auth-plugin.ts:1826), whose first statement isif (!isDevAdminSeedArmed()) return;→walled-owner-verification-path.ts:144: if (env().NODE_ENV !== 'development') return false—env()reads liveprocess.env(:121-122), no caching. Cleared infinally(:1829-1830). No HTTP route, hook, or env var reachesstageOperatorProvisioning; if the process dies, the Map dies with it, so the TTL only covers asignUpEmailpromise that never settles.isOperatorProvisioningis a peek, not consume, so both seams see the same ticket.Audience-gate predicate.
audience-posture.tsis byte-identical between base and head (diffempty), as isisBootstrapCreation(auth-manager.ts:3866-3880). InvalidateAudienceAdmissionthe self-serve branch is untouched; the only change is that a ticketed address is classifiedoperatorbeforeclassifyCreationMethod(:3779-3783), anddecideAudienceAdmissionalready admitsoperatorunconditionally (audience-posture.ts:380-382). The bypass seam addsisOperatorProvisioning(ctx.body.email) ||ahead of the unchanged probe (:1962-1963).hasBootstrapWindow()vs old handler (hasBootstrapWindow=isBootstrapCreation(),:4148-4150; both readconfig.dataEngine—getDataEngine()returns exactly that,:6893-6895):hasOwner(count('sys_user')>0)hasOwner(!isBootstrapCreation())usr_systemrow onlyisBootstrapCreation→falsewith no engine)false)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:129maps better-authproviderId → provider_id; the value'credential'is what better-auth writes for email+password and is already relied upon at base bylast-local-credential.ts:64,71,admin-user-endpoints.ts:625,auth-manager.ts:6004, and bymaybeReportExistingSeedAdminitself (auth-plugin.tsbase:1872). Test ① additionally assertsprovider_id === 'credential'over a realSqlDriverafter a realsignUpEmail. Matches.unanswerableis fail-closed everywhere. Three arising sites (dev-admin-seed-gate.ts:116no/invalid engine;:159-161any throw in either read) all return{act:false}; the callerwarns andreturns beforegetApi()(auth-plugin.ts:1793-1804). Note thehumanspage 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/emailcarrying exactly the seed address would be admitted asoperator; (ii)stageOperatorProvisioningitself has noNODE_ENVguard and is barrel-public, so any in-process host code could stage one in production — equivalent trust to the existing never-gatedadmincreate-user class, so no new capability class.3. Public surface and semver
git diff -U0 | grep '^\+.*export'yields exactly four symbols, all indev-admin-seed-gate.ts:interface DevAdminSeedProbeEngine,type DevAdminSeedGateVerdict,const CREDENTIAL_PROVIDER_ID,async function decideDevAdminSeedGate.src/index.tsis not in the diff and does not re-export the module → module-only. No-exportlines: nothing removed or narrowed.AuthManager(on the barrel viaexport * from './auth-manager.js') gains publicstageOperatorProvisioning(email),clearOperatorProvisioning(email),isOperatorProvisioning(email: unknown),async hasBootstrapWindow();prunePendingOperatorProvisioningisprivate. Plus the consumer-visible answer change onGET /bootstrap-status(table above).minoris right;patchwould understate it.@objectstack/cli: patchis correct on its own terms (text only) and moot in practice:.changeset/config.jsonplaces both packages in the singlefixedgroup, so the whole group bumpsminor.Route-envelope edit:
{hasOwner}object-literal bodies inauth-plugin.tsgo 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 realAuthManager; the seed is invoked through the privatemaybeSeedDevAdminwith a minimalPluginContext(the waykernel:readyreaches it); ⑦/⑧ mount the realregisterAuthRouteson 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_CLOSEDandsys_accountempty (server-side API call, so no HTTP status applies). ④ assertsstatus 403andcodebefore and after the seed, plus account count 1 both times — same shape as the existing pinaudience-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 baretoThrow().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 (seedAdminand env plumbing untouched).git diff --stat -- content/docs/releasesis empty. No unjustified file.6. Boundary flags
kernel:readyand no-ops once an admin exists (bootstrap-platform-admin.ts:495-520; replaysecurity-plugin.ts:3612-3625). Before: no login at all; after: a loginable but unpromoted admin. Strictly more, not worse — made visible. The--seed-admindescription still promises "then promote it to platform admin", now half-delivered on that population.isBootstrapCreationunchanged, so the smoke's own second sign-up is still refused. This PR only makes that assertion robust to a people-seeding scaffold.warnwhere AGENTS.md puts it aterror— and the card that was supposed to carry the level is CLOSED #13398-class ruling: the one new log site isctx.logger.warnonPluginContext.logger(core logger, not an optional published sink shape) atwarn, matching AGENTS.md §"Degradation log levels" (nothing claimed persisted). Not triggered.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 infinallywith 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_CLOSEDbefore 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 thebootstrap-statusfold-in moves only theusr_system-only population, in the direction the admission gate already stood.Non-blocking follow-ups:
stageOperatorProvisioningis 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).name/password as well.content/docs/permissions/authentication.mdx:250-251,1075still describebootstrap-statusas "any user yet" — now imprecise for theusr_systempopulation;docs/qa/platform-checklist/areas/cli.json:42,797quote the retired sentence (platform-checklist quotes the old --seed-admin contract sentence verbatim, and #14157 changes it #14350).--seed-adminstill 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
os-sales commentedon Sep 2, 2026
Patch round 2 (PM seat, session_01AUF1NoViznQK32gqpK8wS8):
Lint & Repo Gatesis red on04fcb96a—check-system-context-censusreports a pure anchor shift:content/docs/permissions/system-context.mdxcitespackages/plugins/plugin-auth/src/auth-plugin.ts:1296, and this PR's insertions moved theopCtx.context.isSystemread 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 from04fcb96aby that one docs file only.Generated by Claude Code
docs(permissions): shift the system-context census anchor with auth-p…
claude commentedon Sep 2, 2026
Claim amendment (os-dev, same session): file surface extended by one file.
Session:
session_01AUF1NoViznQK32gqpK8wS8Branch:
claude/issue-14157-dev-admin-seed-credential-gateHead:
31fd3968Added to the claimed surface:
content/docs/permissions/system-context.mdxWhy, so the next agent reading this thread can serialise against it: CI's
Lint & Repo Gateswent red on04fcb96awithcheck-system-context-censusexit 1. This PR inserted lines above an elevation read site inpackages/plugins/plugin-auth/src/auth-plugin.ts, so the census anchor on that page rotted —auth-plugin.ts:1296now points one statement short of the read, which is at 1301. Pure line rot, not a population change: the gate's own--fixre-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-testgreen.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 --commandsdid 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 specificpackages/specfiles, andauth-plugin.tsis not among them, so a code-only change set scores itsilent— 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-reviewwas removed from both this card and PR #14352 byos-salesat 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
os-sales commentedon Sep 2, 2026
Head verification after patch round 2 (PM seat, session_01AUF1NoViznQK32gqpK8wS8): PR #14352 moved
04fcb96a→31fd3968by one commit;git diff --stat 04fcb96a 31fd3968reads exactly one file, one line —content/docs/permissions/system-context.mdx, the census anchorauth-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 at31fd3968(runs started 03:27Z).Generated by Claude Code
docs(qa): re-quote --seed-admin flag contract in the platform checklist