Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .changeset/meta-object-search-companion-agreement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
---
"@objectstack/objectql": patch
"@objectstack/metadata-protocol": patch
---

fix(meta): `/meta/object/:name` reports the `__search` companion column, agreeing with `GET /meta/object` (#8038)

The two `/meta` reads of an object answered the "does this object have a
`__search` column?" question two different ways, split cleanly by PROVENANCE.
Measured end-to-end on the showcase, booted from a compiled artifact with
`OS_SEARCH_PINYIN_ENABLED=true` (69 objects served):

- **22 package objects** carried the companion on `GET /meta/object` and were
served **without** it by `GET /meta/object/:name` — all 22 of them, and by
`?layers=true`'s `effective` layer too.
- **45 platform objects** carried it on both routes and always had.

(The two objects with no title-eligible field — `showcase_project_membership`
and `sys_session` — have no companion on either route, correctly.)

Nothing about an object caused this; where its by-name read was ANSWERED FROM
did. The companion is provisioned at the SchemaRegistry's
object-materialization seam, so `GET /meta/object` — composed from
`listItems('object')` — serves a materialized body. The by-name read consults
the `metadata` SERVICE first, and on a deployment booted from a compiled
artifact (`artifactSource`: every sealed/served runtime, and `objectstack
serve`) that service holds the author's DECLARATION, captured before
materialization. Platform objects are registered straight into the registry, so
their by-name read never meets that copy and agreed all along. Which side a
caller lands on is invisible in the response.

This is the third thing to arrive through this exact gap, and it is fixed the
way the second one was ruled: #7556 folded the missing `objectExtensions`, and
#6562 ruled (maintainer, 2026-08-08, Option B) that a `/meta` object read serves
the **effective runtime schema** and the minority path converges on the
registry-backed majority — that is what `governServedItem` already does for the
injected system columns (`created_at`, `owner_id`, `organization_id`). The
companion is the same kind of thing coming through the same door, so it
converges at the same read exits, from the same authority: the registry that
made the provisioning decision. It is deployment-gated
(`OS_SEARCH_PINYIN_ENABLED`, or an explicit `searchCompanion` option), and the
gate is read off that registry rather than re-derived from the environment, so
the pass and the decision cannot disagree.

**This is a payload change for every consumer of these routes.** Objects served
by the by-name read on an artifact-booted deployment now carry one additional
hidden field declaration — `__search` (`hidden`, `system`, `readonly`,
`searchable: false`) — where they previously did not, matching what the list
read has always served for the same object. Nothing is removed, and the
`?layers=true` `code` and `overlay` layers stay byte-verbatim: they are what the
package shipped and what the tenant customised, and the convergence deliberately
lands only on read exits and on `effective` (#6562 ruling constraint 1).

Unrelated to #7642, which strips `__search` from RECORD bodies on the data path.
That is row values; this is the schema description, where the companion's
presence is the documented shape — #7561 exists precisely because `/meta`
re-parses the served object body and the stamp had to be spec-valid there.

**Write path.** The read adds a real field declaration, so the write path takes
it back off again, exactly as #6562's `stripInjectedSystemColumns` does for the
injected columns: without it the ordinary GET → edit → PUT stored the platform's
own column as a tenant customisation. Measured on the runtime-created object
path — the write door type `object` has open by default — the stored row went
from `fields: [name]` to `fields: [__search, name]` on a single round-trip. The
strip is exact: only an entry byte-identical to what the provisioning seam would
stamp is removed, recomputed from that function rather than transcribed, so a
body carrying anything else under that name keeps it.
106 changes: 100 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4146,6 +4146,100 @@ export class ObjectStackProtocolImplementation implements
}
}

/**
* [#8038] The `__search` half of {@link governServedItem}'s presence
* convergence — the third stamp in the same family, and the one the
* module-level function cannot carry on its own.
*
* `applyInjectedSystemColumns` (#6562) converges the columns whose
* membership is a pure function of the document
* (`resolveInjectedSystemColumns`), so it needs nothing but the body. The
* search companion is DEPLOYMENT-gated: `SchemaRegistry` provisions it at
* the object-materialization seam only when its own `searchCompanion` flag
* is on, and that flag is `options.searchCompanion ??
* resolveSearchPinyinEnabled()` — a host may set it explicitly. So the
* answer has to come from the registry that made the decision, which is why
* this is a method here and a `provisionSearchCompanionOnto` there, exactly
* as {@link foldObjectExtendersFromRegistry} is a method here and a
* `foldObjectExtendersOnto` there (#7556).
*
* Applied AFTER `governServedItem` at each exit, because the registry
* provisions the companion after `applySystemFields` too: the source field
* is resolved by `resolveDisplayField` over the POST-injection field set,
* so injecting first is what makes this pass and the registry's own answer
* the same answer rather than two independent guesses.
*
* No-op for every type but `object`, and — via the registry — for a
* deployment with companions off, an object with no eligible display field,
* and a body that already carries the column (every registry-backed read,
* which is the majority path this converges the minority onto).
*/
private provisionSearchCompanionFromRegistry<T>(type: string, body: T): T {
if (canonicalMetaType(type) !== 'object') return body;
if (body === null || typeof body !== 'object') return body;
const registry = (this.engine as any)?.registry;
// Partial registry doubles in tests predate this method; a host that
// cannot answer the gate answers exactly as it did before.
if (!registry || typeof registry.provisionSearchCompanionOnto !== 'function') return body;
try {
return registry.provisionSearchCompanionOnto(body) as T;
} catch {
// A read over an in-memory flag and the body's own fields; a failure
// here must not turn a served schema into a 5xx.
return body;
}
}

/**
* {@link governServedItem} plus the deployment-gated half it cannot reach
* ({@link provisionSearchCompanionFromRegistry}) — the whole of what a
* `/meta` READ EXIT owes an object document. Every call site of the free
* function that is a read exit goes through this instead; the one call site
* that is not — `getMetaItemLayered`'s `code` / `overlay` layers, which are
* deliberately raw — never called it in the first place (#6562 ruling
* constraint 1, #7556's boundary).
*/
private governServedObject<T>(type: string, item: T): T {
return this.provisionSearchCompanionFromRegistry(type, governServedItem(type, item));
}

/**
* [#8038] The write-side counterpart of
* {@link provisionSearchCompanionFromRegistry}, owed for the reason
* {@link stripServedSystemColumns} is owed one field family over: the write
* path persists the request body verbatim (ADR-0005 §Validation), so a
* document this service's read added the companion to would otherwise be
* handed straight back and stored carrying it.
*
* Measured on the runtime-created object path — the write door type
* `object` has open by default, an artifact-backed object refusing the save
* outright with `NOT_OVERRIDABLE` — the stored row went from
* `fields: [name]` to `fields: [__search, name]` on a single GET → PUT
* before this was added.
*/
private stripSearchCompanionFromRegistry<T>(type: string, item: T): T {
if (canonicalMetaType(type) !== 'object') return item;
if (item === null || typeof item !== 'object') return item;
const registry = (this.engine as any)?.registry;
if (!registry || typeof registry.stripProvisionedSearchCompanionFrom !== 'function') return item;
try {
return registry.stripProvisionedSearchCompanionFrom(item) as T;
} catch {
// A read over the body's own fields; a failure here must not turn a
// save into a 5xx.
return item;
}
}

/**
* {@link stripServedSystemColumns} plus the companion half
* ({@link stripSearchCompanionFromRegistry}) — the whole of what the write
* path owes {@link governServedObject}.
*/
private stripServedObjectColumns<T>(type: string, item: T): T {
return this.stripSearchCompanionFromRegistry(type, stripServedSystemColumns(type, item));
}

/**
* [#5840] Read ONE item from the `metadata` service, keeping the ADR-0110
* D3 verdict instead of flattening it into `undefined`.
Expand DownExpand Up@@ -4610,7 +4704,7 @@ export class ObjectStackProtocolImplementation implements
// is the other exit a client reads field metadata from, and
// an overlay row wins over the (already-governed) registry
// entry in the merge above, so it carries the same lie.
return governServedItem(request.type, mergeArtifactProtection(it, a)) as any;
return this.governServedObject(request.type, mergeArtifactProtection(it, a)) as any;
}),
),
};
Expand DownExpand Up@@ -4674,7 +4768,7 @@ export class ObjectStackProtocolImplementation implements
return {
type: request.type,
name: request.name,
item: decorateMetadataItem(request.type, governServedItem(request.type, draftItem)),
item: decorateMetadataItem(request.type, this.governServedObject(request.type, draftItem)),
};
}
} catch (error) {
Expand DownExpand Up@@ -4768,7 +4862,7 @@ export class ObjectStackProtocolImplementation implements
return {
type: request.type,
name: request.name,
item: decorateMetadataItem(request.type, governServedItem(request.type, item)),
item: decorateMetadataItem(request.type, this.governServedObject(request.type, item)),
};
}

Expand DownExpand Up@@ -4893,7 +4987,7 @@ export class ObjectStackProtocolImplementation implements
const artifactItem = this.lookupArtifactItem(request.type, request.name, request.packageId);
let decorated = decorateMetadataItem(
request.type,
governServedItem(request.type, mergeArtifactProtection(item, artifactItem)),
this.governServedObject(request.type, mergeArtifactProtection(item, artifactItem)),
);
// ADR-0047 — list views additionally get reference-integrity
// diagnostics (userFilters/tabs fields must exist on the source
Expand DownExpand Up@@ -5185,7 +5279,7 @@ export class ObjectStackProtocolImplementation implements
const effectiveBase: unknown | null = overlay !== null
? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay)
: code;
const effective: unknown | null = governServedItem(request.type, effectiveBase);
const effective: unknown | null = this.governServedObject(request.type, effectiveBase);

const _diagnostics =
effective !== null && effective !== undefined
Expand DownExpand Up@@ -10026,7 +10120,7 @@ export class ObjectStackProtocolImplementation implements
// schema gate, the authoring gate and the persisted body all still see
// one document. See {@link stripServedSystemColumns} for why this is a
// separate strip from the decoration list and not another entry in it.
request.item = stripServedSystemColumns(request.type, request.item);
request.item = this.stripServedObjectColumns(request.type, request.item);
// Per-item lifecycle (ADR-0005 §"Drafts"). Default is `'publish'`
// (legacy semantics — save goes straight live) to keep callers
// that predate the draft/publish split working. Studio's
Expand Down
Loading
Loading