Skip to content

fix(plugin-auth, runtime): four principal-less producers take the explicit system opt-in - #21939

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21912-principal-less-producers-identity
Oct 6, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21912-principal-less-producers-identity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21912
Clause-②: no

This PR is a slice of #21908, the closure of the security middleware's principal-less hand-off (ADR-0096). It covers the identity and runtime producers. #21908 stays open for the deny, which lands last.

What moved

Each producer below reached the data engine with no principal and no isSystem. That is the hand-off, and it is not an authorization. Each one now takes the explicit system opt-in that already exists. ⛔ No new elevation API, no change to what any door authorizes, no accept-set change.

Row Position (function) Engine calls Route taken
17 plugin-auth auth-plugin.ts, the platform-admin OAuth client toggle route (/admin/oauth2/toggle-disabled) findOne + update sys_oauth_application withSystemContext, the wrapper better-auth's adapter already writes these rows through
18 plugin-auth scim-connection-service.ts verifyScimBearerToken findOne sys_scim_connection_credential isSystem: true in the read's trailing options
19 plugin-auth auth-manager.ts organizationHooks.beforeUpdateOrganization (the slug guard) findOne sys_organization, find sys_environment withSystemContext
21 runtime http-dispatcher.ts enforceProjectMembership find sys_environment_member isSystem: true as the read's query context

Row 20 moves nothing. I read every site, and each one already runs with the opt-in:

  • adopt-membership.ts adoptExistingMembership: its only caller hands it the adapter's withSystemContext engine.
  • membership-ended-session.ts endSessionClaimsForEndedMembership: all four calls pass { context: SYSTEM_CTX } (isSystem: true) as the trailing options, and the engine honours that argument on reads and writes.
  • The auth-manager.ts insert helper (settleSelfRegistrationGrant, with findPermissionSetRows): it reads and writes through withSystemReadContext, the deprecated alias of withSystemContext.

The auth-manager.ts edit (row 19) was made after #21872 landed, on a merge of origin/main that contains it.

Measured: no gate fires on any moved call today

An isSystem context short-circuits the gates the hand-off still runs before next(): package-managed, system-row, curated-capability, audience-anchor, engine-owned and delegated-administration. A move is neutral only if none of them fires on the producer's calls.

  • Static. Each gate is keyed to objects and verbs that none of these calls touch. The first four guard writes to sys_permission_set, sys_position, sys_capability and sys_position_permission_set. Engine-owned needs a userId. Delegated-administration guards writes to the RBAC link tables, sys_permission_set and sys_member. Rows 18, 19 and 21 are reads. Row 17 writes sys_oauth_application, which none of the gates names.
  • Instrumented. I added a local, uncommitted probe in security-plugin.ts. It recorded each principal-less, non-system context that reached the hand-off, with its stack, and each gate refusal of such a context. Over every run below it recorded 0 gate refusals. Every call of the card's functions reached the hand-off, so no gate had stopped it.

Per function, before → after (records at the hand-off):

Function dogfood subset dev boot runtime harness
toggle route (row 17) 5 → 0 (findOne 3, update 2) 5 → 0 —
verifyScimBearerToken (row 18) 0 → 0 1 → 0 —
beforeUpdateOrganization (row 19) 0 → 0 1 → 0 (sys_organization findOne) —
enforceProjectMembership (row 21) — — 2 → 0
row 20 functions 0 → 0 0 → 0 0 → 0
all records 1601 → 1596 300 → 293 39 → 37

Before = the base tree with the probe. After = the change with the probe: the dev boot and the harness on the final tree (9878b925), and the dogfood subset on the pre-merge commit 4239dd47, whose row 17 code is the same. The boot's background ticks (the outbox claims) make the totals differ by a few records between runs. The per-function counts are the reading.

  • Dogfood subset. Seven files that reach the card's functions: the two platform-admin route sweeps, the organization-update door, the two SCIM-enabled suites, org-admin reach and membership attribution. 57 tests passed both times. Only row 17 appears in the dogfood suite. This subset reproduces the full-suite census of the measure-first round (6003676228) for these rows exactly.
  • Dev boot. pnpm dev -- --fresh on showcase with SCIM enabled, driven as the seeded admin. It registers an OAuth client, toggles it twice, toggles a missing id, sends a SCIM request with an unknown bearer, and changes the default organization's slug. The answers were identical before and after: register 201, toggles 200 / 200 / 404, SCIM 401, slug update 200.
  • Runtime harness. A scratch file, deleted afterwards, booted a real engine with SecurityPlugin and called enforceProjectMembership for a member and a non-member. No open-source composition reaches row 21: no KernelResolver sets environmentId, and sys_environment_member is a cloud control-plane object. The answers were null and 403 both times.
  • Restored. The probe was reverted (security-plugin.ts blob 5b4ab280 equals HEAD), plugin-security was rebuilt, and ablation-dist-preflight --absent confirms the marker is gone from dist/. The positive control: 4 hits in dist/ while the probe was live.

One difference that is not a gate (row 17). Under the hand-off, the engine's static read-only strip ran on the toggle's update and dropped the updated_at the route supplies, with a WARN. Under isSystem the strip does not run. I compared the stored rows: on the SQL driver, both paths store disabled and an updated_at equal to the driver's own stamp. The only change is that the WARN line no longer appears on each toggle.

Pins (one per package) and ablations

  • plugin-auth/src/principal-less-producers-system-context.test.ts: a real engine with a context-recording middleware. The toggle route's findOne and update and the SCIM probe's findOne are isSystem. The route still answers 200 and flips the stored flag, and still answers 404 RESOURCE_NOT_FOUND. The verifier still resolves a known bearer to its connection, and still answers null for an unknown one.
  • plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts: both slug-guard reads are isSystem, and the guard still refuses with FORBIDDEN / 403 while an active environment exists. On an engine that refuses a principal-less, non-system context, the guard still refuses. The pre-existing catches are pinned as they stand: a read that throws ends the hook without refusing.
  • runtime/src/http-dispatcher.membership-system-context.test.ts: the membership read is isSystem, a member passes, and a non-member gets 403 PROJECT_MEMBERSHIP_REQUIRED. On an engine that refuses a principal-less, non-system context, the non-member is still refused. The pre-existing fail-open catch is pinned as it stands: a read that throws lets the request through.
  • Ablations. Each went through scripts/ablation-replace.mjs: the anchor hit once, the mutation was verified on disk, and the restore was proven (blob equals HEAD, git diff HEAD empty). Each pin imports its subject from src, so no build sat between the mutation and the run.
    • A, row 17, withSystemContext dropped: 2 red.
    • B, row 18, trailing context dropped: 2 red.
    • C, row 21, query context dropped (re-run on 9878b925): 3 red, including the refusing-engine non-member case.
    • D, row 19, withSystemContext dropped: 2 red, including the refusing-engine case.

