Skip to content

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

@os-project-manager

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-boot registerObject replay destroys the packaged contributor in place; isArtifactBacked flips false; a subsequent DELETE /meta/object/:name reaches tier 3 of restoreArtifactRegistryView, the "NEVER RETIRES A CODE-SHIPPED OBJECT" guard is blind (it reads the very predicate the overwrite falsified), and unregisterObject removes 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 says reset: true.

Deliverable

In tier 3: refuse to unregisterObject when the owner contributor's packageId names a currently-installed package — the package binding is measured to survive the overlay overwrite (the definition does not, which is why isArtifactBacked cannot be trusted here). Reproduce the P3/P6 probe from the #6853 dev report (harness: protocol-delete-object-registry-heal.test.ts shape) 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-flight saveMetaItem two-tier gate region and from #6924's assertSortFieldsExist. 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.

Activity

  1. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 8 (domain:metadata seat, 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 by session_01LGRN2cSRfggfX9B2L83bQc under the maintainer's expedite order, in saveMetaItem's gate region and the PR branch, not here. Tier 3 of restoreArtifactRegistryView is 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.ts harness, 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 flips package → org, isArtifactBacked → false, then DELETE empties objectContributors and every data-plane call answers OBJECT_NOT_FOUND / 404 while the table still holds the rows.


    Generated by Claude Code

  2. self-assigned this
    on Aug 9, 2026
  3. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    os-dev report

    Status: done. Draft PR #7093, branch claude/issue-7012-tier3-package-binding-guard, base origin/main @ 2f3e79351. Session session_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.ts harness shape — real SchemaRegistry + real SysMetadataRepository over an in-memory engine, plus a data-plane insert — 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: 1 is 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 loadMetaFromDb the merged object has lost packaged_only, gained overlay_only, _provenance is org, getArtifactItem is undefined — and getObjectOwner(name).packageId is still app.myapp while getPackage('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 restoreArtifactRegistryView still asks isArtifactBacked, 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.ts tier-3 branch + one new private helper installedPackageBindingForObject carrying the argument
    packages/objectql/src/protocol-delete-object-package-binding-guard.test.ts new regression pin, 7 cases, both directions
    .changeset/lucky-buttons-shave.md patch changeset

    saveMetaItem's two-tier write gate region, assertSortFieldsExist, packages/spec and content/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. getPackage is even already declared in the engine contract (packages/spec/src/contracts/objectql-engine.ts:94), so no packages/spec change 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 → null observation does not contradict it. That null was a fact about that probe, which called registerObject directly and installed nothing; it is not what a package-shipped object looks like. Measured on the real paths:

    • ObjectQL.registerApp calls SchemaRegistry.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_packages at boot by packages/services/service-package/src/index.ts.
    • registerPlugin deliberately does not install a package, but keys its objects to the parent id — which registerApp installed.

    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 — disablePackage flips 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 objects list — that re-asks "is this code-shipped", the question whose answer was destroyed, and it is absent for registerPlugin-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 return before 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 in protocol-delete-object-registry-heal.test.ts still 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 moving origin/main — metadata-protocol rebuilt, pins re-run.

    Case Predicted Measured
    package-bound object survives the delete red red — expected undefined to be defined at :308
    DISABLED installed package still protects its object red red at :412
    plural objects spelling guarded red red at :432
    control-plane kernel guarded red red at :452
    boot 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 — Done
    • metadata-protocol has no typecheck script (measured DEBT entry of 63 in scripts/check-type-check-coverage.mjs). tsc --noEmit counts 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-fallthrough all PASS; node scripts/check-nul-bytes.mjs OK; eslint --no-inline-config clean 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-changeset is not applicable — this PR ships a changeset.

    7. Not verified / left open

    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

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions