Skip to content

feat(spec,auth)!: one declared audience posture for self-registration — invite_only|email_domain|open, default invite_only - #11767

Merged
os-zhuang merged 9 commits into
mainfrom
claude/issue-11739-audience-posture
Aug 25, 2026
Merged

os-zhuang merged 9 commits into
mainfrom
claude/issue-11739-audience-posture

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

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, default invite_only when 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 a minor changeset per the launch-window rule (check:changeset-no-major), with the ADR-0087 semantic migration audience-posture-default-invite-only registered (the rest-requireauth-default-flip class: a secure-default flip no transform can decide).

Contract published for the blocked cloud consumer (objectstack-ai/cloud#1649)

  • Field shape and name — authorable key audience on AuthConfig (packages/spec/src/system/auth-config.zod.ts, exported from @objectstack/spec/system as AudienceConfigSchema / AudienceConfig):
    • audience.posture — enum, default invite_only.
    • audience.allowedEmailDomains — string array; required non-empty for email_domain, refused under any other posture (ADR-0078 inert-declaration refusal). Bare hostnames only, at least one dot, no wildcard.
    • audience.selfRegistrationPermissionSet — sys_permission_set name; required whenever the posture permits self-registration (email_domain / open), refused under invite_only, and admin_full_access is always refused.
    • Authorable-surface manifests carry system/AuthConfig:audience and the system/AudienceConfig:* keys, with the invite_only default recorded in authorable-defaults.
  • Vocabulary values — invite_only, email_domain, open; runtime value list AUDIENCE_POSTURES plus guards isAudiencePosture() and audiencePermitsSelfRegistration() (all exported from @objectstack/spec/system).
  • Error codes (ADR-0112 ledger, @objectstack/plugin-auth entry) — all surfaced as HTTP 403 with the code in the body; browser OAuth flows redirect to the error URL carrying the same code:
  • Accessor cloud should read/write — AuthManager.getAudience() is the ONE live read (the getMembershipPolicy() pattern: the enforcement gate, the forced-verification wiring and the public config all read it). Cloud writes the posture by passing audience in the AuthPlugin/AuthManager config at kernel construction (AuthManagerOptions extends Partial of AuthConfig, so the key already flows), and may switch it live via AuthManager.applyConfigPatch({ audience: ... }) — the patch replaces the whole audience object and is validated as a merged result (an invalid patch throws and the standing config keeps ruling). Effective posture is observable per env on GET /api/v1/auth/config as features.audiencePosture (registered beside tenancyPosture in PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS).
  • Backfill note — since undeclared already IS invite_only, backfilling existing envs is semantically a no-op at the framework layer; writing an explicit audience: { 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

  • One decision owner: decideAudienceAdmission() in packages/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, which internalAdapter.createUser invokes 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.
  • Creation classes (the SSO JIT determination the dispatch asked for, stated explicitly): admin and scim are operator class — never posture-gated; sso-oidc / sso-saml and oauth whose providerId is a configured oidcProviders entry (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).
  • Invitation carve-out: a pending, unexpired sys_invitation for 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_only would kill the invitation surface for brand-new users, since better-auth's accept-invitation requires an existing account.
  • Bootstrap bypass: zero users ⇒ admitted (mirrors the disableSignUp bypass — a fresh install must never lock its operator out).
  • Sign-up route raise: the same decision is additionally raised in the /sign-up/email before-hook because the vendor's anti-enumeration shield (shouldReturnGenericDuplicateResponse, dist/api/routes/sign-up.mjs lines 163/235) converts any 403 from the creation seam into a synthetic 200 whenever requireEmailVerification is on — measured: with posture open/email_domain the refusal came back 200 token null with 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. The validateUserInfo gate stays wired for that route too; the decision is idempotent, so the two asks cannot disagree.
  • Invariants: any self-registration-permitting posture forces requireEmailVerification on (explicit false beside it is refused at config entry — constructor AND applyConfigPatch; getPublicConfig() mirrors the forced value so the advertised flag cannot disagree with the wired one); email_domain requires a non-empty, well-formed, duplicate-free domain list; the declared self-registrant permission set must resolve to an active sys_permission_set row 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_set row, org-scoped to the same target org the membership reconciler resolves; a failure to land it is reported at durability level with consequence + fix).
  • Pinned domain matching (the rules are stated and test-locked, per the card): domain = everything after the LAST at-sign, lowercased; case-insensitive exact per-entry equality; subdomains NOT implied in either direction; plus-tag local parts irrelevant; no punycode/IDN normalization (declare the form addresses carry).

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 IS invite_only. One-line fix for deployments that mean to stay open is in the changeset and in the registered ADR-0087 entry (declare audience.posture: 'open' plus selfRegistrationPermissionSet). 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 an open fixture posture because open forces 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 — audiencePosture non-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 + regenerated registry.ts — the ADR-0087 disposition the declared-breaking changeset must carry.
  • Regenerated artifacts: 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, --write after 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-linux flock): 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 at 38e5fea48 ended VERDICT 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).
  • Full-workspace pnpm build --concurrency=2 (129 tasks), pnpm typecheck --concurrency=2 — Tasks: 129 successful, 129 total, and full pnpm lint (eslint . --no-inline-config, exit 0 in 32s — full run, no narrowing).
  • The 40 path-derived gates from node scripts/pm/dispatch-gates.mjs (derivation stderr named this tree at 38e5fea48): 39 green. One environmental red, declared: pnpm check:objectui-changeset's self-test cannot run on this host (its bump-objectui.sh uses mapfile, 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) and Test Core (shards 1/6 and 2/6). One line explains all of them:

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.

decideAudienceAdmission was correct — the WIRING behind isBootstrap was not, and nothing pinned it.

Root cause, measured at the real seam (not reasoned)

isBootstrapCreation probed ctx.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:

OSPROBE adapter=true findOne=function
OSPROBE adapter.findOne THREW: findOne('sys_user') selects no particular record: 'where' is absent or empty
  and the query carries no 'orderBy'. ... (#4419)
  at _ObjectQL.requireFindOnePredicate (packages/objectql/src/engine.ts:8408)
OSPROBE engine sys_user rows=[]

where: [] lowers to an empty filter and requireFindOnePredicate (#4419) refuses a findOne that selects no particular record rather than return an arbitrary row. The surrounding catch { 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 null on an empty table — which is why 75 green plugin-auth files and a full posture x method matrix said nothing about it.

The fix

  • 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. 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-declared open, an empty-environment default) is used.
  • The population predicate is HUMANS, not rows, and is now owned once. 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 legacy usr_system service 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-side role != 'system' drops NULL-role rows under SQL three-valued logic — the direction that fails open.
  • The sibling disableSignUp bootstrap 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 harness signUp enters 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 population invite_only closes. Same lane, and the same two reasons, as audience-gate-test-support.ts: open/email_domain force 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 REAL ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, driving api.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 a usr_system-only database; case 5 pins the disableSignUp sibling.

Ablation (fix committed first; script self-restoring via trap): reverting ONLY isBootstrapCreation's body to the pre-fix adapter probe. Mutation confirmed on disk before the run — injected marker 1 (want 1), deleted BOOTSTRAP_USER_PROBE_LIMIT, 0 (want 0), restored where: [] 1 (want 1). Result Tests 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:

AssertionError: the operator's own first account was refused: Self-registration is closed on this
environment (audience posture invite_only). Ask an administrator for an invitation.

Restore leg verified: injected-marker count back to 0, fixed-form count back to 1, git status --porcelain empty. The mutated subject is same-package source resolved by vitest from src/, so no dist/ 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 reports NO USABLE flock on 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 real TS6133 on the now-unread ctx parameter; fixed).
  • pnpm --filter @objectstack/verify test — Test Files 9 passed (9) / Tests 40 passed (40); typecheck VERDICT 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-team red in 7s with the refusal line).
  • The three previously-red 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 0 in 31s. No narrowing.
  • pnpm --filter @objectstack/spec check:generated — ✓ All 14 generated artifacts are up to date.
  • Re-derived families for the ACTUAL diff (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, stderr naming this tree at 3296e3096): check:cross-package-test-inputs OK: 16 package(s) read outside themselves, all declared; check:test-source-alias OK — 72 packages with tests scanned; check:engine-double-contract OK — 405 pinned, 133 in the DEBT ledger, 2 exempt; check-nul-bytes OK (scanned 6656 text file(s)); check-changeset-fixed / check-empty-changeset / check-changeset-no-major / check-adr-0087-registration all green.

Out of scope, filed rather than fixed here

plugin-security/src/bootstrap-platform-admin.ts carries 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

os-zhuang and others added 4 commits August 24, 2026 23:04
…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.
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-auth, @objectstack/spec, @objectstack/verify, touching 82 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 3689991d2e67f80faacc6c38233e83593a22202c.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 5 changed file(s) yielded no anchor (packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, packages/spec/authorable-surface/system.json, …) — pages documenting those are invisible to this run
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3689991d2e67f80faacc6c38233e83593a22202c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 719eefecc9034bc58f47c489a1341ed5062f2cec — the merge of head 02580fba14f6ed85b8fa509c7d16472ef46ab111 into base 3689991d2e67f80faacc6c38233e83593a22202c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3689991d2e67f80faacc6c38233e83593a22202c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Aug 25, 2026
@os-zhuang

Copy link
Copy Markdown
Contributor Author

Synced onto current main — CI now runs, and it has something to say

@os-sam — closing the loop you opened on the zero-CI reading. Your measurement held: the
head you read really had never been built. It has now, and the farm found a required-gate
red that nobody could have seen before.

⛔ Nothing here changes the reviewed behaviour: the branch's delta against origin/main is
still exactly the 39 paths / 2082 insertions / 33 deletions it was before the sync.
Left as draft; no label, no ready flip, no queue action.

What was synced

origin/main was merged into the branch (never rebased, never force-pushed). The base
had drifted 90 commits, and the diff crosses the whole os-regen artifact family
(api-surface/, authorable-surface/, authorable-defaults/, export-origins/,
json-schema.manifest/, content/docs/references/**, the strictness ledger) plus
src/migrations/registry.ts and the error-code ledger.

scripts/pm/os-regen-merge.sh cannot run on this macOS host (bash 3.2 has no mapfile), so
its documented order was executed by hand: merge, take origin/main's side of every
merge=os-regen path, commit the merge, then regenerate wholesale on the merged tree.

The zero-conflict merge was not taken as evidence — the artifacts were reset to main's
side and regenerated from scratch, and the regeneration reproduced eight of the ten
byte-identically while resolving the two the driver had deferred. Ledger entries from both
sides survive: audience-posture-default-invite-only (this branch),
field-master-detail-set-null-refused,
ui-cloud-connection-widgets-unknown-keys-refused, ui/PageComponent:responsive and the
ResponsiveConfig retired defs (main's).

CI reading — positive-controlled, same window

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) — required
  • Test Core (2/6) (@objectstack/verify's own suite) — required
  • Dogfood 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 clean
  • node 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

os-zhuang and others added 3 commits August 25, 2026 10:26
…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>
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 4f24e9d Aug 25, 2026
39 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11739-audience-posture branch August 25, 2026 06:44
os-steve pushed a commit that referenced this pull request Aug 25, 2026
…, 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…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>
baozhoutao pushed a commit that referenced this pull request Sep 1, 2026
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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(spec,auth)!: one declared audience posture for who may self-register into an app — invite_only|email_domain|open, default invite_only

1 participant