Skip to content

An object overlay write over a packaged object is silently discarded by registerObject's ownership rule while saveMetaItem reports success #6995

Description

@os-zhuang

Out-of-scope finding measured while scoping #6853 (registry heal / map-vs-contributors split). Recorded, not claimed. Pre-existing.

Fact (measured on origin/main @ 51f2bb8c3)

applyObjectRegistryMutation (packages/metadata-protocol/src/protocol.ts:7870) mirrors an accepted object overlay write into the registry with:

this.engine.registry.registerItem(request.type, request.item, 'name');
try {
    this.engine.registry.registerObject(
        { ...(request.item as Record< string, unknown >), _provenance: 'org' } as any,
        request.packageId || 'sys_metadata',
    );
} catch (err: any) {
    console.warn(`[Protocol] registerObject failed for ${request.name}: ${err?.message ?? err}`);
}

(generics spaced so the GitHub body sanitizer does not eat them)

SchemaRegistry.registerObject (packages/objectql/src/registry.ts:1148) throws when an own contributor from a different package already holds the name. So whenever the row's package_id does not equal the packaged owner's package id — including the ordinary package-less row, which normalises to the sys_metadata sentinel — the second half of the write is thrown away, warn-logged, and the save still reports success.

The two registry views then disagree permanently:

  • metadata['object'][name] (written by registerItem) holds the overlay body, so GET /api/v1/meta/object/:name serves it;
  • objectContributors still holds the package body, and that is what registry.getObject resolves — the surface assertObjectRegistered and every data-CRUD dispatch reads.

Measured with the protocol-delete-object-registry-heal.test.ts harness (real SchemaRegistry, real SysMetadataRepository), a packaged myapp_invoice owned by app.myapp plus a package-less overlay save carrying only name + overlay_only:

[save warning] [Protocol] registerObject failed for myapp_invoice: Object "myapp_invoice" is already
               owned by package "app.myapp". Package "sys_metadata" cannot claim ownership.
[contributors] packageId=app.myapp ownership=own provenance=package
               fields=[ ... name, amount, packaged_only ]      <- the PACKAGE body, untouched
[metadata map] plain key "myapp_invoice" present               <- the OVERLAY body
[getObject]    fields=[ ... name, amount, packaged_only ]      <- data plane never saw the overlay

The save's own receipt reports success, and nothing in the response says the runtime discarded half of it.

Reachability

  • Save path: needs the documented operator escape hatch (OS_METADATA_WRITABLE=object) or an equivalent door, because both the protocol gate (protocol.ts:8434) and SysMetadataRepository.assertAllowed (sys-metadata-repository.ts:1037) refuse an overlay over an artifact-backed object otherwise (measured: NOT_OVERRIDABLE/403 on both tenant and control-plane kernels).
  • Boot path: loadMetaFromDb (protocol.ts:11753) makes the same registerObject call for every stored object row. There the throw is counted — errors++ plus a Failed to hydrate warning (protocol.ts:11789) — so the boot leg is at least honest about it; the save leg is not.

Dispositions worth pricing (no recommendation forced)

  1. Fail the save. Turn the swallowed throw into a named refusal (the overlay cannot claim ownership of a name another package owns) so declared = enforced and the caller learns the write will not take effect. Cost: a write that "worked" before now 4xx-es; needs an error code + wording decision.
  2. Register the overlay as a distinct contributor rather than as a competing own, so the ownership rule is never reached and both layers coexist. This subsumes the finding but is the same contributor-model contract change Registry heal restores the metadata-map view but never re-registers a packaged contributor definition an overlay save replaced #6853 is escalating — see that card's option set.
  3. Document the split and leave it. Cheapest; leaves a success receipt for a write the runtime ignores.

Related

Activity

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

    @os-project-manager
    Collaborator

    Triage (pass #2, 2026-08-09, registered on #6015): held as finding, routed domain:metadata — subsumed prospectively by the #6853-B ruling (maintainer, 2026-08-09: the tenant overlay becomes its own contributor layer, which removes this silent-discard seam structurally; recorded in that ruling's item 2). Restart condition: if the ADR-0029 amendment's design ends up NOT covering this write path, this card promotes on its own measurements; otherwise it closes when B lands.


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Closing — subsumed and implemented by ADR-0029 D9, landed as f16e54e1d (PR #7306, card #7277) at 2026-08-10T09:03:31Z.

    This card offered three dispositions and deliberately forced no recommendation. Disposition 2 is what shipped — "register the overlay as a distinct contributor rather than as a competing own, so the ownership rule is never reached and both layers coexist" — via the contributor model D9 ratified: an overlay is a third, non-owning overlay kind with replace semantics on the base layer, so registerObject's single-owner rule is never consulted for it and assertSingleOwnerPerObject stays literally unchanged.

    The three bindings this card's measurement exposed were each implemented as ruled, and pinned:

    • Same package — normal registration.
    • Package-less row (the sys_metadata sentinel) — now accepted. The old throw was an artefact of the overlay borrowing the own slot; with its own kind there is no ownership claim to reject. This is the ordinary case the card measured, and it no longer loses half the write.
    • Different package — refused loudly at the producer, with the newly minted OBJECT_OVERLAY_PACKAGE_MISMATCH (422), and counted in loadMetaFromDb's per-record errors at boot. That closes the specific asymmetry this card opened with: the boot leg was already honest (errors++ plus a hydrate warning) while the save leg swallowed the throw and returned success. Both legs now report.

    The permanent two-view disagreement the card measured — metadata['object'][name] serving the overlay body while objectContributors still resolved the package body, so the data plane never saw the overlay — cannot arise, because the overlay is a registered layer rather than a discarded write. D9's load-bearing property is that resolution is bit-for-bit what the old splice produced (_provenance: 'org' included), pinned directly by comparing a D9 registry against a pre-D9 one; only what the registry remembers changes.

    Verified on origin/main: the landing is a single-parent commit carrying packages/objectql/src/registry.ts, packages/metadata-protocol/src/protocol.ts, packages/objectql/src/plugin.ts, both new pin files, and the two packages/spec declarations, with D9's markers live in registry.ts.

    Not closed as a duplicate — this card's measurement is what established the failure was silent on the save leg, and #7306's #6995-labelled bindings are traceable to it.


    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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions