Repository navigation
Commit c28f317
feat(core): one by-name read of the security catalog (ADR-0131 C2, stage S1) (#22091)
Part of #15196
Clause-②: yes (widening)
**The new public surface of `@objectstack/core`.** One value export,
`createSecurityCatalogReader(sources)`, re-exported from the root barrel
through `packages/core/src/security/index.ts`. It takes `{ registry,
metadata }` (type `SecurityCatalogSources`) and returns a
`SecurityCatalogReader` with two members. `resolve(type, name)` returns
a promise of the `SecurityCatalogEntry` that the name resolves to, or
`undefined` when no reader holds it. `list(type)` returns a promise of
an array of `SecurityCatalogEntry`, one per name, each equal to what
`resolve` answers for that name. `type` is `SecurityCatalogType`, the
union of `'position'`, `'permission'` and `'capability'`. Any other
value rejects with a `TypeError`. An empty or non-string name resolves
to `undefined`. An entry is `{ type, name, definition, source,
packageId? }`. `definition` is the reader's own definition object,
read-only and shared. `source` is a `SecurityCatalogSourceName`,
`'registry'` or `'metadata'`. `packageId` is the definition's
`_packageId`, present when the definition names one. The types exported
with it are `SecurityCatalogType`, `SecurityCatalogSourceName`,
`SecurityCatalogRegistry`, `SecurityCatalogMetadataService`,
`SecurityCatalogSources`, `SecurityCatalogEntry` and
`SecurityCatalogReader`. `SecurityCatalogRegistry` needs `getItem(type,
name)`, `listItems(type)` and `isPackageDisabled(packageId)`; ObjectQL's
`SchemaRegistry` satisfies it as it stands.
`SecurityCatalogMetadataService` needs `get(type, name)` and
`list(type)`, plus the optional `getDiagnosed` and `listDiagnosed`; the
`MetadataManager` satisfies it as it stands. Construction throws a
`TypeError` when either source lacks a member the read calls. The read
rejects with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503)
when a reader throws, when a metadata by-name read lost a loader and
found nothing, or when a metadata list lost a loader at all. Nothing
calls the reader yet, so no grant changes.
## What this stage is
Stage S1 of the C2 stage plan (ADR-0131 D2–D4): one seam that later
stages switch readers onto. **No consumer is switched.** Files:
- `packages/core/src/security/security-catalog.ts` (new): the read.
- `packages/core/src/security/index.ts`: the export, one block.
- Tests only, beyond that:
`packages/core/src/security/security-catalog.test.ts` (new), a new
describe block at the end of
`packages/objectql/src/protocol-boot-hydration-scoped.test.ts`, and
`packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts`
(new).
- `.changeset/15196-core-security-catalog-read.md`: `minor` for
`@objectstack/core`.
## The reader per type, measured before choosing
Re-measured on objectstack `main` at `3d9188502e` (the census was taken
at `e67ba80049`; the readings are unchanged). Showcase was booted four
ways: `single`, `single` with the Default Organization, walled with the
real organizations package declared by a temporary host root, and walled
`posture-only`. Names per reader, identical in all four boots:
| type | engine registry (`SchemaRegistry.listItems`) | metadata service
(`list`) | metadata door (`GET /api/v1/meta/:type`) | union of the first
two equals the door |
|:--|--:|--:|--:|:--|
| `position` | 0 | 10 | 10 | yes |
| `permission` | 17 | 9 (a subset) | 17 | yes |
| `capability` | 2 | 2 (the same names) | 2 | yes |
Neither in-process reader holds the whole catalog. The engine registry
has no stack-declared position, because the engine's stack-collection
list does not decompose `positions`. The metadata service lacks the
eight platform permission sets that `plugin-security` ships on its own
manifest (`admin_full_access` among them). The door is a serving
surface: it reads stored rows on each request and decorates what it
serves. So the read is the union of the two in-process readers, in one
order: **the engine registry first, then the metadata service** for the
names the registry does not hold. The reader that answers on showcase,
per type:
- **`permission`: the engine registry** (17 of 17).
- **`position`: the metadata service** (10 of 10).
- **`capability`: the engine registry**, which answers first. Both hold
the same 2.
For the 11 names both readers hold (9 permission sets and 2
capabilities), the definition bodies are identical across the registry,
the metadata service and the door's by-name read. That was measured by
comparing every non-underscore key. So the order changes no body on
showcase.
## What the read does and does not answer
- **It does not answer whether an item is in effect.** Under the
standing ruling (#12159 Part 3), the row `active` flag stays
authoritative, and no definition carries it. An entry says that a
definition exists under a name, never that it grants. A later stage that
reads definitions keeps reading the row flag with `isRowActive`. The
module doc says so in those words.
- **It does not answer the position → permission-set binding**, which
stays on the junction until C3, or **organization scope**, because the
catalog is environment-level under ADR-0131 D3.
- **A name two packages ship** gets today's answer, which is the
registry's by-name precedence. With no stored override, the read returns
the first-registered package's body. Once one package stores an override
bound to itself and boot hydrates it, the read returns that override for
every caller. The metadata door's by-name read returns the same body in
both cases. Q4 is with the maintainer, so this is pinned as today's
answer and not as a policy.
- **A definition owned by a disabled package answers neither member.**
The registry's `listItems` already hides such items, and the by-name
read applies the same rule, so the two members cannot disagree. This is
the one rule the read adds on top of the readers' own answers; see the
first Acceptance note.
## Pins, each ablated and restored by blob
Every ablation went through `scripts/ablation-replace.mjs` in WRAP mode,
so its anchor hit exactly once and the blob changed. The restore was
proven with "blob == HEAD and `git diff HEAD` is empty". The
dist-resolved legs also rebuilt the package, proved the marker with
`scripts/ablation-dist-preflight.mjs` (`marker present in 2 built
files`), and proved it gone after restore (`marker absent from all …
built files`, `working tree clean against HEAD`).
- **P1.1, `security-catalog-showcase.dogfood.test.ts`** (31 tests,
green). For each type in each of the three postures (`single`, `single`
with the Default Organization, walled `posture-only`), the read lists
exactly the declared names. The expected names come from the producers,
never from a registry: the showcase stack's `positions`, `permissions`
and `capabilities`, plus `securityDefaultPermissionSets`. The read also
lists exactly the door's names, and every listed name resolves to the
entry the list gave.
- Ablation A1, *dropping the platform bootstrap-set source*:
`plugin-security`'s manifest `permissions:` entry was replaced with an
empty array, and `plugin-security` was rebuilt. Red in all three
postures: `permission: lists exactly the declared names`, with
`admin_full_access` and `organization_admin` missing (expected 17,
received 15). The door assertion stayed green because the door lost the
same names, which is why the expected sets are derived from the
producers.
- Ablation A2, *the read drops its engine-registry source*: the read's
registry by-name call was made unreachable, and core was rebuilt. Red in
all three postures, on both the declared half and the door half
(received the metadata service's 9). Positions and capabilities stayed
green, as they should.
- **P1.2, the new block in `protocol-boot-hydration-scoped.test.ts`** (5
tests, green). It uses the real `SchemaRegistry`, the real
`loadMetaFromDb` hydration and the file's existing pinned engine double,
for both `permission` and `position`. With no override, the read returns
the first-registered package's body. With an override bound to package
B, it returns B's override and `packageId` B. The door's `getMetaItem`
is asserted beside each case. A position name that two stacks declare
shares one metadata-service slot, and the later registration holds it.
- Ablation, *swapping the override's package*: the stored override was
bound to package A. Red: the 2 override cases (`permission`,
`position`).
- **Unit pins, `security-catalog.test.ts`** (14 tests, green), each
ablated in the core source:
- **U1**: metadata service first. Red: `a name both readers hold is
answered by the engine registry` and `list gives one entry per name …`.
- **U2**: disabled rule dropped. Red: both disabled-package tests.
- **U3**: a registry throw read as a miss. Red: `a registry read that
throws`.
- **U4**: a metadata throw read as a miss. Red: `a metadata read that
throws`.
- **U5**: a degraded miss read as a miss. Red: `a metadata read that
lost a loader and found nothing`.
- **U6**: construction no longer refuses a missing metadata source. Red:
`construction refuses a missing reader …`.
- The failure pins assert the envelope (`code` `SERVICE_UNAVAILABLE`,
`status` 503, and the brand), never a bare throw.
The ablations ran at `0482468f8e`. The two later commits add the
changeset and turn one fixture argument into JSON text. Neither touches
an ablated line or assertion.
## Verification, at head `b70dd032be`
- `pnpm --filter @objectstack/core test`: `Test Files 78 passed (78)`,
`Tests 2192 passed (2192)`. This ran at `46fe89abad`; the last commit
changed only an objectql test file.
- `pnpm --filter @objectstack/core typecheck`: exit 0, test layer `OK`,
debt unchanged. The new test file is in the `tsconfig.test.json` program
(checked with `--listFiles`).
- `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer
`OK`.
- `pnpm --filter @objectstack/dogfood typecheck`: exit 0.
- The P1.2 file: `Tests 8 passed (8)`. The P1.1 file (`--project
isolated`): `Tests 31 passed (31)`.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` was re-derived on this change: 70 families,
plus the 2 the dispatch named that the re-derivation does not
(`check:authz-resolver`, `check:dispatcher-error-vocabulary`). All 72
were run with the exit code captured before any pipe, and all exit 0.
`check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 7
unbuilt packages); after building them it exited 0, with `106 published
require entry point(s) across 66 package(s) load`. `--ran` reports `70
derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED`. Some verdict
lines: `check-engine-double-contract: OK — 980 pinned`,
`check-nul-bytes: OK`, `check-test-source-alias OK`,
`check:authz-resolver: single shared authorization resolver intact`,
`check-adr-0087-registration: … 1 non-breaking changeset(s) seen`, `This
diff introduces no major bump`.
- ESLint, narrowed and measured: `eslint --no-inline-config --format
json` on the 5 changed `.ts` files reports 5 files, 0 errors, 0
warnings, and every file matches the config. `eslint.config.mjs` enables
no type-aware linting (no `parserOptions.project`). The only files it
reads are two baselines this diff does not touch, so no untouched file's
verdict can move. The repository-wide `pnpm lint` is CI's.
- Declared to CI: the rest of the dogfood suite, the objectql suite
outside the touched file, Build Core and the workspace type-check lanes.
## Acceptance notes
- **The disabled-package rule is a decision for the contract review.**
No security code reads package disable today. The catalog rows a
disabled package seeded keep granting through the row read. When S8
switches a reader onto this seam, a disabled package's sets would stop
resolving, so S8's grant-equivalence goldens should state that. The
alternative was to apply the rule to neither member, which leaves `list`
and `resolve` disagreeing about whether a name exists, because the
registry's `listItems` applies the rule unconditionally.
- **Stack-declared positions carry no `_packageId` in the metadata
service**, so the disabled-package rule cannot reach them. This is the
same root as the positions being absent from the engine registry.
- **A waiver reason has drifted (observation, not filed).**
`scripts/check-stack-collection-maps.mjs`'s waiver for `positions` says
positions "reach the registry through the security bootstrap". Measured,
they reach the metadata service and the catalog rows, never the engine
registry.
- **The census's carrier, "the declared-positions seeder's
registry-first / list fallback is an either-or trap"**, is not removed
by this stage. No consumer is switched, and the seam is the place a
later stage moves that seeder onto.
- **No ADR anchor file** was added under `scripts/adr-anchors/`, because
the stage's surface is the module and its export. ADR-0131 is cited in
the module.
- **The walled arm of P1.1 boots `posture-only`.** The real
organizations package resolves only from a host root that declares it,
and this suite's root does not. The one-off reading with a declared host
root gave the same sets.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Warren Buffett <warren@objectstack.ai>1 parent 8a925b3 commit c28f317
6 files changed
Lines changed: 840 additions & 0 deletions
File tree
- .changeset
- packages
- core/src/security
- objectql/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
222 | 237 | | |
223 | 238 | | |
224 | 239 | | |
| |||
| 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 | + | |
| 18 | + | |
| 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 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 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 | + | |
0 commit comments