From 4a0b240e074810ba28771a1ca5f75ee0c403a908 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 15:46:13 +0000 Subject: [PATCH] feat(metadata-core, objectql): machine-readable provenance for injected system columns (#7865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014C8pAprWdmtecFsEprZax4 --- .../injected-anchor-provenance-marker.md | 46 +++ .../src/injected-system-columns.ts | 173 ++++++++++++ .../test/injected-column-provenance.test.ts | 267 ++++++++++++++++++ packages/objectql/src/index.ts | 13 + .../injected-system-columns-parity.test.ts | 78 +++++ packages/objectql/src/registry.ts | 29 ++ packages/qa/dogfood/package.json | 1 + ...ederated-anchor-provenance.dogfood.test.ts | 194 +++++++++++++ packages/qa/dogfood/vitest.config.ts | 18 ++ pnpm-lock.yaml | 3 + 10 files changed, 822 insertions(+) create mode 100644 .changeset/injected-anchor-provenance-marker.md create mode 100644 packages/metadata-core/test/injected-column-provenance.test.ts create mode 100644 packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts diff --git a/.changeset/injected-anchor-provenance-marker.md b/.changeset/injected-anchor-provenance-marker.md new file mode 100644 index 0000000000..f41cc544a2 --- /dev/null +++ b/.changeset/injected-anchor-provenance-marker.md @@ -0,0 +1,46 @@ +--- +"@objectstack/metadata-core": minor +"@objectstack/objectql": minor +--- + +feat(metadata-core, objectql): machine-readable provenance for injected system columns — one authoritative answer to "is this column actually provisioned by the platform?" (#7865) + +`applySystemFields` injects the platform anchors (`organization_id`, +`owner_id`, `owning_business_unit_id`, the audit family) into every object that +has not opted out — **including federated ones** (ADR-0015 `external`), for +which the platform provisions no storage: `Engine.syncObjectSchema` returns +early and issues no DDL. On such an object those anchors exist in the +registered schema and nowhere else, and a predicate over one degrades silently +on SQLite (unresolvable identifier → string literal → constant-false: HTTP 200, +zero rows, no error). Three consumers had independently re-derived that fact +(engine `DriverOptions.tenantId` withholding, plugin-security's Layer-0 phantom +guard, plugin-sharing's proposed `owner_id` twin). + +Per the 2026-08-12 maintainer ruling (direction B), the injection keeps running +and the injected anchors now carry a machine-readable provenance marker, spelled +as an exported derivation in `@objectstack/metadata-core` (re-exported by +`@objectstack/objectql`, the injecting registry): + +- `platformProvisionsStorage(def)` — `false` exactly for ADR-0015 `external` + objects (the same predicate `syncObjectSchema` routes by, exported once). +- `resolveInjectedColumnProvenance(def, column)` — + `'author' | 'injected-provisioned' | 'injected-unprovisioned' | 'absent'`; + `'injected-unprovisioned'` is the marker: the platform's own injected anchor + with no storage behind it. +- `unprovisionedInjectedColumns(def)` — the enumerable form. + +The marker is deliberately **not** a `provisioned: false` key on the field +definitions: `FieldSchema` is strict (an undeclared key would stamp +`_diagnostics: { valid: false }` on every served federated object, and +declaring it would hand authors a forgeable switch over their own tenant wall), +and the anchor definitions are read by exact identity in the #4326 round-trip +strip and plugin-security's Layer-0 guard — a data key would flip both. No +document byte changes anywhere: registered, served (`/meta`), or stored. The +existing consumer guards are unchanged and converge on this API opportunistically +as they are next touched, per the ruling. + +An author-declared column of the same name — including a real remote +`organization_id` on a federated object — answers `'author'`, never the marker, +so a consumer acting on the marker can never suppress a tenant wall the author +deliberately made real. Any inexact match fails toward `'author'`: toward +enforcement, never exposure. diff --git a/packages/metadata-core/src/injected-system-columns.ts b/packages/metadata-core/src/injected-system-columns.ts index 4a43073e85..d7e331c1a1 100644 --- a/packages/metadata-core/src/injected-system-columns.ts +++ b/packages/metadata-core/src/injected-system-columns.ts @@ -317,3 +317,176 @@ export function stripInjectedSystemColumns(doc: T): T { return { ...(doc as unknown as Record), fields: kept } as unknown as T; } + +// --------------------------------------------------------------------------- +// [#7865] Injected-column PROVENANCE — the one authoritative answer to +// "is this column actually provisioned by the platform?" +// --------------------------------------------------------------------------- + +/** + * [#7865] Does the platform provision storage for this object's schema? + * + * `false` exactly when the object carries an ADR-0015 `external` binding: the + * remote database owns the schema, `Engine.syncObjectSchema` returns early and + * issues no DDL, and `SqlDriver.registerExternalObject` is DDL-free by design. + * This is the same `external != null` predicate `syncObjectSchema` routes a + * federated object by — ONE spelling of "this schema is the remote's", exported + * so consumers stop re-spelling it (`isFederated` in `Engine.buildDriverOptions` + * / PR #7833 and `isFederatedObject` in plugin-security / PR #7859 are the two + * existing hand-rolled copies; both converge here when next touched, per the + * 2026-08-12 maintainer ruling on #7865). + * + * Tolerant of bare / un-parsed metadata records, like everything in this module. + */ +export function platformProvisionsStorage(def: unknown): boolean { + if (!def || typeof def !== 'object' || Array.isArray(def)) return true; + return (def as { external?: unknown }).external == null; +} + +/** + * [#7865] Provenance verdict for one column on one object document — the + * machine-readable marker the 2026-08-12 maintainer ruling ordered (direction + * B: keep injecting, mark the injected anchors), in its API spelling. + * + * - `'injected-provisioned'` — the platform's own injected anchor, with real + * storage behind it: the object's storage is platform-provisioned, so the + * column exists in the table exactly as registered. + * - `'injected-unprovisioned'` — **the marker**: the platform's own injected + * anchor on an object the platform provisions NO storage for (ADR-0015 + * `external`). The column exists in the registered schema and nowhere else; + * a predicate over it can never resolve — on SQLite it degrades to a string + * literal and the query goes constant-false (HTTP 200, zero rows, no error). + * - `'author'` — the author declared this field; the platform makes no storage + * claim about it. On a local object it is provisioned like any declared + * field; on a federated object it maps a remote column the author vouches + * for. Consumers must treat it as REAL — a federated object may legitimately + * expose a real remote `organization_id`, and its tenant wall must keep + * working (#7859's recorded reasoning). + * - `'absent'` — not a column the injection provides on this object, and not + * declared either. (Note `id` always answers `'absent'`: the primary key is + * the DRIVER's, not this pass's — `resolveInjectedSystemColumns` reports it + * as addressable, but no injected definition exists for it, and on a + * federated object the remote's own primary key backs it via the binding.) + * + * ## Why an exported derivation and NOT a `provisioned: false` key in the data + * + * The ruling's literal illustration ("`provisioned: false` or an equivalent") + * cannot land as a key on the injected field definitions without moving + * surfaces the ruling fenced off, so this API is the equivalent: + * + * 1. `FieldSchema` is `strictObject` — an undeclared key on a served document + * is rejected BY NAME, and `/meta` serves the post-injection document, so + * the key would stamp `_diagnostics: { valid: false }` on every federated + * object (the exact #6810 defect, closed once already). Declaring the key + * instead would make it AUTHORABLE, handing authors a switch that turns + * their own tenant wall off — the shape plugin-security's + * `federated-phantom-anchors.ts` records as deliberately rejected. + * 2. Three consumers read the anchor definitions by EXACT identity — the + * #4326 round-trip strip above ({@link stripInjectedSystemColumns}), the + * #7859 Layer-0 guard (`equalsShippedDef`, key-count strict), and the + * stored-vs-shipped no-op check in {@link isInjectedDefinition}'s doc. A + * new key on the external-object anchors flips every one of them from + * "the platform's anchor" to "the author's field" — for the Layer-0 guard + * that re-emits the phantom tenant predicate, resurrecting the measured + * zero-rows defect this family of fixes closed. + * 3. The #7865 fence: the marker must not change what any consumer accepts. + * This derivation changes no document byte anywhere — registered, served, + * stored — which is what makes the three no-regression proofs exact. + * + * ## Convergence map (opportunistic, per the ruling — NOT rewritten in #7865's PR) + * + * - #7833 (engine): `isFederated` ⇒ `!platformProvisionsStorage(schema)`. + * - #7859 (plugin-security): `hasPhantomTenantAnchor(schema)` ⇒ + * `resolveInjectedColumnProvenance(schema, 'organization_id') === 'injected-unprovisioned'`. + * - #7858 (plugin-sharing): the `owner_id` twin of #7859. + * + * ## Fail direction + * + * Any mismatch — an extra key, a stamped default, an unrecognisable shape — + * answers `'author'`: the consumer keeps enforcing exactly as it does today. + * Toward isolation, never toward exposure; the same direction the #7859 guard + * documents. + * + * Accepts both registered `fields` shapes (record and array); the array shape's + * extra `name` key is excluded from the identity comparison, exactly as the + * #7859 guard excludes it, so both shapes reach the same verdict. + */ +export type InjectedColumnProvenance = + | 'author' + | 'injected-provisioned' + | 'injected-unprovisioned' + | 'absent'; + +/** See {@link InjectedColumnProvenance} — the verdict, and the doc, live together. */ +export function resolveInjectedColumnProvenance( + def: unknown, + column: string, +): InjectedColumnProvenance { + const injectedDef = injectedSystemColumnDefs(def)[column]; + const declared = readDeclaredFieldDef(def, column); + if (injectedDef === undefined) { + return declared.present ? 'author' : 'absent'; + } + // Absent from the document ⇒ the injection provides it at registration + // (pre-injection input); identical to the platform's definition ⇒ the + // injection wrote it (post-injection input), or the author typed a + // byte-identical copy — indistinguishable and semantically equivalent, the + // same reasoning {@link stripInjectedSystemColumns} records. Anything else — + // including a present-but-unrecognisable value — is the author's field (the + // fail direction above). + const isPlatformAnchor = + !declared.present || (declared.def !== undefined && isInjectedDefinition(declared.def, injectedDef)); + if (!isPlatformAnchor) return 'author'; + return platformProvisionsStorage(def) ? 'injected-provisioned' : 'injected-unprovisioned'; +} + +/** + * [#7865] The injected columns this object carries with NO storage behind them + * — the enumerable form of the marker. Empty for every object whose storage + * the platform provisions, and for a federated object exactly the injected + * anchors whose registered definition is the platform's own (an + * author-declared column of the same name is the author's and is excluded, in + * both `fields` shapes). Order follows {@link injectedSystemColumnDefs}. + */ +export function unprovisionedInjectedColumns(def: unknown): string[] { + if (platformProvisionsStorage(def)) return []; + return Object.keys(injectedSystemColumnDefs(def)).filter( + (name) => resolveInjectedColumnProvenance(def, name) === 'injected-unprovisioned', + ); +} + +/** + * Read one declared field definition off either `fields` shape — the record + * shape (`fields: { organization_id: {...} }`) or the array shape + * (`fields: [{ name: 'organization_id', ... }]`). The array element's `name` + * key duplicates what the record shape expresses as the map key, so it is + * removed before the identity comparison — the same exclusion the #7859 + * guard's `equalsShippedDef` applies, for the same reason: both shapes must + * reach the same verdict about the same column. + * + * `present` distinguishes "the document does not mention this column" (the + * injection provides it) from "the document mentions it in a shape this module + * cannot read" (the author's — {@link resolveInjectedColumnProvenance}'s fail + * direction requires the two to answer differently). + */ +function readDeclaredFieldDef( + doc: unknown, + name: string, +): { present: boolean; def?: Record } { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { present: false }; + const fields = (doc as { fields?: unknown }).fields; + if (Array.isArray(fields)) { + const found = fields.find( + (f) => !!f && typeof f === 'object' && (f as { name?: unknown }).name === name, + ); + if (found === undefined) return { present: false }; + const copy = { ...(found as Record) }; + delete copy.name; + return { present: true, def: copy }; + } + if (!fields || typeof fields !== 'object') return { present: false }; + const value = (fields as Record)[name]; + if (value === undefined) return { present: false }; + if (!value || typeof value !== 'object' || Array.isArray(value)) return { present: true }; + return { present: true, def: value as Record }; +} diff --git a/packages/metadata-core/test/injected-column-provenance.test.ts b/packages/metadata-core/test/injected-column-provenance.test.ts new file mode 100644 index 0000000000..34bb8bc8bf --- /dev/null +++ b/packages/metadata-core/test/injected-column-provenance.test.ts @@ -0,0 +1,267 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7865] Injected-column provenance — the machine-readable marker for anchors + * the platform registers without provisioning storage (maintainer ruling + * 2026-08-12, direction B). + * + * The fixture matrix mirrors the shipped showcase measurement the card was + * filed from: `showcase_ext_customer` (external → remote table `customers`) + * registers 7 platform anchors against a remote table that has none of them. + * The REAL registered schema is pinned by the dogfood twin of this file + * (`packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts`); + * these fixtures pin the DECISION over every branch, including the ones a + * single showcase boot cannot reach. + */ + +import { describe, it, expect } from 'vitest'; +import { + AUDIT_FIELD_DEFS, + TENANT_SCOPE_FIELD_DEF, + OWNER_FIELD_DEF, + OWNING_BUSINESS_UNIT_FIELD_DEF, + injectedSystemColumnDefs, + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from '../src/index.js'; + +/** The seven anchors the card's measurement counted on the showcase object. */ +const SEVEN_ANCHORS = [ + 'organization_id', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'owner_id', + 'owning_business_unit_id', +] as const; + +const remoteFields = () => ({ + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + region: { type: 'text', label: 'Region' }, + lifetime_value: { type: 'currency', label: 'Lifetime Value', scale: 2 }, +}); + +/** Showcase-shaped federated object (pre-injection, as authored). */ +const external = () => ({ + name: 'showcase_ext_customer', + datasource: 'showcase_external', + external: { remoteName: 'customers' }, + fields: remoteFields(), +}); + +/** The same object with the platform's storage — the local control twin. */ +const local = () => ({ name: 'showcase_customer', fields: remoteFields() }); + +/** Post-injection shape: the anchors present, byte-identical to the tables. */ +const withInjectedAnchors = (def: Record) => ({ + ...def, + fields: { + organization_id: { ...TENANT_SCOPE_FIELD_DEF }, + ...AUDIT_FIELD_DEFS, + owner_id: { ...OWNER_FIELD_DEF }, + owning_business_unit_id: { ...OWNING_BUSINESS_UNIT_FIELD_DEF }, + ...(def.fields as Record), + }, +}); + +describe('[#7865] platformProvisionsStorage', () => { + it('is false exactly for ADR-0015 external objects', () => { + expect(platformProvisionsStorage(local())).toBe(true); + expect(platformProvisionsStorage(external())).toBe(false); + // The routing predicate is `external != null`, not truthiness of contents. + expect(platformProvisionsStorage({ name: 'x', external: {} })).toBe(false); + expect(platformProvisionsStorage({ name: 'x', external: null })).toBe(true); + }); + + it('is tolerant of bare input (total, like the rest of the module)', () => { + expect(platformProvisionsStorage(undefined)).toBe(true); + expect(platformProvisionsStorage('nonsense')).toBe(true); + expect(platformProvisionsStorage([])).toBe(true); + }); +}); + +describe('[#7865] resolveInjectedColumnProvenance — the marker', () => { + it('marks all seven showcase anchors unprovisioned on the federated object (pre-injection input)', () => { + for (const anchor of SEVEN_ANCHORS) { + expect(resolveInjectedColumnProvenance(external(), anchor), anchor).toBe( + 'injected-unprovisioned', + ); + } + }); + + it('marks the same seven anchors unprovisioned on the POST-injection registered shape', () => { + const registered = withInjectedAnchors(external()); + for (const anchor of SEVEN_ANCHORS) { + expect(resolveInjectedColumnProvenance(registered, anchor), anchor).toBe( + 'injected-unprovisioned', + ); + } + }); + + it('answers injected-provisioned for the local control twin — storage is real there', () => { + for (const shape of [local(), withInjectedAnchors(local())]) { + for (const anchor of SEVEN_ANCHORS) { + expect(resolveInjectedColumnProvenance(shape, anchor), anchor).toBe('injected-provisioned'); + } + } + }); + + it("answers 'author' for the object's own declared columns, both storage modes", () => { + for (const shape of [external(), local()]) { + for (const declared of ['name', 'email', 'region', 'lifetime_value']) { + expect(resolveInjectedColumnProvenance(shape, declared), declared).toBe('author'); + } + } + }); + + it("SECURITY DIRECTION: an author-declared organization_id on a federated object is 'author', never the marker", () => { + // #7859's recorded reasoning: a federated object may expose a REAL remote + // organization_id by declaring it — and then the tenant wall must keep + // working. If this case ever answers 'injected-unprovisioned', a consumer + // converged on the marker would suppress a wall that was doing its job. + const declaredReal = { + ...external(), + fields: { + ...remoteFields(), + organization_id: { type: 'text', label: 'Remote Org Key' }, + }, + }; + expect(resolveInjectedColumnProvenance(declaredReal, 'organization_id')).toBe('author'); + expect(unprovisionedInjectedColumns(declaredReal)).not.toContain('organization_id'); + }); + + it("fail direction: ANY mismatch with the shipped definition answers 'author' (toward enforcement)", () => { + const base = external(); + // Extra key on an otherwise-identical anchor. + const extraKey = { + ...base, + fields: { + ...remoteFields(), + organization_id: { ...TENANT_SCOPE_FIELD_DEF, extra: true }, + }, + }; + expect(resolveInjectedColumnProvenance(extraKey, 'organization_id')).toBe('author'); + // Changed value. + const changed = { + ...base, + fields: { + ...remoteFields(), + organization_id: { ...TENANT_SCOPE_FIELD_DEF, readonly: false }, + }, + }; + expect(resolveInjectedColumnProvenance(changed, 'organization_id')).toBe('author'); + // Present but unrecognisable — NOT the same as absent. + const degenerate = { + ...base, + fields: { ...remoteFields(), organization_id: true }, + }; + expect(resolveInjectedColumnProvenance(degenerate, 'organization_id')).toBe('author'); + }); + + it('reaches the same verdicts through the ARRAY fields shape (name key excluded)', () => { + const arrayShape = { + name: 'showcase_ext_customer', + external: { remoteName: 'customers' }, + fields: [ + { name: 'organization_id', ...TENANT_SCOPE_FIELD_DEF }, + { name: 'email', type: 'text', label: 'Email' }, + ], + }; + expect(resolveInjectedColumnProvenance(arrayShape, 'organization_id')).toBe( + 'injected-unprovisioned', + ); + expect(resolveInjectedColumnProvenance(arrayShape, 'email')).toBe('author'); + // An authored array-shape organization_id stays the author's. + const arrayAuthored = { + ...arrayShape, + fields: [{ name: 'organization_id', type: 'text', label: 'Remote Org Key' }], + }; + expect(resolveInjectedColumnProvenance(arrayAuthored, 'organization_id')).toBe('author'); + }); + + it('respects every injection opt-out — an anchor the plan withholds is absent even on external objects', () => { + const cases: Array<[string, Record, readonly string[]]> = [ + ['systemFields: false', { ...external(), systemFields: false }, SEVEN_ANCHORS], + ["managedBy: 'better-auth'", { ...external(), managedBy: 'better-auth' }, SEVEN_ANCHORS], + [ + 'tenancy.enabled: false', + { ...external(), tenancy: { enabled: false } }, + ['organization_id'], + ], + [ + "ownership: 'org'", + { ...external(), ownership: 'org' }, + ['owner_id', 'owning_business_unit_id'], + ], + [ + "ownership: 'business_unit'", + { ...external(), ownership: 'business_unit' }, + ['owner_id'], + ], + ]; + for (const [label, def, withheld] of cases) { + for (const anchor of withheld) { + expect(resolveInjectedColumnProvenance(def, anchor), `${label}: ${anchor}`).toBe('absent'); + expect(unprovisionedInjectedColumns(def), label).not.toContain(anchor); + } + } + }); + + it("'id' is the driver's column, not the injection's — always 'absent' here", () => { + // `resolveInjectedSystemColumns` reports `id` as addressable, but no + // injected DEFINITION exists for it, and on a federated object the + // remote's own primary key backs it through the binding — so the marker + // deliberately makes no claim about it. + expect(resolveInjectedColumnProvenance(external(), 'id')).toBe('absent'); + expect(unprovisionedInjectedColumns(external())).not.toContain('id'); + }); +}); + +describe('[#7865] unprovisionedInjectedColumns — the enumerable marker', () => { + it('lists exactly the seven measured anchors for the showcase-shaped federated object', () => { + expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...SEVEN_ANCHORS].sort()); + expect(unprovisionedInjectedColumns(withInjectedAnchors(external())).sort()).toEqual( + [...SEVEN_ANCHORS].sort(), + ); + }); + + it('is empty for every platform-provisioned object', () => { + expect(unprovisionedInjectedColumns(local())).toEqual([]); + expect(unprovisionedInjectedColumns(withInjectedAnchors(local()))).toEqual([]); + expect(unprovisionedInjectedColumns(undefined)).toEqual([]); + }); +}); + +describe('[#7865] the fence: the marker lives in the API, never in the data', () => { + it('no shipped definition table carries a provisioned key', () => { + // The #7865 ruling's fence: the marker must not change what any consumer + // accepts. Three consumers read these definitions by EXACT identity — + // plugin-security's Layer-0 guard (#7859, key-count strict), the #4326 + // round-trip strip in this package, and FieldSchema's strictObject parse + // of every served document. A `provisioned` key added to any of these + // tables flips all three: the guard re-emits the phantom tenant predicate + // (the measured zero-rows defect), the strip stops recognising its own + // injection, and `/meta` stamps `_diagnostics: { valid: false }` on every + // object (#6810's shape). If this pin is red, read + // `resolveInjectedColumnProvenance`'s doc before proceeding. + const tables: Array<[string, Readonly>]> = [ + ['TENANT_SCOPE_FIELD_DEF', TENANT_SCOPE_FIELD_DEF], + ['OWNER_FIELD_DEF', OWNER_FIELD_DEF], + ['OWNING_BUSINESS_UNIT_FIELD_DEF', OWNING_BUSINESS_UNIT_FIELD_DEF], + ...Object.entries(AUDIT_FIELD_DEFS).map( + ([k, v]) => [`AUDIT_FIELD_DEFS.${k}`, v] as [string, Readonly>], + ), + ]; + for (const [label, table] of tables) { + expect('provisioned' in table, label).toBe(false); + } + // And the per-object defs derived from them — external or not — stay + // byte-identical to the tables (the marker adds NOTHING to the data). + const defs = injectedSystemColumnDefs(external()); + expect(defs.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + expect(defs.owner_id).toEqual(OWNER_FIELD_DEF); + }); +}); diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 6a6a27f72f..881909ff89 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -26,6 +26,19 @@ export { DEFAULT_EXTENDER_PRIORITY, } from './registry.js'; export type { ObjectContributor, SchemaRegistryOptions } from './registry.js'; +// [#7865] Injected-column provenance — the machine-readable marker for anchors +// the registry registers without provisioning storage (external objects, +// ADR-0015). Canonical home: `@objectstack/metadata-core`, beside the +// definition tables it derives from; re-exported here because this registry is +// the producer consumers stand at when they need it. See the doc block over +// the re-export in `registry.ts`, and `resolveInjectedColumnProvenance`'s own +// doc for why the marker is an API and not a `provisioned: false` data key. +export { + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from './registry.js'; +export type { InjectedColumnProvenance } from './registry.js'; // Search-normalization companion column (#2486 — pinyin recall). Shared by // the registry's compile-time provisioning seam, the engine's `$search` diff --git a/packages/objectql/src/injected-system-columns-parity.test.ts b/packages/objectql/src/injected-system-columns-parity.test.ts index 35c90276ac..8e89579d7c 100644 --- a/packages/objectql/src/injected-system-columns-parity.test.ts +++ b/packages/objectql/src/injected-system-columns-parity.test.ts @@ -4,6 +4,17 @@ import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { describe, it, expect } from 'vitest'; import { applySystemFields } from './index.js'; +// [#7865] The provenance marker, through this registry's own re-export — the +// path a consumer standing at the injection site is pointed to. +import { + TENANT_SCOPE_FIELD_DEF, + OWNER_FIELD_DEF, + OWNING_BUSINESS_UNIT_FIELD_DEF, + AUDIT_FIELD_DEFS, + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from './registry.js'; // --------------------------------------------------------------------------- // [#5378] Parity pin: the spec's derivation vs. this registry's injection. @@ -87,6 +98,73 @@ describe('[#5378] resolveInjectedSystemColumns ↔ applySystemFields parity', () } }); + // [#7865] Direction B (maintainer ruling 2026-08-12): the injection KEEPS + // running for `external` objects, the definitions stay byte-identical, and + // the marker for "registered but unprovisioned" is the provenance API — so + // the marker and the live pass must agree about every column the pass adds, + // or a consumer converging on the marker inherits a drifted answer. + describe('[#7865] provenance marker ↔ live injection parity', () => { + const externalize = (def: Record): Record => ({ + ...def, + external: { remoteName: 'remote_table' }, + }); + + it('every column the pass injects carries the marker verdict matching the object storage', () => { + for (const [label, def] of CASES) { + for (const variant of [def, externalize(def)] as const) { + const isExternal = variant.external != null; + const injected = actuallyInjected(variant, true); + const after = applySystemFields(structuredClone(variant) as any, { multiTenant: true }); + for (const column of injected) { + // Verdict agrees between the authored input and the registered output. + for (const shape of [variant, after]) { + expect( + resolveInjectedColumnProvenance(shape, column), + `${label}${isExternal ? ' (external)' : ''}: ${column}`, + ).toBe(isExternal ? 'injected-unprovisioned' : 'injected-provisioned'); + } + } + expect(unprovisionedInjectedColumns(after).sort(), label).toEqual( + isExternal ? [...injected].sort() : [], + ); + } + } + }); + + it('the pass keeps injecting for external objects, byte-identical to the shipped tables', () => { + // The before-picture of the #7865 card (7 anchors on the showcase's + // federated object) must survive the marker: same columns, same bytes. + const def = externalize({ name: 'crm_contact', fields: fields() }); + expect(platformProvisionsStorage(def)).toBe(false); + const after = applySystemFields(structuredClone(def) as any, { multiTenant: true }); + const afterFields = after.fields as Record; + expect(afterFields.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + expect(afterFields.owner_id).toEqual(OWNER_FIELD_DEF); + expect(afterFields.owning_business_unit_id).toEqual(OWNING_BUSINESS_UNIT_FIELD_DEF); + for (const [name, tableDef] of Object.entries(AUDIT_FIELD_DEFS)) { + expect(afterFields[name], name).toEqual(tableDef); + } + // No definition gained a provenance key — the marker is the API above, + // never data (see resolveInjectedColumnProvenance's doc for the three + // identity consumers a data key would flip). + for (const [name, fieldDef] of Object.entries(afterFields)) { + expect('provisioned' in (fieldDef as Record), name).toBe(false); + } + }); + + it("an author-declared anchor on an external object stays 'author' through the pass", () => { + // #7859's security direction: a federated object exposing a REAL remote + // organization_id keeps its tenant wall. + const def = externalize({ + name: 'crm_contact', + fields: { ...fields(), organization_id: { type: 'text', label: 'Remote Org Key' } }, + }); + const after = applySystemFields(structuredClone(def) as any, { multiTenant: true }); + expect(resolveInjectedColumnProvenance(after, 'organization_id')).toBe('author'); + expect(unprovisionedInjectedColumns(after)).not.toContain('organization_id'); + }); + }); + it('multiTenant changes only the INDEX, never which columns exist', () => { // The property that lets an author-time consumer answer without any runtime // context — and therefore the reason the derivation takes no such option. diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index e070c52878..4b70a6f4ba 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -326,6 +326,24 @@ export interface SchemaRegistryOptions { */ export { AUDIT_FIELD_DEFS, TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF }; +/** + * [#7865] Injected-column PROVENANCE — the registry face of the 2026-08-12 + * maintainer ruling (direction B): the injection below keeps running for + * `external` objects, and the machine-readable marker for the anchors it + * registers without provisioning storage is this API, re-exported from the + * table's home in `@objectstack/metadata-core` exactly like the definition + * tables above (#6562's discipline: the producer and every consumer read one + * answer from one place). The definitions themselves stay byte-identical — + * see `resolveInjectedColumnProvenance`'s doc for why the marker is an + * exported derivation and NOT a `provisioned: false` key on the field defs. + */ +export { + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, + type InjectedColumnProvenance, +} from '@objectstack/metadata-core'; + /** * [#4447] The subset of {@link AUDIT_FIELD_DEFS} that is NOT authorable — the * keys that decide who may write an audit column. @@ -368,6 +386,17 @@ export function applySystemFields( // ownership of WHAT each column looks like, the same split #3786 established // for the audit family. Do NOT re-derive a condition below: a second copy here // is exactly the drift the plan exists to prevent. + // + // [#7865] This pass runs for `external` (ADR-0015) objects too — DELIBERATELY + // (maintainer ruling 2026-08-12, direction B). The platform provisions no + // storage for a federated object (`syncObjectSchema` returns early, no DDL), + // so the anchors injected below are registered-but-unprovisioned there; the + // machine-readable marker for that fact is `resolveInjectedColumnProvenance` + // / `unprovisionedInjectedColumns` (re-exported above from + // `@objectstack/metadata-core`), NOT a key on the definitions this function + // spreads — the definitions must stay byte-identical to the shipped tables, + // because the #7859 Layer-0 guard and the #4326 round-trip strip both read + // them by exact identity. Do not add keys here; consumers ask the API. const plan = resolveInjectedSystemColumns(schema); // 1. Hard opt-out at object level (e.g. seed/migration tables). diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 3b2995e60e..ea3e2754be 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -17,6 +17,7 @@ "@objectstack/example-showcase": "workspace:*", "@objectstack/mcp": "workspace:*", "@objectstack/metadata": "workspace:*", + "@objectstack/metadata-core": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-audit": "workspace:*", diff --git a/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts new file mode 100644 index 0000000000..1d5f1616e0 --- /dev/null +++ b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7865] The injected-anchor provenance marker, measured on a REAL boot of the + * shipped showcase — the card's own measurement, reproduced as a pin. + * + * The card (decision, ruled 2026-08-12, direction B): `applySystemFields` + * injects platform anchors into `external` objects the platform provisions no + * storage for; three consumers independently re-derived "that column is not + * really there" (#7833 engine, #7859 plugin-security, #7858 plugin-sharing). + * The ruling keeps the injection and adds a machine-readable provenance marker + * — `resolveInjectedColumnProvenance` / `unprovisionedInjectedColumns` + * (`@objectstack/metadata-core`, re-exported by `@objectstack/objectql`) — so + * consumers ask ONE authoritative question instead of each hand-rolling it. + * + * What this file proves, against the registry a deployed app would really run: + * + * 1. BEFORE-PICTURE PRESERVED — direction B keeps injecting: the federated + * `showcase_ext_customer` still registers all 7 platform anchors over the + * 4-column remote table, byte-identical to the shipped definition tables + * (no `provisioned` key in any served or registered field definition — + * the #7865 fence: the marker changes what no consumer accepts). + * 2. THE MARKER — the 7 anchors, and only they, answer + * `'injected-unprovisioned'`; the local control answers + * `'injected-provisioned'`; declared columns answer `'author'`. + * 3. GUARD PARITY — the marker agrees with the #7859 Layer-0 guard's live + * behaviour on the same boot (no predicate for the federated object, wall + * intact on the local control), through plugin-security's public surface — + * the guard itself is deliberately untouched (opportunistic convergence, + * per the ruling). + * 4. `/meta` NO-REGRESSION — the served document (the post-injection document, + * #6562) reports the same field set as the registry and carries no new + * keys and no `_diagnostics` invalidity. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import type { IObjectQLEngine, ISecurityService } from '@objectstack/spec/contracts'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { + AUDIT_FIELD_DEFS, + TENANT_SCOPE_FIELD_DEF, + OWNER_FIELD_DEF, + OWNING_BUSINESS_UNIT_FIELD_DEF, + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from '@objectstack/metadata-core'; + +/** The federated object the showcase ships, bound to remote table `customers`. */ +const FEDERATED = 'showcase_ext_customer'; +/** A LOCAL showcase object — the control: its anchors ARE provisioned. */ +const LOCAL = 'showcase_project'; + +/** The 7 anchors the #7865 card measured on this exact object. */ +const SEVEN_ANCHORS = [ + 'organization_id', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'owner_id', + 'owning_business_unit_id', +] as const; + +/** The remote table's real columns, as the showcase declares them. */ +const DECLARED = ['name', 'email', 'region', 'lifetime_value'] as const; + +const SHIPPED_DEFS: Record>> = { + organization_id: TENANT_SCOPE_FIELD_DEF, + owner_id: OWNER_FIELD_DEF, + owning_business_unit_id: OWNING_BUSINESS_UNIT_FIELD_DEF, + ...AUDIT_FIELD_DEFS, +}; + +function fieldMap(schema: unknown): Record> { + const fields = (schema as { fields?: unknown } | null)?.fields; + if (Array.isArray(fields)) { + const out: Record> = {}; + for (const f of fields) { + const { name, ...rest } = (f ?? {}) as Record; + if (typeof name === 'string') out[name] = rest; + } + return out; + } + return { ...((fields ?? {}) as Record>) }; +} + +/** Every `organization_id` key anywhere in a composed FilterCondition tree. */ +function mentionsOrgColumn(filter: unknown): boolean { + if (Array.isArray(filter)) return filter.some(mentionsOrgColumn); + if (!filter || typeof filter !== 'object') return false; + return Object.entries(filter as Record).some( + ([k, v]) => k === 'organization_id' || mentionsOrgColumn(v), + ); +} + +describe('[#7865] injected-anchor provenance on a real showcase boot', () => { + let stack: VerifyStack; + let ql: IObjectQLEngine; + let security: ISecurityService; + let federated: ServiceObject; + let localControl: ServiceObject; + + beforeAll(async () => { + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + ql = stack.kernel.getService('objectql'); + security = stack.kernel.getService('security'); + federated = ql.getSchema(FEDERATED) as ServiceObject; + localControl = ql.getSchema(LOCAL) as ServiceObject; + expect(federated?.external, `${FEDERATED} must be federated`).toBeTruthy(); + expect(localControl, `${LOCAL} must exist`).toBeTruthy(); + }, 120_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('BEFORE-PICTURE: the registry still injects all 7 anchors into the federated object (direction B keeps injecting)', () => { + const names = Object.keys(fieldMap(federated)); + for (const anchor of SEVEN_ANCHORS) expect(names, 'anchors').toContain(anchor); + for (const declared of DECLARED) expect(names, 'declared').toContain(declared); + }); + + it('FENCE: every registered anchor definition is byte-identical to the shipped table — the marker adds nothing to the data', () => { + const fields = fieldMap(federated); + for (const anchor of SEVEN_ANCHORS) { + expect(fields[anchor], anchor).toEqual({ ...SHIPPED_DEFS[anchor] }); + expect('provisioned' in (fields[anchor] ?? {}), `${anchor} must not carry a provisioned key`).toBe(false); + } + }); + + it('THE MARKER: the 7 anchors — and only they — are unprovisioned on the federated object', () => { + expect(platformProvisionsStorage(federated)).toBe(false); + expect(unprovisionedInjectedColumns(federated).sort()).toEqual([...SEVEN_ANCHORS].sort()); + for (const anchor of SEVEN_ANCHORS) { + expect(resolveInjectedColumnProvenance(federated, anchor), anchor).toBe( + 'injected-unprovisioned', + ); + } + for (const declared of DECLARED) { + expect(resolveInjectedColumnProvenance(federated, declared), declared).toBe('author'); + } + }); + + it('CONTROL: the local object answers injected-provisioned and lists nothing unprovisioned', () => { + expect(platformProvisionsStorage(localControl)).toBe(true); + expect(unprovisionedInjectedColumns(localControl)).toEqual([]); + expect(resolveInjectedColumnProvenance(localControl, 'organization_id')).toBe( + 'injected-provisioned', + ); + expect(resolveInjectedColumnProvenance(localControl, 'owner_id')).toBe('injected-provisioned'); + }); + + it('GUARD PARITY (#7859, behavioural): the marker and the live Layer-0 verdict agree on both objects', async () => { + // The guard is NOT rewritten by #7865 (opportunistic convergence, per the + // ruling) — so its live behaviour is the reference the marker must match: + // marker says unprovisioned ⇒ Layer 0 composes no organization_id + // predicate; marker says provisioned ⇒ the wall stands. + const ctx = { userId: 'usr_dogfood_member', tenantId: 'org_alpha', positions: [] as string[] }; + const federatedFilter = await security.getReadFilter(FEDERATED, ctx); + expect( + mentionsOrgColumn(federatedFilter), + `marker says unprovisioned ⇒ no tenant predicate, got ${JSON.stringify(federatedFilter)}`, + ).toBe(false); + const localFilter = await security.getReadFilter(LOCAL, ctx); + expect( + mentionsOrgColumn(localFilter), + `marker says provisioned ⇒ wall intact, got ${JSON.stringify(localFilter)}`, + ).toBe(true); + }); + + it('/meta NO-REGRESSION: the served post-injection document reports the registry field set, unchanged and valid', async () => { + const token = await stack.signIn(); + const res = await stack.apiAs(token, 'GET', `/meta/object/${FEDERATED}`); + expect(res.status).toBe(200); + const body: any = await res.json(); + const served = fieldMap(body?.item); + // Same field-name set as the registered schema — the #6562 invariant. + expect(Object.keys(served).sort()).toEqual(Object.keys(fieldMap(federated)).sort()); + // The served anchors carry the shipped bytes and no marker key. + for (const anchor of SEVEN_ANCHORS) { + expect(served[anchor], anchor).toEqual({ ...SHIPPED_DEFS[anchor] }); + } + // And the platform files no defect report about its own document (#6810's + // failure shape: `_diagnostics: { valid: false, unrecognized_keys }`). + const diagnostics = (body?.item as Record | undefined)?.['_diagnostics'] as + | { valid?: boolean } + | undefined; + expect(diagnostics?.valid, JSON.stringify(diagnostics)).not.toBe(false); + }, 120_000); +}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 4627c450c1..e16250942c 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -16,6 +16,7 @@ // `isolated` keeps vitest defaults (fresh fork registry per file) for // everything else — fixture stacks, custom security/plugins, env-flag files. import { defineConfig } from 'vitest/config'; +import path from 'path'; // Files proven eligible for the worker-shared plain showcase stack. const SHARED_SHOWCASE = [ @@ -35,6 +36,23 @@ const SHARED_SHOWCASE = [ ]; export default defineConfig({ + resolve: { + // [#7865] `federated-anchor-provenance.dogfood.test.ts` imports the + // provenance marker from `@objectstack/metadata-core` — alias it to SOURCE + // so the pin is a verdict about the checkout, not about a build artifact + // (`check-test-source-alias`; #7668 is what a dist-resolved pin costs). + // Anchored array form on purpose: the object form matches by prefix and + // would swallow subpath imports (the ENOTDIR trap the gate's header names). + // Aliasing is graph-wide, so packages still loaded from dist (objectql, + // plugin-security, …) resolve their own `@objectstack/metadata-core` + // imports to this same single source instance rather than a second copy. + alias: [ + { + find: /^@objectstack\/metadata-core$/, + replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts'), + }, + ], + }, test: { projects: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06c048eec0..80e1771301 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1829,6 +1829,9 @@ importers: '@objectstack/metadata': specifier: workspace:* version: link:../../metadata + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@objectstack/objectql': specifier: workspace:* version: link:../../objectql