fix(plugin-sharing): refuse a share row on a federated phantom owner anchor, with the single-record gate behaviour measured (#8119) - #8209
Conversation
…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
…erated-phantom-single-record-gates
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…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
Part of #8119
Part of, notFixes, on purpose: the card covers threehasOwnerFieldconsumers and this PR moves one.
checkEdit/checkDeleteare ruled out ofscope on the card itself — they currently refuse, which is fail-closed, and
widening them to
abstaincan 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
customerstable (the shipped federatedobjects carry the ADR-0090 D1
public_read_writestamp and return at a gateabove the phantom line, so they cannot exercise it at all):
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_idkey. Three consequencesthe card could not have known:
writeGateFailClosedis never reached on SQLite, so nothing is logged.The refusal is produced silently by
matchesOwnerScopereadingowner == null.before
__writeScopeis consulted, so even anorg-scope caller is denied.modifyAllRecordsis the only route toallow— and it never reads ashare row to get there.
So the card's premise holds:
checkEditandcheckDeleteboth answerdeny.Verified non-vacuously — the same gates answer
allowon a local record thecaller 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 realsys_record_sharerow: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 ratherthan 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/platformProvisionsStorage38 minutes after #7858 shipped this plugin'shand-rolled
hasPhantomOwnerAnchor. Direction B has consumers converge on themarker as they are touched, so convergence was available here.
Not taken, for one reason:
hasPhantomOwnerAnchorlives infederated-phantom-anchors.ts, whose other two consumers arebuildReadFilterand
buildWriteFilter— the two this card may not change. Rewriting the sharedhelper 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, thestamped object likewise, and the local control reads
author/false(itdeclares its own
owner_id). That agreement is pinned in the dogfood test, sowhoever does converge the helper starts from evidence rather than a fresh
measurement.
Verification
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
denyverdict 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);typecheckclean.@objectstack/dogfood: the new file 18 passed;typecheckclean; fullsuite green earlier on this branch (674 passed / 3 skipped).
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-freshfails pre-existing and unrelated: itmatched 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 thepath 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
assertSharingEnforcedonly, and changingthe 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