Skip to content

Commit 3670cf9

Browse files
os-zhuangclaude
andauthored
feat(metadata-core, objectql): machine-readable provenance for injected system columns (#7865) (#8115)
Direction B per the 2026-08-12 maintainer ruling: applySystemFields keeps injecting the platform anchors into external (ADR-0015) objects, and the anchors it registers without provisioning storage now carry a machine-readable provenance marker — spelled as an exported derivation (resolveInjectedColumnProvenance / unprovisionedInjectedColumns / platformProvisionsStorage in @objectstack/metadata-core, re-exported by @objectstack/objectql) rather than a provisioned key on the field defs, so no document byte changes anywhere: FieldSchema is strict (a data key would stamp _diagnostics invalidity on every served federated object, or become an authorable forgeable wall-off switch), and the #7859 Layer-0 guard plus the #4326 round-trip strip read the anchor defs by exact key-count identity. The three existing consumer guards (#7833 engine, #7859 plugin-security, #7858 plugin-sharing) are deliberately untouched — opportunistic convergence per the ruling. Proofs: unit matrix in metadata-core, marker-vs-live-injection parity in objectql, and a real showcase boot pinning the before-picture (7 anchors on showcase_ext_customer, byte-identical), the marker verdicts, behavioural parity with the live Layer-0 guard, and /meta serving an unchanged, valid post-injection document. Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1602949 commit 3670cf9

10 files changed

Lines changed: 822 additions & 0 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata-core": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
feat(metadata-core, objectql): machine-readable provenance for injected system columns — one authoritative answer to "is this column actually provisioned by the platform?" (#7865)
7+
8+
`applySystemFields` injects the platform anchors (`organization_id`,
9+
`owner_id`, `owning_business_unit_id`, the audit family) into every object that
10+
has not opted out — **including federated ones** (ADR-0015 `external`), for
11+
which the platform provisions no storage: `Engine.syncObjectSchema` returns
12+
early and issues no DDL. On such an object those anchors exist in the
13+
registered schema and nowhere else, and a predicate over one degrades silently
14+
on SQLite (unresolvable identifier → string literal → constant-false: HTTP 200,
15+
zero rows, no error). Three consumers had independently re-derived that fact
16+
(engine `DriverOptions.tenantId` withholding, plugin-security's Layer-0 phantom
17+
guard, plugin-sharing's proposed `owner_id` twin).
18+
19+
Per the 2026-08-12 maintainer ruling (direction B), the injection keeps running
20+
and the injected anchors now carry a machine-readable provenance marker, spelled
21+
as an exported derivation in `@objectstack/metadata-core` (re-exported by
22+
`@objectstack/objectql`, the injecting registry):
23+
24+
- `platformProvisionsStorage(def)` — `false` exactly for ADR-0015 `external`
25+
objects (the same predicate `syncObjectSchema` routes by, exported once).
26+
- `resolveInjectedColumnProvenance(def, column)` —
27+
`'author' | 'injected-provisioned' | 'injected-unprovisioned' | 'absent'`;
28+
`'injected-unprovisioned'` is the marker: the platform's own injected anchor
29+
with no storage behind it.
30+
- `unprovisionedInjectedColumns(def)` — the enumerable form.
31+
32+
The marker is deliberately **not** a `provisioned: false` key on the field
33+
definitions: `FieldSchema` is strict (an undeclared key would stamp
34+
`_diagnostics: { valid: false }` on every served federated object, and
35+
declaring it would hand authors a forgeable switch over their own tenant wall),
36+
and the anchor definitions are read by exact identity in the #4326 round-trip
37+
strip and plugin-security's Layer-0 guard — a data key would flip both. No
38+
document byte changes anywhere: registered, served (`/meta`), or stored. The
39+
existing consumer guards are unchanged and converge on this API opportunistically
40+
as they are next touched, per the ruling.
41+
42+
An author-declared column of the same name — including a real remote
43+
`organization_id` on a federated object — answers `'author'`, never the marker,
44+
so a consumer acting on the marker can never suppress a tenant wall the author
45+
deliberately made real. Any inexact match fails toward `'author'`: toward
46+
enforcement, never exposure.

‎packages/metadata-core/src/injected-system-columns.ts‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,176 @@ export function stripInjectedSystemColumns<T>(doc: T): T {
317317

318318
return { ...(doc as unknown as Record<string, unknown>), fields: kept } as unknown as T;
319319
}
320+
321+
// ---------------------------------------------------------------------------
322+
// [#7865] Injected-column PROVENANCE — the one authoritative answer to
323+
// "is this column actually provisioned by the platform?"
324+
// ---------------------------------------------------------------------------
325+
326+
/**
327+
* [#7865] Does the platform provision storage for this object's schema?
328+
*
329+
* `false` exactly when the object carries an ADR-0015 `external` binding: the
330+
* remote database owns the schema, `Engine.syncObjectSchema` returns early and
331+
* issues no DDL, and `SqlDriver.registerExternalObject` is DDL-free by design.
332+
* This is the same `external != null` predicate `syncObjectSchema` routes a
333+
* federated object by — ONE spelling of "this schema is the remote's", exported
334+
* so consumers stop re-spelling it (`isFederated` in `Engine.buildDriverOptions`
335+
* / PR #7833 and `isFederatedObject` in plugin-security / PR #7859 are the two
336+
* existing hand-rolled copies; both converge here when next touched, per the
337+
* 2026-08-12 maintainer ruling on #7865).
338+
*
339+
* Tolerant of bare / un-parsed metadata records, like everything in this module.
340+
*/
341+
export function platformProvisionsStorage(def: unknown): boolean {
342+
if (!def || typeof def !== 'object' || Array.isArray(def)) return true;
343+
return (def as { external?: unknown }).external == null;
344+
}
345+
346+
/**
347+
* [#7865] Provenance verdict for one column on one object document — the
348+
* machine-readable marker the 2026-08-12 maintainer ruling ordered (direction
349+
* B: keep injecting, mark the injected anchors), in its API spelling.
350+
*
351+
* - `'injected-provisioned'` — the platform's own injected anchor, with real
352+
* storage behind it: the object's storage is platform-provisioned, so the
353+
* column exists in the table exactly as registered.
354+
* - `'injected-unprovisioned'` — **the marker**: the platform's own injected
355+
* anchor on an object the platform provisions NO storage for (ADR-0015
356+
* `external`). The column exists in the registered schema and nowhere else;
357+
* a predicate over it can never resolve — on SQLite it degrades to a string
358+
* literal and the query goes constant-false (HTTP 200, zero rows, no error).
359+
* - `'author'` — the author declared this field; the platform makes no storage
360+
* claim about it. On a local object it is provisioned like any declared
361+
* field; on a federated object it maps a remote column the author vouches
362+
* for. Consumers must treat it as REAL — a federated object may legitimately
363+
* expose a real remote `organization_id`, and its tenant wall must keep
364+
* working (#7859's recorded reasoning).
365+
* - `'absent'` — not a column the injection provides on this object, and not
366+
* declared either. (Note `id` always answers `'absent'`: the primary key is
367+
* the DRIVER's, not this pass's — `resolveInjectedSystemColumns` reports it
368+
* as addressable, but no injected definition exists for it, and on a
369+
* federated object the remote's own primary key backs it via the binding.)
370+
*
371+
* ## Why an exported derivation and NOT a `provisioned: false` key in the data
372+
*
373+
* The ruling's literal illustration ("`provisioned: false` or an equivalent")
374+
* cannot land as a key on the injected field definitions without moving
375+
* surfaces the ruling fenced off, so this API is the equivalent:
376+
*
377+
* 1. `FieldSchema` is `strictObject` — an undeclared key on a served document
378+
* is rejected BY NAME, and `/meta` serves the post-injection document, so
379+
* the key would stamp `_diagnostics: { valid: false }` on every federated
380+
* object (the exact #6810 defect, closed once already). Declaring the key
381+
* instead would make it AUTHORABLE, handing authors a switch that turns
382+
* their own tenant wall off — the shape plugin-security's
383+
* `federated-phantom-anchors.ts` records as deliberately rejected.
384+
* 2. Three consumers read the anchor definitions by EXACT identity — the
385+
* #4326 round-trip strip above ({@link stripInjectedSystemColumns}), the
386+
* #7859 Layer-0 guard (`equalsShippedDef`, key-count strict), and the
387+
* stored-vs-shipped no-op check in {@link isInjectedDefinition}'s doc. A
388+
* new key on the external-object anchors flips every one of them from
389+
* "the platform's anchor" to "the author's field" — for the Layer-0 guard
390+
* that re-emits the phantom tenant predicate, resurrecting the measured
391+
* zero-rows defect this family of fixes closed.
392+
* 3. The #7865 fence: the marker must not change what any consumer accepts.
393+
* This derivation changes no document byte anywhere — registered, served,
394+
* stored — which is what makes the three no-regression proofs exact.
395+
*
396+
* ## Convergence map (opportunistic, per the ruling — NOT rewritten in #7865's PR)
397+
*
398+
* - #7833 (engine): `isFederated` ⇒ `!platformProvisionsStorage(schema)`.
399+
* - #7859 (plugin-security): `hasPhantomTenantAnchor(schema)` ⇒
400+
* `resolveInjectedColumnProvenance(schema, 'organization_id') === 'injected-unprovisioned'`.
401+
* - #7858 (plugin-sharing): the `owner_id` twin of #7859.
402+
*
403+
* ## Fail direction
404+
*
405+
* Any mismatch — an extra key, a stamped default, an unrecognisable shape —
406+
* answers `'author'`: the consumer keeps enforcing exactly as it does today.
407+
* Toward isolation, never toward exposure; the same direction the #7859 guard
408+
* documents.
409+
*
410+
* Accepts both registered `fields` shapes (record and array); the array shape's
411+
* extra `name` key is excluded from the identity comparison, exactly as the
412+
* #7859 guard excludes it, so both shapes reach the same verdict.
413+
*/
414+
export type InjectedColumnProvenance =
415+
| 'author'
416+
| 'injected-provisioned'
417+
| 'injected-unprovisioned'
418+
| 'absent';
419+
420+
/** See {@link InjectedColumnProvenance} — the verdict, and the doc, live together. */
421+
export function resolveInjectedColumnProvenance(
422+
def: unknown,
423+
column: string,
424+
): InjectedColumnProvenance {
425+
const injectedDef = injectedSystemColumnDefs(def)[column];
426+
const declared = readDeclaredFieldDef(def, column);
427+
if (injectedDef === undefined) {
428+
return declared.present ? 'author' : 'absent';
429+
}
430+
// Absent from the document ⇒ the injection provides it at registration
431+
// (pre-injection input); identical to the platform's definition ⇒ the
432+
// injection wrote it (post-injection input), or the author typed a
433+
// byte-identical copy — indistinguishable and semantically equivalent, the
434+
// same reasoning {@link stripInjectedSystemColumns} records. Anything else —
435+
// including a present-but-unrecognisable value — is the author's field (the
436+
// fail direction above).
437+
const isPlatformAnchor =
438+
!declared.present || (declared.def !== undefined && isInjectedDefinition(declared.def, injectedDef));
439+
if (!isPlatformAnchor) return 'author';
440+
return platformProvisionsStorage(def) ? 'injected-provisioned' : 'injected-unprovisioned';
441+
}
442+
443+
/**
444+
* [#7865] The injected columns this object carries with NO storage behind them
445+
* — the enumerable form of the marker. Empty for every object whose storage
446+
* the platform provisions, and for a federated object exactly the injected
447+
* anchors whose registered definition is the platform's own (an
448+
* author-declared column of the same name is the author's and is excluded, in
449+
* both `fields` shapes). Order follows {@link injectedSystemColumnDefs}.
450+
*/
451+
export function unprovisionedInjectedColumns(def: unknown): string[] {
452+
if (platformProvisionsStorage(def)) return [];
453+
return Object.keys(injectedSystemColumnDefs(def)).filter(
454+
(name) => resolveInjectedColumnProvenance(def, name) === 'injected-unprovisioned',
455+
);
456+
}
457+
458+
/**
459+
* Read one declared field definition off either `fields` shape — the record
460+
* shape (`fields: { organization_id: {...} }`) or the array shape
461+
* (`fields: [{ name: 'organization_id', ... }]`). The array element's `name`
462+
* key duplicates what the record shape expresses as the map key, so it is
463+
* removed before the identity comparison — the same exclusion the #7859
464+
* guard's `equalsShippedDef` applies, for the same reason: both shapes must
465+
* reach the same verdict about the same column.
466+
*
467+
* `present` distinguishes "the document does not mention this column" (the
468+
* injection provides it) from "the document mentions it in a shape this module
469+
* cannot read" (the author's — {@link resolveInjectedColumnProvenance}'s fail
470+
* direction requires the two to answer differently).
471+
*/
472+
function readDeclaredFieldDef(
473+
doc: unknown,
474+
name: string,
475+
): { present: boolean; def?: Record<string, unknown> } {
476+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { present: false };
477+
const fields = (doc as { fields?: unknown }).fields;
478+
if (Array.isArray(fields)) {
479+
const found = fields.find(
480+
(f) => !!f && typeof f === 'object' && (f as { name?: unknown }).name === name,
481+
);
482+
if (found === undefined) return { present: false };
483+
const copy = { ...(found as Record<string, unknown>) };
484+
delete copy.name;
485+
return { present: true, def: copy };
486+
}
487+
if (!fields || typeof fields !== 'object') return { present: false };
488+
const value = (fields as Record<string, unknown>)[name];
489+
if (value === undefined) return { present: false };
490+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { present: true };
491+
return { present: true, def: value as Record<string, unknown> };
492+
}

0 commit comments

Comments
 (0)