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

Closed

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. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 9, 2026
  3. self-assigned this
    on Aug 9, 2026
  4. 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

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions