Skip to content

feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member - #22173

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22074-identity-objects-preset
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-22074-identity-objects-preset

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22074

Clause-②: yes (narrowing)

What changed

  1. plugin-auth exports the identity-object registration as a plugin it composes itself. createIdentityObjectsPlugin() registers authIdentityManifest() (new, packages/plugins/plugin-auth/src/manifest.ts:147): the manifest header, authIdentityObjects and authObjectExtensions. AuthPlugin spreads the same builder into the one manifest it registers (auth-plugin.ts:656), so a reduced kernel and a full kernel register one list under one package id, and nothing is registered twice on a full kernel.
  2. plugin-security declares what its authorization store reads and refuses at boot without it. security-plugin.ts:1088 declares sys_user and sys_member; a kernel:ready handler subscribed at the head of start() (:1654) asks the engine registry for both and throws AuthzIdentityObjectsMissingError naming the missing objects and the plugin that registers them. plugin-security registers neither object.

Public surface, for the at-tier contract review

  • New exports on @objectstack/plugin-auth (through export * of the new module src/identity-objects-plugin.ts): createIdentityObjectsPlugin(options?), IdentityObjectsPlugin, IdentityObjectsPluginOptions (manifestDatasource, same meaning as on AuthPluginOptions), IDENTITY_OBJECTS_PLUGIN_NAME (com.objectstack.auth.identity-objects). authIdentityManifest lives in manifest.ts, which the package index does not export.
  • The boot refusal, and whom it newly refuses. A kernel where SecurityPlugin.start() ran and the engine registry does not hold sys_user or sys_member at kernel:ready: bootstrap() now rejects. Before, it booted and every authenticated permission read failed with AuthzStoreUnavailableError (SERVICE_UNAVAILABLE, 503). Newly refused, measured: the three in-repo harnesses listed under Cross-lane files. Newly refused, by code reading only (not run): DevPlugin with services: { auth: false } and security left on, because DevPlugin.start() hands the real context to SecurityPlugin.start().
  • Not refused. Any kernel mounting AuthPlugin (os serve pairs the two; @objectstack/verify's bootStack mounts AuthPlugin); a kernel registering the two objects any other way (the gate asks the registry, not who registered); a declarations-only boot (os migrate composes host plugins with start() suppressed). The last is measured: packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, which composes SecurityPlugin without AuthPlugin, stays green on this tree.
  • A second, smaller refusal. IdentityObjectsPlugin.init() refuses a kernel that also mounts AuthPlugin (both register the same objects under one package id, and, read from the registry code rather than run, the later registration replaces the package record). optionalDependencies: ['com.objectstack.auth'] orders AuthPlugin ahead, so its auth service is registered when init() asks.

Measure first: @objectstack/verify

Measured, read only: bootStack in packages/verify/src/harness.ts mounts AuthPlugin unconditionally (kernel.use(new AuthPlugin(...)), around :569), so its in-process handle already registers the identity objects. It is the sanctioned kit for an app suite; the new module's header says so. The export lands for kits that compose their own kernel (hotcrm's nine harnesses are that shape). packages/verify is not edited.

Mechanism hypotheses

  • H1, confirmed. authIdentityObjects in manifest.ts is the single list, registered through the manifest service under AUTH_PLUGIN_ID. Both paths now read it through authIdentityManifest(). Pinned: AuthPlugin.init() registers exactly one manifest whose objects is the same array (toBe) and whose identity half equals authIdentityManifest(), pages and dashboards still present; a real AuthPlugin + SecurityPlugin kernel registers plugin-auth's list once under com.objectstack.plugin-auth.
  • H2, measured: sys_user and sys_member, not sys_member alone. resolveUserAuthzGrants (packages/core/src/security/resolve-authz-context.ts:757, :794, :796) reads sys_user (whenever the caller seeded no email, no ai_seat, or a platform-admin list is configured) and sys_member, plus five objects plugin-security registers itself (sys_user_position, sys_user_permission_set, sys_position, sys_position_permission_set, sys_permission_set). plugin-security's own boot passes also read sys_account and sys_organization, but through local read helpers that degrade with a warn; they are not the authz store and are not declared. Ablation reading below: with the gate removed, the boot passes and the first resolveUserAuthzGrants call fails with AuthzStoreUnavailableError, code SERVICE_UNAVAILABLE, status 503, object sys_member.
  • H3, measured: no kernel mechanism declares objects. dependencies / optionalDependencies / requiresServices name plugins and services (packages/core/src/plugin-order.ts). The existing mechanism used is the kernel's documented boot-gate moment: a kernel:ready handler that throws fails bootstrap() on both kernels (packages/core/src/kernel-base.ts, lite-kernel.ts). The check stays inside plugin-security. It is subscribed in start(), not init(): os migrate composes host plugins for init() only and fires kernel:ready anyway, so an init() subscription would refuse a schema command that never reads the store.
  • H4, measured: three harnesses outside the two packages. Candidates: every test file outside plugin-auth / plugin-security that imports @objectstack/plugin-security without going through bootStack or AuthPlugin (runtime 7 files, service-automation 2, rest 9, organizations 1, cli 3, dogfood 1, plugin-dev's whole suite), run against the built change. Red: packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts (1 file), packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts (1 case) and .../runas-system-stamping.integration.test.ts (2 cases), all with AuthzIdentityObjectsMissingError. Inside plugin-security, three test files whose engine doubles fire every kernel:ready handler (14 cases) now answer the two names from getSchema.
  • H5, decided: package id com.objectstack.plugin-auth, kernel plugin name com.objectstack.auth.identity-objects, provenance @objectstack/plugin-auth. The registry records one owning package per object and refuses a second package claiming it (ADR-0029 D3, ObjectOwnershipConflictError), so the manifest id decides the objects' provenance; a different id would make sys_user's owner depend on which plugin a kernel mounts. The kernel plugin name differs from AuthPlugin's only because the kernel keys plugins by name. The export ships from plugin-auth, the package that owns the list, rather than as a copy built from @objectstack/platform-objects/identity imports.

Cross-lane files (outside plugin-auth / plugin-security), for the seat to declare

  • packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts: mounts createIdentityObjectsPlugin() before SecurityPlugin (runtime already depends on plugin-auth).
  • packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts and packages/services/service-automation/src/runas-system-stamping.integration.test.ts: the same mount.
  • packages/services/service-automation/package.json: @objectstack/plugin-auth as a devDependency (no workspace cycle: plugin-auth's closure does not contain service-automation), with pnpm-lock.yaml (+3 lines).
  • packages/services/service-automation/vitest.config.ts: an anchored alias of @objectstack/plugin-auth to source, because check:test-source-alias refuses a new unaliased artifact import (its ledger is shrink-only).

Tests (HEAD 90bb6549a8)

  • packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts (3 cases, real kernel, ObjectQL, SQLite): refused with both names; only the missing name; boots when the registry holds both, registered by a test package.
  • packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts (9 cases): the card's four pins, plus a control (without the preset the sys_user insert is refused OBJECT_NOT_FOUND / 404), the datasource override on both paths, the ordering edge, and the co-mount refusal. The app in pin 1 registers its manifest through the manifest service, as AppPlugin.init() does; AppPlugin itself lives in @objectstack/runtime, which depends on plugin-auth, so this suite cannot import it without a workspace cycle.
  • Full suites, at 1a507892 (the one later commit, 90bb6549a8, only re-spells IdentityObjectsPlugin.optionalDependencies as a literal; its pin file was re-run there, 9 passed): plugin-security 173 files, 3666 passed, 45 skipped; plugin-auth 127 files, 2633 passed, 10 skipped; service-automation 175 files, 2120 passed. Runtime: the edited file, 13 passed.
  • Typecheck: pnpm --filter typecheck for plugin-auth, plugin-security, service-automation and runtime, all exit 0 (test layers OK, debt ledgers unchanged).

Ablation of the boot-refusal pin

Committed first; node scripts/ablation-replace.mjs replaced the one kernel:ready subscription line in security-plugin.ts (anchor x1 to x0, marker x0 to x1, blob b90b3e0f to e8696819). The suites resolve plugin-security from source (relative import, and plugin-auth's vitest alias), so no build sat between edit and run. Mutated: the boot-refusal file went red 2 of 3 (the "boots when both are registered" case stays green, as it should), and plugin-auth's pin 3 went red 1 of 9. The ablated boot passed and the first permission read answered AuthzStoreUnavailableError / SERVICE_UNAVAILABLE / 503 / sys_member (a scratch probe, deleted after). Restore: blob back to b90b3e0f, equal to HEAD, git diff HEAD empty.

Gates (HEAD 90bb6549a8)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 80 commands; all 80 ran and exited 0, reconciled with --ran (80 derived, 80 run, 0 NOT MEASURED, every line carrying its exit code). Run in addition because the diff touches plugin lifecycle code: check:startup-registry-verdict, check:init-service-contract (it caught an optionalDependencies spelled through a constant, now a literal), check:durability-log-level, all exit 0. The derivation names no check:api-surface family for these packages (that gate covers packages/spec); check:dts-closure and check:published-files are green. ESLint on the 14 changed TypeScript files: 0 errors, 0 warnings (population from eslint.config.mjs, which enables no type-aware linting, so untouched files' verdicts cannot move).

Notes

  • The branch sits on 033e5c53; origin/main has since gained four commits, none touching a file in this diff. Not merged here; CI's merge ref carries the joint check.
  • No packages/spec, packages/platform-objects or packages/verify edit.

Acceptance notes

  • A test kit loads createIdentityObjectsPlugin() through plugin-auth's root entry, which also evaluates better-auth. A light subpath entry would need a package.json exports and tsup entry edit, outside this card's fence. Carrier: none.
  • DevPlugin with services: { auth: false } and security on is now refused at boot (by code reading). DevPlugin could mount createIdentityObjectsPlugin() itself in that branch; that is a plugin-dev change, outside this card. Carrier: none.

Generated by Claude Code

claude added 8 commits October 8, 2026 02:41
…Plugin refuses a kernel without the identity objects its authz store reads

plugin-auth exports createIdentityObjectsPlugin(), which registers the
identity half of its manifest (authIdentityManifest, also spread by
AuthPlugin's own registration) for a kernel without AuthPlugin.
SecurityPlugin declares sys_user and sys_member and refuses at
kernel:ready when the engine registry holds either one not.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… identity objects

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… not init()

A declarations-only boot (os migrate composes host plugins for init()
and suppresses start()) arms nothing that reads the authorization store
and must not be refused.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
… without AuthPlugin mount the identity preset

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…y boot refusal (minor, breaking)

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…authorable is renamed

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…as a literal the init-service gate reads

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-auth, @objectstack/plugin-security, @objectstack/service-automation, touching 17 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/index.ts, packages/services/service-automation/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

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

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/index.ts, packages/services/service-automation/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 38 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 25 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 959c209d56a8b9ab6f7e561e87700721194e4678 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 33c47902d66a8d440cfc535cd11e1c4739078245 — the merge of head 3bbff329c32ed91ef4bee9392f362449ecd74f8e into base 959c209d56a8b9ab6f7e561e87700721194e4678, 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 33c47902d66a8d440cfc535cd11e1c4739078245 && git checkout 33c47902d66a8d440cfc535cd11e1c4739078245
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 959c209d56a8b9ab6f7e561e87700721194e4678 3bbff329c32ed91ef4bee9392f362449ecd74f8e && git checkout -B drift-repro 959c209d56a8b9ab6f7e561e87700721194e4678 && git merge --no-ff 3bbff329c32ed91ef4bee9392f362449ecd74f8e

node scripts/docs-audit/affected-docs.mjs --json 959c209d56a8b9ab6f7e561e87700721194e4678

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Validate Package Dependencies is red here, and it is not this PR's · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T04:05Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90bb6549a87ef271f7f895eaf80a9359027f9f0e
Local-runs: none

Inputs: card #22074 (body; comments 6038422460 triage ruling, 6050889097 claim, 6051891554 os-dev-report), PR #22173 (body, 18-file list, net diff against the merge-base 033e5c5: 8 commits, +664/-26), and the check-runs on the head, read at 2026-10-08T04:14Z. Base-tree facts below are read from the clone at that merge-base and from the head's blobs; nothing was built, run or re-run.

① Derived judgments

Public surface, plugin-auth (widening). The root entry gains createIdentityObjectsPlugin, IdentityObjectsPlugin, IdentityObjectsPluginOptions and IDENTITY_OBJECTS_PLUGIN_NAME through export * of the new module; no name collides with an existing export, and package.json exports are unchanged (. and ./rate-limit-storage). RIGHT. authIdentityManifest stays in manifest.ts, which the index does not export, so the builder is not public. RIGHT.

One list, one owner. authIdentityObjects plus authObjectExtensions plus the header are the one builder; AuthPlugin spreads that builder into its single registration and nothing later in its literal re-sets objects, objectExtensions or defaultDatasource (the later keys are pages, dashboards, the SSO nav spread). Both paths register under AUTH_PLUGIN_ID = com.objectstack.plugin-auth, so the objects keep one owning package whichever plugin a kernel mounts (ADR-0029 D3, single owner per object); the kernel plugin name com.objectstack.auth.identity-objects is distinct only because the kernel keys plugins by name. RIGHT. plugin-security declares AUTHZ_STORE_IDENTITY_OBJECTS = ['sys_user', 'sys_member'] and registers nothing; its package.json gains no plugin-auth dependency; the error names the package by string. RIGHT per the ruling's "declares, does not register".

Accept-set narrowing, plugin-security. The gate is a kernel:ready handler subscribed at the head of start(). Both kernels dispatch kernel:ready with triggerHookOrThrow (kernel-base.ts, the #5170 boot-gate contract), so the throw rejects bootstrap() on ObjectKernel and LiteKernel alike. RIGHT. It asks ql.getSchema(name), which is registry.getObject on the same registry the read path's find('sys_member') consults, so the boot question is the read's question. RIGHT. Subscribed in start(), not init(): composeForDeclarations in schema-migrate.ts suppresses a host plugin's start() and the kernel still fires kernel:ready, so os migrate keeps booting; an init() subscription would have refused it. RIGHT. PluginContext.hook is typed, so the (ctx as any).hook cast under a typeof guard is unneeded, but it is the file's own convention (the ADR-0029 D8 i18n hook, runBootstrap), and a hook-less context exists only as a test double; every kernel context carries hook. Acceptable, named.

Who is newly refused, read from every non-test SecurityPlugin composition on this tree.

  • os serve (cli serve.ts): the SecurityPlugin mount sits inside the same else branch as the AuthPlugin mount ("Pair:"); the host-kernel branch and the no-secret branch mount neither. Not refused. RIGHT.
  • @objectstack/verify bootStack mounts AuthPlugin unconditionally (harness.ts, the kernel.use(new AuthPlugin(...)) at the top level of the boot); verify --rls boots through bootStack with a security: override; every dogfood fixture is a security: override into bootStack. Not refused. RIGHT, and the card's measure-first is answered: verify is the sanctioned app-suite kit, the export still lands for kits that compose their own kernel.
  • check:app-nav-i18n (lint.yml) calls SecurityPlugin.start() on a fake context whose hook is a no-op; nothing fires. Not refused.
  • DevPlugin: services.auth !== false is the default, so the stock dev stack mounts AuthPlugin. services: { auth: false } with security left on, or plugin-auth not installed while plugin-security is, is newly refused: DevPlugin.start() hands the real context to SecurityPlugin.start() and kernel:ready fires after it. By code reading, as the dev says; see ③.
  • In-repo harnesses: three went red and now mount the preset (runtime 1, service-automation 2); three plugin-security doubles that fire every kernel:ready handler now answer exactly the two names from getSchema and nothing else, so they do not widen what the double vouches for. RIGHT.

Co-mount refusal. IdentityObjectsPlugin.init() detects AuthPlugin through the presence of the auth service, which only AuthPlugin.init() registers in product code; optionalDependencies: ['com.objectstack.auth'] orders that init() first (ADR-0116 order-if-present), so the proxy is sound for the real pair and pinned. A kit that stubs an auth service and also mounts the preset would be refused with a message naming AuthPlugin. Edge, named, non-blocking.

Datasource. The builder inherits the header's defaultDatasource: 'cloud' unless manifestDatasource is passed, exactly as AuthPlugin behaves today; pin 1 boots a SQLite driver.default kit on the default. Unchanged semantics, and the option is documented in the changeset. RIGHT.

Cross-lane wiring. service-automation's devDependency on plugin-auth creates no workspace cycle (plugin-auth's closure is core, platform-objects, rest, service-messaging, spec, types and the better-auth family; none reaches service-automation). The lockfile's three lines are one workspace:* link; no external package is newly resolved, so this diff adds no advisory. The anchored vitest alias follows the file's existing rows. RIGHT.

Pins. The four ruled pins are present: pin 1 with a manifest-registering app stand-in (runtime owns AppPlugin and depends on plugin-auth, so importing it here would be a workspace cycle; the stand-in does what AppPlugin.init() does) plus a control; pin 2 resolves grants from sys_member; pin 3 is refused by name and the message names the export; pin 4 pins AuthPlugin's one registration toBe the same arrays. The plugin-security suite pins the refusal on a real kernel with a stand-in package, proving the gate asks the registry and not the registrar. RIGHT.

② Semver level

  • .changeset/22074-auth-identity-objects-plugin.md: @objectstack/plugin-auth minor, Clause-②: yes (widening). New exports, nothing removed or renamed. Matches the diff. RIGHT.
  • .changeset/22074-security-identity-objects-boot-refusal.md: @objectstack/plugin-security minor with BREAKING stated, Clause-②: yes (narrowing), shipped as minor under the launch-window convention that check-changeset-no-major enforces. Matches the diff: a composition that booted is refused at boot. RIGHT. ADR-0087 disposition not-required (no-migration-prescription): no spec key, export, option or stored shape is removed, renamed or re-shaped, so no ledger entry can exist and the remedy is a kernel-composition edit; the marker closes the other five arms on facts. The right arm of the closed set. RIGHT. The remedy is carried in prose because the gate refuses a FROM-TO table under this arm. Carried.
  • PR body Clause-②: yes (narrowing) is the dominant arm and equals the dispatch's corrected line; each changeset carries exactly one arm; no skip-changeset. Check Changeset is green on the head. RIGHT.

③ Boundary flags

Dev flags (os-dev-report deviations, out-of-scope findings, PR acceptance notes), each answered:

  1. Gate armed in start() rather than init(): answered above; right, and the host-composition test's continued green is the pin.
  2. auth-plugin.ts import block edited beyond the :645 region: required by the builder; adjacent and minimal. Accepted.
  3. Six cross-lane files: each is a harness that boots SecurityPlugin without AuthPlugin, or the dependency, alias and lockfile line those harnesses need; under the stop line and enumerated for the seat to declare. Accepted.
  4. plugin-security remedy as prose: the gate's constraint under the chosen arm. Accepted.
  5. Branch sits on 033e5c5 and main has moved since; the dev read none of the later commits touching a diff file, and CI's merge ref carries the joint check. The seat merges main before queueing if the queue asks. Accepted.
  6. Commit trailer form: not a contract matter.
  7. DevPlugin with services: { auth: false } and security on (or plugin-auth absent) is newly refused at boot, by code reading only. Within the ruling: "a kernel with plugin-security but without the identity objects fails at boot, with an error naming the missing objects and the plugin that registers them". The changeset's remedy holds by code reading: extraPlugins are pushed into childPlugins and init()-ed with the real context after the manifest service exists, and the gate runs at kernel:ready after DevPlugin.start(). Not run on this tree. Escalated to the seat, non-blocking: the dev marks it "carrier: none"; a published package's own composition that is newly refused should carry a plugin-dev follow-up card (DevPlugin composes createIdentityObjectsPlugin() itself in the auth-off, security-on branch, or its header documents the extraPlugins remedy), not only a changeset sentence.
  8. A light subpath entry for the preset (the root entry evaluates better-auth): outside this card's fence; a devpath follow-up at the seat's discretion. Non-blocking.
  9. dispatch-gates derived neither check:init-service-contract nor check:startup-registry-verdict for a diff editing plugin init()/start() and declarations, and the former caught a real defect when run by hand: carrier is the PM seat, as the dev routes it. Noted for the seat.
  10. AuthzIdentityObjectsMissingError is export class in security-plugin.ts but not re-exported from plugin-security's index (which exports only SecurityPlugin from that module), so it is not a public export and consumers identify the refusal by name and missingObjects; the changeset's "an error named" is accurate. No widening arm is owed on plugin-security. Named; if the seat wants instanceof for consumers, that is a follow-up export, not a defect here.

open_questions: none.

Check-runs on the head at the reading above: Validate Package Dependencies is red with the base's own OSV matches on next@16.3.6 (card #22148; red on main's scheduled scan and on every dependency-touching PR until the lockfile fix lands); this PR's lockfile adds one workspace link and no resolved package, so the red is inherited, not added. Console Pin Gate and Packed-tarball smoke are skipped by design. Every other completed check is green; the still-running ones are not yet verdicts and the seat lands only on green.

Implemented-by: claude/issue-22074-identity-objects-preset
Reviewed-by: session_01WkL6Eijt432S1Y7ekb6ovQ

VERDICT: PASS


Generated by Claude Code

Brings in PR #22138, so this PR's OSV scan judges only what it adds
(main's next@16.3.6 advisories, anchor #22148, are the base's).
security-plugin.ts and the runtime harness changed on both sides and
merged cleanly; the PR's added and removed lines against the new main
are identical to the reviewed head 90bb654.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Base merge · domain:services seat 1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T04:17Z

main (with PR #22138, base-relative OSV scans) is merged into this branch as 3bbff329, a merge commit with no edit of its own. Two files had changed on both sides (plugin-security's security-plugin.ts, from PR #22139, and the runtime harness), and both merged without conflict. Against the new main, the PR's added and removed lines are identical to the reviewed head 90bb6549 (726 of 726). Only one context line moved, an import main added next to a hunk. The contract review 6052075615 therefore covers what lands. Ready and auto-merge follow once every check on 3bbff329 is green.

@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22074-identity-objects-preset branch October 8, 2026 05:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 9, 2026
…olumn get their name, once, at boot (ADR-0131 C2 S4b) (objectstack-ai#22143)

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

**What this does.** Grants stored in `sys_user_permission_set` before
the `permission_set` name column existed carry `NULL` there. On boot,
`@objectstack/plugin-security` now fills that column once on each such
grant. The value is the `name` of the `sys_permission_set` row that the
grant's own `permission_set_id` names, read inside the grant's own
organization and checked through the security catalog read before it is
written. A grant this pass cannot name keeps `NULL` and is reported in
the boot log. The pass records its verdict once in `sys_migration`.
**Clause-② is `no`:** the column and its rule (system-written, agreeing
with the id) already shipped with stage S4a. This pass brings older rows
under that rule. No accepted or refused input changes, no reader reads
the name yet, and no surface is added: the module is not exported from
the package.

Stage S4b of ADR-0131 C2 (claim amendment `6049507458` on the card). Not
in this PR:
- no reader changes (S5);
- no change to the S4a hooks or to the object definition;
- no drop of the id column (C8);
- nothing in `packages/spec`, nothing in `plugin-auth`;
- no row deleted, no grant moved.

## What changes

- **`grant-permission-set-name-backfill.ts`** (new, `plugin-security`).
For every grant whose name is `NULL` or blank (the whole set is read,
ordered by id, before anything is written):
1. **The set row its id names, read inside the grant's wall.** The read
runs under the grant's organization (`seedCtx`), so the driver's tenant
scope returns that organization's rows and the organization-less ones.
Then the resolver's own grant rule is asked of the set row: an
organization-less set applies everywhere, and an organization's set
applies only to a grant of that same organization. So an
organization-less grant may name only an organization-less set.
2. **The name is verified through S1's catalog read**
(`createSecurityCatalogReader` from `@objectstack/core`). A name the
catalog does not resolve is not written.
3. **Written as a system update of the name column alone,** under the
grant's organization.
- **Report, never guess.** Each category below is logged once, with a
count and grant row ids only. No name or id from another organization is
logged.
  - an id that names no set row: `warn`;
  - an id whose set row is outside the grant's organization: `warn`;
- a set row whose name the catalog read does not resolve: `error`, with
the remedy;
  - a name write that did not land: `error`;
- a read that did not happen (the grant scan, a set row, the catalog's
`AuthzStoreUnavailableError`): the pass stops and says which read
failed. It never reads an outage as "no such set".
- **Once, and remembered.** The ledger row follows `plugin-auth`'s
`membership-backfill-ledger.ts`, which this PR reads but does not edit:
- one row with its own id,
`adr-0131-grant-permission-set-name-backfill`;
  - `verified_at: null`, since nothing gates on it;
  - `blocking: 0`;
  - `details` holds the counts.
- A verdict is recorded only when a later pass could not decide
differently: everything was named, or names no set row, or names a set
outside the grant's organization. An unresolved name, a failed write or
a stopped pass leaves it unrecorded, so the next boot judges again. A
concurrent boot that lands the same id first counts as recorded.
- **One departure from that module, on purpose:** without a readable
ledger, this pass still runs. That module does not run without its
ledger, because its second run would decide membership again. This pass
only fills a `NULL` with the name the row's own id already names, so a
second run writes nothing new. The cost is one scan per boot, and a
`warn` that says so.
- **Boot wiring** in `SecurityPlugin.start`, beside the platform
bootstrap (near `:4816`; disjoint from stage S2's manifest edit near
`:1448`). It runs at `kernel:bootstrapped`, inside a `try` that only
warns, so the boot never fails on it.
- Outside the claimed file list, both mechanical:
- `scripts/adr-anchors/…grant-permission-set-name-backfill.ts.json`
anchors the module to ADR-0131.
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` were
regenerated with `tenant-audit-census --write`. The module adds two
engine write call sites, so the count goes from main's 232 to 234, and
the page's prose figures follow. They were regenerated again on the
merge of `main` at `aa9447c70b`, whose own regeneration had moved the
base from 233 to 232.

## The write path, and why (measured)

Measured on a real `ObjectQL` engine over `SqlDriver` with the real
`SecurityPlugin` started, under `single` and `isolated`. The S4a hooks
run on the backfill's system write. Results:

| system update under the grant's organization | result |
|---|---|
| `{ id, permission_set: OTHER_SET_NAME }` | refused,
`VALIDATION_FAILED` |
| `{ id, permission_set: AGREEING_NAME }` (this PR's write) | lands |
| `{ id, permission_set_id: SAME_ID }`, no name | the hook stamps the
name |
| `{ id, OTHER_COLUMN: VALUE }` | name stays `NULL` |

So the hooks fill the name by themselves only on a write that carries
the id. This PR writes the verified name, not an id echo, for three
reasons:
- Two independent checks agree before a name lands: the catalog read
here, and the hook's re-derivation from the stored id. A grant whose id
moved between this pass's read and its write is refused, not
mislabelled.
- The write carries no `permission_set_id`. So nothing keyed on that
column re-judges the grant: `plugin-auth`'s last-administrator guard
lists it in `GRANT_STANDING_KEYS`.
- The name lands with the hooks unbound too (pinned).

**The hook is not this pass's wall.** For a system write whose stored id
the hook cannot resolve inside the writer's wall, the hook stands down
and lets the value land. Ablation A2b below shows that a name carried
across organizations lands through the hook.

## The tenant wall, against today's resolver (measured)

The grant rule: `resolveUserAuthzGrants` was called for a user holding
one grant per case, with the active organization set to the grant's
organization (A), to another organization (B), or to none. The same in
`single`, `group` and `isolated`:

| grant org → set row org | resolver today: A / B / none | this pass |
|---|---|---|
| A → A | grants / – / – | names it |
| A → organization-less | grants / – / – | names it |
| organization-less → organization-less | grants / grants / grants |
names it |
| organization-less → A | grants / grants / grants | reported, `NULL` |
| A → B | grants / – / – | reported, `NULL` |

Inside the wall the pass matches today's resolution exactly. The last
two rows differ: today's resolver reads a grant's set row by id without
a wall (`resolve-authz-context.ts` §6b), so those grants grant across
organizations by id. This order's rule is that a name never crosses
organizations, so those grants are reported and left unnamed. Readers
still read the id, so their grants do not move here. What they resolve
to once readers switch to the name is S5's question (Acceptance notes).

## When it runs (measured)

At `kernel:bootstrapped`: the kernel fires it only after every
`kernel:ready` handler has settled, on `ObjectKernel` and on
`LiteKernel`. Code-declared and environment catalog items are in the
registry by `kernel:ready`. A real `LiteKernel` boot is pinned:
- a provider plugin whose `kernel:ready` handler registers a permission
set after `SecurityPlugin`'s own handler;
- a grant on that set, stored before the boot.

The grant is named on the same boot, and the verdict is recorded. With
the wiring moved to `kernel:ready` (A6), the name reads missing.

A definition that arrives after the boot, such as a package installed
into the running process, cannot turn into a recorded "missing". The
unresolved name is reported at `error`, the verdict stays unrecorded,
and the next boot judges it again (pinned).

## Pins

All pins are in `grant-permission-set-name-backfill.test.ts`, on a real
engine with the real plugin:
- Every backfilled name equals the set row its id names and resolves
through S1's read, in `single` and `isolated`.
- **Grant equivalence:** the platform administrator, organization
administrator, member and agent resolve to the S4a golden before and
after the pass, in two postures.
- An id with no set row is reported by count and grant id, and no name
is guessed.
- A catalog read that answers nothing: no name is written, one `error`,
and the verdict is not recorded.
- An unresolved-yet name: no record; after registration, the next pass
names it.
- A failed name write: `error`, and no record.
- A cross-organization id is not written, and nothing of that
organization is logged. Two postures.
- A second pass reads and writes nothing, and the ledger row is written
once.
- No ledger: the pass still names, warns, and a second pass renames
nothing.
- The name lands with the S4a hooks unbound.
- The real-kernel late-provider boot, and the boot wiring.

**Ablations.** All legs ran at `06642523e1` through
`scripts/ablation-replace.mjs` in wrap mode. Each restore was proven by
blob equal to `HEAD`, with an empty `git diff HEAD`. The subject is
imported from `src` by a relative path, so no `dist/` leg applies.

| leg | mutation | result |
|---|---|---|
| A1 | catalog check always passes | red 2/4 (`report, never guess`):
silent catalog, unresolved-yet |
| A2 | grant rule dropped, driver wall kept | red 2/2
(cross-organization, both postures): organization-less grants named |
| A2b | grant rule dropped and set-row read unwalled | red 2/2: all
three cross-organization grants named |
| A3 | ledger read skipped | red 2/2 (`once, and remembered`): second
pass returns `ran` instead of `already-run` |
| A4 | an id with no set row gets the id as its name | red 1/4: expected
`'ps_gone'` to be `null` |
| A5 | the write also sets `valid_until` in the past | red 2/2: the
grant-equivalence golden |
| A6 | wiring moved to `kernel:ready` | red 1/1: the late provider's set
reads missing |

## Verification

Head `7f9500afd5`. That is `main` at `8fc50b7647` merged in, plus this
PR.

- **This file:** 15/15. `plugin-security` typecheck is green, test layer
included: 0 files, 0 errors, 0 debt.
- **Full `plugin-security` vitest after the merge:** 173 files, 3678
passed, 45 skipped. Its sources are byte-identical to the head except
the typed test reads, which are re-run in this file.
- **`core`:** typecheck green, and vitest `--project local` 78 files,
2192 passed. The closure was rebuilt first.
- **Dogfood, 7 files, 64 passed and 2 skipped** (`rls-multitenant`'s own
`skipIf`). The files: `security-catalog-showcase` (its walled arm
included), `membership-ended-session-revoke`,
`org-admin-affordance-reach`, `admin-platform-admin-standing`,
`me-apps-and-everyone-baseline`, `showcase-permission-seeding` and
`rls-multitenant`. All 8 real showcase boots ran the pass and recorded
it, with `{"unnamed":0,"named":0,"dangling":0,"crossOrganization":0}`: a
fresh boot's writers write both columns.
- **Gates:** `dispatch-gates --commands` derived 104 at this head, and
all 104 exited 0. `dispatch-gates --ran`: 104 run, 0 NOT-MEASURED, 0
UNRUN.
- First-pass reds, fixed: `check:tenant-audit-census` (regenerated,
above) and `check:query-options-erasure`. For the second, ten test reads
had erased their options to `any`; they are now typed, and the test
surface is back to 236.
- Two `PREREQUISITE NOT MET` refusals (exit 3) were re-run after
building their packages, and both exit 0.
- **Lint, a narrowed run.** `eslint --no-inline-config --format json`
over the 3 changed TS files: 3 linted, 0 errors, 0 warnings, none
ignored. The config sets no `parserOptions.project` and no typed rule,
so untouched files' verdicts are invariant. The full `pnpm lint` is
CI's.
- **Patch round, head `0306ca272a`** (`main` at `aa9447c70b` merged; no
rebase, no force-push). The two census files conflicted with the
regeneration that landed on `main`. They were resolved by regenerating
with `tenant-audit-census --write` from `main`'s copy, and the prose
figures were updated from the gate's own numbers: write sites 232 to
234, run-time names 78 to 80, decidably elevated 122 to 123, elevation
undecidable 102 to 103. No side was hand-picked. At this head:
- `check-tenant-audit-census`, `check-system-context-census` and
`check-regen-pending` all exit 0; no os-regen path is touched and none
is pending.
- This file passes 15/15, and `plugin-security` typecheck is green after
a closure rebuild.
- `dispatch-gates --commands` derived the same 104 commands. All 104
exit 0 after rebuilding the packages the dist-reading gates read, and
`--ran` reports 104 run, 0 NOT-MEASURED, 0 UNRUN.
- **Main moved since `aa9447c70b`:** six commits. One of them (objectstack-ai#22173)
edits `security-plugin.ts` in other regions, and `git merge-tree`
against it is clean. The boot refusal it adds asks for `sys_user` and
`sys_member`, which the pins' engine registers. The joint build is CI's
and the queue's.

## Acceptance notes

- **Grants whose id names a set row outside their organization grant
today by id.** The resolver's set-row read (§6b) has no wall, measured
in the table above. This PR leaves those grants unnamed and lists them
at boot. The reader switch will meet them: it must decide what they
resolve to, and pin it. Carrier: S5a (`resolve-authz-context.ts` §6).
- **A grant a system writer leaves unnamed after the ledger row lands is
never backfilled.** The S4a hook stands down for a system write whose id
resolves to nothing yet, and this pass is one-time. No writer in this
repository leaves one (all four write both columns). D10's "verified
rewrite" before the id column is dropped must count `NULL` names, not
trust this ledger row. Carrier: C8.
- **`persistGrantNameBackfillRecord` is a new durability seam.** It logs
at `error` and is pinned, but it is not in
`DURABILITY_CRITICAL_CALLEES`, because that edit is outside this stage's
surface. Carrier: none.

---
_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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants