Skip to content

The /meta read exits materialize the FOLDED object, the registry materializes the BASE — so an extended title-less object gets a __search column the registry never provisioned #8376

Closed

Description

@os-zhuang

Filed by the #8268 dev agent, measured while enumerating the stamps that cross the /meta read-exit materialization seam. Unassigned. This is a position defect of that seam, not a missing stamp — which is why #8268 did not fix it and deliberately preserved the existing behaviour rather than re-spelling it.

Symptom

An object whose BASE layer has no title-eligible field, extended by an extend contributor that adds one, is served a __search companion column by both /meta reads while registry.getObject(name) has none:

registry.getObject(name).fields  => project, seats, nickname, ...      (no __search)
GET /meta/object/:name  .fields  => project, seats, nickname, __search, ...
GET /meta/object        .fields  => project, seats, nickname, __search, ...

The measurement

Reproduces on the absent host — no metadata service at all, so the read falls through to the registry. That is the tell: this is not the artifact-ingest seam #8038/#8268 are about. Both /meta reads disagree with the registry on an object the registry itself produced.

absent host, untitled base + extend contributor adding a text field:
  byName vs registry  diverge on keys: fields
  listed vs registry  diverge on keys: fields
  differing key: __search   byName: present   registry: absent

Control, same run: a base that does carry a title-eligible field agrees on every key with an extender present (diverge on keys: (none)), so the divergence tracks the base-vs-folded distinction specifically and not the presence of extenders.

Root cause

The two run the same provisioning at different points in the pipeline:

  • SchemaRegistry.registerObject materializes the BASE layer — the ADR-0079 gate is ownership === 'own' || 'overlay', and the comment there says it outright: "extensions must not redesignate the owner's title". resolveObject then folds extend contributors on afterwards and never re-materializes.
  • The /meta read exits apply the convergence at the EXIT, by which point foldObjectExtendersFromRegistry has already folded the extenders on. So provisionSearchCompanionOnto resolves its source field over the folded field set and finds the extension's text column.

So the registry answers "this object has no title, therefore no companion" and the read answers "this object has a title contributed by an extension, therefore a companion".

Why #8268 did not fix it

#8268's dispatch scoped the work to the read-exit stamps and explicitly forbade re-spelling the mechanism #8038 built. Relocating the convergence from the exit to the pre-fold position is exactly that re-spelling, and it has real blast radius: the OVERLAY list path depends on the exit-side convergence (meta-object-search-companion-agreement.test.ts pins that the list read converges an overlay row, and says a revert makes that case alone go red in the listed half). So it needs its own card.

#8268 did make sure it does not GROW the problem: its nameField convergence withholds a designation the registry itself declined, precisely so the title stamp does not acquire this defect too. materializeServedObjectOnto's docstring records that asymmetry and points here.

Which answer is right — worth ruling explicitly

Both readings are defensible and the fix differs:

  • The registry is right (materialize the base, extensions never contribute a title or a companion): then the read exits must materialize BEFORE the fold, and the __search column disappears from these objects.
  • The read is right (the resolved object genuinely has a searchable text column, so it should have a companion): then resolveObject must re-materialize after folding, and the registry — and therefore the driver's syncSchema, which creates the real column — gains one.

⚠️ The second option is schema-migration-bearing (ADR-0045): it would materialize a real DB column on objects that do not have one today. The first is not. That asymmetry probably decides it, but it should be decided rather than fallen into.

Where (anchored on function names, not line numbers)

  • packages/objectql/src/registry.ts — registerObject (materializes the base), resolveObject / foldExtendersOntoDefinition (folds afterwards, no re-materialization), materializeServedObjectOnto (the read-exit replay, and the docstring recording this gap).
  • packages/metadata-protocol/src/protocol.ts — foldObjectExtendersFromRegistry runs before governServedObject at each exit; that ordering is the defect.

Reproduction

  1. new SchemaRegistry({ searchCompanion: true }); register a junction object whose fields are only lookup + number as ownership: 'own'.
  2. Register a second contributor for the same name as ownership: 'extend' carrying one text field.
  3. Read GET /meta/object/:name (no metadata service needed) and compare with registry.getObject(name).
  4. The served body carries __search; the registry's does not.

