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

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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…SHARING_NOT_ENABLED list (objectstack-ai#8119) (objectstack-ai#8217)

The "Typical Errors" list enumerates the exact conditions that produce a 422,
and objectstack-ai#8209 added a fifth: a FEDERATED (ADR-0015 `external`) object whose
`owner_id` is the platform's injected anchor rather than a real remote column.
Leaving it out is the enforced-but-undocumented inverse of a
declared-but-unenforced gap — the runtime now refuses more than the page says.

Docs-only; the behaviour itself shipped in objectstack-ai#8209.


Claude-Session: https://claude.ai/code/session_01PEVB6w7D7uCszR9Mw1BL73

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…velope (objectstack-ai#8111) (objectstack-ai#8212)

* fix(rest): converge the record-sharing family onto the ADR-0112 D5 envelope (objectstack-ai#8111)

`registerSharingEndpoints` answered two retired dialects across its nine
refusal arms: `respond501` was flat `{ code, message }`, and the five mapped
verdicts (400/403/404/409/422) plus the three verb-specific 500s were
`{ code, error: '<bare string>' }`. `body.error.code` — the one position
ADR-0112 D5 declares — read `undefined` on all nine.

Every arm now emits through the shared `sendError` from `@objectstack/types`
(imported here as `sendEnvelopeError`; this module has a local `sendError` of
its own for thrown errors), so the family agrees by construction rather than
by nine literals that happen to match.

No status code moves and no code VALUE changes. The 409 arm's `CONFLICT` was
registered in neither `StandardErrorCode` nor `ERROR_CODE_LEDGER` — so
`ApiErrorSchema`, whose `code` is a closed enum, would have rejected that
body — and is now registered under `@objectstack/rest`, keeping the emitted
value byte-identical. Renaming it onto `RESOURCE_CONFLICT` would change what
clients read and is filed separately.

The `CODE:` message prefix stays: censused as a server-internal service→REST
derivation, stripped before the response is written, never on the wire.

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

* docs(spec): regenerate api references for the CONFLICT ledger entry (objectstack-ai#8111)

Registering `CONFLICT` in `ERROR_CODE_LEDGER` grows `ErrorCode` — the union
`ApiErrorSchema.code` parses against — by exactly one member, and every
generated reference page that renders an `ApiError`-shaped field prints that
enum as a truncated summary with a "+N more" count. So one ledger row moves
134 counter lines across 11 pages, plus the two pages that list the vocabulary
in full gain a `CONFLICT` bullet.

Generated output only (`gen:schema` + `gen:docs`), never hand-edited.

Measured as a genuine cascade, not absorbed drift: a pristine `origin/main`
tree regenerates completely clean under the identical commands, and every one
of the 136 changed lines here is either the `+264 more` → `+265 more` counter
(134) or a `CONFLICT` bullet (2) — nothing else.

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

* test(qa): migrate objectstack-ai#8209's share-grant dogfood pin to the ADR-0112 D5 position (objectstack-ai#8111)

`federated-phantom-share-grant.dogfood.test.ts` (added by objectstack-ai#8209 / PR objectstack-ai#8209 for
objectstack-ai#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 — objectstack-ai#8209
was green before this convergence existed and this branch was green before
objectstack-ai#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

---------

Co-authored-by: Claude <noreply@anthropic.com>
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