Repository navigation
fix(objectql): the data door serves a residual top-level object under the stack's manifest id - #22701
Conversation
…al rows Every acme_* object /meta/object lists is read back on /data/<name>, through both boots. Row 4 now pins that the residual object the metadata door lists under manifest.id is served by the data door too. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…under the stack's manifest id A multi-package stack's top-level objects that no package body declares are registered by the metadata door under manifest.id, while the manifest service read the bodies only, so /meta/object listed them and /data/<name> answered 404 through both boot doors. The service now registers the objects of the residual rule's own answer (unclaimedTopLevel) after the bodies, under that id, with no package record of their own. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…tion would refuse the boot Registering a residual object as a body's object inherits two boot refusals the residual rule never raised: a name another package already owns (the registry's ownership refusal) and a field naming a picklist only the residual declares (the kernel:ready picklist audit; the residual's lists are metadata only). Both were measured refusing the boot. Such an object is not registered; the boot names it once, with the reason and the remedy. The residual rule's comments in @objectstack/metadata now name the engine half. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…sidual-object-doors
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 29 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 93e2fecf1ff8490a91cf1384161e9547f4ab4642 && git checkout 93e2fecf1ff8490a91cf1384161e9547f4ab4642
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d8830c28056b9f466adfe9d59cd179b1d0fa6784 e7fba19ba4c7168afc3b5e5a6ab112b0689e28f4 && git checkout -B drift-repro d8830c28056b9f466adfe9d59cd179b1d0fa6784 && git merge --no-ff e7fba19ba4c7168afc3b5e5a6ab112b0689e28f4
node scripts/docs-audit/affected-docs.mjs --json d8830c28056b9f466adfe9d59cd179b1d0fa6784
|
Fixes #22615
Clause-②: no
What was wrong
A stack that carries
packages[]registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered the residual under the stack'smanifest.id, and the boot warned that "every door will report that id as their owner". The engine'smanifestservice registered the bodies only (resolveArtifactPackageOrderanswerspackages[]when the key is present). SoGET /api/v1/meta/objectlisted the residual objectacme_noteundermanifest.id, whileGET /api/v1/data/acme_noteanswered404. Both boot doors behaved this way: the artifact boot and the config boot (os serve objectstack.config.ts).What changes
packages/objectql/src/plugin.ts: themanifestservice now registers the residual's objects. It reads them fromunclaimedTopLevel(@objectstack/metadata), which is the residual rule's own answer. The metadata door, the config door andos validatealready read that same function, so there is no second rule and no second list. The objects are registered:manifest.id;registry.registerObject, the same call a body's objects go through inregisterApp.No package record is created for that id, because the residual is not a package. When the id names a body (a composed stack keeps one member's manifest), that body's record is unchanged. When a late artifact (after
start()) carries a residual, its objects are copied to the metadata service along with the bodies' objects.Two classes stay listed and are not served. Registering either one would make a boot fail where the stack boots today. Both were measured failing on a kernel, at
39ef9df4, before this handling existed:OBJECT_OWNERSHIP_CONFLICT, 422). The data door keeps serving that other package's object.kernel:readyfails (INVALID_METADATA, 422), because the stack's top-level picklists are registered as metadata only. This class is decided from the stack's bodies, never from the registry, which is still filling at this point.The engine prints one warning naming each such object and the reason. The remedy it gives is the residual warning's own: declare the object inside the
packages[]entry of the package that owns it, together with every picklist its fields name. To add fields to another package's object, useobjectExtensions. Any other registry refusal, such as a field with no validtype, still fails the boot, as it does for a body.packages/metadata/src/plugin.ts: comments only. The residual rule's header and docblocks now describe the engine half. The residual warning text is unchanged byte for byte..changeset/22615-objectql-residual-objects-served.md:@objectstack/objectqlpatch.Pins
packages/cli/test/serve-config-boot-residual.integration.test.ts(an existing two-boot harness,domain:cli, test file only). Everyacme_*object that/meta/objectlists is now read back on/data/NAME, through both boots.200on both boots.200on every row, and so does a single-package stack.packages/objectql/src/manifest-service-residual-objects.test.ts(9 cases) covers:manifest.id;manifest.idnames a body, the residual takes that id and the body's record keeps its own objects;packages: []with a top-level object;Measured
origin/main. Run at77b8611b, which iscb3bb933plus the pin commit only, through--project integration, rows 4 and control. Row 4's new case failed:Expected ... "acme_note:200" / Received ... "acme_note:404"on the artifact boot. The config-boot reading was equal to it (toEqual(artifact)passed), so both boots answered404. Result: 1 failed, 4 passed, 9 skipped.577b0757:Tests 14 passed (14). After mergingmain, ate7fba19b:Tests 14 passed (14).node scripts/ablation-replace.mjs). The anchor wasif (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered);and it was replaced by a marker. The anchor count went 1 to 0 and the blob changed from7010e04df780to6abbdd6b25e2. Result:Tests 7 failed | 2 passed (9). The 2 that passed are the two controls (no package record, normally composed). The file was then restored: blob equal to HEAD andgit diff HEADempty. The unit file imports./plugin.jsfrom source, so this mutation needed no rebuild.@objectstack/objectql's root entry now imports@objectstack/metadata's root entry, which addschokidar,glob,js-yamlandreaddirpto whatimport('@objectstack/objectql')loads. Measured: 208 modules before, 214 after.@objectstack/objectql/coreis unchanged:pnpm check:lean-entry-closureholds its admitted set of 15 packages, and its 6 denied names are absent.unclaimedTopLevelis published only from that root entry. A lean subpath for it would be a new entry export, so it is not part of this PR.Acceptance notes
packages/runtime/src/app-plugin.ts(registerUnclaimedTopLevel's docblock, about lines 787 to 799) still says themanifestservice "registers the package bodies and never the top level". After this PR it also registers the residual's objects. This is a comment in thedomain:clilane, left untouched here (carrier: none).GET /api/v1/packagesdoes not list amanifest.idthat names no body, and the object routes to the default datasource rather than to adefaultDatasourceon the stack manifest. Not measured on a boot.ingestReloadedObjectsalready registered every reloaded object onmetadata:reloaded, residual ones included. So a--devboot answered404before its first reload and served the object after it. From boot onward the answer is now the same. This was read from the code, not measured on a reload.kernel:ready) now meets the registry's ownership refusal, as it would against a body-declared object. Not measured.Gates
Everything below was read at
e7fba19b, the head of this PR, unless another commit is named.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 commands for this diff. That includes all 51 named in the dispatch, plus 15 more that the changeset and the test files bring in. All 66 were run and every one exited 0. The--ranreconciliation reports 66 derived, 66 run, 0 NOT-MEASURED and 0 UNRUN.node scripts/check-engine-split-ratio.mjs --days 90exited 2 because the clone was shallow. Aftergit fetch --shallow-since=2026-07-05, it exited 0.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET) because 9 packages were unbuilt. Afterturbo run build --filter='./packages/**', it exited 0.pnpm check:startup-registry-verdict, exit 0.@objectstack/objectql:typecheckpassed, and the full vitest run passedTest Files 396 passed (396),Tests 7767 passed (7767).@objectstack/metadata:typecheckpassed at577b0757(the merge did not touch it), and vitest passed58 passed (58)files,871 passed (871)tests.@objectstack/cliintegration: the file above,14 passed (14). Thepackages/clichange is an integration-tier test file, so only that tier was run here.eslint --no-inline-config --format jsonover the 4 changed TypeScript files counted 4 files, 0 errors and 0 warnings.eslint.config.mjsnever turns on type-aware linting (noparserOptions.project), so this diff cannot change the result for any file it does not touch. The repo-widepnpm lintis left to CI.pnpm check:nul-bytesexited 0.Generated by Claude Code