Skip to content

Commit 5e247fd

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): a /meta object read serves the effective runtime schema, whichever layer answered (#6562) (#6811)
* wip(6562): shared injected-system-column table + read-exit injection/write strip * fix(metadata-protocol): a /meta object read serves the effective runtime schema (#6562) * docs: name the filed follow-up (#6810) at the residual-divergence pin --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4fedb11 commit 5e247fd

7 files changed

Lines changed: 1274 additions & 98 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(metadata-protocol): a `/meta` object read serves the effective runtime schema, whichever layer answered (#6562)
8+
9+
`GET /api/v1/meta/object/:name` answered a **different set of fields** depending
10+
on which link of its resolution chain produced the answer, for the same object:
11+
12+
- **registry-backed** → the schema AFTER `applySystemFields`, so it carried the
13+
injected system columns — `created_at`, `created_by`, `updated_at`,
14+
`updated_by`, `organization_id`, `owner_id`, `owning_business_unit_id` — even
15+
when the author declared none of them;
16+
- **overlay-backed** (a `sys_metadata` customization row, or a MetadataService
17+
body) → the stored document VERBATIM, so every one of those columns was simply
18+
absent.
19+
20+
Whether an object carries an overlay is invisible to the caller, so the same
21+
request reported the platform's own columns or not, and nothing in the response
22+
said which had happened. `/meta` is the machine-readable contract clients and AI
23+
authors code against: an author reading an overlay-backed object saw no
24+
`created_at` / `owner_id` / `organization_id` and reasonably concluded the
25+
columns do not exist — while every one of them is real in the database,
26+
filterable, orderable, and enforced read-only on write.
27+
28+
**Every `/meta` object read exit now serves the effective schema.** The
29+
single-item read, the list, the cached/ETag branch, both draft reads and the
30+
layered read's `effective` layer all report the injected columns, with the same
31+
`readonly` / `system` markers the engine enforces (`owner_id` stays
32+
`readonly: false` — ownership is transferable). This is the presence half of the
33+
seam #4513 closed the value half of.
34+
35+
Three things deliberately did **not** change:
36+
37+
- **`?layers=1`'s `overlay` layer stays byte-verbatim.** Injection happens at the
38+
read exits only, so Studio's "what you customised" diff never shows a column
39+
nobody wrote. Only `effective` is injected.
40+
- **A `GET``PUT` round-trip still persists a byte-identical body** (#4326).
41+
The write path gained the strip counterpart: a field byte-identical to the
42+
platform's own definition is removed again on save, so a served document handed
43+
straight back stores exactly what it stored before — same checksum, same
44+
history diff. A declared `owner_id` carrying the author's own label is *not*
45+
the platform's definition and survives untouched.
46+
- **A declared system column stays the author's.** Injection only ever adds a
47+
column nobody declared; it never rewrites one that was.
48+
49+
Which columns an object carries is `resolveInjectedSystemColumns`
50+
(`@objectstack/spec/data`, #5378) — the same derivation `applySystemFields`
51+
consumes — so every opt-out (`systemFields: false`, `managedBy: 'better-auth'`,
52+
`systemFields.audit`/`.tenant`, `tenancy.enabled: false`, the per-tier
53+
`ownership` table, the `sys_*` namespace) is answered in one place and re-derived
54+
in none. **What** each column looks like moves to `@objectstack/metadata-core`
55+
(`AUDIT_FIELD_DEFS` and the three tenancy/ownership anchors, re-exported from
56+
`@objectstack/objectql` so the symbols still resolve there) — the same relocation,
57+
for the same dependency cycle, as the audit-governance table in #4513:
58+
`@objectstack/objectql` depends on `@objectstack/metadata-protocol`, so the read
59+
path could not import the definitions from the registry that provisions them.
60+
One table now feeds the injection pass and the read exits, so they cannot drift.
61+
62+
One key is deliberately not carried onto a served document: `organization_id`'s
63+
`indexed`. It is not a `FieldSchema` key — removed in the 16.x line (#2377,
64+
ADR-0049) and rejected by name by the strict schema — and its only consumer is
65+
`driver-mongodb`'s schema builder, which reads the registered schema and never a
66+
served document. It stays at the injection site; that the registry-backed read
67+
answers `_diagnostics: { valid: false }` because of it is filed as #6810.

packages/metadata-core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export * from './engine-update-dispatch.js';
3636
// reporting two.
3737
export * from './audit-field-governance.js';
3838

39+
// [#6562] The injected-system-column DEFINITION table and the served-document
40+
// injection/strip pair built on it, sunk here by the same criterion and for the
41+
// same cycle as the governance table above. `resolveInjectedSystemColumns`
42+
// (spec, #5378) says WHICH columns an object carries; this says WHAT each one
43+
// looks like — the half that used to exist only inside `applySystemFields`, one
44+
// import away from every `/meta` read exit and unreachable from all of them.
45+
// `@objectstack/objectql` now reads this table instead of its own literals.
46+
export * from './injected-system-columns.js';
47+
3948
// [ADR-0106 / #3682] The metadata-plane FLS projection — one masking function
4049
// and one fingerprint, shared by every object-schema exit in
4150
// `@objectstack/rest` and `@objectstack/runtime`. Sunk here by the same
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The **one** table of injected-system-column DEFINITIONS, and the served-document
5+
* injection / strip pair built on it (objectstack#6562, ruled Option B).
6+
*
7+
* ## The split this completes
8+
*
9+
* `resolveInjectedSystemColumns` (`@objectstack/spec/data`, #5378) is the one
10+
* answer to *"WHICH columns does the platform provision on THIS object without
11+
* the author declaring them?"*. It deliberately owns only the names — #3786's
12+
* split leaves *"WHAT does each one look like?"* to the runtime. Until now the
13+
* only copy of that second half lived inside `applySystemFields`
14+
* (`@objectstack/objectql`), reachable only by running the registry.
15+
*
16+
* That is the same wall #4513 hit and recorded one file over
17+
* ({@link applyAuditFieldGovernance}): `@objectstack/objectql` **depends on**
18+
* `@objectstack/metadata-protocol`, so the `/meta` read path cannot import from
19+
* the registry that owns the answer, and the reverse import closes a cycle turbo
20+
* rejects outright. The honest way out is the one this package already carries
21+
* twice — sink the contract into a package **both** sides depend on. This
22+
* package's own dependencies are `{ @objectstack/spec, zod }`, so there is no
23+
* new edge and no new cycle. `applySystemFields` now reads this table instead of
24+
* its own literals; the read path reads it too, and the two cannot drift because
25+
* there is nothing left for them to disagree about.
26+
*
27+
* ## Why a `/meta` read needs it at all (#6562)
28+
*
29+
* `GET /api/v1/meta/object/:name` answered a **different set of fields**
30+
* depending on which link of its resolution chain produced the answer:
31+
*
32+
* - registry-backed → the schema AFTER `applySystemFields`, carrying
33+
* `created_at` / `created_by` / `updated_at` / `updated_by` /
34+
* `organization_id` / `owner_id` / `owning_business_unit_id` even when the
35+
* author declared none of them;
36+
* - overlay-backed (a `sys_metadata` row, or a MetadataService body) → the
37+
* stored document VERBATIM, so every one of those columns was simply absent.
38+
*
39+
* Whether an object carries an overlay is invisible to the caller, so the same
40+
* request reported the platform's own columns or not, and nothing said which had
41+
* happened. An author reading the overlay-backed answer concludes the columns do
42+
* not exist — while every one of them is real in the database, filterable,
43+
* orderable and enforced read-only on write. The maintainer's ruling
44+
* (2026-08-08) is Option B: the read serves the EFFECTIVE runtime schema, and
45+
* the overlay-backed minority path converges on the registry-backed majority.
46+
*
47+
* ## The one key this table deliberately does NOT carry: `indexed`
48+
*
49+
* `applySystemFields` stamps `indexed: <multiTenant>` onto its `organization_id`
50+
* definition, for the MongoDB driver's schema builder (the only consumer;
51+
* `driver-mongodb/src/mongodb-schema.ts`). `indexed` is **not a `FieldSchema`
52+
* key** — it was removed in the 16.x line (#2377, ADR-0049) and `FieldSchema` is
53+
* `strictObject`, so an object document carrying it is rejected BY NAME:
54+
*
55+
* ```
56+
* Unrecognized key(s) on this field: `indexed`.
57+
* • never a FieldSchema key; a field-level index flag built no index (#2377).
58+
* ```
59+
*
60+
* Measured on `origin/main` (2026-08-08): a registry-backed `/meta` object read
61+
* therefore already answers `_diagnostics: { valid: false }` on exactly that
62+
* key, in BOTH multiTenant modes — filed as #6810, and deliberately not
63+
* inherited here. Converging the overlay-backed exit onto a key the object
64+
* schema refuses would spread that defect rather than close #6562's; the field
65+
* SET and every spec-authorable key converge, and the DDL hint stays where the
66+
* DDL is. `multiTenant` is also the *only* thing that key depends on, which is
67+
* why nothing in this module takes a `multiTenant` input: per
68+
* `resolveInjectedSystemColumns`' own measurement, the flag changes whether
69+
* `organization_id` is INDEXED, never whether it EXISTS.
70+
*/
71+
72+
import {
73+
AUDIT_PROVENANCE_FIELDS,
74+
resolveInjectedSystemColumns,
75+
type AuditProvenanceField,
76+
} from '@objectstack/spec/data';
77+
import { SystemFieldName } from '@objectstack/spec/system';
78+
79+
/**
80+
* Column definitions for the audit-provenance family, keyed by the spec's
81+
* {@link AUDIT_PROVENANCE_FIELDS} tuple — the canonical declaration of WHICH
82+
* columns exist (#3786). This table owns only WHAT each column looks like.
83+
*
84+
* The `satisfies` clause is the sync mechanism: a name added to the spec tuple
85+
* without a definition here — or a definition for a name the spec dropped — is
86+
* a compile error, not a silently diverging copy. Same discipline as the spec's
87+
* `APPROVER_VALUE_BINDINGS`.
88+
*
89+
* Moved here from `@objectstack/objectql`'s registry by #6562; see the module
90+
* header for why, and {@link AUDIT_FIELD_GOVERNANCE} for the subset of these
91+
* keys that is forced over a *declared* audit field rather than merely injected
92+
* in its absence.
93+
*/
94+
export const AUDIT_FIELD_DEFS = {
95+
created_at: {
96+
type: 'datetime',
97+
label: 'Created At',
98+
required: false,
99+
readonly: true,
100+
system: true,
101+
description: 'Timestamp when the record was created (auto-populated by the driver).',
102+
},
103+
created_by: {
104+
type: 'lookup',
105+
reference: 'sys_user',
106+
label: 'Created By',
107+
required: false,
108+
readonly: true,
109+
system: true,
110+
description: 'User who created the record (populated when an authenticated session is present).',
111+
},
112+
updated_at: {
113+
type: 'datetime',
114+
label: 'Last Modified At',
115+
required: false,
116+
readonly: true,
117+
system: true,
118+
description: 'Timestamp of the most recent modification (auto-populated by the driver).',
119+
},
120+
updated_by: {
121+
type: 'lookup',
122+
reference: 'sys_user',
123+
label: 'Last Modified By',
124+
required: false,
125+
readonly: true,
126+
system: true,
127+
description: 'User who last modified the record (populated when an authenticated session is present).',
128+
},
129+
} satisfies Record<AuditProvenanceField, Record<string, unknown>>;
130+
131+
/**
132+
* `organization_id` — THE tenant scope anchor, in its **authorable** shape.
133+
*
134+
* ⚠️ `applySystemFields` spreads `indexed: opts.multiTenant` on top of this when
135+
* it provisions the physical column; see the module header for why that key
136+
* lives at the injection site and never in a served document.
137+
*/
138+
export const TENANT_SCOPE_FIELD_DEF: Readonly<Record<string, unknown>> = {
139+
type: 'lookup',
140+
reference: 'sys_organization',
141+
label: 'Organization',
142+
required: false,
143+
hidden: true,
144+
readonly: true,
145+
system: true,
146+
description:
147+
'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).',
148+
};
149+
150+
/**
151+
* `owner_id` — the canonical reassignable owner. `system: true` marks it
152+
* platform-provided (so tooling/migrations recognise it), but — unlike the audit
153+
* `*_by` lookups — it is NOT `readonly`: ownership is transferable, so it stays
154+
* editable in forms and assignable via the API. SecurityPlugin auto-stamps it to
155+
* the acting user on insert when left NULL.
156+
*/
157+
export const OWNER_FIELD_DEF: Readonly<Record<string, unknown>> = {
158+
type: 'lookup',
159+
reference: 'sys_user',
160+
label: 'Owner',
161+
required: false,
162+
readonly: false,
163+
system: true,
164+
description:
165+
'Record owner (auto-stamped to the creating user on insert; reassignable). ' +
166+
'Drives owner-scoped views, reports and notifications.',
167+
};
168+
169+
/**
170+
* [ADR-0117 D1] `owning_business_unit_id` — record-level business-unit
171+
* ownership. Shaped after `organization_id` (a server-stamped scope anchor), NOT
172+
* after `owner_id` (a user-assignable business field). The full reasoning for
173+
* each of `readonly` / `hidden` / `required` — and for why the shape presumes
174+
* nothing about the still-unruled D2 policy — stays at the injection site in
175+
* `@objectstack/objectql`'s `applySystemFields`, which is where an author of the
176+
* stamping middleware will be reading.
177+
*/
178+
export const OWNING_BUSINESS_UNIT_FIELD_DEF: Readonly<Record<string, unknown>> = {
179+
type: 'lookup',
180+
reference: 'sys_business_unit',
181+
label: 'Owning Business Unit',
182+
required: false,
183+
hidden: true,
184+
readonly: true,
185+
system: true,
186+
description:
187+
'Record-level business-unit ownership (ADR-0117 D1). Server-stamped scope anchor; ' +
188+
'NULL until the stamping middleware lands.',
189+
};
190+
191+
/**
192+
* The injected columns THIS object carries, as `name -> definition`.
193+
*
194+
* Gated entirely by {@link resolveInjectedSystemColumns} — every opt-out row
195+
* (`systemFields: false`, `managedBy: 'better-auth'`, `systemFields.audit:
196+
* false`, `tenancy.enabled: false`, the per-tier `ownership` table) is answered
197+
* there and re-derived nowhere. `id` is deliberately absent although the plan
198+
* reports it: the primary key is provisioned by the DRIVER
199+
* (`table.string('id').primary()`), not by the injection pass, so no object
200+
* document declares it and neither exit serves it.
201+
*
202+
* Tolerant of bare / un-parsed metadata records, the same contract the plan
203+
* itself carries.
204+
*/
205+
export function injectedSystemColumnDefs(def: unknown): Record<string, Readonly<Record<string, unknown>>> {
206+
const plan = resolveInjectedSystemColumns(def);
207+
const defs: Record<string, Readonly<Record<string, unknown>>> = {};
208+
if (plan.tenant) defs[SystemFieldName.ORGANIZATION_ID] = TENANT_SCOPE_FIELD_DEF;
209+
if (plan.audit) for (const name of AUDIT_PROVENANCE_FIELDS) defs[name] = AUDIT_FIELD_DEFS[name];
210+
if (plan.owner) defs[SystemFieldName.OWNER_ID] = OWNER_FIELD_DEF;
211+
if (plan.owningBusinessUnit) defs[SystemFieldName.OWNING_BUSINESS_UNIT_ID] = OWNING_BUSINESS_UNIT_FIELD_DEF;
212+
return defs;
213+
}
214+
215+
/**
216+
* Is this field definition byte-for-byte the platform's own — i.e. a column the
217+
* INJECTION put there, not something the author wrote?
218+
*
219+
* Shallow by construction: every value in the tables above is a primitive, so a
220+
* key-count check plus strict per-key equality is exact. A nested or extra key
221+
* therefore fails the comparison, and failure means "the author's field" — the
222+
* conservative direction, since {@link stripInjectedSystemColumns} only ever
223+
* removes what matches. A declared `owner_id` carrying the author's own label
224+
* survives; one that happens to be identical to the platform definition is
225+
* removed and re-injected identically, which is a no-op by inspection.
226+
*/
227+
function isInjectedDefinition(value: unknown, def: Readonly<Record<string, unknown>>): boolean {
228+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
229+
const rec = value as Record<string, unknown>;
230+
const keys = Object.keys(rec);
231+
if (keys.length !== Object.keys(def).length) return false;
232+
for (const key of keys) if (rec[key] !== def[key]) return false;
233+
return true;
234+
}
235+
236+
/** The `fields` record of a metadata document, or `undefined` when it has none. */
237+
function fieldsOf(doc: unknown): Record<string, unknown> | undefined {
238+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return undefined;
239+
const fields = (doc as Record<string, unknown>).fields;
240+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return undefined;
241+
return fields as Record<string, unknown>;
242+
}
243+
244+
/**
245+
* Add every injected system column the object carries but does not declare, so a
246+
* served object document reports the EFFECTIVE runtime schema (#6562).
247+
*
248+
* The merge direction is `applySystemFields`': injected definitions **lose** to
249+
* a declared field of the same name, because a declared `owner_id` is the
250+
* author's field and the registry lets it win. (The audit family's *governance*
251+
* — the keys that decide who may write it — is the other half, and stays with
252+
* {@link applyAuditFieldGovernance}: this function only adds absent columns, it
253+
* never rewrites a declared one.)
254+
*
255+
* A document with no `fields` record is returned untouched, deliberately: the
256+
* write-side {@link stripInjectedSystemColumns} could not tell an emptied
257+
* `fields: {}` from one that was never there, and the #4326 byte-identical
258+
* round-trip invariant is what that symmetry protects.
259+
*
260+
* Returns the **same reference** when nothing needed adding, so the
261+
* registry-sourced path (already injected at registration) pays a comparison and
262+
* no copy. Pure and total — any record may be handed to it.
263+
*/
264+
export function applyInjectedSystemColumns<T>(doc: T): T {
265+
const declared = fieldsOf(doc);
266+
if (declared === undefined) return doc;
267+
268+
let additions: Record<string, unknown> | undefined;
269+
for (const [name, def] of Object.entries(injectedSystemColumnDefs(doc))) {
270+
if (declared[name] !== undefined) continue;
271+
additions ??= {};
272+
additions[name] = { ...def };
273+
}
274+
if (additions === undefined) return doc;
275+
276+
return {
277+
...(doc as unknown as Record<string, unknown>),
278+
fields: { ...additions, ...declared },
279+
} as unknown as T;
280+
}
281+
282+
/**
283+
* The write-side counterpart of {@link applyInjectedSystemColumns}: remove the
284+
* injected-but-undeclared columns a served document picked up on its way out, so
285+
* the standard Studio GET → edit → PUT round-trip still persists a
286+
* **byte-identical** body (#4326).
287+
*
288+
* Same discipline, and the same reason, as `stripReadDecorations`
289+
* (`@objectstack/spec/kernel`): the write path persists the request body verbatim
290+
* by design (ADR-0005 §Validation), so anything the READ adds must be removed
291+
* again on the way in or it is baked into `sys_metadata.metadata`, into its
292+
* checksum, and into every history diff. It is not the same *list*, though, and
293+
* must not be folded into that one — a read decoration is derived diagnostics
294+
* that no schema accepts, whereas these are real, spec-valid field declarations
295+
* an author may legitimately write. Hence the exactness of
296+
* {@link isInjectedDefinition}: only a field identical to the platform's own is
297+
* removed.
298+
*
299+
* Returns the **same reference** when nothing needed removing. Pure and total.
300+
*/
301+
export function stripInjectedSystemColumns<T>(doc: T): T {
302+
const declared = fieldsOf(doc);
303+
if (declared === undefined) return doc;
304+
305+
let kept: Record<string, unknown> | undefined;
306+
for (const [name, def] of Object.entries(injectedSystemColumnDefs(doc))) {
307+
if (!isInjectedDefinition(declared[name], def)) continue;
308+
kept ??= { ...declared };
309+
delete kept[name];
310+
}
311+
if (kept === undefined) return doc;
312+
313+
return { ...(doc as unknown as Record<string, unknown>), fields: kept } as unknown as T;
314+
}

0 commit comments

Comments
 (0)