You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 684f2f6
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments