Repository navigation
feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only - #11767
Conversation
…mail_domain|open, default invite_only Closed vocabulary + runtime value list + type guard (MembershipPolicy precedent), completeness predicates in the schema (email_domain requires non-empty domains; self-registration-permitting postures require the declared permission set; inert declarations refused per ADR-0078), two registered error codes (SELF_REGISTRATION_CLOSED, EMAIL_DOMAIN_NOT_ALLOWED) and the audiencePosture non-flag public-config key. Artifacts regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssion seam One decision owner (decideAudienceAdmission) wired at user.validateUserInfo for every creation path, plus the sign-up before-hook raise that the vendor's anti-enumeration shield forces (a 403 from the creation seam is converted to a synthetic 200 when requireEmailVerification is on — measured on dist/api/routes/sign-up.mjs:163,235). Pending invitations admit under every posture; enterprise-IdP JIT is provider-class; self-registrants receive the DECLARED permission set via sys_user_permission_set or are not admitted at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nvite_only default New audience-posture suite (pure decision matrix, entry refusals, wire outcomes incl. the landed permission-set grant). Existing harness suites create their non-first fixture users through the invitation carve-out (audience-gate-test-support) — the real admission lane under the new default, chosen over an open posture because open forces email verification and would stop sign-up from minting the sessions these suites exercise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ture default flip Registered audience-posture-default-invite-only (the rest-requireauth-default-flip class: a secure-default flip no transform can decide); pinned ledger regenerated for the new engine double. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Discharges the os-regen deferral recorded by the merge commit. The two driver-deferred artifacts (content/docs/references/index.mdx and the strictness-ledger counts) are regenerated from the merged sources; the remaining eight os-regen paths were reset to origin/main and regenerated back to byte-identical content, which is the evidence the textual merge dropped nothing.
📓 Docs Drift CheckThis PR changes 3 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 719eefecc9034bc58f47c489a1341ed5062f2cec && git checkout 719eefecc9034bc58f47c489a1341ed5062f2cec
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3689991d2e67f80faacc6c38233e83593a22202c 02580fba14f6ed85b8fa509c7d16472ef46ab111 && git checkout -B drift-repro 3689991d2e67f80faacc6c38233e83593a22202c && git merge --no-ff 02580fba14f6ed85b8fa509c7d16472ef46ab111
node scripts/docs-audit/affected-docs.mjs --json 3689991d2e67f80faacc6c38233e83593a22202c
|
Synced onto current
|
| head | reading |
|---|---|
38e5fea48 (old head, your measurement) |
total_count: 0 — still 0 |
23af75116 (new head) |
total_count: 29 |
PR #11929 head 96c7644f4 (control) |
total_count: 37 |
So the instrument was never broken and the drift was the whole story: the same call that
read 0 on the stale head reads 29 on the synced one.
⚠️ What the first-ever run found — one root cause, four reds
Not a merge artifact, and not something this sync introduced: it is the PR's own default
flip meeting the composed runtime for the first time.
INFO [audience] self-registration refused by posture
{"code":"SELF_REGISTRATION_CLOSED","method":"email-password","posture":"invite_only"}
WARN [auth] dev admin seed skipped: Self-registration is closed on this environment
(audience posture invite_only). Ask an administrator for an invitation.
...
Error: verify signIn failed: 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}
The @objectstack/verify harness seeds its admin through self-registration, which the
new invite_only default now refuses; every downstream signIn then 401s. It cascades to:
Dogfood Regression Gate(1/3, 2/3, 3/3) — requiredTest Core (2/6)(@objectstack/verify's own suite) — requiredDogfood Verify CLI— advisory
Build Core, Temporal Conformance (live PG + MySQL) and Type Check · source gates are
green; Lint & Repo Gates and the remaining shards were still running at the time of
writing.
Both the harness seed path and Dogfood Verify CLI are green on origin/main (0e5bea612)
and on control PR #11929 in the same window, so the signal is this diff's, not the base's.
Fixing it means changing behaviour on an already-ACCEPTed diff, which is outside this
sync's remit — flagging it for the owning seat rather than touching it. The shape of the
remedy is a decision, not a mechanical fix: either the dev/CI admin seed is exempted from
the posture at its own seam, or the harness declares audience.posture explicitly.
Local verification on the synced head
pnpm --filter @objectstack/plugin-auth test—Test Files 75 passed (75)·Tests 1561 passed (1561)pnpm --filter @objectstack/spec test—Test Files 420 passed | 4 skipped (424)·Tests 11263 passed | 22 skipped (11285)pnpm --filter @objectstack/spec check:generated—✓ All 14 generated artifacts are up to date.pnpm --filter @objectstack/spec typecheck/pnpm --filter @objectstack/plugin-auth typecheck— both cleannode scripts/check-changeset-no-major.mjs—✓ This diff introduces no major bump.node scripts/check-adr-0087-registration.mjs—✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.pnpm check:merge-driver,check:engine-double-contract,check:error-code-casing,check:nul-bytes,check-empty-changeset— all exit 0
The unit suites pass precisely because they exercise the gate in isolation; the breakage
lives at the composed-runtime seam, which is the half only CI boots.
Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh could not
take the shared verify lock on this host: no usable flock. The shared verify lock is
declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the
commands above ran directly, without the lock — a declared narrowing, not a silent one. No
serialization guarantee held for those runs.
Generated by Claude Code
…s seam The #11739 bootstrap bypass never fired on a real deployment. Its probe was `ctx.context.adapter.findOne({ model: 'user', where: [] })`; `where: []` lowers to an empty filter and the real ObjectQL engine's `requireFindOnePredicate` (#4419) REFUSES a findOne that selects no particular record. The surrounding `catch { return false; }` read that refusal as "users exist", so every bootstrap creation was judged non-bootstrap and the new `invite_only` default refused the operator's own first account — including the dev-admin seed's. Fix, without touching the posture's ruled semantics or its fail-closed floor: - `isBootstrapCreation` asks the question in a form that can be ANSWERED — a bounded `find` through `withSystemReadContext`, the same ctx-independent data path `hasPendingInvitationFor` uses one frame away. An unanswerable probe still reads as NOT bootstrap. - The population predicate is HUMANS, not rows: `isHumanUserRow` is now the one owner, shared by the gate and by the dev-admin seed's own precondition, which had spelled the same filter by hand. They must agree — a DB still carrying the legacy `usr_system` row is exactly where they would have diverged, with plugin-security announcing "no human users yet" while the gate refused that very sign-up. - The sibling `disableSignUp` bootstrap bypass carried the identical dead probe and now routes through the same helper. Pinned by `audience-bootstrap-seam.test.ts` over a REAL ObjectQL engine, and through the server-side `api.signUpEmail` lane the dev seed actually uses — the two things no existing suite exercised. Case 0 asserts the #4419 refusal itself, so the reason a fake engine cannot pin this is under test rather than asserted in prose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#11739 made `invite_only` the default audience posture. The verify harness's `signUp` helper exists to mint the SECOND, THIRD… fixture identity — the very population the wall now closes — so every fixture that needs a plain member died with 403 SELF_REGISTRATION_CLOSED once the dev-admin seed was unblocked. Seed a pending `sys_invitation` for the address first, which is the same lane plugin-auth's own suites take (`audience-gate-test-support.ts`) and for the same two reasons: `open`/`email_domain` would force email verification on and stop sign-up minting the token this helper returns, and the invitation lane keeps the audience gate honestly ON the path — the carve-out is a real admission verdict, not a bypass. Best-effort: an unreachable engine just signs up and lets the gate answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verify harness lane Two facts the audience-posture changeset did not carry: the bootstrap bypass counts non-system HUMANS rather than sys_user rows (so a legacy usr_system row is still a fresh install), and @objectstack/verify's stack.signUp now enters through the invitation carve-out — which a consumer counting their own pending invitations needs to know about. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, not truthiness The double read the caller's bound as `if (q.limit)`, so `limit: 0` -- a request for NOTHING -- returned every matched row. Arrived with #11767 after this branch's ledger was measured; fixed rather than seated, since it is one line in one file. Ledger unchanged: 253 files, 168 blind, 32 wrong, 55 unjudged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
…mit`, or refuse it loudly (objectstack-ai#12030) * wip: objectql double-limit gate (rescued uncommitted work) Rescue commit of in-place worktree contents after a container restart. Verification has NOT been re-established at this point; a later commit carries the verified state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx * fix(tooling): seat an ObjectQL find double only on positive control evidence The control probe granted a seat whenever it had not been disproven, rather than when it had been proven: `allCarry` answers `null` when NO returned row carries the probe's field, and `null !== false`. So a double answering both control probes with rows of its OWN -- constant stubs, and schema-signature fixtures that exist only to satisfy a parse() -- was seated and then graded limit-blind. That is debt with no possible remedy: there is no corpus to bound, and a shrink-only ledger carrying unfixable entries can never burn down. 19 of 294 seated candidates were in that state. They now take the same fallthrough every unseated candidate takes: 15 drop OUT OF SCOPE (the body never filters), 4 are DECLARED unjudged (it does, but the lift cannot drive it). Both directions are pinned by new --self-test fixtures. Baseline re-derived from the corrected measurement: 263 files / 186 blind / 50 unjudged -> 251 files / 167 blind / 54 unjudged. Shape breakers are unchanged at 32, and the header's SHAPED invariant -- which claimed no shape violation was grandfathered while 32 were -- now describes what the ledger holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx * fix(tooling): record the bare-root verdict for the ObjectQL double-limit gate REFUSE-UNSPELLABLE: the gate walks packages/ but admits *.test.ts only (2696 of 5161, 52%). packages/** is the only spellable claim and covers 2465 non-test files the gate never opens; every narrower glob collapses to packages//.test.ts and covers 0 of 2696. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx * fix(tooling): re-measure the double-limit ledger against the merged tree The initial measurement was taken at merge base 311e207. Merging 42 commits of main moved the corpus underneath it: three new test files arrived carrying find doubles, and main's objectstack-ai#11962 independently taught the bootstrap-declared-capabilities double to honour the bound. Re-measured: 251 -> 253 files, 167 -> 168 blind, 54 -> 55 unjudged, wrong unchanged at 32. Ratchet DOWN applied, three arrivals seated as pre-existing debt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx * test(plugin-auth): bound the audience-posture find double by presence, not truthiness The double read the caller's bound as `if (q.limit)`, so `limit: 0` -- a request for NOTHING -- returned every matched row. Arrived with objectstack-ai#11767 after this branch's ledger was measured; fixed rather than seated, since it is one line in one file. Ledger unchanged: 253 files, 168 blind, 32 wrong, 55 unjudged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx --------- Co-authored-by: Claude <noreply@anthropic.com>
…er (objectstack-ai#12029) * fix(auth): authorize before the break-glass guard on /admin/remove-user The break-glass last-local-credential guard is a global better-auth `hooks.before`, which runs ahead of an endpoint's own middleware. On /admin/remove-user — served directly by better-auth's router, whose adminMiddleware establishes only a session — the guard's lookup and its target-dependent refusal were reached by any authenticated caller before either authorization layer had run. /admin/ban-user already ran the same guard AFTER authorization, because objectstack-ai#9652 shades that path with an ObjectStack raw mount whose platform-admin gate fires first. One guard, two routes, opposite orders, nothing asserting either. /admin/remove-user now carries the same shading, converging the /admin/* family on authorization before the guard. The mount reuses the landed objectstack-ai#9652 / objectstack-ai#9653 gate-then-delegate pattern; no new mechanism. It DELEGATES rather than re-implementing removal, so the request re-enters better-auth's router: the path-keyed hook still fires and the guard still decides, just after authorization. A platform admin is unaffected in every respect, including the vendor's own admission decision (objectstack-ai#9969). An ordering pin ships with the fix: one authenticated non-admin naming two different targets must receive indistinguishable responses, and — so the pin cannot be satisfied by deleting the guard — an admitted platform admin must still hit the guard's refusal and still succeed on an ordinary user. Part of the /admin/* ordering convergence; dogfood bucket for the route reclassified to `shaded-vendor-gate` (ObjectStack gate answers the refusal, better-auth still owns admission). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 * test(verify): expect the gate's verdict for a plain member on /admin/remove-user The objectstack-ai#10792 pin at erasure-transaction-authorization.test.ts asserted the vendor's YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS for a signed-in plain member — a literal from the pre-objectstack-ai#11477 surface, where better-auth's router served the route unshaded. Ruled option A on objectstack-ai#11477 shades /admin/remove-user with the objectstack-ai#9652 pattern (gateAdmin before the break-glass guard), so through the real mount chain the member now hears the ObjectStack gate's target-independent 403 PERMISSION_DENIED. The pin's intent is unchanged and its other legs are untouched: the member still hears an authorization refusal (403, never 401), the answer still arrives fast on the pool max=1 dialect, and a refusal still erases nothing. Only the code literal moves to the converged verdict. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa * test(auth): stage the objectstack-ai#11477 ordering-pin fixture through the invitation carve-out Main moved under the branch: objectstack-ai#11767 made the platform's default audience posture invite_only, so the pin's bare-AuthManager fixture had every self-serve sign-up past the first refused with 403 SELF_REGISTRATION_CLOSED during staging — all 7 pin tests died before reaching their assertions. The fixture now seeds a pending invitation per staged user via inviteForAudienceGate, the house lane the neighbouring green fixtures already use (an `open` posture would force email verification on and stop sign-up from minting the bearers the authenticated legs need). No assertion changed; the pin still asserts exactly what it asserted. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQgPSniH1GFM9ZDeGyuGUa --------- Co-authored-by: Claude <noreply@anthropic.com>
The packed-tarball smoke asserted that the first POST /auth/sign-up/email after `objectstack dev --fresh` returns 200 — an expectation authored before #11739/#11767 made `invite_only` the default audience posture. It is not the first account that reaches that probe. `--fresh` seeds a dev admin in-process at boot through the real signUpEmail pipeline, and THAT creation is the zero-user one the bootstrap bypass admits. The smoke's own sign-up is the SECOND self-serve account, which invite_only refuses with SELF_REGISTRATION_CLOSED + 403 — correctly. The release candidate had therefore never once passed this gate with the posture default aboard. Per the maintainer-approved ruling (option A, director batch #23): - assert the seeded admin can SIGN IN, which is the packed install's proof that the bootstrap bypass really fired; - assert the uninvited second self-serve sign-up is REFUSED, pinning both the 403 and the envelope code so an accidental widening of the unauthenticated surface reds the RC instead of shipping; - obtain the non-admin probe user through the operator path (the invitation carve-out) before the existing session/CRUD probes. Behaviour only — no vendor-internal symbol is pinned, and no auth or audience runtime code is touched. The posture is READ from the artifact's own public /auth/config rather than hard-coded, because the two smoke modes install different products: `registry` smokes the last PUBLISHED release, and plugin-auth 17.2.0 (2026-08-23) predates #11767 (2026-08-25). In `pack` mode the advertised posture must BE invite_only; in both modes enforcement must match what the artifact advertises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
…rness text state each case in words instead of a tracker number (stage 5b) (objectstack-ai#21139) Fixes objectstack-ai#20513 Clause-②: no **Stage 5b, the last stage of this lane under the maintainer's A / A ruling on the card (5902360492): the `src/`-shipped test strings.** These are the 28 literals (32 occurrences in 6 files) that PR objectstack-ai#20924's body inventoried as stage 5b and release 5922539397 restates: shared case labels, a suite title, two contract `why` texts and the mongod test-harness text. After this PR the `domain:engine` packages hold zero rows in `scripts/doc-authoring-prose-id.baseline.json`. Text only: no error `code`, export, HTTP status, case value or control flow moves. ## What this does Each label or harness line sent the reader to a tracker number for the reason behind it. In form D, as stages 1 to 5a applied it, the number goes. Where the sentence already stated the case, only the citation goes (17 literals). Where the sentence leaned on the number, it now says the case in words (11 literals): | Where | Cited | The text now says | |---|---|---| | `driver-mongodb` `test-mongod.ts` skip reason | 5517 | "retired from default test runs because concurrent downloads made green runs exit 1" | | `driver-mongodb` `test-mongod.ts` abandoned-download warning | 5517 | "Ignoring the MongoDB binary download abandoned by a concurrent-download race" | | `ENGINE_DELETE_DISPATCH_CASES`: where.id plus a CAS operator, `multi: false` | 11009 | "refused, the by-id path would drop the CAS guard" | | `ENGINE_FINDONE_PREDICATE_CASES`: an empty `where` object | 3896 | "read as match-every-row, so NOT a predicate" | | `ENGINE_UPDATE_DISPATCH_CASES`: payload id beside a DIFFERENT scalar where.id | 11142, 5748 | "refused; the payload id no longer silently wins over a where.id naming another row" | | same table: payload id beside an `$in` where.id, no multi | 11230 | "refused; the by-id write would silently drop the declared row SET" | | same table: payload id beside a NULL where.id | 11230 | "refused; a declared where.id that is not one primary key is never silently dropped" | | same table: payload id beside a FALSY scalar where.id | 11142, 11230 | "(it is a scalar, so neither the different-row nor the non-scalar refusal applies)" | | same table: where.id plus a CAS operator, `multi: false` | 11009 | "the redeliver shape, refused rather than dropping its CAS guard" | | same table: payload id plus an extra where predicate, no multi | 11009 | "refused; the by-id write would silently drop the predicate, through the payload door" | | `OBJECT_SCHEMA_MASK_CASES` `write-capable-caller/exempt` `why` | 7020, 6603 | "D4 is DERIVED from the schema write gate (`manage_metadata`)" | Citation only: the contract suite's `serialized-form identity` group title; 3 delete, 2 findOne and 9 update labels; the empty-readable-set `why`; the mongod acquisition-timeout message. Each new clause was read against today's code: `resolveEngineUpdateDispatch` (the different-row refusal fires only on a truthy scalar where.id, the non-scalar refusal only on a declared non-scalar), `ObjectQL.requireFindOnePredicate` (absent, null and an empty object are read as match-every-row), and `OBJECT_SCHEMA_WRITE_CAPABILITIES` (`manage_metadata`, the key the D4 exemption is derived from). The `test-mongod.ts` docblock "Names the issue and the switch" now says "Names the reason and the switch", so it stays true. No other comment or docblock moves; code comments still cite ids, as in every earlier stage. **Re-pins (H4).** The two `objectstack-ai#5517` number pins in `mongodb-memory-server-gate.test.ts` now assert the text that replaced the number: the skip line contains "retired from default test runs because concurrent downloads made green runs exit 1", and the guard's warning contains "download abandoned by a concurrent-download race". Neither pin was deleted. Both were proven able to fail (below). ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Recomputed with `node scripts/check-doc-authoring.mjs --census-ledger` (exit 0: no growth refusal) and copied into place. The diff is 25 deleted lines, nothing added. | File | Before | After | |---|---|---| | `drivers/driver-mongodb/src/test-mongod.ts` | 3 | 0 | | `metadata-core/src/contract-suite.ts` | 1 | 0 | | `metadata-core/src/engine-delete-dispatch.ts` | 4 | 0 | | `metadata-core/src/engine-findone-predicate.ts` | 3 | 0 | | `metadata-core/src/engine-update-dispatch.ts` | 18 | 0 | | `metadata-core/src/object-schema-fls-contract.ts` | 3 | 0 | | **This stage** | **32 in 6 files** | **0** | Whole ledger: files 175 → 169, pairs 441 → 428, occurrences 653 → 621. Gate pinned sites 549 → 521. No other file's row moves. After each of the four merges of `origin/main` (below) the recomputed ledger was byte-identical to the committed one. ## Hypotheses, measured - **H1, held.** At `origin/main` `9c8b65aa2` the six files held exactly the inventory: `objectstack-ai#5517` ×3; `objectstack-ai#7856` ×1; `objectstack-ai#11009` ×4 (delete); `objectstack-ai#11767`, `objectstack-ai#3896`, `objectstack-ai#4346` (findOne); `objectstack-ai#11009` ×6, `objectstack-ai#11142` ×4, `objectstack-ai#11230` ×4, `objectstack-ai#5748` ×4 (update); `objectstack-ai#3545`, `objectstack-ai#6603`, `objectstack-ai#7020` (FLS). The gate's census listed 28 literals carrying 32 occurrences. - **H2, held.** The recomputed ledger has 0 rows under `packages/objectql`, `core`, `formula`, `metadata*`, `platform-objects`, `drivers/driver-*` and `plugins/plugin-pinyin-search` (the `domain:engine` path set). The lane's ledger is empty. The whole ledger is not, so the gate's stale-baseline arm keeps its blindness floor. - **H3, nothing keys on the old labels.** Each of the 28 old literals was searched byte for byte across the repo (`dist` excluded). Each was found only in this stage's own source files (two delete labels have update twins), with two exceptions. The tail `download — objectstack-ai#5517)` also sits in a comment in `mongodb-pipeline-evaluator.testkit.ts`. `serialized-form identity (objectstack-ai#7856)` also titles an independent `describe` in `metadata-core/test/canonicalize.test.ts`, a test file outside the ledger. Both are left alone. No CI workflow, `package.json` script, vitest config, snapshot or skip list carries the labels or a `-t` filter on them. The cited numbers appear elsewhere only in workflow comments, an ADR anchor's invariant prose and a merge-parent sha. The labels reach other code only as test names: objectql's `engine-*-dispatch` / `engine-findone-predicate` parity loops, `service-queue`'s `it.each`, and `assertObjectSchemaMaskCase`'s failure text. Each label stays unique within its table (35 / 19 / 18). - **H4, held.** See Re-pins. ## Changeset `.changeset/20513-metadata-core-case-labels-state-the-case.md`: `patch` for `@objectstack/metadata-core`. Its case tables ship in `dist/index.*`, and the contract suite and FLS table ship in `dist/testing.js` (measured after build: the new labels found in those files). There is **no `@objectstack/driver-mongodb` entry**, because `test-mongod.ts` is not published. The package builds `src/index.ts` only, and only test files import `test-mongod.ts`. Measured after build: `mongodSkipReason`, "retired from default test runs", "concurrent-download race" and "waiting for the MongoDB binary" are each in 0 files under `packages/drivers/driver-mongodb/dist`. The positive control `MongoDBDriver` is in 4. ## Text-only proof A TypeScript-AST skeleton of each changed `.ts` file compares merge base `670680e93` with head `24b72b809`. In the skeleton, every string literal and template text is one placeholder, pure-literal `+` operands collapse, and comments are never read. Result: 7 of 7 SAME, with token and literal-slot counts identical per file. Control: the same tool reports DIFF on `rest` `import-template.ts` across `6f1f1c103`, a real code change. ## Tests (head `24b72b809` unless noted) - Build: `turbo run build` over `./packages/*` and `./packages/*/*` under the verify lock, 71/71, after the last merge. - `@objectstack/metadata-core`: 16 files / 298 tests passed; `typecheck` exit 0. - `@objectstack/driver-mongodb`: 30 files passed and 5 skipped, 675 tests passed and 172 skipped (the skips need a MongoDB server); `typecheck` exit 0, including `check:test-typecheck`. - Consumers of the case tables, against the rebuilt `metadata-core` `dist`: - `objectql`, 7 files / 181 tests: `engine-delete-dispatch`, `engine-update-dispatch`, `engine-findone-predicate`, `engine-update-by-id-payload-id`, `engine-update-multi-payload-id`, `engine-write-not-found-gate` and `validation/operator-object-write-value`; - `service-queue` `db-queue-adapter`, 38; - `rest` `meta-object-fls`, 95; - `runtime` `domains/meta-object-fls`, 85; - `metadata-fs` `contract`, 31; - `metadata-protocol` `sys-metadata-repository.contract` and `protocol.update-path-id-wins`, 64. - A verbose run before the merges showed the parity loops naming the new labels. objectql's tests resolve `metadata-core` through `dist`, so this shows they read the rebuilt build. - Re-pin mutations, one-off, on the committed head (`2729901db`), through `scripts/ablation-replace.mjs` in WRAP mode (restore armed on EXIT/INT/TERM): - Skip-reason anchor ×1 → ×0: `mongodb-memory-server-gate.test.ts` 1 failed / 7 passed. - Warning anchor ×1 → ×0: 1 failed / 7 passed. - Each restore: blob equals HEAD's (`22a95643c8`), and `git diff HEAD` is empty. After both, `git diff HEAD` was 0 bytes and `git status --porcelain` 0 lines. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) at `24b72b809` derived 69 commands. All 69 ran one at a time from the worktree, each exit 0. `--ran` reports "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN". Among them: - `check:doc-authoring`: 521 pinned sites across 169 files, no growth, no burn-down unrecorded; - `check:driver-conformance`: 50 covered cells, 0 DEBT, 0 exempt, the same reading as before the change; - `check:engine-double-contract`, `check:dual-build-cjs-loads` (105 require entry points across 66 packages load, read from the post-merge build), `check:nul-bytes`, `check:type-check-coverage` and `check:type-check-debt`. After the first merge (`packages/spec` moved on main): `pnpm --filter @objectstack/spec check:generated` reported all 15 artifacts up to date. Narrowed lint: `eslint --no-inline-config --format json` over the 7 changed `.ts` files reported 7 files, 0 errors and 0 warnings, none ignored (counts from eslint's JSON). The resolved `parserOptions` are `ecmaVersion: latest, sourceType: module`, with no `project` or `projectService`. So no type-aware rule runs, and this diff cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. NOT MEASURED locally (CI's): the live MongoDB cells of `driver-mongodb`, the Test Core shards, Dogfood, and the workspace type-check lanes. ## Acceptance notes - `origin/main` was merged four times while this was open (`1d55ee66e`, `4f1c99822`, `4516a0643`, `24b72b809`; the third brings in objectstack-ai#21097). None of the incoming commits touches a file this PR changes. They touch `objectql`, `rest`, `runtime` and `metadata-protocol`, so those consumer suites were re-run at the final head. `main` moved again after the gate union; the queue rebuilds on it. - PR objectstack-ai#21105, in `driver-mongodb`'s filter and temporal suites, landed before the first merge and shares no file with this PR. - `metadata-core/test/canonicalize.test.ts` titles its own `describe` `serialized-form identity (objectstack-ai#7856)`, and `mongodb-memory-server-gate.test.ts` titles two `describe` blocks `[objectstack-ai#5517] …`. Both are `*.test.ts` files, which the ruling's third category (`src/`-shipped test strings) and the ledger do not cover. They are left as they are: noted, not filed. - The lane children objectstack-ai#20749, objectstack-ai#20751 and objectstack-ai#20753 carry `Blocked-by:` lines naming this card. They unlock when it leaves the open state on merge. --- _Generated by [Claude Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #11739
Part of #11723
One declared audience posture answers "who may become a user of an environment's apps": a closed vocabulary
invite_only | email_domain | open, defaultinvite_onlywhen undeclared (maintainer ruling on the epic, 2026-08-24 — no legacy/undeclared limbo), one decision owner on the creation path, and every invariant enforced where it is declared. Ships as aminorchangeset per the launch-window rule (check:changeset-no-major), with the ADR-0087 semantic migrationaudience-posture-default-invite-onlyregistered (therest-requireauth-default-flipclass: a secure-default flip no transform can decide).Contract published for the blocked cloud consumer (objectstack-ai/cloud#1649)
audienceonAuthConfig(packages/spec/src/system/auth-config.zod.ts, exported from@objectstack/spec/systemasAudienceConfigSchema/AudienceConfig):audience.posture— enum, defaultinvite_only.audience.allowedEmailDomains— string array; required non-empty foremail_domain, refused under any other posture (ADR-0078 inert-declaration refusal). Bare hostnames only, at least one dot, no wildcard.audience.selfRegistrationPermissionSet—sys_permission_setname; required whenever the posture permits self-registration (email_domain/open), refused underinvite_only, andadmin_full_accessis always refused.system/AuthConfig:audienceand thesystem/AudienceConfig:*keys, with theinvite_onlydefault recorded inauthorable-defaults.invite_only,email_domain,open; runtime value listAUDIENCE_POSTURESplus guardsisAudiencePosture()andaudiencePermitsSelfRegistration()(all exported from@objectstack/spec/system).@objectstack/plugin-authentry) — all surfaced as HTTP 403 with the code in the body; browser OAuth flows redirect to the error URL carrying the same code:SELF_REGISTRATION_CLOSED— a valid posture closed self-registration (no pending invitation for the address).EMAIL_DOMAIN_NOT_ALLOWED— postureemail_domain, address's domain off the allowlist.AUTH_CONFIG_ERROR(pre-registered) — the audience configuration itself is unusable at admission time (off-vocabulary posture smuggled past entry, or the declared permission set cannot be resolved). Deliberately distinct from "policy said no" (the finding(auth): reconcileMembership 与 backfillMemberships 对「词表外的 policy」判断相反 —— 一个 fail-open,一个 fail-safe #5205 lesson).AuthManager.getAudience()is the ONE live read (thegetMembershipPolicy()pattern: the enforcement gate, the forced-verification wiring and the public config all read it). Cloud writes the posture by passingaudiencein the AuthPlugin/AuthManager config at kernel construction (AuthManagerOptions extends Partial of AuthConfig, so the key already flows), and may switch it live viaAuthManager.applyConfigPatch({ audience: ... })— the patch replaces the wholeaudienceobject and is validated as a merged result (an invalid patch throws and the standing config keeps ruling). Effective posture is observable per env onGET /api/v1/auth/configasfeatures.audiencePosture(registered besidetenancyPostureinPUBLIC_AUTH_CONFIG_NON_FLAG_KEYS).invite_only, backfilling existing envs is semantically a no-op at the framework layer; writing an explicitaudience: { posture: 'invite_only' }per env is still worthwhile for auditability, and the switch surface should write the full object (posture + domains + permission set) in one stroke.What enforcement looks like
decideAudienceAdmission()inpackages/plugins/plugin-auth/src/audience-posture.ts(pure — the test matrix drives it as a table). Wired at better-auth 1.7.1's own admission seam,user.validateUserInfo, whichinternalAdapter.createUserinvokes for every creation path with a method-discriminated source (measured against the installed dist:email-password,oauth,sso-oidc/sso-saml,magic-link,email-otp,phone-number,anonymous,admin,scim), failing closed by vendor construction.adminandscimare operator class — never posture-gated;sso-oidc/sso-samlandoauthwhose providerId is a configuredoidcProvidersentry (enterprise SSO, incl. the cloud platform IdP whose OP side already enforces app-assignment per D5.1) are provider class — never posture-gated, because registering an IdP is a platform-admin-only operator act (finding: the direct /sso/register endpoint's ADR-0024 before-hook admits org owners/admins — wider than the platform-admin posture #9653 landed on the /admin/sso/* bridges #10009) and the closed vocabulary could not otherwise express the standard enterprise combo "self-registration closed + IdP JIT on" (gating it would brick every SSO-enforced deployment's entire user base). Social-provider OAuth (socialProviders) is self-serve — a public IdP account is not an operator act — as is every unrecognized method (fail closed).sys_invitationfor the address admits its self-serve sign-up under every posture (and trumps the domain list — an invited external contractor must not dead-end). Without it,invite_onlywould kill the invitation surface for brand-new users, since better-auth's accept-invitation requires an existing account.disableSignUpbypass — a fresh install must never lock its operator out)./sign-up/emailbefore-hook because the vendor's anti-enumeration shield (shouldReturnGenericDuplicateResponse,dist/api/routes/sign-up.mjslines 163/235) converts any 403 from the creation seam into a synthetic 200 wheneverrequireEmailVerificationis on — measured: with postureopen/email_domainthe refusal came back200 token nullwith a synthetic user. An audience refusal is deterministic per (posture, domain, invitation) and reveals nothing about any account, so surfacing it is not an enumeration oracle. ThevalidateUserInfogate stays wired for that route too; the decision is idempotent, so the two asks cannot disagree.requireEmailVerificationon (explicitfalsebeside it is refused at config entry — constructor ANDapplyConfigPatch;getPublicConfig()mirrors the forced value so the advertised flag cannot disagree with the wired one);email_domainrequires a non-empty, well-formed, duplicate-free domain list; the declared self-registrant permission set must resolve to an activesys_permission_setrow at admission time — a dangling declaration refuses admission (AUTH_CONFIG_ERROR) rather than admitting an ungranted user, and an admitted self-registrant really receives the set (sys_user_permission_setrow, org-scoped to the same target org the membership reconciler resolves; a failure to land it is reported at durability level with consequence + fix).Breaking behaviour and its migration
FROM: undeclared config meant open email/password self-registration with no verification, self-registrants implicitly on
member_default. TO: undeclared ISinvite_only. One-line fix for deployments that mean to stay open is in the changeset and in the registered ADR-0087 entry (declareaudience.posture: 'open'plusselfRegistrationPermissionSet). Operator lanes (admin create/import, SCIM, enterprise IdP JIT, invitations) are unaffected under every posture. In-repo fixture fallout was triaged accordingly: harness suites now create non-first fixture users through the invitation carve-out (audience-gate-test-support.ts) — the real admission lane, chosen over anopenfixture posture becauseopenforces verification and would stop sign-up from minting the sessions those suites exercise.File surface — declared expansions beyond the card's named globs
The card named
packages/spec/src/system/**(+ authorable-surface manifests),packages/plugins/plugin-auth/src/**,packages/platform-objects/src/**(unused — the permission-set predicate landed in plugin-auth, where the enforcement point lives). Also touched, each mechanically forced and declared here rather than silent:packages/spec/src/api/error-code-ledger.zod.ts— the card requires the two codes REGISTERED; the ledger is the registration.packages/spec/src/kernel/public-auth-features.ts—audiencePosturenon-flag booking; the dispatch prompt names this file (the fix(platform-objects,spec): surface the email-invite entry on the org page default Members tab #11562 motion).packages/spec/src/migrations/entries/semantic/18.audience-posture-default-invite-only.ts+ regeneratedregistry.ts— the ADR-0087 disposition the declared-breaking changeset must carry.packages/spec/{api-surface,authorable-surface,authorable-defaults,export-origins,json-schema.manifest}/system.json,content/docs/references/**,docs/audits/2026-07-unknown-key-strictness-ledger.counts.md,scripts/engine-double-contract.pinned.json(coverage ledger,--writeafter the new pinned double)..changeset/audience-posture-invite-only-default.md.Verification (all at the head this PR points at,
38e5fea48; tree clean)Every heavy command ran through
scripts/pm/os-verify-lock.sh, which printed its lock-unusable verdict on this macOS host (no util-linuxflock): declared narrowing — verification ran UNLOCKED, no serialization guarantee held. Verdict lines quoted from the runs:pnpm --filter @objectstack/plugin-auth test—Test Files 75 passed (75)/Tests 1559 passed (1559); union re-run at38e5fea48endedVERDICT command-exit 0 · UNLOCKED (declared).pnpm --filter @objectstack/spec typecheck+ targeted spec suites (src/system src/api src/kernel src/migrations+ the type-alias pin) —Test Files 128 passed (128)/Tests 3597 passed (3597).pnpm --filter @objectstack/spec check:generated—✓all 14 artifact gates (api-surface, authorable-surface, docs, spec-changes, upgrade-guide, migration-registry, strictness-ledger included).pnpm build --concurrency=2(129 tasks),pnpm typecheck --concurrency=2—Tasks: 129 successful, 129 total, and fullpnpm lint(eslint . --no-inline-config, exit 0 in 32s — full run, no narrowing).node scripts/pm/dispatch-gates.mjs(derivation stderr named this tree at38e5fea48): 39 green. One environmental red, declared:pnpm check:objectui-changeset's self-test cannot run on this host (itsbump-objectui.shusesmapfile, absent from macOS bash 3.2 — status 127 in the self-test harness, not a verdict about this diff; the gate matched only because a changeset file was added). CI runs it on Linux.check-engine-double-contract— green,385 (file, verb) row(s) held by the RETAINED ledger(+2 rows for the new double);check-error-code-casing,check-nul-bytes— green;check-adr-0087-registration—1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.CI convergence is the PM's read per the dispatch contract; gate states at report time are as above.
Follow-up round (#11767 CI red): the declared bootstrap bypass did not actually fire
The first CI run on this branch turned two required gates red —
Dogfood Regression Gate(all three shards) andTest Core(shards 1/6 and 2/6). One line explains all of them:decideAudienceAdmissionwas correct — the WIRING behindisBootstrapwas not, and nothing pinned it.Root cause, measured at the real seam (not reasoned)
isBootstrapCreationprobedctx.context.adapter.findOne({ model: 'user', where: [] }). Instrumenting that exact call inside a real dogfood boot showed the adapter is present and the probe still does not answer — it throws:where: []lowers to an empty filter andrequireFindOnePredicate(#4419) refuses afindOnethat selects no particular record rather than return an arbitrary row. The surroundingcatch { return false; }read that refusal as "users exist", so every bootstrap creation was judged non-bootstrap. The last line is the proof it was a false verdict: zero rows, read through the very path the fix now uses.The in-memory engine double the #11739 matrix runs on has no such guard — an absent filter matches everything and returns
nullon an empty table — which is why 75 green plugin-auth files and a full posture x method matrix said nothing about it.The fix
isBootstrapCreationasks the question in a form that can be ANSWERED: a boundedfindthroughwithSystemReadContext, the same ctx-independent data pathhasPendingInvitationForuses one frame away. The posture's ruled semantics and the fail-closed floor are untouched — an unanswerable probe still reads as NOT bootstrap. None of the rejected shapes (exempting the harness at its own seam, a harness-declaredopen, an empty-environment default) is used.isHumanUserRow(audience-posture.ts) is the single spelling, read by the gate AND by the dev-admin seed's own precondition, which had hand-spelled the same filter. They must agree: on a database still carrying the legacyusr_systemservice row, an any-row predicate would have plugin-security announcing "no human users yet — first sign-up will be promoted to platform admin" while the gate refused that very sign-up. The filter runs in JS because a store-siderole != 'system'drops NULL-role rows under SQL three-valued logic — the direction that fails open.disableSignUpbootstrap bypass carried the identical dead probe and now routes through the same helper. Same defect class, same file, mechanical, one guard closing the whole class — declared here rather than left as a known-inert guard.@objectstack/verify's harnesssignUpenters through the invitation carve-out. Once the dev-admin seed was unblocked, the next wall was the harness's own second/third fixture identity — the populationinvite_onlycloses. Same lane, and the same two reasons, asaudience-gate-test-support.ts:open/email_domainforce verification on and would stop sign-up minting the token the helper returns, and the invitation lane keeps the gate honestly on the path.The regression test is at the seam, and the ablation says so
packages/plugins/plugin-auth/src/audience-bootstrap-seam.test.ts— a REALObjectQLover@objectstack/driver-sql+ better-sqlite3:memory:, drivingapi.signUpEmail({ body }), the server-side lane the dev seed actually calls. Case 0 asserts the #4419 refusal itself, so the reason a fake engine cannot pin this is under test rather than asserted in prose; case 3 is the control that the bypass did not become "always admit"; case 4 pins the humans-not-rows decision on ausr_system-only database; case 5 pins thedisableSignUpsibling.Ablation (fix committed first; script self-restoring via
trap): reverting ONLYisBootstrapCreation's body to the pre-fix adapter probe. Mutation confirmed on disk before the run — injected marker1(want 1), deletedBOOTSTRAP_USER_PROBE_LIMIT,0(want 0), restoredwhere: []1(want 1). ResultTests 5 failed | 1 passed (6), with case 0 green as predicted (it measures the engine, not the gate). The ablated failure text is byte-identical to the CI log line:Restore leg verified: injected-marker count back to
0, fixed-form count back to1,git status --porcelainempty. The mutated subject is same-package source resolved by vitest fromsrc/, so nodist/staleness is in play for either leg.Verification for this round (all at
02580fba1, tree clean)Same declared narrowing as above — every heavy command went through
scripts/pm/os-verify-lock.sh, which reportsNO USABLE flockon this macOS host: verification ran UNLOCKED (declared); nothing was serialized. Verdict lines quoted from the runs:pnpm --filter @objectstack/plugin-auth test—Test Files 76 passed (76)/Tests 1567 passed (1567).pnpm --filter @objectstack/plugin-auth typecheck—VERDICT command-exit 0(the first run caught a realTS6133on the now-unread ctx parameter; fixed).pnpm --filter @objectstack/verify test—Test Files 9 passed (9)/Tests 40 passed (40);typecheckVERDICT command-exit 0.pnpm --filter @objectstack/dogfood test—Test Files 126 passed | 1 skipped (127)/Tests 932 passed | 3 skipped (935). The same command reproduced the CI red on this host before the fix (org-create-default-teamred in 7s with the refusal line).Test Core (1/6)CLI e2e files (serve-mcp-capability-collision,serve-mcp-stdio-answers,serve-stdio-stdout-purity) —Test Files 3 passed (3).pnpm lint(eslint . --no-inline-config) — full repo run,VERDICT command-exit 0in 31s. No narrowing.pnpm --filter @objectstack/spec check:generated—✓ All 14 generated artifacts are up to date.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr naming this tree at3296e3096):check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared;check:test-source-aliasOK — 72 packages with tests scanned;check:engine-double-contractOK — 405 pinned, 133 in the DEBT ledger, 2 exempt;check-nul-bytesOK (scanned 6656 text file(s));check-changeset-fixed/check-empty-changeset/check-changeset-no-major/check-adr-0087-registrationall green.Out of scope, filed rather than fixed here
plugin-security/src/bootstrap-platform-admin.tscarries a third hand-spelled copy of the same human-user predicate. Consolidating it needs the predicate to move to a package both plugins depend on (plugin-security does not depend on plugin-auth), which is a spec/api-surface change this PR should not carry.Generated by Claude Code