You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Registry heal restores the metadata-map view but never re-registers a packaged contributor definition an overlay save replaced #6853
Out-of-scope finding recorded by the dev of #6808 / PR #6818 in its scope notes; filed by the engine-core PM on its behalf. Recorded, not claimed. Pre-existing — not introduced by #6818.
Fact
restoreArtifactRegistryView (the #6687 three-tier heal, packages/metadata-protocol/src/protocol.ts) restores the metadata-map view of an artifact-backed item in tiers 1/2. But an overlay save of an object went through registerObject, which replaced the packaged contributor definition in objectContributors. Nothing in the heal re-registers that packaged definition once the overlay is removed.
#6818 closed the removal half of the same map-vs-contributors split (the new name-addressed SchemaRegistry.unregisterObject, used by the heal's tier-3 object limb for names that are not artifact-backed). This is the restoration half: after an overlay that shadowed a packaged object is deleted, the map view is healed while the contributor list may still hold the overlay-era definition rather than the package's.
Why it was not fixed there
Stated by the dev and endorsed at acceptance: it is undecidable at that seam with the information the walk has — the heal knows the item is artifact-backed, but not which package's definition (and at what priority/ownership) should be re-contributed, nor whether a sibling contributor has since changed the effective shape. Guessing would produce a silently wrong registered schema, which is worse than the current gap.
Grading and routing to triage. If promoted, note that the three cards form one family and the API question ("what re-contributes a packaged definition, and with whose priority?") is the shared unknown.
Why not promoted now: the card's own analysis stands — the missing piece is an API decision ("what re-contributes a packaged definition, and with whose priority?") that the heal cannot answer locally. That is the promotion gate; when the family is taken up, the three cards should travel together.
Triage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted finding → pm:queue (domain:metadata kept). This is the restoration half of the map-vs-contributors split whose removal half #6818 just closed — a heal that restores one view and leaves the other shadowed is an incomplete invariant, and the anchor points are already named. Serialize behind #6818's merge; reuse its name-addressed registry API.
Session: session_01W6bLax4KMrSfnE1ydFU8Dw
Branch: claude/issue-6853-heal-recontribute-packaged-definition
Worktree: objectstack-issue-6853
Domain: domain:metadata
Base: origin/main @ 30283263c or newer (≥ 47a4e676, which is where engine-core's #6818 touched this function's tier-3 limb)
Dispatched decision-scoping-first, and the reason is on the record. The two triage comments set different bars, and the later one clears the earlier one's gate by assertion rather than by measurement:
01:26Z — routing only, finding grade explicitly kept, with a named promotion gate: "the missing piece is an API decision ('what re-contributes a packaged definition, and with whose priority?') that the heal cannot answer locally."
Reusing #6818's name-addressed verb is a plausible answer to how to re-register. It is not, on its face, an answer to which package's definition, at what priority and ownership — which is the question the gate names, and the question both the #6818 dev and this card's filer measured as undecidable at that seam with the information the walk has. Guessing it produces a silently wrong registered schema, which is worse than today's gap.
So the dispatch's first deliverable is to test that assertion, not to assume it: if the name-addressed API genuinely closes the loop without any new priority/ownership judgement, implement it; if a judgement is still required, produce the option set with its costs and STOP — this seat escalates it as a decision, the way #6710 went (dev STOP → PM option menu → maintainer ruling → same claim resumed → landed). ⛔ Picking the priority/ownership semantics by taste is not authorized under either triage comment.
File surface if it proves implementable: packages/metadata-protocol/src/protocol.ts — restoreArtifactRegistryView (the #6687 three-tier heal; triage read it live at :8058, anchor on the symbol) — plus tests and a changeset. ⛔ The saveMetaItem two-tier write gate (:7xxx) is #6190's surface in flight this round; ⛔ assertSortFieldsExist (:4745) is #6924's. Region-disjoint or STOP.
Family note carried into the dispatch: #6808 / PR #6818 is the merged removal half (the verb this would reuse), and #6725 is the write side of the same map-vs-contributors split, dormant in-tree. Triage's 01:26Z comment asks that the three travel together when taken up — if the measurement shows the restoration half cannot be settled without also settling #6725's write side, that is a STOP-and-report, not a widening.
Status: STOP — needs_decision. No PR. Measured on origin/main @ 51f2bb8c3 (contains 47a4e676, #6818's merge). Session session_01W6bLax4KMrSfnE1ydFU8Dw, branch claude/issue-6853-heal-recontribute-packaged-definition (nothing pushed).
The card's premise stands, and the measured consequence is worse than the card states. The 06:49Z promotion's assertion — "reuse #6818's name-addressed registry API" — does not clear the 01:26Z gate. Evidence below.
1. What was measured, and how
Harness: the existing packages/objectql/src/protocol-delete-object-registry-heal.test.ts shape — a real SchemaRegistry behind a real SysMetadataRepository over an in-memory sys_metadata/sys_metadata_history engine, plus a data-plane insert. Bodies chosen so the two layers are distinguishable: the packaged body carries amount + packaged_only; the overlay body carries overlay_only and neither of those.
Predictions were written before running (P1-P5); P0 and P6 were added after P1-P5 measured a precondition I had not predicted. Every written prediction held; the two additions are recorded as such rather than folded in.
P0 — the overlay save over a packaged object is REFUSED by default (not predicted)
controlPlane=true -> NOT_OVERRIDABLE / 403 (SysMetadataRepository.assertAllowed)
controlPlane=false -> NOT_OVERRIDABLE / 403 (protocol.ts:8434 two-tier gate)
contributors unchanged in both cases
So the card's scenario needs the documented operator hatch (OS_METADATA_WRITABLE=object) — or a row that already exists — to get started. That is not a narrowing that closes the card; see P6.
P1 — the overlay REPLACES the packaged contributor (predicted, confirmed)
Packaged registerObject(body, 'app.myapp'), then saveMetaItem({type:'object', packageId:'app.myapp', …}):
registerObject (registry.ts:1157) splices out the same-package own contributor. The packaged definition is destroyed at write time — it is not shadowed, and no second copy exists anywhere in the registry. Note the flip of getArtifactItem to false: that is the same predicate the heal's tier-3 guard reads.
P2 — the different-package leg does NOT replace it (predicted, confirmed)
With a package-less row (sys_metadata sentinel) the registerObject call throws, is caught and warn-logged, and the packaged contributor survives untouched — while the metadata map holds the overlay. No restoration gap on this leg; it is a write-side divergence instead, filed separately as #6995.
P3 / P6 — the delete does not leave a stale contributor, it DELETES the object
P6 is the one that matters, because it uses no escape hatch at all and a tenant kernel. A sys_metadata object row bound to app.myapp already exists (authored once under the hatch, seeded, imported, or predating the gates). Boot order is the real one: the package registers its objects, then loadMetaFromDb hydrates.
loadMetaFromDb (protocol.ts:11753) calls registerObject(row, row.package_id || 'sys_metadata') with no authorization gate, so this replacement re-happens on every boot, silently (errors: 0, no warning). The delete then reaches tier 3, isArtifactBacked is falsebecause the overlay overwrote the very contributor that guard reads, and unregisterObject takes the whole entry.
Consequence for a caller: a code package's object is off the data plane — assertObjectRegistered fails closed, so all CRUD on it 404s — until the process restarts. The tier-3 comment's "AND IT NEVER RETIRES A CODE-SHIPPED OBJECT" guard is blind here, and the receipt says reset: true / "it no longer exists" while the artifact default it claims to reset to is exactly what has just been unregistered.
P4 — the card's literal claim, reproduced (predicted, confirmed)
Seed a composite artifact entry in metadata['object'] so tier 1 heals:
keys before delete: ["myapp_invoice", "app.myapp:myapp_invoice"]
keys after delete: ["app.myapp:myapp_invoice"] <- map view healed
contributors after: provenance=org fields=[ … name, overlay_only ]
getObject after : fields=[ … name, overlay_only ] _provenance="org"
Map healed, contributor list permanently overlay-era — exactly as filed. But: for an object, that composite key is written only by MetadataFacade.register('object', …), i.e. #6725's dormant write side. engine.registerApp puts objects in objectContributors only, and loadMetadataFromService explicitly skips object for registerItem (objectql/src/plugin.ts:1965-1969). I had to seed it by hand; nothing in-tree writes it.
2. Decidability — the answer is NO, and the reason is sharper than the gate stated
A re-contribution needs (definition, packageId, namespace, ownership, priority). Measured availability at the seam, after the overlay has already replaced the contributor:
input
available at the heal?
evidence
packageId
yes
the surviving contributor still reads app.myapp; and replacement can only occur when the ids match, so it provably equals the packaged owner's id
ownership
yes
always own — registerObject only splices an own contributor
namespace
no
measured "" on the overlay contributor while the package registered with "myapp" (P5). Inert today only because computeFQN is identity (registry.ts:61)
priority
no
the overlay always writes DEFAULT_OWNER_PRIORITY; a package registered at a custom priority has already lost it
definition
no, in general
destroyed at write time, not at delete time
The definition is the load-bearing one, and the three places it might survive are each conditional:
registry.getPackage(pkgId).manifest.objects — retained (P5: the original name, amount, packaged_only is still there), but only for manifest-installed packages. In P1/P3/P6 getPackage('app.myapp') was null: objects registered through registerObject directly (ingestReloadedObjects, several boot paths) leave no manifest record.
So the 06:49Z reasoning does not close the loop.unregisterObject(name) needs only an address; registerObject needs four more values, three of which no longer exist when the heal runs. Reusing the name-addressed verb family answers how to remove, and there is no symmetric "re-contribute" verb it can be paired with, because the value to pass it is gone. The 01:26Z gate was right, and understated: the missing piece is not only "with whose priority" — it is the definition itself.
3. Option set
A — restore from a recovered body inside the heal (what the card literally proposes)
In tiers 1/2, having found a packaged body, call registerObject(body, contributor.packageId, '', 'own', DEFAULT_OWNER_PRIORITY).
Long-term soundness: it is a consumer-side reconstruction of a value the producer destroyed. Namespace and priority are guessed; extender merge order can shift silently.
Hard to get wrong: no. Its failure mode is precisely "a silently wrong registered schema", which both prior measurements named.
Does not fix P3/P6 — the damaging leg — at all.
B — stop destroying it: the tenant overlay becomes its own contributor (producer-side)
applyObjectRegistryMutation and loadMetaFromDb stop registering the overlay as a competing own, and register it as a distinct, higher-precedence contributor instead. The packaged own contributor is then never spliced out; "restoration" becomes removal of the overlay contributor — the same shape as #6818's verb, addressed by name plus provenance — and the packaged definition is restored by still being there. isArtifactBacked also stops flipping, which re-arms both the tier-3 guard and saveMetaItem's overlay gate.
Long-term soundness: this is ADR-0005's overlay model actually applied to objects — layers coexist and resolution decides — instead of a destructive in-place overwrite. No workaround, contract-first.
Hard to get wrong: best of the three. Declared = enforced; the registry stops holding a lossy merge of two layers.
The decision it requires, and why I will not make it: today an overlay replaces the whole body, whereas mergeObjectDefinitions merges fields additively. Modelling the overlay as an extender would silently stop an overlay from removing a packaged field; modelling it as a second own breaks assertSingleOwnerPerObject. So it needs either a new ownership kind or an explicit replace-semantics contributor — an ADR-0029 change with authoring-visible consequences. That is the maintainer's call, not mine.
C — narrow the tier-3 guard only (cheap safety fix, leaves the stale view)
In tier 3, decide on the contributor's package binding rather than on isArtifactBacked: refuse to unregisterObject when the owner contributor names a currently-installed package (measured: packageId survives the overlay; the definition does not).
Stops the P3/P6 outage; leaves the card's stale-contributor gap intact.
Splittable as its own card if the outage should be stopped before the model question is settled.
D — do nothing
Self-heals at the next restart. Only defensible if the P3/P6 outage is filed and graded separately, because that is a live 404 rather than a stale read.
Recommendation: B, with C landed first as a separate, decision-light card if the outage should stop this week. B is the only option that serves a measured business scenario, is the only one sound long-term, and is the only one that makes the seam hard to get wrong; A buys the dormant leg only and carries exactly the failure mode everyone has been avoiding. I have not picked B's ownership/merge semantics — that is the maintainer decision this card is being escalated for.
Tier structure — verified, and the card points at the wrong tier for the worst symptom. A re-contribution would belong in tiers 1/2 (a lower layer serves the name). But the damage is in tier 3, where the correct action is not to remove, not to re-contribute.
Is the gap narrower than filed? Narrower in one direction (needs the hatch or a pre-existing row to start; the tier-1 leg needs MetadataFacade.register('object', …) writes where neither of its own object reads look #6725), wider in another (on the mainline leg the object is unregistered outright and CRUD 404s, rather than merely serving a stale schema). It does not close the card.
The probe is a single file built on the harness in packages/objectql/src/protocol-delete-object-registry-heal.test.ts: register the packaged object with registry.registerObject(packagedBody, 'app.myapp'), persist an overlay row for the same name with the same packageId (hatch open once), replay rows into a fresh tenant-kernel harness with the hatch closed, run protocol.loadMetaFromDb(), then protocol.deleteMetaItem({type:'object', name}), and read objectContributors / registry.getObject / a createData call on each side. It was deliberately not committed — this card produced no PR.
PM: moved to needs-user-decision (metadata seat, session session_01W6bLax4KMrSfnE1ydFU8Dw). No PR, no speculative code, worktree removed — the decision-scoping dispatch did its job.
⚠️ This card is more severe than it was filed as — read this part first
It was filed and graded as an incomplete-invariant / stale-contributor gap. The measurement says otherwise. On a tenant kernel with no operator opt-in and no escape hatch, measured end-to-end on origin/main @ 51f2bb8c3:
loadMetaFromDb -> {"loaded":1,"errors":0,"invalid":0} boot warnings: []
after hydration -> contributor provenance: "org" (packaged body gone)
delete -> {"success":true,"reset":true}
after delete contributors -> []
getObject after delete -> null
data CRUD after delete -> {"code":"OBJECT_NOT_FOUND","status":404}
That is a live data-plane 404 on an object the package still ships, until the next restart. The mechanism, which I verified in the source rather than taking on report:
When an overlay row's package_id equals the packaged owner's id, registerObjectsplices the packaged contributor out (packages/objectql/src/registry.ts:1159). The packaged definition is destroyed at write time, not shadowed — there is no second copy in the registry.
loadMetaFromDb re-does that replacement on every boot, with no authorization gate and no log.
The later delete then does not merely leave a stale contributor: isArtifactBacked consults the very contributor the overlay overwrote, so it answers false, the tier-3 limb fires, and objectContributors goes empty.
The card's literal claim (map healed, contributor stale) also reproduces — but only through the composite metadata['object'] key, which only #6725's dormant path writes. The damaging leg needs no #6725 at all.
The decidability question: the answer is NO, and the 06:49Z assertion does not clear the 01:26Z gate
The promotion said "the anchor points are already named — reuse #6818's name-addressed registry API." Measured: unregisterObject is address-only, while registerObject needs four more values, three of which are gone before the heal runs.packageId and ownership are recoverable at the seam; namespace and priority are not, and the definition body is not recoverable in general — it survives in manifest records only for manifest-installed packages (getPackage() returned null in the mainline shapes), in the map only via #6725, and in the MetadataService only for loader-sourced objects, where tier 1 returns before tier 2 ever runs.
So the 01:26Z gate stands: this needs a ruling, not a verb.
The decision
A — heal-side restore from a recovered body, re-registering with the surviving contributor's packageId at default namespace + priority. Costs: namespace and priority are guessed, extender merge order can shift, its tier-1 precondition is MetadataFacade.register('object', …) writes where neither of its own object reads look #6725's dormant path (nothing on main creates it), and it does nothing for the measured 404. Failure mode is exactly the silently-wrong registered schema both prior measurements warned about.
B — producer-side: stop destroying it(dev's recommendation; I concur). applyObjectRegistryMutation and loadMetaFromDb register the tenant overlay as a distinct contributor instead of a competing own, so the packaged own is never spliced out. Restoration then becomes removal of the overlay contributor — the same shape as fix(objectql): deleting an object really unregisters it — a name-addressed SchemaRegistry.unregisterObject (#6808) #6818's verb, addressed by name plus provenance. Fixes the outage, re-arms the tier-3 guard and saveMetaItem's overlay gate that the current shape silently disarms, and subsumes An object overlay write over a packaged object is silently discarded by registerObject's ownership rule while saveMetaItem reports success #6995. Cost — and this is the part that is genuinely yours to rule: an ADR-0029 contract change. Today an overlay replaces the whole body, while mergeObjectDefinitions merges fields additively, so modelling the overlay as an extender would silently stop overlays from removing a packaged field; and a second own breaks assertSingleOwnerPerObject. It needs a new ownership kind, or an explicit replace-semantics contributor.
D — do nothing, it self-heals at restart. Only defensible if the measured 404 is filed and graded separately.
Recommendation: B, with C landed first as a decision-light card if you want the outage stopped before the model question is settled. B is the only option serving a measured scenario; A's sole in-tree trigger is dormant. B is also ADR-0005's overlay model actually applied to objects (layers coexist, resolution decides) rather than a destructive in-place overwrite, whereas A is consumer-side reconstruction of a value the producer destroyed — a workaround under Prime Directive #5.
The dev explicitly did not pick the ownership/merge semantics, and was right not to: that is the ADR-0029 call, and picking it by taste is what both earlier measurements said would produce a silently wrong schema.
This card was graded without the 404 leg visible. Please re-grade with the measurement above — a live data-plane 404 on a shipped object, reachable on a tenant kernel with no operator action, may warrant target:v17. target:* is your seat's to set, not mine, so this is a request rather than a change. #6995 also needs its first grading and domain:* routing.
Maintainer ruling (2026-08-09): direction B approved in principle; C ships first as its own stop-the-bleed card; A rejected. This card now tracks B; pm:queue.
C — filed as a separate S-class card (created and cross-linked): tier 3 of restoreArtifactRegistryView stops deciding on isArtifactBacked (measured to flip false exactly when the overlay has destroyed the contributor it reads) and refuses to unregisterObject when the owner contributor names a currently-installed package (packageId measured to survive the overwrite). Stops the live P3/P6 outage — data-plane 404 on a shipped object's intact table — this week. Accepted cost per REGISTER WIDE / RETIRE NARROW: some genuinely deleted package-bound objects stay listable-but-rowless until restart.
B — this card's deliverable becomes the ADR-0029 amendment proposal: the tenant overlay registers as a distinct contributor layer instead of splicing out the packaged own contributor — layers coexist, resolution decides, isArtifactBacked stops lying, and "restoration" degenerates to removing the overlay layer. The unresolved semantics (replace-semantics contributor vs a new ownership kind — today's overlay replaces the whole body while extender merge is additive) are designed in the amendment, not guessed here; the ADR PR is maintainer-merged per standing discipline, so the design lands on the maintainer's desk by construction. An object overlay write over a packaged object is silently discarded by registerObject's ownership rule while saveMetaItem reports success #6995 (write-side silent discard) is subsumed by B and is annotated accordingly.
Also ruled by implication: the 06:49Z promotion's premise ("reuse the name-addressed API") is formally retracted — the measurement showed unregisterObject needs an address while re-contribution needs four values that no longer exist; the 01:26Z gate was right.
Maintainer directive (verbatim, covering this decision round): 「接受你的建议」. Recorded by PM session session_01LGRN2cSRfggfX9B2L83bQc. Veto window open — comment to overturn.
Re-claiming under the 08:52Z ruling, which changed what this card delivers: direction B is approved in principle, and this card's deliverable is now the ADR-0029 amendment proposal — not an implementation. The stop-the-bleed half shipped separately as #7012, dispatched by this seat a few minutes ago.
Scope carried into the dispatch:
Design, do not implement. The ruling is explicit that B's unresolved semantics are designed in the amendment, not guessed — today an overlay replaces the whole body while mergeObjectDefinitions merges fields additively, so "overlay as extender" would silently stop an overlay removing a packaged field, and a second own breaks assertSingleOwnerPerObject. The amendment has to choose between a replace-semantics contributor and a new ownership kind, and argue it.
The measurement backing the amendment is already on this card (the 08:12Z dev report, P0–P6) and does not need re-deriving — only re-verifying where the amendment makes a factual claim.
Status: done — the ADR-0029 amendment is drafted and up as a DRAFT PR. PR #7087, branch claude/issue-6853-adr-0029-overlay-contributor-layer, session session_01W6bLax4KMrSfnE1ydFU8Dw. Design document only, per the 08:52Z ruling; no implementation, and the region locks (#7012's tier 3, PR #6973's saveMetaItem gate) were not touched.
1. The semantics chosen, and why
The ruling's tension: today an overlay replaces the whole body, while mergeObjectDefinitions merges fields additively — so "overlay as extender" would silently stop an overlay removing a packaged field, and "overlay as a second own" breaks assertSingleOwnerPerObject.
Chosen: a replace-semantics, NON-owning contributor kind (overlay) — not a new ownership kind. Declared in the amendment as D9, with these clauses:
D9.2 resolution — base = overlay ?? own, then extenders fold on top exactly as today. This is bit-for-bit what the current splice already produces (today the overlay is the owner, and extenders fold over it), including the resolved body's _provenance: 'org'. The resolved schema does not move; only what the registry remembers does. That is the argument that made replace-semantics the cheap choice rather than the expensive one.
D9.5 the single-owner assertion is unchanged, literally. Overlays are not owners, so assertSingleOwnerPerObject keeps counting exactly one own with no exemption clause — which matters because ADR-0028 D5/D6 depend on D3 being unconditional. One violation class is added: an orphan overlay (a layer with no owner), reachable by uninstalling the owning package.
D9.3 priority — a constant between owner (100) and extender (200) keeps the contributor list in layer order, but base selection asks the kind, never "highest priority wins". Extender priority is author-declared (ext.priority ?? 200, engine.ts:2944), and a package must not be able to re-rank which layer is the base.
D9.4 computeFQN is untouched — it is identity today, the layer shares the owner's key, and no namespace dimension is introduced. The measured namespace loss on the owner contributor (P5: '' where the package registered 'myapp') is repaired by subtraction, because the owner contributor is no longer removed.
D9.6 artifact identity is read from the OWNER contributor, not the merged object. This one had to be decided separately and is easy to miss: since D9.2 deliberately leaves the merged body identical — _provenance: 'org' included — isArtifactBacked only stops lying if getArtifactItem's object branch (registry.ts:1727-1733) reads the owner's definition instead of getObject's merged result. Without this clause, B would fix the destruction and leave the predicate lying.
D9.1 — the kind is loader-set and never authorable. Measured: ObjectOwnershipEnum has zero runtime consumers, and own/extend are set at exactly three loader call sites (engine.ts:2920/2933/3147/3157 and :2956); no author writes an ownership value. So the third kind adds no authoring surface — the "AI-written metadata cannot get this wrong" axis, structurally.
D9.8 — the write-path discriminator: at both hydration seams, ask the registry whether a packaged own already exists for the name; yes → overlay, no → own (today's runtime-authored behaviour). The point of direction B in one sentence: the judgement moves to write time, where the packaged owner is one lookup away, instead of delete time, where it has been destroyed. The same discriminator is owed to the two metadata-service ingest paths (objectql/src/plugin.ts:688, :765) or the splice returns through a third door.
The owning-kind alternative is rejected in the record: it grants authority the overlay measurably never uses (registers no namespace — both seams pass no namespace argument; does not decide package membership — getAllObjects matches any contribution's packageId at :1307; owns no table), and it would make D3 conditional at every getObjectOwner call site.
Honestly stated cost (D9.6). With the predicate honest, saveMetaItem refuses an overlay write to a packaged object with NOT_OVERRIDABLEevery time, not only the first, because object declares allowOrgOverride: false. Today the first write is refused and, by destroying the evidence, admits every later write through the allowRuntimeCreate tier. This is the declared contract enforced consistently — but a deployment living in the post-first-write state will feel it, and the amendment says so with the migration note rather than burying it.
2. #6995, handled by the model (not left as a loose end)
Because an overlay makes no ownership claim, registerObject's "already owned by package X" throw is unreachable from the overlay seams, so the silent-discard-with-success-receipt cannot recur. D9.9 rules all three bindings of the row's package_id against the packaged owner's id:
binding
verdict
same package
the normal case — one overlay layer.
package-less ('sys_metadata' sentinel)
accepted. ADR-0005's platform-global overlay shape; today's throw was an artefact of the borrowed slot, not a decision.
a different package
refused at the producer, loudly — an error from saveMetaItem instead of a success receipt; at boot the row is counted in loadMetaFromDb's per-record errors (which that seam already does today, protocol.ts:11706-11708), which is why #6995 is a write-path divergence and not a boot-path one.
The last row carries a store asymmetry worth having on the record: the overlay-uniqueness index keys on (type, name, organization_id, COALESCE(package_id, '')), so sys_metadata can legitimately hold two active rows for one (type, name) bound to two packages — which for object the registry can never represent, because computeFQN is identity and there is exactly one entry per object name. Refusing the mis-bound row is what keeps the two stores in agreement.
3. What was verified against the tree
Everything cited was re-read on 2f3e79351 rather than carried over from the 08:12Z report's 51f2bb8c3. Confirmed still true: the two hydration seams and their two-argument calls (protocol.ts:7897-7911, :11670-11673); the same-package own splice (registry.ts:1157-1160); base selection in resolveObject (:1217); additive-only mergeObjectDefinitions with no removal expression (:86-108, fields at :91); identity computeFQN (:61-63); the own-gated provisionPrimary / provisionSearchCompanion (:1116-1130); the merged-body read in getArtifactItem's object branch (:1727-1733) and isTenantAuthored (:844-846); own-only counting in assertSingleOwnerPerObject (:1355-1379); the two-tier gate (protocol.ts:8460-8472) and tier-3 limb (:8259-8272); object's allowOrgOverride: false, allowRuntimeCreate: true (metadata-plugin.zod.ts:628).
Two things the re-read added to the report's picture, both now in the amendment:
D9.6 is not implied by D9.2 (above) — the merged body keeps tenant provenance by design, so artifact identity has to be re-pointed explicitly.
A silent regression a naive implementation would ship: provisionPrimary / provisionSearchCompanion are gated on ownership === 'own'. Since the overlay body becomes the resolved base, that gate must become "is this a BASE layer" or nameField changes on every overlaid object. It is in the blast-radius table with every other .ownership read site.
4. Answers to the dispatch's stated assumptions
Amendment, not a new ADR — confirmed, and the repo's own convention decides it. Ten ADRs carry ## Amendment (date, #issue) sections, and they routinely carry new decisions, not just corrections (ADR-0073's amendment made user-less data ops REFUSED; ADR-0119's decided D1's two open caveats; ADR-0045's moved a gate onto a new key). ADR-0029 carried no amendment before this one, so the shape was taken from ADR-0045 / ADR-0119 / ADR-0005 ([docs] ADR-0005 的「Index migration」段落描述的是已不存在的机制:addSysMetadataOverlayIndex 已随 #6771 删除,段落还写着 project_id 与 partial 键 #6825): Status-line entry + end-of-document section, with the amended block (D3) left standing per Prime Directive [WIP] Add Chinese version of the documentation #13. A superseding ADR would be wrong here: D1-D8 (kernel decomposition, capability plugins owning their objects, the K-plan) are entirely intact, and D3's invariant is not even weakened — it is kept by refusing to make the overlay an owner.
ADR-0005 implicated — confirmed, and deliberately NOT edited. It owns the overlay whitelist (object: allowOrgOverride: false, so the whole scenario needs the operator hatch) and the three-layer read model D9 is applying to objects; the amendment references it throughout. I did not add an "Amended by" pointer to ADR-0005's header, because that line asserts acceptance and this amendment is a proposal until the maintainer merges it. If it is accepted, the ADR-0005 pointer is a one-line follow-up — flagging it here so it is not forgotten rather than pre-writing a claim that may not become true.
scripts/adr-anchors.json — one entry added, and it is exactly the docs(adr-0005): amend the overlay-index section instead of rewriting it #6981 situation: packages/objectql/src/registry.ts implements D3 (the splice at :1157-1160, assertSingleOwnerPerObject at :1355, the extender guard at :1490), already cites ADR-0029 in prose, and was unanchored — which is how the splice could read as an ordinary re-registration convenience rather than as the ADR-0029 contract. packages/metadata-protocol/src/protocol.ts is deliberately not anchored to ADR-0029: its relationship there is the defect the amendment describes, not a realized decision, and it earns its anchor with the implementation.
Measurement inline — done. P0-P6 are quoted where they support a claim (the destroyed fields, the errors: 0 boot, the namespace '', the 404) and cited rather than re-derived; the amendment's §9 says exactly which facts come from the 08:12Z run and which were re-read here.
5. Changeset route
No changeset, and the tooling prescribes that: pr-automation.yml's changeset gate has no docs-only path exemption, and route 2 is the skip-changesetlabel rather than an empty changeset file (an empty file is a real input to changesets/action, the label is a gate-level exemption — #5292 / #5471). The label was applied by this seat immediately after the PR existed.
6. CI — per-job conclusions
All 26 check runs on 746c47d are completed — no job left in_progress, read per job rather than from the aggregate:
job
conclusion
ESLint (carries the family gates, incl. check:adr-anchors, check:nul-bytes, check:doc-authoring)
success
TypeScript Type Check
success
Check Documentation Links (incl. check-adr-links)
success
Check Changeset
success — read the skip-changeset label live
ADR maintainer approval
failure — the pending human gate, by design (see below)
Auto Label · Check PR Size · Test Core · Dogfood Regression Gate · Console Pin Freshness · Vercel Preview Comments · filter · "No other open PR may claim the same issue"
skipped (path filters — no packages/** in this diff)
Flag docs affected by code changes (docs-drift-check.yml) did not report at all, and that is correct rather than missing: its paths filter is packages/** … scripts/docs-audit/**, and this diff touches docs/adr/** plus scripts/adr-anchors.json only, so the workflow never triggered and no check context was created.
Labels, read back after the bots settled (the read is the proof, not the write): ["documentation", "skip-changeset"] — skip-changeset applied by this seat seconds after the PR existed, documentation added by Auto Label afterwards; neither write wiped the other.
The ADR maintainer approval failure is the designed pending human gate, not a defect. Its log says so verbatim: "ADR merges are reserved to the maintainer in person. Drafting this PR was fine and stays fine — only the MERGE is reserved. Green path: the maintainer (@hotlong) reviews and approves; the approval re-runs this check via the pull_request_review trigger." Per the ruling that is the point of this card: the design lands on the maintainer's desk by construction. Not auto-merged, not queued, left as a draft for the PM.
7. No reverse verification, and none is fabricated
This is prose. Prose has no test coverage, so there is no before-red/after-green limb to report and inventing one would be evidence-shaped fiction (PR #6981 is the precedent). What exists instead are the gate outputs actually run locally:
check-adr-anchors: OK (47 anchored file(s), every governing ADR still referenced;
118 decision number(s) …; 20542 citation(s) across 3353 file(s) resolve)
check-adr-links: 531 relative link destination(s) under docs/adr/ resolve
check-nul-bytes: OK (scanned 6525 tracked text file(s); no raw ASCII control bytes)
check-doc-authoring · check-docs-audit-scope · check-role-word ·
check-quick-reference-counts · check-release-notes: all OK
pnpm install was deliberately skipped: the diff is one Markdown file and one JSON file, and every gate above is a zero-dependency node script — which is how check-links.yml and adr-merge-approval.yml run them in CI. The worktree has been removed.
8. What I could not verify
The runtime behaviour itself was not re-measured. P0-P6 were measured on 51f2bb8c3 by the 08:12Z run and are cited, not reproduced — re-running the probe would have meant building the packages this card does not touch. Every source claim the amendment makes was re-read on 2f3e79351; every behavioural claim is attributed.
Whether the three existing tests keep their expectations under D9 (registry-single-owner.test.ts, registry-unregister-object.test.ts, protocol.delete-object-registry-unregister.test.ts) is argued from D9.2/D9.5 and stated in the amendment as a check the implementation card must confirm, not as a claim this record can make.
Activity
os-zhuang commentedon Aug 9, 2026
Findings-round routing repair: +
domain:metadata. Routing only — thefindinggrade stands, no ownership taken.restoreArtifactRegistryView, verified live onorigin/main@41610f6atpackages/metadata-protocol/src/protocol.ts:8058; any re-contribution verb would sit beside it and beside fix(objectql): deleting anobjectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818'sunregisterObject.packages/metadata*⇒domain:metadataper the post-[PM seat] domain:server · seat 1 — 🟢 os-elon-musk · 由 os-elon-musk 账户的项目负责 #6367 lane table.MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 (write side of the same map-vs-contributors split) and A deleted runtimeobjectis still served bySchemaRegistry.getObject— the registry heal reaches themetadatamap but neverobjectContributors#6808 / PR fix(objectql): deleting anobjectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818 (removal half, merged) are the named family — adjacent, not duplicates. No other open card covers the restoration half.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
Triage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted
finding→pm:queue(domain:metadatakept). This is the restoration half of the map-vs-contributors split whose removal half #6818 just closed — a heal that restores one view and leaves the other shadowed is an incomplete invariant, and the anchor points are already named. Serialize behind #6818's merge; reuse its name-addressed registry API.Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
Claim: PM loop round 7 (
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8DwBranch:
claude/issue-6853-heal-recontribute-packaged-definitionWorktree:
objectstack-issue-6853Domain:
domain:metadataBase:
origin/main@30283263cor newer (≥47a4e676, which is where engine-core's #6818 touched this function's tier-3 limb)Dispatched decision-scoping-first, and the reason is on the record. The two triage comments set different bars, and the later one clears the earlier one's gate by assertion rather than by measurement:
findinggrade explicitly kept, with a named promotion gate: "the missing piece is an API decision ('what re-contributes a packaged definition, and with whose priority?') that the heal cannot answer locally."pm:queueon the grounds that "the anchor points are already named. Serialize behind fix(objectql): deleting anobjectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818's merge; reuse its name-addressed registry API."Reusing #6818's name-addressed verb is a plausible answer to how to re-register. It is not, on its face, an answer to which package's definition, at what priority and ownership — which is the question the gate names, and the question both the #6818 dev and this card's filer measured as undecidable at that seam with the information the walk has. Guessing it produces a silently wrong registered schema, which is worse than today's gap.
So the dispatch's first deliverable is to test that assertion, not to assume it: if the name-addressed API genuinely closes the loop without any new priority/ownership judgement, implement it; if a judgement is still required, produce the option set with its costs and STOP — this seat escalates it as a decision, the way #6710 went (dev STOP → PM option menu → maintainer ruling → same claim resumed → landed). ⛔ Picking the priority/ownership semantics by taste is not authorized under either triage comment.
File surface if it proves implementable:
packages/metadata-protocol/src/protocol.ts—restoreArtifactRegistryView(the #6687 three-tier heal; triage read it live at:8058, anchor on the symbol) — plus tests and a changeset. ⛔ ThesaveMetaItemtwo-tier write gate (:7xxx) is #6190's surface in flight this round; ⛔assertSortFieldsExist(:4745) is #6924's. Region-disjoint or STOP.Family note carried into the dispatch: #6808 / PR #6818 is the merged removal half (the verb this would reuse), and #6725 is the write side of the same map-vs-contributors split, dormant in-tree. Triage's 01:26Z comment asks that the three travel together when taken up — if the measurement shows the restoration half cannot be settled without also settling #6725's write side, that is a STOP-and-report, not a widening.
Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
os-dev report
Status: STOP —
needs_decision. No PR. Measured onorigin/main@51f2bb8c3(contains47a4e676, #6818's merge). Sessionsession_01W6bLax4KMrSfnE1ydFU8Dw, branchclaude/issue-6853-heal-recontribute-packaged-definition(nothing pushed).The card's premise stands, and the measured consequence is worse than the card states. The 06:49Z promotion's assertion — "reuse #6818's name-addressed registry API" — does not clear the 01:26Z gate. Evidence below.
1. What was measured, and how
Harness: the existing
packages/objectql/src/protocol-delete-object-registry-heal.test.tsshape — a realSchemaRegistrybehind a realSysMetadataRepositoryover an in-memorysys_metadata/sys_metadata_historyengine, plus a data-planeinsert. Bodies chosen so the two layers are distinguishable: the packaged body carriesamount+packaged_only; the overlay body carriesoverlay_onlyand neither of those.Predictions were written before running (P1-P5); P0 and P6 were added after P1-P5 measured a precondition I had not predicted. Every written prediction held; the two additions are recorded as such rather than folded in.
P0 — the overlay save over a packaged object is REFUSED by default (not predicted)
So the card's scenario needs the documented operator hatch (
OS_METADATA_WRITABLE=object) — or a row that already exists — to get started. That is not a narrowing that closes the card; see P6.P1 — the overlay REPLACES the packaged contributor (predicted, confirmed)
Packaged
registerObject(body, 'app.myapp'), thensaveMetaItem({type:'object', packageId:'app.myapp', …}):registerObject(registry.ts:1157) splices out the same-packageowncontributor. The packaged definition is destroyed at write time — it is not shadowed, and no second copy exists anywhere in the registry. Note the flip ofgetArtifactItemtofalse: that is the same predicate the heal's tier-3 guard reads.P2 — the different-package leg does NOT replace it (predicted, confirmed)
With a package-less row (
sys_metadatasentinel) theregisterObjectcall throws, is caught and warn-logged, and the packaged contributor survives untouched — while the metadata map holds the overlay. No restoration gap on this leg; it is a write-side divergence instead, filed separately as #6995.P3 / P6 — the delete does not leave a stale contributor, it DELETES the object
P6 is the one that matters, because it uses no escape hatch at all and a tenant kernel. A
sys_metadataobject row bound toapp.myappalready exists (authored once under the hatch, seeded, imported, or predating the gates). Boot order is the real one: the package registers its objects, thenloadMetaFromDbhydrates.loadMetaFromDb(protocol.ts:11753) callsregisterObject(row, row.package_id || 'sys_metadata')with no authorization gate, so this replacement re-happens on every boot, silently (errors: 0, no warning). The delete then reaches tier 3,isArtifactBackedisfalsebecause the overlay overwrote the very contributor that guard reads, andunregisterObjecttakes the whole entry.Consequence for a caller: a code package's object is off the data plane —
assertObjectRegisteredfails closed, so all CRUD on it 404s — until the process restarts. The tier-3 comment's "AND IT NEVER RETIRES A CODE-SHIPPED OBJECT" guard is blind here, and the receipt saysreset: true/ "it no longer exists" while the artifact default it claims to reset to is exactly what has just been unregistered.P4 — the card's literal claim, reproduced (predicted, confirmed)
Seed a composite artifact entry in
metadata['object']so tier 1 heals:Map healed, contributor list permanently overlay-era — exactly as filed. But: for an
object, that composite key is written only byMetadataFacade.register('object', …), i.e. #6725's dormant write side.engine.registerAppputs objects inobjectContributorsonly, andloadMetadataFromServiceexplicitly skipsobjectforregisterItem(objectql/src/plugin.ts:1965-1969). I had to seed it by hand; nothing in-tree writes it.2. Decidability — the answer is NO, and the reason is sharper than the gate stated
A re-contribution needs
(definition, packageId, namespace, ownership, priority). Measured availability at the seam, after the overlay has already replaced the contributor:packageIdapp.myapp; and replacement can only occur when the ids match, so it provably equals the packaged owner's idownershipown—registerObjectonly splices anowncontributornamespace""on the overlay contributor while the package registered with"myapp"(P5). Inert today only becausecomputeFQNis identity (registry.ts:61)priorityDEFAULT_OWNER_PRIORITY; a package registered at a custom priority has already lost itdefinitionThe definition is the load-bearing one, and the three places it might survive are each conditional:
registry.getPackage(pkgId).manifest.objects— retained (P5: the originalname, amount, packaged_onlyis still there), but only for manifest-installed packages. In P1/P3/P6getPackage('app.myapp')wasnull: objects registered throughregisterObjectdirectly (ingestReloadedObjects, several boot paths) leave no manifest record.metadata['object']entry — only written byMetadataFacade.register('object', …)writes where neither of its own object reads look #6725's dormant path (P4)._packageIdmay be absent, in which case an owner identity would have to be synthesised — the exact move fix(objectql): deleting anobjectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818's own TSDoc argues against.And the heal cannot tell which case it is in.
So the 06:49Z reasoning does not close the loop.
unregisterObject(name)needs only an address;registerObjectneeds four more values, three of which no longer exist when the heal runs. Reusing the name-addressed verb family answers how to remove, and there is no symmetric "re-contribute" verb it can be paired with, because the value to pass it is gone. The 01:26Z gate was right, and understated: the missing piece is not only "with whose priority" — it is the definition itself.3. Option set
A — restore from a recovered body inside the heal (what the card literally proposes)
In tiers 1/2, having found a packaged body, call
registerObject(body, contributor.packageId, '', 'own', DEFAULT_OWNER_PRIORITY).objectisMetadataFacade.register('object', …)writes where neither of its own object reads look #6725's dormant write path — nothing onmaincreates it. This would ship code whose triggering condition does not occur today.B — stop destroying it: the tenant overlay becomes its own contributor (producer-side)
applyObjectRegistryMutationandloadMetaFromDbstop registering the overlay as a competingown, and register it as a distinct, higher-precedence contributor instead. The packagedowncontributor is then never spliced out; "restoration" becomes removal of the overlay contributor — the same shape as #6818's verb, addressed by name plus provenance — and the packaged definition is restored by still being there.isArtifactBackedalso stops flipping, which re-arms both the tier-3 guard andsaveMetaItem's overlay gate.objectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995.mergeObjectDefinitionsmerges fields additively. Modelling the overlay as an extender would silently stop an overlay from removing a packaged field; modelling it as a secondownbreaksassertSingleOwnerPerObject. So it needs either a new ownership kind or an explicit replace-semantics contributor — an ADR-0029 change with authoring-visible consequences. That is the maintainer's call, not mine.C — narrow the tier-3 guard only (cheap safety fix, leaves the stale view)
In tier 3, decide on the contributor's package binding rather than on
isArtifactBacked: refuse tounregisterObjectwhen the owner contributor names a currently-installed package (measured:packageIdsurvives the overlay; the definition does not).record.packageIdfrom a snake_case row — always undefined, every object overlay registers under the 'sys_metadata' sentinel at boot #4636) from a package-shipped one — both carry a realpackage_id— so some genuinely deleted objects stay registered until restart. By the walk's own REGISTER WIDE / RETIRE NARROW argument that is the cheap direction: a surplus entry degrades to "listable but rowless"; a wrongly retired one 404s the whole data plane.D — do nothing
Self-heals at the next restart. Only defensible if the P3/P6 outage is filed and graded separately, because that is a live 404 rather than a stale read.
Recommendation: B, with C landed first as a separate, decision-light card if the outage should stop this week. B is the only option that serves a measured business scenario, is the only one sound long-term, and is the only one that makes the seam hard to get wrong; A buys the dormant leg only and carries exactly the failure mode everyone has been avoiding. I have not picked B's ownership/merge semantics — that is the maintainer decision this card is being escalated for.
4. Answers to the dispatch's stated assumptions
MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 coupling — partially confirmed, and it splits the card. The card's literal claim (P4, map healed / contributor stale) is reachable only throughMetadataFacade.register('object', …)writes where neither of its own object reads look #6725's write path, which is dormant in-tree. The damaging leg (P3/P6) is fully reachable today withoutMetadataFacade.register('object', …)writes where neither of its own object reads look #6725. So the two halves are separable and I did not widen intoMetadataFacade.register('object', …)writes where neither of its own object reads look #6725.objectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818's merged work — verified as the base (47a4e676is an ancestor of51f2bb8c3);unregisterObjectread in full atregistry.ts:1482. Its contract is address-only, which is the core of the finding above.MetadataFacade.register('object', …)writes where neither of its own object reads look #6725), wider in another (on the mainline leg the object is unregistered outright and CRUD 404s, rather than merely serving a stale schema). It does not close the card.5. Out-of-scope finding filed
objectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995 — anobjectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success (P2). Unassigned, unlabeled, left for triage.Reproduction
The probe is a single file built on the harness in
packages/objectql/src/protocol-delete-object-registry-heal.test.ts: register the packaged object withregistry.registerObject(packagedBody, 'app.myapp'), persist an overlay row for the same name with the samepackageId(hatch open once), replayrowsinto a fresh tenant-kernel harness with the hatch closed, runprotocol.loadMetaFromDb(), thenprotocol.deleteMetaItem({type:'object', name}), and readobjectContributors/registry.getObject/ acreateDatacall on each side. It was deliberately not committed — this card produced no PR.Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
PM: moved to
needs-user-decision(metadata seat, sessionsession_01W6bLax4KMrSfnE1ydFU8Dw). No PR, no speculative code, worktree removed — the decision-scoping dispatch did its job.It was filed and graded as an incomplete-invariant / stale-contributor gap. The measurement says otherwise. On a tenant kernel with no operator opt-in and no escape hatch, measured end-to-end on
origin/main@51f2bb8c3:That is a live data-plane 404 on an object the package still ships, until the next restart. The mechanism, which I verified in the source rather than taking on report:
package_idequals the packaged owner's id,registerObjectsplices the packaged contributor out (packages/objectql/src/registry.ts:1159). The packaged definition is destroyed at write time, not shadowed — there is no second copy in the registry.loadMetaFromDbre-does that replacement on every boot, with no authorization gate and no log.isArtifactBackedconsults the very contributor the overlay overwrote, so it answersfalse, the tier-3 limb fires, andobjectContributorsgoes empty.The card's literal claim (map healed, contributor stale) also reproduces — but only through the composite
metadata['object']key, which only #6725's dormant path writes. The damaging leg needs no #6725 at all.The decidability question: the answer is NO, and the 06:49Z assertion does not clear the 01:26Z gate
The promotion said "the anchor points are already named — reuse #6818's name-addressed registry API." Measured:
unregisterObjectis address-only, whileregisterObjectneeds four more values, three of which are gone before the heal runs.packageIdand ownership are recoverable at the seam; namespace and priority are not, and the definition body is not recoverable in general — it survives in manifest records only for manifest-installed packages (getPackage()returned null in the mainline shapes), in the map only via #6725, and in the MetadataService only for loader-sourced objects, where tier 1 returns before tier 2 ever runs.So the 01:26Z gate stands: this needs a ruling, not a verb.
The decision
packageIdat default namespace + priority. Costs: namespace and priority are guessed, extender merge order can shift, its tier-1 precondition isMetadataFacade.register('object', …)writes where neither of its own object reads look #6725's dormant path (nothing on main creates it), and it does nothing for the measured 404. Failure mode is exactly the silently-wrong registered schema both prior measurements warned about.applyObjectRegistryMutationandloadMetaFromDbregister the tenant overlay as a distinct contributor instead of a competingown, so the packagedownis never spliced out. Restoration then becomes removal of the overlay contributor — the same shape as fix(objectql): deleting anobjectreally unregisters it — a name-addressedSchemaRegistry.unregisterObject(#6808) #6818's verb, addressed by name plus provenance. Fixes the outage, re-arms the tier-3 guard andsaveMetaItem's overlay gate that the current shape silently disarms, and subsumes Anobjectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995. Cost — and this is the part that is genuinely yours to rule: an ADR-0029 contract change. Today an overlay replaces the whole body, whilemergeObjectDefinitionsmerges fields additively, so modelling the overlay as an extender would silently stop overlays from removing a packaged field; and a secondownbreaksassertSingleOwnerPerObject. It needs a new ownership kind, or an explicit replace-semantics contributor.isArtifactBacked(which does not). Stops the 404, leaves the stale-contributor gap. Cannot separate a package-bound runtime-authored object (loadMetaFromDb object branch readsrecord.packageIdfrom a snake_case row — always undefined, every object overlay registers under the 'sys_metadata' sentinel at boot #4636's Studio package workspace) from a package-shipped one, so some genuinely deleted objects stay registered until restart — which by the heal's own REGISTER WIDE / RETIRE NARROW argument is the cheap direction.Recommendation: B, with C landed first as a decision-light card if you want the outage stopped before the model question is settled. B is the only option serving a measured scenario; A's sole in-tree trigger is dormant. B is also ADR-0005's overlay model actually applied to objects (layers coexist, resolution decides) rather than a destructive in-place overwrite, whereas A is consumer-side reconstruction of a value the producer destroyed — a workaround under Prime Directive #5.
The dev explicitly did not pick the ownership/merge semantics, and was right not to: that is the ADR-0029 call, and picking it by taste is what both earlier measurements said would produce a silently wrong schema.
Two smaller results
MetadataFacade.register('object', …)writes where neither of its own object reads look #6725's write side settled first, expecting the three-card family to travel together. Measured no — the literal stale-view leg needsMetadataFacade.register('object', …)writes where neither of its own object reads look #6725 (onlyMetadataFacade.register('object', …)writes that composite key;engine.registerAppputs objects inobjectContributorsonly, andloadMetadataFromServiceexplicitly skips'object'), but the damaging leg is fully reachable without it. B/C can proceed alone;MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 governs only whether the tier-1 leg can ever be exercised. The dev split the card instead of widening into it.objectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995 filed (unassigned, unlabeled, for triage): anobjectoverlay over a packaged object owned by a different package is silently discarded —registerObjectthrows on the ownership rule, the throw is caught and warn-logged atprotocol.ts:7880, whilesaveMetaItemreports success.metadata['object']then serves the overlay whilegetObjectand all data CRUD keep serving the package body. B subsumes it.To the triage seat
This card was graded without the 404 leg visible. Please re-grade with the measurement above — a live data-plane 404 on a shipped object, reachable on a tenant kernel with no operator action, may warrant
target:v17.target:*is your seat's to set, not mine, so this is a request rather than a change. #6995 also needs its first grading anddomain:*routing.Generated by Claude Code
os-project-manager commentedon Aug 9, 2026
Maintainer ruling (2026-08-09): direction B approved in principle; C ships first as its own stop-the-bleed card; A rejected. This card now tracks B;
pm:queue.restoreArtifactRegistryViewstops deciding onisArtifactBacked(measured to flip false exactly when the overlay has destroyed the contributor it reads) and refuses tounregisterObjectwhen the owner contributor names a currently-installed package (packageIdmeasured to survive the overwrite). Stops the live P3/P6 outage — data-plane 404 on a shipped object's intact table — this week. Accepted cost per REGISTER WIDE / RETIRE NARROW: some genuinely deleted package-bound objects stay listable-but-rowless until restart.owncontributor — layers coexist, resolution decides,isArtifactBackedstops lying, and "restoration" degenerates to removing the overlay layer. The unresolved semantics (replace-semantics contributor vs a new ownership kind — today's overlay replaces the whole body while extender merge is additive) are designed in the amendment, not guessed here; the ADR PR is maintainer-merged per standing discipline, so the design lands on the maintainer's desk by construction. Anobjectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995 (write-side silent discard) is subsumed by B and is annotated accordingly.MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 leg, reconstructs a value the producer destroyed from guessed namespace/priority, and its failure mode is precisely the silently-wrong registered schema everyone measured to avoid.Also ruled by implication: the 06:49Z promotion's premise ("reuse the name-addressed API") is formally retracted — the measurement showed
unregisterObjectneeds an address while re-contribution needs four values that no longer exist; the 01:26Z gate was right.Maintainer directive (verbatim, covering this decision round): 「接受你的建议」. Recorded by PM session session_01LGRN2cSRfggfX9B2L83bQc. Veto window open — comment to overturn.
Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
Claim (round 8, new deliverable): PM loop (
domain:metadataseat, sticker #6367)Session:
session_01W6bLax4KMrSfnE1ydFU8DwBranch:
claude/issue-6853-adr-0029-overlay-contributor-layerWorktree:
objectstack-issue-6853-adrDomain:
domain:metadataRe-claiming under the 08:52Z ruling, which changed what this card delivers: direction B is approved in principle, and this card's deliverable is now the ADR-0029 amendment proposal — not an implementation. The stop-the-bleed half shipped separately as #7012, dispatched by this seat a few minutes ago.
Scope carried into the dispatch:
mergeObjectDefinitionsmerges fields additively, so "overlay as extender" would silently stop an overlay removing a packaged field, and a secondownbreaksassertSingleOwnerPerObject. The amendment has to choose between a replace-semantics contributor and a new ownership kind, and argue it.restoreArtifactRegistryView, and PR fix(metadata-protocol): refuse an org-scoped write of a type that has no per-org channel (#6190) #6973 is being rebased insaveMetaItem's gate region by another session. B's eventual implementation touchesapplyObjectRegistryMutationandloadMetaFromDb— that implementation is not this card. Writing it now would collide with 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 and skip the maintainer's design step.objectoverlay write over a packaged object is silently discarded byregisterObject's ownership rule whilesaveMetaItemreports success #6995 is subsumed by B per the ruling and must be addressed by the amendment's model rather than left as a loose end.docs/adr/**is maintainer-merged only — this seat will flip it to ready and will not enable auto-merge or queue it. The ruling notes this is by design, so the design question reaches the maintainer's desk by construction.Generated by Claude Code
os-zhuang commentedon Aug 9, 2026
os-dev report
Status: done — the ADR-0029 amendment is drafted and up as a DRAFT PR. PR #7087, branch
claude/issue-6853-adr-0029-overlay-contributor-layer, sessionsession_01W6bLax4KMrSfnE1ydFU8Dw. Design document only, per the 08:52Z ruling; no implementation, and the region locks (#7012's tier 3, PR #6973'ssaveMetaItemgate) were not touched.1. The semantics chosen, and why
The ruling's tension: today an overlay replaces the whole body, while
mergeObjectDefinitionsmerges fields additively — so "overlay as extender" would silently stop an overlay removing a packaged field, and "overlay as a secondown" breaksassertSingleOwnerPerObject.Chosen: a replace-semantics, NON-owning contributor kind (
overlay) — not a new ownership kind. Declared in the amendment as D9, with these clauses:base = overlay ?? own, then extenders fold on top exactly as today. This is bit-for-bit what the current splice already produces (today the overlay is the owner, and extenders fold over it), including the resolved body's_provenance: 'org'. The resolved schema does not move; only what the registry remembers does. That is the argument that made replace-semantics the cheap choice rather than the expensive one.assertSingleOwnerPerObjectkeeps counting exactly oneownwith no exemption clause — which matters because ADR-0028 D5/D6 depend on D3 being unconditional. One violation class is added: an orphan overlay (a layer with no owner), reachable by uninstalling the owning package.ext.priority ?? 200,engine.ts:2944), and a package must not be able to re-rank which layer is the base.computeFQNis untouched — it is identity today, the layer shares the owner's key, and no namespace dimension is introduced. The measured namespace loss on the owner contributor (P5:''where the package registered'myapp') is repaired by subtraction, because the owner contributor is no longer removed._provenance: 'org'included —isArtifactBackedonly stops lying ifgetArtifactItem's object branch (registry.ts:1727-1733) reads the owner's definition instead ofgetObject's merged result. Without this clause, B would fix the destruction and leave the predicate lying.ObjectOwnershipEnumhas zero runtime consumers, andown/extendare set at exactly three loader call sites (engine.ts:2920/2933/3147/3157and:2956); no author writes an ownership value. So the third kind adds no authoring surface — the "AI-written metadata cannot get this wrong" axis, structurally.ownalready exists for the name; yes →overlay, no →own(today's runtime-authored behaviour). The point of direction B in one sentence: the judgement moves to write time, where the packaged owner is one lookup away, instead of delete time, where it has been destroyed. The same discriminator is owed to the two metadata-service ingest paths (objectql/src/plugin.ts:688,:765) or the splice returns through a third door.The owning-kind alternative is rejected in the record: it grants authority the overlay measurably never uses (registers no namespace — both seams pass no namespace argument; does not decide package membership —
getAllObjectsmatches any contribution'spackageIdat:1307; owns no table), and it would make D3 conditional at everygetObjectOwnercall site.Honestly stated cost (D9.6). With the predicate honest,
saveMetaItemrefuses an overlay write to a packaged object withNOT_OVERRIDABLEevery time, not only the first, becauseobjectdeclaresallowOrgOverride: false. Today the first write is refused and, by destroying the evidence, admits every later write through theallowRuntimeCreatetier. This is the declared contract enforced consistently — but a deployment living in the post-first-write state will feel it, and the amendment says so with the migration note rather than burying it.2. #6995, handled by the model (not left as a loose end)
Because an overlay makes no ownership claim,
registerObject's "already owned by package X" throw is unreachable from the overlay seams, so the silent-discard-with-success-receipt cannot recur. D9.9 rules all three bindings of the row'spackage_idagainst the packaged owner's id:'sys_metadata'sentinel)saveMetaIteminstead of a success receipt; at boot the row is counted inloadMetaFromDb's per-recorderrors(which that seam already does today,protocol.ts:11706-11708), which is why #6995 is a write-path divergence and not a boot-path one.The last row carries a store asymmetry worth having on the record: the overlay-uniqueness index keys on
(type, name, organization_id, COALESCE(package_id, '')), sosys_metadatacan legitimately hold two active rows for one(type, name)bound to two packages — which forobjectthe registry can never represent, becausecomputeFQNis identity and there is exactly one entry per object name. Refusing the mis-bound row is what keeps the two stores in agreement.3. What was verified against the tree
Everything cited was re-read on
2f3e79351rather than carried over from the 08:12Z report's51f2bb8c3. Confirmed still true: the two hydration seams and their two-argument calls (protocol.ts:7897-7911,:11670-11673); the same-packageownsplice (registry.ts:1157-1160); base selection inresolveObject(:1217); additive-onlymergeObjectDefinitionswith no removal expression (:86-108, fields at:91); identitycomputeFQN(:61-63); theown-gatedprovisionPrimary/provisionSearchCompanion(:1116-1130); the merged-body read ingetArtifactItem's object branch (:1727-1733) andisTenantAuthored(:844-846);own-only counting inassertSingleOwnerPerObject(:1355-1379); the two-tier gate (protocol.ts:8460-8472) and tier-3 limb (:8259-8272);object'sallowOrgOverride: false, allowRuntimeCreate: true(metadata-plugin.zod.ts:628).Two things the re-read added to the report's picture, both now in the amendment:
provisionPrimary/provisionSearchCompanionare gated onownership === 'own'. Since the overlay body becomes the resolved base, that gate must become "is this a BASE layer" ornameFieldchanges on every overlaid object. It is in the blast-radius table with every other.ownershipread site.4. Answers to the dispatch's stated assumptions
## Amendment (date, #issue)sections, and they routinely carry new decisions, not just corrections (ADR-0073's amendment made user-less data ops REFUSED; ADR-0119's decided D1's two open caveats; ADR-0045's moved a gate onto a new key). ADR-0029 carried no amendment before this one, so the shape was taken from ADR-0045 / ADR-0119 / ADR-0005 ([docs] ADR-0005 的「Index migration」段落描述的是已不存在的机制:addSysMetadataOverlayIndex 已随 #6771 删除,段落还写着 project_id 与 partial 键 #6825): Status-line entry + end-of-document section, with the amended block (D3) left standing per Prime Directive [WIP] Add Chinese version of the documentation #13. A superseding ADR would be wrong here: D1-D8 (kernel decomposition, capability plugins owning their objects, the K-plan) are entirely intact, and D3's invariant is not even weakened — it is kept by refusing to make the overlay an owner.object:allowOrgOverride: false, so the whole scenario needs the operator hatch) and the three-layer read model D9 is applying to objects; the amendment references it throughout. I did not add an "Amended by" pointer to ADR-0005's header, because that line asserts acceptance and this amendment is a proposal until the maintainer merges it. If it is accepted, the ADR-0005 pointer is a one-line follow-up — flagging it here so it is not forgotten rather than pre-writing a claim that may not become true.scripts/adr-anchors.json— one entry added, and it is exactly the docs(adr-0005): amend the overlay-index section instead of rewriting it #6981 situation:packages/objectql/src/registry.tsimplements D3 (the splice at:1157-1160,assertSingleOwnerPerObjectat:1355, the extender guard at:1490), already cites ADR-0029 in prose, and was unanchored — which is how the splice could read as an ordinary re-registration convenience rather than as the ADR-0029 contract.packages/metadata-protocol/src/protocol.tsis deliberately not anchored to ADR-0029: its relationship there is the defect the amendment describes, not a realized decision, and it earns its anchor with the implementation.errors: 0boot, the namespace'', the 404) and cited rather than re-derived; the amendment's §9 says exactly which facts come from the 08:12Z run and which were re-read here.5. Changeset route
No changeset, and the tooling prescribes that:
pr-automation.yml's changeset gate has no docs-only path exemption, and route 2 is theskip-changesetlabel rather than an empty changeset file (an empty file is a real input tochangesets/action, the label is a gate-level exemption — #5292 / #5471). The label was applied by this seat immediately after the PR existed.6. CI — per-job conclusions
All 26 check runs on
746c47dare completed — no job leftin_progress, read per job rather than from the aggregate:check:adr-anchors,check:nul-bytes,check:doc-authoring)check-adr-links)skip-changesetlabel livepackages/**in this diff)Flag docs affected by code changes(docs-drift-check.yml) did not report at all, and that is correct rather than missing: itspathsfilter ispackages/**…scripts/docs-audit/**, and this diff touchesdocs/adr/**plusscripts/adr-anchors.jsononly, so the workflow never triggered and no check context was created.Labels, read back after the bots settled (the read is the proof, not the write):
["documentation", "skip-changeset"]—skip-changesetapplied by this seat seconds after the PR existed,documentationadded by Auto Label afterwards; neither write wiped the other.The
ADR maintainer approvalfailure is the designed pending human gate, not a defect. Its log says so verbatim: "ADR merges are reserved to the maintainer in person. Drafting this PR was fine and stays fine — only the MERGE is reserved. Green path: the maintainer (@hotlong) reviews and approves; the approval re-runs this check via thepull_request_reviewtrigger." Per the ruling that is the point of this card: the design lands on the maintainer's desk by construction. Not auto-merged, not queued, left as a draft for the PM.7. No reverse verification, and none is fabricated
This is prose. Prose has no test coverage, so there is no before-red/after-green limb to report and inventing one would be evidence-shaped fiction (PR #6981 is the precedent). What exists instead are the gate outputs actually run locally:
pnpm installwas deliberately skipped: the diff is one Markdown file and one JSON file, and every gate above is a zero-dependencynodescript — which is howcheck-links.ymlandadr-merge-approval.ymlrun them in CI. The worktree has been removed.8. What I could not verify
51f2bb8c3by the 08:12Z run and are cited, not reproduced — re-running the probe would have meant building the packages this card does not touch. Every source claim the amendment makes was re-read on2f3e79351; every behavioural claim is attributed.registry-single-owner.test.ts,registry-unregister-object.test.ts,protocol.delete-object-registry-unregister.test.ts) is argued from D9.2/D9.5 and stated in the amendment as a check the implementation card must confirm, not as a claim this record can make.Generated by Claude Code
docs(adr-0029): amend D3 — a tenant object overlay is its own contrib…