fix(plugin-security): refuse ADR-0068 built-in identity names at both position write doors - #17436
Conversation
… position write doors `sys_position.name` and `sys_user_position.position` were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`). PR #15948 closed every in-repo reader that turned such a name into authority; it could not stop the row existing, and an out-of-repo reader that reads the name instead of the capability rung reopens the hole with nothing mechanical to catch it. Both declarations now carry an object-level `validations[]` rule whose CEL list literal is GENERATED from `BUILTIN_IDENTITY_NAMES` — the spec constant that declares the identities — so the closed enumeration is imported, never retyped and never widened to an `org_*` pattern. Object-level validations are evaluated by the engine on insert, by-id update and multi-row update, so the data API, the seeders and metadata import are all covered by ONE refusal carrying ONE code (`VALIDATION_FAILED`). `sys_position` exempts the platform's own catalog provenance (`managed_by` of `platform`, or its legacy `system` spelling) because `bootstrapBuiltinRoles` seeds exactly these names; `sys_user_position` takes no exemption at all, since no writer in any package creates an assignment row spelling one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…y-name rows `scripts/measure-reserved-identity-name-census.mjs` reports rows that already stand on an ADR-0068 built-in identity name and rewrites none of them, per the maintainer ruling («refuse new writes only. No migration. A read-only census reports existing colliding rows to the maintainer»). Two modes: the default censuses DECLARATIONS in this repository; `--rows FILE` censuses a deployment from a read-only export, separating the platform's own seeded catalog rows from real collisions and refusing an input that never exported a table rather than reading it as zero. The reserved set is parsed out of the spec constant that declares it, with a control that throws instead of reporting a comfortable zero when the parse finds nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…s-position-reserved-names
…voked-as, and make the gate double refuse combinators
Two gate findings on this branch's own diff:
- `check:entry-guard` — the census script carried a hand-typed
`import.meta.url === file://${process.argv[1]}` guard, which answers false
through a symlink and silently does nothing. Routed through
`scripts/invoked-as.mjs`'s `isEntrypoint`, like every other `scripts/` entry.
- `check:where-matcher` — the DelegatedAdminGate test double read a `$and` /
`$or` key as a field name instead of refusing it, the silently-wrong shape:
every row would fail the lookup and the assertion would pass for a reason
unrelated to what it measures. It now throws on any combinator it does not
implement, matching the sibling double in `delegated-admin-gate.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 10 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 e2df5f6bcf5cf9b081497948f862b91907968222 && git checkout e2df5f6bcf5cf9b081497948f862b91907968222
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd62a66b7c48be167deb74467ca93e6ad3223f38 03b02318646e89e2447a1ba7f94ff608f5a78f0a && git checkout -B drift-repro fd62a66b7c48be167deb74467ca93e6ad3223f38 && git merge --no-ff 03b02318646e89e2447a1ba7f94ff608f5a78f0a
node scripts/docs-audit/affected-docs.mjs --json fd62a66b7c48be167deb74467ca93e6ad3223f38
|
…ation messages `node scripts/check-i18n-bundles.mjs --write`, nothing else in this commit. The two `validations[]` entries added on this branch carry an authored `message`, which the rule validator resolves through i18n at refusal time (`objects.<object>._validations.<rule>.message`), so the package's bundles were behind the schema — `check:i18n` reported `plugins/plugin-security: 7 bundle(s) drifted` on CI, which is the measurement this branch could not take locally until the gate's build prerequisite was cleared. Exactly the gate's designed output: `en` is rewritten from source (it is a copy, not a translation), and merge mode adds the new keys to the translated locales filled with the source text, which still needs translating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Docs-drift advisory (re-run on
|
| count | |
|---|---|
| changed files | 13 |
content/docs/releases/** |
0 ✅ |
any content/docs/** |
0 ✅ |
governed surface (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) |
0 ✅ |
⇒ The guardrail is listed, ⛔ not crossed. The 7 added files are exactly the regenerated bundles and nothing else.
⇒ Advisory answered, no action, and ⛔ this PR is not widened on account of it.
Generated by Claude Code
|
| HEAD | declaredObjects |
--self-test |
|---|---|---|
origin/main (ab56ea3a1) |
298 | exit 0 |
this branch (638d2b544) |
300 | exit 1 |
Mechanism. declaredObjects() in scripts/tenant-audit-census.mjs walks every *.object.ts file and counts each object literal carrying a snake_case name: string literal — it does not distinguish an object declaration from a nested one. The two validations[] rules this PR adds are counted as declared objects:
reserved_identity_name -> packages/plugins/plugin-security/src/objects/sys-position.object.ts
reserved_identity_position -> packages/plugins/plugin-security/src/objects/sys-user-position.object.ts
298 + 2 = 300. ⛔ Not the i18n bundles: the count was already 300 before those were regenerated, and the walk reads *.object.ts only.
The over-match itself predates this PR — the same rule already counts the four actions[] names on sys_position (activate_position, clone_position, …), so 298 was never a count of objects. What this PR did is move the number, and the gate's own design is why that breaks only the self-test: the corpus-scale figures are dated and deliberately not compared (the bare gate is green at 300 — check-tenant-audit-census: OK … 23 prose figures held to the census), while the self-test's mutation builds the string to replace from the LIVE count, so it silently becomes a no-op once the tolerated drift becomes real.
The remedy, measured green and then withdrawn from this branch. node scripts/tenant-audit-census.mjs --write (the script's own documented mode — no checker, fixture or expectation is touched) plus the one prose figure that sits outside the GENERATED block:
content/docs/permissions/tenant-audit-census.mdx— line 87 proseAcross 298 declared objects→300; and the generated block:declared objects in the registry298 → 300,tracked non-test sources scanned557 → 562,engine-shaped types recognised59 → 58,Measured on2026-09-079cefca9a3→ 2026-09-10638d2b544.docs/audits/2026-08-tenant-audit-write-call-sites.counts.md— the same generated block.
With that applied locally, --self-test exits 0 and the bare gate stays 0.
⛔ Not pushed. content/docs/** is domain:devx, outside this card's declared file surface, so it is handed to the PM to route rather than carried here. This comment is the standing-down record for that hand-off.
The rest of the job is clear. Lint & Repo Gates aborts at the first failure, so a red there is a lower bound. With the docs remedy applied locally I re-ran every one of the 182 gate invocations in that job's step list, exit code captured before any pipe: 182 of 182 exit 0. ⇒ this docs count is the only thing between this PR and a green Lint & Repo Gates.
#17437 stays open and is unaffected: it is the card for the checker fragility itself — a self-test whose mutation depends on a figure its own gate declares unenforced will re-break for the next author who adds a snake_case name: to any *.object.ts. Fixing the count clears this PR; it does not close that.
Generated by Claude Code
`node scripts/tenant-audit-census.mjs --write` — the script's own documented mode — plus the one prose figure that mirrors the generated count and sits outside the GENERATED block. ROOT CAUSE, and it is this branch's. `declaredObjects()` in `scripts/tenant-audit-census.mjs` walks every `*.object.ts` and counts EACH object literal carrying a snake_case `name:` string literal; it does not distinguish an object declaration from a nested one. The four `actions[]` names on `sys_position` (`activate_position`, `clone_position`, …) were already in the tally before this branch, so 298 was never a count of objects. The two `validations[]` rules added here are counted the same way, moving it to 300 — and a rule name cannot dodge it, since `packages/spec` requires it to be snake_case. Measured in ONE worktree with ONE `node_modules`, switching only HEAD: at `origin/main` (`ab56ea3a1`) the census reports 298 and `check-tenant-audit-census --self-test` exits 0; at this branch's head it reports 300 and the self-test exits 1. The gate itself is green either way — the corpus-scale figures are dated and explicitly NOT compared. What breaks is the self-test case that rewords the prose claim off the page: it builds the string to replace from the LIVE count, so it silently becomes a no-op once the page's tolerated drift becomes real, and the case then fails for the page rather than for the classifier it pins. That latent fragility is #17437 and is NOT closed by this commit. DISCLOSED: the block regenerates whole, so it also refreshes two figures this diff did not cause — `tracked non-test sources scanned` 557 -> 562 and `engine-shaped types recognised` 59 -> 58 — drift accumulated since the block was last measured at `9cefca9a3`. ⛔ The checker, its fixtures and its expectations are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Correction to the comment above — ⛔ this is NOT waiting on another laneThe PM routed the remedy back into this PR, and it is pushed ( Why the surface extension is a distinction, not a convenience — the PM's reasoning, recorded here so the next reader does not re-derive it: What landed, exactly and nothing else:
After the edit: #17437 stays open and gains this mechanism: a self-test whose mutation string is built from a live figure its own gate declares unenforced will re-break for the next author who adds a snake_case Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34501527873 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Merge-queue build 34501527873 failed — triaged, and it is ⛔ NOT this PR's. Standing down without spending a re-queue, because the root has already left the queue and GitHub has re-queued this PR on a clean base.
What failed, and the two controls that place it
Control 1 — the file surface. This PR's diff, counted mechanically: 0 changed files matching Control 2 — the base branch. On ⇒ Not this PR's, and ⛔ not main-red either. By elimination it is a semantic conflict inside the queue stack — the third case on the triage workflow's own checklist. The root, and why the inherited rows were bystandersThe queue-flake anchor's #17444 is fix(objectql)!: refuse a field whose Full evidence and reasoning posted to the anchors: #17479 (with the complete reading) and #17481 (same build, same shape, pointer). ⛔ Stated there as the place to look, not as a proven cause — confirming it is the root's owner's read. Why no fix was ported and no re-queue was spent
⇒ Watching this build. If it fails again on a base without #17444, that result is this PR's to root-cause and this seat will treat it as such. ⛔ No test will be skipped, disabled or quarantined to reach green, and no empty commit will be pushed to kick CI.
Generated by Claude Code |
…ccount.issuer, retire the backfill, lift the family to 1.7.3 (objectstack-ai#17454) Fixes objectstack-ai#17440 Maintainer ruling 2026-09-10 on objectstack-ai#16629, option 1: adopt better-auth's account-issuer rollback. `sys_account.issuer` retires with the backfill that served it, and the `@better-auth/*` family lifts to an exact `1.7.3` in one line. Verified at `e577e0eb4`. --- ## ⭐ The finding that shaped the migration The card asks for a pre-flight that detects rows sharing `provider_id` + `account_id` and differing only in `issuer`. Measuring the premise first changed how that pre-flight had to be built: **`sys_account` has declared `{ fields: ['provider_id', 'account_id'], unique: true }` since the object was created.** `git log -S` puts it in the commit that created the object; the `(issuer, account_id)` pair arrived much later, with the 1.7.0-rc.2 bump (objectstack-ai#3632). So the "new" key is not new — it long predates the column being dropped, and wherever that index is physically present the collision class is refused at write time. That does not make the pre-flight unnecessary. It makes one thing about it load-bearing:⚠️ **"Declared" is not "present."** `syncDeclaredIndexes` logs a plain UNIQUE whose CREATE fails on existing duplicates onto the durability channel and lets the boot continue (objectstack-ai#14902 / objectstack-ai#15479) — deliberately, so one dirty table cannot take a deployment down. A database that ever held duplicates therefore carries the declaration and not the constraint, and can still hold the class today. ⇒ **On such a database the drop does not blow up. It degrades silently:** the rows become indistinguishable, `findAccountByKey` resolves whichever the driver hands back first, and a sign-in can land on the wrong user's account. That is strictly worse than a failed apply, and it is why the pre-flight reads **rows**, never the index declaration. ## The ceremony — reused, not invented ADR-0131 D10 fixes the shape and says in the same breath that it *"reuses the ADR-0120 D4 migration ceremony where it exists (index and column changes) rather than inventing a second one."* A column drop plus an index re-key is exactly ADR-0120 D4's class, and this repository already ships every leg of it: | leg | what runs it | new here? | |:--|:--|:--| | plan | `os migrate plan` reports the drop as destructive drift | no | | ⭐ row pre-flight | **`os migrate account-issuer`** — read-only, exits non-zero | **yes — this was the gap** | | backup | the operator's act, and the apply step's stated precondition | no | | apply | `os migrate apply --allow-destructive`, **which now refuses this drop while the pre-flight is dirty** | refusal is new | | post-check | re-run `os migrate account-issuer`; it reads zero | no | | boot refusal | `runArtifactBootMigrationGate` already fails the boot on unapplied destructive drift, naming the command; `os serve` never auto-migrates | no | So the smallest honest addition was the read-only pre-flight D4 asks for on a **narrowing** index change, plus a refusal in front of the drop. An `os migrate account-issuer --apply` that dropped the column itself would be the second ceremony D10 forbids, and it would drop a column outside the drift reconciler that owns every other column drop. ⛔ No `sys_migration` flag, deliberately — `os migrate summary-nulls` documents the rule that a deployment flag nothing reads is a fact nothing reads. The consumer of this verdict is the gate in `os migrate apply`, which re-runs the probe against the live database at the moment it matters; a row saying "clean on Tuesday" authorises nothing on Thursday. ### Refusal discipline Two readings are deliberately **not** reported as clean, because a pre-flight that cannot see is not a pre-flight that found nothing: 1. **A read that throws refuses.** The retired `backfill-account-issuer.ts` wrapped its reads in `try { … } catch { return [] }` — correct for an idempotent best-effort pass that runs again next boot, and exactly wrong for an answer that authorises an irreversible drop. 2. **A truncated walk refuses.** An unenumerated tail is not zero rows. ⛔ Nothing is merged or deleted for the operator: which row survives is application knowledge, and two different people can be behind one colliding key. ##⚠️ The re-pointed provider — answered, and pinned **A `provider_id` re-pointed at a different IdP must have its account bindings REBUILT. No key separates them, and after the column drop nothing can.** `sys_sso_provider` declares `{ fields: ['provider_id'], unique: true }`, so within an environment `provider_id → issuer` is a function and `(provider_id, account_id)` determines what `(issuer, account_id)` determined — for as long as that function holds. Re-pointing breaks it. If the new IdP mints a `sub` the old one had already issued to somebody else, the new key resolves that sign-in onto the **other person's** account row.⚠️ Under the old key that shape failed **loudly**: `findAccountByKey` missed the old row, better-auth tried to insert, and the long-standing `(provider_id, account_id)` unique refused it — the user saw `unable_to_link_account`. Under the new key it resolves silently. The narrowing turns a loud refusal into a quiet cross-user sign-in, which is why this is answered rather than left to a constraint. **Enforced at the re-point, because that is the last moment the distinction exists.** After the drop no column records which IdP vouched for a row, so no runtime check can tell an old binding from a new one. `refuseIssuerRepointWithLiveBindings` sits on the `sys_sso_provider` update doors and declines an issuer change while accounts are still bound to that `provider_id` (`RESOURCE_CONFLICT` / 409). The operator deletes the stale bindings; each user re-links on their next sign-in. Pinned by five cases, including the one that states the answer directly — two rows under one `provider_id` differing only in issuer are **one key**, two issuers, two people. ## The two flagged items **`showcase-demo-personas-loginable.dogfood.test.ts`** keeps its file and its real half. The issuer assertion is **replaced, not dropped**: its job was "the account is resolvable under the key sign-in uses", and the key is now `(provider_id, account_id)` — so that is what it asserts, with the admin's own better-auth-minted account as the same positive control the issuer case carried, plus a new assertion that the retired column is **absent**. The header quotes the old assertion verbatim and records why it went away, so the trap that bit four checklist items is not lost with the field that caused it. **`check:vendor-export-contract` is not loosened.** It still requires an exact declared range, agreement with the installed version, and real resolution of every named symbol. Its self-test carried the instruction *"if the durable fix landed, retire this case with it"* — this is that fix. ⛔ Retiring the **specimen** is not retiring the case: what it catches is a collector that has silently stopped reaching publishable source, which is how objectstack-ai#16186 passed over nothing for three releases. So it re-anchors on a live edge (`better-auth/adapters` → `createAdapterFactory`) and still asserts a **named** symbol, and a new case asserts the two deleted names are imported nowhere. ## Out of scope, untouched ⛔ objectstack-ai#11627's hash-shadow-key machinery stays — a generic driver capability serving five UNIQUE members of the >768-char class. The one place it was cited as an illustration (`platform-keyed-text-bounds.test.ts`) moves to a **measured** surviving member of that class, `sys_oauth_access_token.token` (1024), rather than a plausible-looking name. --- ## Verification⚠️ **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 below were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs.⚠️ **Also declared: `TMPDIR` was pointed at a non-symlinked path** for the CLI and dogfood suites. On macOS `/var` is a symlink to `/private/var`, and ten CLI cases compare a path the test itself built from `tmpdir()` against the realpath Node returns. Proven to be the host and not this diff: the same three files, unchanged on this branch, pass **41/41** under `TMPDIR=/private/tmp/…`. CI runs on Linux, where `/tmp` is not symlinked. ### Acceptance **① The pre-flight refuses on a fixture containing the collision class — watched refusing.** ``` ✓ objectstack-ai#17440 the preflight REFUSES on the collision class > refuses, naming the rows, when one key is held by two rows differing only in issuer ✓ … > flags a same-user collision WITHOUT the cross-user marker ✓ CONTROL — a clean table passes … ✓ CONTROL — an empty table is clean … ✓ a read that throws refuses instead of reporting zero rows ✓ a walk stopped by its row cap refuses instead of reporting a partial scan as clean Test Files 1 passed (1) · Tests 15 passed (15) ``` Every refusal asserts the ADR-0112 envelope (`code` **and** `status`) and the substance of the message — never a bare `toThrow()`, which would pass on a fixture that never reached the probe. ⭐ **The fixture registers an index-less `sys_account` on purpose, and the file says why.** The `PREMISE` case proves the class cannot be inserted where the declared unique is physically present, by trying against the **real** object and watching the driver refuse (with a control: a different `account_id` inserts fine). So the only population that can hold the class is a deployment carrying the declaration without the constraint — which is exactly what the fixture models. **② Fresh install and existing-data upgrade both end with working sign-in over a real auth route.** Fresh — the real showcase boot: ``` ✓ each persona holds a credential account resolvable under the SAME key better-auth uses for the admin ✓ each persona SIGNS IN over the real auth route, and the session resolves to that persona Test Files 1 passed (1) · Tests 4 passed (4) ``` Existing data — two engines over one SQLite file (engine A declares `issuer` and signs a user up through the real HTTP route so the hash is better-auth's own; engine B on the same file registers today's objects: new code, old table): ``` ✓ a 1.7.2-era account still SIGNS IN over the real auth route after the column is undeclared ✓ the undeclared column is NOT silently dropped by schema sync — the drop stays the operator's deliberate act ✓ the pre-flight reads CLEAN on that database, which is what authorises the drop ✓ and sign-in still works once the column is actually GONE — the far side of the ceremony Test Files 1 passed (1) · Tests 4 passed (4) ``` Both sign-ins are judged by the principal the session resolves to, never by a status. Both `PRAGMA` reads carry a control — `not.toContain` passes vacuously on an empty array, which is the one reading this must never produce by accident. **③ The re-pointed-provider answer is stated and pinned** — stated above, pinned by the five cases in `account-identity-preflight.test.ts`. **④ `check:vendor-export-contract` — both directions proven.** Passing at `1.7.3`: ``` check-vendor-export-contract --self-test OK (1 governed vendor family) VERDICT: PASS — vendor export contract (installed workspace), 1 edge(s) verified ✓ better-auth/adapters @ better-auth@1.7.3 (1 symbol(s): createAdapterFactory) ``` Still failing when pointed at a symbol the pinned version does not export — an ablation on the committed tree, mutation confirmed on disk before the measurement and the restore proven by hash: ``` HEAD blob hash: 1b1d1b5 --- before: original present=1, injected present=0 --- after: original present=0, injected present=1 --- MUTATION CONFIRMED ON DISK --- ABLATED_EXIT=1 VERDICT: FAIL — vendor export contract (installed workspace) - better-auth/adapters at better-auth@1.7.3 does not export resolveAccountIssuerForProvider — imported statically by @objectstack/plugin-auth. A static ESM named import of a missing export is a link-time SyntaxError: the package does not load at all. post-restore blob hash: 1b1d1b5 --- RESTORE PROVEN: hash matches HEAD blob, git diff HEAD empty --- ``` No rebuild leg is owed: this gate parses publishable **source** and resolves against `node_modules`, so no `dist/` sits between the mutation and the verdict. **⑤ The changeset carries its ADR-0087 disposition and the FROM → TO mapping.** The changeset carries the disposition marker naming `sys-account-issuer-retired` (spelled as the HTML comment the gate reads; not reproduced here, because this body's sanitizer eats angle-bracket fragments). The entry is added under protocol major 18 and `registry.ts` plus both projections regenerated.⚠️ **Graded `minor`, not `major`.** The dispatch card asked for a major arm; `check-changeset-no-major` refuses a major in this launch window, and the live convention carries breaking-ness with a **BREAKING** banner plus the ADR-0087 disposition. Flagged rather than silently chosen. ### Suites and gates ``` @objectstack/plugin-auth test 106 files · 2243 tests · all passed @objectstack/plugin-auth typecheck OK (+ check:test-typecheck) @objectstack/cli test 234 files · 3040 tests · all passed @objectstack/dogfood personas 1 file · 4 tests · all passed typecheck cli · client · platform-objects · spec · example-showcase — all Done ``` Gates run locally, all exit 0: `check:nul-bytes` · `check:vendor-export-contract` · `check:adr-0087-registration` · `check:override-consistency` · `check:changeset-gate-self-tests` · `check:error-code-casing` · `check:doc-authoring` · `check:i18n` · `check:i18n-coverage` · `check:i18n-stale-fill` · `check:i18n-walk-parity` · `check:cli-command-ids` · `check:cli-examples-parity` · `check:test-source-alias` · `check:cross-package-test-inputs` · `check:engine-double-contract` · `check:dts-closure` · `check:published-readme-exports` · `check:pm-widening-tells` · `check:single-claim-paths` · `check:route-envelope` · `check:error-status-conformance` · `check:agent-test-spelling` · `check:pm-governed-prose` · `check:partof-closing-keyword`, and in `@objectstack/spec`: `check:migration-registry` · `check:spec-changes` · `check:upgrade-guide` · `check:api-surface` · `check:export-origins` · `check:exported-any` · `check:liveness` · `check:authorable-surface`. `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` derives **110** families for this change set and was re-derived after the diff grew (no new families). The remainder is the repo-wide farm, which CI runs exactly once — a declared narrowing, not an omission.⚠️ `check:nul-bytes` caught a real defect of mine mid-run: two raw NUL bytes in the pre-flight's composite map key, an escape materialised into the byte while the file was being written. The key is now `JSON.stringify([providerId, accountId])` — no delimiter ambiguity and no control byte at all. ## A neighbouring behaviour change the family lift brought with it better-auth `1.7.3` added the objectstack-ai#10700 gate one layer above ours: `/two-factor/enable` throws `TOTP_ALREADY_ENABLED` when a two-factor row exists with `verified !== false`. Measured: that code appears in **0** files in 1.7.2 and **5** in 1.7.3, against a control code present in both (5 / 5). ⭐ **Upstream's gate READS `verified` — the exact field objectstack-ai#10700 was about — so `two-factor-reenrollment-verified-reset.ts` is what keeps that gate's input truthful. ⛔ It is not dead code superseded by the vendor.** The re-enrollment legs now assert the upstream refusal envelope plus the property behind it (nothing rotated behind the refusal); the inertness assertion moves to the unconfirmed path, which is the one upstream's gate still admits; and rotation moves to `disable → enable → confirm`. --- ## Merged with `main` — the conflict that was running zero CI This PR sat **conflicting, and a conflicting PR runs nothing**: `mergeable=false`, `mergeable_state=dirty`, **0 workflow runs** on its head. Its check list was the previous head's and said nothing about this one. Two census artefacts conflicted, both generated — `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`. main's `2a79726ac` (objectstack-ai#17436, verified here rather than taken on trust) had independently re-run the same census, so both sides rewrote the same `Measured on` line and the same corpus-scale table. ⭐ **The order is fixed and it is not the obvious one** — regenerating while the tree is still in MERGE state rolls a generated anchor back to the branch's old fork point, and a rolled-back artefact is still *authentic*, so every gate passes while a landed advance is quietly undone. `scripts/pm/os-regen-merge.sh` mechanises the right order and was used: 1. `bash scripts/pm/os-regen-merge.sh` — fetched, merged, and stopped exactly where it should: neither conflicted path is routed to the `merge=os-regen` driver, and the script refuses to resolve non-generated files on your behalf. ⭐ Measured before resolving anything: of the **six** paths changed on both sides of this merge, **zero** are os-regen paths — so the driver's silent-drop hazard did not apply here and the script's step 2 had no work to do. ⛔ It was deliberately **not** re-run after the merge commit: its own header says the base must be read BEFORE step 1, and afterwards `git merge-base HEAD origin/main` is main's own tip, which makes step 2 inert for the wrong reason. 2. The conflicted block was resolved to main's side and **committed as the merge** — a placeholder, and the commit message says so, because a census block is an answer to a tree and the merged tree is neither side's. 3. `node scripts/tenant-audit-census.mjs --write`, on the committed merge. 4. Every prose figure re-derived from that census. ### Re-derived, never carried forward ⛔ No figure below was copied from a CI log, from the pre-merge branch, or from main. A script imported the gate's own `PROSE_COUNTS`, applied the same `splitPage` plus whitespace normalisation the gate applies — the page is hard-wrapped at 80 columns, so an un-normalised match is a false NO MATCH, which is how six rows first read as missing — and evaluated `expected(census)` against the page for all 23 enforced rows: ``` enforced rows: 23, failures: 0 ``` Only corpus scale moved: `engine-shaped types recognised` 58 to 59, plus the dated marker. `sources scanned` 562, `declared objects` 300 and `non-engine calls` 137 arrived with main's re-run and the merged tree reproduces all three. The **population held exactly still** — 222 write call sites, 148 decidable, 9 provable-and-tenancy-enabled, 32 unreadable — which is why no enforced prose figure needed an edit. That is a measurement, not an assumption.⚠️ **The claims that ride on a figure without quoting it — the class no gate can see — were re-checked against the same census:** - `44 of 222` reached through an erased receiver is **19.8%**, and a fifth of 222 is 44.4, so *"just under a fifth"* still holds. main's side of that sentence reads *"better than a fifth"* at `45 of 222`: true for main's tree, false for the merged one. The auto-merge kept the branch's corrected wording, and this is the reading that confirms it. - `104 of 222` decidably elevated is **46.9%**, so the quoted `(47%)` still rounds true. The gate captures the count and the population out of that sentence and leaves the per cent unread. - `Across 300 declared objects` is the one UNENFORCED prose figure — required to be present, never compared. It came in from main's side and the regenerated scale row agrees with it. `node scripts/check-tenant-audit-census.mjs` and its `--self-test` both exit 0 on the merged tree. --- ## The ten version stamps the 1.7.3 lift falsified `check:vendor-version-stamps` was red, and it is this PR's own doing — the same gate exits 0 on an unmodified `main` checkout. CI had not reported it because the lint job fail-fasts on the census check, roughly 900 lines earlier. ⛔ **Not a `1.7.2` to `1.7.3` substitution.** The gate's own reason: a stamp attests that a behaviour was MEASURED against the version it names, so changing the number without redoing the measurement manufactures a claim nobody made, which is worse than a stale one. The ten sites were judged one at a time, and they split **7 / 3**. ### Route (a) — re-measured against the installed 1.7.3, then restamped AND dated Seven sites whose claim is a **static reading of the vendor's published files**. Cheap to take again and worth taking, because a family lift is precisely the event that could invalidate one. All seven came back **unchanged**: | site | what was re-read at 1.7.3 | |:--|:--| | `packages/cli/src/commands/init.ts:178` | `@better-auth/scim@1.7.3` still peers `@better-auth/utils@0.4.2` exactly, off the installed manifest | | `packages/cli/src/commands/init.ts:509` | nothing in better-auth 1.7.3's published files names better-sqlite3 except its own peer declaration | | `packages/plugins/plugin-auth/src/auth-schema-config.ts:954` | `SCIMOptions` still declares no `schema` / `modelName` / `fields` — the same six members | | `packages/plugins/plugin-auth/src/list-user-invitations-verification.ts:11` | `crud-invites.mjs` still asks the helper on the three id-addressed routes and still throws unconditionally in `listUserInvitations`, so the defect this file repairs is still minted upstream | | `packages/plugins/plugin-auth/src/auth-email-locale.test.ts:869` | `/sign-in/magic-link` still sends with no user lookup; `/magic-link/verify` still creates the user unless `disableSignUp` | | `packages/plugins/plugin-auth/src/auth-email-locale.test.ts:1001` | `signInMagicLinkBodySchema` is still `z.email()` with no case transform; `findUserByEmail` still matches on `email.toLowerCase()` | | `packages/plugins/plugin-auth/src/auth-manager.ts:3888` | `db/adapter-base.mjs` still builds `memoryDB` from `Object.keys(tables)` — the schema KEY — while `@better-auth/memory-adapter` still resolves by model name and throws |⚠️ One of them was also made **more accurate** rather than merely restamped: `init.ts:509` said better-sqlite3 is referenced by *"no file in the published package at all"*, and `package.json` is a file that references it. It now reads *except that peer declaration itself*. ### Route (b) — anchored, deliberately NOT restamped Three sites whose reading came from a **drive**, not from a file. Restamping these would assert a drive nobody re-ran. | site | why anchoring is the honest route | |:--|:--| | `packages/client/src/index.ts:3656` | measured over a real `AuthManager` plus `SqlDriver`. Anchored to the date and card that took it (2026-09-09, objectstack-ai#16761), scoped to "the then-installed 1.7.2", and the sentence now says out loud that the drive has not been re-run against the lifted family | | `packages/plugins/plugin-auth/src/scim-connection-service.ts:55` | ⭐ the reading is an **ablation of a REJECTED design** — `enterWith` losing the store. Re-measuring would mean re-breaking the scope to watch it fail again. Anchored to 2026-09-02 / objectstack-ai#14624, and the sentence now points at `scim-transaction-scope.test.ts`, which pins the SHIPPED behaviour at run time against whatever version is installed | | `packages/plugins/plugin-auth/src/account-issuer-upgrade-path.test.ts:27` | the version named is the **pre-upgrade runtime this fixture models**. 1.7.2 is not installed any more and cannot be — that is the premise of the whole file — so it is scoped and dated. ⛔ Not "then-installed": 1.7.3 was already installed when this file was written, so the honest scope is that the reading came off the derivation this branch retires | ### Proof that the repair changed prose and not behaviour Seven of the eight touched files are provably **comment-only**: each was transpiled with `removeComments` at HEAD and at the working copy, and the emitted JS hashes are equal — with a `const` to `let` control on every file proving the instrument can say no, so "identical" is not a vacuous verdict. ⛔ A raw scanner is **not** sound for this question (template literals and regex-versus-division need parser context); the first attempt using one reported three false differences before it was replaced with a real parse and emit. `packages/cli/src/commands/init.ts` is the exception **by design** — its stamp lives in string literals the scaffold writes into a user's project, so it is a real change to emitted content. Its scaffold tests were therefore run: ``` Test Files 3 passed (3) Tests 62 passed (62) test/init.test.ts · test/scaffold-workspace-consistency.test.ts test/better-sqlite3-peer-declaration.pin.test.ts ``` ### Gates, at the commit that carries them Union re-run **after** the final commit, `a760606a6`, all exiting 0: `check-tenant-audit-census` and its `--self-test` · `check:vendor-version-stamps` (self-test 64 checks, then 6980 files scanned) · `check:nul-bytes` · `check:doc-authoring` · `check:corpus-claim-drift` · `check:pm-governed-prose` · `check:scaffold-emission-policy` · `check:cli-examples-parity` · `check:type-check-coverage` · `check:type-check-debt`. Repo-wide `pnpm lint` exits 0 (24s — not narrowed, so no narrowing needs declaring). `typecheck` green on `@objectstack/cli`, `@objectstack/client` and `@objectstack/plugin-auth`, over freshly built dependency closures.⚠️ **Declared narrowing, same as the section above: these runs were UNLOCKED.** `scripts/pm/os-verify-lock.sh` reports `NO USABLE flock` on this host — the shared verify lock is Linux-only — so every heavy command was routed through the entry point and ran in its declared unlocked mode, each printing `VERDICT command-exit 0 · UNLOCKED (declared)`. No serialization guarantee held.⚠️ **A correction that cannot be made in place:** the commit message for the stamp repairs heads route (a) with "six sites" and then lists seven. The split is **7 / 3**, as the tables above show. Pushed history is not rewritten on this branch, so the correction lives here. ## Filed, not fixed objectstack-ai#17453 — three `knownGap` texts in `docs/qa/platform-checklist/areas/approvals.json` cite the now-retired `backfill-account-issuer.ts`. Their own convention is *the gap text stays because it carries the reason*, so the right rewrite is a judgment call about historical record rather than a path substitution. No gate is red on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_6679d191-11f4-465b-b322-0e0409d76793)_ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Fixes #15972
Clause-②: no
(declaration line written by the
domain:servicesreview seat, not by the implementer. It is added NOW, after the contract review passed andneeds:contract-reviewwas cleared from both carriers: while that label was on the PR it WAS the carrier (scripts/check-changeset-no-major.mjsreads the label asyesand it outranks the body), so a body line would have been redundant then and anowould have contradicted it. Measured on THIS diff: 4 addedexports, all in the newsrc/objects/reserved-identity-names.ts, which the package barrelsrc/index.tsdoes not re-export (explicit named re-exports only, noobjects/line) and the package'sexportsmap does not reach ("."only) ⇒ 0 net public surface; 0 new error codes (the refusal answers the already-registeredVALIDATION_FAILED); and the accept set narrows — the whole change is a refusal.no+ aminorchangeset is a legal pair. ⛔ Do not delete this line.)What this closes
sys_position.nameandsys_user_position.positionwere unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name —platform_admin,org_owner,org_admin,org_member. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant.Both declarations already SAID so, in prose.
sys-position.object.ts: "Framework-reserved built-in identities (platform_admin / org_) … MUST NOT be repurposed by a tenant"*.resolve-authz-context.ts, from the other side: "Read the RUNG — neverpositions.includes(...); an ADR-0057 D4sys_user_positionrow may spell that very name." Both were comments. This PR is the enforcement of sentences that were already in the tree.Ruling implemented
Maintainer ruling via director seat, summon #20, decision batch #105 item 4, 2026-09-09 (comment 5595726426); maintainer reply verbatim: 「16934 关闭 ;其他同意」 = item 4 = A.
validations[]on both objects —objectql's rule validator runs them on insert, by-id update and multi-row updateorg_*by patternRESERVED_IDENTITY_NAMESisBUILTIN_IDENTITY_NAMES; the CEL list literal is GENERATED from itscripts/measure-reserved-identity-name-census.mjs; nothing here rewrites a rowThe second door, and why it grew no refusal of its own
The card's exposure is the ASSIGNMENT row, not the definition:
sys_user_position.positionis free text (it referencessys_position.nameby convention, not by lookup), the platform seeds aplatform_admincatalog row in every organization, and a delegated administrator reaches the write —assertAssignmentWritejudges a position by the permission sets it DISTRIBUTES, and that seeded position distributes none, soboundSets.every(…)approves it vacuously. Re-measured on this branch, and still true (the gate APPROVES the assignmentin the suite).So the refusal has to cover that door. It does — at the object layer, on
sys_user_positiontoo. The delegated-admin gate is a hook on the same engine write, so an assignment that clears the gate still meets the refusal, and both doors answer with one error code.⛔ A second refusal inside the gate was deliberately NOT added. It would carry that gate's own code (
PERMISSION_DENIED) for a condition the object layer already names (VALIDATION_FAILED), and which of the two a caller saw would depend on hook order — two vocabularies for one condition, i.e. the "second copy" the ruling refuses. The predicate is shared; the refusal is single. This is the one place the implementation reads the ruling's wording rather than following it literally, so it is flagged for review.Error code — no new code, and no
packages/speceditAcceptance (a) contemplates a new
ERROR_CODE_LEDGERrow. None is taken: the refusal is authored as METADATA, so it carriesVALIDATION_FAILED— objectql'sValidationError.code, already ledger-registered under the door that serves it, and already what every other object-level validation on this platform answers. There is no new stamp site inplugin-security, socheck:error-code-provenanceneeds nothing. A dedicated code remains available as a follow-up if the maintainer wants the condition named on the wire; it would be apackages/specchange and route to that seat.Two doors, two shapes
sys_positionexempts the platform's own catalog provenance (managed_byofplatform, or its legacysystemspelling — the pairSYSTEM_ROW_PROVENANCEalso maps to "the platform").bootstrapBuiltinRolesseeds exactly these four names per organization on purpose. Apackage- or tenant-authored row is refused; a tenant cannot reach the exemption by claiming it, sincemanaged_byisreadonlyand the admin-door provenance gate refuses a payload spellingplatform/packageoutright.sys_user_positiontakes no exemption. No writer in any package creates an assignment row spelling a built-in identity name —platform_adminstanding comes from the unscopedadmin_full_accessgrant, theorg_*trio fromsys_member.role.验收备注
Acceptance, verbatim from the ruling, and where each half is met:
(a) a
sys_positionwrite spelling any ADR-0068 built-in identity name is refused at the object layer and at the service door with one error code, registered inERROR_CODE_LEDGERif new.Met. Both doors refuse with
VALIDATION_FAILED; no new code, so no ledger row (see above). Pinned per name, on a real engine over a real SQL driver, for insert AND update, at both doors.(b) negative control — every other name still writes.
Met, and it is asserted with the shapes a pattern-based guard would have swallowed:
sales_manager,org_manager,platform_admin_deputy,hr_specialistall still write. In both ablation legs below the negative controls stayed GREEN while the refusals went red.(c) the census lists existing colliding rows and modifies none.
Met.
scripts/measure-reserved-identity-name-census.mjsopens no connection and takes no credentials: the default mode censuses DECLARATIONS in this repository,--rows FILEcensuses a deployment from a read-only export. It separates the platform's own seeded catalog rows from real collisions, and refuses an input that never exported a table rather than reading it as zero. Its reserved set is parsed out of the declaring spec constant, with a control that throws instead of reporting a comfortable zero.(d) zero reader changes (readers are #15948's, done).
Met — the diff touches no reader. Two object declarations, one new predicate module, one test, one census script, one changeset.
Fleet census of name-as-authority readers (the card's first deliverable), objectstack half, with a firing control:
node scripts/measure-reserved-identity-name-census.mjsover 2683 non-test source files returns exactly one candidate —packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts:262 name: 'org_admin', a test-kit fixture, not a shipped declaration. The FIRING CONTROL is the script's own--self-test: 5 controls, including a synthetic corpus where the scanner must hitplatform_admin/org_adminand must NOT hitorg_manager,platform_admin_x, or alabel:key. ⛔ A zero (or a one) here is a reading over DECLARATIONS in this repository, never over a deployment — the script prints that sentence beside every count. Thecloudhalf is out of reach from this session and remains the seam card the ruling files.docs-drift-checkadvisory — read and answered, no page falsified. The bot lists 6 hand-written pages because they name a symbol this diff touched; the anchors arecross_fieldandmanaged_by, both string literals inside the newvalidations[]entries, so the pages match on the MECHANISM this change uses rather than on anything it changes about that mechanism. Checked against the falsifying shapes: no page enumerates which system objects carry validations, and none states thatsys_position.nameis unconstrained.validation.mdx/objectql/schema.mdx/seed-data.mdxdocument the rule TYPES;authorization.mdx/capabilities.mdx/permission-sets.mdxdocumentmanaged_byprovenance, and the exemption here reuses that vocabulary unchanged. ⛔ No docs edited, and nocontent/docs/releases/**page touched. The bot can only match shared identifiers, so the page that restates this invariant WITHOUT naming these symbols was checked by hand:content/docs/permissions/positions.mdxlists the four names as "Framework-seeded" and documents'org_admin' in current_user.positionsas the RLS/CEL membership test. Neither sentence is falsified — the first gains the enforcement it already implied, and the second is strictly safer now that the array can no longer carry a forged built-in name.Ablation — C3, both negative pins
Mutation:
severity: 'error'→'warning'on one rule at a time. That is the defect this card closes, spelled minimally: the rule stays declared, the metadata still lists it, the predicate still evaluates, and it refuses nothing — onlyerrorblocks the write. Predicted direction: RED. No dist is involved (both objects are imported by RELATIVE path inside their own package, so vitest resolves them tosrc/*.ts).sys-position.object.tsseverity: 'error'1→0; injectedseverity: 'warning'0→1sys_positionrefusal red, negative controls greensys-user-position.object.tsRestoration proved BY STATE, not by exit code, on each leg:
git checkout HEAD -- PATH(never a baregit checkout --), thengit diff HEADempty ANDgit hash-objectequal to the HEAD blob. Final state:git diff HEADEMPTY. The script carriedtrap … EXIT INT TERMwith absolute paths throughout.A census figure this PR moves, and what it does NOT mean
scripts/tenant-audit-census.mjs'sdeclaredObjects()counts every object literal in a*.object.tscarrying a snake_casename:string literal — it does not distinguish an object declaration from a nested one. It already counts the fouractions[]names onsys_position(activate_position,deactivate_position,set_default_position,clone_position), so 298 was never a count of objects. ⛔ This PR does not add two objects; it adds twovalidations[]rules whosenameis snake_case by contract (packages/specrequires/^[a-z_][a-z0-9_]*$/), and the counter tallies them the same way — 298 → 300.Measured in ONE worktree with ONE
node_modules, switching only HEAD: atorigin/main(ab56ea3a1) the census reports 298 andcheck-tenant-audit-census --self-testexits 0; at this branch's head it reports 300 and the self-test exits 1. So the artefacts are regenerated here with the script's own documented--write, per 「碰生成物的 PR 入队前先同步 + 整体重生成」.tracked non-test sources scanned557 → 562 andengine-shaped types recognised59 → 58 — drift accumulated since the block was last measured at9cefca9a3. Onlydeclared objects in the registry298 → 300 is this PR's.The gate itself is green either way: the corpus-scale figures are dated and deliberately not compared. What broke is the self-test case that rewords the prose claim off the page — it builds the string to replace from the LIVE count, so it silently no-ops once the page's tolerated drift becomes real, and then fails for the page rather than for the classifier it pins. ⛔ That latent fragility is #17437 and is not closed here; the checker, its fixtures and its expectations are untouched.
Verification
pnpm --filter @objectstack/plugin-security test— 110 files, 2127 tests, all passing (VERDICT command-exit 0).pnpm --filter @objectstack/plugin-security typecheck—VERDICT command-exit 0.pnpm --filter '@objectstack/plugin-security^...' build—VERDICT command-exit 0.pnpm lint— the WHOLE repo,eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing evidence is owed. Run atce329d7c.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the last commit (unchanged), and reconciled — 79 derived, 79 run, 0 UNRUN. Exit codes captured before any pipe.check:entry-guard(a hand-typedprocess.argv[1]entry guard in the census script) andcheck:where-matcher(the gate test double read a$and/$orkey as a field name instead of refusing it).check:dual-build-cjs-loads,check:i18n,check:type-check-debteach exited 3 = PREREQUISITE NOT MET (they read a fully built workspace). Declared to CI's Build Core job; nothing was measured, and that is not a finding in either direction.638d2b544, before the census artefacts were regenerated):node scripts/check-tenant-audit-census.mjs --self-testexited 1, while its PR-verdict siblingnode scripts/check-tenant-audit-census.mjswas green. Cause: the live census counted 300 declared objects whilecontent/docs/permissions/tenant-audit-census.mdxstill said 298, and the gate deliberately does not compare that number ("value free, sentence required"), so only the self-test's exact-string.replace()noticed.git diff origin/main...HEAD | grep -c '^+.*ObjectSchema.create'= 0), and the underlying counter fragility is ⛔ not fixed here — it is check-tenant-audit-census --self-test exits 1 on main: the page states 298 declared objects, the live census counts 300, and the self-test needs the exact string the main gate deliberately ignores #17437.Scope
No reader touched, no
packages/specedit, none of the five files fenced to PR #17332, and no edit todelegated-admin-gate.ts(the reasoning is under "the second door" above).Generated by Claude Code