Skip to content

fix(metadata-protocol): an orphan adoption hands each adopted object's registry owner and definition to the target package in the same request (#23646) - #23688

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23646-adopt-orphans-object-registry
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23646-adopt-orphans-object-registry

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23646

Clause-②: no

What changed

reassignOrphanedMetadata is the method POST /api/v1/packages/:id/adopt-orphans calls (ADR-0070 D5). It rebinds every package-less sys_metadata row to the target package Q. Its registry write-through followed view rows only. So an adopted object kept two things until a restart: the sys_metadata sentinel as its registry owner, and its pre-adoption definition. Meanwhile a save naming Q answered 200 and never went live.

Each adopted active object row now goes through two steps in the same request:

  1. releaseAdoptedObjectOwner (a new private helper beside the method) removes the sentinel's registration. It calls the existing SchemaRegistry.unregisterObject, the verb the delete heal already uses to hand an object's name to a remaining row.
  2. The row registers at its new binding through applyRegistryWriteThrough, the same write-through a save uses.

The registry holds one owner per object throughout (ADR-0029 D3). The adoption's request, its response and the set of rows it rebinds are unchanged.

What the helper does depends on who owns the adopted name in the registry. Every row below is pinned:

Registered owner of the adopted name What the adoption does
the sentinel (the adopted row's own registration) unregisters it, then registers the row under Q
none registers the row under Q, as the next boot would
another package's row (legacy residue beside it) rebinds the row in storage only; the registry keeps that owner, as before
the sentinel, but another package extends the object unregisterObject refuses; the registry keeps the sentinel until the next restart; one console.error names the object, the consequence and the remedy

A draft object row is rebound in storage only, because the boot registers active rows only (loadMetaFromDb).

The card's first acceptance line: the HTTP route, walked on a full composition

The route was walked over HTTP before the fix and again after it. The composition was pnpm dev:crm -- --fresh -p 38461, the CRM example with the runtime's package service mounted, under the seeded admin's session.

Before. The code was origin/main 57994c311f; branch head e8a22df1b added only the new test file and the changeset.

  1. PUT /api/v1/meta/object/qa_probe_gadget, naming no package, answered 200: "Saved object 'qa_probe_gadget' (env-wide, state=active)". Two POST /api/v1/data/qa_probe_gadget answered 201, 201.
  2. POST /api/v1/packages/com.qa.q/adopt-orphans answered 200 with reassignedCount: 1. The server log has no registry line for it.
  3. PUT /api/v1/meta/object/qa_probe_gadget?package=com.qa.q, adding a field serial, answered 200: "Saved object … package=com.qa.q". The server log then reads: [Protocol] registerObject failed for qa_probe_gadget: Object "qa_probe_gadget" is already owned by package "sys_metadata". Package "com.qa.q" cannot claim ownership.
  4. The metadata read follows storage. GET /api/v1/meta/object/qa_probe_gadget serves label "Gadget Q", _packageId com.qa.q and the fields name and serial.
  5. The data plane does not follow. POST /api/v1/data/qa_probe_gadget with serial answered 400 INVALID_FIELD ("Unknown field 'serial'"), and so did GET /api/v1/data/qa_probe_gadget?serial=S-3.

After. The head was 535cd8562, with metadata-protocol rebuilt.

  • Steps 1–4 answer the same.
  • At step 2 the server log now reads Unregistered object: qa_probe_gadget (1 contribution(s), owner sys_metadata) and then Registered object: qa_probe_gadget (own, priority=100) from com.qa.q.
  • At step 3 it logs … from com.qa.q, with no refusal.
  • At step 5 the POST with serial answered 201, and GET …?serial=S-3 answered 200 with total: 1.

A boot after that cannot be walked over HTTP, because --fresh storage is deleted on exit. The pins below hold the boot half at protocol level.

Pins

The pins are in packages/objectql/src/protocol-adopt-orphans-object-registry.test.ts. They run on a real ObjectKernel with ObjectQLPlugin and a memory driver; another kernel booted over the same store is the reboot.

  • Adopt, then read: owner Q, the data plane's definition stamped Q, one owner, and Q's package filter lists the object. This holds for an object saved in this process and for one hydrated at boot.
  • A save naming Q after the adoption goes live: its new field is on the data plane at once, and a record using it inserts.
  • A boot after that agrees.
  • An adopted draft object row registers nothing.
  • An object another package extends keeps its owner, said once at error naming the object and Q.
  • CONTROL: an adoption of non-object items leaves the object registry untouched (no registerObject and no unregisterObject call), and the view follows as before.
  • CONTROL: a package-less row of an object another package's row owns is rebound in storage, and the registry keeps that owner. Nothing of the residue is registered.

Fixture triage. One existing case in packages/metadata-protocol/src/protocol.object-registry-write-through-spelling.test.ts pinned the defect: "an orphaned objects row not at all", with registeredObjects empty. It now pins the fold the new object limb applies: a row stored as objects registers under object, never under the plural. The applyRegistryWriteThrough call-site count stays 7, because the object limb rides the adoption's one existing call.

Ablations (final code head 3061f6f3b)

objectql's tests import metadata-protocol through dist/. So each leg mutated the source through scripts/ablation-replace.mjs (the anchor had to hit), rebuilt the package and proved the marker reached dist/ with scripts/ablation-dist-preflight.mjs.

  • A — object limb removed. The admission type !== 'object' was mutated, so objects skip again, as on main.
    • The tsup JS bundles built. Its DTS stage refused the mutant with TS2367: the narrowed 'view' | … union makes type === 'object' unreachable, the mutant's own signature. The preflight read "marker present in 2 built files" (index.js, index.cjs).
    • Pin file: 3 failed | 3 passed. The two adopt cases and the extender case went red. The draft case and both CONTROLs stayed green, by design.
    • Expected red, observed red.
  • B — active-row gate neutralised. row.state !== 'active' was changed to a comparison that never holds.
    • Build exit 0; the preflight found the marker in 2 built files.
    • Pin file: 1 failed | 5 passed, the draft case.
    • Expected red, observed red.
  • Restore.
    • The blob 3a0c26243aa1 equals HEAD, and git diff HEAD on the path is empty.
    • The rebuild exited 0, and both markers are absent from all 24 built files.
    • The working tree is clean, and the pin file reads 6 passed.

Tests (local)

These ran at 7d0b4d97d, the branch merged with origin/main 547702080, which includes #23659's delete heal:

  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.
  • pnpm --filter @objectstack/metadata-protocol test: 236 files passed and 3 skipped; 20563 tests passed and 19 skipped.
  • pnpm --filter @objectstack/objectql typecheck: exit 0. check:test-typecheck reports OK; the new file adds no debt.
  • objectql protocol suites, 8 files, 74 passed: the new pin file, protocol-package-lifecycle, protocol-writepath-object-ownership, protocol-runtime-owner-overlay-refusal, protocol-boot-object-package-binding, protocol-object-overlay-layer, protocol-boot-hydration-scoped and protocol-delete-boot-hydrated-object-heal.
  • runtime package-duplicate-adopt-org-scope.integration.test.ts: 4 passed.

These ran at 3061f6f3b: the pin file, 6 passed, and the objectql typecheck, exit 0. The last commit 9e2c7a578 only types the pin file's data-plane read options (EngineReadOptions in place of an as any). After it, check:query-options-erasure holds at 236 test sites, the ceiling.

Gates (local)

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 9e2c7a578 derived 67 commands.
  • All 67 ran on that head. Each exit code was captured before any pipe: 67 × exit 0.
  • --ran reconciliation: "67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED".
  • check:type-check-debt ran inside one verify-lock hold, because its closure build needs the lock.
  • The first union, at 3061f6f3b, had two non-zero results, both superseded by the 9e2c7a578 run:
    • check:query-options-erasure was red: the test surface grew 236 → 237 through the pin file's as any on a read's options. 9e2c7a578 fixed it.
    • check:type-check-debt answered PREREQUISITE NOT MET because it got no turn on the lock.
  • CI runs its full battery on this PR.

Acceptance notes

  • Finding, handed to the seat to file. The adoption's existing view limb registers a draft package-less view row as live. This was measured on a real ObjectKernel with ObjectQLPlugin; the view limb is byte-identical to main.
    • Before the adoption, registry.getItem('view', NAME) answered nothing.
    • After it, the registry serves the draft body, bound to Q, while the stored row is still state: draft.
    • The boot registers active rows only.
    • This PR does not touch the view limb; the CONTROL above pins it as unchanged.
  • Legacy residue. Suppose a package-less row sits beside another package R's row of the same object. The adoption rebinds the package-less row into Q in storage, so two rows of one object are bound to two packages, a shape the registry cannot hold (D9.9). The registry keeps R, as before. The next boot judges both rows (not measured here). This behaviour predates this PR.
  • The extender refusal is logged at error, not warn. By AGENTS.md's degradation question, storage and the runtime disagree while the adoption answers success. The remedy the line names is a restart.

Generated by Claude Code

…s registry owner to its rebound row (#23646)

reassignOrphanedMetadata's registry write-through followed view rows only,
so an adopted object kept the sys_metadata sentinel as its registry owner
and its pre-adoption definition until the next boot, and a save naming the
target package answered success without going live. Each adopted object row
now releases the sentinel's registration (SchemaRegistry.unregisterObject)
and registers at its new binding through the same write-through, in the same
request.

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…retired organization column (#23646)

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…TIVE row only, as the boot does (#23646)

A package-less draft object row adopted into a package is rebound in
storage only; a draft never reaches the runtime registry.

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…wner, said once at error (#23646)

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…stead of erasing them (#23646)

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 5 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/data-modeling/drivers.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/deployment/validating-metadata.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/kernel/cluster.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/kernel/services-checklist.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/permissions/authorization.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))

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

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/17-6.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/17-7.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))
  • content/docs/releases/v17/index.mdx (via sys_metadata (literal, a string literal in a comment in ObjectStackProtocolImplementation; a string literal in releaseAdoptedObjectOwner))

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

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

Coarse fallback — 11 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 361a1c46d357de926c61a1f3d7d8b4997e5bac42 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1b63091d6cbdbb921e2f01ecce18a7298099acab — the merge of head 9e2c7a57801e6b4de635e41a2db3432aeac32c06 into base 361a1c46d357de926c61a1f3d7d8b4997e5bac42, 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 1b63091d6cbdbb921e2f01ecce18a7298099acab && git checkout 1b63091d6cbdbb921e2f01ecce18a7298099acab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 361a1c46d357de926c61a1f3d7d8b4997e5bac42 9e2c7a57801e6b4de635e41a2db3432aeac32c06 && git checkout -B drift-repro 361a1c46d357de926c61a1f3d7d8b4997e5bac42 && git merge --no-ff 9e2c7a57801e6b4de635e41a2db3432aeac32c06

node scripts/docs-audit/affected-docs.mjs --json 361a1c46d357de926c61a1f3d7d8b4997e5bac42

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T14:55Z · ACCEPT (landing waits for every check on the head)

Head: 9e2c7a57801e6b4de635e41a2db3432aeac32c06.

Item Reading
Shape Draft PR → main. The first line is Fixes #23646. A closing-keyword scan of the whole body finds one hit, line 1; #23514 is named only as "remains open".
Scope 4 files (+492 / −10): packages/metadata-protocol/src/protocol.ts, one re-pinned fixture beside it, the new real-kernel pin file in packages/objectql/src, and one changeset. That is the claim's surface (6191415525). No registry verb was added, so the claim's conditional packages/objectql source arm was not used. No content/docs/releases/ edit, no governed path, under the 3,000-line threshold.
Clause-② no, on the claim, the PR body and the changeset. The adoption's request, response and rebound row set are unchanged; the registry now follows the row it already rebinds. No accept-set narrowing, no published schema, so no contract review is owed.
Changeset, sentences read One patch row for @objectstack/metadata-protocol. The title line "an orphan adoption moves each adopted object's registry owner and definition to the target package in the same request (ADR-0070 D5)", and a "What stays as before" list that matches the pinned table.
Seat's read of the diff The object limb is synchronous end to end: releaseAdoptedObjectOwner unregisters the sentinel, then applyRegistryWriteThrough's object branch calls applyObjectRegistryMutation and returns before that method's first await. So the header's "no request sees the name unowned" holds on this head. Only an active row is handed over; a name another package owns or extends keeps its owner.
Card's first acceptance line Met: the HTTP route was walked before and after the fix on pnpm dev:crm (400 INVALID_FIELD → 201 / 200, the server's unregister and register lines quoted in the body).
Negative control Two ablation legs at 3061f6f3b, each with the marker proved in dist/: A (object limb removed) 3 red of 6, B (active gate neutralised) 1 red of 6, controls green; restore clean, 6 of 6.
Hot file protocol.ts's reassignOrphanedMetadata region. PR #23683 (#23630) is in the promote doors, a different region; whichever lands second takes the other through the queue's merge. #23514 resumes in classifyObjectContribution; this PR builds no re-key primitive for it to reuse.
Gates The dev's derived union at this head: 67 commands, 67 × exit 0, --ran reconciled. CI was still running at this read. Landing needs every check green or on the expected-skip roster, and main's full battery green (the lane's main-red #23679 is in flight).

Residual, accepted. If registerObject threw after the sentinel's unregister, applyObjectRegistryMutation's best-effort warn would leave the name unregistered until a restart. The body registered is the one the sentinel already held, so no input path to that throw was found, and the boot re-registers the object under Q. No code is owed for it.

Disposition:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 15:18
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 15:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit cbe7712 Oct 15, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23646-adopt-orphans-object-registry branch October 15, 2026 15:48
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landed · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T15:48Z

Probe Reading
Queue enqueued at 15:21:30Z (the PR event), after pr_ready + automerge_enable through relay run 38759143962. After the merge, git ls-remote finds no pr-23688 queue branch.
origin/main cbe7712706 is on origin/main (git merge-base --is-ancestor exit 0). It is the squash of PR #23688, the same 4 files (+492 / −10). The merge event fired at 15:48:17Z.
Cards closed Only #23646 among issues closed since 15:40Z. #23514 (in flight) and #23690 (filed from this PR) stay open.

Status: #23646 is closed, and its pm:dispatched is removed in this stroke. #23514's cloud dev reads the merged object limb before it writes its prescription (dispatch H3).


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants