Repository navigation
Commit 0fea05f
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
- .changeset
- packages
- cli
- src
- commands
- utils
- test
- metadata/src
- runtime/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 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2556 | 2556 | | |
2557 | 2557 | | |
2558 | 2558 | | |
2559 | | - | |
2560 | | - | |
2561 | | - | |
2562 | | - | |
2563 | | - | |
2564 | | - | |
| 2559 | + | |
| 2560 | + | |
| 2561 | + | |
| 2562 | + | |
| 2563 | + | |
| 2564 | + | |
| 2565 | + | |
| 2566 | + | |
| 2567 | + | |
| 2568 | + | |
| 2569 | + | |
| 2570 | + | |
| 2571 | + | |
2565 | 2572 | | |
2566 | 2573 | | |
2567 | 2574 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
| |||
81 | 83 | | |
82 | 84 | | |
83 | 85 | | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | | - | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
88 | 91 | | |
89 | 92 | | |
90 | 93 | | |
91 | 94 | | |
92 | 95 | | |
93 | 96 | | |
94 | 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 | + | |
95 | 126 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
33 | 38 | | |
34 | 39 | | |
35 | 40 | | |
36 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
37 | 50 | | |
38 | 51 | | |
39 | 52 | | |
40 | 53 | | |
41 | | - | |
42 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
43 | 57 | | |
44 | 58 | | |
45 | 59 | | |
| |||
52 | 66 | | |
53 | 67 | | |
54 | 68 | | |
| 69 | + | |
55 | 70 | | |
56 | 71 | | |
57 | 72 | | |
| |||
80 | 95 | | |
81 | 96 | | |
82 | 97 | | |
83 | | - | |
| 98 | + | |
| 99 | + | |
84 | 100 | | |
85 | 101 | | |
86 | 102 | | |
87 | 103 | | |
88 | 104 | | |
89 | 105 | | |
90 | 106 | | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
91 | 115 | | |
92 | 116 | | |
93 | 117 | | |
94 | 118 | | |
95 | 119 | | |
96 | 120 | | |
97 | 121 | | |
98 | | - | |
| 122 | + | |
99 | 123 | | |
100 | 124 | | |
101 | 125 | | |
102 | 126 | | |
103 | 127 | | |
104 | | - | |
| 128 | + | |
105 | 129 | | |
106 | 130 | | |
107 | 131 | | |
| |||
0 commit comments