Repository navigation
Stop-the-bleed: tier-3 heal must not unregister a package-bound object — decide on the contributor's package binding, not isArtifactBacked (#6853 ruling C) #7012
Description
Activity
Claim: PM loop round 8 (
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8Dw
Branch:claude/issue-7012-tier3-package-binding-guard
Worktree:objectstack-issue-7012
Domain:domain:metadata
Base:origin/main@ current (≥47a4e676)Taking this as round 8's first dispatch — it is the stop-the-bleed half of a ruling whose entire measurement this seat produced (#6853's decision-scoping dispatch, dev report P0–P6), so the context transfers without re-derivation.
Region disjointness re-verified at claim time as the card asks: both cards it names as neighbours have left the field — #6924 landed (PR #6996,
6443b792c) and #6190's final leg is held bysession_01LGRN2cSRfggfX9B2L83bQcunder the maintainer's expedite order, insaveMetaItem's gate region and the PR branch, not here. Tier 3 ofrestoreArtifactRegistryViewis uncontested.Carried into the dispatch verbatim, so nothing is re-litigated: the accepted cost is ruled (a package-bound runtime-authored object is indistinguishable from a package-shipped one by binding alone, so some genuinely deleted objects stay listable-but-rowless until restart — REGISTER WIDE / RETIRE NARROW), and the honest fix for the distinguishability itself is #6853's direction B, not this card.
The regression pin is the P3/P6 probe from #6853's dev report — deliberately not committed there, so it must be rebuilt here on the
protocol-delete-object-registry-heal.test.tsharness, in both directions. The measurement it must reproduce, with no escape hatch and on a tenant kernel:loadMetaFromDb → {"loaded":1,"errors":0}silently, contributor provenance flipspackage → org,isArtifactBacked → false, thenDELETEemptiesobjectContributorsand every data-plane call answersOBJECT_NOT_FOUND/ 404 while the table still holds the rows.
Generated by Claude Code
os-dev report
Status: done. Draft PR #7093, branch
claude/issue-7012-tier3-package-binding-guard, baseorigin/main@2f3e79351. Sessionsession_01W6bLax4KMrSfnE1ydFU8Dw. Premise still valid — reproduced verbatim before any code was written.
1. The outage, reproduced (not re-derived)
Throwaway probe on the
protocol-delete-object-registry-heal.test.tsharness shape — realSchemaRegistry+ realSysMetadataRepositoryover an in-memory engine, plus a data-planeinsert— run against this branch's base with the fix absent. Tenant kernel, no escape hatch, no operator action:{"loadMetaFromDb":{"loaded":1,"errors":0,"invalid":0,"storeUnavailable":false}, "afterHydration_isArtifactBacked":false, "insertBeforeDelete_ok":true, "deleteReceipt":{"success":true,"reset":true, "message":"Deleted object 'myapp_invoice' — it no longer exists. [seq=1]"}, "storedRowsAfter":0, "objectContributorsAfter":0, "getObjectAfter":null, "dataCrudAfter":{"code":"OBJECT_NOT_FOUND","status":404,"object":"myapp_invoice"}, "dataRows":1}dataRows: 1is the measurement that matters: the table still holds the row the object can no longer reach.No hatch was needed, because the probe uses the real boot order across two sessions instead of forging a row: session 1 persists the overlay while nothing ships the name (so the two-tier gate has nothing to refuse and the platform stamps its own checksum), session 2 installs the package, registers the packaged object, then hydrates. That is #6853 P6 exactly, and it is now the committed pin's setup rather than a one-off.
The committed pin also measures the hydration half independently, and it is green in both directions (a guard on the premise, not evidence for the fix): after
loadMetaFromDbthe merged object has lostpackaged_only, gainedoverlay_only,_provenanceisorg,getArtifactItemisundefined— andgetObjectOwner(name).packageIdis stillapp.myappwhilegetPackage('app.myapp')is defined. That pair is the whole fix in one line: the binding survived, the definition did not.2. What changed
Tier 3 of
restoreArtifactRegistryViewstill asksisArtifactBacked, and now additionally refuses when the owner contributor's package binding names a currently-installed package. The refusal is logged rather than swallowed — same discipline as the ADR-0029 extender refusal one line up.Files:
File Change packages/metadata-protocol/src/protocol.tstier-3 branch + one new private helper installedPackageBindingForObjectcarrying the argumentpackages/objectql/src/protocol-delete-object-package-binding-guard.test.tsnew regression pin, 7 cases, both directions .changeset/lucky-buttons-shave.mdpatch changeset saveMetaItem's two-tier write gate region,assertSortFieldsExist,packages/specandcontent/docs/releases/are untouched. Direction B is not implemented.3. The predicate, and the PM's assumptions answered
registry.getObjectOwner(name)?.packageId→registry.getPackage(packageId).Assumption 1 (guard change at one decision point, no new plumbing) — CONFIRMED. Tier 3 can express the question with verbs that already exist.
getPackageis even already declared in the engine contract (packages/spec/src/contracts/objectql-engine.ts:94), so nopackages/specchange and no cross-seat declaration on #6298 was needed. The only deviation from the declared surface is that the argument lives in a private helper next to the walk rather than inline; the decision itself is still one branch at one point.Assumption 2 (the right predicate exists) — CONFIRMED, and #6853's
getPackage→nullobservation does not contradict it. Thatnullwas a fact about that probe, which calledregisterObjectdirectly and installed nothing; it is not what a package-shipped object looks like. Measured on the real paths:ObjectQL.registerAppcallsSchemaRegistry.installPackage(manifest)immediately before registering the manifest's objects (packages/objectql/src/engine.ts), so the package record and the contributor come from the same call.- durable / AI-authored packages are re-installed from
sys_packagesat boot bypackages/services/service-package/src/index.ts. registerPlugindeliberately does not install a package, but keys its objects to the parent id — whichregisterAppinstalled.
So a package-shipped object always has an installed-package record. The
'sys_metadata'sentinel is handled by construction, not by a special case: nothing installs a package under it.Two things the predicate deliberately does not ask, each pinned:
- not
enabled/status—disablePackageflips lifecycle flags and removes no contributor, so a disabled package's objects stay registered and stay dispatchable. Reading the flag would unregister a definition nothing else removes: the same outage through a second door. - not the manifest's
objectslist — that re-asks "is this code-shipped", the question whose answer was destroyed, and it is absent forregisterPlugin-contributed objects.
Assumption 3 (the other two tiers need no change) — VERIFIED, not assumed. Tiers 1 and 2 each conclude that a lower layer still serves the name and
returnbefore any removal; neither has a removal branch to get wrong. The damage is entirely tier 3 deciding to remove. No change there, and the existing tier-1 pin inprotocol-delete-object-registry-heal.test.tsstill passes untouched.4. Reverse verification — direction predicted BEFORE running
Predicted RED, and only on the positive-direction cases. Fix removed with
git checkout 2f3e79351 -- packages/metadata-protocol/src/protocol.ts— this branch's own base, never a movingorigin/main—metadata-protocolrebuilt, pins re-run.Case Predicted Measured package-bound object survives the delete red red — expected undefined to be definedat:308DISABLED installed package still protects its object red red at :412plural objectsspelling guardedred red at :432control-plane kernel guarded red red at :452boot replay destroys definition / binding survives green both green — guard on the premise package-LESS runtime object still retired (+ CRUD 404s) green both green — guard against over-refusal binding naming a NOT-installed package still retired green both green — guard; also why every pre-existing heal pin stays green Tests 4 failed | 3 passed (7)with the fix out;Tests 7 passed (7)with it in. Every prediction held; none missed.One prediction went UNMEASURED in the reverse direction and is not counted as passed. The "survives" case fails at its first post-delete assertion, so its data-plane-recovery and warning-text assertions never execute with the fix out. They are measured green in the fixed direction, and the pre-fix data-plane verdict (
OBJECT_NOT_FOUND/ 404) was measured separately by the probe in §1 rather than inferred from the short-circuit.5. Local verification
pnpm --filter @objectstack/metadata-protocol test—Test Files 65 passed (65),Tests 827 passed (827)pnpm --filter @objectstack/objectql test—Test Files 163 passed (163),Tests 2793 passed (2793)pnpm --filter @objectstack/runtime test—Test Files 117 passed (117),Tests 1761 passed (1761)(run as the heal's downstream consumer; its first run's 91 collect failures were the fresh-worktree stale-artefact trap, gone after--filter '@objectstack/runtime^...' build)pnpm --filter @objectstack/objectql typecheck—Donemetadata-protocolhas notypecheckscript (measured DEBT entry of 63 inscripts/check-type-check-coverage.mjs).tsc --noEmitcounts 63 — exactly the recorded number, so the ratchet is unmoved.- gates:
check:engine-double-contract,check:error-code-casing,check:route-envelope,check:meta-type-normalized,check:empty-changeset,check:adr-anchors,check:wildcard-fallthroughall PASS;node scripts/check-nul-bytes.mjsOK;eslint --no-inline-configclean on both changed files.
6. CI — per-job conclusions on #7093 (head
365c97e7)All 25 check runs reached
completed. Read individually, not as an aggregate:Job status conclusion ESLint (carries the family gates) completed success TypeScript Type Check completed success Build Core completed success Test Core (1/3, 2/3, 3/3) + roll-up completed success Dogfood Regression Gate (1/3, 2/3, 3/3) + roll-up completed success Dogfood Verify CLI completed success Temporal Conformance (live PG + MySQL) completed success Check Changeset completed success No other open PR may claim the same issue completed success Check PR Size / Auto Label / Console Pin Freshness / ADR maintainer approval / Doc Links / docs-affected / filter / Vercel completed success Build Docs, Console Pin Gate completed skipped (path filter) No red job.
skip-changesetis not applicable — this PR ships a changeset.7. Not verified / left open
- The accepted cost is not observable from inside this seam, by construction — that is the ruling, not a gap. A package-bound runtime-authored object (loadMetaFromDb object branch reads
record.packageIdfrom a snake_case row — always undefined, every object overlay registers under the 'sys_metadata' sentinel at boot #4636) genuinely deleted will stay listable-but-rowless until restart. There is no measurement in this PR that could tell the two apart, because the distinguishing fact is exactly what Registry heal restores the metadata-map view but never re-registers a packaged contributor definition an overlay save replaced #6853 direction B restores. getObjectOwner(name)is name-addressed, andcomputeFQNis identity today, so the registry key IS the object name and the guard reads the same entryunregisterObjectwould remove. A legacyns__name-keyed entry looked up by short name would make the owner lookup answerundefinedand the guard fail open (today's behaviour, no regression) — nothing in-tree writes such a key, so this is documented in the helper rather than coded around.- No browser/dogfood pass — the change is a registry decision with no UI surface; the CI Dogfood Regression Gate and Dogfood Verify CLI both went green.
8. Out-of-scope findings
None filed. Nothing surfaced outside this card's scope that was not already tracked by the named family (#6853 direction B, #6725 write side, #6995 write-side silent discard, all pre-existing and referenced rather than re-filed).
Generated by Claude Code
- added a commit that references this issue
on Aug 17, 2026
Maintainer ruling 2026-08-09 on #6853 (「接受你的建议」): option C ships first, as this standalone S-class card, ahead of #6853's direction-B ADR work.
The outage this closes (measured, #6853 dev report P3/P6 — no escape hatch needed)
A stored overlay row for a packaged
object+loadMetaFromDb's ungated per-bootregisterObjectreplay destroys the packaged contributor in place;isArtifactBackedflips false; a subsequentDELETE /meta/object/:namereaches tier 3 ofrestoreArtifactRegistryView, the "NEVER RETIRES A CODE-SHIPPED OBJECT" guard is blind (it reads the very predicate the overwrite falsified), andunregisterObjectremoves the whole entry: every data-plane call on the object 404s (OBJECT_NOT_FOUND) until process restart, while the table still holds the data and the delete receipt saysreset: true.Deliverable
In tier 3: refuse to
unregisterObjectwhen the owner contributor'spackageIdnames a currently-installed package — the package binding is measured to survive the overlay overwrite (the definition does not, which is whyisArtifactBackedcannot be trusted here). Reproduce the P3/P6 probe from the #6853 dev report (harness:protocol-delete-object-registry-heal.test.tsshape) as the regression pin, both directions.Accepted cost (ruled, do not re-litigate)
A package-bound runtime-authored object (Studio package workspace, #4636) is indistinguishable from a package-shipped one by binding alone, so some genuinely deleted objects stay registered until restart — listable-but-rowless. Per the walk's own REGISTER WIDE / RETIRE NARROW argument this is the cheap direction; the honest fix for the distinguishability itself is #6853's direction B (overlay as its own contributor layer), which re-arms
isArtifactBacked.Region note
Lands in
restoreArtifactRegistryView(tier 3) — disjoint from #6190's in-flightsaveMetaItemtwo-tier gate region and from #6924'sassertSortFieldsExist. Verify disjointness at claim time; STOP on overlap.Refs: #6853 (ruling + full measurement), #6818 (name-addressed verb), #6725 (dormant write side), #6995 (write-side sibling, subsumed by B), ADR-0005, ADR-0029.