Repository navigation
feat(core): one by-name read of the security catalog (ADR-0131 C2, stage S1) - #22091
Conversation
…registry and the metadata service ADR-0131 D2-D4: positions, permission sets and capabilities resolve by name from the environment registry. No single in-process reader holds the whole catalog (the engine registry has no stack-declared position; the metadata service lacks the platform permission sets), so the read is their union in one order, registry first. No consumer is switched. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
… ship Today's answer, until shared catalog names are ruled: the first-registered package's body with no stored override; the override one package stored for itself, for every caller, once hydrated. The metadata door's by-name read is asserted beside each answer. A position name two stacks declare shares one metadata-service slot; the later registration holds it. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
…ures Per type, the read lists exactly the declared names (the showcase stack's positions, permissions and capabilities, plus the platform's bootstrap permission sets) and exactly the names the metadata door lists, in single, single with the Default Organization, and walled postures; every listed name resolves by name to the entry the list gave. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
The row's metadata column is text; passing the object added a type error the test-typecheck ledger does not record. Claude-Session: https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 27 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 afff2169134a217a9b25e2456ddc9c3d7c19062a && git checkout afff2169134a217a9b25e2456ddc9c3d7c19062a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bafb58bb08aa3989b1d72a633496b361fcf0842e e05d4692103c4d8276a5aecafb01e188158d6f5c && git checkout -B drift-repro bafb58bb08aa3989b1d72a633496b361fcf0842e && git merge --no-ff e05d4692103c4d8276a5aecafb01e188158d6f5c
node scripts/docs-audit/affected-docs.mjs --json bafb58bb08aa3989b1d72a633496b361fcf0842e
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #15196 (body and all 17 comments), PR #22091 (body, the 6-file list, the net diff against Public surface of Accept-set of the new read, each judged on the diff:
No consumer switched, verified on the diff: the only non-test source lines are the new module and one export block. The pinned sibling checkout is unaffected, since the change is an additive export. ② Semver level
③ Boundary flagsDev flags (the PR's Acceptance notes and the os-dev-report
Card-level open questions: Q3 (the walled half) and Q4 remain with the maintainer ( Gate verdicts on the head: 30 check-runs Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Regen-provenance: 6041388058 · Seat Why the merge: the first CI and Lint & Type Check runs on What the merge carries: the PR's own delta against Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37654973228 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
… security catalog read (ADR-0131 C2 stage S2b) (objectstack-ai#22210) Part of objectstack-ai#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 objectstack-ai#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 objectstack-ai#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>
Part of #15196
Clause-②: yes (widening)
The new public surface of
@objectstack/core. One value export,createSecurityCatalogReader(sources), re-exported from the root barrel throughpackages/core/src/security/index.ts. It takes{ registry, metadata }(typeSecurityCatalogSources) and returns aSecurityCatalogReaderwith two members.resolve(type, name)returns a promise of theSecurityCatalogEntrythat the name resolves to, orundefinedwhen no reader holds it.list(type)returns a promise of an array ofSecurityCatalogEntry, one per name, each equal to whatresolveanswers for that name.typeisSecurityCatalogType, the union of'position','permission'and'capability'. Any other value rejects with aTypeError. An empty or non-string name resolves toundefined. An entry is{ type, name, definition, source, packageId? }.definitionis the reader's own definition object, read-only and shared.sourceis aSecurityCatalogSourceName,'registry'or'metadata'.packageIdis the definition's_packageId, present when the definition names one. The types exported with it areSecurityCatalogType,SecurityCatalogSourceName,SecurityCatalogRegistry,SecurityCatalogMetadataService,SecurityCatalogSources,SecurityCatalogEntryandSecurityCatalogReader.SecurityCatalogRegistryneedsgetItem(type, name),listItems(type)andisPackageDisabled(packageId); ObjectQL'sSchemaRegistrysatisfies it as it stands.SecurityCatalogMetadataServiceneedsget(type, name)andlist(type), plus the optionalgetDiagnosedandlistDiagnosed; theMetadataManagersatisfies it as it stands. Construction throws aTypeErrorwhen either source lacks a member the read calls. The read rejects withAuthzStoreUnavailableError(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.packages/core/src/security/security-catalog.test.ts(new), a new describe block at the end ofpackages/objectql/src/protocol-boot-hydration-scoped.test.ts, andpackages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts(new)..changeset/15196-core-security-catalog-read.md:minorfor@objectstack/core.The reader per type, measured before choosing
Re-measured on objectstack
mainat3d9188502e(the census was taken ate67ba80049; the readings are unchanged). Showcase was booted four ways:single,singlewith the Default Organization, walled with the real organizations package declared by a temporary host root, and walledposture-only. Names per reader, identical in all four boots:SchemaRegistry.listItems)list)GET /api/v1/meta/:type)positionpermissioncapabilityNeither 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 thatplugin-securityships on its own manifest (admin_full_accessamong 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
activeflag 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 withisRowActive. The module doc says so in those words.listItemsalready 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.mjsin WRAP mode, so its anchor hit exactly once and the blob changed. The restore was proven with "blob == HEAD andgit diff HEADis empty". The dist-resolved legs also rebuilt the package, proved the marker withscripts/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).security-catalog-showcase.dogfood.test.ts(31 tests, green). For each type in each of the three postures (single,singlewith the Default Organization, walledposture-only), the read lists exactly the declared names. The expected names come from the producers, never from a registry: the showcase stack'spositions,permissionsandcapabilities, plussecurityDefaultPermissionSets. The read also lists exactly the door's names, and every listed name resolves to the entry the list gave.plugin-security's manifestpermissions:entry was replaced with an empty array, andplugin-securitywas rebuilt. Red in all three postures:permission: lists exactly the declared names, withadmin_full_accessandorganization_adminmissing (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.protocol-boot-hydration-scoped.test.ts(5 tests, green). It uses the realSchemaRegistry, the realloadMetaFromDbhydration and the file's existing pinned engine double, for bothpermissionandposition. With no override, the read returns the first-registered package's body. With an override bound to package B, it returns B's override andpackageIdB. The door'sgetMetaItemis asserted beside each case. A position name that two stacks declare shares one metadata-service slot, and the later registration holds it.permission,position).security-catalog.test.ts(14 tests, green), each ablated in the core source:a name both readers hold is answered by the engine registryandlist gives one entry per name ….a registry read that throws.a metadata read that throws.a metadata read that lost a loader and found nothing.construction refuses a missing reader ….codeSERVICE_UNAVAILABLE,status503, 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
b70dd032bepnpm --filter @objectstack/core test:Test Files 78 passed (78),Tests 2192 passed (2192). This ran at46fe89abad; the last commit changed only an objectql test file.pnpm --filter @objectstack/core typecheck: exit 0, test layerOK, debt unchanged. The new test file is in thetsconfig.test.jsonprogram (checked with--listFiles).pnpm --filter @objectstack/objectql typecheck: exit 0, test layerOK.pnpm --filter @objectstack/dogfood typecheck: exit 0.Tests 8 passed (8). The P1.1 file (--project isolated):Tests 31 passed (31).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwas 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-loadsfirst exited 3 (PREREQUISITE NOT MET: 7 unbuilt packages); after building them it exited 0, with106 published require entry point(s) across 66 package(s) load.--ranreports70 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 --no-inline-config --format jsonon the 5 changed.tsfiles reports 5 files, 0 errors, 0 warnings, and every file matches the config.eslint.config.mjsenables no type-aware linting (noparserOptions.project). The only files it reads are two baselines this diff does not touch, so no untouched file's verdict can move. The repository-widepnpm lintis CI's.Acceptance notes
listandresolvedisagreeing about whether a name exists, because the registry'slistItemsapplies the rule unconditionally._packageIdin 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.scripts/check-stack-collection-maps.mjs's waiver forpositionssays positions "reach the registry through the security bootstrap". Measured, they reach the metadata service and the catalog rows, never the engine registry.scripts/adr-anchors/, because the stage's surface is the module and its export. ADR-0131 is cited in the module.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