Skip to content

fix(plugin-sharing): refuse a share row on a federated phantom owner anchor, with the single-record gate behaviour measured (#8119) - #8209

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8119-federated-phantom-single-record-gates
Aug 12, 2026
Merged

fix(plugin-sharing): refuse a share row on a federated phantom owner anchor, with the single-record gate behaviour measured (#8119)#8209
os-zhuang merged 2 commits into
mainfrom
claude/issue-8119-federated-phantom-single-record-gates

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Part of #8119

Part of, not Fixes, on purpose: the card covers three hasOwnerField
consumers and this PR moves one. checkEdit / checkDelete are ruled out of
scope on the card itself — they currently refuse, which is fail-closed, and
widening them to abstain can turn a refusal into an allow. That is a decision,
and this PR is its measured input, not its execution. Merging must not close the
card.

Phase 1 — the measurement, which is the deliverable

The card was filed as a code-path reading and flagged its own premise as
unverified: nobody had run a federated single-record write, and the SELECT-list
behaviour for a nonexistent column (as opposed to #7858's measured
comparison-position degradation) was expected to be dialect-dependent.

Measured on a booted showcase stack — SQLite external datasource, an unstamped
federated object bound to the remote customers table (the shipped federated
objects carry the ADR-0090 D1 public_read_write stamp and return at a gate
above the phantom line, so they cannot exercise it at all):

find(obj, { where:{id:'c1'}, fields:['id','name'] })
  -> keys [id, name]                                        projection HONOURED

find(obj, { where:{id:'c1'}, fields:['id','owner_id'] })
  -> keys [id, created_at, updated_at, name, email, region, lifetime_value]
     hasOwnProperty('owner_id') === false                    projection DISCARDED

NO throw, in any position tested.

Neither branch the card predicted. The driver does not raise, and it does not
return a 2-column row with an absent value — it discards the whole projection
and returns the full row, which simply has no owner_id key. Three consequences
the card could not have known:

  1. writeGateFailClosed is never reached on SQLite, so nothing is logged.
    The refusal is produced silently by matchesOwnerScope reading owner == null.
  2. The refusal is not depth-dependent. The null-owner branch short-circuits
    before __writeScope is consulted, so even an org-scope caller is denied.
  3. modifyAllRecords is the only route to allow — and it never reads a
    share row to get there.

So the card's premise holds: checkEdit and checkDelete both answer deny.
Verified non-vacuously — the same gates answer allow on a local record the
caller owns, and on a federated object whose author declared a real remote
owner_id.

Phase 2 — the one refusal this authorises

Phase 1 also turned up something the card described only as a hypothetical. Pre-fix,
an admin's grant() on such an object succeeded, minting a real
sys_record_share row:

{ id: 'shr_...', object_name: 'measure_ext_nostamp', record_id: 'c1',
  recipient_id: '...', access_level: 'edit', source: 'manual' }

Over HTTP that is 201 Created for a share no verdict can ever consult — the
ADR-0078 silently-inert trap ADR-0111 D7's guard exists to close. It now answers
422 SHARING_NOT_ENABLED, with a message naming the federated anchor rather
than claiming a missing field (which would be false, and would send an operator to
add a column the platform already injected).

Refusing costs no live access: the row it declines to write could never have
granted any.

The #7865 marker: convergence available, deliberately declined

#8115 landed resolveInjectedColumnProvenance / unprovisionedInjectedColumns /
platformProvisionsStorage 38 minutes after #7858 shipped this plugin's
hand-rolled hasPhantomOwnerAnchor. Direction B has consumers converge on the
marker as they are touched, so convergence was available here.

Not taken, for one reason: hasPhantomOwnerAnchor lives in
federated-phantom-anchors.ts, whose other two consumers are buildReadFilter
and buildWriteFilter — the two this card may not change. Rewriting the shared
helper changes their behaviour transitively, which is the refactor the scope
ruling excludes.

The decision is measured rather than assumed: on the real registry the two agree
on every case — the federated anchor reads injected-unprovisioned / true, the
stamped object likewise, and the local control reads author / false (it
declares its own owner_id). That agreement is pinned in the dogfood test, so
whoever does converge the helper starts from evidence rather than a fresh
measurement.

Verification

  • Reverse verification, direction predicted before running (plain red on the
    new cases only; nothing counts diagnostics and no rule is narrowed, so removing
    an added refusal branch can only remove refusals). Observed exactly that:
    unit 3 failed / 24 passed — the three new grant cases, with every deny
    verdict and both "must not change" grant cases unmoved; dogfood 2 failed / 16
    passed
    grant() resolved and HTTP returned 201 instead of 422.
  • @objectstack/plugin-sharing: 517 passed (20 files); typecheck clean.
  • @objectstack/dogfood: the new file 18 passed; typecheck clean; full
    suite green earlier on this branch (674 passed / 3 skipped).
  • Gates: check:engine-double-contract, check:query-options-erasure,
    check:test-source-alias, check:docs-audit-scope, check:objectui-changeset,
    check:changeset-gate-self-tests, check-changeset-no-major, check:nul-bytes
    — all pass. check-objectui-pin-fresh fails pre-existing and unrelated: it
    matched only because this PR adds a changeset, and the diff does not touch
    .objectui-sha (moving the pin is release-adjacent work, not this card's).

Not in this PR

grant() skips the posture guard entirely for system callers, which is the
path the sharing-rule evaluator materialises through — so a rule can still
materialise an inert row on a phantom-anchor federated object. Left alone
deliberately: the scope ruling names assertSharingEnforced only, and changing
the system path would change rule-evaluator behaviour. Filed as #8207.

Also filed while measuring: #8208 (a record an admin creates over HTTP is
invisible to its own creator when the caller has no active organization).


Generated by Claude Code

claude added 2 commits August 12, 2026 19:51
…anchor (#8119)

`assertSharingEnforced` treated the registry-injected `owner_id` on a FEDERATED
(ADR-0015 `external`) object as a real owner column, so `grant()` minted a
`sys_record_share` row that no read/write verdict can ever consult — the
ADR-0078 silently-inert trap the guard exists to close (ADR-0111 D7). It now
refuses with SHARING_NOT_ENABLED (REST 422).

Measured on a booted showcase stack rather than inferred: the single-record
ownership lookup does NOT raise on SQLite. A projection naming the phantom
column is DISCARDED and the full row comes back without it, so
`matchesOwnerScope` reads `owner == null` and both write gates deny silently —
for every principal at every write DEPTH, `org` included, because the null-owner
branch short-circuits before the scope is consulted.

`checkEdit` / `checkDelete` are deliberately UNCHANGED and pinned as such:
they refuse today, which is fail-closed, and widening them to `abstain` can turn
a refusal into an allow. That is a decision recorded on #8119, not a rider here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 12, 2026 8:20pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-sharing.

7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/plugin-sharing)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-sharing)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-sharing)
  • content/docs/permissions/authorization.mdx (via packages/plugins/plugin-sharing)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-sharing)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-sharing)
  • content/docs/protocol/objectql/security.mdx (via packages/plugins/plugin-sharing)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-sharing)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 12, 2026 20:33
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit b8c95a6 Aug 12, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8119-federated-phantom-single-record-gates branch August 12, 2026 20:50
hotlong pushed a commit that referenced this pull request Aug 12, 2026
…position (#8111)

`federated-phantom-share-grant.dogfood.test.ts` (added by #8209 / PR #8209 for
#8119) asserts the `/data/:object/:id/shares` refusal envelope. It was written
against the FLAT dialect — `body.code` / `body.error` as a bare string — because
that is what the route emitted when it landed. This PR converges that family onto
`{ error: { code, message } }`, so the pin reads `undefined` and fails.

Migrated, not loosened: the assertions move to `body.error.code` and
`body.error.message` and assert the D5 position ALONE. Accepting either shape
would re-admit the dialect this card retires.

The CONTROL case's negative assertion moves too. It did not fail — a negative
assertion on the vacated flat position passes for free
(`undefined !== 'SHARING_NOT_ENABLED'`) — which is exactly why it had to move:
left alone it would have gone on "passing" while reading a key no response
carries any more.

Why the queue caught this and PR CI did not: the merge queue runs the FULL
suite, PR-side CI only the affected subset, and neither PR alone is red — #8209
was green before this convergence existed and this branch was green before
#8209's pin existed. The interaction is only visible composed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants