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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27661-owner-anchor-one-reader.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 16 additions & 19 deletions packages/lint/src/validate-external-owner-sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] {
Expand Down
106 changes: 27 additions & 79 deletions packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown>)[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<Record<string, unknown>>): boolean {
if (!def || typeof def !== 'object' || Array.isArray(def)) return false;
const actual = { ...(def as Record<string, unknown>) };
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
Expand All @@ -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<string, unknown>).includes(OWNER_COLUMN);
return isOwnerAnchorUnprovable(schema);
}
18 changes: 8 additions & 10 deletions packages/plugins/plugin-sharing/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,7 @@
"emptyGroupValueFor (function)",
"expandEmptyOperator (function)",
"externalBindingDatasourceRefusal (function)",
"externalColumnMapTargets (function)",
"fieldForm (const)",
"fieldGroupBodyTableRefusal (function)",
"filterSubtreeProvenanceOf (function)",
Expand Down Expand Up @@ -926,6 +927,7 @@
"isMultiValueField (function)",
"isNowDefaultToken (function)",
"isOrganizationUnique (function)",
"isOwnerAnchorUnprovable (function)",
"isPlainRecord (function)",
"isPublicSharingEnabled (function)",
"isReadableIds (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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)",
Expand Down
Loading
Loading