Repository navigation
Commit 8a995b8
Fixes #22328
Clause-②: no
The `plugin-auth` half of the parent card
#22316. The gate half is PR #22325
(`scripts/check-settings-bind-window.mjs`), which lands after this one.
This PR does not touch that script.
## What changed
`packages/plugins/plugin-auth/src/auth-plugin.ts`: the ADR-0093 D6
backfill's `app:seeded` handler used to be registered from `start()`.
Until the pass was armed, the `backfillArmed` runtime flag made it a
no-op (PR #22312). It is now registered by the `kernel:ready` handler
that arms the pass, in the same synchronous step:
```ts
ctx.hook('kernel:ready', () => {
backfillArmed = true;
ctx.hook('app:seeded', () => runBackfill('app:seeded'));
return runBackfill('kernel:ready');
});
```
This is the seat's ruling on #22316 (comment 6065407005): "**Decision:
(a).** `plugin-auth` registers the `app:seeded` handler from inside the
arming `kernel:ready` handler, which is the flag's guarantee made
structural."
- **Producer side.** The registration lives only in `auth-plugin.ts`. No
other package changes.
- **`backfillArmed` stays.** It is not dead after the move. The
`default-org-created` trigger (`runBackfillOnDefaultOrg`) still calls
`runBackfill` before arming from two places. One is the `objectql`
middleware, which a Phase-2 `sys_user` seed insert can trigger. The
other is the bootstrap's own `kernel:ready` hook (`runEnsure`), which is
registered earlier in `start()` and so runs before the arming handler.
- **One unit test changed.** `auth-plugin.test.ts` had a test,
"registers an app:seeded hook alongside kernel:ready", that checked the
hook was registered right after `init()`+`start()`, which is the old
structure. It now checks the new structure: no `app:seeded` handler
after `start()`, and exactly one after `kernel:ready` fires. Neither
#22312 pin changed.
- **Changeset.** One patch changeset for `@objectstack/plugin-auth`. No
option, setting, export or public type changes.
## Is behaviour identical? Measured, not assumed
**(1) The hook bus accepts a registration made from inside another
hook's handler, and a later emit reaches it.** `hook()` pushes onto the
kernel's `hooks` map, creating the entry if needed. `trigger()` reads
`this.hooks.get(name)` when it dispatches. Sources:
`packages/core/src/kernel.ts` lines 198-209 (ObjectKernel),
`kernel-base.ts` lines 118-128 (LiteKernel), and
`security/plugin-permission-enforcer.ts` lines 453-456, which delegates.
`dispatchHookPropagating` (`hook-dispatch.ts`) loops `for...of` over the
live array, so a handler pushed during a dispatch is also picked up by
that dispatch if it is still running.
**(2) No `app:seeded` emit that the flag let through is lost.** The
arming and the registration happen in one synchronous step, so no emit
can fall between them.
I measured both claims with a throwaway A/B probe, deleted afterwards
and not committed. It booted real kernels (driver-sql in-memory SQLite,
ObjectQLPlugin, the REAL SettingsServicePlugin) with the base
`auth-plugin.ts` (`28bff18d0c`) and with this one. The one-time pass was
stubbed `undecided`, so every `runBackfill` that actually ran counts as
one visible call:
| probe | base | this PR |
|---|---|---|
| P0 `app:seeded` handlers at a `start()`-time emit / after boot
(ObjectKernel and LiteKernel) | 1 / 1 | **0** / 1 |
| P1 a post-ready emit (the over-budget seed): passes after boot, then
after the emit (both kernels) | 1, then 2 | 1, then 2 |
| P2 dispatch in flight across arming, blocked by a subscriber BEFORE
the auth slot | 2 | 2 |
| P5 awaited pre-ready emit (the in-budget seed; the #22312 pin's shape)
| 1 | 1 |
| P3 dispatch in flight across arming, blocked by a subscriber AFTER the
auth slot | 1 | **2** |
| P4 order of a post-ready emit's subscribers, with another
`start()`-registered subscriber whose plugin starts after auth | auth,
other | **other, auth** |
So assumption (2) holds, and so does (1) on both kernels. The probe also
found two differences in scheduling, P3 and P4. Neither loses an emit.
Both come from the same mechanism: a registration made after `start()`
takes a later position in the subscriber list.
- **P3.** A dispatch that started before arming has already passed the
auth slot as a no-op on base. If it is still running when the pass arms,
it now reaches the newly added handler, which queues one more post-bind
`runBackfill('app:seeded')` behind the `kernel:ready` pass. That extra
call stops at `backfillDecided` once the pass has decided, and otherwise
runs the same idempotent pass any later trigger would. It runs after the
bind, which is the guarantee this change is about.
- **P4.** For an emit after arming, the auth handler now runs after any
`app:seeded` subscriber registered in `start()` by a plugin that starts
after auth. Before, it ran ahead of such subscribers.
- **No in-repo instance of either on the shipped composition.** The
`app:seeded` subscribers in this repo are `plugin-security` (registered
in `init()`), the CLI seed-settlement announcer (`init()`),
`platform-objects` (`start()`) and this one. `os serve` composes
`PlatformObjectsPlugin` before `AuthPlugin` (`serve.ts` 3945 vs 4102),
and no ordering edge reverses that. So the subscriber order on `os
serve` is the same in both shapes, and no subscriber sits after the auth
slot. Cloud-held compositions: NOT MEASURED.
- **No structural shape is fully identical.** The kernel only appends;
it has no API to insert a handler at a position. A `start()`-time
registration is exactly what the gate rejects by design. The ruling's
shape is the smallest structural one, and the residue is the position in
the subscriber list.
## Proof the move does its job: PR #22325's gate (head `96d46ea02d`)
over this tree
I copied the gate into a scratch root. Its `scripts/` held symlinks to
this worktree's scripts except for that one file, and `packages` and
`node_modules` were symlinks to this worktree. The script's md5 matched
`git show 96d46ea:scripts/check-settings-bind-window.mjs`
(`dbd45f65ff759cded2ddbcf7e349d026`). It is not committed here.
**Control, unmodified `origin/main` `28bff18d0c`:** exit 1
```text
✗ settings bind-window guard (#11045)
packages/plugins/plugin-auth/src/auth-plugin.ts:1591 — AuthPlugin
getService('settings') runs in [app:seeded-hook-from-start], which is inside the
pre-bind window under EVERY composition order: 'com.objectstack.service.settings' binds its
engine from its own 'kernel:ready' hook, strictly after every plugin's init() and
start(), after every handler registered during init(), and after every handler of
a hook those phases fire. No dependency edge can move this read out of the window —
do not add one and call it fixed.
...
1 settings read(s) in the pre-bind window with no declaration covering them.
```
**This tree, head `9c8ed2b62e`:** exit 0, no ledger entry
```text
✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream / 0 ledgered (73 plugin unit(s) scanned, 8 pre-bind hook(s) fired = pinned, provider 'com.objectstack.service.settings').
```
**Ablation**, with the fix committed at `9c8ed2b62e`.
`scripts/ablation-replace.mjs` in WRAP mode moved the registration back
out of the handler to its `start()`-time position. It reported "anchor 1
-> 0, blob 49113b9 -> 04decbf3fac2". On disk, the in-handler count
went 1 to 0 and the start-time count 0 to 1. The gate read RED again,
exit 1, at `auth-plugin.ts:1603 [app:seeded-hook-from-start]`. Restore:
"blob == HEAD (49113b9) and `git diff HEAD` is empty". My own check
agreed: `git hash-object` = `49113b925d9785632b45ea864d83f97d8a41b70f` =
the `HEAD:` blob, and `git status` was clean.
## Tests (head `9c8ed2b62e`)
- Built the dependency closure under the verify lock: `pnpm
--workspace-concurrency=2 --filter '@objectstack/plugin-auth^...'
build`, VERDICT command-exit 0, 29 of 81 projects. Then `pnpm --filter
@objectstack/plugin-auth build`.
- `pnpm --filter @objectstack/plugin-auth typecheck`: VERDICT
command-exit 0. `check:test-typecheck: OK — ... 10 file(s) / 94 error(s)
/ 23 pinned signature(s) held`, so no new test-layer error.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2` (the full suite): VERDICT command-exit 0, `Test Files
129 passed (129)`, `Tests 2657 passed | 10 skipped (2667)`.
- The #22312 pins, unchanged, run verbose:
`auth-settings-seeded-boot.pin.test.ts` 2/2 (no Pre-bind READ; the
control reports exactly 1), `auth-settings-ordering.pin.test.ts` 5/5.
Also the `Membership backfill re-run on app:seeded (#2996)` block 11/11
and `membership-policy-setting.test.ts`. Together: 4 files, 123 tests
passed.
## Gates (head `9c8ed2b62e`)
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 67 commands for 3 paths (48 changed
lines). I ran each with its exit code captured before any pipe, plus
`pnpm check:settings-bind-window` (this tree's own version: exit 0, "4
declared / 0 self / 1 structurally upstream / 0 ledgered"). `--ran`
reports: "✓ dispatch-gates --ran: 67 derived famil(ies) accounted for —
66 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)".
- 66 of 67 exit 0, including `check:nul-bytes` ("no raw ASCII control
bytes"), `check:engine-double-contract`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage`, `check:type-check-debt`, the changeset
gates (`check-adr-0087-registration`, `check-changeset-no-major`,
`check-empty-changeset`) and `check:doc-authoring`.
- NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE
NOT MET. The gate reads every publishable package's `dist/`, and only
plugin-auth's dependency closure is built here; a whole-workspace build
is CI's. Targeted substitute: `require('@objectstack/plugin-auth')` from
the package resolves `dist/index.js` and loads (`AuthPlugin: function`,
exit 0). The diff adds no import.
## Acceptance notes
- PR #22325's gate population is hook handlers. The `objectql`
middleware path (`registerMiddleware` → `runEnsure` →
`runBackfillOnDefaultOrg`) can call `runBackfill` during Phase 2, and
the gate does not model it; there, `backfillArmed` is the only guard.
This is an observation about the gate's reach, not a defect: the flag
keeps that path out of the pre-bind window at runtime. Owner: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0115N1oNnQS5WqofZ2DzaT3q)_
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3599fef commit 8a995b8
3 files changed
Lines changed: 38 additions & 10 deletions
File tree
- .changeset
- packages/plugins/plugin-auth/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1635 | 1635 | | |
1636 | 1636 | | |
1637 | 1637 | | |
1638 | | - | |
| 1638 | + | |
| 1639 | + | |
| 1640 | + | |
1639 | 1641 | | |
1640 | | - | |
| 1642 | + | |
| 1643 | + | |
| 1644 | + | |
1641 | 1645 | | |
1642 | 1646 | | |
1643 | 1647 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1352 | 1352 | | |
1353 | 1353 | | |
1354 | 1354 | | |
| 1355 | + | |
| 1356 | + | |
| 1357 | + | |
| 1358 | + | |
| 1359 | + | |
| 1360 | + | |
| 1361 | + | |
| 1362 | + | |
| 1363 | + | |
| 1364 | + | |
| 1365 | + | |
| 1366 | + | |
| 1367 | + | |
| 1368 | + | |
| 1369 | + | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
| 1374 | + | |
1355 | 1375 | | |
1356 | 1376 | | |
1357 | | - | |
1358 | | - | |
1359 | | - | |
1360 | | - | |
1361 | | - | |
1362 | | - | |
1363 | | - | |
1364 | | - | |
1365 | 1377 | | |
1366 | 1378 | | |
1367 | 1379 | | |
| |||
0 commit comments