fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) - #14643

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages
Sep 2, 2026
Merged

fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599)#14643
hotlong merged 1 commit into
mainfrom
claude/issue-14599-metadata-door-reads-packages

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#14599
Part of #14122

The reader half of ADR-0130 D4. The producer (composeStacks / os build) is untouched and keeps emitting the flattened top level alongside packages[] — that is #14512's decision, not this PR's.

The defect

MetadataPlugin._parseAndRegisterArtifact iterated the flattened top level and stamped every item with the artifact's own manifest.id. For an artifact composed with composeStacks(…, { manifest: 'preserve' }) that id is one arbitrary member's — selectManifest's 'last' pick — so a two-package artifact registered the module's object under the App package's identity, while the ObjectQL load path, reading the same artifact's packages[], owned it under the module's. Two answers to "who owns crm_order", one per door.

What changed

_parseAndRegisterArtifact now reads both shapes and attributes every item to the body it was found in:

  • packagespresent → each assembled package body's collections are registered stamped with that body's id and version;
  • packagesabsent → the single-manifest branch runs exactly as before (D7).

Three properties are load-bearing and each is stated in the source:

  1. The owner is read off the body, never reverse-derived. Matching a top-level item's name against a name-to-package index would be a second metadata-identity resolution path — the road A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512's triage rejected by name (its option C) for failing silently when it dangles.
  2. Ordering and the entry gate are reused, not re-derived (D5). The door calls the same resolveArtifactPackageOrder the ObjectQL load path calls, so two readers of one packages[] cannot disagree about the order or about which artifacts are loadable at all.
  3. Nothing a booted instance can see today disappears. Measured, not assumed: every live ARTIFACT_FIELD_TO_TYPE key is a member of AssembledPackageBodySchema, so iterating bodies loses no collection. And because packages composes by concat, a residual sweep keeps any top-level definition no package body repeats — registered once, attributed to the artifact's own identity, and logged, because it means the artifact's two halves disagree about what it ships.

Scope note: resolveArtifactPackageOrder moved to @objectstack/core

Reusing it was not optional (D5, and the module's own header forbids a second sort "in any form"), and importing it where it lived was impossible: it was in @objectstack/objectql, which depends on@objectstack/metadata. @objectstack/core already owns resolvePluginOrder and is already a dependency of both readers, so hosting it there adds no edge to the package graph. @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under their existing names — its published surface is unchanged. packages/runtime/src/dispatcher-error-vocabulary.ts has its three file: anchors repointed; pnpm check:dispatcher-error-vocabulary is green.

Acceptance — measured on a real boot

examples/app-multi-package, objectstack dev --seed-admin -p 4599 -d file:SCRATCH/data.db, signed in as admin@objectos.ai via POST /api/v1/auth/sign-in/email → Bearer.

1. GET /api/v1/meta/object — one row per object, each with its own owner

[{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"}]

On the ablated tree (same boot, same DB path, door change reverted) the same read answers with the card's three rows:

[{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":"1.0.0","_provenance":"package"},
{"name":"crm_order","_packageId":"com.example.multi.orders","_packageVersion":null,"_provenance":"package"},
{"name":"crm_account","_packageId":"com.example.multi.core","_packageVersion":null,"_provenance":"package"}]

2. ?package= returns exactly what that package owns

{"?package=com.example.multi.core": [{"name":"crm_account","_packageId":"com.example.multi.core"}],
"?package=com.example.multi.orders": [{"name":"crm_order","_packageId":"com.example.multi.orders"}]}

Matching GET /api/v1/packages, whose rows carry core.manifest.objects = [crm_account] and orders.manifest.objects = [crm_order].

3. GET /api/v1/meta/object/crm_order/layers — and the item door agrees

{"code._packageId":"com.example.multi.orders","code._packageVersion":"1.0.0","overlay":null,
"effective._packageId":"com.example.multi.orders","lock":"none","provenance":"package",
"packageId":"com.example.multi.orders"}
{"item._packageId":"com.example.multi.orders","item._packageVersion":"1.0.0","packageId":"com.example.multi.orders"}

(the item door was asked under ?package=com.example.multi.core on purpose — the exact request the card shows disagreeing with the layers door.)

4. GET /api/v1/meta/app

[{"name":"multi_crm","_packageId":"com.example.multi.core"},
{"name":"setup","_packageId":"com.objectstack.setup"},
{"name":"account","_packageId":"com.objectstack.account"}]

The fixture's other collections: orders carries no views/flows/permission sets and core carries none beyond the app, so multi_crm is the whole of what acceptance 4 has to show. GET /api/v1/meta/view answers {"count":0} on both legs.

5. GET /api/v1/packages — unchanged

[{"id":"com.example.multi.core","objects":["crm_account"],"writable":false},
{"id":"com.example.multi.orders","objects":["crm_order"],"writable":false}]

6. Single-package control — examples/app-todo, real boot, both legs

Captured through the same script on the fixed tree and on the ablated tree; diff of the two recordings is empty:

{"objectList":[{"name":"todo_task","_packageId":"com.example.todo","_packageVersion":null,"_provenance":"package"}],
"appList":[{"name":"todo_app","_packageId":"com.example.todo"},{"name":"setup","_packageId":"com.objectstack.setup"},{"name":"account","_packageId":"com.objectstack.account"}],
"viewCount":{"count":3,"byPackage":["com.example.todo"]}}

Pins

  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (new, 7 cases) — a two-package fixture built by calling the real producercomposeStacks(…, { manifest: 'preserve' }), so it tracks the producer rather than pinning this door against a shape nothing emits. Asserts per-package packageId/packageVersion on registered items, that no slot is registered twice, topological (not array) order, the residual-sweep case, and the D7 control: the whole single-manifestmanager.register sequence as a literal.
  • packages/qa/dogfood/test/multi-package-artifact.dogfood.test.ts (extended, +3 cases) — acceptance 1–3 over real HTTP. bootStack registers no MetadataPlugin, so the existing block measures the ObjectQL registry alone; the new block boots the same stack with the artifact door mounted over a build-shaped artifact (artifactSource), the shape showcase-object-extension-meta-read.dogfood.test.ts established.

Ablation

Predicted before running: direction RED. Unit pin 4 red / 3 green — red on per-package stamping, no-double-registration, topological order and the residual case; green on the premise guard, the "no warning" control and the D7 sequence. Dogfood 3 red / 5 green.

Mutation:git checkout 2a2653619 -- packages/metadata/src/plugin.ts (the door change and nothing else; the module move and the tests stay).

Proved on disk — blob hash ac95906e…0b22c5f2…; the three new markers went _registerArtifactBodyCollections 3→0, resolveArtifactPackageOrder 3→0, carriesPackages 4→0, and the deleted text packageId: manifestPackageId came back at 3 occurrences.

Rebuilt on both legs — the dogfood suite resolves @objectstack/metadata through dist/. node scripts/ablation-dist-preflight.mjs @objectstack/metadata '_registerArtifactBodyCollections' --absent on the mutate leg: ✓ dist/: marker absent from all 24 built files. Same command without --absent on the restore leg: ✓ dist/: marker present in 6 built files.

Observed — exactly the prediction:

metadata unit pin Tests 4 failed | 3 passed (7)
× stamps every item with the package that owns it - "packageId": "com.example.multi.orders"
+ "packageId": "com.example.multi.core"
dogfood Tests 3 failed | 5 passed (8)
× serves each object exactly ONCE + "crm_order" (the duplicate row)

Restored by bytesgit checkout HEAD -- ABSOLUTE_PATH, git hash-object back to ac95906ed193bcaf1adf2eb112fa01ea91a8b622 (equal to the HEAD blob), whole-tree git status --porcelain empty, git diff HEAD empty. Both pins re-run green afterwards (7/7 and 8/8).

Gates

Union re-derived from the actual diff at 423074c99 (clean tree, nothing uncommitted): node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands → 43 commands, all run, exit codes captured before any pipe.

  • 40 green (exit 0), including check:dispatcher-error-vocabulary, check-system-context-census (anchors did not move — no --fix needed), check:stack-collection-maps, check:test-source-alias, check:undeclared-dep-imports, check:type-source-resolution, check:published-files, check:empty-changeset, check:changeset-no-major.
  • 3 NOT MEASURED (exit 3, the scripts' own PREREQUISITE-NOT-MET code — not findings):check-test-completeness and check-half-states refuse without an input CI supplies; check:dual-build-cjs-loads needs a whole-repo pnpm build.
  • Plus pnpm check:nul-bytes → OK (7980 files, no raw control bytes).

Package suites, all on this tree: @objectstack/metadata 44 files / 684 tests, @objectstack/core 47 / 1156, @objectstack/objectql 262 / 4518, @objectstack/runtime 209 / 3079 — all passed. pnpm --filter @objectstack/objectql --filter @objectstack/runtime run typecheck green (objectql's check:test-typecheck included).

Clause-② assessment: NO

No accept/reject behaviour on any public contract changes. The artifact schema is untouched; resolveArtifactPackageOrder already gated packages[] entries on the ObjectQL path and now gates them identically at this door, so no artifact that loaded before is refused now and none that was refused is accepted. What changes is the attribution of registered items — and only for artifacts carrying packages[], of which none exists in the field yet (#14512 records this explicitly: no customer artifact carries the key). For every artifact that does exist — single-manifest — the door's behaviour is byte-for-byte what it was, pinned as a literal register sequence and measured on a real examples/app-todo boot on both ablation legs.


🤖 Generated with Claude Code

https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m


Generated by Claude Code

…data door
The metadata artifact door iterated the flattened top level and stamped every
item with the artifact's own `manifest.id`. For an artifact composed with
`composeStacks(…, { manifest: 'preserve' })` that id is one arbitrary member's
(`selectManifest`'s 'last' pick), so a two-package artifact registered the
module's object under the App package's identity while the ObjectQL load path,
reading the same artifact's `packages[]`, owned it under the module's — two
answers to "who owns this object", one per door.
`_parseAndRegisterArtifact` now reads both shapes (ADR-0130 D4): `packages`
present → register each assembled package body's collections stamped with THAT
body's id; `packages` absent → the single-`manifest` branch runs exactly as
before (D7). Ordering and the entry gate are reused from
`resolveArtifactPackageOrder`, the same call the ObjectQL load path makes (D5),
so the two readers of one `packages[]` cannot disagree.
That function and `artifactPackageId` moved from `@objectstack/objectql` to
`@objectstack/core`: objectql depends on metadata, so the door could not import
them from where they lived, and core already owns `resolvePluginOrder` and is
already a dependency of both readers — no package edge added. objectql
re-exports both under their existing names, so its published surface is
unchanged.
A residual sweep keeps any top-level definition no package body repeats
(`packages` composes by `concat`), registered once under the artifact's own
identity and logged, so nothing a booted instance sees today disappears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/metadata, @objectstack/objectql, @objectstack/runtime, touching 14 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 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), packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • content/docs/api/environment-routing.mdx(via packages.list (sdk, the route ledger binds it to GET /api/v1/packages))
  • 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/getting-started/examples.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/contracts/metadata-service.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/kernel/services-checklist.mdx _(via MetadataPlugin (symbol, a top-level class), /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/permissions/permission-sets.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/http-protocol.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))
  • content/docs/protocol/kernel/metadata-service.mdx(via MetadataPlugin (symbol, a top-level class))
  • content/docs/ui/apps.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

2 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.mdx _(via /api/v1/packages (route, a path literal in MetadataPlugin; a path literal in parseAndRegisterArtifact))

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
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/objectql/src/index.ts, packages/objectql/src/plugin.ts, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 48 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bbpackageMentionDocs.

Which tree this was computed on

This run read content/docs from b849f950bf08389cc9003d42b6a3a6b001425dbc — the merge of head 423074c998138bcddec0a84b8c9bd855b09de0ce into base ca48cf37724d85363cfcbd2e7c85abbb5550b5bb, 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 b849f950bf08389cc9003d42b6a3a6b001425dbc && git checkout b849f950bf08389cc9003d42b6a3a6b001425dbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca48cf37724d85363cfcbd2e7c85abbb5550b5bb 423074c998138bcddec0a84b8c9bd855b09de0ce && git checkout -B drift-repro ca48cf37724d85363cfcbd2e7c85abbb5550b5bb && git merge --no-ff 423074c998138bcddec0a84b8c9bd855b09de0ce
node scripts/docs-audit/affected-docs.mjs --json ca48cf37724d85363cfcbd2e7c85abbb5550b5bb

⚠️ 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 ca48cf37724d85363cfcbd2e7c85abbb5550b5bb → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

Review — PASS (reader half of ADR-0130 D4; Clause-② NO)

Disclosure: reviewer and dispatcher are the same session (session_01UHvF5hyiZjnCyExFnfQB8m); the implementing agent was dispatched from this session under a direction the PM fixed on #14512 (reader half proceeds, producer half stays with the maintainer). Maintainer authorized same-session self-review for this epic on 2026-09-02. Every claim below was re-verified against the tree at 423074c9, not taken from the PR body.

  1. One reader, reused (D5)resolveArtifactPackageOrder moved to @objectstack/core by rename (R091: body identical; only the header and the ./plugin-order.js import changed), @objectstack/objectql re-exports resolveArtifactPackageOrder / artifactPackageId / ArtifactPackageError under the same names, so its published surface is unchanged; @objectstack/core already depends on @objectstack/spec (the ArtifactPackageSchema gate), so no package edge is added. The metadata door calls that one function; it returns [artifact] when packages is absent, which is what lets the single-package branch run the same loop with the same two ids as before.
  2. Attribution read off the body, never reverse-derived_registerArtifactBodyCollections(ctx, memLoader, body, { packageId: artifactPackageId(body), packageVersion }) per body; the residual sweep registers only (type, name) slots no body claimed, under the artifact's own id, with a warn. No name-to-package index anywhere (A multi-package artifact serializes its metadata twice — the flattened top level and every packages[i] body carry the same definitions #14512 option C stays refused).
  3. D7 pinned as a literalplugin-artifact-packages-attribution.test.ts case 7 asserts the whole single-manifestmanager.register sequence; the PR's ablation kept it green on both legs, and the examples/app-todo boot recordings diff empty.
  4. Pins are real-shape — the unit fixture is built by the real producer (composeStacks(…, { manifest: 'preserve' })), and the dogfood block boots with MetadataPlugin over an artifactSource (the bootStack block alone measured only the ObjectQL registry). Ablation predicted 4/3 + 3/5 and observed exactly that; dist rebuilt on both legs with the preflight (--absent on mutate, present on restore).
  5. Acceptance on a real boot — one crm_order / one crm_account row on GET /api/v1/meta/object, ?package= exact sets matching GET /api/v1/packages, layers door and item door both com.example.multi.orders, multi_crm stamped core, /api/v1/packages unchanged with writable: false on both rows.
  6. Changeset@objectstack/core minor (new exported module), @objectstack/objectql patch, @objectstack/metadata patch, @objectstack/runtime patch (vocabulary file: anchors). No governed surface. Clause-② NO is right: the gate that decides which artifacts load is the same function on both readers, so no accept set moved; only attribution changed, and only for packages[] artifacts, of which none is in the field.
  7. Gates — the six required contexts green on 423074c9 (checks tab); the agent's NOT MEASURED list (check-test-completeness, check-half-states, check:dual-build-cjs-loads) is covered by CI.

Non-blocking, recorded: the residual sweep's warn is the only signal when an artifact's two halves disagree; if #14512 lands B (producer stops emitting the flattened half for multi-package artifacts) the sweep becomes dead code and should go with it.

Flipping ready, arming auto-merge (MERGE). Studio re-verification on the merged head follows.


Generated by Claude Code

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

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude