Skip to content

Registry heal restores the metadata-map view but never re-registers a packaged contributor definition an overlay save replaced #6853

Closed

Description

@os-zhuang

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.

Related

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.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 9, 2026
  2. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Findings-round routing repair: + domain:metadata. Routing only — the finding grade stands, no ownership taken.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  3. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    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.


    Generated by Claude Code

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 9, 2026
  5. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round 7 (domain:metadata seat, sticker #6367)

    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:

    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.


    Generated by Claude Code

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 9, 2026
  7. self-assigned this
    on Aug 9, 2026
  8. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    os-dev report

    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', …}):

    before: packageId=app.myapp ownership=own priority=100 provenance=package
            fields=[ … name, amount, packaged_only ]     getArtifactItem -> true
    after : packageId=app.myapp ownership=own priority=100 provenance=org
            fields=[ … name, overlay_only ]              getArtifactItem -> false
            warnings during save: []
    

    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.

    before hydration : provenance=package  fields=[ … name, amount, packaged_only ]
    loadMetaFromDb   : {"loaded":1,"errors":0,"invalid":0}      warnings: []
    after  hydration : provenance=org      fields=[ … name, overlay_only ]
                       isArtifactBacked -> false
    DELETE /meta/object/myapp_invoice
      -> {"success":true,"reset":true,"message":"Deleted object 'myapp_invoice' — it no longer exists."}
      -> warnings: []
      -> objectContributors: []          getObject: null
      -> data CRUD: OBJECT_NOT_FOUND / 404
    

    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 false because 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:

    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; 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).

    • Real business need: thin, and measured so. The only in-tree tier-1 precondition for an object is MetadataFacade.register('object', …) writes where neither of its own object reads look #6725's dormant write path — nothing on main creates it. This would ship code whose triggering condition does not occur today.
    • 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.

    • Real business need: strong and measured. It is the only option that fixes P3/P6 (a live data-plane 404 for a shipped object, reachable today with no operator opt-in), and it subsumes An object overlay write over a packaged object is silently discarded by registerObject's ownership rule while saveMetaItem reports success #6995.
    • 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).

    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

    1. 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 through MetadataFacade.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 without MetadataFacade.register('object', …) writes where neither of its own object reads look #6725. So the two halves are separable and I did not widen into MetadataFacade.register('object', …) writes where neither of its own object reads look #6725.
    2. fix(objectql): deleting an object really unregisters it — a name-addressed SchemaRegistry.unregisterObject (#6808) #6818's merged work — verified as the base (47a4e676 is an ancestor of 51f2bb8c3); unregisterObject read in full at registry.ts:1482. Its contract is address-only, which is the core of the finding above.
    3. 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.
    4. 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.

    5. Out-of-scope finding filed

    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 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.


    Generated by Claude Code

  9. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    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:

    1. When an overlay row's package_id equals the packaged owner's id, registerObject splices 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.
    2. loadMetaFromDb re-does that replacement on every boot, with no authorization gate and no log.
    3. 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

    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

    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 and domain:* routing.


    Generated by Claude Code

  10. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 9, 2026
  11. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    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.

    1. 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.
    2. 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.
    3. A (reconstruct inside the heal) rejected on all three axes: serves only the dormant 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 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.


    Generated by Claude Code

  12. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 9, 2026
  13. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    Claim (round 8, new deliverable): PM loop (domain:metadata seat, sticker #6367)

    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-6853-adr-0029-overlay-contributor-layer
    Worktree: objectstack-issue-6853-adr
    Domain: domain:metadata

    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:


    Generated by Claude Code

  14. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 9, 2026
  15. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    ContributorAuthor

    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, 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_OVERRIDABLE every 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:

    1. 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.
    2. 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

    1. 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.
    2. 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.
    3. 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.
    4. 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-changeset label 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" success
    Build Core · Build Docs · Test Core shards · Dogfood shards · Dogfood Verify CLI · Console Pin Gate · Temporal Conformance · duplicate Auto Label / Check Changeset / Check PR Size contexts 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


    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