diff --git a/.changeset/27661-owner-anchor-one-reader.md b/.changeset/27661-owner-anchor-one-reader.md new file mode 100644 index 00000000000..9aa011f1ff8 --- /dev/null +++ b/.changeset/27661-owner-anchor-one-reader.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +'@objectstack/plugin-sharing': patch +'@objectstack/lint': patch +--- + +fix(spec): one reader decides whether an external object's owner is provable, and record sharing and lint both read it + +Clause-②: yes +Metadata-protocol: none + +An external object (ADR-0015) gets the platform's `owner_id` injected but no storage for it. Record sharing narrows such an object's member reads and bulk writes to no row by ownership unless the author declares where the owner comes from: a declared `owner_id` field, or an `external.columnMap` entry that maps a remote column onto `owner_id`. That question was answered in three places. The spec's provenance helper did not know the `columnMap` arm, while plugin-sharing and lint each read `columnMap` with their own copy. + +- **`isOwnerAnchorUnprovable(def)`** (`@objectstack/spec/data`) is now the one answer: `true` when `resolveInjectedColumnProvenance(def, 'owner_id')` is `injected-unprovisioned` and no `external.columnMap` value is `owner_id`. Plugin-sharing's `hasPhantomOwnerAnchor` and lint's `hasUnprovableOwnerAnchor` return it. +- **`externalColumnMapTargets(def, field)`** reads whether `external.columnMap` maps a remote column onto `field`. Lint's `security-external-owner-unmapped` uses it for its "mapped but not declared" wording. +- **Public surface.** `@objectstack/spec/data` gains the two exports above; no existing export changes its answer. +- **`resolveInjectedColumnProvenance` is unchanged.** A `columnMap` entry does not change who wrote the `owner_id` field, and the lint write rule depends on that: on a plugin driver the map is not applied, so only a declared field lets a member write. + +Read scopes, write scopes, share-grant refusals and lint findings are unchanged for registered objects. On hand-built documents, two readings move: an object with no `owner_id` key that would be injected one now reads as unprovable, which every sharing caller already guards by checking for the field first; and an object whose tier withholds `owner_id` but which declares a field identical to the platform's now reads as the author's, the spec's existing rule. Both still match no remote row the author did not vouch for. diff --git a/packages/lint/src/validate-external-owner-sharing.ts b/packages/lint/src/validate-external-owner-sharing.ts index f0a7b40acc6..6932117d097 100644 --- a/packages/lint/src/validate-external-owner-sharing.ts +++ b/packages/lint/src/validate-external-owner-sharing.ts @@ -41,7 +41,8 @@ * the same anchor: a declared `owner_id` scopes them by owner, while the * platform's own injected one, with no `columnMap` entry naming it, proves no * owner, so on an owner-scoped model a member's read scope admits no row - * (plugin-sharing `hasPhantomOwnerAnchor`, #27425) and an edit answers 404. + * (`isOwnerAnchorUnprovable`, `@objectstack/spec/data`, which plugin-sharing's + * `hasPhantomOwnerAnchor` reads; #27425) and an edit answers 404. * * ## The two arms * @@ -76,7 +77,11 @@ * accept set. */ -import { resolveInjectedColumnProvenance } from '@objectstack/spec/data'; +import { + externalColumnMapTargets, + isOwnerAnchorUnprovable, + resolveInjectedColumnProvenance, +} from '@objectstack/spec/data'; import { recordsOf } from './object-graph.js'; import { effectiveSharingModelOf } from './validate-sharing-rule-enforceability.js'; @@ -132,13 +137,6 @@ export const READ_ONLY_EXTERNAL_WAY_OUT = "declare sharingModel: 'public_read' (no member writes a read-only binding, and on 'private' a member " + 'reads no row: no declared field or external.columnMap entry names the remote owner_id)'; -/** Does `external.columnMap` (remote column → local field) map some remote column to `field`? */ -function columnMapTargets(external: AnyRec, field: string): boolean { - const map = external.columnMap; - if (!map || typeof map !== 'object' || Array.isArray(map)) return false; - return Object.values(map as AnyRec).includes(field); -} - /** * The column whose missing storage refuses every member write on this object, * and whether the injection supplies it at all — or `undefined` when the @@ -193,21 +191,20 @@ export function externalOwnershipGap( if (readOnlyDatasources.has(obj.datasource)) return undefined; const undeclared = undeclaredOwnershipColumn(obj); if (!undeclared) return undefined; - return { ...undeclared, mapped: columnMapTargets(external as AnyRec, undeclared.column) }; + return { ...undeclared, mapped: externalColumnMapTargets(obj, undeclared.column) }; } /** - * [#27425] The lint's reading of plugin-sharing's `hasPhantomOwnerAnchor`: an - * external object whose `owner_id` is the platform's injected anchor (no - * declared field) and no `external.columnMap` entry targets it. No owner is - * provable for such an object, so record sharing admits no row by ownership on - * an owner-scoped model, whatever the binding's writability. + * [#27425] An external object whose `owner_id` is the platform's injected + * anchor (no declared field) and no `external.columnMap` entry targets it. No + * owner is provable for such an object, so record sharing admits no row by + * ownership on an owner-scoped model, whatever the binding's writability. + * The verdict is the spec's `isOwnerAnchorUnprovable`, the same function + * plugin-sharing's `hasPhantomOwnerAnchor` reads, so this warning and the + * runtime narrowing it describes cannot disagree. */ export function hasUnprovableOwnerAnchor(obj: AnyRec): boolean { - const external = obj.external; - if (!external || typeof external !== 'object' || Array.isArray(external)) return false; - if (columnMapTargets(external as AnyRec, OWNER_COLUMN)) return false; - return resolveInjectedColumnProvenance(obj, OWNER_COLUMN) === 'injected-unprovisioned'; + return isOwnerAnchorUnprovable(obj); } export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] { diff --git a/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts b/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts index 211080fcfdb..938c2c94ae9 100644 --- a/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts +++ b/packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts @@ -47,12 +47,18 @@ * So the question is not "is this object federated?" but "is this object's * `owner_id` the anchor the PLATFORM injected, or a column the AUTHOR * declared?" — identity against the shipped declaration, never a pattern match - * on a public grammar. Here the shipped declaration is {@link OWNER_FIELD_DEF} - * itself: `applySystemFields` spreads it verbatim - * (`additions.owner_id = { ...OWNER_FIELD_DEF }`) and a declared field of the - * same name suppresses the injection entirely (`if (wantOwner && - * !schema.fields?.owner_id)`), so a registered def that equals the constant can - * only have come from the platform. + * on a public grammar. [#27425] And past provenance, one more declaration + * counts: an `external.columnMap` entry that maps a remote column onto + * `owner_id` names the column that carries the owner, so the anchor is not + * phantom either. + * + * Both halves are read from `@objectstack/spec/data`, never restated here: + * {@link isOwnerAnchorUnprovable} is the one reader of "no owner is provable + * from declared metadata" (the provenance verdict + * `resolveInjectedColumnProvenance(schema, 'owner_id') === + * 'injected-unprovisioned'` plus the `columnMap` arm), and lint's + * `hasUnprovableOwnerAnchor` reads the same function, so the author-time + * warning and this runtime narrowing cannot drift apart. * * Deliberately NOT an authorable "this column is phantom" flag: provenance is a * fact about who wrote the column, and letting metadata claim it would hand @@ -71,74 +77,30 @@ * `@objectstack/plugin-security` carries a structurally identical module for the * TENANT anchor (`federated-phantom-anchors.ts`, #7835), and #7738 / PR #7833 * withheld `DriverOptions.tenantId` for `external` objects one layer down in - * `@objectstack/objectql`. Three consumers, one producer. The duplication is - * deliberate and temporary: this plugin does not depend on - * `@objectstack/plugin-security` (it declares the narrow slices it probes — - * see `SharingSecurityProbe`), so importing that copy would create a plugin - * dependency edge to save nine lines. The maintainer's 2026-08-12 ruling on - * #7865 chose direction **B** — the registry keeps injecting and grows a - * machine-readable provenance marker, and consumer guards converge on that - * marker **as they are touched**. When that marker lands, this module and its - * plugin-security twin collapse into one read of it; keeping the two shaped - * identically is what makes that collapse mechanical. + * `@objectstack/objectql`. Per the maintainer's 2026-08-12 ruling on #7865 + * (direction **B** — the registry keeps injecting and consumers converge on the + * spec's provenance marker as they are touched), this module has converged: + * it holds no copy of the identity comparison and no `columnMap` reader of its + * own. The tenant twin converges the same way when next touched; it has no + * `columnMap` arm to carry. */ -// [#6562] The injected-column DEFINITION table lives in `@objectstack/metadata-core` -// (the registry that provisions the columns reads the same one, and the `/meta` read -// path consumes it too). Importing the constant — rather than restating its shape -// here — is what makes the provenance test track the producer instead of a copy that -// can drift silently. `@objectstack/objectql` re-exports it, but only from -// `registry.js`, not from its package entry point, so `metadata-core` is the -// importable home; its own dependencies are `{ @objectstack/spec, zod }`, so this -// adds no cycle. -import { OWNER_FIELD_DEF } from '@objectstack/metadata-core'; - -/** The one column ownership scoping ever emits a predicate for. */ -const OWNER_COLUMN = 'owner_id'; - -/** Pick a field definition out of either registered `fields` shape. */ -function readFieldDef(schema: unknown, name: string): unknown { - const fields = (schema as { fields?: unknown } | null | undefined)?.fields; - if (Array.isArray(fields)) { - return fields.find((f) => (f as { name?: unknown } | null)?.name === name); - } - if (fields && typeof fields === 'object') { - return (fields as Record)[name]; - } - return undefined; -} - -/** - * Structural identity against the shipped constant. Flat by construction — - * every value in {@link OWNER_FIELD_DEF} is a primitive — so a flat comparison - * is exact rather than a shortcut, and an extra or missing key is a mismatch - * (see "Direction of an inexact match" in the module docs). - * - * The array shape carries an additional `name` key that the object shape - * expresses as the map key; it is excluded so both shapes reach the same - * verdict about the same column. - */ -function equalsShippedDef(def: unknown, shipped: Readonly>): boolean { - if (!def || typeof def !== 'object' || Array.isArray(def)) return false; - const actual = { ...(def as Record) }; - delete actual.name; - const shippedKeys = Object.keys(shipped); - if (Object.keys(actual).length !== shippedKeys.length) return false; - return shippedKeys.every((k) => actual[k] === shipped[k]); -} +import { isOwnerAnchorUnprovable, platformProvisionsStorage } from '@objectstack/spec/data'; /** * Is `schema` a federated (ADR-0015 `external`) object binding a remote table? * The platform provisions no storage for one, so nothing it injects is real. + * The spec's `platformProvisionsStorage` is the one spelling of that predicate + * (#7865's convergence map), so this reads it rather than restating it. */ export function isFederatedObject(schema: unknown): boolean { - return (schema as { external?: unknown } | null | undefined)?.external != null; + return !platformProvisionsStorage(schema); } /** * Does this object's `owner_id` exist only in the registry — i.e. is it the * platform's injected anchor on an object whose storage the platform never - * provisioned? + * provisioned, with nothing declared naming a remote column for it? * * `true` ⇒ no owner predicate can be proven for the object: nothing declared * says a remote column answers to `owner_id`, even where a driver happens to @@ -147,24 +109,10 @@ export function isFederatedObject(schema: unknown): boolean { * never widens. `false` for every local object (the platform DID provision the * column there), for a federated object whose author declared a real remote * `owner_id`, and for one whose `external.columnMap` maps a remote column onto - * `owner_id` (see {@link mapsRemoteColumnOntoOwner}). + * `owner_id`. The built-in SQL drivers apply that map and serve the column; a + * driver that does not apply it returns no `owner_id`, so the owner predicate + * scoping composes matches no row — the fail direction stays closed either way. */ export function hasPhantomOwnerAnchor(schema: unknown): boolean { - if (!isFederatedObject(schema)) return false; - if (mapsRemoteColumnOntoOwner(schema)) return false; - return equalsShippedDef(readFieldDef(schema, OWNER_COLUMN), OWNER_FIELD_DEF); -} - -/** - * [#27425] Does the object's `external.columnMap` (remote column → local field) - * name `owner_id` as a target? That is the author declaring which remote column - * carries the owner, so the anchor is not phantom. The built-in SQL drivers - * apply the map and serve the column; a driver that does not apply it returns - * no `owner_id`, so the owner predicate scoping composes matches no row — the - * fail direction stays closed either way. - */ -function mapsRemoteColumnOntoOwner(schema: unknown): boolean { - const columnMap = (schema as { external?: { columnMap?: unknown } } | null | undefined)?.external?.columnMap; - if (!columnMap || typeof columnMap !== 'object' || Array.isArray(columnMap)) return false; - return Object.values(columnMap as Record).includes(OWNER_COLUMN); + return isOwnerAnchorUnprovable(schema); } diff --git a/packages/plugins/plugin-sharing/vitest.config.ts b/packages/plugins/plugin-sharing/vitest.config.ts index 32c1ba89502..2aec8cf61e9 100644 --- a/packages/plugins/plugin-sharing/vitest.config.ts +++ b/packages/plugins/plugin-sharing/vitest.config.ts @@ -41,16 +41,14 @@ export default defineConfig({ // (ENOTDIR) — a config that looks right and fails at run time. Same shape // as `service-storage`'s and `service-knowledge`'s. // - // `@objectstack/metadata-core` (#7858) is on the same footing, and reaches - // the tests two ways: `federated-phantom-owner-scoping.test.ts` imports - // `OWNER_FIELD_DEF` to build its fixture, and `federated-phantom-anchors.ts` - // — pulled in transitively by `sharing-service.ts` — imports the same - // constant to compare against. That constant IS the provenance test's - // subject: it decides whether a federated object's `owner_id` is the - // platform's injected anchor or a column its author declared. Resolved from - // `dist/`, a stale copy would move the verdict without moving the assertion - // — the guard would answer about a definition that is no longer shipped, - // and stay green while doing it. + // `@objectstack/metadata-core` (#7858) is on the same footing: + // `federated-phantom-owner-scoping.test.ts` imports `OWNER_FIELD_DEF` to + // build its fixture. That constant IS the provenance test's subject: it + // decides whether a federated object's `owner_id` is the platform's + // injected anchor or a column its author declared. Resolved from `dist/`, a + // stale copy would move the fixture without moving the assertion. (The + // guard itself, `federated-phantom-anchors.ts`, reads the verdict from + // `@objectstack/spec/data` since #27661, and compares nothing here.) alias: [ { find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') }, { find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts') }, diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index b5d09c0afcb..80e31296d4a 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -874,6 +874,7 @@ "emptyGroupValueFor (function)", "expandEmptyOperator (function)", "externalBindingDatasourceRefusal (function)", + "externalColumnMapTargets (function)", "fieldForm (const)", "fieldGroupBodyTableRefusal (function)", "filterSubtreeProvenanceOf (function)", @@ -926,6 +927,7 @@ "isMultiValueField (function)", "isNowDefaultToken (function)", "isOrganizationUnique (function)", + "isOwnerAnchorUnprovable (function)", "isPlainRecord (function)", "isPublicSharingEnabled (function)", "isReadableIds (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 8c4479e2c51..6fb6c083565 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -860,6 +860,7 @@ "emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)", "expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)", "externalBindingDatasourceRefusal": "src/data/external-binding-datasource.ts#externalBindingDatasourceRefusal (function)", + "externalColumnMapTargets": "src/data/injected-system-column-provenance.ts#externalColumnMapTargets (function)", "fieldForm": "src/data/field.form.ts#fieldForm (const)", "fieldGroupBodyTableRefusal": "src/data/related-list-body-table.ts#fieldGroupBodyTableRefusal (function)", "filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)", @@ -912,6 +913,7 @@ "isMultiValueField": "src/data/field-value.zod.ts#isMultiValueField (function)", "isNowDefaultToken": "src/data/default-value-tokens.ts#isNowDefaultToken (function)", "isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)", + "isOwnerAnchorUnprovable": "src/data/injected-system-column-provenance.ts#isOwnerAnchorUnprovable (function)", "isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)", "isPublicSharingEnabled": "src/data/object.zod.ts#isPublicSharingEnabled (function)", "isReadableIds": "src/data/readable-ids.ts#isReadableIds (function)", diff --git a/packages/spec/src/data/injected-system-column-provenance.test.ts b/packages/spec/src/data/injected-system-column-provenance.test.ts index 1432d4a998b..8c0cf8c3552 100644 --- a/packages/spec/src/data/injected-system-column-provenance.test.ts +++ b/packages/spec/src/data/injected-system-column-provenance.test.ts @@ -21,8 +21,10 @@ import { TENANT_SCOPE_FIELD_DEF, OWNER_FIELD_DEF, OWNING_BUSINESS_UNIT_FIELD_DEF, + externalColumnMapTargets, injectedSystemColumnDefs, isInjectedColumnDefinition, + isOwnerAnchorUnprovable, platformProvisionsStorage, resolveInjectedColumnProvenance, unprovisionedInjectedColumns, @@ -124,3 +126,45 @@ describe('provenance derivation at its spec home — where the author-time linte expect(resolveInjectedColumnProvenance(external(), 'id')).toBe('absent'); }); }); + +describe('[#27425] owner anchor provable from declared metadata: the one reader', () => { + const mapped = () => ({ + ...external(), + external: { remoteName: 'customers', columnMap: { account_owner: 'owner_id' } }, + }); + + it('answers unprovable for the injected anchor on an external object, in both document stages', () => { + // Pre-injection (no `owner_id` key) and post-injection (the platform's own definition). + expect(isOwnerAnchorUnprovable(external())).toBe(true); + const registered = { ...external(), fields: { ...external().fields, owner_id: { ...OWNER_FIELD_DEF } } }; + expect(isOwnerAnchorUnprovable(registered)).toBe(true); + expect(isOwnerAnchorUnprovable({ ...external(), fields: [{ name: 'owner_id', ...OWNER_FIELD_DEF }] })).toBe(true); + }); + + it('takes the columnMap arm the provenance verdict deliberately does not', () => { + // The map names the remote column that carries the owner, so an owner is provable... + expect(isOwnerAnchorUnprovable(mapped())).toBe(false); + expect(externalColumnMapTargets(mapped(), 'owner_id')).toBe(true); + // ...while the field itself is still the platform's anchor: provenance does not move, + // which is what lets a write-path consumer tell "mapped" from "declared". + expect(resolveInjectedColumnProvenance(mapped(), 'owner_id')).toBe('injected-unprovisioned'); + }); + + it('a declared owner_id is provable; a local object and an object with no owner tier are not unprovable', () => { + const declared = { ...external(), fields: { ...external().fields, owner_id: { type: 'lookup', reference: 'sys_user' } } }; + expect(isOwnerAnchorUnprovable(declared)).toBe(false); + expect(isOwnerAnchorUnprovable(local())).toBe(false); + expect(isOwnerAnchorUnprovable({ ...external(), ownership: 'none' })).toBe(false); + expect(isOwnerAnchorUnprovable({ ...external(), systemFields: false })).toBe(false); + }); + + it('reads columnMap targets tolerantly and only as values (remote column → local field)', () => { + // A KEY named owner_id is a remote column, not a mapping onto the local owner. + const keyOnly = { ...external(), external: { remoteName: 'customers', columnMap: { owner_id: 'account_owner' } } }; + expect(externalColumnMapTargets(keyOnly, 'owner_id')).toBe(false); + expect(isOwnerAnchorUnprovable(keyOnly)).toBe(true); + for (const bad of [undefined, null, 'x', [], { external: 'x' }, { external: { columnMap: ['owner_id'] } }]) { + expect(externalColumnMapTargets(bad, 'owner_id')).toBe(false); + } + }); +}); diff --git a/packages/spec/src/data/injected-system-column-provenance.ts b/packages/spec/src/data/injected-system-column-provenance.ts index 68c13496558..db49a0f8a02 100644 --- a/packages/spec/src/data/injected-system-column-provenance.ts +++ b/packages/spec/src/data/injected-system-column-provenance.ts @@ -326,7 +326,15 @@ export function platformProvisionsStorage(def: unknown): boolean { * - #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. + * - #7858 (plugin-sharing): the `owner_id` twin of #7859, with one arm the + * tenant guard has no counterpart for. Since #27425 the owner question is + * "is an owner provable from declared metadata?", and an + * `external.columnMap` entry targeting `owner_id` is a declaration too, so + * `hasPhantomOwnerAnchor(schema)` ⇒ {@link isOwnerAnchorUnprovable} — the + * provenance verdict plus that arm — not the bare verdict. Lint's + * `hasUnprovableOwnerAnchor` reads the same function, and its + * `security-external-owner-unmapped` wording reads + * {@link externalColumnMapTargets}. * - #8116 (lint): the author-time consumer this module moved to the spec for — * expression / semantic-role validation reads the marker and warns. * @@ -386,6 +394,58 @@ export function unprovisionedInjectedColumns(def: unknown): string[] { ); } +/** + * [#27425] Does this object's ADR-0015 `external.columnMap` (remote column → + * local field) map some remote column onto `field`? The author naming which + * remote column backs a local field — a declaration, read off the document + * like every input in this module. Whether a driver honours it is the driver's + * (the built-in SQL drivers apply the map; a plugin driver must return the + * field itself), so a consumer judging a WRITE path reads this as a fact about + * the document, never as proof the column is served. + * + * Tolerant of bare / un-parsed metadata records: anything that is not a plain + * `external` object carrying a plain `columnMap` object answers `false`. + */ +export function externalColumnMapTargets(def: unknown, field: string): boolean { + if (!def || typeof def !== 'object' || Array.isArray(def)) return false; + const external = (def as { external?: unknown }).external; + if (!external || typeof external !== 'object' || Array.isArray(external)) return false; + const columnMap = (external as { columnMap?: unknown }).columnMap; + if (!columnMap || typeof columnMap !== 'object' || Array.isArray(columnMap)) return false; + return Object.values(columnMap as Record).includes(field); +} + +/** + * [#27425] Is NO owner provable for this object from its declared metadata — + * the one reader of "can record-level ownership scope this object at all?" + * + * `true` exactly when `owner_id` is the platform's injected anchor with no + * storage behind it (`resolveInjectedColumnProvenance(def, 'owner_id') === + * 'injected-unprovisioned'`) AND no `external.columnMap` entry targets + * `owner_id` ({@link externalColumnMapTargets}). Either declaration — a + * declared `owner_id` field, or a map entry naming the remote column that + * carries it — is the author vouching for an owner; with neither, a predicate + * over `owner_id` can only be answered by a driver's accident. Record sharing + * then admits no row by ownership below `org` (plugin-sharing + * `hasPhantomOwnerAnchor`), and lint warns on owner-scoped models. + * + * Deliberately NOT folded into {@link resolveInjectedColumnProvenance}: a + * `columnMap` entry does not change WHO wrote the `owner_id` field — the + * registered definition is still the platform's — and a consumer judging the + * write gates (lint `security-external-owner-unmapped`) needs exactly that + * distinction, because on a plugin driver the map is not applied and the + * declared field is what holds. Provenance answers "who wrote the column"; + * this answers "is an owner provable", and only the owner anchor asks it. + * + * Fail direction: `true` narrows (no owner predicate, no row by ownership), + * `false` keeps ordinary owner scoping, which on a column the remote lacks + * also matches no row. Both are closed; neither widens to every row. + */ +export function isOwnerAnchorUnprovable(def: unknown): boolean { + if (externalColumnMapTargets(def, SystemFieldName.OWNER_ID)) return false; + return resolveInjectedColumnProvenance(def, SystemFieldName.OWNER_ID) === 'injected-unprovisioned'; +} + /** * Read one declared field definition off either `fields` shape — the record * shape (`fields: { organization_id: {...} }`) or the array shape