Skip to content

fix(verify): make the RLS by-id-write class reachable; re-decide the two authz-matrix rows on their own evidence - #7975

Merged
hotlong merged 3 commits into
mainfrom
claude/issue-7685-verify-rls-probe-and-authz-matrix
Aug 12, 2026
Merged

fix(verify): make the RLS by-id-write class reachable; re-decide the two authz-matrix rows on their own evidence#7975
hotlong merged 3 commits into
mainfrom
claude/issue-7685-verify-rls-probe-and-authz-matrix

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#7685 — items (i) and (ii) only. Item (iii) is objectstack-ai/objectui#4446 and is not touched here.

Cross-links #7665 (the defect whose acceptance criterion 2 this probe implements) and #1994 (the invariant).

(i) verify --rls — the measurement first, because it decides (ii)

objectstack verify --rls reported 0 HOLES on both example apps. Neither number was evidence.

The probe was answered by the OBJECT gate, never by record scope. The persona was a bare signUp() member holding no object grants, so checkObjectPermission refused with 403 before the row-level gate was consulted — and the runner banked that 403 as rls-consistent.

A skip read as a pass, and cascaded. One showcase_account auto-record 400 skipped that object and every object with a required relation to it, while the summary line still said 0 HOLES.

What verify --rls reports BEFORE and AFTER

showcase (23 objects)crm (6 objects)
BEFORE13 consistent, 0 holes, 2 member-visible, 8 skipped4 consistent, 0 holes, 2 skipped
AFTER20 PROVEN (20 consistent, 0 holes), 3 not proven6 PROVEN (6 consistent, 0 holes), 0 not proven

The BEFORE "consistent" counts are the finding, not the baseline: 11 of the 13 showcase verdicts and all 4 crm verdicts were GET 403 — the object gate — and only 2 showcase objects (showcase_inquiry, showcase_private_note) reached record scope with a GET 404. On the other 15, no platform regression could have produced rls-hole.

AFTER, every consistent verdict is a record-scope GET 404 + PATCH 403 with the row unchanged as ground truth. The 3 remaining showcase objects are honestly unprovable by this runner and are printed as such: one has no plain-text field to mutate, two are read-only federated objects.

Changes

  • The probe persona is the one the class needs: object read+edit on every declared object, narrowed by an owner policy authored operation: 'select'only — deliberately the authoring shape that was the hole, so every object of every verified app becomes a live regression guard for the by-id write-scope derivation. It is registered at boot (rlsProbeSecurity) rather than written as a bare sys_permission_set row, because the evaluator's last-resort DB loader hydrates the object bits but notrowLevelSecurity — a row-only probe set would have granted the grants and silently dropped the narrowing.
  • Reachability is measured per object, not assumed. A LIST 403 is reported as probe-blocked — a distinct status that is never a pass — so a persona that silently failed to receive its grants cannot masquerade as a proof.
  • An unsatisfiable admin create no longer cascades. The runner adopts an existing row as the probe target, which is what took showcase 8 → 3 skips and crm 2 → 0.
  • PROVEN and NOT-PROVEN are separated.summary gains proven / unproven / probeBlocked, RlsReport.unproven lists every object the run did not exercise with its reason, RlsReport.probe names the persona, and the formatted output prints an explicit "this run is not a clean bill of health" line whenever anything went unproven.
  • A degraded probe fails the run rather than quietly probing with an ungranted member.

Reverse verification

Ablating the #7665 write-scope derivation in plugin-security — while leaving the #1994 pre-image re-read fully in place — and rebuilding the plugin's dist:

  • with the fix: 20 PROVEN, 0 holes, exit 0
  • ablated: 16 rls-hole, hardFailures: 16, exit 1, each reported as GET 404 … yet MUTATED it by id (PATCH 200)

The ablation was reverted and the plugin rebuilt before committing; the branch contains no plugin-security change.

CI gate risk: none

ci.yml's dogfood-verify job runs verify --app <app> --rls for both example apps and gates through dogfood-gate. Both apps exit 0 after this change, so nothing is being suppressed to keep CI green and no blocker decision is needed.

(ii) authz-conformance.matrix.ts — both rows survive, on their own evidence

Decided per row after the measurement, per ADR-0049. Neither row was downgraded, so — per the ruling — the mirroring comment in the showcase's permission-sets.ts is left alone: it is accurate as shipped.

RowVerdictCitationChanged
rls-by-id-write (:39)stays enforced — the card is stalerls-fixture.dogfood.test.ts is not vacuous for this row despite sharing the file with rls-read: since #7665 / merged PR #7792 it carries a dedicated select-only block (:107–172) whose member set grants full CRUD on rls_note, so its refusals are the record gate, never the object gate — it asserts the by-id PATCH refused with the row unchanged and that an in-scope write still lands. Second, independent measurement: the newly-reachable probe, 20/23 + 6/6 proven, 16 holes under ablation.enforcement + note only — state untouched
controlled-by-parent (:48)stays enforced — the card is stalecontrolled-by-parent.dogfood.test.ts is a dedicated proof, and fixtures/cbp-fixture.ts grants the member full CRUD on both objects, so every refusal it asserts is the derived record gate. It asserts derived read denial, derived by-id write denial with admin ground truth, and that a note under a member-owned master stays readable and writable (not over-blocked). Independently, the probe measures showcase_invoice_line — a real controlled_by_parent detail — consistent, and it flips to rls-hole under the ablation.note only — state untouched

The one substantive record correction: rls-by-id-write's enforcement named only the pre-image re-read, which the ablation proves is a no-op on its own under select-only authoring — the 16 holes appeared with that re-read fully present. The row now names both halves. No covers key was added or orphaned; AuthzState is unchanged.

Verification

  • pnpm -w typecheck — clean, 126/126.
  • pnpm lint — clean.
  • pnpm check:type-check-debt — OK, none above its recorded number (nothing raised).
  • node scripts/check-verify-stand-in-erasure.mjs — OK; the new provisionRlsProbePersona is classified in NOT_A_STAND_IN with its reason, as the reconciliation requires.
  • @objectstack/verify tests — 23 passed.
  • rls-runner.test.ts (extended), authz-conformance.test.ts, rls-fixture.dogfood.test.ts, controlled-by-parent.dogfood.test.ts — 27 passed.
  • origin/main merged and all of the above re-run afterwards.

rls-runner.test.ts is the runner's detector-liveness oracle — the live fixture can no longer plant the hole — so it gains cases for probe-blocked, target adoption, and the proven/unproven accounting.

Out of scope, recorded rather than fixed

  • The conformance ledger checks only that a cited proof file exists (checkLedger, packages/verify/src/conformance.ts:77), never that it proves the row. That is the mechanism behind this card's premise and it is unchanged here — a general fix touches ~30 rows.
  • The probe cannot reach narrowing authored on a position it does not hold (the showcase's positions: ['contributor'] rules); reaching those needs a per-declared-position persona. Stated in the module header so the green is not over-read.
  • showcase_project_membership is skipped for having no plain-text field to mutate. Widening the probe-field set to the text family risks a sanitizer or a min/max rejection reading as a false rls-consistent, so it was left alone deliberately.

Generated by Claude Code

…reading as passes
The `verify --rls` runner reported 0 HOLES over a probe that could not, by
construction, observe the class it claims to prove.
- The probe persona was a bare `signUp()` member holding no object grants, so
`checkObjectPermission` answered 403 before record scope was ever consulted.
Measured on the stock showcase: 11 of 13 "consistent" verdicts were that
object-gate 403; only 2 were a record-scope 404.
- A `showcase_account` auto-record 400 cascaded into four further skips
(8 of 23 objects skipped), and the summary line still read "0 HOLES".
The probe is now the persona the class needs: object read+edit plus an
owner-scoped SELECT-only narrowing, registered at boot so its policies are on
the resolution path. That is deliberately the authoring shape that was the
hole, so every object of every verified app is now a live regression guard for
the by-id write-scope derivation. Reachability is MEASURED per object (a LIST
403 reports `probe-blocked`, never a pass), an unsatisfiable admin create falls
back to adopting an existing row so one failure no longer cascades, and the
report separates PROVEN objects from NOT-PROVEN ones in both the structured
summary and the formatted output.
…over the runner's new not-proven statuses
ADR-0049's point is the RECORD telling the truth, so both matrix rows were
re-decided on their own evidence rather than downgraded by assertion.
`rls-by-id-write` stays `enforced`, but its enforcement citation was
incomplete in a way that mattered: the named pre-image re-read is a no-op under
select-only authoring, which was #7665. Ablating the write-scope derivation
while leaving the pre-image re-read in place turns 16 of 20 probed showcase
objects into `rls-hole`, so the row now names both halves. The shared proof
file is NOT vacuous for this row — since #7665/PR #7792 it carries a dedicated
select-only block whose member set grants full CRUD, so its refusals are the
record gate.
`controlled-by-parent` stays `enforced` on its own dedicated proof, whose
fixture likewise grants full CRUD on both objects. Neither row was downgraded,
so the mirroring showcase `permission-sets.ts` comment is accurate as shipped
and is left alone.
The runner's unit oracle gains the two statuses that stop a not-proven object
reading as a pass (`probe-blocked`, and skip/adoption accounting).
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 9:17am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/dogfood, @objectstack/verify.

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

  • content/docs/ai/skills-reference.mdx(via packages/cli)
  • content/docs/api/client-sdk.mdx(via @objectstack/cli)
  • content/docs/api/data-flow.mdx(via @objectstack/cli)
  • content/docs/api/environment-routing.mdx(via @objectstack/cli)
  • content/docs/api/error-catalog.mdx(via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx(via packages/cli)
  • content/docs/deployment/backup-restore.mdx(via @objectstack/cli)
  • content/docs/deployment/cli.mdx(via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx(via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/cli)
  • content/docs/kernel/runtime-services/index.mdx(via packages/cli)
  • content/docs/permissions/authentication.mdx(via @objectstack/cli)
  • content/docs/permissions/authorization.mdx(via packages/qa/dogfood)
  • content/docs/permissions/delegated-administration.mdx(via packages/qa/dogfood)
  • content/docs/plugins/index.mdx(via @objectstack/cli)
  • content/docs/plugins/packages.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx(via @objectstack/cli)

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

  • content/docs/releases/implementation-status.mdx(via @objectstack/cli, @objectstack/verify)
  • content/docs/releases/v15.mdx(via @objectstack/verify)
  • content/docs/releases/v16.mdx(via @objectstack/cli)
  • content/docs/releases/v17.mdx(via @objectstack/cli)

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.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

PM review — domain:cli seat (#6024). Verdict: accept, pending CI. No change requests.

The dispatch's sharpest lead was a hypothesis, and it was checked rather than taken

I handed this card a specific lead: rls-read and rls-by-id-write cite the same proof file, so the by-id-write row may have been borrowing credibility from a test that only exercises the read filter. That was a good reason to look. It was also stale — since #7665 / merged PR #7792 that file carries a dedicated select-only block (:107–172) whose member set grants full CRUD on rls_note, so its refusals are the record gate and never the object gate.

The dev verified the lead instead of implementing it. That matters: taking my framing on faith would have downgraded a row that is genuinely enforced — a false claim in the opposite direction, one that would have made #7665's fix look unlanded. ⛔ Do not inherit a PM's lead; measure it. Done exactly right here.

Both rows keep state: 'enforced'. The card is stale on both. And because neither was downgraded, the mirroring comment in the showcase's permission-sets.ts is correctly left alone — it is accurate as shipped.

The finding the card did not contain

rls-by-id-write's enforcement field named only the pre-image re-read — and the ablation proves that is a no-op on its own under select-only authoring: 16 holes appeared with the re-read fully present. So the record was right about the state and wrong about the mechanism, naming one half of a two-part enforcement. Correcting enforcement while leaving state untouched is precisely the ADR-0049 distinction this card was about.

(i): the probe was made harder, not friendlier

The thing I most wanted to be impossible here was tuning the probe to preserve a green. The opposite happened:

  • The BEFORE numbers are the finding, not a baseline — 11 of 13 showcase verdicts and all 4 crm verdicts were GET 403, the object gate. Only two objects ever reached record scope. 0 HOLES was very nearly vacuous, and now that is quantified rather than asserted.
  • The persona is narrowed by an owner policy authored operation: 'select'only — deliberately the authoring shape that was the hole. That turns every object of every verified app into a live regression guard for the by-id write-scope derivation.
  • probe-blocked is a distinct status that is never a pass, so a persona that silently failed to receive its grants cannot masquerade as a proof. That generalizes the "a skip must never read as a pass" requirement instead of patching the one cascade.
  • Registering the probe set at boot rather than as a bare sys_permission_set row, because the evaluator's last-resort DB loader hydrates the object bits but notrowLevelSecurity — a row-only probe would have granted the grants and silently dropped the narrowing. That is the same class of silent-drop this whole card is about, avoided one layer down.

And the 0 holes AFTER is backed by an ablation: 16 rls-hole, exit 1, each GET 404 … yet MUTATED it by id (PATCH 200). The detector has teeth, so the zero means something.

The CI-gate question was answered, not dodged.ci.yml's dogfood-verify runs verify --rls for both apps and gates through dogfood-gate; both exit 0 after this change. Nothing suppressed, no blocker decision needed — which is what I asked to be told either way.

Out-of-scope, recorded rather than quietly fixed

The ledger checks only that a cited proof file exists (packages/verify/src/conformance.ts:77), never that it proves the row — the mechanism behind this card's premise, left unchanged because a general fix touches ~30 rows. Correct call, and worth its own card.

Also honest about what the new green does not cover: the probe cannot reach narrowing authored on a position it does not hold, stated in the module header so the result is not over-read; and showcase_project_membership is skipped deliberately, because widening the probe-field set risks a sanitizer or min/max rejection reading as a false rls-consistent. Refusing to buy one more "proven" at the price of a possible false pass is the right trade in a tool whose entire job is to not lie.

Enqueueing once CI lands.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review August 12, 2026 09:48
@hotlong
hotlong added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit be37f85Aug 12, 2026
27 checks passed
@hotlong
hotlong deleted the claude/issue-7685-verify-rls-probe-and-authz-matrix branch August 12, 2026 10:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@claude