Tests and gates (at 9878b925)

  • New pins, on 9878b925: plugin-auth 2 files, 9/9 passed. runtime 1 file, 5/5 passed.
  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 on 9878b925: 330 files, 4654 passed, 19 skipped. pnpm --filter @objectstack/runtime run typecheck: exit 0.
  • plugin-auth on 60c5f22c: the suite (126 files, 2607 passed, 10 skipped) and run typecheck (exit 0). The only commit since, 9878b925, touches runtime and the changeset, and plugin-auth imports neither.
  • The runtime suite caught a spelling of mine. The membership read first carried its context as a trailing third argument. Eight existing assertions read the read's two arguments: toHaveBeenCalledWith in http-dispatcher.test.ts and http-dispatcher.membership-skip-boundary.test.ts. They turned red. The context now rides inside the query instead. The opt-in is the same and so is the engine's reading (ObjectQL merges the two), and both suites pass unedited.
  • eslint --no-inline-config over the 7 changed .ts files: 7 files, 0 errors, 0 warnings. These 7 are the whole population whose lint verdict this diff can move. eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules), so no untouched file's verdict can change. The repo-wide pnpm lint is CI's.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 9878b925 derived 98 commands. All 98 ran and exited 0. The --ran reconciliation over the exit-coded record reads: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)". The same 98 also ran green on 60c5f22c.
  • The branch sits 3 commits behind origin/main (9dce6353). Those commits touch content/docs/permissions/sso.mdx and a rest test, none of this diff's files. fix(plugin-security): the packaged-permission-set lock refusal carries its guidance as userMessage #21902 is merged into main and contained in this branch.

Acceptance notes

  • The fail-open catches on rows 19 and 21 are unchanged. They are pre-existing, and row 21's is documented as deferred. This PR removes the path by which a principal-less deny would trip them: both reads are now isSystem. A read that throws for any other reason still skips the slug guard (row 19) or opens the membership gate (row 21). Both behaviours are pinned as they stand, so the seat can sequence them before the deny.
  • Row 19 in the open-source composition. sys_environment is not registered there, so the environment read throws before it reaches the engine middleware. The catch then ends the hook, and the slug guard never refuses in an open-source deployment. It acts only where the object exists. Measured on the dev boot: the slug change answered 200 and recorded no environment read at the hand-off.
  • NOT MEASURED: a cloud composition. An isSystem read also bypasses any host read hook keyed on the caller, such as a control-plane org-scope hook. I measured the six named gates only, and only in-repo.
  • mintScimConnectionCredential inserts without the opt-in. It has no runtime caller (tests only) and is not exported from the package entry, so nothing produces through it today. Noted, not changed.
  • The census page (content/docs/permissions/system-context.mdx) is current. --fix moved its held declaration count from 25 to 26, for the new trailing-options type on the SCIM probe. It asked for no anchors.

Generated by Claude Code

claude added 7 commits October 5, 2026 23:37
… explicit system opt-in

The platform-admin OAuth client toggle route reads and writes
sys_oauth_application through withSystemContext, the wrapper better-auth's
adapter already writes those rows through. The SCIM bearer verifier's
credential probe and the dispatcher's environment-membership read pass
isSystem: true in the read's trailing options.

Each call is authorized by its own door (the platform-admin judge, the
bearer digest, the membership gate itself); none now reaches the engine as
a context with no principal and no opt-in, the security middleware's
principal-less hand-off (ADR-0096). No door, answer or stored row moves.

Pins: a context recorder on a real engine (plugin-auth) and a find double
(runtime), plus the membership gate's answers on an engine that refuses a
principal-less context and its unchanged fail-open catch.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…r the three system opt-ins

The census page's held declaration count follows the new trailing-options
type on the SCIM credential probe (check-system-context-census --fix).
patch changesets for plugin-auth and runtime.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…it system opt-in

beforeUpdateOrganization reads sys_organization and sys_environment
through withSystemContext. The hook is the slug guard itself: the
organization id is the where, not the reader, so neither read reaches the
engine as a context with no principal and no opt-in (ADR-0096's
principal-less hand-off). The catches around both reads are unchanged.

Pins: both reads carry the opt-in; the guard still refuses on an engine
that refuses a principal-less context; the pre-existing catches still end
the hook without refusing.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
… that take the system opt-in

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
…uery, not the trailing options

Same opt-in, same engine reading (ObjectQL merges the query's context with
the trailing one). Spelled inside the query so the existing membership
suites, which assert the read's two arguments, stay unchanged.

Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/runtime, touching 7 documentable anchor(s).

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-flow.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/api/environment-routing.mdx (via enforceProjectMembership (symbol, a method of class HttpDispatcher))
  • content/docs/automation/hook-bodies.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/automation/webhooks.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/kernel/contracts/data-engine.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/kernel/contracts/index.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/kernel/events.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/permissions/attachments-access.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/permissions/field-level-security.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/permissions/record-view-auditing.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/permissions/rls.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/permissions/system-context.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/protocol/objectql/query-syntax.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/protocol/objectql/schema.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/ui/react-pages.mdx (via findOne (symbol, a method of interface CredentialEngine))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/releases/v16.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/releases/v17/17-0.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/releases/v17/17-5.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/releases/v17/17-6.mdx (via findOne (symbol, a method of interface CredentialEngine))
  • content/docs/releases/v17/index.mdx (via findOne (symbol, a method of interface CredentialEngine))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 36 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 9dce635337c2cc42a4149aa49289ad77d172363d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9378e862d9522e8d4b3b335b064f391cbfa7dde0 — the merge of head 9878b925fc207120aae9966a26929a58d274c268 into base 9dce635337c2cc42a4149aa49289ad77d172363d, 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 9378e862d9522e8d4b3b335b064f391cbfa7dde0 && git checkout 9378e862d9522e8d4b3b335b064f391cbfa7dde0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9dce635337c2cc42a4149aa49289ad77d172363d 9878b925fc207120aae9966a26929a58d274c268 && git checkout -B drift-repro 9dce635337c2cc42a4149aa49289ad77d172363d && git merge --no-ff 9878b925fc207120aae9966a26929a58d274c268

node scripts/docs-audit/affected-docs.mjs --json 9dce635337c2cc42a4149aa49289ad77d172363d

⚠️ 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 9dce635337c2cc42a4149aa49289ad77d172363d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 6, 2026 03:05
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 6, 2026 03:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 131b937 Oct 6, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21912-principal-less-producers-identity branch October 6, 2026 03:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…cision in words instead of a tracker number (stage 23) (objectstack-ai#21947)

Part of objectstack-ai#20749
Clause-②: no

Stage 23 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the last name-ordered `ui/` group: the 16 id-bearing
test files directly under `packages/spec/src/ui/` from
`view-item-config-type.test.ts` to `widget.test.ts`. Those files carried
100 messages and 106 tracker ids, citing 53 records. All 106 now either
state what their record decided, in words (form D), or are dropped where
the title already says it. No needle sits in this group. Text only: no
assertion, identifier, test count or code comment changes, and no file
is renamed.

## Census at the base (`9e33ee7c59`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`), byte-identical to the copies stages
10 to 22 used. A literal counts as a test title when its folded message
is argument 0 of a `describe` / `it` / `test` call, `.each` / `.skip` /
`.only` chains included. Everything else is an "other" string.

The worktree was cut from `origin/main` at `9e33ee7c59`, the claim's
base. Both instruments read **571 messages / 604 ids in 127 files**, the
seat's reading and stage 22's head reading.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 167 | 128 / 138 | 26 / 29 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ui/` (this PR: 16 of the 21 files) | 21 | 107 / 113 | 97 / 103 | 10 /
10 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **127** | **571 / 604** | **523 / 553** | **48 / 51** |

The group reads **100 messages / 106 ids in 16 files**, the seat's
figures file for file:

| file (under `ui/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `view-item-config-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-metadata-schema.test.ts` | 8 / 8 | 8 / 8 | 0 |
| `view-metadata-type.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `view-overlay-options-bag.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `view-overlay-options-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-overlay-owner-hidden-retirement.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-overlay-viewkind-arm.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `view-overlay-viewkind-type.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view-strictness-batch18.test.ts` | 10 / 11 | 10 / 11 | 0 |
| `view-submit-redirect-url.test.ts` | 5 / 5 | 5 / 5 | 0 |
| `view-union-branch-focus.test.ts` | 7 / 7 | 6 / 6 | 1 / 1 |
| `view-union-diagnostics.test.ts` | 4 / 5 | 4 / 5 | 0 |
| `view-union-retirement-prescription.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `view.test.ts` | 39 / 43 | 38 / 42 | 1 / 1 |
| `widget-i18n-retirement.test.ts` | 3 / 3 | 2 / 2 | 1 / 1 |
| `widget.test.ts` | 1 / 1 | 1 / 1 | 0 |
| **16 files** | **100 / 106** | **97 / 103** | **3 / 3** |

Three more test files sit in the same name range and carry no id
(`view-item-owner-hidden-retirement.test.ts`,
`view-list-tabs-retirement.test.ts`, `vocabulary-derivation.test.ts`).
The three "other" strings are rewritten and declared to the text-only
tool: the table label at `view-union-branch-focus.test.ts:139`, which
prints inside two `for … of` test titles, and the expect messages at
`view.test.ts:4099` and `widget-i18n-retirement.test.ts:135`.

- **Controls.** Lit: `ui/notification.test.ts` and
`api/api-error-code-type.test.ts`, outside the group, read 1 id each at
the base and at the head. Dark: `view.test.ts` reads 0 at the head while
92 of its comment lines still carry a number. Planted in a scratch tree:
an id put into a `widget.test.ts` title reads 1 / 1 (`title:describe`),
and an id put into a `view-metadata-type.test.ts` comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same as the gate
pattern in 15 of the 16 files at the base. `view.test.ts` reads 2 more,
and keeps them at the head: the CSS colours `'#00cc00'` (`:2770`) and
`'#22c55e'` (`:3537`), fixture values that cite nothing.
- **At the head:** 471 messages / 498 ids in 111 files. The 16 files
read 0 / 0, `ui/` reads 7 / 7, and no other file moved.

## How the area was chosen

`ui/` has no subdirectory test file with an id, so it is taken in
name-ordered file groups near the ~100-id bound. Stage 22's re-cut named
this group at 106 ids, and this census reads 106, so no re-cut was
needed. `view.test.ts` (43 ids) is one file inside one text-only proof
here, so it is not split.

**`ui/` after this PR** reads 7 / 7, all kept items: stage 20's
`component-props-unknown-members.pin.test.ts:322`, stage 21's four
colour literals (`dashboard-chart-structure-refusal.test.ts:94`,
`dashboard.test.ts:124`), and stage 22's two needles
(`notification.test.ts:123`, `strictness-batch14.test.ts:395`).

**Named for the next stages** (cut from the head census, 471 / 498):
- **`api/`, 201 ids in 40 files**, with no subdirectory. Its first
name-ordered group near the bound is `ai-agents-envelope.test.ts`
through `package-lifecycle.test.ts`: 27 files, 100 messages / 106 ids
(95 / 101 titles, 5 / 5 other: `auth.test.ts`,
`discovery-environment-subset.pin.test.ts` and three in
`export-job-family-retirement.test.ts`). The second is
`plugin-rest-api.handler-status-retirement.test.ts` through
`zod-issues-to-fields.test.ts`: 13 files, 89 / 95, `protocol.test.ts`
alone 50.
- `system/` 167, two stages. The files directly in `src/`, 120, one.
- The needles: the three docblock needles, the kept `:322` and stage
22's two. One stage, with an at-tier review.

## What each id became

- **25 literals (27 ids)** now state a decision in words.
- **20 literals (22 ids)** get their subject back in words, where the
number stood for a thing.
- **55 literals (57 ids)** drop a number the title already explains.

Every cited record was fetched with all its comments through REST, and
its decision was read from its ruling, ACCEPT and landing comments: 53
records, 51 answer 200 and 2 answer 404. Five citations are objectui's
and were read from objectui: `objectui#5233`, `objectui#2231` (cited
bare at `view-strictness-batch18.test.ts:309`), `objectui#6237` (cited
bare at `view.test.ts:949`), `objectui#5435` and `objectui#3289`. Three
same-number records in the other repository were fetched first and set
aside: `objectstack#2231` is a version-packages PR, `objectstack#6237` a
datasource PR, and `objectui#2998` a form PR; `framework#1894 / objectstack-ai#2998`
are this repository's objectstack-ai#1894 and objectstack-ai#2998 under its old name. The two that
answer 404 were read from what landed:
- **objectstack-ai#9933**, from its landing commit `d5552ca13f` ("admit columnState as
an explicitly runtime-only view-overlay key") and the CHANGELOG entry
for `d5552ca`;
- **objectstack-ai#11195**, from PR objectstack-ai#11458, the PR that closed it
("UserActionsConfigSchema adopts group / hideFields / rowColor (ruled A
on objectui#5435)").

One citation names the wrong record, and the titles now state what
landed instead. `objectstack-ai#3896 close-out` (twice in `view.test.ts`) names objectstack-ai#3896,
the sharing-rule criteria card, which records no decision about these
keys; the two titles state the decision from the landed tombstones of
`form.defaultSort` and `view.responsive` / `view.performance`, as stage
20 did for `action.test.ts`.

Where a record's first decision was corrected later, the title follows
the correction:
- **objectstack-ai#6926:** its first triage direction retired the `groups` alias; the
measurement found live consumers, and the maintainer re-ruled A, a fold
at the producer. The two titles say "the producer-side `groups` fold"
and "folds onto `sections`".
- **objectstack-ai#7025 and objectstack-ai#7741:** objectstack-ai#7025 froze the acceptance face; objectstack-ai#7741's ruling
then moved it, and later retirements moved it again, each pinned. The
title says "frozen by the diagnostics work; every move since is
deliberate and pinned", not "moved only once".
- **objectstack-ai#7510:** the "[objectstack-ai#7510] ⛔ the acceptance face did not move" describe
sits beside two ruled moves recorded in its own comments, so the title
now names what did not move it: "⛔ the branch focusing did not move the
acceptance face".

**Stated in words:**

| record | literal (under `ui/`) | now reads | the decision |
|:--|:--|:--|:--|
| objectstack-ai#5599 | `view-metadata-schema.test.ts:95` | "REJECTS a bare `{}` — the
pin this line used to make, reversed by the identity precondition" |
Maintainer ruling 2026-08-06, direction B: a minimal identity
precondition ahead of the union's four members; each member's `.strip()`
is untouched. |
| objectstack-ai#7741 | `view-metadata-schema.test.ts:125` | "… NO object binding — a
row no read path could serve, with located guidance" | Maintainer ruling
2026-08-12, direction B: a row that cannot be expanded or served by any
read path is not stored and badged valid; the inline arm requires the
binding, refused with `defineView`'s guidance. |
| objectstack-ai#5599 | `view-metadata-schema.test.ts:215` | "identity precondition —
a body must read as a view before any member judges it" | The same
direction B. |
| `objectui#5233` | `view-metadata-schema.test.ts:413` | "… a
`columnState`-only patch (the patch-only write the console persists)" |
Maintainer ruling 2026-08-12 (on objectstack-ai#7494): `persistViewPatch` stores the
patch only, not the merged base. |
| objectstack-ai#17152 | `view-overlay-owner-hidden-retirement.test.ts:335` | "… names
the family's D2 conversion (ruled: a D3 entry per family, even beside a
lossless D2)" | Ruling B (director seat, 2026-09-10, upheld 2026-09-11):
one D3 semantic entry per retired family, beside its D2 conversion even
when D2 is lossless. |
| objectstack-ai#7494 | `view-overlay-viewkind-arm.test.ts:102` | "the console %s
toggle (a patch-only write, as ruled) is ACCEPTED on listOverlay" |
Maintainer ruling 2026-08-12: the overlay store is org-wide, and the
toolbar write stores the patch only. |
| objectstack-ai#4001 | `view-strictness-batch18.test.ts:91` | "批 18, unknown keys
refused — the doors these shapes are reachable through" | The strictness
campaign: an unknown key on the authorable surface is refused, not
silently stripped. |
| objectstack-ai#15469 | `view-strictness-batch18.test.ts:149` | "… a CLOSED entry,
and since the renderer-ahead `.passthrough()` was removed a CLOSED
parent too" | Maintainer ruling A (decision batch objectstack-ai#41, 2026-09-05):
every key the gantt and tree renderers read is declared, and both
`.passthrough()` calls go. |
| objectstack-ai#5074 | `view-strictness-batch18.test.ts:364` | "[RESOLVED by the
ruled split] ViewItemSchema SPLIT — …" | Maintainer ruling A
(2026-08-04): split — a strict authoring `ViewItemSchema` and a reopened
wire member in the union. |
| objectstack-ai#5074 | `view-strictness-batch18.test.ts:403` | "[RESOLVED with the
ruled split] ListViewSchema.sort CLOSED — …" | The split's scope
addendum: the wire door strips the console's decoration keys before
validating, so `sort[]` closed again with no declared `id`. |
| objectstack-ai#7025, objectstack-ai#7741 | `view-union-diagnostics.test.ts:246` | "the acceptance
face of ViewMetadataSchema — frozen by the diagnostics work; every move
since is deliberate and pinned" | objectstack-ai#7025's sweep rule: the diagnostic
face improves, the acceptance face does not move; objectstack-ai#7741's ruled binding
requirement is the first pinned move since. |
| objectstack-ai#9463 | `view.test.ts:342` | "viewMode — the granularities the gantt
renderer honours, measured" | Declare `viewMode` with exactly the
granularities objectui's `GanttView` honours, measured, not invented
(the spec half of objectui#5074's ruling). |
| objectstack-ai#17053 | `view.test.ts:441` | "the legacy string `sort` clause is
retired — one spelling, the array" | objectui's ruling (director batch
objectstack-ai#77, option B): one spelling, the array; the spec stops producing the
string. |
| objectstack-ai#13704 | `view.test.ts:873` | "wizard tightening — sections are the
steps, the inert step keys are refused, no key is added" | The ruled
shape of objectstack-ai#13622 (2026-08-31): sections are the steps, the wizard-inert
step keys are refused at parse, zero new keys. |
| `objectui#6237` | `view.test.ts:949` | "… stay accepted on
tabbed/simple (the ruled split confines it to wizard steps)" |
Maintainer ruling 2026-08-30 (director batch objectstack-ai#3): `FormSectionConfig` is
split, so tabbed sections take a predicate and wizard steps carry none.
|
| `objectui#2231` | `view.test.ts:2876` | "ListColumnSchema summary
object form and prefix — spec-owned, no longer an objectui-local
extension" | The derive-by-reference unification: `677b591` moved
`prefix` and the `{ type, field }` `summary` form into the spec, closing
objectui's local `.extend()`. |
| objectstack-ai#3896 (see above) | `view.test.ts:3144` | "FormViewSchema — retired
defaultSort (audit close-out: nothing read it)" | The landed tombstone:
`form.defaultSort` was removed because nothing read it. |
| `objectui#5435` | `view.test.ts:3386` | "… defaults asymmetry, copied
from what the renderer reads" | Ruling A (2026-08-22): the spec adopts
`group` / `hideFields` / `rowColor`, with the defaults copied from
`ListView`'s reads. |
| objectstack-ai#3896 (see above) | `view.test.ts:3826` | "ListViewSchema — retired
responsive/performance (audit close-out: no renderer read them)" | The
landed tombstones: no renderer or runtime read either key. |
| objectstack-ai#7176 | `view.test.ts:3847` | "ListViewSchema — retired
striped/bordered/virtualScroll (every reader only passed them through)"
| Maintainer ruling 2026-08-10: retire under ADR-0049, since every
measured reader copied the keys forward and none applied them. |
| objectstack-ai#5832 | `view.test.ts:4099` (expect message) | "`HttpMethodType` was
renamed to `HttpMethodSubset`" | Maintainer ruling 2026-08-06: rename
the 5-value subset; the 7-value `HttpMethod` keeps its name and its wire
contract. |
| objectstack-ai#16577, objectstack-ai#13817 | `view.test.ts:4708` | "… the `type: 'calendar'` axis
is NOT gated by the `allowedVisualizations` check (ruled: a completeness
warning)" | Ruling B (director seat, 2026-09-11): the objectstack-ai#13817 guard keeps
gating `allowedVisualizations` only; the `type: 'calendar'` route is
carried at warning by `checkViewCompleteness`. |
| objectstack-ai#19228 | `view.test.ts:4793` | "view row bound — `pagination.pageSize`
is the one bound; no per-kind `limit` on the view configs" | Maintainer
ruling D (2026-09-23): one row bound per view, `pagination.pageSize`;
the per-kind `limit` was removed before it shipped. |
| objectstack-ai#5055 | `widget-i18n-retirement.test.ts:70` | "ui/ widget + i18n
family retirement — doorless vocabularies removed, not tightened" |
Maintainer ruling A (2026-08-06): ADR-0049 enforce-or-remove retires the
unreachable widget and locale vocabularies; closing them would only
dress a dead slot as a checked one. |
| `objectui#3289` | `widget-i18n-retirement.test.ts:196` | "the
surviving `error` slot is exactly the one objectui renamed its own slot
onto, with no alias" | objectui followed the spec: its widget
`errorMessage` slot became the spec's `error`, with no alias, and the
form renderer produces it. |

**Subject back in words** (20 literals): "(binding pair, objectstack-ai#7741)" becomes
"(the object + viewKind binding pair)"; "union error behaviour (objectstack-ai#5014)"
becomes "union error behaviour (where a branch prescription gets
buried)"; the five `objectstack-ai#7496` prefixes become "the ruled redirect `url`
shape —" (twice) and "ruled bullet 1 / 2 / 3 —", the file's own name for
the ruling's three bullets; "the pre-objectstack-ai#7510 ranking" becomes "the pre-fix
ranking"; "the objectstack-ai#4001 wrap prescription" becomes "the `defineView` wrap
prescription"; the acceptance-face describe at
`view-union-branch-focus.test.ts:261` (above); "the objectstack-ai#6926 fold" becomes
"the producer-side `groups` fold"; "(objectstack-ai#7025 membership)" becomes "and the
union corpus pins it accepted"; "(objectstack-ai#8321/objectstack-ai#12174)" becomes "(a negative or
fractional scale)", what that test probes; "the exact declaration objectstack-ai#9340
exists to make legal" and "the gap objectstack-ai#9340 closes" name "this block";
"(acceptance criterion, objectstack#11195)" becomes "(the acceptance
criterion for adopting the three keys)"; "(objectstack-ai#7176 rides …)" becomes "(the
retirement rides …)"; "the axis objectstack-ai#13817 does not gate" becomes "the axis
the `allowedVisualizations` check does not gate"; "zero holders after
objectstack-ai#5055" becomes "after the widget + i18n retirement"; "objectstack-ai#5055 — the one
surviving shape" becomes "the widget retirement — the one surviving
shape".

**Dropped where already stated** (55 literals, 57 ids). A number goes
only where the title already says its decision. Examples: the four
`[objectstack-ai#19920]` prefixes ("… typed by its arm, not unknown", "… a parsed view
body, not unknown") and `[objectstack-ai#19871]`; the six `[objectstack-ai#20051]` describes on the
`options` bag and the one in
`view-union-retirement-prescription.test.ts`; the eight `objectstack-ai#20186`
describes ("a column-less list PATCH is judged by the list member",
"each member judges ONE viewKind", …); the three `[objectstack-ai#6391]` describes,
three `[objectstack-ai#7510]` titles and the `[objectstack-ai#21180]` table label ("the retired
`publicPicker` key itself"); "(objectstack-ai#3095)", "(objectstack-ai#5074)" after "`.strip()`
round-tripping is untouched", "(objectstack-ai#9933)" after "runtime-only overlay
key", and the `view.test.ts` tails `(objectstack-ai#15469)`, `(objectstack-ai#6926)`, `(objectstack-ai#12174)`,
`(objectstack-ai#19088)`, `(objectstack-ai#7084)`, `(objectstack-ai#9340 — …)`, `(objectstack-ai#17499)`, `(objectstack-ai#18791)`,
`(framework#1894 / objectstack-ai#2998)`, `(objectstack-ai#5073 — …)` x2, `(objectstack-ai#8010)`, `[objectstack-ai#4688]`,
`[objectstack-ai#4691]`, `(objectstack-ai#6416 / objectstack-ai#6619)`, `(objectstack-ai#17063)`, `(objectstack-ai#16885)`, `(objectstack-ai#13817)` and
`(objectstack-ai#16577)`. The batch label `批 18` stays, in stage 20's "批 19, unknown
keys refused" form on the file's first describe and bare on the other
four. `W2` stays: the file's own header defines W1 and W2. The commit
`ce70876e` stays in "(measured on origin/main ce70876)": a commit, not
a tracker id.

**No file is renamed.**

## Readers

- **Test-name filters:** none. No tracked script, workflow or package
config passes `-t` / `--testNamePattern` (the 3 hits are `docker build
-t`, `type -t` and `lsof -t`).
- **Snapshots:** none. No `__snapshots__` directory is tracked under
`packages/spec`, and none of the 16 files calls a snapshot matcher.
- **Projects:** none of the 16 files is in the `repo` project
(`packages/spec/vitest.repo-tests.json`); all run in `local`.
- **By substring:** every old literal, its id-bearing fragment and a
window around each id (299 needles) was searched with `git grep` at the
base, across the tracked tree outside its own file. No gate, doc,
filter, snapshot, QA checklist entry or `scripts/check-*.mjs` self-test
reads one. The 16 hits are windows that share wording with code comments
and one CHANGELOG line: 15 comments in the migration registry, its
semantic entries and `view-list-tabs-retirement.test.ts` read "(ruling B
on objectstack-ai#17152)", and `packages/spec/CHANGELOG.md:30342` reads "runtime-only
overlay key (objectstack-ai#9933)".
- **Cross-references by id:** two places name the `columnState` section
of `view-metadata-schema.test.ts` as "§objectstack-ai#9933": a code comment at
`packages/spec/scripts/strictness-ledger.test.ts:380` and the
`view.zod.ts` row of
`docs/audits/2026-07-unknown-key-strictness-ledger.md`. Neither matches
a string; both point a reader at the section, which still carries
"columnState — runtime-only overlay key" in its title and its banner
comment. A code comment and an audit record are not this card's share,
so neither is edited.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. This stage declares three lines:
`view-union-branch-focus.test.ts:139`, `view.test.ts:4099` and
`widget-i18n-retirement.test.ts:135`.

- **Result:** 16 of 16 files SAME on all three legs, with the per-file
counts predicted in writing before the run.
- **Totals:** 100 changed string leaves in 100 literals: 97 titles and 3
declared. The diff's `+` and `-` lines are exactly the 100 planned lines
as multisets, and every file keeps its line count.
- **Controls (14 of 14 as predicted on the first run, on scratch copies,
each anchor hit once):** identifier rename DIFF; numeric literal DIFF;
comment edit COMMENT DIFF; a non-title string given an id VIOLATION; a
rewritten title given a new id VIOLATION; a title that was id-free at
base edited VIOLATION; one title reverted to base SAME; an `it.each` row
given an id VIOLATION; an undeclared expect message changed VIOLATION; a
title re-split into a `+` chain DIFF; a declared expect message reverted
to base SAME; a declared expect message given a new id VIOLATION; the
declared table label given a new id VIOLATION; a template-literal title
given a new id VIOLATION.
- **Templates and tables:** one `.each` title changes,
`view-overlay-viewkind-arm.test.ts:102`, a `%s` template whose
placeholder and rows are untouched. The table label at
`view-union-branch-focus.test.ts:139` feeds two `for … of` template
titles, which print it whole. The template-literal title at `:173`
changes only its text before `${label}`.

**Test counts:** the 16 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 862 tests in 16 files, all passed, with the same count and
status sequence per file in 16 of 16. 574 full test names change, and
each changed name equals the base name with the planned replacements
applied (0 mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
16 touched files are in it, and no `*.test.ts` at all. The controls
`src/ui/view.zod.ts`, `src/ui/widget.zod.ts` and `dist/index.mjs` are in
it.
- In the built `dist/`, a new phrase and an old one each read in 0
files. The control `Unrecognized key` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `75022207b3`)

- `pnpm turbo run build` over all packages: 71 / 71, through the shared
verify lock (`VERDICT command-exit 0`).
- `@objectstack/spec`:
  - `vitest run --project local`: 619 files, 18471 passed, 1 todo.
- `typecheck`: exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 16 group
files, counted by path with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date, against the
`dist/` the build above wrote.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 22, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN, every family with its exit code recorded.
The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 16 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 16 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 200 changed lines (+100
/ -100).
- A control-byte scan over the 16 changed files finds none.

## `main` since the base

Re-fetched just before this PR opened, `origin/main` was four commits
past the base (`1f0469655f`: objectstack-ai#21939, objectstack-ai#21937, objectstack-ai#21943, objectstack-ai#21928). They touch
32 files, none of the 16; two are under `packages/spec` (a step-18
semantic migration entry and the migration registry, neither a test
file). So `main` was not merged. The census on that tree still reads 571
/ 604 in test files and 0 elsewhere. `git merge-tree` onto `1f0469655f`
is clean, and none of the 8 open PRs touches any of the 16 files.

## Acceptance notes

- **The `{{record.FIELD}}` title.**
`view-submit-redirect-url.test.ts:187` keeps its literal placeholder
after "ruled bullet 2 —"; this body spells it with `FIELD` because the
platform strips angle-bracket fragments from PR text.
- **Same-id test titles in this card's later stages** go with those
stages: 5 lines in `packages/spec/src`,
`api/api-error-code-type.test.ts:71` ("[objectstack-ai#19920] …"),
`stack.test.ts:1510` ("(objectstack-ai#17063)"), `system/stack-server.test.ts:93` and
`system/translation.test.ts:672` / `:767` ("(objectstack-ai#4001)").
- **Same-id test titles in other packages** stay: 46 lines in 11
packages (`metadata-protocol` 11, `objectql` 8, `spec/scripts` 8, `lint`
6, `rest` 4, `plugin-auth` 3, `cli` 2, and one each in
`plugin-security`, `plugin-sharing`, `qa/dogfood` and
`service-automation`), each package's share under the objectstack-ai#20513 lane
children. The three `plugin-auth` titles cite objectstack's objectstack-ai#5233, a
different record from `objectui#5233`.
- **Code comments with live ids** remain in these files and their
sources, among them the "§objectstack-ai#9933" cross-references above, the `[objectstack-ai#7741]` /
`[objectstack-ai#21180]` corpus notes in `view-union-branch-focus.test.ts` and
`view-union-diagnostics.test.ts`, and the `objectstack-ai#5055` banners in
`widget-i18n-retirement.test.ts`. Code comments are not this card's
share.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…rganization slug guard fail closed when their own read faults (objectstack-ai#21954)

Fixes objectstack-ai#21941
Clause-②: no (narrowing)

Two access guards used to let a request through when their own read
faulted. Each now tells three answers apart: the read answered
(unchanged), the object is not registered in this composition (the guard
does not apply, decided from the registry), and a registered read that
cannot answer (refused with `503 SERVICE_UNAVAILABLE`). This follows the
triage direction in the card's triage comment: fail closed the
platform's own way, not as allowed and not as the guard's own `403`.

## What changed

### `@objectstack/runtime` — `HttpDispatcher.enforceProjectMembership`
(the environment-membership gate)

- **The read throws** ⇒ the gate throws
`AuthzStoreUnavailableError('sys_environment_member', cause)` out of
`dispatch()`. This is the same loud outage the identity step and the
`/keys` and activation domain gates already raise for an authorization
input they could not read. The transport answers `503` with a declared
`SERVICE_UNAVAILABLE` envelope. The old catch logged at debug level and
returned `null` (admit).
- **No ObjectQL engine resolves on the request's kernel** ⇒ refused the
same way. It used to return `null` (admit).
- **The request engine's registry does not register
`sys_environment_member`** ⇒ the gate does not apply and nothing is
read. The question is `ql.registry.getObject(...)`, the same lookup the
engine's verbs make before refusing an unregistered name. An engine
whose registry cannot be asked is read as before, and a fault on that
read refuses.
- Healthy reads keep their answers byte for byte: a member is admitted
(and cached), and a non-member gets `403 PROJECT_MEMBERSHIP_REQUIRED`.

### `@objectstack/plugin-auth` —
`organizationHooks.beforeUpdateOrganization` (the organization slug
guard)

- **The `sys_organization` read or the `sys_environment` read throws** ⇒
better-auth `APIError('SERVICE_UNAVAILABLE')` (`503`), via the new
module helper `slugGuardReadFaultApiError`. The driver's error rides
`cause`. Both catches used to `return`, which ended the hook without
refusing, so the slug changed.
- **The engine does not register `sys_environment`** (asked through
`getSchema`) ⇒ the guard does not apply, and it is checked before either
read. Nothing is read.
- **No data engine** ⇒ the guard does not apply (unchanged `return`, now
stated in code).
- Healthy reads are unchanged: a slug change while an active environment
references the organization is still `403 FORBIDDEN`, and anything else
is allowed.

## The composition questions the triage asked to measure

All readings are on `objectstack` at this branch's base, `d16b9fbf`.

- **Can a composition that serves environment-scoped doors lack
ObjectQL?** No, for any composition built from this repository. Only a
host `KernelResolver` writes `context.environmentId`, and this
repository registers none (`git grep` over `packages/**` finds
`kernel-resolver` read in three places and registered in none). The gate
reaches its read only for a caller the `auth` service signed in. This
repository's `auth` provider, `AuthPlugin`, declares `dependencies =
['com.objectstack.engine.objectql']` (`auth-plugin.ts:291`). So the
no-engine branch refuses like any other fault.
- **Can a composition that mounts the organization-update door lack a
data engine?** Only a standalone `AuthManager` can. `AuthPlugin` reads
`ctx.getService('data')`, which throws when the service is unregistered,
and the plugin hard-depends on ObjectQL. A standalone `AuthManager` runs
on better-auth's in-memory store, and no engine there registers
`sys_environment`. So the guard does not apply there, and the code says
so.
- **Is `sys_environment` registered in the open-source composition?**
No. No package in this repository defines it: `git grep` finds only
lookup-field references and the spec constant
`CLOUD_PROVIDED_OBJECT_NAMES`, and `platform-objects/src/index.ts` says
the `sys_environment*` objects are cloud-only. Pinned against the real
ObjectQL registry: an engine that holds exactly `authIdentityObjects`
answers `getSchema('sys_environment') === undefined`, and the guard then
reads nothing. The fix decides this from registration, not from catching
the throw.
- **`sys_environment_member`** is also in `CLOUD_PROVIDED_OBJECT_NAMES`.
The membership gate therefore asks the same registry question.

### Not measured: the cloud composition

`NOT MEASURED: the cloud composition's membership gate, reason: this
session was refused attaching objectstack-ai/cloud.` Two things there
decide how this lands, and only the cloud tree can answer them:

1. Does the per-environment engine that `context.kernel` resolves
register `sys_environment_member`? `packages/client/CHANGELOG.md`
(11.0.0) records cloud#533 as retiring that object. If it is not
registered there, this PR changes nothing in that composition: the gate
used to admit through the caught throw and now admits by declaration.
2. If the engine does register it, does the read succeed? If that read
faults on every request, every signed-in, non-platform-organization
request on an environment-scoped door answers `503` after this lands.
That is the triage's direction ("a real fault on a registered read
refuses"), but it would be a visible change in that deployment. The
cloud seat should confirm it before release.

## The HTTP door for the slug guard

This was measured with a throwaway test that drove the real better-auth
organization-update endpoint through `AuthManager.handleRequest`, over a
memory engine double. The test was not committed.

| engine | answer | slug after | `sys_environment` reads |
|---|---|---|---|
| does not register `sys_environment` | `200` | changed | 0 |
| registers it, read faults | `503`, body `{ message }` | unchanged | 1
|
| registers it, one active environment | `403`, body `{ message }`
(control) | unchanged | 1 |
| registers it, no environment | `200` | changed | 1 |

On the `503` leg, `handleRequest` also logs one server-side line
(`better-auth returned error: 503 …`). The `503` body follows
better-auth's native shape, `{ message }`, the same shape the guard's
own `403` refusal uses. No `code` field is added.

## Tests

The pins PR objectstack-ai#21939 added now assert the refusal. Each superseded
assertion is quoted in place.

-
`packages/runtime/src/http-dispatcher.membership-system-context.test.ts`,
13 tests:
- a read that throws (plain, on a registered engine, and an engine-side
`PERMISSION_DENIED`) is refused, asserted on `code`, `status` and
`object`;
  - no engine is refused;
  - an unregistered object reads nothing;
- a healthy registered read still admits a member and refuses a
non-member;
- on the wire, through `createDispatcherPlugin` on a real
`ObjectKernel`: member `501` (admitted, no automation service),
non-member `403 PROJECT_MEMBERSHIP_REQUIRED`, read fault `503
SERVICE_UNAVAILABLE` (the envelope parses against `ApiErrorSchema`).
-
`packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`,
10 tests:
- an organization read fault refuses `503` and the environment read is
never made;
- an environment read fault refuses `503`, both on a registering engine
and on one whose registry cannot be asked;
  - healthy registered reads still refuse and allow;
  - an unregistered `sys_environment` reads nothing;
- with the real ObjectQL registry, `authIdentityObjects` alone reads
nothing, and a real engine fault (no driver) refuses `503`.
- Fixture triage, three runtime files. They did not pin the defect:
- `http-dispatcher.membership-skip-boundary.test.ts` and
`packages-unscoped-environment-binding.test.ts` answered the membership
read from a registry that registered nothing, which the real engine
refuses with `OBJECT_NOT_FOUND`. They now register
`sys_environment_member`.
- `domains/meta-verb-fallthrough.test.ts` composes no ObjectQL engine at
all, and the gate now refuses that composition. The gate is not that
file's subject, so the file sets `enforceProjectMembership: false`, as
the dispatcher option documents for tests.
- Fixtures with `environmentId: 'platform'` and an engine whose registry
does not register the member object (for example
`meta-state-plural-tolerance`) used to pass the gate through a swallowed
`TypeError`. They now pass by the registry's answer. The outcome is the
same.

### Ablation

Each negative pin was ablated: the fail-open answer was put back, the
pin turned red, and the file was restored. The mutation went through
`scripts/ablation-replace.mjs`: the anchor must hit, and the blob change
and restore are verified on disk, with a script `trap` plus a HEAD-blob
hash proof. The subjects are imported relatively
(`./http-dispatcher.js`, `./auth-manager`), so no `dist/` leg applies.
Ablation was run at head `6966166a0`, with the same red counts as an
earlier run at `b275f81b`.

| leg | mutation | red |
|---|---|---|
| A1 | membership read catch → `return null` | 4/13: the three
read-fault pins, and the wire pin `expected 501 to be 503` (a non-member
admitted to the domain) |
| A2 | no engine → `return null` | 1/13 |
| B1 | organization read catch → `return` | 2/10 (`the slug change was
let through …`) |
| B2 | environment read catch → `return` | 2/10 |

The first A2 attempt was a no-op. Its replacement re-contained the
anchor, the tool refused it (anchor 1 → 1), and no test ran. A2 was
redone with a different replacement.

### Results at head `6966166a0`

- `pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2`: 330 files, 4662 passed, 19 skipped, 0 failed.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2`: 126 files, 2612 passed, 10 skipped, 0 failed. This ran
at `b275f81b`. Since then `auth-manager.ts` is byte-identical, and the
one changed test file was re-run at the head: 10/10.
- `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter
@objectstack/plugin-auth typecheck`: both exit 0, including
`check:test-typecheck`.
- `node scripts/pm/dispatch-gates.mjs --commands` derived 75 gate
families from this diff, all run at this head, all exit 0. Reconciled
with `--ran`: 75 derived, 75 run, 0 NOT-MEASURED, every exit code
recorded. These include `check:dispatcher-error-vocabulary`,
`check:auth-mount-ledger`, `check-system-context-census`,
`check-tenant-audit-census`, `check-platform-object-tenancy-census`,
`check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`,
`check:engine-double-contract`, `check:slot-lookup`,
`check:dual-build-cjs-loads` (106 require entry points across 66
packages load) and `check-adr-0087-registration`.
- `check-changeset-no-major.mjs --base origin/main --event` with this
body's `Clause-②` line: exit 0. The level axis reads `no (narrowing)`,
and no moved package is graded `patch`.
- Lint, narrowed to the 7 changed `.ts` files with `eslint
--no-inline-config --format json`: 0 errors and 0 warnings. That is 7
files linted and none ignored. `eslint.config.mjs` enables no type-aware
linting (no `parserOptions.project`), so this diff cannot move a verdict
on an untouched file. The full `pnpm lint` is left to CI.
- One ledger row was added: `scripts/engine-double-contract.pinned.json`
now records the new pinned `findOne` double, written by
`check-engine-double-contract --write`.

## Acceptance notes

- **Two neighbouring fail-opens in `enforceProjectMembership` are out of
this card's scope and untouched.** The session-read catch ("Auth
resolution failed — do not block the request on RBAC") and the `if
(!userId) return null` fall-through both remain. Fixing either in place
is not mechanical: the catch also covers a composition with no auth
wired, which needs the registry's classified lookup. It is also
unmeasured whether either is reachable through a public door, because
the identity step reads the same session first, so this is read-only
inference. Noted here, not filed.
- **`environmentId: 'platform'`** (the reserved virtual id
`rest-server.ts` documents) is skipped by `resolveRequestScope`'s
helpers but not by this gate. If a host resolver ever writes it, the
gate reads `sys_environment_member` for an environment id that has no
rows. That behaviour is unchanged here. Noted, not filed.
- **The wire `503` message is withheld** by the transport's 5xx
sanitizer (`Internal server error`). The failed read is named only
server-side, on the error's `object`. This is the same as the identity
step's tenancy outage today.
- **Changeset grade.** The dispatch asked for a `patch` changeset. Under
`Clause-②: no (narrowing)`, `check-changeset-no-major.mjs` enforces
`minor` for a package the diff moves. This was measured with a `patch`
grade in a throwaway worktree: exit 1 (`enforce`). So the changeset is
`minor` for both packages, carries the **BREAKING** banner, and records
the ADR-0087 disposition `not-required (no-migration-prescription)`.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_

---------

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 size/l tests tooling

Projects

None yet

2 participants