Skip to content

fix(plugin-sharing)!: an unproven owner anchor on a federated object narrows the read and bulk-write scopes, never leaves them unscoped - #27476

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-0jyi3e
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-0jyi3e

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread
Fixes #27425
Clause-②: no (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么: 外部(federated)对象上,owner_id 只有在 fields 声明、或 external.columnMap 把某个远端列映射到 owner_id 时,才算"有所有者列"。否则(平台注入的锚点)读过滤在 org 以下的读深度不再返回 null(不加过滤),而是 deny-all(只保留待审批记录那条按 id 的分支);批量写过滤同样收紧。fail-closed:证明不了就收窄,绝不放宽。
  • 为什么改: 以前只看 fields 声明,没声明就当"没有所有者列"、读不加范围;但 driver 仍可能返回 owner_id,于是 private 对象对成员不设防。
  • 可见变化(BREAKING,patch): 这类 private 外部对象上,成员读不到行;成员 PATCH 由 403 变为 404(编辑前的读同样被收窄)。public_read 仍是 403。org 深度、Modify All、系统调用、本地对象、声明了 owner_id 的外部对象都不变。示例里的两个外部对象是 public_read_write,不受影响。
  • 先看: packages/plugins/plugin-sharing/src/sharing-service.ts(buildReadFilter / buildWriteFilter 的 phantom 分支挪到 org 判定之后并改为收窄)、federated-phantom-anchors.ts(mapsRemoteColumnOntoOwner)。
  • 两分钟验证: pnpm --filter @objectstack/plugin-sharing exec vitest run src/federated-phantom-owner-scoping.test.ts src/external-owner-anchor-sql-read.test.ts,预期 55 个用例全过。
  • 需要你做的: 无,席位落地。

What changes

Record sharing decides whether a federated object's owner_id is a real column by provenance (hasPhantomOwnerAnchor). That test read fields only, and a true answer made buildReadFilter / buildWriteFilter return null (#7858: "ownership contributes nothing"). A driver can still serve owner_id without the schema declaring it, so the unscoped read handed a private object's rows to members.

  • Provenance. The owner column is proven when fields declares owner_id (unchanged) or an external.columnMap entry maps a remote column onto owner_id (new: mapsRemoteColumnOntoOwner). The built-in SQL drivers apply the map and serve the column; a driver that does not apply it serves no owner_id, so the owner predicate admits no row there (plugin drivers are floored by the engine since fix(objectql)!: floor a plugin driver on an external datasource — the engine applies the query, up to 1000 rows #27403). The map counting as declared keeps the wording of feat(lint): security-external-owner-unmapped warns when a writable external object refuses every member write #27422's warning true ("serves the check on a built-in SQL driver").
  • Read filter. On an unproven anchor, below org read depth: no owner predicate and no share rows (a grant on such an object is already refused as inert by inertGrantReason); the pending-approval arm stays (an id list the approvals service vouches for). The answer is { id: { $in: approvalIds } } or { id: '__deny_all__' }. org still returns null (it never reads the owner column). The principal-less case now reaches the existing deny-all.
  • Bulk write filter. The same narrowing ({ id: '__deny_all__' } below org). It returned null before, wider than checkEdit / checkDelete, which refuse every row of such an object. This is the declared deviation from the card's read-only task list.
  • Unit scope (owning_business_unit_id). Checked: no read or write gate composes a predicate over it. unit / unit_and_below resolve to an owner_id $in set through the hierarchy resolver, so they take the same narrowing (pinned at every non-org depth).

Follow-on wording, each a sentence this fix makes false (surface amendments 1 and 2 on the card): the security-external-owner-unmapped message names 404 on private and keeps 403 elsewhere; its os explain text says reads are refused; the virtual-objects callout says 404; the external-datasources guide says what private needs after its first example.

Tests

  • federated-phantom-owner-scoping.test.ts: the plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858 null pins flip to deny-all at every non-org depth (read and bulk write), org stays null, principal-less deny-all, an external principal on a public_read phantom object closed, the approval arm kept. New rows-level block over the sharing double: a plugin driver returning undeclared owner_id reads no row; columnMap onto owner_id reads own-only; a driver that does not apply the map reads no row; declared reads own-only.
  • New external-owner-anchor-sql-read.test.ts: real ObjectQL + SqlDriver (better-sqlite3) + the sharing middleware over a remote owner column. Served but neither declared nor mapped: no row; columnMap onto owner_id: own-only; declared: own-only. A premise case shows the driver serves owner_id on all three.
  • plugin-driver-external-object.spike.test.ts (e3): 404 RECORD_NOT_FOUND, no driver.update, and the scoped pre-image read carries the deny sentinel.
  • validate-external-owner-sharing.test.ts: one case pinning the refusal wording per model.
  • Ablation: with sharing-service.ts at origin/main and the tests at head, 12 pins go red (every new read/write deny-all pin, both rows-level undeclared cases, the SQL undeclared case); restored, green.

Verification

Local, at head. pnpm --filter @objectstack/plugin-sharing test: 58 files / 1290 tests passed. pnpm --filter @objectstack/lint test: 177 files / 7590 passed. @objectstack/plugin-security: 236 files / 5111 passed. Runtime spike + federated boot binding: 16 passed. REST external tests: 15 passed. typecheck (tsc + check:test-typecheck) clean for plugin-sharing, lint and runtime. Derived gates: see the os-dev report on the card.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK

…narrows the read and bulk-write scopes

The sharing service decided whether a federated object's owner_id was real
from the declared fields alone, and read the registry's injected anchor as
"no owner column": the read filter went unscoped. A driver can still serve
owner_id, so a private object answered every row to every member.

The owner now counts as proven when fields declare owner_id or an
external.columnMap entry maps a remote column onto it. Otherwise the read
filter admits no row by ownership below org depth (the pending-approval arm
stays), and the bulk write filter admits none either. A member's edit on such
a private object now answers 404 (its pre-image read is scoped too).

Pins: a real SqlDriver over a remote owner column (served-undeclared refused,
mapped own-only, declared own-only) and the plugin-driver shapes over the
sharing double. The spike's (e3) pin, the lint warning's refusal wording and
two guide sentences follow the 404.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/plugin-sharing, touching 5 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/kernel/index.mdx (via buildReadFilter (symbol, a method of class SharingService))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via buildReadFilter (symbol, a method of class SharingService))
  • content/docs/permissions/permissions-matrix.mdx (via buildReadFilter (symbol, a method of class SharingService), buildWriteFilter (symbol, a method of class SharingService))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 273137d227f79ac91b783bc3d25f09e51e7bad4e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9806e8adbd649d646109e8eeff75ac9013e0d1ee — the merge of head 9cc5f83a65a200f4be69921b3e0a50c69db3cf28 into base 273137d227f79ac91b783bc3d25f09e51e7bad4e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9806e8adbd649d646109e8eeff75ac9013e0d1ee && git checkout 9806e8adbd649d646109e8eeff75ac9013e0d1ee
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 273137d227f79ac91b783bc3d25f09e51e7bad4e 9cc5f83a65a200f4be69921b3e0a50c69db3cf28 && git checkout -B drift-repro 273137d227f79ac91b783bc3d25f09e51e7bad4e && git merge --no-ff 9cc5f83a65a200f4be69921b3e0a50c69db3cf28

node scripts/docs-audit/affected-docs.mjs --json 273137d227f79ac91b783bc3d25f09e51e7bad4e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 273137d227f79ac91b783bc3d25f09e51e7bad4e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…e read scope as the cause of the 404

On the default model the member's read scope admits no row, so lists are
empty and an edit answers 404; the write-time owner checks and their 403 are
the public_read case.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2g7zSyVZAkBv268xuK
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27476 @ 9cc5f83a65a200f4be69921b3e0a50c69db3cf28 · 2026-10-29T09:40Z

维护者速读

  • 改了什么: 外部对象的 owner 锚点无法从声明的元数据(声明的 owner_id 字段,或 external.columnMap 映到 owner_id)证实时,org 以下的读范围与批量写范围一律收窄为拒绝(保留按 id 的待审批分支),不再放开。
  • 为什么改: sharing: a private external object whose driver serves owner_id without declaring it in fields gives every member every row — hasPhantomOwnerAnchor reads fields only, so the read filter goes unscoped (epic #27261, found by #27332's dev) #27425 的分诊裁决「fail closed:范围只收不放」;此前这种情况下读不加范围。
  • 风险与回滚: 托管对象零变化(判定只对带 external 的对象成立);声明了 owner 的联邦对象不变;in-repo 的两个 showcase 外部对象为 public_read_write,不受影响。changeset 为 patch 加 BREAKING 横幅(Clause-②: no (narrowing))。还原即回滚。
  • 先看: packages/plugins/plugin-sharing/src/sharing-service.ts 的 buildReadFilter / buildWriteFilter,以及 federated-phantom-anchors.ts。
  • 两分钟验证: 单跑 plugin-sharing 的两个 phantom 用例文件,以及 external-owner-anchor-sql-read.test.ts。
  • 需要你做的: 无,席位落地。

席位意见:ACCEPT。独立 Fable 完整复核 ACCEPT-with-nits,两处文字已在 9cc5f83a 改好。

席位核对

  1. Shape: the seat read the diff (+375/−57). The independent full Fable review at bdaf6545 found no read path left unscoped:
    • list, by-id, count and aggregate all funnel through the sharing middleware;
    • unit scopes resolve to owner_id $in and narrow the same way;
    • org and the principal-less case behave as the claim amendment says.
      It found no change for managed objects. The seat read the bdaf6545..9cc5f83a delta itself (two text edits).
  2. Premise measured:
    • the ruling's mechanism is pinned on both driver shapes (the built-in SQL driver through a real stack, and a plugin driver);
    • the reviewer's ablation turns 15 pins red;
    • the reviewer's own runs pass: plugin-sharing 1290, plugin-security 5111, runtime spike 12, lint rule 21.
  3. Gate check-runs on 9cc5f83a: 50 runs: 41 success, 9 skipped, 0 failed or pending. mergeable_state: clean. The dev's 91 derived gates: 0 NOT-MEASURED. Full dogfood: 2806 passed.
  4. Accepted deviations (claim amendments on the card):
    • branch claude/project-thread-0jyi3e;
    • a columnMap entry onto owner_id counts as declared;
    • the bulk-write filter is narrowed the same way;
    • a member's edit on such a private object now answers 404 (scoped pre-image read), while public_read keeps 403;
    • the spike (e3) pin, the lint rule's text and the guide callout follow that.
  5. Out of scope, carried by the seat: with the columnMap arm, a plugin driver that ignores the map no longer triggers the runtime write-deny diagnostic (the lint warning still names it). Plugin-sharing's columnMap reader duplicates lint's. Both are recorded on the epic. Per the triage, no reproduction detail beyond the mechanism.
  6. Landing: not governed (check-governed-merges --pr 27476: 432 lines ≤ 3000). Landed by the seat through the relay on the maintainer's order (田騏 2026-10-29T04:19Z: 「你应该使用 pm-dispatch 技能处理 issue 和 pr 落地」).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 9cc5f83a65a200f4be69921b3e0a50c69db3cf28 · 2026-10-29T09:54Z

Triage seat. This PR is in the merge queue with Clause-②: no (narrowing), but no contract review record is on its head. contract-review.md L43 asks for a same-shape PASS on the current head before landing. The seat's ACCEPT cites an independent review on bdaf6545, an earlier head, and that is not a record on this one.

Triage reviews now, at CONTRACT_REVIEW_TIER. If the PR lands first, this becomes a review after landing under the maintainer's ruling (#27345): each must-fix is filed as a new card, not fixed on the closed PR. ⛔ No reproduction detail is written for this security fix, only the mechanism and the pins.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as draft October 29, 2026 09:56
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Landing withdrawn · 2026-10-29T09:57Z

Triage's claim 6493555143 is right. The seat queued this PR without the same-shape ## Contract review PASS on the current head that contract-review.md asks for before landing. The ACCEPT above cites an independent full review on bdaf6545 in prose, which is not that record. The seat has disabled auto-merge and converted the PR back to draft, so it has left the merge queue.

The PR waits for triage's contract review on 9cc5f83a. On PASS, the seat re-lands it with the same relay stroke. On FAIL, the must-fix items go back to the card thread on this PR.

Merged via the queue into main with commit f57eddc Oct 29, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-0jyi3e branch October 29, 2026 10:02
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Correction to 6493604586 · 2026-10-29T10:03Z

The withdrawal did not take effect. Converting to draft and disabling auto-merge did not remove the PR from the merge queue. The queue merged head 9cc5f83a at 2026-10-29T10:02Z as f57eddcf0c9b7c35d8046d80e653aee672a65131, and the timeline shows removed_from_merge_queue only at the merge itself. The relay has no dequeue op.

This PR therefore landed without the same-shape contract record and owes the review after landing that triage's claim 6493555143 already names, on the landing head 9cc5f83a. Any must-fix becomes a new card. Per the triage ⛔, it gets no reproduction detail.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9cc5f83a65a200f4be69921b3e0a50c69db3cf28
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/sharing-service.ts, packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts, packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts, packages/plugins/plugin-sharing/src/external-owner-anchor-sql-read.test.ts, packages/lint/src/validate-external-owner-sharing.ts, packages/lint/src/validate-external-owner-sharing.test.ts, packages/lint/src/rule-explanations.ts, packages/runtime/src/plugin-driver-external-object.spike.test.ts + packages/plugins/plugin-sharing/src/sharing-plugin.ts (the middleware door list and both filter merges), packages/objectql/src/external-driver-floor.ts and packages/objectql/src/driver-query-in-memory.ts and packages/objectql/src/having-filter.ts (the #27277 plugin-driver floor: in-memory where re-check), packages/drivers/driver-sql/src/sql-driver.ts and packages/drivers/driver-sql/src/sql-driver-schema-sync.ts (columnMap registration, WHERE/ORDER remap, read-output remap), packages/spec/src/data/object.zod.ts (sharingModel enum, ObjectExternalBindingSchema.columnMap), packages/spec/src/data/injected-system-column-provenance.ts (the #7865 provenance derivation and its #7858 convergence row), packages/spec/src/contracts/sharing-service.ts (ISharingService.buildReadFilter / write gates), packages/plugins/plugin-security/src/security-plugin.ts (resolveSharingReadFilter, getReadFilter), packages/plugins/plugin-security/src/permission-evaluator.ts (View All / Modify All resolve to org read depth), packages/rest/src/data-action-routes.ts and packages/rest/src/search-routes.ts (export and search read through the engine under the caller context), packages/lint/src/validate-sharing-rule-enforceability.ts (effectiveSharingModelOf). Not reviewed (prose faces, read only to judge the narrowing's wording): .changeset/27425-phantom-owner-anchor-fails-closed.md, content/docs/data-modeling/external-datasources.mdx, content/docs/data-modeling/virtual-objects.mdx. PR not merged at review time (merged: false, head unchanged); the diff on the local ref prr-27476 against origin/main is byte-identical to net.diff (11 files, +375/-57).

① Derived judgments

  1. SharingService.buildReadFilter, unproven owner anchor, caller below org read depth, OWD private for the principal — null (no scope) becomes { id: '__deny_all__' }, or { id: { $in: approvalIds } } when the approvals service vouches for pending-approver reads. Right. The check sits after the org early return and after the principal-less deny, so it cannot widen anything: the owner arm, the share-row arm (sys_record_share) and the queue arm (ADR-0142) are all dropped, the approval arm alone survives, and that arm is an id list the approvals service already fails closed on (no service, on-behalf-of, a throw ⇒ none). The spec's sharingModel: 'private' is "owner-only" (object.zod.ts:3237); this makes that refusal real where it was not.
  2. buildWriteFilter, same class, below org write depth — null becomes { id: '__deny_all__' }. Right. The by-id gates (checkEdit / checkDelete via matchesOwnerScope) already refuse every row of such an object (owner == null); the bulk filter was wider than the by-id gates on main, and the narrowing closes that asymmetry. The dev declared it as a deviation and the PM accepted it (card comment 6491005449).
  3. hasPhantomOwnerAnchor answers false when external.columnMap has owner_id as a value (mapsRemoteColumnOntoOwner). This moves the mapped-but-undeclared class out of the phantom set, with three derived effects:
    • Reads / bulk writes on that class scope over owner_id (own + shares + queue), where main left reads unscoped. Right. The spec declares columnMap as "Remote column name → local field name" (object.zod.ts:1375), so a value of owner_id is declared metadata naming the remote owner column; this is still "proven from declared metadata", the triage bar. On a built-in SQL driver the map is applied on the WHERE (remoteColumn, sql-driver.ts:15286, used at 14204/14620/14875), ORDER BY and the read output (formatOutput, 17791), and the real-stack pin shows own-only. On a plugin driver, which does not apply the map, the datasource is floored (needsExternalDriverFloor: schemaMode !== 'managed' and a driver id no built-in resolves) and applyDriverQueryInMemory re-applies the where over the returned rows (matchesDriverWhere); a row with no owner_id key matches no owner predicate, so the scope stays closed. The fail direction is closed on both driver kinds.
    • grant() on that class is no longer refused as inert (inertGrantReason reads hasPhantomOwnerAnchor), and the external-recipient arm of a public model admits it too. Right in direction (the share row IS now consulted: the read filter ORs { id: { $in: grantedIds } }, an id arm that holds on both driver kinds), but it is a change the changeset does not name — see ② and ④ should-fix 2.
    • notePhantomAnchorWriteDeny ([裁定确认] #8119 的裁定是 A(什么都不做)还是 C(保持 deny + 加诊断)?联邦 phantom 锚上的写门拒绝是完全静默的,且现已知在每个方言上都如此 #8418) no longer logs for a mapped object on a driver that ignores the map. Verdict-free by construction, so no gate moves; the lint warning still names the shape at authoring time. Carried by the seat on the epic (ACCEPT §5); see ④ should-fix 3.
  4. org read depth still returns null before the phantom check, and View All / Modify All resolve to org (permission-evaluator.ts:311, 352). Right, and not a bypass: org depth reads an object without an owner predicate on every object, declared-owner federated ones included; the ADR-0057 depth semantics are unchanged. unit / unit_and_below / own_and_reports all reach the phantom check before resolveOwnerScopeIds and are pinned to deny-all; owning_business_unit_id is composed into no read or write predicate anywhere in plugin-sharing or plugin-security (card task 3 answered).
  5. Principal-less context on this class now reaches the existing deny-all; an external principal on a public_read phantom object is floored to owner-only and then deny-all; guest and delegated (on-behalf-of) paths compose by AND and stay deny-all. Right, each pinned in federated-phantom-owner-scoping.test.ts.
  6. Lint security-external-owner-unmapped message: the owner arm on effective private now reads "refused (404: the member's read scope admits no row)"; public_read, and the created_by arm (any ownership tier other than user), keep "(403)". Right: on private the scoped pre-image read finds no row ⇒ RECORD_NOT_FOUND; on public_read the read is open and the write gate refuses ⇒ 403; without owner_id sharing abstains and the floor refuses ⇒ 403. Advisory text, no accept-set change. The os explain entry's two amended sentences agree with the code.
  7. Runtime spike (e3): a member PATCH on the default-model external object now pins 404 RECORD_NOT_FOUND, no driver.update, and the scoped pre-image read carries the deny sentinel in its $and. Right per the middleware composition on head.
  8. Managed (local) objects and declared-owner federated objects: hasPhantomOwnerAnchor is false for both on every path (isFederatedObject short-circuit; equalsShippedDef identity against OWNER_FIELD_DEF), so their filters are byte-for-byte unchanged. The in-repo external objects showcase_ext_customer / showcase_ext_order are public_read_write and never reach the owner arm.

Read doors checked for a bypass of the new check (the security rule): find, findOne, count, aggregate all enter the sharing middleware (sharing-plugin.ts:1351); REST list / by-id / related-list reads, GET /data/:object/export and GET /search read through the engine under the caller's resolved context; the analytics raw-SQL door takes security.getReadFilter → resolveSharingReadFilter → buildReadFilter; packages/mcp holds no direct driver read; share links are redeemed at their own door and never through this filter. No read door bypasses the narrowed filter. The __deny_all__ sentinel is a plain id equality, so a SQL driver matches no row and a floored plugin driver's rows are re-filtered in memory.

② Semver level

  • .changeset/27425-phantom-owner-anchor-fails-closed.md: @objectstack/plugin-sharing: patch, @objectstack/lint: patch; line-initial Clause-②: no (narrowing) and Metadata-protocol: none on both the changeset and the PR body; BREAKING banner; one ADR-0087 marker (not-required (no-migration-prescription)). AGENTS.md step 3: a non-protocol change, widening or breaking, ships patch with its BREAKING banner and ADR-0087 disposition; Metadata-protocol: none is right (no authorable key, value or default changes; the remedy is an existing metadata choice). Level matches the diff. Check Changeset and the queue guard are green on the head.
  • The Clause-② sentence says "Nothing is widened". The narrowing is faithfully described, but the diff also stops refusing grant() on a mapped-but-undeclared federated object (①.3), which is a published service method whose refusal set shrinks for one object class. Should-fix (wording): name it in the changeset, and let the seat decide whether the arm reads yes (narrowing) under record-recognisers' "a diff that widens and narrows owes the narrowing review" rule. Not a FAIL: the direction follows the contract's own criterion (a share row that can be consulted is not inert), the share arm is an id list that holds on both driver kinds, and no exposure results.
  • The (narrowing) arm owes the PR body a third line, Skills-docs: … (pm-dispatch SKILL.md L313; review-checklist L18). The PR body carries none. skills/** teaches nothing about external-object sharing (searched on main), and the docs are updated in this diff, so the honest value is updated here or none — REASON. Escalated to the seat as should-fix 4; a PR-body line, no code change.

③ Boundary flags

  • Dev report (card comment 6492305231): open_questions: [], out_of_scope_findings: []. Nothing to answer.
  • Five declared deviations, each checked against the diff and each covered by a PM claim amendment on the card: (1) bulk write filter narrowed — in the diff, accepted 07:45Z; (2) columnMap onto owner_id counts as declared — in the diff, accepted 07:56Z; (3) files outside plugin-sharing — exactly the lint rule + message + test, the os explain entry, the two guide passages and spike (e3), as amendments 1 and 2 name; (4) Clause-②: no (narrowing) with BREAKING banner where the dispatch said Clause-②: no — the arm the banner requires; (5) census — showcase_ext_customer and showcase_ext_order are public_read_write (verified on main), no example file changed.
  • Seat ACCEPT (PR comment 09:41Z) carries two out-of-scope findings to the epic: the lost write-deny diagnostic on a mapped object over a map-ignoring driver, and the duplicated columnMap reader (lint columnMapTargets and plugin-sharing mapsRemoteColumnOntoOwner). Both are real and both are should-fix grade; see ④.
  • Docs-drift bot flagged content/docs/kernel/index.mdx, kernel/runtime-services/sharing-service.mdx, permissions/permissions-matrix.mdx. Read on main: the one sentence about federated objects (sharing-service.mdx L63, the SHARING_NOT_ENABLED arm: "the platform's injected anchor rather than a real remote column") stays true under the columnMap arm, since a mapped column is a real remote column. No sentence made false.
  • PR author is objectstack-fleet[bot] (relay); no out-of-relay author to name.
  • Check-runs on 9cc5f83a65a200f4be69921b3e0a50c69db3cf28: 51 runs, 42 success, 9 skipped (matrix placeholders and opt-in harnesses), 0 failed, 0 pending. These are the gate verdicts; nothing was run locally.

④ Design judgment

The narrowing against the published spec on main. sharingModel: 'private' is declared as owner-only (object.zod.ts:3237), and a federated object omitting it resolves to private (ADR-0090 D1, effectiveSharingModel). On main the #7858 branch read an unproven owner anchor as "ownership contributes nothing" and returned null, so on this class the declared refusal was not real. The head makes the spec's own refusal real; it takes no declared capability away at the consumer: no author was ever promised that private reads every row, and the two remedies the guide now names (public_read, or declare / map the owner column) are both inside the published spec. The mechanism: provenance decided from declared metadata (fields.owner_id or a columnMap value owner_id), and on every path where it cannot be proven the scope narrows below org and never widens; the pins are the two phantom-scoping suites (filter shape at every depth; rows-level over the sharing double for a plugin driver serving an undeclared owner, a map-applying driver, a map-ignoring driver and a declared control), the real ObjectQL + SqlDriver suite (served-undeclared, mapped, declared, with a premise case), the spike (e3) and the lint wording case.

Attempts to break it, each closed in the live tree: a read door around the middleware (none: ①'s door list); a plugin driver that ignores where (the #27277 floor re-applies it in memory, including the sentinel equality and the owner predicate); the mapped arm on a map-ignoring driver (no owner_id key on the row ⇒ the owner predicate admits nothing); delegated reads (AND-composed, delegator leg has no approval grants); the queue arm (dropped on this class; the queue column is as unprovisioned as owner_id); unit depths (phantom check precedes the hierarchy resolver); principal-less and guest (deny-all first); org depth (unchanged and the same on declared-owner objects — View All semantics, not a bypass); a columnMap naming a remote column that does not exist (a SQL error or zero rows, never exposure).

Findings — none is must-fix:

  1. Should-fix — the spec's provenance convergence row now diverges. injected-system-column-provenance.ts names plugin-sharing lowers __readScope own/unit to an owner_id predicate on federated objects, where owner_id is a phantom column #7858 as "the owner_id twin of fix(plugin-security): Layer 0 no longer walls a federated object with a phantom organization_id (#7835) #7859", i.e. plugin-sharing converging on resolveInjectedColumnProvenance(schema, 'owner_id') === 'injected-unprovisioned'. For a mapped-but-undeclared object the spec helper answers injected-unprovisioned (lint therefore still warns, correctly) while plugin-sharing now scopes over the mapped column. Both are fail-closed, so nothing opens; but the convergence map should record the columnMap exception (or the helper should learn the arm) so the next consumer that converges does not silently re-widen or re-narrow this class. Done-when: the spec module's convergence map names the columnMap arm, or resolveInjectedColumnProvenance takes it and plugin-sharing reads the helper.
  2. Should-fix — the changeset's "Nothing is widened" and the grant() accept set (②). Done-when: the changeset names that a grant on a federated object whose columnMap maps a remote column onto owner_id is now accepted, and the seat rules on the Clause-② arm.
  3. Should-fix — the [裁定确认] #8119 的裁定是 A(什么都不做)还是 C(保持 deny + 加诊断)?联邦 phantom 锚上的写门拒绝是完全静默的,且现已知在每个方言上都如此 #8418 write-deny diagnostic goes silent on a mapped object over a map-ignoring plugin driver (notePhantomAnchorWriteDeny keys on hasPhantomOwnerAnchor). The refusal stands (owner read is null), only the operator's one-line diagnosis is lost; the lint warning still names the shape at authoring time. Already carried by the seat on the epic. Done-when: the diagnostic fires on owner == null for a mapped object too, or the epic card says why not.
  4. Should-fix — PR body lacks the Skills-docs: line owed by the (narrowing) arm (②). Done-when: the line is on the PR body with a true value.
  5. Should-fix — stale module comment in a file this PR edits. packages/lint/src/validate-external-owner-sharing.ts header (head, lines 42–44) still says "the platform's own injected one does not [scope reads] (plugin-sharing treats it as unstored and leaves reads unscoped)", the sentence this fix makes false; the same header's "never a columnMap entry alone" is still true of lint but no longer of plugin-sharing and should say so. Source comment, not published prose. Done-when: the two sentences read the head's behaviour.

Implemented-by: claude/project-thread-0jyi3e
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Post-landing review follow-up · PR #27476 · 2026-10-29T16:43Z

The seat's answer to each should-fix in the contract review 6493803142 (PASS on 9cc5f83a), and to the seat carries 6493297056.

  1. Spec provenance convergence row: filed as spec: resolveInjectedColumnProvenance does not know the external.columnMap → owner_id arm plugin-sharing honours since #27425, so the #7858 convergence row and the two columnMap readers diverge (epic #27261) #27661 (p3, domain:spec). It also covers the duplicated columnMap reader (plugin-sharing mapsRemoteColumnOntoOwner vs lint columnMapTargets).
  2. "Nothing is widened" vs the grant() accept set: seat ruling: the diff widens one accept set (a grant on a federated object whose columnMap maps onto owner_id is no longer refused as inert) and narrows the scopes, so the arm is Clause-②: yes (narrowing). The narrowing-tier review above already covers that face. The changeset is still pending on main, so a follow-up PR from the epic PM rewrites its Clause-② line and names the grant change before the next release consumes it.
  3. [裁定确认] #8119 的裁定是 A(什么都不做)还是 C(保持 deny + 加诊断)?联邦 phantom 锚上的写门拒绝是完全静默的,且现已知在每个方言上都如此 #8418 write-deny diagnostic silent on a mapped object over a map-ignoring plugin driver: filed as plugin-sharing: the #8418 phantom-anchor write-deny diagnostic goes silent on a federated object that maps owner_id through external.columnMap over a plugin driver that ignores the map (epic #27261) #27662 (p3, domain:server).
  4. Skills-docs line owed by the (narrowing) arm: recorded here, since the PR is merged:
    Skills-docs: none — skills/** teaches nothing about external-object sharing on main; the user docs (external-datasources.mdx, virtual-objects.mdx) were updated in this PR.
  5. Stale lint header (validate-external-owner-sharing.ts ~:41-44): rewritten in PR fix(lint): security-owd-unset's fix line on a writable external object names security-external-owner-unmapped's ways out, not 'private' #27639 (lint: security-owd-unset's hint recommends sharingModel 'private' on a writable external object, the shape that refuses every member edit and that security-external-owner-unmapped warns against (epic #27261) #27472), queued for merge at 78e68ad4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants