Skip to content

fix(objectql): the data door serves a residual top-level object under the stack's manifest id - #22701

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22615-residual-object-doors
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22615-residual-object-doors

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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's manifest.id, and the boot warned that "every door will report that id as their owner". The engine's manifest service registered the bodies only (resolveArtifactPackageOrder answers packages[] when the key is present). So GET /api/v1/meta/object listed the residual object acme_note under manifest.id, while GET /api/v1/data/acme_note answered 404. 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: the manifest service now registers the residual's objects. It reads them from unclaimedTopLevel (@objectstack/metadata), which is the residual rule's own answer. The metadata door, the config door and os validate already read that same function, so there is no second rule and no second list. The objects are registered:

    • after the bodies, in the order the metadata door uses;
    • under the stack's manifest.id;
    • through registry.registerObject, the same call a body's objects go through in registerApp.

    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:

    • An object whose name another package already owns. The registry refuses a second owner (OBJECT_OWNERSHIP_CONFLICT, 422). The data door keeps serving that other package's object.
    • An object with a field that names a picklist no package body declares. The boot's picklist audit at kernel:ready fails (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, use objectExtensions. Any other registry refusal, such as a field with no valid type, 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/objectql patch.

Pins

  • packages/cli/test/serve-config-boot-residual.integration.test.ts (an existing two-boot harness, domain:cli, test file only). Every acme_* object that /meta/object lists is now read back on /data/NAME, through both boots.
    • Row 4: the residual object answers 200 on both boots.
    • Controls: the bodies' own objects answer 200 on every row, and so does a single-package stack.
    • The header paragraph saying this file does not pin the data door is replaced.
  • packages/objectql/src/manifest-service-residual-objects.test.ts (9 cases) covers:
    • the residual object resolves and is owned by manifest.id;
    • no package record is created for that id;
    • when manifest.id names a body, the residual takes that id and the body's record keeps its own objects;
    • a normally composed stack registers nothing extra;
    • packages: [] with a top-level object;
    • a residual object whose picklist a body declares is served with that list's options;
    • the two unserved classes: the boot completes, the other package keeps the name, and the warning names each object;
    • a late artifact's residual reaches the metadata service.

Measured

  • Premise on origin/main. Run at 77b8611b, which is cb3bb933 plus 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 answered 404. Result: 1 failed, 4 passed, 9 skipped.
  • Fixed. The full file at 577b0757: Tests 14 passed (14). After merging main, at e7fba19b: Tests 14 passed (14).
  • Ablation (node scripts/ablation-replace.mjs). The anchor was if (residual) this.registerUnclaimedTopLevelObjects(ctx, residual, ordered); and it was replaced by a marker. The anchor count went 1 to 0 and the blob changed from 7010e04df780 to 6abbdd6b25e2. 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 and git diff HEAD empty. The unit file imports ./plugin.js from source, so this mutation needed no rebuild.
  • Load closure. @objectstack/objectql's root entry now imports @objectstack/metadata's root entry, which adds chokidar, glob, js-yaml and readdirp to what import('@objectstack/objectql') loads. Measured: 208 modules before, 214 after. @objectstack/objectql/core is unchanged: pnpm check:lean-entry-closure holds its admitted set of 15 packages, and its 6 denied names are absent. unclaimedTopLevel is 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 the manifest service "registers the package bodies and never the top level". After this PR it also registers the residual's objects. This is a comment in the domain:cli lane, left untouched here (carrier: none).
  • A residual object's owner id has no package record. So GET /api/v1/packages does not list a manifest.id that names no body, and the object routes to the default datasource rather than to a defaultDatasource on the stack manifest. Not measured on a boot.
  • Before this PR, ingestReloadedObjects already registered every reloaded object on metadata:reloaded, residual ones included. So a --dev boot answered 404 before 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.
  • Ordering: another package that registers an object with the same name LATER in the same boot (for example a marketplace rehydrate at kernel:ready) now meets the registry's ownership refusal, as it would against a body-declared object. Not measured.
  • The residual's other item types (views, docs, picklists, actions and the rest) are still metadata only in the engine registry, as they were before. Not measured on any door.

Gates

Everything below was read at e7fba19b, the head of this PR, unless another commit is named.

  • Gate list. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 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 --ran reconciliation reports 66 derived, 66 run, 0 NOT-MEASURED and 0 UNRUN.
  • Two first runs measured nothing and were re-run:
    • node scripts/check-engine-split-ratio.mjs --days 90 exited 2 because the clone was shallow. After git fetch --shallow-since=2026-07-05, it exited 0.
    • pnpm check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET) because 9 packages were unbuilt. After turbo run build --filter='./packages/**', it exited 0.
  • Also run: pnpm check:startup-registry-verdict, exit 0.
  • Package checks:
    • @objectstack/objectql: typecheck passed, and the full vitest run passed Test Files 396 passed (396), Tests 7767 passed (7767).
    • @objectstack/metadata: typecheck passed at 577b0757 (the merge did not touch it), and vitest passed 58 passed (58) files, 871 passed (871) tests.
    • @objectstack/cli integration: the file above, 14 passed (14). The packages/cli change is an integration-tier test file, so only that tier was run here.
  • ESLint. eslint --no-inline-config --format json over the 4 changed TypeScript files counted 4 files, 0 errors and 0 warnings. eslint.config.mjs never turns on type-aware linting (no parserOptions.project), so this diff cannot change the result for any file it does not touch. The repo-wide pnpm lint is left to CI.
  • NUL bytes. pnpm check:nul-bytes exited 0.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata, @objectstack/objectql, touching 4 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via MetadataPlugin (symbol, a top-level class))
  • content/docs/api/index.mdx (via MetadataPlugin (symbol, a top-level class))
  • content/docs/automation/flows.mdx (via MetadataPlugin (symbol, a top-level class))
  • content/docs/data-modeling/objects.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services-checklist.mdx (via MetadataPlugin (symbol, a top-level class), ObjectQLPlugin (symbol, a top-level class))
  • content/docs/kernel/services.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/metadata-service.mdx (via MetadataPlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via MetadataPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectQLPlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ObjectQLPlugin (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 29 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d8830c28056b9f466adfe9d59cd179b1d0fa6784 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 93e2fecf1ff8490a91cf1384161e9547f4ab4642 — the merge of head e7fba19ba4c7168afc3b5e5a6ab112b0689e28f4 into base d8830c28056b9f466adfe9d59cd179b1d0fa6784, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d8830c28056b9f466adfe9d59cd179b1d0fa6784 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants