Skip to content

Commit 684f2f6

Browse files
fix(spec): one reader decides whether an external object's owner is provable, and record sharing and lint both read it (#27794)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiBU4VHoPDw4wUW5UW7aprCx)_ Fixes #27661 Clause-②: yes Metadata-protocol: none **Fable review owed.** This PR touches `packages/spec/src/**` and carries `Clause-②: yes`; it was developed on Opus while the Fable quota is out, and it does not land until a `CONTRACT_REVIEW_TIER` contract review record with a PASS verdict is on its current head. ## 维护者速读 - **改了什么**:「外部对象的 owner 能否从声明的元数据证明」原来有三份判定(spec 的 `resolveInjectedColumnProvenance`、plugin-sharing 的 `hasPhantomOwnerAnchor`、lint 的 `hasUnprovableOwnerAnchor` 加 `columnMapTargets`)。现在 spec 新增一个导出 `isOwnerAnchorUnprovable`(provenance 判定 + `external.columnMap` 映到 `owner_id` 这一臂)和它用的 `externalColumnMapTargets`,plugin-sharing 与 lint 都改成直接读它,删掉各自的 `columnMap` 读取和身份比较副本;spec 里 #7858 那一行收敛表按现状重写。 - **为什么改**:#27425 只在 plugin-sharing 里加了 `columnMap` 分支,spec 的 helper 不认、收敛表也没记;下一个按收敛表对齐 helper 的消费方会悄悄放宽或收窄这类对象。 - **风险与回滚**:`resolveInjectedColumnProvenance` 本身不动(lint 写规则靠它区分「只映射未声明」)。已注册对象上读/写范围、共享拒绝和 lint 结论都不变;两处只在手搓文档上才会动的边角读法写在 Acceptance notes,两边都 fail closed。回滚即 revert 本 PR。 - **先看**:`packages/spec/src/data/injected-system-column-provenance.ts`(新增的两个函数与收敛表 #7858 行);`packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts`(`hasPhantomOwnerAnchor` 变成一行);`packages/lint/src/validate-external-owner-sharing.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/external-owner-anchor-sql-read.test.ts src/federated-phantom-owner-scoping.test.ts`,预期全部通过。 - **需要你做的**:无,席位落地(合约复审 PASS 之后)。 ## What changes - **`packages/spec/src/data/injected-system-column-provenance.ts`** gains two exports on `@objectstack/spec/data`: - `externalColumnMapTargets(def, field)`: does `external.columnMap` (remote column to local field) map some remote column onto `field`? Tolerant of un-parsed records; a non-object `external` or `columnMap` answers `false`. - `isOwnerAnchorUnprovable(def)`: `true` exactly when `resolveInjectedColumnProvenance(def, 'owner_id') === 'injected-unprovisioned'` and `externalColumnMapTargets(def, 'owner_id')` is `false`. The one reader of "is an owner provable from declared metadata". - `resolveInjectedColumnProvenance` is unchanged, on purpose: a `columnMap` entry does not change who wrote the `owner_id` field, and lint's `security-external-owner-unmapped` needs that distinction (on a plugin driver the map is not applied, so only a declared field lets a member write). Folding the arm into the provenance verdict would have dropped that rule's mapped-but-undeclared findings. The new function's doc says so. - The convergence map's #7858 row now reads the head: plugin-sharing converges on `isOwnerAnchorUnprovable` (provenance plus the `columnMap` arm), and lint reads the same function. - **`packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts`**: `hasPhantomOwnerAnchor(schema)` returns `isOwnerAnchorUnprovable(schema)`. The module's own `readFieldDef`, `equalsShippedDef`, `isFederatedObject` and `mapsRemoteColumnOntoOwner` copies are deleted (none was exported from the package entry; `git grep` finds no other importer), and the module doc's convergence paragraph is rewritten. `sharing-service.ts` is untouched. - **`packages/lint/src/validate-external-owner-sharing.ts`**: `hasUnprovableOwnerAnchor(obj)` returns `isOwnerAnchorUnprovable(obj)`; the private `columnMapTargets` is replaced by `externalColumnMapTargets` in `externalOwnershipGap`. Messages and hints are untouched. - `packages/plugins/plugin-sharing/vitest.config.ts`: the `metadata-core` alias comment no longer says the guard compares against `OWNER_FIELD_DEF` (only the scoping test's fixture imports it now). - `packages/spec/api-surface/data.json` and `export-origins/data.json` regenerated (two new names each). - Changeset `.changeset/27661-owner-anchor-one-reader.md`: patch on `@objectstack/spec`, `@objectstack/plugin-sharing`, `@objectstack/lint`. ## Tests On the final head `e402956d` (`fe45a398` plus the merge of `origin/main` `79d78b4e`), heavy runs under `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/spec test`: Test Files 754 passed (754), Tests 22410 passed, 1 todo (22411); exit 0. Taken on `fe45a398`; the merge brought no change to `packages/spec/src/data/`. - `pnpm --filter @objectstack/spec exec vitest run src/data/injected-system-column-provenance.test.ts`: 10 passed, 4 of them new (the unprovable verdict in both document stages and both `fields` shapes; the `columnMap` arm with provenance staying `injected-unprovisioned`; declared, local and owner-less tiers; `columnMap` read as values only, tolerant of bad shapes). - `packages/plugins/plugin-sharing` `vitest run`: Test Files 58 passed (58), Tests 1290 passed (1290), on `e402956d`. The federated phantom-scoping suites and `external-owner-anchor-sql-read.test.ts` are unchanged and green. - `packages/lint` `vitest run`: Test Files 178 passed (178), Tests 7623 passed (7623), on `e402956d`. `validate-external-owner-sharing.test.ts` is unchanged and green, so the rule's findings and hints keep their bytes. - `pnpm --filter @objectstack/metadata-core test` (the producer's provenance matrix, through the re-export): Test Files 21 passed (21), Tests 873 passed (873). - `pnpm --filter @objectstack/spec typecheck`, `pnpm --filter @objectstack/lint typecheck`, `pnpm --filter @objectstack/plugin-sharing typecheck`: exit 0. <details><summary>Ablation: both consumers really read the spec reader</summary> Committed head first; the mutation deletes the `columnMap` guard line from `isOwnerAnchorUnprovable` in `packages/spec/src/data/injected-system-column-provenance.ts` (`grep -c` of the guard line: 0 mutated, 1 restored, as at `HEAD`), then `pnpm --filter @objectstack/spec build`, because plugin-sharing and lint resolve `@objectstack/spec/data` through `dist/`. - Mutant: plugin-sharing `external-owner-anchor-sql-read.test.ts` + `federated-phantom-owner-scoping.test.ts` 3 failed of 55 (the `columnMap` to `owner_id` arms: "own rows only"); lint `validate-external-owner-sharing.test.ts` + `validate-security-posture.test.ts` 1 failed of 202 ("keeps the private recommendation where the owner is provable"). - Restored with `git checkout HEAD`, `git diff HEAD` empty, spec rebuilt: 55 of 55 and 202 of 202 pass. </details> ## Acceptance notes - **Edge move (a), unreachable at runtime.** A hand-built federated document with no `owner_id` key at all, on an `ownership: 'user'` object, used to answer `false` in plugin-sharing (its copy needed the key present) and now answers `true` (the spec treats a pre-injection document as carrying the injected anchor, as lint already did). Every `hasPhantomOwnerAnchor` caller in `sharing-service.ts` either checks `hasOwnerField(schema)` first (`buildReadFilter`, `buildWriteFilter`, `inertGrantReason`) or is the verdict-free write-deny diagnostic, and the registry always injects `owner_id` on such an object, so no registered schema reaches it. - **Edge move (b), fails closed both ways.** An external object whose tier withholds `owner_id` (`ownership: 'none' | 'org' | 'business_unit'`, `systemFields: false`) but which declares a field byte-identical to `OWNER_FIELD_DEF` used to read as phantom in plugin-sharing (deny-all below `org`) and now reads as the author's field (the spec's existing rule: with no injected definition, any declaration is the author's), so ordinary owner scoping applies. On a remote that lacks the column that predicate matches no row. Agreed with the epic PM before the edit. - Non-object `external` values (rejected by Zod, so only on un-parsed lint input): lint's `hasUnprovableOwnerAnchor` used to answer `false` for them and now follows the spec's `external != null` storage predicate, as plugin-sharing already did. - `packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts` L251-L256 still describes `hasPhantomOwnerAnchor` as hand-rolled. It is a historical test comment outside this card's file surface; noted, not filed (`carrier: none`). - Serial constraint from the claim: #27662 edits plugin-sharing's write-deny diagnostic in `sharing-service.ts`; this PR does not touch that file, so the two do not overlap. ## Verification Local gates on `e402956d`, every exit code written to a file before any pipe. `node scripts/pm/dispatch-gates.mjs --commands` derived 86 families (the same 86 before and after the merge), every one was run after a full `pnpm build`: - 86 exit 0, among them spec `check:api-surface` ("public API surface + factory signatures unchanged"), `check:export-origins` ("5651 exports across 19 entry points resolve exactly as recorded"), `check:docs` ("232 generated files in sync"), `check:authorable-surface`, `check:llms-txt`, `check:generated`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-debt`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:i18n`, `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check-adr-0087-registration --base origin/main`, `check-empty-changeset --base origin/main`, `check-changeset-no-major --base origin/main` and the `--self-test` batteries. - `dispatch-gates --ran`: "86 derived, 86 run, 0 NOT-MEASURED, 0 UNRUN". - `pnpm --filter @objectstack/spec check:generated` after the merge and a spec rebuild: all 14 artifacts up to date. Before the merge it found `api-surface/` and `export-origins/` stale (the two new names), regenerated with `gen:api-surface` and `gen:export-origins`. - `origin/main` merged once, as `e402956d`, via `scripts/pm/os-regen-merge.sh` (main had not moved either regenerated artifact). - The full battery (`pnpm lint`, Test Core shards, the UI jobs) is CI's and was not re-run locally. No dogfood battery: no `!` in the title and no `(narrowing)` arm. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (The new functions are document derivations beside the existing provenance derivation, tolerant of un-parsed input like the rest of the module.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2eCEdjSZBk7DGrPPFg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5f4a06 commit 684f2f6

8 files changed

Lines changed: 179 additions & 109 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-sharing': patch
4+
'@objectstack/lint': patch
5+
---
6+
7+
fix(spec): one reader decides whether an external object's owner is provable, and record sharing and lint both read it
8+
9+
Clause-②: yes
10+
Metadata-protocol: none
11+
12+
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.
13+
14+
- **`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.
15+
- **`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.
16+
- **Public surface.** `@objectstack/spec/data` gains the two exports above; no existing export changes its answer.
17+
- **`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.
18+
19+
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.

‎packages/lint/src/validate-external-owner-sharing.ts‎

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
* the same anchor: a declared `owner_id` scopes them by owner, while the
4242
* platform's own injected one, with no `columnMap` entry naming it, proves no
4343
* owner, so on an owner-scoped model a member's read scope admits no row
44-
* (plugin-sharing `hasPhantomOwnerAnchor`, #27425) and an edit answers 404.
44+
* (`isOwnerAnchorUnprovable`, `@objectstack/spec/data`, which plugin-sharing's
45+
* `hasPhantomOwnerAnchor` reads; #27425) and an edit answers 404.
4546
*
4647
* ## The two arms
4748
*
@@ -76,7 +77,11 @@
7677
* accept set.
7778
*/
7879

79-
import { resolveInjectedColumnProvenance } from '@objectstack/spec/data';
80+
import {
81+
externalColumnMapTargets,
82+
isOwnerAnchorUnprovable,
83+
resolveInjectedColumnProvenance,
84+
} from '@objectstack/spec/data';
8085
import { recordsOf } from './object-graph.js';
8186
import { effectiveSharingModelOf } from './validate-sharing-rule-enforceability.js';
8287

@@ -132,13 +137,6 @@ export const READ_ONLY_EXTERNAL_WAY_OUT =
132137
"declare sharingModel: 'public_read' (no member writes a read-only binding, and on 'private' a member " +
133138
'reads no row: no declared field or external.columnMap entry names the remote owner_id)';
134139

135-
/** Does `external.columnMap` (remote column → local field) map some remote column to `field`? */
136-
function columnMapTargets(external: AnyRec, field: string): boolean {
137-
const map = external.columnMap;
138-
if (!map || typeof map !== 'object' || Array.isArray(map)) return false;
139-
return Object.values(map as AnyRec).includes(field);
140-
}
141-
142140
/**
143141
* The column whose missing storage refuses every member write on this object,
144142
* and whether the injection supplies it at all — or `undefined` when the
@@ -193,21 +191,20 @@ export function externalOwnershipGap(
193191
if (readOnlyDatasources.has(obj.datasource)) return undefined;
194192
const undeclared = undeclaredOwnershipColumn(obj);
195193
if (!undeclared) return undefined;
196-
return { ...undeclared, mapped: columnMapTargets(external as AnyRec, undeclared.column) };
194+
return { ...undeclared, mapped: externalColumnMapTargets(obj, undeclared.column) };
197195
}
198196

199197
/**
200-
* [#27425] The lint's reading of plugin-sharing's `hasPhantomOwnerAnchor`: an
201-
* external object whose `owner_id` is the platform's injected anchor (no
202-
* declared field) and no `external.columnMap` entry targets it. No owner is
203-
* provable for such an object, so record sharing admits no row by ownership on
204-
* an owner-scoped model, whatever the binding's writability.
198+
* [#27425] An external object whose `owner_id` is the platform's injected
199+
* anchor (no declared field) and no `external.columnMap` entry targets it. No
200+
* owner is provable for such an object, so record sharing admits no row by
201+
* ownership on an owner-scoped model, whatever the binding's writability.
202+
* The verdict is the spec's `isOwnerAnchorUnprovable`, the same function
203+
* plugin-sharing's `hasPhantomOwnerAnchor` reads, so this warning and the
204+
* runtime narrowing it describes cannot disagree.
205205
*/
206206
export function hasUnprovableOwnerAnchor(obj: AnyRec): boolean {
207-
const external = obj.external;
208-
if (!external || typeof external !== 'object' || Array.isArray(external)) return false;
209-
if (columnMapTargets(external as AnyRec, OWNER_COLUMN)) return false;
210-
return resolveInjectedColumnProvenance(obj, OWNER_COLUMN) === 'injected-unprovisioned';
207+
return isOwnerAnchorUnprovable(obj);
211208
}
212209

213210
export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharingFinding[] {

‎packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts‎

Lines changed: 27 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,18 @@
4747
* So the question is not "is this object federated?" but "is this object's
4848
* `owner_id` the anchor the PLATFORM injected, or a column the AUTHOR
4949
* declared?" — identity against the shipped declaration, never a pattern match
50-
* on a public grammar. Here the shipped declaration is {@link OWNER_FIELD_DEF}
51-
* itself: `applySystemFields` spreads it verbatim
52-
* (`additions.owner_id = { ...OWNER_FIELD_DEF }`) and a declared field of the
53-
* same name suppresses the injection entirely (`if (wantOwner &&
54-
* !schema.fields?.owner_id)`), so a registered def that equals the constant can
55-
* only have come from the platform.
50+
* on a public grammar. [#27425] And past provenance, one more declaration
51+
* counts: an `external.columnMap` entry that maps a remote column onto
52+
* `owner_id` names the column that carries the owner, so the anchor is not
53+
* phantom either.
54+
*
55+
* Both halves are read from `@objectstack/spec/data`, never restated here:
56+
* {@link isOwnerAnchorUnprovable} is the one reader of "no owner is provable
57+
* from declared metadata" (the provenance verdict
58+
* `resolveInjectedColumnProvenance(schema, 'owner_id') ===
59+
* 'injected-unprovisioned'` plus the `columnMap` arm), and lint's
60+
* `hasUnprovableOwnerAnchor` reads the same function, so the author-time
61+
* warning and this runtime narrowing cannot drift apart.
5662
*
5763
* Deliberately NOT an authorable "this column is phantom" flag: provenance is a
5864
* fact about who wrote the column, and letting metadata claim it would hand
@@ -71,74 +77,30 @@
7177
* `@objectstack/plugin-security` carries a structurally identical module for the
7278
* TENANT anchor (`federated-phantom-anchors.ts`, #7835), and #7738 / PR #7833
7379
* withheld `DriverOptions.tenantId` for `external` objects one layer down in
74-
* `@objectstack/objectql`. Three consumers, one producer. The duplication is
75-
* deliberate and temporary: this plugin does not depend on
76-
* `@objectstack/plugin-security` (it declares the narrow slices it probes —
77-
* see `SharingSecurityProbe`), so importing that copy would create a plugin
78-
* dependency edge to save nine lines. The maintainer's 2026-08-12 ruling on
79-
* #7865 chose direction **B** — the registry keeps injecting and grows a
80-
* machine-readable provenance marker, and consumer guards converge on that
81-
* marker **as they are touched**. When that marker lands, this module and its
82-
* plugin-security twin collapse into one read of it; keeping the two shaped
83-
* identically is what makes that collapse mechanical.
80+
* `@objectstack/objectql`. Per the maintainer's 2026-08-12 ruling on #7865
81+
* (direction **B** — the registry keeps injecting and consumers converge on the
82+
* spec's provenance marker as they are touched), this module has converged:
83+
* it holds no copy of the identity comparison and no `columnMap` reader of its
84+
* own. The tenant twin converges the same way when next touched; it has no
85+
* `columnMap` arm to carry.
8486
*/
8587

86-
// [#6562] The injected-column DEFINITION table lives in `@objectstack/metadata-core`
87-
// (the registry that provisions the columns reads the same one, and the `/meta` read
88-
// path consumes it too). Importing the constant — rather than restating its shape
89-
// here — is what makes the provenance test track the producer instead of a copy that
90-
// can drift silently. `@objectstack/objectql` re-exports it, but only from
91-
// `registry.js`, not from its package entry point, so `metadata-core` is the
92-
// importable home; its own dependencies are `{ @objectstack/spec, zod }`, so this
93-
// adds no cycle.
94-
import { OWNER_FIELD_DEF } from '@objectstack/metadata-core';
95-
96-
/** The one column ownership scoping ever emits a predicate for. */
97-
const OWNER_COLUMN = 'owner_id';
98-
99-
/** Pick a field definition out of either registered `fields` shape. */
100-
function readFieldDef(schema: unknown, name: string): unknown {
101-
const fields = (schema as { fields?: unknown } | null | undefined)?.fields;
102-
if (Array.isArray(fields)) {
103-
return fields.find((f) => (f as { name?: unknown } | null)?.name === name);
104-
}
105-
if (fields && typeof fields === 'object') {
106-
return (fields as Record<string, unknown>)[name];
107-
}
108-
return undefined;
109-
}
110-
111-
/**
112-
* Structural identity against the shipped constant. Flat by construction —
113-
* every value in {@link OWNER_FIELD_DEF} is a primitive — so a flat comparison
114-
* is exact rather than a shortcut, and an extra or missing key is a mismatch
115-
* (see "Direction of an inexact match" in the module docs).
116-
*
117-
* The array shape carries an additional `name` key that the object shape
118-
* expresses as the map key; it is excluded so both shapes reach the same
119-
* verdict about the same column.
120-
*/
121-
function equalsShippedDef(def: unknown, shipped: Readonly<Record<string, unknown>>): boolean {
122-
if (!def || typeof def !== 'object' || Array.isArray(def)) return false;
123-
const actual = { ...(def as Record<string, unknown>) };
124-
delete actual.name;
125-
const shippedKeys = Object.keys(shipped);
126-
if (Object.keys(actual).length !== shippedKeys.length) return false;
127-
return shippedKeys.every((k) => actual[k] === shipped[k]);
128-
}
88+
import { isOwnerAnchorUnprovable, platformProvisionsStorage } from '@objectstack/spec/data';
12989

13090
/**
13191
* Is `schema` a federated (ADR-0015 `external`) object binding a remote table?
13292
* The platform provisions no storage for one, so nothing it injects is real.
93+
* The spec's `platformProvisionsStorage` is the one spelling of that predicate
94+
* (#7865's convergence map), so this reads it rather than restating it.
13395
*/
13496
export function isFederatedObject(schema: unknown): boolean {
135-
return (schema as { external?: unknown } | null | undefined)?.external != null;
97+
return !platformProvisionsStorage(schema);
13698
}
13799

138100
/**
139101
* Does this object's `owner_id` exist only in the registry — i.e. is it the
140102
* platform's injected anchor on an object whose storage the platform never
141-
* provisioned?
103+
* provisioned, with nothing declared naming a remote column for it?
142104
*
143105
* `true` ⇒ no owner predicate can be proven for the object: nothing declared
144106
* says a remote column answers to `owner_id`, even where a driver happens to
@@ -147,24 +109,10 @@ export function isFederatedObject(schema: unknown): boolean {
147109
* never widens. `false` for every local object (the platform DID provision the
148110
* column there), for a federated object whose author declared a real remote
149111
* `owner_id`, and for one whose `external.columnMap` maps a remote column onto
150-
* `owner_id` (see {@link mapsRemoteColumnOntoOwner}).
112+
* `owner_id`. The built-in SQL drivers apply that map and serve the column; a
113+
* driver that does not apply it returns no `owner_id`, so the owner predicate
114+
* scoping composes matches no row — the fail direction stays closed either way.
151115
*/
152116
export function hasPhantomOwnerAnchor(schema: unknown): boolean {
153-
if (!isFederatedObject(schema)) return false;
154-
if (mapsRemoteColumnOntoOwner(schema)) return false;
155-
return equalsShippedDef(readFieldDef(schema, OWNER_COLUMN), OWNER_FIELD_DEF);
156-
}
157-
158-
/**
159-
* [#27425] Does the object's `external.columnMap` (remote column → local field)
160-
* name `owner_id` as a target? That is the author declaring which remote column
161-
* carries the owner, so the anchor is not phantom. The built-in SQL drivers
162-
* apply the map and serve the column; a driver that does not apply it returns
163-
* no `owner_id`, so the owner predicate scoping composes matches no row — the
164-
* fail direction stays closed either way.
165-
*/
166-
function mapsRemoteColumnOntoOwner(schema: unknown): boolean {
167-
const columnMap = (schema as { external?: { columnMap?: unknown } } | null | undefined)?.external?.columnMap;
168-
if (!columnMap || typeof columnMap !== 'object' || Array.isArray(columnMap)) return false;
169-
return Object.values(columnMap as Record<string, unknown>).includes(OWNER_COLUMN);
117+
return isOwnerAnchorUnprovable(schema);
170118
}

‎packages/plugins/plugin-sharing/vitest.config.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,14 @@ export default defineConfig({
4141
// (ENOTDIR) — a config that looks right and fails at run time. Same shape
4242
// as `service-storage`'s and `service-knowledge`'s.
4343
//
44-
// `@objectstack/metadata-core` (#7858) is on the same footing, and reaches
45-
// the tests two ways: `federated-phantom-owner-scoping.test.ts` imports
46-
// `OWNER_FIELD_DEF` to build its fixture, and `federated-phantom-anchors.ts`
47-
// — pulled in transitively by `sharing-service.ts` — imports the same
48-
// constant to compare against. That constant IS the provenance test's
49-
// subject: it decides whether a federated object's `owner_id` is the
50-
// platform's injected anchor or a column its author declared. Resolved from
51-
// `dist/`, a stale copy would move the verdict without moving the assertion
52-
// — the guard would answer about a definition that is no longer shipped,
53-
// and stay green while doing it.
44+
// `@objectstack/metadata-core` (#7858) is on the same footing:
45+
// `federated-phantom-owner-scoping.test.ts` imports `OWNER_FIELD_DEF` to
46+
// build its fixture. That constant IS the provenance test's subject: it
47+
// decides whether a federated object's `owner_id` is the platform's
48+
// injected anchor or a column its author declared. Resolved from `dist/`, a
49+
// stale copy would move the fixture without moving the assertion. (The
50+
// guard itself, `federated-phantom-anchors.ts`, reads the verdict from
51+
// `@objectstack/spec/data` since #27661, and compares nothing here.)
5452
alias: [
5553
{ find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') },
5654
{ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts') },

‎packages/spec/api-surface/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,7 @@
874874
"emptyGroupValueFor (function)",
875875
"expandEmptyOperator (function)",
876876
"externalBindingDatasourceRefusal (function)",
877+
"externalColumnMapTargets (function)",
877878
"fieldForm (const)",
878879
"fieldGroupBodyTableRefusal (function)",
879880
"filterSubtreeProvenanceOf (function)",
@@ -926,6 +927,7 @@
926927
"isMultiValueField (function)",
927928
"isNowDefaultToken (function)",
928929
"isOrganizationUnique (function)",
930+
"isOwnerAnchorUnprovable (function)",
929931
"isPlainRecord (function)",
930932
"isPublicSharingEnabled (function)",
931933
"isReadableIds (function)",

‎packages/spec/export-origins/data.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,7 @@
860860
"emptyGroupValueFor": "src/data/aggregation-policy.ts#emptyGroupValueFor (function)",
861861
"expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)",
862862
"externalBindingDatasourceRefusal": "src/data/external-binding-datasource.ts#externalBindingDatasourceRefusal (function)",
863+
"externalColumnMapTargets": "src/data/injected-system-column-provenance.ts#externalColumnMapTargets (function)",
863864
"fieldForm": "src/data/field.form.ts#fieldForm (const)",
864865
"fieldGroupBodyTableRefusal": "src/data/related-list-body-table.ts#fieldGroupBodyTableRefusal (function)",
865866
"filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)",
@@ -912,6 +913,7 @@
912913
"isMultiValueField": "src/data/field-value.zod.ts#isMultiValueField (function)",
913914
"isNowDefaultToken": "src/data/default-value-tokens.ts#isNowDefaultToken (function)",
914915
"isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)",
916+
"isOwnerAnchorUnprovable": "src/data/injected-system-column-provenance.ts#isOwnerAnchorUnprovable (function)",
915917
"isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)",
916918
"isPublicSharingEnabled": "src/data/object.zod.ts#isPublicSharingEnabled (function)",
917919
"isReadableIds": "src/data/readable-ids.ts#isReadableIds (function)",

0 commit comments

Comments
 (0)