Skip to content

fix(metadata-protocol): a republish of a view container retires the views it dropped, through one reconcile - #23234

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23198-container-expansion-reconcile
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-23198-container-expansion-reconcile

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Part of #23198

Clause-②: no

What was wrong

A view container (a defineView document such as showcase_task) registers its own name plus one view per entry, under OBJECT.VIEWKEY, in the process registry. A republish of the container registered the views it expanded to and retired none it no longer expanded to. After a publish that dropped listViews.added_by_overlay, GET /api/v1/meta/view, the ?object= list and the by-name read kept serving showcase_task.added_by_overlay until a restart. Measured before the change, on this PR's pins (red at 0219bb6a0) and over the real SchemaRegistry (see Verification).

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

One private reconcile, reconcileViewExpansions(type, write, organizationId). After any write that changes a container's stored row, it leaves the registry holding the views that row expands to now:

  1. It registers each expanded view, except a name another package ships ([finding(metadata-protocol): a stored copy of a view container a package ships on another package's object expands under its own name (#21334's arm), so a form withdrawn in that copy does not reach the package's shipped form of that name #21980], [finding(metadata-protocol): on an unscoped kernel, a package-bound stored row of a view name two packages ship is hydrated into the registry's bare slot, so a by-name read naming the other package serves that row's body under its own _packageId #22057]). That name is never recorded.
  2. It replaces the record of the rows the write addresses with the names it just registered. The record is PR fix(metadata-protocol): a view container's reset heals the expanded views its publish registered #23196's, now kept per stored row.
  3. It heals each name the rows registered before and do not register now, through PR fix(metadata-protocol): a view container's reset heals the expanded views its publish registered #23196's per-name walk (restoreArtifactRegistryView). A packaged view of that name is un-shadowed, a baseline is re-registered on a control-plane kernel, and a name no layer serves is retired.

Both sides call it, and nothing else registers an expansion or touches the record:

  • The registration. hydrateExpandedViewItems passes the row's binding and its expansion. hydrateOverlayIntoRegistry reaches it on both of its registering paths. That covers every hydration: the write-through (save-publish, publish, rollback, revert, a replica's convergence), the list read and the boot.
  • The heal. restoreArtifactRegistryView passes removed: 'every-row' (an empty set) ahead of its tier walk. A reset, a delete, a revert's removal and a replica's removal reach it there.

The walk reads the MetadataService, so the reconcile is async. hydrateOverlayIntoRegistry and applyRegistryWriteThrough became async, and all eight call sites await them. A publish therefore answers after the heal. The #9111 type assert still runs before the first await, and its refusal reaches the same catch as before. protocol.hydrate-overlay-canonical-type.test.ts awaits the hydrator now. Its four affected cases assert what they asserted before: code, status, message and nothing minted.

Why the record is per row (zone 2, hypothesis 2, measured). One container name can have a package-less row and a package-bound row (ADR-0048), and the list read hydrates both on every call. If the record is keyed on the name alone, each row reconciles against the other row's set. The first list read then retired the package-less row's own member (ablation B below). Keyed per row, a republish of one row heals only what that row dropped. A view both rows expand to, which one row drops, leaves the registry with that row. Both doors answer it from the other row's stored row, and the next list read registers it again (pinned). A reset still heals every row of the name, because the heal is handed no binding (#23196's behaviour, unchanged).

Zone 2 hypotheses

  1. The record is a union. Confirmed. Pin: a reset after a republish leaves alone a name the container dropped earlier. Before the change, that reset healed a bare entry held by another source.
  2. Two rows of one name. Confirmed for a name-only key. Answered with per-row keys (above).
  3. Callers, enumerated from the tree. hydrateOverlayIntoRegistry has three callers: the list read's hydration in getMetaItems (not the cluster peer), applyRegistryWriteThrough, and loadMetaFromDb. The peer reaches the registry through applyRegistryWriteThrough or restoreArtifactRegistryView. The completeness pin reads that population from the source.
  4. The gates. The organization gate is in the reconcile itself and in both callers. Re-registration stays control-plane only, inside the walk. The another-package skip is inside the reconcile's registration loop. fix(metadata-protocol): a view container's reset heals the expanded views its publish registered #23196's pins for the organization gate and the another-package skip pass unchanged.
  5. A degraded tier-2 read. Inherited from the code, not pinned by a run. The walk stops, so that name stays registered (and is no longer recorded) until a restart. No retry was added.

Pins

protocol.view-container-reset-expansions.test.ts, a new [#23198] block of 5 cases on #23196's doubles (so no new engine-double rows). Each case asserts the list read, the ?object= list and the by-name read together:

  • a republish adds a member;
  • the card's repro: a republish drops two members, so the one no package ships is served by no door, and the packaged showcase_task.in_progress is served again, once;
  • a reset after a republish;
  • a delete of a container no package ships, after a republish that dropped a member;
  • two rows of one container name.

protocol.view-expansion-reconcile-call-sites.test.ts, the family's closing check (13 cases, source-reading, no CI gate). It covers three populations:

  • Stored-row writes. Every repository write verb and raw sys_metadata write in protocol.ts (9 sites) carries a recorded route to the write-through or the heal. A same-member route must sit after its write and before the member's next write. A write with no route turns it red, naming the write.
  • The chain. The write-through reaches the hydrator. Every registering path of the hydrator reaches the expansion hydration. The heal reconciles ahead of its walk, past its organization gate. Every call into the chain is awaited.
  • The record and the verbs. Only the reconcile reads or writes the record, and only the walk calls the heal verbs.

The data door cannot write sys_metadata: its apiMethods are get and list.

protocol.register-item-call-sites.test.ts (#22057): the expansion's registerItem( row moves to reconcileViewExpansions.

Ablation (fix committed first; scripts/ablation-replace.mjs WRAP mode; these suites import ./protocol.js, so they resolve from src/ and need no build)

Every leg was restored with blob equal to HEAD and an empty git diff HEAD.

  • A. The heal of a dropped name made a no-op (anchor 1 to 0, blob a9b1a41c7 to 5faee5a1f). 9 red, 2 green: the dashboard control and "a republish adds a member".
  • B. The record keyed on the container name alone (blob to 70a74e603). Only the two-row case is red, at read 1: expected undefined to be 'A only'.
  • C. The bound-row path of the hydrator registers without reconciling. This leg took three attempts:
    1. Replacing the call and its return with a commented return true; went red, but only at the closing check's path count. So the count now reads a trailing comment too (675c31319).
    2. A bare return true; was refused by the tool: its replacement restated part of the anchor, and the replace count stayed 5 to 5. Nothing ran in that attempt.
    3. Deleting the await this.hydrateExpandedViewItems line (blob to c85a10a1c) turned only the closing check red, in 2 cases. All 11 behaviour pins stayed green, so this bypass is visible to the closing check alone.
  • D. The delete's heal after repo.delete removed (blob to e1658dc6f). The census is red: deleteMetaItem: repo.delete( → restoreArtifactRegistryView.
  • E. The rollback's write-through not awaited (at 99dd8fda3, blob e5645d2f9 to a6c6cec44). The await case is red.

Legs A to D ran at a4b879a33 and 675c31319, on the protocol.ts blob a9b1a41c7 that the merge left unchanged. After them, protocol.ts changed only in comments.

Verification (HEAD 99dd8fda35)

  • Full @objectstack/metadata-protocol suite on the merged tree 4035e9baee (after main at 75cada0ea), run in two halves: 116 files passed and 3 skipped (25931 tests), then 118 files passed (2370 tests). VERDICT command-exit 0 for both. Before the merge, at a4b879a33: 234 files passed, 3 skipped, 28301 tests. 99dd8fda35 adds comment lines and one closing-check case, which ran green with typecheck.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. tsc --listFiles lists all four changed or new test files.
  • Real composition (scratch file in packages/objectql/src, not committed, over the rebuilt dist/, with the real SchemaRegistry). The three legs are the card's repro then a reset, the delete, and the two rows. They are 3/3 green. Against the base protocol.ts (rebuilt, then restored to HEAD and rebuilt) they are 3/3 red, at Added and Closed still served and a_only still registered. 12 files and 178 tests are green across them plus the 11 objectql suites that drive this heal and hydration.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 99dd8fda35 derives 65 commands. --ran with exit codes reports 64 run with exit 0 and 1 NOT MEASURED. check:lean-entry-closure first exited 3 because objectql/dist was absent; after the objectql build it exited 0. NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3, 60+ packages unbuilt in this container. Narrow stand-in: require of packages/metadata-protocol/dist/index.cjs loads ObjectStackProtocolImplementation (a function).
  • Lint, a declared narrowing. All 5 changed .ts files are in eslint.config.mjs's population (--print-config resolves each). eslint --no-inline-config --format json reports 5 files, 0 errors and 0 warnings. Type-aware linting is not enabled: the resolved config has no parserOptions.project or projectService. So this diff cannot move the verdict of an untouched file. The full pnpm lint is CI's.
  • Built declarations. ObjectStackProtocolImplementation gains the untyped private name reconcileViewExpansions and loses recordViewExpansion and takeViewExpansions. ViewContainerWrite is module-private and absent from dist/index.d.ts. No public member, export, schema or accept-set changes, so Clause-②: no stands.
  • Changed lines: +635 / -102 in 6 files against merge base 75cada0ea.

Acceptance notes

  • Orphan adoption bypasses the reconcile, and the closing check names it (recorded as a shrink-only bypass row). POST /api/v1/packages/ID/adopt-orphans (reassignOrphanedMetadata) rebinds a package-less view container row to another package with a raw sys_metadata update and touches no registry entry. This was measured over the real SchemaRegistry with this fix built. After adopting an overlay of showcase_task into another package, the row expands to showcase_task.showcase_task* for that package. Meanwhile the package-less registration's bare showcase_task, showcase_task.default (All Tasks (X), 6) and showcase_task.in_progress stay registered, and all three doors keep serving All Tasks (X) for showcase_task.default until a restart. It is the same family, but a fifth write kind outside this claim's region. It is reported for the seat to route; see the report on metadata-protocol: a republish of a view container that drops a member keeps serving the dropped member's expanded view until restart #23198.
  • Residual, documented in the reconcile's docblock. A dropped name whose walk stops on a degraded MetadataService read stays registered, no longer recorded, until a restart. Before this change the same residual was reachable from a reset only.
  • Two rows of one name. A reset or delete of either row heals both rows' views. The remaining row is hydrated again on the next list read. This is fix(metadata-protocol): a view container's reset heals the expanded views its publish registered #23196's behaviour, unchanged.
  • Test hygiene, not addressed. protocol.code-defined-datasource-door.test.ts calls the now-async hydrator without await in its hydrate helper. It stays correct because a datasource row registers before the first await.
  • Not done: a live API run on examples/app-showcase. The real-registry scratch stands in.

Generated by Claude Code

…write (red before the fix)

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…ns after every write

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
Co-authored-by: Claude <noreply@anthropic.com>
…aches the reconcile

Claude-Session: https://claude.ai/code/session_01P88ehrZ6hM19hBzdpsQTTo
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 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).

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

  • content/docs/api/client-sdk.mdx (via meta.rollbackItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/rollback, selected by route anchor /:type/:name/rollback), rollbackItem (sdk, the bare tail of client method meta.rollbackItem, bound to POST /api/v1/meta/:type/:name/rollback))
  • content/docs/api/metadata-api.mdx (via /packages/:id/commits/:commitId/revert (route, the route ledger binds it to client method packages.revertCommit))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), applyRemoteMetadataMutation (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/environment-variables.mdx (via applyRemoteMetadataMutation (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via applyRegistryWriteThrough (symbol, a method of class ObjectStackProtocolImplementation), applyRemoteMetadataMutation (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/contracts/metadata-service.mdx (via /:type/:name/rollback (route, bridged from symbol rollbackMetaItem — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

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

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 01a3421f42b15801dd6289f9a5fdf22fa469b8f2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 24ec3829d6cbc02e05c41ef85e3ffbd1a950f8f3 — the merge of head 99dd8fda359bc1d7a9e399ba21c24de65ce259c9 into base 01a3421f42b15801dd6289f9a5fdf22fa469b8f2, 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 24ec3829d6cbc02e05c41ef85e3ffbd1a950f8f3 && git checkout 24ec3829d6cbc02e05c41ef85e3ffbd1a950f8f3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 01a3421f42b15801dd6289f9a5fdf22fa469b8f2 99dd8fda359bc1d7a9e399ba21c24de65ce259c9 && git checkout -B drift-repro 01a3421f42b15801dd6289f9a5fdf22fa469b8f2 && git merge --no-ff 99dd8fda359bc1d7a9e399ba21c24de65ce259c9

node scripts/docs-audit/affected-docs.mjs --json 01a3421f42b15801dd6289f9a5fdf22fa469b8f2

⚠️ 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 01a3421f42b15801dd6289f9a5fdf22fa469b8f2 → 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 10:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit 78c13bd Oct 13, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23198-container-expansion-reconcile branch October 13, 2026 11:19
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

Development

Successfully merging this pull request may close these issues.

2 participants