Skip to content

fix(metadata-protocol): the orphan adoption keeps overlays of packaged items, and the registry follows each view row it rebinds (#23198) - #23264

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23198-orphan-adoption-reconcile
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23198-orphan-adoption-reconcile

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23198

Clause-②: no

This is the second half of #23198, the closing card for the registry-heal family. The first half landed as #23234.

The orphan adoption (POST /api/v1/packages/ID/adopt-orphans → reassignOrphanedMetadata) now leaves a package-less overlay of a packaged item as it is stored. Each view row it does rebind is written through to the registry with the binding it left, so the one reconcile moves that row's record at once. This was the family's last stored-row write that did not reach the reconcile. The closing check's bypass row is gone, and the census no longer has an exception kind.

Direction: PM comment 6144955760 on #23198, which chose option B1 of the needs_decision report 6144921283. It is execution under ADR-0070's kernel section: "Overlays of artifact items (intent = override-artifact, allowOrgOverride) are unchanged — they keep their binding to the packaged item they customise."

What was wrong (measured before the change)

Measured over the real SchemaRegistry and the built protocol on a control-plane kernel, at 6f4c518b01, in an uncommitted scratch in packages/objectql/src. A "restart" means a fresh registry with the package registered, then loadMetaFromDb over the same rows.

  • Reading A. A package-less overlay of the packaged container showcase_task (All Tasks (X), 6 columns). Adoption rebound it to the base.
    • In the same process, showcase_task.default still served All Tasks (X), and two new views were listed next to it.
    • After a restart, the list, the ?object= list and the by-name read all served showcase_task.default as the packaged All Tasks (7 columns). The row expanded under its own name in the base instead, as showcase_task.showcase_task and showcase_task.showcase_task.in_progress.
  • Reading B. A package-less overlay of one packaged view, showcase_task.in_progress. This is the shape of the record list's View Config Save, PUT /meta/view/NAME?mode=draft with no package. After adoption and a restart, the list served two entries under that one name.
  • Control D. A true orphan, the container rt_ticket on an object no package owns. Its names and content were unchanged; only _packageId moved.

The change (packages/metadata-protocol/src/protocol.ts)

  1. reassignOrphanedMetadata skips a package-less row for which isArtifactBacked(type, name) holds. That is the predicate saveMetaItem uses to set intent: 'override-artifact', with the type folded the same way the save folds it (PLURAL_TO_SINGULAR). There is no second derivation.
  2. After each successful rebind of a row whose canonical type is view, the adoption prepares the row the way the list read does (storedOverlayEntries, at the new binding). It then calls applyRegistryWriteThrough with reboundFrom: { packageId: OLD_PACKAGE_ID }.
    • Rows of other types are still rebound in storage only.
    • If preparing the row fails, the adoption logs a best-effort warn, as the write-through itself does, and moves on to the next row.
  3. reboundFrom passes through hydrateOverlayIntoRegistry and hydrateExpandedViewItems (they now share a HydrationOptions type) into reconcileViewExpansions. Its binding form gains a rebind arm: the record row of the binding the row left is removed, and its names count as the row's old names. Names that the new expansion lacks are healed by the usual per-name walk.
    • It is still one reconcile with its two callers. The closing check's chain pins did not move.
  4. The headers of reassignOrphanedMetadata, the reconcile, the write-through and ViewContainerWrite now say all of this.

No signature, export or schema changed: every changed member is private, and HydrationOptions is module-local.

The spec JSDoc stays as it is

packages/spec/src/api/package-lifecycle.zod.ts reads: "bulk-rebind package-less (orphaned) metadata into this base (ADR-0070 D5)". That is still true after B1:

  • The verb still bulk-rebinds orphaned package-less metadata.
  • In ADR-0070 an orphan is a runtime-created item ("Every runtime-created metadata item is bound to a writable package"), and the kernel section carves overlays of artifact items out as "unchanged". An overlay of a packaged item was never an orphan in that record, so a sentence about orphans does not cover it.
  • The response's descriptions ("How many orphaned items were adopted.", "Every item that was adopted.") also stay true.

No spec path is touched, so no cross-lane declaration is owed.

Pins

In protocol.view-container-reset-expansions.test.ts, a new block. The harness's boot now takes an existing row store, so a restart is boot({ rows }) plus loadMetaFromDb.

  • Reading A: not adopted (reassignedCount: 0), and the row is still package-less. In the same process and after a restart, every door serves All Tasks (X)/6, and showcase_task.showcase_task is served by none.
  • Reading B: not adopted. In the same process and after a restart, every door serves In Progress (X)/2, once.
  • Control D, next to the reading A overlay: only rt_ticket is adopted. Before any read, the registry entries of the container and of each expanded view carry the base's _packageId. Every door serves the views in the same process and after a restart, and the overlay keeps serving.
  • The record moves with the rebind: a package-less row of the adopted name, written later with no members, heals nothing of the base row's (rt_ticket.open and rt_ticket.closed stay registered).

Elsewhere:

Ablations

One-shot runs, not kept as files. The fix was committed first. Each leg used scripts/ablation-replace.mjs in WRAP mode (the anchor had to hit, and the blob was proven changed) and was restored with blob == HEAD and an empty git diff HEAD.

leg mutation expected observed
1 the isArtifactBacked skip replaced by true A, B, D red A, B, D red (3 failed, 24 passed)
2 the adoption never reaches its write-through (the view gate always continues) D and the record pin red exactly those 2 red
3 the reconcile's rebind arm off the record pin red exactly that 1 red
4 firing control: a fresh raw this.engine.update('sys_metadata', …) in rollBackUnmanagedImport the census red the writes in the source are exactly the recorded ones red, naming rollBackUnmanagedImport: this.engine.update('sys_metadata'
5 premise: protocol.ts restored to main's afbf960c26 the new pins and the census route red 5 red: the census same-member route, A, B, D and the record pin
6 the view gate also admits object the #8862 route case red red

The first attempt at leg 4 was refused by the tool, because the replacement still contained the anchor; nothing ran. The second attempt ran.

Tests and gates

All of these were read at head dc2e536938 (the commit that merges main at 4f5679c261).

Size: 5 files, +282 / −35 against the merge base 4f5679c26.

Acceptance notes

  • An environment whose only package-less rows are overlays now answers reassignedCount: 0 and success: false. The response shape is unchanged, and the changeset says this.
  • Noted, not filed: the boot hydration (loadMetaFromDb) registers a package-bound container's own body without the _packageId that the list read's hydration stamps on it. So after a restart, the container's bare entry carries no package, while its expanded views do. This PR does not change that difference between the two callers. The control D pin asserts the expanded views after a restart, not the container body.
  • Noted, not filed (carried from report 6144921283): the save door accepts a base-bound view named after a shipped view (showcase_task.in_progress with packageId app.home). The list then serves two bodies under that name, and both wear the shipping package's _packageId. Adoption no longer produces that row. It was measured at the protocol layer only, with no other producer measured.
  • Two existing pins outside the claim's named region were edited, as listed under Pins: the Third type === 'objects' tolerance in protocol.ts: applyObjectRegistryMutation CONSUMES the tolerant spelling rather than only guarding on it #8862 caller count with its header trace, and the census header.

Generated by Claude Code

…d items, and the registry follows each view row it rebinds

ADR-0070 keeps an overlay of an artifact item bound to the item it
customises, so reassignOrphanedMetadata skips a package-less row for which
isArtifactBacked holds, the predicate saveMetaItem sets override-artifact
by. Each view row it rebinds goes through the write-through with the
binding it left, so reconcileViewExpansions moves the row's record and
registers its expansion at once. The closing check's bypass row is gone.

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…pansions, not the container body the boot hydrates as stored

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…n adoption, which writes views through and objects not at all

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 31c294cd0a11f14e5ca6687255c88389125f719d — the merge of head dc2e53693853285aca7029c671a3913796ae2348 into base 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d, 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 31c294cd0a11f14e5ca6687255c88389125f719d && git checkout 31c294cd0a11f14e5ca6687255c88389125f719d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d dc2e53693853285aca7029c671a3913796ae2348 && git checkout -B drift-repro 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d && git merge --no-ff dc2e53693853285aca7029c671a3913796ae2348

node scripts/docs-audit/affected-docs.mjs --json 6d7f4df3c91c0d4e7d0a40c3b5dc796290e90a2d

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 13:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 9543e9c Oct 13, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23198-orphan-adoption-reconcile branch October 13, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

metadata-protocol: a republish of a view container that drops a member keeps serving the dropped member's expanded view until restart

2 participants