Repository navigation
feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member - #22173
Conversation
…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>
…oot refusal 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>
📓 Docs Drift CheckThis PR changes 3 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
|
Contract reviewServed-tier: 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 judgmentsPublic surface, plugin-auth (widening). The root entry gains One list, one owner. Accept-set narrowing, plugin-security. The gate is a Who is newly refused, read from every non-test
Co-mount refusal. Datasource. The builder inherits the header's 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 Pins. The four ruled pins are present: pin 1 with a manifest-registering app stand-in (runtime owns ② Semver level
③ Boundary flagsDev flags (os-dev-report deviations, out-of-scope findings, PR acceptance notes), each answered:
Check-runs on the head at the reading above: Implemented-by: 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>
|
Base merge ·
|
…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>
Fixes #22074
Clause-②: yes (narrowing)
What changed
createIdentityObjectsPlugin()registersauthIdentityManifest()(new,packages/plugins/plugin-auth/src/manifest.ts:147): the manifest header,authIdentityObjectsandauthObjectExtensions.AuthPluginspreads 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.security-plugin.ts:1088declaressys_userandsys_member; akernel:readyhandler subscribed at the head ofstart()(:1654) asks the engine registry for both and throwsAuthzIdentityObjectsMissingErrornaming the missing objects and the plugin that registers them. plugin-security registers neither object.Public surface, for the at-tier contract review
@objectstack/plugin-auth(throughexport *of the new modulesrc/identity-objects-plugin.ts):createIdentityObjectsPlugin(options?),IdentityObjectsPlugin,IdentityObjectsPluginOptions(manifestDatasource, same meaning as onAuthPluginOptions),IDENTITY_OBJECTS_PLUGIN_NAME(com.objectstack.auth.identity-objects).authIdentityManifestlives inmanifest.ts, which the package index does not export.SecurityPlugin.start()ran and the engine registry does not holdsys_userorsys_memberatkernel:ready:bootstrap()now rejects. Before, it booted and every authenticated permission read failed withAuthzStoreUnavailableError(SERVICE_UNAVAILABLE, 503). Newly refused, measured: the three in-repo harnesses listed under Cross-lane files. Newly refused, by code reading only (not run):DevPluginwithservices: { auth: false }and security left on, becauseDevPlugin.start()hands the real context toSecurityPlugin.start().AuthPlugin(os servepairs the two;@objectstack/verify'sbootStackmountsAuthPlugin); a kernel registering the two objects any other way (the gate asks the registry, not who registered); a declarations-only boot (os migratecomposes host plugins withstart()suppressed). The last is measured:packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts, which composesSecurityPluginwithoutAuthPlugin, stays green on this tree.IdentityObjectsPlugin.init()refuses a kernel that also mountsAuthPlugin(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']ordersAuthPluginahead, so itsauthservice is registered wheninit()asks.Measure first:
@objectstack/verifyMeasured, read only:
bootStackinpackages/verify/src/harness.tsmountsAuthPluginunconditionally (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/verifyis not edited.Mechanism hypotheses
authIdentityObjectsinmanifest.tsis the single list, registered through themanifestservice underAUTH_PLUGIN_ID. Both paths now read it throughauthIdentityManifest(). Pinned:AuthPlugin.init()registers exactly one manifest whoseobjectsis the same array (toBe) and whose identity half equalsauthIdentityManifest(), pages and dashboards still present; a realAuthPlugin+SecurityPluginkernel registers plugin-auth's list once undercom.objectstack.plugin-auth.sys_userandsys_member, notsys_memberalone.resolveUserAuthzGrants(packages/core/src/security/resolve-authz-context.ts:757,:794,:796) readssys_user(whenever the caller seeded no email, noai_seat, or a platform-admin list is configured) andsys_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 readsys_accountandsys_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 firstresolveUserAuthzGrantscall fails withAuthzStoreUnavailableError, codeSERVICE_UNAVAILABLE, status 503, objectsys_member.dependencies/optionalDependencies/requiresServicesname plugins and services (packages/core/src/plugin-order.ts). The existing mechanism used is the kernel's documented boot-gate moment: akernel:readyhandler that throws failsbootstrap()on both kernels (packages/core/src/kernel-base.ts,lite-kernel.ts). The check stays inside plugin-security. It is subscribed instart(), notinit():os migratecomposes host plugins forinit()only and fireskernel:readyanyway, so aninit()subscription would refuse a schema command that never reads the store.@objectstack/plugin-securitywithout going throughbootStackorAuthPlugin(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 withAuthzIdentityObjectsMissingError. Inside plugin-security, three test files whose engine doubles fire everykernel:readyhandler (14 cases) now answer the two names fromgetSchema.com.objectstack.plugin-auth, kernel plugin namecom.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 makesys_user's owner depend on which plugin a kernel mounts. The kernel plugin name differs fromAuthPlugin'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/identityimports.Cross-lane files (outside plugin-auth / plugin-security), for the seat to declare
packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts: mountscreateIdentityObjectsPlugin()beforeSecurityPlugin(runtime already depends on plugin-auth).packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.tsandpackages/services/service-automation/src/runas-system-stamping.integration.test.ts: the same mount.packages/services/service-automation/package.json:@objectstack/plugin-authas a devDependency (no workspace cycle: plugin-auth's closure does not contain service-automation), withpnpm-lock.yaml(+3 lines).packages/services/service-automation/vitest.config.ts: an anchored alias of@objectstack/plugin-authto source, becausecheck:test-source-aliasrefuses 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 thesys_userinsert is refusedOBJECT_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 themanifestservice, asAppPlugin.init()does;AppPluginitself lives in@objectstack/runtime, which depends on plugin-auth, so this suite cannot import it without a workspace cycle.1a507892(the one later commit,90bb6549a8, only re-spellsIdentityObjectsPlugin.optionalDependenciesas 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.pnpm --filtertypecheckfor 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.mjsreplaced the onekernel:readysubscription line insecurity-plugin.ts(anchor x1 to x0, marker x0 to x1, blobb90b3e0ftoe8696819). 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 answeredAuthzStoreUnavailableError/SERVICE_UNAVAILABLE/ 503 /sys_member(a scratch probe, deleted after). Restore: blob back tob90b3e0f, equal to HEAD,git diff HEADempty.Gates (HEAD
90bb6549a8)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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 anoptionalDependenciesspelled through a constant, now a literal),check:durability-log-level, all exit 0. The derivation names nocheck:api-surfacefamily for these packages (that gate coverspackages/spec);check:dts-closureandcheck:published-filesare green. ESLint on the 14 changed TypeScript files: 0 errors, 0 warnings (population fromeslint.config.mjs, which enables no type-aware linting, so untouched files' verdicts cannot move).Notes
033e5c53;origin/mainhas since gained four commits, none touching a file in this diff. Not merged here; CI's merge ref carries the joint check.packages/spec,packages/platform-objectsorpackages/verifyedit.Acceptance notes
createIdentityObjectsPlugin()through plugin-auth's root entry, which also evaluates better-auth. A light subpath entry would need apackage.jsonexportsand tsup entry edit, outside this card's fence. Carrier: none.DevPluginwithservices: { auth: false }and security on is now refused at boot (by code reading).DevPlugincould mountcreateIdentityObjectsPlugin()itself in that branch; that is a plugin-dev change, outside this card. Carrier: none.Generated by Claude Code