Repository navigation
Commit 17e4425
fix(plugin-security): the declared-positions seeder reads through the security catalog read (ADR-0131 C2 stage S2b) (#22210)
Part of #15196
Clause-②: no
**Stage S2b of ADR-0131 C2: the declared-positions seeder reads through
S1's catalog read.** `bootstrapDeclaredPositions` used to read one
source or the other. It took the engine registry alone whenever the
registry held any position besides the six built-ins, and the metadata
service otherwise. A position a platform administrator saves with `PUT
/api/v1/meta/position/:name` is hydrated into the registry. So one such
save made every later seeding pass write that position and none of the
stack's declared ones, including the pass each new organization gets on
a walled deployment. The seeder now reads
`createSecurityCatalogReader(...).list('position')`
(`@objectstack/core`, from PR #22091). That read takes the engine
registry and the metadata service in the catalog's one read order, every
pass. The six built-ins are still excluded, by name, so
`bootstrapBuiltinRoles` stays their only writer.
Measured on a booted showcase, walled. A platform administrator saves
`zz_s2b_door` at the door, then a new organization is created. Before,
that organization's `sys_position` catalog held 7 rows: the six
built-ins and `zz_s2b_door`, with none of the showcase's ten declared
positions. After, it holds 17 rows: the six, the ten and `zz_s2b_door`.
The `zz_s2b_door` row reads `admin | true | false | S2b Door` on both
sides.
The rest of #15196 stays open. No reader moves to the catalog read here
(S8), no Setup write changes (S7), `bootstrapBuiltinRoles`' rows do not
change, and `packages/spec` and `security-plugin.ts` are untouched.
## The read order, measured before any row write changed
For a name both sources hold, S1's read answers the registry's body,
through the registry's own by-name precedence: a stored definition in
the bare slot first, else the first-registered package's. The old seeder
answered the same, because a non-built-in name in the registry was
itself what made the registry answer alone. The pin `a name both sources
hold is written from the registry's body` uses the real `SchemaRegistry`
with a door-saved `field_rep` beside a declared `field_rep`. It writes
the door-saved label and description on both sides of the change: green
at base `959c209d56` with the old seeder restored by blob, and green at
this head.
One boundary was read and not pinned. The old read iterated `listItems`,
so a name the registry held twice had both entries processed, the last
one's label winning. S1 answers one entry per name. For a non-built-in
position name this state is unreachable today: the manifest's
`positions` key reaches no registry (measured in S2), so only the six
carry a composite package entry, and the six are excluded.
## What changed
- **`bootstrap-declared-positions.ts`**: `readDeclaredPositions` is the
catalog read's list minus the six (`isBuiltinPositionName`, an exact
name match), mapped to each entry's `definition`. The row writes are
unchanged: the same columns, the same three-outcome existence read and
the same refusal reporting.
- **A read that did not happen is not "nothing declared".** The catalog
read refuses to be built over a source missing a member it calls
(`TypeError`). It raises `AuthzStoreUnavailableError`
(`SERVICE_UNAVAILABLE`, 503) for a source that threw or a metadata list
that lost a loader. The seeder lets both reach its caller, which already
catches and logs `[security] declared-position seeding failed` at
`warn`. Before, the seeder swallowed both and seeded whatever part it
could read, silently. This is the only behaviour change outside the
door-authored state, and it is in a failure path. In 26 booted dogfood
files it never fired (zero such lines).
- **Comment-only:** `claim-seed-ownership.ts` now carries the
`domain:engine` seat's proposed sentence on the `sys_stamp_audit_*`
builtins (note 6050244606). The note on `builtin-positions.ts`
describing the declared seeder's exclusion said "registry-first
decision"; this stage made that false, so it now describes the catalog
read.
- **Test doubles:** three sibling suites seed positions from a stub
registry (`bootstrap-seed-round-trips`, `per-organization-catalog`,
`seed-write-refusal`). Each stub gains the two members the catalog read
calls (`getItem`, `isPackageDisabled`), and each suite passes a metadata
service that declares nothing instead of `null`. What each suite asserts
is unchanged.
- **`vitest.config.ts`** declares `OS_REGISTRY_LOG: 'warn'`. The
seeder's unit suite now constructs a `SchemaRegistry`, which puts this
package in `check:registry-log-declared`'s population; the gate went red
without it and prescribes exactly this line. Side effect, measured: the
full suite's log went from 10,908 `[Registry]` lines to 7.
- **Changeset:** `@objectstack/plugin-security` `patch`, carrying the
same `Clause-②: no` line.
**Surface.** The claim amendment (6051984337) named
`bootstrap-declared-positions.ts`, its tests, the S2 boot harness and
the `claim-seed-ownership.ts` comment. Every other file above is in
`plugin-security` and is either a test double of this seeder, a comment
this stage made false, or the line a gate demanded. Apart from the
changeset, no file outside `plugin-security` is touched.
## Pins
**The real-boot harness (`builtin-positions.boot.test.ts`)** boots this
plugin for real: `init`, `start`, then its `kernel:ready` handlers in
order, over ObjectQL on SQLite, with `field_rep` declared in the
metadata service. It now runs 9 scenarios: three postures (`single`,
`single + organization`, `walled` with an organization at boot plus one
created after), each booted as it is, with a door-authored position in
the registry, and with a stored definition under a built-in name.
- **The census and the write ledger.** The census reads name,
organization, `managed_by`, `active`, `is_default`, label and
description. The ledger holds every `sys_position` insert and update,
refused ones included. In each scenario they equal the built-ins, every
stack-declared position and the door-authored one. The three postures as
they are keep S2's goldens unchanged, as recorded before the
declarations. The door-authored goldens now include `field_rep` in every
pass.
- **The door-authored position's own rows** (`single`, then walled) are
pinned separately to what the registry-only read wrote, and the pin is
green on both sides of the change.
- **The built-in shadow state.** The reviewer flagged it in 6051555790
③: `org_admin` and `everyone` saved as package-less, tenant-authored
definitions. Positive control: the catalog read answers the stored body
for both names. Pinned: the census and ledger equal the plain posture's,
so the declared seeder neither seeds nor restamps either name, and the
built-in rows are exactly what `bootstrapBuiltinRoles` writes.
- **Grant equivalence.** The resolver's whole envelope for the platform
administrator, the organization administrator, a member and an agent
equals S2's golden. It runs in all 6 scenarios with an organization
(`single + organization` and walled, times the three states), where S2
ran it in 2. No principal holds a newly seeded position.
- **Position write refusals** match S2's golden in the same 6 scenarios:
a dangling name, deleting `everyone`, relabelling `platform_admin`, plus
the two controls.
**Unit pins (`bootstrap-declared-positions.test.ts`)** run over the real
`SchemaRegistry`, the six registered the way the plugin registers them:
- a door-authored position no longer silences the declared ones;
- the door-authored row is written as before;
- the read order pin above;
- the shadow pin, covering both hydration shapes: tenant-authored only,
and with this plugin's envelope grafted on;
- a source that throws, or a metadata list that lost a loader, writes
nothing and rejects with `SERVICE_UNAVAILABLE` / 503;
- a composition with no metadata service rejects (`TypeError`) instead
of reading the registry alone.
**Base leg** (`bootstrap-declared-positions.ts` restored from
`959c209d56` by blob, then restored to `HEAD`: blob equal, `git diff
HEAD` empty). The two files ran 6 red and 40 green. The 6 red are the
three door-authored census/ledger scenarios (the only difference is the
missing `field_rep` rows and inserts), the unit trap pin and the two
failure-path pins. Every grant, refusal, shadow, read-order and
door-authored-row pin is green at base.
**Ablations.** Each went through `scripts/ablation-replace.mjs` (anchor
1 to 0, blob changed). Each was restored by `git checkout HEAD`, blob
equal to `HEAD` and `git diff HEAD` empty, and re-checked by a separate
blob comparison. The run covers five suites, 118 tests.
| ablation | red |
|---|---|
| A1: restore the either-or read | 6: the three door-authored scenarios
(census and ledger), the unit trap pin, the two failure-path pins |
| A2: the seeder takes built-in names (no exclusion) | 15: all nine boot
scenarios' census and ledger, and six unit pins, the shadow pin among
them |
| A3: exclude by package instead of by name | 7: exactly the three
shadow scenarios, the unit shadow pin, and the three S2 unit pins whose
stub carries no package |
| A4: read the metadata service alone | 30, the door-authored rows pin
among them (all three scenarios) |
| A5: metadata service first for a shared name | 5: the read-order pin,
the three door-authored ledgers (order), a failure-path pin |
| A6: grant sensitivity (built-in names taken and inserted inactive) |
26, the grant golden among them in all 6 scenarios with an organization
|
## Measured on a booted showcase, walled
The flow is a platform administrator's `PUT
/api/v1/meta/position/zz_s2b_door` (200), then `sys_organization` insert
`org_s2b_gamma`, then a read of its `sys_position` rows. Before is
`plugin-security` rebuilt from the base seeder source; the dist
preflight found the base marker in 2 built files, and after the run the
source was restored by blob, rebuilt, and the marker was absent again.
After is this branch, pre-merge `2d41056b54` and again at `5c4e58def7`.
| reading | before | after |
|---|---|---|
| an organization present before the save | 16 rows | 16 rows |
| the organization created after the save | 7 rows: the six and
`zz_s2b_door` | 17 rows: the six, the ten declared, `zz_s2b_door` |
| the `zz_s2b_door` row | `admin`, active, not default, `S2b Door` |
identical |
In `single`, the seeder runs once per boot, so a door-saved position
reaches it at the next boot as a hydrated registry item. That is the
harness's `single` door-authored scenario: `insert door_authored@-`,
written identically before and after.
## Verification
All at `5c4e58def7`, the head of this PR: `origin/main` `c6fe02d7ac`
merged in, then `pnpm install --frozen-lockfile` and the closures
rebuilt.
- **`plugin-security`:** `typecheck` exit 0, test layer 0 errors.
`vitest run`: 175 files, 3715 passed, 45 skipped.
- **`core`:** `typecheck` exit 0, test layer at its 4 pinned debt
signatures. `vitest run --project local`: 78 files, 2192 passed.
Measured before the merge, at `e593e1ab2a`. The merge brought no change
to `packages/core`.
- **Closures built:** `@objectstack/plugin-security...` (18 packages)
and `@objectstack/dogfood^...` (63 packages).
- **Dogfood:** 27 files, the 21 that boot a walled posture plus six
position and baseline files (`showcase-declarative-rbac-seeding`,
`me-apps-and-everyone-baseline`, `membership-role-vocabulary`,
`showcase-d7-default-profile`, `delegation-of-duty`,
`org-scoped-sharing-rule-listing`). 26 passed, with 220 tests passed and
3 skipped. `rls-multitenant` skipped itself through its own
organizations probe. No boot logged `declared-position seeding failed`.
The same 27 files read the same before the merge.
- **Gates:** the 65 commands `dispatch-gates --commands` derives for
this diff, run at `5c4e58def7`, each exit captured before any pipe: 65
of 65 exit 0. `--ran` reconciled them as 65 derived, 65 run, 0
NOT-MEASURED (a derived zero), 0 UNRUN.
- On the first pass, at `2d41056b54`, two commands did not exit 0.
`check:registry-log-declared` went red because the seeder's suite now
constructs a `SchemaRegistry`; the line it prescribes is in
`e593e1ab2a`. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET
because eight packages outside this diff's closure were unbuilt; it
passed once they were built.
- `check:query-options-erasure` reads 236 on the test surface,
unchanged.
- **Lint (scoped):** the 9 changed `.ts` files, `eslint
--no-inline-config --format json`: 9 files linted, 0 errors, 0 warnings.
The population is `eslint.config.mjs`'s own: every `packages/**`
TypeScript file, and none of the 9 was ignored. The file count is read
from the JSON. The config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot change the verdict on any
file it does not touch. The repo-wide `pnpm lint` is CI's.
## Acceptance notes
- **The failure-path change above is deliberate and declared.** Before,
a degraded metadata list seeded a partial catalog with no word. Now the
pass seeds nothing and the boot says so once at `warn`. The caller's
level is `security-plugin.ts`'s, which this stage does not touch.
- **The read-order boundary (a name held twice in the registry)** is
described above. It is unreachable for non-built-in positions today, so
it is noted and not pinned.
- **S1's module doc** in `security-catalog.ts` still carries a measured
table from before S2 (`position`: engine registry 0). That table is
already carried to S8a, and nothing here changes it.
- **No `scripts/adr-anchors/` entry** is added; the ADR-0131 D2 anchor
is carried to S8a (seat ACCEPT 6051569578).
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 7ebbc01 commit 17e4425
10 files changed
Lines changed: 461 additions & 157 deletions
File tree
- .changeset
- packages/plugins/plugin-security
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
16 | 17 | | |
17 | | - | |
| 18 | + | |
| 19 | + | |
18 | 20 | | |
19 | | - | |
20 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
21 | 51 | | |
22 | 52 | | |
23 | 53 | | |
24 | 54 | | |
25 | 55 | | |
26 | | - | |
| 56 | + | |
27 | 57 | | |
28 | 58 | | |
29 | 59 | | |
| |||
57 | 87 | | |
58 | 88 | | |
59 | 89 | | |
60 | | - | |
| 90 | + | |
61 | 91 | | |
62 | 92 | | |
63 | 93 | | |
| |||
72 | 102 | | |
73 | 103 | | |
74 | 104 | | |
75 | | - | |
| 105 | + | |
76 | 106 | | |
77 | 107 | | |
78 | 108 | | |
| |||
87 | 117 | | |
88 | 118 | | |
89 | 119 | | |
90 | | - | |
| 120 | + | |
91 | 121 | | |
92 | 122 | | |
93 | 123 | | |
| |||
100 | 130 | | |
101 | 131 | | |
102 | 132 | | |
103 | | - | |
| 133 | + | |
104 | 134 | | |
105 | | - | |
| 135 | + | |
106 | 136 | | |
107 | 137 | | |
108 | 138 | | |
109 | 139 | | |
110 | 140 | | |
111 | 141 | | |
112 | | - | |
113 | | - | |
114 | | - | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | | - | |
119 | | - | |
120 | | - | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
121 | 145 | | |
122 | 146 | | |
123 | 147 | | |
124 | | - | |
125 | | - | |
126 | | - | |
127 | 148 | | |
128 | | - | |
| 149 | + | |
129 | 150 | | |
130 | 151 | | |
131 | 152 | | |
| |||
135 | 156 | | |
136 | 157 | | |
137 | 158 | | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
142 | | - | |
143 | | - | |
144 | | - | |
145 | 159 | | |
146 | 160 | | |
147 | 161 | | |
| |||
164 | 178 | | |
165 | 179 | | |
166 | 180 | | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
0 commit comments