Skip to content

Commit 0fea05f

Browse files
fix(metadata,runtime): a multi-package config boot registers its unowned top level under manifest.id, as the artifact boot does (#22612)
Fixes #22521 Clause-②: yes `@objectstack/metadata` widens its public surface: its root entry exports `unclaimedTopLevel` with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel`. That package takes `minor`; `@objectstack/runtime` and `@objectstack/cli` stay `patch`. The one new refusal is a pull-back to the declared contract, not a narrowing of it (contract review 6095729741, judgment 2). A residual view container whose own `name` is not its object is refused by the judge every door that files a container already calls; the multi-package config's top level was simply never read. So no arm is declared and nothing is BREAKING. No key, export or error code is removed. ## What this does A config boot (`os serve objectstack.config.ts`, with or without `--dev`) of a stack that carries `packages[]` hands the stack to the `manifest` service. That service registers the package bodies and never the top level. A top-level item that no package body declares was served by no door, and the boot said nothing. `os build` of the same project, booted as an artifact, registered the same items under `manifest.id`, and the metadata plugin warned. Both doors now run one residual rule, `MetadataPlugin.registerUnclaimedTopLevel` (`packages/metadata/src/plugin.ts`). Which top-level items are residual, and their owner, is the rule's pure half: `unclaimedTopLevel(stack)`, exported from `@objectstack/metadata`. - **The artifact door** calls the rule from `_parseAndRegisterArtifact`, unchanged in behaviour. It makes the same registrations and prints the same lines, byte for byte, refusals included. - **The config door** is `AppPlugin.start()` (`packages/runtime/src/app-plugin.ts`). It calls the rule with the metadata service's in-memory registry as its sink, and skips when the composition declares the artifact door (`securityMetadataRegistrar: 'artifact-door'`). That keeps one registrar per boot. - **The author-time door.** `os validate` and `os build` judge view containers through `findViewContainerNameRefusals` (`packages/cli/src/utils/view-container-names.ts`). That walk now also judges the residual containers of a multi-package stack, reading `unclaimedTopLevel`. The CLI never re-derives which items are residual. ⛔ No residual rule is written in `app-plugin.ts`, `serve.ts` or the CLI walk. ## What changes - **Served.** A config boot lists every top-level item no package body declares under the stack's `manifest.id`, and prints one `warn` line with the count: `config stack 'ID' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy (declare each item inside the `packages[]` entry that owns it). - **A new refusal, in one corner: a pull-back to the declared contract.** A residual view container is judged like any other container. A top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot. The refusal reads `Invalid` + `views:` + `container from manifest 'MANIFEST_ID': the container's own name is ...`. On `main` that config booted, because its top level was never read; an artifact boot of the same project already refused it, as `from artifact`. `os validate` and `os build` now refuse the same container first, in the same words. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. - **The words on the config door.** The residual container refusal names its source `manifest`, the word the config boot's own registrar and `os validate` already use for the same stack. The artifact door keeps `artifact`. - **What widens, in `@objectstack/metadata`.** The root entry exports `unclaimedTopLevel(stack)`, the pure residual decision, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`. `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. Nothing public is removed: the old `_registerArtifactBodyCollections` was private. - **The changeset** carries one sentence superseding, in the same release, the pending `.changeset/22405-serve-flat-docs-owner.md` note "A config boot of such a stack still does not list them". That file is not edited, per the foreign-changeset rule. ## Before and after, round 1 (row × door × listed / warned) Readings were taken through the public doors on real boots, with the authenticated `GET /api/v1/meta/{doc,object,view}` and the boot output. "Before" is base `86f53a4b8d`. "After" is this branch. | row | fixture | door | before: listed | before: warned | after: listed | after: warned | |:--|:--|:--|:--|:--|:--|:--| | 1 | composed, `manifest.id` names the app package, flat `src/docs` | config `--dev` / artifact | under the app package (PR #22520) | no / no | unchanged | no / no | | 2 | `manifest.id` names no package, flat `src/docs` | config | not listed | no | listed under `manifest.id` | yes, count 2 | | 2 | same | config `--dev` | not listed | no | listed under `manifest.id` | yes, count 2 | | 2 | same | artifact | listed under `manifest.id` | yes, count 2 | unchanged | unchanged | | 3 | inline `docs` spread on a composed stack's top level | config / config `--dev` | not listed | no | listed under `manifest.id` (the app's, composed last) | yes, count 1 | | 3 | same | artifact | NOT MEASURED: `os build` refuses the shape with "the default export was not built by `defineStack`", so no artifact exists | n/a | same refusal | n/a | | 4 | row 2 plus a top-level object and a top-level view container | config / config `--dev` | object, view and docs not listed (by-name 404) | no | object, expanded view and docs listed under `manifest.id` | yes, count 5 | | 4 | same | artifact | object, expanded view and docs listed under `manifest.id` | yes, count 5 | unchanged | unchanged | | control | one package, no `packages[]` | config / config `--dev` / artifact | everything under its one package | no | unchanged | no | In row 4 the config door also gives the artifact door's answer on the two doors this PR does not pin. The data door answers 404 for the residual object on both doors. The by-name view read answers 500 on both doors, and it answers 500 on an artifact boot of the single-package control too. The seat files both as findings. ## Before and after, round 2: a divergent view container no package owns The fixture is the neutral two-package config, with `manifest.id` naming no package and one top-level container `{ name: 'account_list', object: 'acme_account', list: ... }`. The "claimed" variant also declares that container on the app body. | fixture | door | base `86f53a4b8d` | round 1 head `3c69fda05` | this head `c1db768097` | |:--|:--|:--|:--|:--| | residual | `os validate` | exit 0 | exit 0 | exit 1, refused `from manifest 'com.example.acme.release'` (1 view container) | | residual | `os serve` config `--dev` | booted | refused `from artifact 'com.example.acme.release'` | refused `from manifest 'com.example.acme.release'` | | residual | `os build` | exit 0, artifact written | exit 0, artifact written | exit 1, no artifact, same words | | claimed | `os validate` / `os build` | exit 1, refused once `from manifest 'com.example.acme'` | same | same | | claimed | `os serve` config `--dev` | refused once, the body's | same | same | ## Mechanism hypotheses, measured - **H1 holds.** `AppPlugin.init()` hands `{ ...manifest, ...bundle }` to `getService('manifest').register()`. `ObjectQLPlugin`'s manifest service runs `resolveArtifactPackageOrder(artifact)`, which answers the bodies alone when `packages` is present, and calls `registerApp` per body. The boot shows this too. Before this change, a multi-package config whose top level carried a malformed view container booted clean. The single-package config with the same view refused at boot. The top level never reached registration, for docs, objects and views alike. - **H2 holds.** The artifact door registers each body, then registers what no body claimed under `manifest.id`, and warns. The warning counts were 2 for row 2 and 5 for row 4: 2 docs, 1 object, 1 view container and 1 expanded view. - **H3 holds.** On a config boot, `createStandaloneStack` composes `MetadataPlugin` over `dist/objectstack.json`. That file is absent, so the door loads nothing and the config never reaches it. The listing (`readFlattenedMetaItems`) merges the engine registry with `metadataService.list()`. On the artifact boot the residual object is listed on `/meta/object` while the data door answers 404, so the residual lives only in the metadata service. - **H4 holds, and it decides the fold.** `placeCollectedDocs` is placement, the one `os build` uses (`compile.ts` calls it too). It is not a residual rule, and it also attaches each package's own `src/PACKAGE/docs/` set. ## The fold decision: `serve.ts`'s `placeCollectedDocs` call stays To measure it, the flat placement was withheld (`flatDocs: []`, through `scripts/ablation-replace.mjs`, then restored by blob) and row 1 was booted with the fix in place. The flat pages were still served under the app package, but as a residual. The config boot printed "2 top-level metadata item(s)", and the artifact boot of the same project printed nothing. Removing the call would therefore make the two doors disagree, and removing it whole would also drop the per-package docs. Only its comment changes, to say it is placement. Row 1's pin stays green. ## Files - `packages/metadata/src/plugin.ts`: - `unclaimedTopLevel(stack)` is the pure residual decision: the unclaimed items with their field, index and type, the owner id and version, and the body count. `registerUnclaimedTopLevel` registers exactly those items. - The body-registration loop is a module-level function that writes through a sink. The artifact door's sink is its in-memory loader followed by the manager, the same two writes in the same order. The loop takes the refusal's source word: `artifact` on the artifact door, `manifest` for the config door's residual. - A body's claimed slots are read by the slot derivation the registration uses. The artifact door's pins are unedited and green. - `packages/metadata/src/index.ts`: exports `unclaimedTopLevel` and its two types. - `packages/runtime/src/app-plugin.ts`: the config door calls the rule from `start()`, ahead of the ADR-0057 security block, and skips on an artifact-door composition. `@objectstack/runtime` already depends on `@objectstack/metadata`. - `packages/cli/src/utils/view-container-names.ts` and its test: the walk judges the residual containers after the bodies, at their top-level path, under the residual's owner id. The module header now states that walk. - `packages/cli/src/commands/serve.ts`: a comment only (the fold decision). - `packages/cli/test/serve-config-boot-residual.integration.test.ts`: the pin, with one fixture per row (2, 3, 4) and a single-package control. The row 4 refusal corner pins that `os serve`, `os validate` and `os build` refuse the residual container with the judge's message, and that a claimed one is refused once, as the body's. - `packages/runtime/src/standalone-stack-security-catalog-one-holder.test.ts`: one stale comment line. - `.changeset/22521-config-boot-residual.md`: `minor` for `@objectstack/metadata`, `patch` for `@objectstack/runtime` and `@objectstack/cli`, `Clause-②: yes` with no arm, and one sentence naming what widens. **The warning.** The sentence and the count are the artifact door's. Only the noun and the remedy follow the door: an artifact is told to be rebuilt, a config is told to declare each item in the `packages[]` entry that owns it. ## Verification **This head, 12129d0 (changeset text only, over c1db768).** The diff since `c1db768097` is `.changeset/22521-config-boot-residual.md` alone, so no suite was re-run. - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0, "No changeset from the merge base modified or deleted by this diff". - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0, "This diff introduces no `major` bump". Driven offline with `--event` carrying this body, the level axis reads: "this PR declares clause-② `yes`, and it grades a package whose `packages/**/src/**` it moves at `minor` or above — the declared widening is accounted for: @objectstack/metadata: minor". - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0, "this PR adds no declared-breaking changeset". - `dispatch-gates --commands` (no paths) derives the same 70 families as at `c1db768097`, and all 70 exit 0. The three `check:i18n*` gates first exited 3 (PREREQUISITE NOT MET: the CLI was unbuilt in the fresh worktree) and exited 0 after `pnpm --filter @objectstack/cli build`. `check:type-check-debt` and `check:dual-build-cjs-loads` ran last. `--ran`: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero ...)". **The code head, c1db768.** - **The pins.** `vitest run --project integration` over `serve-config-boot-residual`, `serve-config-boot-flat-docs`, `serve-package-declared-capabilities` and `schema-migrate.requires-providers.integration`: `Test Files 4 passed (4)`, `Tests 25 passed (25)`. - **The walk.** `view-container-names.test`, `build-view-container-name.test`, `validate-build-gate-parity.test` and `validate-view-container-name.test`: `Test Files 4 passed (4)`, `Tests 44 passed (44)`. - **Round 2 ablation, the walk.** Committed first, then `view-container-names.ts` was restored to `3c69fda05` (blob `a61633a25b`; `unclaimedTopLevel` 0 hits in the file). - Unit: `Tests 1 failed | 6 passed (7)`. The red case is the unclaimed-container case. - The pin's refusal corner: `Tests 2 failed | 2 passed`. The red cases are `os validate` and `os build`, which printed "Validation passed" and "Build complete". `os serve` and the claimed case stayed green. - Restored to the HEAD blob `3c0f4cdf0a`, with 0 dirty paths. - **Round 1 ablations (at `12de4b4656`).** - The config door's call, removed through a dist-proven rebuild: `Tests 4 failed | 5 passed (9)`, the four config-boot parity cases. - The artifact-door skip, disabled: the artifact boot printed `[ 2, 2 ]`, `Tests 3 failed | 6 skipped (9)`. - Both were restored by blob. - **Metadata.** Typecheck green. `Test Files 58 passed (58)`, `Tests 871 passed (871)`. The artifact-door files plus `view-container-name.test`, run verbose: `Test Files 10 passed (10)`, `Tests 101 passed (101)`. - **Runtime.** Typecheck green. `vitest run --project local`: `Test Files 345 passed (345)`, `Tests 4909 passed | 19 skipped`. - **CLI.** Typecheck green, and the CLI was built. The unit layer: `Test Files 277 passed (277)`, `Tests 4107 passed (4107)`. - **Dogfood.** `multi-package-artifact` and `picklist-shared-across-objects`: `Test Files 2 passed (2)`, `Tests 14 passed (14)`. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 70 families at this head, all exit 0. `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET: `@objectstack/connector-slack` was unbuilt in this worktree), and gave "OK (13 config(s), 621 baselined untranslated string(s), none new)" after `pnpm --filter @objectstack/connector-slack build`. `check:type-check-debt` and `check:dual-build-cjs-loads` ran last. `--ran`: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero ...)". - **Lint (a proven narrowing).** 1. The population comes from `eslint.config.mjs`, whose `files` glob is every `ts`, `tsx`, `mts`, `cts`, `js`, `jsx`, `mjs` and `cjs` file. 2. `eslint --no-inline-config --format json` on the 9 changed paths returned 9 results: 8 source and test files with 0 errors and 0 warnings, and the changeset `.md` reported as "File ignored because no matching configuration was supplied". 3. The config has no `parserOptions.project` or `projectService`, and at load it reads only `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, both untouched. So the diff cannot move the verdict on any file it does not touch. The full `pnpm lint` is CI's. ## Acceptance notes - **Filed by the seat.** On an artifact boot, `GET /api/v1/meta/view/acme_note` answers 500. Separately, a residual object is listed by the metadata door while the data door answers 404. - **No forward conversion on the config door's residual.** The config door registers residual items as the config carries them, which is how this door registers its package bodies. Security collections keep the ADR-0057 block's forward-converted copy, because that block runs after the residual. - **The CLI walk now imports the `@objectstack/metadata` root.** It needs `unclaimedTopLevel`. Measured after `@objectstack/objectql`, which `os validate` already loads, the import costs about 19 ms on a shared box. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 243dd3c commit 0fea05f

9 files changed

Lines changed: 1164 additions & 205 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/metadata': minor
4+
'@objectstack/cli': patch
5+
---
6+
7+
`os serve <config>`: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did
8+
9+
Clause-②: yes
10+
11+
A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned.
12+
13+
- **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '<id>' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/<type>` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve <config>` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning.
14+
- **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '<manifest id>': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to.
15+
- **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src/<package>/docs/`, where `<package>` is the package id or its last dot-separated segment.
16+
- **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call.
17+
- **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing.
18+
- **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/<object>` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2556,12 +2556,19 @@ export default class Serve extends Command {
25562556
//
25572557
// [#22405] WHERE the flat set lands is `placeCollectedDocs`'s decision,
25582558
// the one `os build` makes: on a multi-package config it rides the body
2559-
// of the package whose id is the stack's `manifest.id`, because this boot
2560-
// registers package bodies and never the top level of such a config, so a
2561-
// flat doc left there is served by nothing and warned about by nothing.
2562-
// With no owner (no `packages[]`, or a `manifest.id` naming no single
2563-
// entry) it stays on the top level, exactly as before. Inline top-level
2564-
// docs never move; the name dedupe below runs only over what stays.
2559+
// of the package whose id is the stack's `manifest.id`. With no owner (no
2560+
// `packages[]`, or a `manifest.id` naming no single entry) it stays on the
2561+
// top level, exactly as `os build` keeps it. Inline top-level docs never
2562+
// move; the name dedupe below runs only over what stays.
2563+
//
2564+
// [#22521] This is PLACEMENT, not the residual rule, and it stays: what
2565+
// remains on the top level of a multi-package config is registered under
2566+
// `manifest.id` with a warning by the metadata door's one residual rule
2567+
// (`AppPlugin` → `MetadataPlugin.registerUnclaimedTopLevel`). Measured
2568+
// with this placement withheld, the flat pages of a stack whose
2569+
// `manifest.id` names one package are still served under that id, but as
2570+
// a residual, so the config boot warns where the artifact boot of the same
2571+
// project — whose `os build` placed them on the body — does not.
25652572
if (!useArtifactFallback) {
25662573
try {
25672574
const { collectDocsFromSrc, placeCollectedDocs } = await import('../utils/collect-docs.js');

‎packages/cli/src/utils/view-container-names.test.ts‎

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
* that judge and under which package id — the load path's answer:
1111
*
1212
* - no `packages[]` → the top-level `views`, owned by `manifest.id`;
13-
* - `packages[]` → each body's own `views`, owned by that package, and the
14-
* top level NOT at all (the load path does not register from it).
13+
* - `packages[]` → each body's own `views`, owned by that package, then
14+
* the top-level entries NO body declares — the residual the config boot
15+
* registers (and refuses) under the stack's manifest id — and never a
16+
* top-level entry a body already declares.
1517
*
1618
* Every row's message is asserted EQUAL to the judge's answer for the same
1719
* entry, source label and id — never re-spelled — so this file cannot drift
@@ -81,15 +83,44 @@ describe('#20331 — findViewContainerNameRefusals walks what the load path regi
8183
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
8284
});
8385

84-
it('a `packages[]` stack: the top-level `views` is NOT judged, because the load path does not register it', () => {
85-
// `resolveArtifactPackageOrder` returns the package bodies alone once
86-
// `packages` is present, so a top-level copy never reaches the registrar.
87-
// Judging it here would refuse a stack the server loads.
86+
it('a `packages[]` stack: a top-level container a body already declares is NOT judged', () => {
87+
// The residual rule skips a top-level entry whose slot a body registers —
88+
// `composeStacks(…, { manifest: 'preserve' })` repeats every body at the
89+
// top level — so no boot ever judges this copy. Judging it here would
90+
// refuse a stack the server loads.
8891
const rows = findViewContainerNameRefusals({
8992
manifest: manifest(ID),
9093
views: [divergent],
9194
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ name: 'vcn_order_line' })] } }],
9295
});
9396
expect(rows).toEqual([]);
9497
});
98+
99+
it('[#22521] a `packages[]` stack: an UNCLAIMED top-level container is judged, under the stack\'s manifest id', () => {
100+
// No body declares `vcn_order_line`, so the config boot registers this
101+
// container as the stack's residual — and refuses it, in these words.
102+
const RELEASE = 'com.example.vcn.release';
103+
const rows = findViewContainerNameRefusals({
104+
manifest: manifest(RELEASE),
105+
views: [view({ name: 'vcn_order_line', object: 'vcn_other' }), divergent],
106+
packages: [{ manifest: { ...manifest('com.example.core'), views: [view({ object: 'vcn_other', name: 'vcn_other' })] } }],
107+
});
108+
expect(rows).toHaveLength(1);
109+
expect(rows[0]).toEqual({
110+
path: 'views[1]',
111+
code: 'VALIDATION_ERROR',
112+
httpStatus: 400,
113+
message: viewContainerNameRefusal(divergent, 'manifest', RELEASE)!.message,
114+
});
115+
});
116+
117+
it('[#22521] a divergent container a body declares AND the top level repeats is judged ONCE, as the body\'s', () => {
118+
const rows = findViewContainerNameRefusals({
119+
manifest: manifest('com.example.vcn.release'),
120+
views: [divergent],
121+
packages: [{ manifest: { ...manifest('com.example.orders'), views: [divergent] } }],
122+
});
123+
expect(rows.map((r) => r.path)).toEqual(['packages[0].manifest.views[0]']);
124+
expect(rows[0].message).toBe(viewContainerNameRefusal(divergent, 'manifest', 'com.example.orders')!.message);
125+
});
95126
});

‎packages/cli/src/utils/view-container-names.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,31 @@
2929
* `resolveArtifactPackageOrder` returns that payload itself when it carries no
3030
* `packages` key and each `packages[i].manifest` body otherwise (ADR-0130 D4);
3131
* `ObjectQL.registerApp(body)` then runs `registerMetadataCollections(body,
32-
* body.id || body.name, 'manifest')`. So:
32+
* body.id || body.name, 'manifest')`. On a stack that carries `packages[]`,
33+
* `AppPlugin` then registers the RESIDUAL — the top-level items no package
34+
* body declares — through the metadata package's one residual rule
35+
* (`MetadataPlugin.registerUnclaimedTopLevel`), which refuses a divergent
36+
* residual container in the same words, from `manifest` and under the stack's
37+
* own manifest id. So:
3338
*
3439
* - no `packages[]` → the top-level `views`, owned by the manifest's id;
3540
* - `packages[]` → each body's own `views`, owned by that package's id,
36-
* and ⛔ NOT the top level, which the load path does not register from.
41+
* THEN the top-level `views` the residual rule registers, owned by the
42+
* stack's manifest id. ⛔ Never a top-level container a body already
43+
* declares: the residual rule skips it, so judging it here would refuse
44+
* the same document twice.
45+
*
46+
* WHICH top-level entries are residual, and their owner id, is
47+
* `@objectstack/metadata`'s `unclaimedTopLevel` — the answer the boot
48+
* registers from. ⛔ Never re-derived here: a second spelling of "which top
49+
* level does boot read" is how this walk and the boot came to disagree once.
3750
*
3851
* The package id is read through the owners that already exist for it: the
3952
* runtime's `artifactPackageId` (`@objectstack/core` — "every seam that has to
4053
* name a package reads the id through THIS function") for the one-package
41-
* payload, and this package's `artifactPackages` for `packages[]`, the reader
42-
* both `os build` and `os validate` already walk with.
54+
* payload, this package's `artifactPackages` for `packages[]`, the reader
55+
* both `os build` and `os validate` already walk with, and `unclaimedTopLevel`
56+
* for the residual.
4357
*
4458
* ⚠️ Bound, stated rather than hidden: a nested `plugins[]` entry's `views` is
4559
* registered by boot too (label `nested plugin`), and is NOT walked here. The
@@ -52,6 +66,7 @@
5266
*/
5367

5468
import { artifactPackageId } from '@objectstack/core';
69+
import { unclaimedTopLevel } from '@objectstack/metadata';
5570
import { viewContainerNameRefusal } from '@objectstack/objectql';
5671

5772
import { artifactPackages } from './artifact-packages.js';
@@ -80,28 +95,37 @@ const asRec = (v: unknown): AnyRec | undefined =>
8095
* Returns `[]` for a stack the boot registrar accepts on this axis.
8196
*/
8297
export function findViewContainerNameRefusals(parsed: AnyRec): ViewContainerNameRefusalRow[] {
83-
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown }> = [];
98+
// `located`: `at` already names the one entry `views` holds.
99+
const bodies: Array<{ at: string; ownerId: string | undefined; views: unknown; located?: boolean }> = [];
84100
// The resolver's own branch test (`declared === undefined`). On the PARSED
85101
// stack a present `packages` is an array — `ArtifactPackageSchema[]`,
86102
// `.optional()`, so `null` and every non-array were refused at the parse.
87103
if (parsed.packages !== undefined) {
88104
for (const pkg of artifactPackages(parsed)) {
89105
bodies.push({ at: `packages[${pkg.index}].manifest.views`, ownerId: pkg.id, views: pkg.body.views });
90106
}
107+
// The residual, after the bodies — the order boot registers them in. Each
108+
// unclaimed `view` entry is located at its own top-level index; the judge
109+
// refuses only a container, exactly as the residual registration does.
110+
const residual = unclaimedTopLevel(parsed);
111+
for (const { field, index, type, item } of residual?.items ?? []) {
112+
if (type !== 'view') continue;
113+
bodies.push({ at: `${field}[${index}]`, ownerId: residual?.ownerId, views: [item], located: true });
114+
}
91115
} else {
92116
const manifest = asRec(parsed.manifest);
93117
const payload = manifest ? { ...manifest, ...parsed } : parsed;
94118
bodies.push({ at: 'views', ownerId: artifactPackageId(payload), views: parsed.views });
95119
}
96120

97121
const rows: ViewContainerNameRefusalRow[] = [];
98-
for (const { at, ownerId, views } of bodies) {
122+
for (const { at, ownerId, views, located } of bodies) {
99123
if (!Array.isArray(views)) continue;
100124
views.forEach((entry, index) => {
101125
const refusal = viewContainerNameRefusal(entry, 'manifest', ownerId);
102126
if (!refusal) return;
103127
rows.push({
104-
path: `${at}[${index}]`,
128+
path: located ? at : `${at}[${index}]`,
105129
code: refusal.code,
106130
httpStatus: refusal.httpStatus,
107131
message: refusal.message,

0 commit comments

Comments
 (0)