Pinned as an EXPECTED divergence in packages/rest/src/meta-object-materialization-agreement.test.ts (MEASURES the companion over-provisioned on an extended title-less base), so a fix here turns that pin red and should update it in the same PR.

Dedup

GitHub search over open issues for companion provisioning / extender fold / registry disagreement found no twin (nearest: #3955, closed, migrate-CLI vs dev-runtime companion disagreement — a different pair of producers). nameField on this seam is #8268; the multi-tenant indexes stamp is its sibling finding.

Related

#8268 · #8038 (installed the exit-side convergence) · #7556 / #8027 (the extender fold at the read exits) · ADR-0029 D9 · ADR-0045 · ADR-0079


Generated by Claude Code

Activity

  1. added a commit that references this issue on Aug 13, 2026
  2. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    ContributorAuthor

    Decision card — routed to the maintainer, not ruled by the seat

    Labelled needs-user-decision. Recording why this is not a seat call and what the two branches actually cost, so it can be ruled without re-deriving anything.

    The divergence

    SchemaRegistry.registerObject materializes the base layer. resolveObject folds extend contributors on afterwards and never re-materializes. The /meta read exits then transform the already-folded document.

    So the two sides materialize at different points in the pipeline, and a title-less base that an extend contributor gives a text field to gets a __search column from both /meta reads and none from registry.getObject(). Same object, same host, same moment.

    It reproduces on a registry-only host, so this is not the artifact-ingestion seam that #8268 fixed — it is a distinct defect that has been live since #8038 and is unchanged by PR #8380. #8380 deliberately did not act on it, correctly: relocating that convergence is the re-spelling #8268 was told not to do, and the overlay list path depends on the current placement (#8038's own test shows a revert makes that case alone go red).

    Why the seat is not ruling it

    1. Branch 2 is schema-migration-bearing. It makes the driver's syncSchema create real __search columns on objects that have none today — an ADR-0045 additive migration against existing tenant data. Migrations against live tenant data are the maintainer's call; this seat has consistently routed them up rather than absorbing them.
    2. It is a semantic question about extension authority — may an extend contributor influence a designation the owner's base did not make? That is architecture, not implementation sequencing.

    The two branches

    1 — the registry is right 2 — the read is right
    Fix Move the read-exit convergence to before the extender fold resolveObject re-materializes after folding
    DB impact None — no new columns syncSchema creates real __search columns on objects that have none today (ADR-0045 additive migration)
    Semantics Extensions cannot redesignate the owner's title — matches registerObject's stated intent An extension's contribution can earn the object a search companion it did not have
    Risk The overlay list path depends on the current placement; relocating it needs care Migration against existing tenant data, and the surface only grows

    Recommendation on record (informed, not binding)

    The dev who measured it recommends branch 1: it is the non-migration-bearing option and it matches registerObject's stated intent that extensions must not redesignate the owner's title. I am recording that as an informed recommendation rather than adopting it, because adopting it is exactly the call I am saying is not mine.

    What is needed

    A ruling on which side is authoritative. Until then this stays needs-user-decision and is not dispatched; the divergence is pinned as an EXPECTED failure in PR #8380's new test, so whichever way it is ruled, the pin fails deliberately rather than drifting silently.


    Generated by Claude Code

  3. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage supplement — four-prong card-face block (required on every needs-user-decision card; the seat's decision comment above carries the full analysis, this serializes it):

    1. Platform long-term coherence: Branch 1 (registry is authoritative; materialize before the fold) shrinks the special case — one materialization point, extensions cannot redesignate the owner's title, matching registerObject's stated ADR-0079 intent. Branch 2 grows the surface: fold-then-re-materialize becomes a second materialization pass every consumer must reason about.
    2. Measured business pull: zero — no user report; found by seam enumeration on a synthetic host (title-less base + extending contributor). Nothing observed hitting the phantom __search column today.
    3. AI-agent error-resistance: Branch 1 is the closed answer — the served document equals what the registry provisioned, so an AI reading /meta cannot be told a column exists that syncSchema never created (today's shape is exactly the silent declared≠provisioned trap). Branch 2 repairs the same lie in the opposite direction but at the cost of implicit schema growth an author never declared.
    4. Startup scope discipline: Branch 1 is non-migration-bearing (no new columns, no ADR-0045 action); Branch 2 creates real DB columns on existing tenant data. Remove-the-divergence beats grow-the-schema.

    All four prongs point at branch 1, consistent with the recorded dev recommendation — but this card sits on the manual floor twice over (live-data migration shape on one branch; extension-authority semantics), so it stays in the inbox for the maintainer regardless of prong alignment.


    Generated by Claude Code

  4. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Maintainer ruling — Branch 1: the registry is authoritative

    Ruled in a live PM session, 2026-08-13, accepting the triage seat's four-prism recommendation in full (maintainer verbatim: 「接受你的全部建议」). Recorded by the triage seat as a ruling record.

    Ruling: branch 1. SchemaRegistry's base-layer materialization is the authoritative answer. The /meta read exits must materialize before the extender fold, so the served document stops carrying a __search companion the registry never provisioned and syncSchema never created. Extensions do not redesignate the owner's title — matching registerObject's stated ADR-0079 intent.

    ⛔ Branch 2 is rejected, on the asymmetry the card itself identified: it is schema-migration-bearing (ADR-0045 additive columns against existing tenant data) and it repairs a declared≠provisioned lie by growing the schema instead of removing the divergence.

    Prism basis: single materialization point (coherence); zero measured pull, so the cheaper branch wins on tie-break; a served document that equals what was provisioned is the closed answer an AI author cannot be misled by; and no migration beats a migration.

    Binding implementation constraints for the metadata seat:

    • The OVERLAY list path depends on the current exit-side placement — the pin asserting the list read converges an overlay row must stay green. If relocation cannot preserve it, stop and report the fork; ⛔ do not trade one divergence for another.
    • The EXPECTED-divergence pin (meta-object-materialization-agreement.test.ts, the "companion over-provisioned on an extended title-less base" case) turns red by design — update it in the same PR, and state in the PR body that its inversion is the deliverable's proof.
    • nameField's deliberate withhold asymmetry (recorded in materializeServedObjectOnto's docstring, pointing here) should be re-read at implementation time: once this lands, that docstring's note about the gap needs updating too.

    State: needs-user-decision → pm:queue. Blocked-by: none (checked live — the referenced sibling PR has landed).


    Generated by Claude Code

  5. self-assigned this
    on Aug 14, 2026
  6. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor

    Claim — metadata seat, implementing the 2026-08-13 ruling (branch 1)

    • Session: session_012WMpuAfA2KSdDjGF6tm1bH
    • Branch: claude/issue-8376-materialize-before-fold
    • Worktree: ../objectstack-issue-8376

    packages/metadata-protocol/src/protocol.ts region declared (file is capped at 3 concurrent editors): the /meta object read-exit materialization seam — foldObjectExtendersFromRegistry / governServedObject / materializeServedObject and their call sites at the byName, listed and effective-schema exits. Not touching publishPackageDrafts (held by the #8400 seat).

    Also expecting to touch packages/objectql/src/registry.ts (materializeServedObjectOnto's docstring, per the ruling's third constraint) and the two packages/rest/src/meta-object-*-agreement.test.ts pins.

    Working to the ruling as written: materialize before the fold; the overlay list pin must stay green or I stop and report the fork; the EXPECTED-divergence pin inverts in the same PR as the deliverable's proof.


    Generated by Claude Code


    Generated by Claude Code

  7. os-zhuang commented on Aug 14, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 8376,
      "status": "done",
      "branch": "claude/issue-8376-materialize-before-fold",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/8614",
      "premise_still_valid": true,
      "summary": "Implements the 2026-08-13 ruling, branch 1. The /meta read-exit replay held an already-folded object and re-decided every document-sensitive materialization stamp over it, so a title-less base that an extend contributor gives a text field to was served a __search column the registry never provisioned. The seam now defers each such stamp to the verdict the registry reached over the BASE at registration, generalising the nameField withhold #8268 installed for one stamp into the seam's one rule. IMPORTANT DEVIATION IN MECHANISM, NOT IN OUTCOME: the ruling's literal 'move the convergence before the fold' is not reachable as a call-site reordering, and I measured why rather than assuming. On the card's own reproduction host (registry-only) no protocol-side fold runs at all — resolveObject folded the body inside the registry before the protocol saw it — so there is no exit-side call to reorder; and the exit cannot un-fold, because subtractExtenderContributions reaches validations/indexes only while fields is a key-keyed spread with no inverse (an extender may replace a base field under the same key). Both facts measured with a standalone probe. The ruling's stated outcome is delivered exactly: the served document stops carrying a companion the registry never provisioned, with no schema migration and no object gaining a column. No ledger grew; no call site moved.",
      "tests": "Baseline on origin/main: rest 114 files / 1881 tests passed (both agreement suites green, the #8376 case pinned as an EXPECTED divergence). Post-fix, post-merge with origin/main: objectql 202 files / 3559 tests passed; metadata-protocol 86 files / 1263 tests passed; rest 115 files / 1896 tests passed. Typecheck green for all three. CONSTRAINT 1 MEASURED BY NAME in a verbose run: '✓ agrees on an OVERLAY-backed host — the third link, and the only one the LIST exit converges' — the overlay list pin survived; no fork to report. Four ablations, each predicted before running. A1 revert the whole registry change: predicted 1 red (the inversion), measured 1 failed / 17 passed, 'expected [...] to not include __search'. A2 disable only the companion withhold: predicted 1 red, measured 1 failed / 17 passed. A3 disable only the primary withhold: predicted 2 red incl. the inversion failing on its divergingKeys line rather than its companion lines, measured 2 failed / 16 passed with \"expected 'nickname' to be undefined\" and \"expected ['nameField'] to deeply equal []\" — prediction matched including the reason. A4 neutralise the exit-side convergence entirely: predicted broad artifact-host reds plus the OVERLAY case red in its listed half plus the inversion staying GREEN; measured 9 failed / 9 passed, overlay red exactly at expect(host.listed).toContain(SEARCH_COMPANION), inversion green — direction right but I UNDER-COUNTED (predicted 6-7, measured 9): two absence-asserting cases also carry a whole-document divergingKeys assertion which moves when nameField is lost. Gates: union re-derived from the final diff with scripts/pm/dispatch-gates.mjs and re-run after merging origin/main — check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:objectui-changeset, check:query-options-erasure, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, check-cross-package-test-inputs, check-engine-split-ratio all pass; check:type-check-debt passes in both --self-test and --re-measure (the latter only after building the full workspace closure, which the script correctly refuses to run without). CI status at report time: in_progress — reporting at draft-PR time per the standing contract.",
      "open_questions": [
        {
          "question": "The ruling's wording is positional ('the read exits must materialize BEFORE the extender fold'). That is not implementable as a call-site move — measured: the reproduction host runs no protocol-side fold, and `fields` has no inverse to un-fold with. I delivered the ruling's stated OUTCOME by making the seam defer each document-sensitive stamp to the registry's own pre-fold verdict. Does the maintainer want to confirm this reading before the PR is enqueued?",
          "options": [
            "A — accept as landed: the registry is authoritative, so taking the pre-fold verdict from the registry IS the ruling's semantics; the mechanism differs from the wording only because the wording assumed a call site that does not exist on the defect's own host.",
            "B — require a literal repositioning: this would mean changing what the registry hands the read path (serving a base layer plus a separate fold at the exit) rather than touching the exits, a much wider change across every /meta consumer, and it would put the overlay list pin at risk — the exact fork the ruling told me to stop on."
          ],
          "recommendation": "A. B trades one divergence for another and is the blast radius #8268 was told not to take on; A leaves every call site untouched, which is why the overlay list pin survived structurally rather than by luck. The PR body states the deviation openly rather than presenting the fix as a repositioning."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions