Skip to content

Split the row-level security page and fix its example #715

Closed

Description

@0x054

Parent

#698

What to build

The 736-line RLS page mixes explanation, how-to and reference, shows its first example at line 111, and never shows what a scoped query returns; its example's policy reads a membership table it never declares, so on Postgres the models as shown should fail to migrate.

It splits into an explanation (concepts/row-level-security.md: what RLS is and how Ferro's session settings drive it), a how-to (the existing guide page: set up and use it, with a scoped query beside an unscoped one) and a reference (reference/row-level-security.md: RowSecurity, RowPolicy, the command matrix, the shorthand cast). The example declares every table its policies read and runs on Postgres in the harness (skipped without Postgres, as the other Postgres examples are). Before writing about round trips and which open transactions see a new set_config value, the ticket confirms the behaviour in the code and states it once; the session docstrings ticket (#720) aligns its docstrings to it.

Pages: guide/row-level-security.md, concepts/row-level-security.md, reference/row-level-security.md

Acceptance criteria

  • Every fact on the page matches the code at the time of the PR; each High finding in the audit rows below is fixed, and each Medium/Low finding is fixed or answered in the PR body with the reason it was left.
  • Each page is one kind (tutorial, how-to, reference or explanation) and opens with one sentence saying what it covers, then an example on the first screen (writing-for-humans rules 1–2).
  • Each page uses one running example, declared once near the top and reused; each section shows only what changed (rule 3).
  • Every code block has a lead-in sentence ending in a colon and a result sentence after it, and shows its result: printed output, or the asserted value inside the embedded snippet range (rules 7–8). Snippet end: markers sit below the assert or print that holds the result.
  • Every copyable block either shows the async with engines.session(): wrapper or sits under a sentence that says it runs inside one and links to Connections › Sessions; the page's first block shows the wrapper.
  • Every inline Python block that does real work runs under the harness ({test=run prelude=<name>}) or moves into an example script; a block left compile-only says why in its fence marker.
  • Every lambda passed to a query method names its parameter after the model in lowercase singular (AGENTS.md I-8); python -m tests._docs_checks reports no violation for the owned files.
  • Every error check in an owned example fails when the expected error is not raised (try/except/else: raise AssertionError, or the harness helper).
  • Field-declaring examples show both declaration styles as tabs, each backed by a runnable script (AGENTS.md I-7), and the twins use the one Annotated idiom recorded in the docs conventions.
  • Headings are short sentence-case noun phrases naming the feature (rule 5); how-to titles name the goal (rule 24); no minimising words (rule 17); admonitions follow rule 20.
  • No ADR numbers, issue numbers, codenames, dated or release-bound notes, or contributor-only references in page text; version markers are self-contained blocks at the end of a section.
  • Reference material that has a home in Reference is linked, not repeated; each topic ends with a pointer to its reference entry (rule 4).

Files owned

  • Create: docs/pages/concepts/row-level-security.md, docs/pages/reference/row-level-security.md
  • Modify: docs/pages/guide/row-level-security.md, docs/examples/row_level_security.py, docs/examples/row_level_security_annotated.py

Interfaces

Test scenarios

  • Happy path: the strict site build succeeds and every snippet range on the owned pages renders without leading indentation.
  • Happy path: every owned example script runs to completion under tests/test_docs_examples.py, and every inline block marked {test=run ...} runs.
  • Error path: deleting the raise an owned example's error check expects makes that example's test fail.
  • Edge case: python -m tests._docs_checks over the owned files exits 0; renaming one lambda parameter to t makes it exit 1 naming the file and line.
  • Integration (Postgres): the RLS example migrates and a scoped query returns only the current tenant's rows while the unscoped one returns all.

Verify

uv run --no-sync zensical build --clean --strict
uv run --no-sync pytest tests/test_docs_examples.py -q --no-cov -p no:cacheprovider -n 8
uv run --no-sync python -m tests._docs_checks docs/pages/guide/row-level-security.md docs/pages/concepts/row-level-security.md docs/examples/row_level_security.py docs/examples/row_level_security_annotated.py

Blocked by

Audit findings

Rows from the docs audit this ticket covers (file, line, rule, fix)

docs/pages/guide/row-level-security.md

  • Kind (rule 1): mixed. Explanation (pooler constraint, NULLIF contract, why FORCE), how-to (deploy
    ordering, JSON-claims recipe, framework integration), reference (validation rules, connection-delivery
    mode trade-offs, drift/teardown rules). 736 lines.
  • Running example (rule 3): Invoice with tenant_id / app.tenant_id. It is not held:
    Doc (multi-policy), owner_id (JSON claims), rlsorder (migrations listing) each appear as new
    examples.
  • Verdict: Accurate and careful. Every claim spot-checked holds against rowsecurity.py,
    session.py and the Rust warning texts. But it is three page kinds in one, puts two hazard sections
    ahead of the first example, and never shows a scoped query's actual result. Split it and lead with the
    model. Grade: C.
Sev Line Rule Finding Suggested fix
H 1–736 1 Three kinds in one page. A reader who wants "add a tenant policy" scrolls past pooler theory, and a reader who wants the connection-delivery trade-offs finds them 580 lines down. Split it three ways. How-to "Scope rows by tenant with Postgres RLS" (declare → deliver settings → deploy order → framework recipes). Explanation "How Ferro delivers session settings" (pooler constraint, NULLIF contract, scope stability, connection delivery). Reference for RowPolicy / RowSecurity / engines.session(settings=) validation rules, ideally generated from the docstrings, which already carry most of it.
M 1–110 2, 4 The first example is at line 111. Lines 19–103 are two warning sections ("The pooler constraint", "Deploy ordering") ahead of any model. Open with the Invoice model and its generated SQL. Move the pooler material into the delivery section, and keep the deploy ordering as a short Warning admonition just before "Deploy".
M 1 5, 24 Title "Using with Postgres RLS" says neither what the page gives nor what is being used. "Row-level security on PostgreSQL" (guide), or the how-to title above.
M 123–137, 197–216 8 The page never shows what a scoped query returns. Line 134 says "a connection whose app.tenant_id setting is unset sees no rows" but nothing shows it. Add a four-row demo: two tenants, two invoices each. Show Invoice.all() inside session(settings={"app.tenant_id": A}) returning 2 rows, and inside a settings-less session returning [].
M 208, 595 fact The page says "three extra round-trips" (BEGIN, set_config, COMMIT). The engines.session docstring (src/ferro/session.py:440) says "about two extra round-trips per operation". Pick the true number and use it in both places.
M 291–297 fact The page says an open transaction() in any task sharing the session gets the new value re-applied. The set_config docstring (session.py:286–301) says in-flight work "keeps running under the scope it started with" and names only the same-task transaction as special. Check with the implementation, then make page and docstring agree.
M 68, 685–725 fact/omission Deploy ordering says "run migrate_updates (or an Alembic revision)". ferro migrate new, the first-party door documented at line 689, is left out of the step a production reader follows. "run ferro migrate new (or migrate_updates, or an Alembic revision)".
M 292 22 / fact Line 292 says "a fresh SET LOCAL batch", but the rest of the page insists Ferro never sends plain SET and uses set_config($1,$2,true). "a fresh set_config(..., true) batch".
M 33, 70, 389, 485 humanizer Not-X-but-Y and triads: "SET there isn't a correctness gap — it's a cross-tenant data leak"; "a blank dashboard, not an error, not a warning …"; "matters for performance, not just style". More than 60 em dashes in total. Make the plain claims: "Plain SET behind a transaction-mode pooler leaks one tenant's scope to the next request." Cut the dashes by about half.
M 255–268 7, 15 Lead-in ends with an em dash and the block is followed by "— which is the only way…", a sentence split around a code block. End the lead-in with a colon. Start the follow-up as its own sentence: "This is the only way to reach…".
M 403–570 9 The Litestar, FastAPI and generic ASGI recipes are untested, and they assume scope["user"] and request.state.user.tenant_id. The FastAPI recipe relies on a contextvar set inside a yield dependency reaching the handler. Add a runnable example per framework in docs/examples/ (or one test with Starlette's TestClient for the generic ASGI one). At least state the assumption about where auth puts the user.
L 496–522 9 The deferred-pattern middleware snippets omit imports (ferro, ScopeType, …) and use an undefined resolve_tenant_from_auth_header. Mark them as fragments ("continuing the class above"), or make them complete.
L 692–701 3 The migrations listing uses table rlsorder and migration 0002_order_rls, not invoice. Regenerate the listing for Invoice (rls_invoice_tenant_id), and tag it <!-- output: --> so test_docs_migrations.py pins it.
L 195, 727 5, 22 The heading "Opening a session with settings=" has the identifier out of code font. "See Also" is title case. "Opening a session with settings="; "See also".
L 549 grammar "a genuinely separate ASGI application app.mount(...)s alongside this one" "a separate ASGI application that you app.mount(...) beside this one".
L 719–720 22 "ferro" lowercase in prose ("whose body ferro cannot verify"). "Ferro".
L 681 22 Bare "(ADR-0019)" is not a link and the ADRs are not on the site. Drop it, or link the ADR on GitHub.
L 314–317 ext The NULLIF table is good. The BYPASSRLS ALTER ROLE and FORCE explanation suit a !!! warning, and the deploy-ordering section is the one place an admonition clearly earns its place. Use !!! warning "Deploy settings delivery before policies".

I-7: satisfied (both tabs snippet-backed, both companions run). I-8: satisfied (lambda invoice:).


docs/examples/row_level_security.py + row_level_security_annotated.py

  • Embedded by: guide/row-level-security.md (models, multi_policy + annotated)
  • Verdict: C. The models are copy-paste broken on Postgres, the only database the feature exists for, and the example never exercises a policy.
Sev Line Rule Finding Suggested fix
H 55–62 9 The invitee_read policy's using= subquery reads membership (doc_id, member), which the example never declares. On Postgres, CREATE POLICY resolves the relation, so the shown models fail to migrate as written. tests/test_rls_end_to_end.py:45, 354 creates membership separately; the reader is never given it. Declare a Membership model in the multi_policy section, or note above the block that the table must exist first.
M 1–8, 69–85 9 The script runs on SQLite only and asserts the "PostgreSQL-only" warning. No docs test runs these models on Postgres, even though test_docs_migrations.py already has a Postgres-gated path. Add a Postgres-gated run of this script (skip without FERRO_POSTGRES_URL) that sets app.tenant_id and asserts tenant isolation.
L 33 10 Model Doc with a string owner: fine, though Document reads better. —

Activity

  1. added
    documentationImprovements or additions to documentation
    ready-for-agentUnblocked and fully specified; an agent may claim it
    size:MNeeds a plan; one or two local workers
    on Oct 19, 2026
  2. added theissue type on Oct 19, 2026
  3. self-assigned this
    on Oct 19, 2026
  4. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Status: verify-passed
    Worker: worker-715 (reviewed by reviewer-715)
    Branch: sdd/715-rls-split
    PR: #733
    Verify: passed 140abd6 (trial merge into feat/docs-rewrite at f8aa4b2 passed too)

    Review: Spec PASS, Quality PASS, Minor findings only. The Postgres test passed for both twins in CI's backend matrix.
    Still open: no issue exists yet for the gap where a raw policy reads a table created in the same pass, so the orchestrator should file one.

  5. 0x054 commented on Oct 19, 2026

    @0x054
    ContributorAuthor

    Merged in #733 into feat/docs-rewrite

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

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationready-for-agentUnblocked and fully specified; an agent may claim itsize:MNeeds a plan; one or two local workers

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions