#!/usr/bin/env python3
"""Built the v1.0 DRAFT of content/research/catalogue-driven-rls.json from the artifact's results.

SUPERSEDED: the report was revised by hand for v1.1 (2 November 2026). Do not run this to
regenerate the published report; it would restore v1.0 wording and claims.

Citations are written as [[key]] and numbered in order of first appearance;
the references array is emitted in that order.
"""
import json
import os
import re
import statistics as st

ART = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
S = json.load(open(os.path.join(ART, "results", "summary.json")))
S["machine"] = "an Apple M1 Pro laptop (8 cores, 16 GB RAM) running macOS 27.0.1"
SCOPE = json.load(open(os.path.join(ART, "results", "exp3_scope_history.json")))
COUNTS = json.load(open(os.path.join(ART, "results", "exp3_catalogue_counts.json")))
MIN = json.load(open(os.path.join(ART, "results", "summary_minimal.json")))
SLUG = "catalogue-driven-rls"
IMG = f"/images/research/{SLUG}"
OUT = os.environ.get("PAPER_OUT", "catalogue-driven-rls.json")

L = S["exp1_lockscope"]
A = S["exp4_audit_chain"]
Q = S["exp2_query_timing"]
PB = S["exp2_pgbench"]
H = S["exp2_helper_overhead"]


def ms(v):
    if v >= 10000:
        return f"{v/1000:.1f}&nbsp;s"
    if v >= 100:
        return f"{v:,.0f}&nbsp;ms"
    if v >= 1:
        return f"{v:.1f}&nbsp;ms"
    return f"{v:.3f}&nbsp;ms"


def plain_ms(v):
    return re.sub("&nbsp;", " ", ms(v))


def x(v):
    return f"{v:,.0f}" if v >= 10 else f"{v:.1f}"


b_same, b_other = L["before"]["same_tenant_ms"], L["before"]["other_tenant_ms"]
a_same, a_other = L["after"]["same_tenant_ms"], L["after"]["other_tenant_ms"]
h_same, h_other = L["head"]["same_tenant_ms"], L["head"]["other_tenant_ms"]
i_same, i_other = L["head_immediate"]["same_tenant_ms"], L["head_immediate"]["other_tenant_ms"]
speedup = b_same["p50"] / a_same["p50"]
speedup_head = b_same["p50"] / h_same["p50"]

post_trials = sum(A[v][k]["trials"] for v in ("after", "head") for k in ("interleave", "stress"))
post_fail = sum(A[v][k]["chains_failing_verification"] for v in ("after", "head") for k in ("interleave", "stress"))
pre_inter, pre_stress = A["before"]["interleave"], A["before"]["stress"]

c22, chead = COUNTS["m022"], COUNTS["head"]
blind22 = next(p for p in SCOPE if p["label"].startswith("022"))
blindhead = next(p for p in SCOPE if p["label"].startswith("HEAD"))

qx0, qx50, qx1k = Q["x0"], Q["x50"], Q["x1000"]


def pgb(db, mode):
    d = PB[db][mode]
    return d["tps_ci"], d["latency_ci"]


tps0_r, lat0_r = pgb("wc_bench_x0", "rls")
tps0_o, lat0_o = pgb("wc_bench_x0", "owner_filter")
tps0_p, lat0_p = pgb("wc_bench_x0", "rls_plus_filter")
tps50_r, lat50_r = pgb("wc_bench_x50", "rls")
tps50_o, lat50_o = pgb("wc_bench_x50", "owner_filter")
tps50_p, lat50_p = pgb("wc_bench_x50", "rls_plus_filter")

hs = {k: v["exec_ms"]["p50"] for k, v in H.items()}
hmin = {k: v["exec_ms"]["min"] for k, v in H.items()}

LOAD = json.load(open(os.path.join(ART, "results", "load_summary.json")))

REFS = {
    "chong2006": ("F. Chong, G. Carraro and R. Wolter. Multi-tenant data architecture. Microsoft Corporation (MSDN), June 2006.",
                  "https://citus-docs.readthedocs.io/en/v8.0/_static/mt-data-arch.pdf"),
    "pgrls": ("PostgreSQL Global Development Group. PostgreSQL 17 documentation, section 5.9: Row Security Policies.",
              "https://www.postgresql.org/docs/17/ddl-rowsecurity.html"),
    "schneier1999": ("B. Schneier and J. Kelsey. Secure audit logs to support computer forensics. ACM Transactions on Information and System Security 2(2):159-176, 1999.",
                     "https://doi.org/10.1145/317087.317089"),
    "crosby2009": ("S. A. Crosby and D. S. Wallach. Efficient data structures for tamper-evident logging. In Proceedings of the 18th USENIX Security Symposium, pp. 317-334, 2009.",
                   "https://www.usenix.org/conference/usenixsecurity09/technical-sessions/presentation/efficient-data-structures-tamper-evident"),
    "berenson1995": ("H. Berenson, P. Bernstein, J. Gray, J. Melton, E. O'Neil and P. O'Neil. A critique of ANSI SQL isolation levels. In Proceedings of ACM SIGMOD 1995, pp. 1-10.",
                     "https://doi.org/10.1145/223784.223785"),
    "warszawski2017": ("T. Warszawski and P. Bailis. ACIDRain: concurrency-related attacks on database-backed web applications. In Proceedings of ACM SIGMOD 2017, pp. 5-20.",
                       "https://doi.org/10.1145/3035918.3064037"),
    "fekete2005": ("A. Fekete, D. Liarokapis, E. O'Neil, P. O'Neil and D. Shasha. Making snapshot isolation serializable. ACM Transactions on Database Systems 30(2):492-528, 2005.",
                   "https://doi.org/10.1145/1071610.1071615"),
    "cahill2008": ("M. J. Cahill, U. Röhm and A. D. Fekete. Serializable isolation for snapshot databases. In Proceedings of ACM SIGMOD 2008, pp. 729-738.",
                   "https://doi.org/10.1145/1376616.1376690"),
    "ports2012": ("D. R. K. Ports and K. Grittner. Serializable snapshot isolation in PostgreSQL. Proceedings of the VLDB Endowment 5(12):1850-1861, 2012.",
                  "https://doi.org/10.14778/2367502.2367523"),
    "rfc6962": ("B. Laurie, A. Langley and E. Kasper. Certificate Transparency. RFC 6962, IETF, June 2013 (obsoleted by RFC 9162).",
                "https://www.rfc-editor.org/rfc/rfc6962"),
    "pgfunc": ("PostgreSQL Global Development Group. PostgreSQL 17 documentation: CREATE FUNCTION, section \"Writing SECURITY DEFINER Functions Safely\".",
               "https://www.postgresql.org/docs/17/sql-createfunction.html"),
    "pgtrigger": ("PostgreSQL Global Development Group. PostgreSQL 17 documentation: CREATE TRIGGER (constraint triggers, DEFERRABLE INITIALLY DEFERRED).",
                  "https://www.postgresql.org/docs/17/sql-createtrigger.html"),
    "pglocks": ("PostgreSQL Global Development Group. PostgreSQL 17 documentation, section 13.3: Explicit Locking (advisory locks).",
                "https://www.postgresql.org/docs/17/explicit-locking.html"),
    "pginline": ("PostgreSQL wiki. Inlining of SQL functions.",
                 "https://wiki.postgresql.org/wiki/Inlining_of_SQL_functions"),
    "supabase": ("Supabase. Row Level Security: performance recommendations (documentation page, accessed 2 November 2026).",
                 "https://supabase.com/docs/guides/database/postgres/row-level-security"),
    "jia2011": ("Y. Jia and M. Harman. An analysis and survey of the development of mutation testing. IEEE Transactions on Software Engineering 37(5):649-678, 2011.",
                "https://doi.org/10.1109/TSE.2010.62"),
}


def c(*keys):
    return "".join(f"[[{k}]]" for k in keys)


sections = []


def sec(id_, title, html):
    sections.append({"id": id_, "title": title, "html": html})


# ---------------------------------------------------------------- 1
sec("introduction", "1 Introduction", f"""
<p>A multi-tenant SaaS product that keeps every customer in one shared PostgreSQL schema has exactly one thing standing between two customers' data: the predicate that says which rows belong to whom. Putting that predicate in the application means one forgotten <code>WHERE</code> clause is a breach. Putting it in the database, as PostgreSQL row-level security (RLS) policies, makes a forgotten filter fail closed{c('chong2006', 'pgrls')}. It does not remove the problem, though. It moves it: now the risk is a table created without RLS, a policy that keys on the wrong column, a helper function that runs with more privilege than its caller, or an audit log whose integrity mechanism quietly fails under concurrency.</p>
<p>This report studies those risks in WayClub, a group-management product for clubs, societies and communities that the author is building. It is in development: it has no live site and no real tenants, and its cloud database is stopped. That is a real constraint on what can be claimed, and it is also why the database can be studied in detail. Every schema state in its history can be rebuilt from migrations and examined on a laptop at no cost.</p>
<p>Two engineering episodes from that history motivate the paper. First, the project's security documents once said that RLS was "enabled and forced" and that a test asserted coverage. Neither was true. When the claim was checked, turning on <code>FORCE ROW LEVEL SECURITY</code> broke the policy helpers. The response was to replace prose with five tests that query PostgreSQL's catalogue directly. One of those tests later turned out to have a blind spot of its own: its tenant-scope predicate looked only for <code>institution_id</code>, so it ignored tables added once <code>account_id</code> became the tenant root. Second, the hash-chained audit log forked under concurrency, and its tenant lock serialised every audited write in a tenant behind the slowest open request. A serial test suite had reported the chain correct throughout.</p>
<p>This report re-examines both episodes with new, reproducible measurements, and adds one finding that was not known before it was written: the policy-helper style the design depends on is very expensive per row.</p>
<h3>Contributions</h3>
<ul>
<li><strong>Catalogue-driven RLS assurance, evaluated by mutation.</strong> The five catalogue assertions are ported to SQL and run against eight seeded schema mutations under the old and new tenant-scope predicates. The new predicate catches every in-scope mutation. The old one misses the two that use <code>account_id</code>. Neither catches a person-scoped or foreign-key-derived table, which only a scope-free release check catches (Section 5.3).</li>
<li><strong>A measured history of the coverage gap.</strong> The schema is rebuilt at three points in its history. The documented counts (78 RLS tables, 126 policies) reproduce exactly at migration 022. The old predicate's blind spot grows from 0 tables at migration 016 to {blind22['new_scope'] - blind22['old_scope']} at 022 and {blindhead['new_scope'] - blindhead['old_scope']} at today's head. At head the suite is red, on four policies that need explicit exemptions (Section 5.2).</li>
<li><strong>A fork-free, availability-preserving audit chain.</strong> Allocating the chain position inside the lock at commit time, through a deferred constraint trigger, removes the fork: {post_fail} of {post_trials} post-fix trials failed verification, against {pre_inter['chains_failing_verification'] + pre_stress['chains_failing_verification']} of {pre_inter['trials'] + pre_stress['trials']} before. It also cuts the median same-tenant wait behind a 5-second holder from {ms(b_same['p50'])} to {ms(a_same['p50'])} (Sections 4.4, 5.4, 5.5).</li>
<li><strong>A measured cost of the RLS design, with its cause.</strong> At realistic row counts, a tenant-scoped read under RLS costs two to three orders of magnitude more than the explicit filter it replaces. The cause is that the policy evaluates helper calls once per row. Removing the <code>SET</code> clause or the definer property barely helps. A set-based or hoisted formulation removes most of the cost (Section 5.6).</li>
</ul>
""")

# ---------------------------------------------------------------- 2
sec("background", "2 Background and related work", f"""
<h3>2.1 Multi-tenant data architectures</h3>
<p>Chong, Carraro and Wolter describe a spectrum running from a separate database per tenant, through a separate schema per tenant, to a shared schema in which every row carries a tenant identifier{c('chong2006')}. The shared schema is the cheapest to run and the easiest to scale to many small tenants. Its isolation is only as good as the code that filters by tenant. WayClub sits at the shared end. Its tenants are many small clubs and a few institutions, so per-tenant databases would cost more than the product can carry before launch.</p>
<h3>2.2 PostgreSQL row-level security</h3>
<p>PostgreSQL RLS attaches policies to a table. Each policy is a <code>USING</code> expression that filters visible rows and a <code>WITH CHECK</code> expression that constrains written rows, and permissive policies combine by OR{c('pgrls')}. Enabling RLS on a table with no policies denies all access. Three exemptions matter here. Superusers and roles with <code>BYPASSRLS</code> skip policies. The table owner skips them too, unless the table is marked <code>FORCE ROW LEVEL SECURITY</code>. And a <code>SECURITY DEFINER</code> function runs with its owner's privileges, so it is subject to whatever RLS applies to that owner. PostgreSQL's guidance for definer functions is to pin <code>search_path</code> with a <code>SET</code> clause{c('pgfunc')}. A pinned <code>SET</code> clause, and the definer property on its own, both stop the planner inlining a SQL function into the calling query{c('pginline')}. Vendor guidance for RLS-heavy platforms gives two remedies for per-row function cost: wrap row-independent calls in a scalar sub-select so they run once per statement, and use definer functions for membership lookups{c('supabase')}. Section 5.6 measures both.</p>
<h3>2.3 Tamper-evident logging</h3>
<p>Schneier and Kelsey showed how to make log entries written before a machine's compromise impossible for the attacker to read, or to alter or delete without detection, using hash chains and evolving keys{c('schneier1999')}. Crosby and Wallach observed that auditing a hash chain costs linear time. They proposed a Merkle-tree history structure that gives logarithmic membership and consistency proofs, with an untrusted logger and auditors who hold commitments{c('crosby2009')}. Certificate Transparency put that design into production for public certificate logs{c('rfc6962')}. WayClub's chain is much more modest. It is a per-tenant SHA-256 hash chain inside the same database it protects, with no external commitment. Section 5.5 measures exactly what that does and does not detect.</p>
<h3>2.4 Concurrency anomalies in database-backed applications</h3>
<p>Berenson et al. showed that the ANSI isolation levels do not capture several anomalies that real implementations allow, and they defined snapshot isolation{c('berenson1995')}. Fekete et al. characterised when applications under snapshot isolation stay serializable{c('fekete2005')}. Cahill, Röhm and Fekete made snapshot isolation serializable at run time{c('cahill2008')}, a method PostgreSQL adopted as its <code>SERIALIZABLE</code> level{c('ports2012')}. Warszawski and Bailis found 22 concurrency attacks in 12 web applications and traced them to developers reasoning about one request at a time{c('warszawski2017')}. The audit-chain fork in Section 4.4 has the same shape at a smaller scale. A sequence number was allocated outside the lock that was meant to order it, the defect showed only with two open transactions, and a serial test suite reported the property as holding. PostgreSQL's advisory locks{c('pglocks')} and deferred constraint triggers{c('pgtrigger')} are the two primitives the fix uses.</p>
<h3>2.5 Mutation analysis of tests</h3>
<p>Mutation testing judges a test suite by seeding faults and counting how many the suite detects{c('jia2011')}. Section 5.3 applies the same idea to schema-level security assertions. Each mutant is a plausible migration mistake, and a good assertion set should fail on it.</p>
""")

# ---------------------------------------------------------------- 3
sec("system-and-threat-model", "3 System and threat model", f"""
<p><strong>System.</strong> WayClub is a modular monolith: a NestJS API, a Next.js web client and a Flutter mobile client over one PostgreSQL 17 database. The database has {chead['migration_files']} migration files at the studied commit (<code>65a18ff</code>, 1 November 2026). The tenant root is the <em>account</em>, either an institution that governs many clubs or a standalone club. Every tenant-scoped row carries <code>account_id</code>. Institution-governed rows also carry <code>institution_id</code>, and a composite foreign key ties it to the row's own account. Three database roles exist. <code>wayclub_admin</code> owns every object and runs migrations and seeds. <code>wayclub_app</code> serves every request, owns nothing and lacks <code>BYPASSRLS</code>. <code>wayclub_service</code> has <code>BYPASSRLS</code> and is reserved for narrow scheduled jobs. The API sets the caller's identity per transaction with <code>set_config('request.jwt.claims', $1, true)</code>, and policies read it through <code>auth.uid()</code>-style helper functions.</p>
<p><strong>Assets.</strong> Tenant data (members, events, registrations, money records, handovers) and the audit log, which records approvals, role grants and state changes and has to stand up as evidence in a dispute between club officers.</p>
<p><strong>Adversaries in scope.</strong> (A1) An authenticated user of one tenant who controls their own requests, including a forged or replayed token for their own identity, and who tries to read or write another tenant's rows. (A2) A developer, human or AI assistant, who writes a migration that adds a table or policy and forgets a protection. This is the adversary the catalogue assertions target. (A3) Concurrent honest traffic, which is no adversary but has historically broken integrity properties that serial tests reported as holding. (A4) An attacker with database-owner or superuser access who edits the audit log afterwards.</p>
<p><strong>Out of scope.</strong> A4 is in scope for <em>detection</em> only. The chain cannot prevent an owner from rewriting it, and Section 5.5 shows the cases it cannot even detect. Also out of scope: compromise of the API container (which, as the project's security document records, can read the service-role credential), SQL injection (the codebase uses parameterised queries, and this report does not test that), side channels, and denial of service beyond the lock-scope effect in Section 5.4.</p>
""")

# ---------------------------------------------------------------- 4
sec("design", "4 Design", f"""
<h3>4.1 RLS enabled, deliberately not forced</h3>
<p>The original architecture decision called for every tenant table to have RLS enabled <em>and forced</em>. That was never implemented. When the gap was found, the obvious fix was to turn <code>FORCE</code> on, and doing so broke the system. <code>FORCE</code> changes exactly one thing: whether policies also bind the table owner. Here the owner is the role that owns the <code>SECURITY DEFINER</code> helpers the policies call (<code>app.is_member_of</code>, <code>app.current_account_id</code> and about thirty more), and those helpers are definer-owned precisely so that evaluating a policy does not recurse into RLS. We re-ran the project's measurement on a seeded copy of today's schema, inside a transaction that was then rolled back. With RLS forced on <code>public.membership</code>, <code>app.is_member_of()</code> returned <code>false</code> for a genuine member, where it returned <code>true</code> before. The owner's <code>count(*)</code> over membership dropped from 16 rows to 0.</p>
<p>The design that was adopted puts the trust boundary in role separation, not in <code>FORCE</code>. The request role owns nothing and has no <code>BYPASSRLS</code>, so RLS binds it unconditionally. Rules that must bind the owner as well are written as constraints and triggers, not policies: the composite tenant foreign key, the append-only audit trigger, the <code>unique (account_id, chain_index)</code> backstop and several immutability triggers. A catalogue test asserts that no table is forced, and it carries the measurement in a comment, so that nobody reverses the decision without seeing the reason.</p>
<h3>4.2 Caller-validated SECURITY DEFINER functions</h3>
<p>Definer functions are the audited exceptions to the boundary. They run as the owner, so RLS cannot protect anything a definer function writes. An adversarial review found two of them authorising on less than they should. <code>app.write_audit</code> accepted a cross-tenant forged audit row, and <code>app.promote_waitlist_head</code> authorised on tenancy alone. Migrations 012 to 015 made each definer check its caller's claims itself. The request-role audit writer now pins account, institution and actor to the caller's own claims. The unvalidated writer is granted to no role at all and is reachable only from owner-definer trigger functions. Both regressions are covered by passing tests in the project's database suite. At head there are {chead['definer_functions']} <code>SECURITY DEFINER</code> functions in the application schemas, all with a pinned <code>SET search_path</code>, and {chead['policies_calling_app_helpers']} of the {chead['public_policies']} public-schema policies call at least one <code>app.*</code> helper. That ratio becomes important in Section 5.6.</p>
<h3>4.3 Catalogue assertions</h3>
<p>The five assertions in the project's <code>RLS coverage (mechanical)</code> test block read <code>pg_class</code>, <code>pg_policy</code> and <code>has_table_privilege</code> instead of a hand-kept list. They first compute a <em>tenant scope</em>, the set of public tables that name a tenant. Then:</p>
<ol>
<li><strong>A1</strong>: every in-scope table has <code>relrowsecurity</code> set;</li>
<li><strong>A2</strong>: no in-scope table has <code>relforcerowsecurity</code> set (Section 4.1);</li>
<li><strong>A3</strong>: no in-scope table is granted to the request role without a policy, with a positive control that more than ten "breadth" tables are both ungranted and unpoliced;</li>
<li><strong>A4</strong>: the request role has only a SELECT policy on <code>audit_event</code>;</li>
<li><strong>A5</strong>: every request-role policy outside the two tenant-root tables mentions <code>current_account_id</code>.</li>
</ol>
<p>The tenant-scope predicate is the weak point. It was first written as "the <code>institution</code> table plus every table with an <code>institution_id</code> column" (commit <code>85475d7</code>). Migration 017 then made <code>account_id</code> the tenant root. Tables added after it, including entitlements, club affiliation, institution claims and club migration, carry <code>account_id</code> and no <code>institution_id</code>, so the predicate silently stopped selecting them. Commit <code>5096bcd</code> widened it to "<code>institution</code> or <code>account</code>, or any table with <code>institution_id</code> <em>or</em> <code>account_id</code>". Section 5.2 measures what the old predicate would have missed at each point.</p>
<p>The deployment pipeline also runs a release-time role check, <code>database-checks.mjs</code>. Among other things, it fails on any privilege the request role holds on any relation without RLS, in any non-system schema. That rule needs no tenant predicate. We call it <strong>R1</strong> and include it in the evaluation because it covers exactly the cases the scoped assertions cannot.</p>
<h3>4.4 Per-account audit chain with commit-time allocation</h3>
<p><code>audit_event</code> is append-only. A <code>BEFORE UPDATE OR DELETE</code> trigger rejects changes from every role, owner included. Each row's hash is SHA-256 over the previous row's hash and the row's own fields. Migration 006 computed the hash in a <code>BEFORE INSERT</code> trigger. That trigger took <code>pg_advisory_xact_lock</code> on the tenant, read the latest row by <code>seq</code> (a <code>generated always as identity</code> column) and chained onto it. This had two defects.</p>
<p><em>Integrity.</em> <code>seq</code> is allocated while the tuple is formed, before the trigger runs, and so before the lock is taken. Allocation order and lock order can differ. Suppose a transaction T1 writes a row and holds the lock. T2 then allocates <code>seq</code>&nbsp;=&nbsp;<em>n</em> and blocks. T1 writes a second row with <code>seq</code>&nbsp;=&nbsp;<em>n</em>+1 and commits. T2 then chains its lower-<code>seq</code> row onto T1's higher-<code>seq</code> row. A verifier that walks <code>seq</code> order sees two breaks. With several rows per transaction it gets worse. A transaction's second row looks up "the latest row by <code>seq</code>", which can be another transaction's committed row rather than its own first row, so two rows end up with the same predecessor: a real fork (Section 5.5).</p>
<p><em>Availability.</em> An advisory transaction lock lasts until commit. The lock was taken at the first audited write, and API transactions span whole request handlers, so one slow request blocked every audited write in its tenant.</p>
<p>Migration 011 fixed both. A new <code>chain_index</code> column carries the authoritative position. Chaining moved into a <code>DEFERRABLE INITIALLY DEFERRED</code> constraint trigger, which fires during <code>COMMIT</code>. That trigger takes the lock, reads the tenant's highest <code>chain_index</code>, and writes <code>chain_index</code>, <code>hash_prev</code> and <code>hash</code> onto the new row in one narrowly exempted update. Chain order and lock order are now the same by construction, and the critical section is the commit, not the rest of the handler. A <code>unique (account_id, chain_index)</code> index is the structural backstop: if serialisation were ever defeated, two rows would collide and the commit would fail loudly instead of forking silently. Migration 019 re-keyed the chain from institution to account, so that standalone clubs, which have no institution, get a chain too. Before re-deriving hashes under the new formula, it verified every existing chain under the old one, because re-hashing an unverified chain would launder a forgery. One trade-off remains. A session that runs <code>SET CONSTRAINTS ALL IMMEDIATE</code> makes the trigger fire per insert again, which brings the availability problem back. Integrity is unaffected, because the position is still allocated inside the lock.</p>
""")

# ---------------------------------------------------------------- 5
tbl_counts = f"""
<table><caption>Table 1. Catalogue counts, measured on rebuilt schemas (public schema; the migration runner's own ledger table excluded).</caption>
<thead><tr><th>Quantity</th><th>Documented (2026-08-03, at migration 022)</th><th>Measured at migration 022</th><th>Measured at head (65a18ff)</th></tr></thead>
<tbody>
<tr><td>Tables</td><td>79</td><td>{c22['tables']}</td><td>{chead['tables']}</td></tr>
<tr><td>Tables with RLS enabled</td><td>78</td><td>{c22['rls_enabled']}</td><td>{chead['rls_enabled']}</td></tr>
<tr><td>Tables without RLS</td><td>1 (<code>role</code>)</td><td>{c22['rls_disabled']} ({c22['rls_disabled_names']})</td><td>{chead['rls_disabled']} ({chead['rls_disabled_names']})</td></tr>
<tr><td>Tables with RLS forced</td><td>0</td><td>{c22['rls_forced']}</td><td>{chead['rls_forced']}</td></tr>
<tr><td>Policies</td><td>126 across 47 tables</td><td>{c22['policies']} across {c22['tables_with_policies']}</td><td>{chead['policies']} across {chead['tables_with_policies']} ({chead['request_policies']} for the request role, {chead['service_policies']} for the service role)</td></tr>
<tr><td>Tables reachable by <code>wayclub_app</code></td><td>48</td><td>{c22['app_reachable']}</td><td>{chead['app_reachable']}</td></tr>
<tr><td>Deny-all tables (RLS on, no policy, no grant)</td><td>31</td><td>{c22['deny_all']}</td><td>{chead['deny_all']}</td></tr>
</tbody></table>"""

mut_rows = "".join(
    f"<tr><td>{m['id']}</td><td>{m['desc']}</td><td>{m['new']}</td><td>{m['old']}</td><td>{m['r1']}</td></tr>"
    for m in json.load(open(os.path.join(ART, "results", "exp3_mutation_table.json"))))

tbl_mut = f"""
<table><caption>Table 2. Mutation analysis. Each mutant is applied inside a transaction on the seeded head schema, the assertions are evaluated, and the transaction is rolled back. Only violations that are new relative to the unmutated baseline count. "Missed" means the suite would stay green.</caption>
<thead><tr><th>Mutant</th><th>Seeded mistake</th><th>A1-A5, new predicate</th><th>A1-A5, old predicate</th><th>R1 (scope-free release check)</th></tr></thead>
<tbody>{mut_rows}</tbody></table>"""

tbl_lock = f"""
<table><caption>Table 3. Time to complete an audited write (begin, insert, commit) while a third transaction holds the same tenant's chain open for 5 s after its own audit write. 30 runs per schema.</caption>
<thead><tr><th>Schema</th><th>Same tenant p50</th><th>Same tenant p95</th><th>Other tenant p50</th><th>Other tenant p95</th></tr></thead>
<tbody>
<tr><td>Before fix (migrations 001-010)</td><td>{ms(b_same['p50'])}</td><td>{ms(b_same['p95'])}</td><td>{ms(b_other['p50'])}</td><td>{ms(b_other['p95'])}</td></tr>
<tr><td>After fix (001-011)</td><td>{ms(a_same['p50'])}</td><td>{ms(a_same['p95'])}</td><td>{ms(a_other['p50'])}</td><td>{ms(a_other['p95'])}</td></tr>
<tr><td>Head, per-account chain</td><td>{ms(h_same['p50'])}</td><td>{ms(h_same['p95'])}</td><td>{ms(h_other['p50'])}</td><td>{ms(h_other['p95'])}</td></tr>
<tr><td>Head, with <code>SET CONSTRAINTS ALL IMMEDIATE</code></td><td>{ms(i_same['p50'])}</td><td>{ms(i_same['p95'])}</td><td>{ms(i_other['p50'])}</td><td>{ms(i_other['p95'])}</td></tr>
<tr><td>Public minimal reproduction, before (excerpt of 006)</td><td>{ms(MIN['before']['same_tenant_ms']['p50'])}</td><td>{ms(MIN['before']['same_tenant_ms']['p95'])}</td><td>{ms(MIN['before']['other_tenant_ms']['p50'])}</td><td>{ms(MIN['before']['other_tenant_ms']['p95'])}</td></tr>
<tr><td>Public minimal reproduction, after (excerpt of 006 + 011)</td><td>{ms(MIN['after']['same_tenant_ms']['p50'])}</td><td>{ms(MIN['after']['same_tenant_ms']['p95'])}</td><td>{ms(MIN['after']['other_tenant_ms']['p50'])}</td><td>{ms(MIN['after']['other_tenant_ms']['p95'])}</td></tr>
</tbody></table>"""


def chain_row(label, d):
    return (f"<tr><td>{label}</td><td>{d['trials']}</td><td>{d['chains_failing_verification']}</td>"
            f"<td>{d['trials_with_shared_prev_hash']}</td><td>{d['trials_with_writer_errors']}</td></tr>")


tbl_chain = f"""
<table><caption>Table 4. Chain integrity under concurrency. Each trial uses a fresh, empty tenant chain. "Interleave" replays the project's regression interleaving (T1 writes, T2 writes and blocks, T1 writes again, T1 commits, T2 commits). "Stress" runs 8 concurrent writers that each insert 5 rows with random 0-15 ms pauses. Verification recomputes every hash in the order that version's own verifier walks (<code>seq</code> before the fix, <code>chain_index</code> after). A "fork" is two rows that share a predecessor hash.</caption>
<thead><tr><th>Schema and workload</th><th>Trials</th><th>Chains failing verification</th><th>Trials with a fork</th><th>Trials with writer errors</th></tr></thead>
<tbody>
{chain_row('Before fix, interleave', A['before']['interleave'])}
{chain_row('Before fix, stress', A['before']['stress'])}
{chain_row('After fix, interleave', A['after']['interleave'])}
{chain_row('After fix, stress', A['after']['stress'])}
{chain_row('Head, interleave', A['head']['interleave'])}
{chain_row('Head, stress', A['head']['stress'])}
{chain_row('Minimal reproduction before, stress', MIN['chain']['before']['stress'])}
{chain_row('Minimal reproduction after, stress', MIN['chain']['after']['stress'])}
</tbody></table>"""

tam = {r["case"]: r for r in A["head"]["tamper"]}
yes = lambda b: "detected" if b else "<strong>not detected</strong>"
tbl_tamper = f"""
<table><caption>Table 5. Tamper evidence on the head schema. A superuser bypasses the append-only trigger (<code>session_replication_role = replica</code>), applies each change to a fresh 10-row chain and commits, and the verifier is then run. Results were identical on the after-fix schema.</caption>
<thead><tr><th>Tampering</th><th>Verifier result</th><th>Signal</th></tr></thead>
<tbody>
<tr><td>Owner role issues <code>UPDATE</code> (no trigger bypass)</td><td>refused, SQLSTATE WC403</td><td>append-only trigger</td></tr>
<tr><td>Edit one interior row's <code>detail</code></td><td>{yes(tam['edit_detail_of_interior_row']['detected'])}</td><td>{tam['edit_detail_of_interior_row']['after']['breaks']} hash mismatch</td></tr>
<tr><td>Delete one interior row</td><td>{yes(tam['delete_interior_row']['detected'])}</td><td>link break and <code>chain_index</code> gap</td></tr>
<tr><td>Delete the newest row (tail truncation)</td><td>{yes(tam['delete_last_row (tail truncation)']['detected'])}</td><td>the shorter chain is internally consistent</td></tr>
<tr><td>Edit one row and recompute every later hash</td><td>{yes(tam['edit_and_rehash_suffix (full suffix rewrite)']['detected'])}</td><td>the formula is public and unkeyed</td></tr>
</tbody></table>"""


def qrow(scale_label, d):
    return (f"<tr><td>{scale_label}</td><td>{ms(d['owner_filter_exec_ms']['p50'])}</td><td>{ms(d['rls_exec_ms']['p50'])}</td>"
            f"<td>{ms(d['owner_filter_exec_ms']['min'])}</td><td>{ms(d['rls_exec_ms']['min'])}</td><td>{x(d['ratio_median'])}&times;</td><td>{d['rows_equal_fraction']*100:.0f}%</td></tr>")


tbl_q = f"""
<table><caption>Table 6. Server execution time (EXPLAIN ANALYZE, timing off) of two tenant-scoped reads, RLS versus the explicit tenant filter RLS replaces. 16 personas (every active membership in the seed) with 5 repetitions at seed and x50 scale; 4 personas (one per account) with 1 repetition at x1000. "Ratio" is the median of per-measurement ratios. "Same rows" is the share of measurements in which both returned the same number of rows.</caption>
<thead><tr><th>Query, scale</th><th>Filter p50</th><th>RLS p50</th><th>Filter min</th><th>RLS min</th><th>Ratio</th><th>Same rows</th></tr></thead>
<tbody>
{qrow('Club events, seed (22 events)', qx0['club'])}
{qrow('Club events, x50 (1,122)', qx50['club'])}
{qrow('Club events, x1000 (22,022)', qx1k['club'])}
{qrow('Discovery list, seed', qx0['discovery'])}
{qrow('Discovery list, x50', qx50['discovery'])}
{qrow('Discovery list, x1000', qx1k['discovery'])}
</tbody></table>"""


def ci_txt(c_):
    return f"{c_['mean']:,.1f} &plusmn; {c_['ci95']:,.1f}"


tbl_pgb = f"""
<table><caption>Table 7. pgbench, mixed transaction (claims, two reads, save and unsave an event), 8 clients, 4 threads, 20 s, prepared statements, 5 interleaved runs per mode. Mean &plusmn; 95% Student-t half-width over runs. No failed transactions in any run.</caption>
<thead><tr><th>Scale, mode</th><th>Throughput (tps)</th><th>Mean latency (ms)</th></tr></thead>
<tbody>
<tr><td>Seed, explicit filter (owner)</td><td>{ci_txt(tps0_o)}</td><td>{ci_txt(lat0_o)}</td></tr>
<tr><td>Seed, RLS</td><td>{ci_txt(tps0_r)}</td><td>{ci_txt(lat0_r)}</td></tr>
<tr><td>Seed, RLS plus explicit filter</td><td>{ci_txt(tps0_p)}</td><td>{ci_txt(lat0_p)}</td></tr>
<tr><td>x50, explicit filter (owner)</td><td>{ci_txt(tps50_o)}</td><td>{ci_txt(lat50_o)}</td></tr>
<tr><td>x50, RLS</td><td>{ci_txt(tps50_r)}</td><td>{ci_txt(lat50_r)}</td></tr>
<tr><td>x50, RLS plus explicit filter</td><td>{ci_txt(tps50_p)}</td><td>{ci_txt(lat50_p)}</td></tr>
</tbody></table>"""

hn = {"shipped_definer_with_set": "SECURITY DEFINER + <code>SET search_path</code> (as shipped)",
      "definer_without_set": "SECURITY DEFINER, no SET",
      "invoker_inlinable": "SECURITY INVOKER, no SET (claims read inline)",
      "set_based_definer_once": "definer called once, returns the caller's club ids",
      "no_membership_test": "no membership test (floor)",
      "university_admin_per_row": "<code>app.is_university_admin()</code>, per row",
      "university_admin_hoisted": "<code>(select app.is_university_admin())</code>"}
tbl_h = "<table><caption>Table 8. Formulations of the policy helpers over one account's 14,014 events at x1000 scale, as superuser with persona 1's claims. Median and minimum of 5 interleaved runs. Every pair returns the same count. The invoker variant is included only to isolate call overhead: in production it would itself be subject to membership RLS, which is why the design uses definers.</caption><thead><tr><th>Formulation</th><th>p50</th><th>min</th><th>count</th></tr></thead><tbody>" + "".join(
    f"<tr><td>{hn[k]}</td><td>{ms(H[k]['exec_ms']['p50'])}</td><td>{ms(H[k]['exec_ms']['min'])}</td><td>{H[k]['count'][0]:,}</td></tr>" for k in hn if k in H) + "</tbody></table>"

rq = f"""
<p>Six questions are asked. <strong>RQ1</strong>: do the documented catalogue counts reproduce, and what are they today? <strong>RQ2</strong>: how large was, and is, the old tenant-scope predicate's blind spot? <strong>RQ3</strong>: which plausible migration mistakes do the catalogue assertions catch? <strong>RQ4</strong>: how far does the lock-scope fix restore availability? <strong>RQ5</strong>: does commit-time allocation prevent forks, and what tampering does the chain detect? <strong>RQ6</strong>: what does RLS cost compared with an explicit filter, and why?</p>
"""

sec("evaluation", "5 Evaluation", f"""
{rq}
<h3>5.1 Setup</h3>
<p>All experiments ran locally on {S.get('machine', 'an Apple M1 Pro laptop (8 cores, 16 GB) running macOS 27.0.1')}, against a dedicated PostgreSQL 17.10 (Homebrew) cluster created for this study: port 55499, <code>fsync</code> and <code>synchronous_commit</code> on, <code>shared_buffers</code> 512&nbsp;MB, everything else at defaults. No cloud resource was touched. Schemas were rebuilt from the private repository's migrations exactly as they stood at three commits: <code>85475d7</code> (migrations up to 010, the "before" schema, and up to 011, the "after" schema), <code>940b2ae</code> (up to 022) and <code>65a18ff</code> (head, 68 migration files, 1 November 2026). The head schema was seeded with the project's deterministic seed (32 users, 22 events, four accounts). Clients were Node.js 26 with <code>pg</code> 8.22.0 and pgbench 17.10. The audit-chain experiments were also run against a public minimal reproduction, consisting of verbatim excerpts of migrations 006 and 011 over a stub table, which needs none of the private source. The two agree (Tables 3 and 4).</p>
<p><strong>Load caveat.</strong> The laptop was shared with other work throughout. The one-minute load average sampled during the timed runs had a median of {LOAD['median']:.0f} (range {LOAD['min']:.0f}-{LOAD['max']:.0f}) on 8 cores. Lock-wait results are dominated by a deliberate 5-second hold and are insensitive to this. Execution-time results are inflated by it, so Section 5.6 reports minima next to medians and draws conclusions only from ratios of two orders of magnitude or more.</p>

<h3>5.2 RQ1 and RQ2: the counts, and the predicate's blind spot</h3>
<p>Table 1 shows the documented figures reproduced exactly when the schema is rebuilt at migration 022: 78 RLS tables, 126 policies across 47 tables, 48 tables reachable by the request role and 31 deny-all tables. The document counts 79 tables. The rebuilt public schema has 80, the extra one being the migration runner's own <code>schema_migrations</code> ledger, which also has no RLS and no grant. At head the schema is larger: {chead['rls_enabled']} of {chead['tables']} tables have RLS, with {chead['policies']} policies across {chead['tables_with_policies']} tables. No table is forced, and only <code>role</code>, a global read-only catalogue of role definitions, lacks RLS. The documented counts are therefore correct for the date they give and out of date today.</p>
{tbl_counts}
<p>Figure 2 applies the old and new tenant-scope predicates to each rebuilt schema. At migration 016, before account tenancy, they select the same {next(p for p in SCOPE if p['label'].startswith('016'))['old_scope']} tables. At migration 022 the old predicate misses {blind22['new_scope'] - blind22['old_scope']} ({blind22['blind_spot_tables']}). At head it misses {blindhead['new_scope'] - blindhead['old_scope']} ({blindhead['blind_spot_tables']}). In every case, every table in the blind spot does have RLS enabled. The gap was latent: no table escaped protection, only the assertions' attention. One claim in the project's documents needs correcting. The continuity-domain tables (handover, external tool and so on) carry <code>institution_id</code> as well as <code>account_id</code>, so contrary to the security document they were never in the blind spot.</p>
<figure><img src="{IMG}/scope-history.svg" alt="Grouped bars: at migration 016 both predicates select 57 tables; at 022 the old predicate selects 72 and the new 78; at head 85 versus 95."/><figcaption>Figure 2. Tables in the tenant scope under the old (<code>institution_id</code> only) and new (<code>institution_id</code> or <code>account_id</code>) predicates, by schema version.</figcaption></figure>
<p>The project's own test file also runs red at head. Assertion A5 fails on four policies added by migrations 066 and 067 (<code>legal_version</code>, <code>reserved_handle</code>, <code>person_handle</code>, <code>person_avatar</code>), none of which mention <code>current_account_id</code>. Two of these tables hold global reference data. The other two are person-scoped and reach the account boundary only through a helper. Whether each is safe is a design question, and the assertion did its job by forcing that question to be asked. The developer's uncommitted working tree, which this study did not use, answers it with an explicit allow-list that checks each exemption's exact shape.</p>

<h3>5.3 RQ3: mutation analysis of the assertions</h3>
<p>Table 2 lists eight mutants, each a mistake a migration author could plausibly make. Under the new predicate, the five assertions catch all six mutants that fall inside a tenant scope. Under the old predicate they miss M1 and M2, the two whose only tenant column is <code>account_id</code>: a table created without RLS but granted to the request role, and a table with RLS on, granted, and no policy. This is the migration-017 gap made concrete. Neither predicate catches M7 (a person-scoped table with only a <code>user_id</code>) or M8 (a child table whose tenancy is derivable only through a foreign key). Both are granted to the request role without RLS, which is the worst case, and both are caught only by the scope-free release rule R1. In turn, R1 misses M2, M4, M5 and M6, which need the scoped assertions. Neither family catches everything on its own, and together they catch all eight. The SQL port's verdicts match the project's own vitest block on the baseline and on M1, M2 and M7, the three that were also run through the real suite.</p>
{tbl_mut}

<h3>5.4 RQ4: lock scope and availability</h3>
<p>Each run opens a holder transaction, writes one audit row for tenant A, and keeps the transaction open for 5&nbsp;s. After 100&nbsp;ms, a writer for tenant A and a writer for tenant B each run begin, insert, commit, and are timed. Table 3 and Figure 1 show the result. Before the fix, the same-tenant writer waits out the holder: median {ms(b_same['p50'])}, against {ms(b_other['p50'])} for the other tenant. This agrees with the project's original single measurement of 4,780 and 4,792&nbsp;ms against 43&nbsp;ms. After the fix the two writers are indistinguishable (medians {ms(a_same['p50'])} and {ms(a_other['p50'])}), a {speedup:,.0f}-fold reduction in the median same-tenant wait. The head schema behaves the same way ({ms(h_same['p50'])} and {ms(h_other['p50'])}; its absolute times are higher because the head schema runs more triggers per insert and the machine was loaded). With <code>SET CONSTRAINTS ALL IMMEDIATE</code> the old behaviour comes straight back (median {ms(i_same['p50'])}), confirming that commit-time firing is what restores availability, as ADR-0009 predicts.</p>
<figure><img src="{IMG}/lock-scope.svg" alt="Strip plot: before the fix, same-tenant writes cluster near 4.9 seconds while other-tenant writes stay near 20 ms; after the fix and at head both cluster below 0.3 seconds; with constraints set immediate, same-tenant writes return to about 5 seconds."/><figcaption>Figure 1. Commit latency of a same-tenant (orange) and an other-tenant (grey) audited write while a third transaction holds the tenant's chain open for 5&nbsp;s. 30 runs per row, log scale.</figcaption></figure>
{tbl_lock}

<h3>5.5 RQ5: fork resistance and tamper evidence</h3>
<p>Table 4 shows that before the fix, every one of {pre_inter['trials']} replays of the regression interleaving produced a chain that failed verification in <code>seq</code> order, with a median of {pre_inter['breaks_per_trial']['p50']:.0f} broken links per 3-row chain. In those replays the hash links still formed a single path; it simply did not match <code>seq</code> order. The multi-row stress workload is worse. All {pre_stress['trials']} trials produced <em>real</em> forks, in which two rows share a predecessor (median {pre_stress['breaks_per_trial']['p50']:.0f} broken links per 40-row chain), so no ordering of the rows can make them verify. After the fix, {post_fail} of {post_trials} trials across the after-fix and head schemas failed verification, and none had a fork or a writer error. The <code>unique (account_id, chain_index)</code> backstop never fired, which is consistent with the lock serialising every allocation. A test that runs one transaction at a time cannot observe any of this. The original suite was serial and green while the property was false.</p>
{tbl_chain}
<p>Table 5 records what the chain detects. An ordinary owner-role update is refused outright by the append-only trigger. With that trigger bypassed by a superuser, an edited interior row and a deleted interior row are both detected. Two attacks are not: deleting the newest row, and editing a row and recomputing every later hash. Both follow directly from an unkeyed chain held in the same database it protects. The chain is tamper-evident only against an attacker who does not recompute. Closing that gap needs what Schneier and Kelsey, and Crosby and Wallach, prescribe: a commitment held outside the attacker's reach, such as a periodically published chain head, a keyed MAC with an externally held key, or a Merkle-tree log with external auditors{c('schneier1999', 'crosby2009', 'rfc6962')}. The project's documents list "externally anchored checkpoints" as planned, not built, and this measurement shows why it matters.</p>

<h3>5.6 RQ6: what RLS costs, and why</h3>
<p>Table 6 compares two tenant-scoped reads that the product actually issues. One lists a club's latest 20 events, the other lists the latest 50 events open for discovery. Each runs under RLS as the request role with a persona's claims, and again as the owner with the explicit filter <code>account_id = $1</code> that RLS replaces. Both modes returned the same number of rows in every measurement. On the 22-event seed the uncontended difference is small: the minima are {ms(qx0['club']['rls_exec_ms']['min'])} under RLS and {ms(qx0['club']['owner_filter_exec_ms']['min'])} with the filter. The median ratio of {x(qx0['club']['ratio_median'])}&times; at this scale is mostly load noise acting on very short queries, so we do not rely on it. The cost grows with the number of rows the policy has to inspect. At x50 scale even the minima differ by {x(qx50['club']['rls_exec_ms']['min'] / qx50['club']['owner_filter_exec_ms']['min'])}&times; for the club read. At x1000 scale the discovery read takes a median of {ms(qx1k['discovery']['rls_exec_ms']['p50'])} under RLS, against {ms(qx1k['discovery']['owner_filter_exec_ms']['p50'])} with the explicit filter. Even the minima, the least-contended observations, differ by {x(qx1k['discovery']['rls_exec_ms']['min'] / max(qx1k['discovery']['owner_filter_exec_ms']['min'], 1e-9))}&times;. pgbench tells the same story at the level of whole transactions (Table 7). At seed scale the mixed transaction completes {tps0_o['mean']:,.0f} tps with the explicit filter and {tps0_r['mean']:,.0f} tps under RLS. At x50 scale the figures are {tps50_o['mean']:,.0f} and {tps50_r['mean']:,.1f} tps. Adding the explicit filter on top of RLS changes little ({tps0_p['mean']:,.0f} and {tps50_p['mean']:,.1f} tps), because RLS already restricts to the account through an indexed condition, and the cost lies in the remaining policy terms.</p>
<figure><img src="{IMG}/rls-overhead.svg" alt="Horizontal log-scale bars: explicit-filter reads stay under a few milliseconds at every scale, while RLS reads grow from milliseconds at seed scale to seconds at x50 and tens of seconds at x1000."/><figcaption>Figure 3. Median server execution time of two reads under RLS (orange) and an explicit tenant filter (grey), at three data scales. Log scale.</figcaption></figure>
{tbl_q}
{tbl_pgb}
<p>The query plans locate the cost. In <code>event_select</code>, the conditions after the account test are an OR of calls such as <code>app.is_committee_of(club_id)</code>, <code>app.is_member_of(club_id)</code>, <code>app.is_reviewer_for_club(club_id)</code>, <code>app.is_university_admin()</code> and <code>app.club_is_suspended(club_id)</code>. The planner evaluates them per row as an opaque filter. Every helper is a <code>SECURITY DEFINER</code> SQL function with a <code>SET search_path</code> clause, and some call further definers (<code>is_university_admin</code> calls <code>has_institution_role</code>, which calls <code>active_assignment</code>). Such functions cannot be inlined{c('pginline')}, and each call also saves and restores a configuration setting. Our hypothesis was that this call overhead was the main cost. Table 8 and Figure 4 test that on one account's 14,014 events, and the data mostly refutes it. Dropping the <code>SET</code> clause (median {ms(hs['definer_without_set'])}) or making it a plain invoker function with no <code>SET</code> that reads the claims inline ({ms(hs['invoker_inlinable'])}) gives little improvement on the shipped {ms(hs['shipped_definer_with_set'])}, because the membership sub-query still runs once per row. What works is changing <em>how often</em> the helper runs. A definer called once per statement that returns the caller's club ids, tested with <code>club_id = any(...)</code>, takes the median to {ms(hs['set_based_definer_once'])} (minimum {ms(hmin['shipped_definer_with_set'])} down to {ms(hmin['set_based_definer_once'])}), while keeping the definer property the design needs to avoid RLS recursion. A row-independent helper inside an OR is evaluated per row as well. <code>app.is_university_admin()</code> costs {ms(hs['university_admin_per_row'])} over these rows, and wrapping it in a scalar sub-select makes it an InitPlan evaluated once, at {ms(hs['university_admin_hoisted'])}. This is the vendor guidance cited in Section 2.2{c('supabase')}. On its own, outside an OR, PostgreSQL already treats such a call as a one-time filter; we hit this in a first, discarded version of the microbenchmark. These rewrites are measured here as microbenchmarks only. They have not been applied to WayClub's policies, and a policy rewrite would need its own equivalence tests before it shipped.</p>
<figure><img src="{IMG}/helper-overhead.svg" alt="Horizontal log-scale bars comparing the shipped definer helper with variants without SET, an invoker without SET, a set-based once-per-statement definer and a hoisted role check; the set-based and hoisted forms are one to three orders of magnitude cheaper."/><figcaption>Figure 4. The same membership test, and the same row-independent role check, in different helper formulations, over 14,014 rows.</figcaption></figure>
{tbl_h}
""")

# ---------------------------------------------------------------- 6
sec("lessons", "6 Lessons for practitioners", f"""
<ol>
<li><strong>Assert security properties against the catalogue, not in prose.</strong> Three of the project's documents agreed with one another for days about <code>FORCE</code> and a coverage test, and none of them agreed with the database. A query over <code>pg_class</code> and <code>pg_policy</code> cannot drift that way.</li>
<li><strong>A scope predicate encodes a tenancy model, and it goes stale when the model changes.</strong> The <code>institution_id</code> predicate was correct until the tenant root moved, then quietly covered fewer tables after every migration. At head it is stale again, now for person-scoped tables. Pair scoped assertions with at least one scope-free invariant, such as "the request role holds no privilege on any relation without RLS". In our mutation study, neither family caught every mutant on its own, and together they caught all eight.</li>
<li><strong>Keep FORCE off when policies call owner-owned definers, and bind the owner with constraints instead.</strong> Forcing RLS made a membership helper lie. Rules that must hold for every role belong in foreign keys, unique indexes and triggers.</li>
<li><strong>A definer function is an authorisation decision.</strong> It runs with the owner's privileges, so it has to validate its caller's claims itself. Grant the unvalidated variants to no role at all.</li>
<li><strong>Allocate a sequence inside the lock that orders it, and hold the lock for as short a time as possible.</strong> Identity columns are allocated before <code>BEFORE</code> triggers run. A deferred constraint trigger moves both the allocation and the lock into <code>COMMIT</code>. A unique index on the position turns any remaining race into a loud failure instead of a silent fork.</li>
<li><strong>Test concurrency properties with genuinely concurrent transactions.</strong> A serial suite was green for the whole life of the forking chain. Two real connections, a deliberate interleaving and a many-writer stress loop exposed it in every trial.</li>
<li><strong>An in-database hash chain does not detect truncation or a full rewrite.</strong> Without an external anchor it detects only an attacker who does not recompute. Say so in the threat model.</li>
<li><strong>Measure RLS at realistic row counts before launch.</strong> Policies that call helper functions once per row were cheap on a 22-event seed and dominated at 22,000 events. The fix is not "avoid <code>SECURITY DEFINER</code>" (removing <code>SET</code> or the definer property barely helped) but "call it once": hoist row-independent checks into <code>(select ...)</code>, and turn per-row membership calls into a single definer call that returns the caller's ids.</li>
</ol>
""")

# ---------------------------------------------------------------- 7
sec("threats-to-validity", "7 Threats to validity", f"""
<p><strong>Internal validity.</strong> The timed runs shared a heavily loaded laptop (median one-minute load average {LOAD['median']:.0f} on 8 cores). Lock-wait measurements are dominated by a fixed 5&nbsp;s hold, and their conclusions survive the noise: the before-fix same-tenant minimum ({ms(b_same['min'])}) exceeds the after-fix maximum ({ms(L['after']['same_tenant_ms']['max'])}). The RLS execution times are inflated and noisy, with large spread across repetitions of the same query, so the report relies only on ratios of two or more orders of magnitude and gives minima alongside medians. The x1000 query timings have one repetition per persona. The comparison baseline for RLS is the explicit tenant filter, not a full reimplementation of the policies' visibility rules. Both modes returned identical row counts in every measurement, but on other data they could differ. The tamper experiment uses a superuser trigger bypass to stand in for an owner-level attacker. The SQL port of the assertions was checked against the project's own vitest block on four schema states, not all nine.</p>
<p><strong>Construct validity.</strong> "Fork" means two rows that share a predecessor hash, and "verification failure" means any recomputed hash or link that does not match in the version's own documented order. The pre-fix interleaving produces failures without forks, and the stress workload produces both. The two are reported separately.</p>
<p><strong>External validity.</strong> One system, one developer, one PostgreSQL version and synthetic data. The x1000 scale replicates the seed's 22 events a thousand times. That preserves the seed's mix of status and visibility but not a real tenant's distribution. The mutants are hand-chosen and stand in for plausible mistakes, not a sample of real ones.</p>
""")

# ---------------------------------------------------------------- 8
sec("limitations", "8 Limitations", """
<ul>
<li><strong>No real tenants and no real load.</strong> WayClub is in development, with no live site, and its cloud database was stopped throughout. Nothing here says how the system behaves in production, and the RLS cost has not been measured at the scale real tenants would reach.</li>
<li><strong>A known gap that is enforced but not proven.</strong> The project's security document records that the negative side of the two-party handover attestation (an ordinary member being refused an access verification) is enforced only by a policy and is not asserted by any test. This study did not close that gap, and it remains enforced but unproven.</li>
<li><strong>The head suite is red.</strong> At the studied commit, assertion A5 fails on four new policies and an unrelated schema-guard test fails on a new column. Both fixes exist only in uncommitted work, which this study deliberately did not use.</li>
<li><strong>The proposed RLS rewrites are microbenchmarks.</strong> They have not been applied to the product's policies or tested for equivalence there.</li>
<li><strong>Single developer, AI-assisted.</strong> One person designed the system, and much of the implementation and of this report was produced with AI coding assistants under his direction. No independent party has reviewed either. The adversarial review cited in Section 4.2 was itself AI-assisted.</li>
<li><strong>The full source is private.</strong> Only the audit-chain experiments can be reproduced from the public artifact alone. The others need the private repository.</li>
</ul>
""")

# ---------------------------------------------------------------- 9
sec("conclusion", "9 Conclusion", f"""
<p>Shared-schema multi-tenancy on PostgreSQL RLS is only as strong as the assurance around it, and that assurance has to be a query, not a sentence. Catalogue assertions caught every mutant inside their scope. Their scope predicate is itself a model of tenancy that drifts, so a scope-free invariant has to run beside them. A hash-chained audit log needs its position allocated inside the same lock that orders it, held for the commit and no longer. Done that way, {post_trials} concurrent trials produced no forks, and a same-tenant writer behind a 5-second holder waited a median of {ms(a_same['p50'])} instead of {ms(b_same['p50'])}. The same study found the cost the design was carrying unnoticed: definer-helper policies that are cheap on a seed and expensive at realistic row counts. The fixes are known, and none of them has yet been applied to the product.</p>
""")

sec("artifact-availability", "Artifact availability", f"""
<p>The artifact is at <a href="/research/{SLUG}/artifact/">/research/{SLUG}/artifact/</a>. It contains the experiment drivers (Node.js and shell), the SQL ports of the catalogue assertions and of the release check, the eight mutation files, the benchmark transactions, every raw result file, the analysis and figure scripts, and a README with exact commands, the PostgreSQL version and the run date (1-2 November 2026). WayClub's source is private. The artifact includes only verbatim excerpts of the two audit-chain migrations, enough to reproduce Experiments 1 and 4 on an empty database, together with the catalogue and benchmark SQL. Experiments 2 and 3 need the private schema and are provided as scripts and raw outputs.</p>
""")

# ---------------------------------------------------------------- citations
order = []
for s in sections:
    for k in re.findall(r"\[\[([a-z0-9]+)\]\]", s["html"]):
        if k not in order:
            order.append(k)
for s in sections:
    s["html"] = re.sub(r"\[\[([a-z0-9]+)\]\]", lambda m: f'<a class="cite" href="#ref-{m.group(1)}">[{order.index(m.group(1)) + 1}]</a>', s["html"])
    s["html"] = re.sub(r"\n\s*", "\n", s["html"]).strip()
unused = [k for k in REFS if k not in order]
assert not unused, unused
references = [{"id": k, "text": REFS[k][0], "url": REFS[k][1]} for k in order]

words = len(re.sub(r"<[^>]+>", " ", " ".join(s["html"] for s in sections)).split())

abstract = (
    f"Moving the audit chain's lock from the first audited write to commit time cut the median wait of a same-tenant write behind a slow transaction from {plain_ms(b_same['p50'])} to {plain_ms(a_same['p50'])}, and in {post_trials} concurrent trials the chain never forked; before the fix it failed verification in all {pre_inter['trials'] + pre_stress['trials']}. "
    "This technical report studies a pre-launch, multi-tenant PostgreSQL SaaS (WayClub) that isolates tenants with row-level security (RLS) in one shared schema. "
    "RLS is enabled but deliberately not forced, because forcing it made an owner-owned SECURITY DEFINER policy helper deny a genuine member; we re-measured that. "
    "Instead of a hand-kept table list, five tests assert coverage from PostgreSQL's catalogue. "
    f"Rebuilding the schema from its migration history reproduces the documented 78 RLS tables and 126 policies exactly at migration 022 and finds {chead['rls_enabled']} and {chead['policies']} today. "
    f"The tests' original institution-only scope predicate would have ignored {blindhead['new_scope'] - blindhead['old_scope']} tenant tables today. "
    "In a mutation study the widened assertions catch all six in-scope mistakes, and a scope-free release check catches the two they cannot see. "
    f"The study also found a cost nobody had measured: because policies call definer helpers once per row, tenant reads ran {x(qx1k['discovery']['ratio_median'])} to {x(qx1k['club']['ratio_median'])} times slower than an explicit filter at 22,000 events. Set-based or hoisted helpers remove most of that cost. "
    "All experiments ran locally and are reproducible from the artifact."
)

paper = {
    "slug": SLUG,
    "title": "Catalogue-Driven Row-Level Security Assurance and a Fork-Free, Tamper-Evident Audit Chain for a Multi-Tenant PostgreSQL SaaS",
    "short": "Proving Tenant Isolation",
    "authors": [{"name": "Yousof Selim", "affiliation": "Sunway University (Lancaster University dual degree), Malaysia", "email": "yousofselim2@gmail.com"}],
    "date": "2026-11-02",
    "version": "v1.0",
    "status": "Technical report",
    "area": "Software engineering · Database security · Multi-tenancy",
    "keywords": ["PostgreSQL", "row-level security", "multi-tenancy", "SECURITY DEFINER", "catalogue assertions", "mutation analysis",
                 "tamper-evident logging", "hash chain", "concurrency", "advisory locks", "deferred constraint triggers"],
    "projects": ["wayclub"],
    "pitch": f"I found why one slow request froze a customer's audit-log writes, cut that wait {speedup:,.0f}-fold, and showed my security checks catch forgotten protections.",
    "keyResult": {"value": f"{speedup:,.0f}×", "label": "shorter wait for a customer's writes behind slow requests"},
    "heroFigure": {"src": f"{IMG}/lock-scope.svg", "alt": "Strip plot: before the fix, writes in the same tenant as a slow transaction wait about 4.9 seconds while other tenants wait about 20 ms; after the fix both wait well under a second."},
    "findings": [
        f"Allocating the audit-chain position at commit cut the median same-tenant wait behind a 5-second transaction from {plain_ms(b_same['p50'])} to {plain_ms(a_same['p50'])}, a {speedup:,.0f}-fold reduction.",
        f"Before the fix all {pre_inter['trials'] + pre_stress['trials']} concurrent trials broke the audit chain, including {pre_stress['trials_with_shared_prev_hash']} with true forks; after it, 0 of {post_trials} did.",
        f"The catalogue assertions' original scope predicate would have ignored {blindhead['new_scope'] - blindhead['old_scope']} tenant tables at today's head, and widened assertions plus a scope-free check caught 8 of 8 seeded mistakes.",
        f"Row-level security policies that call helper functions once per row made tenant reads {x(qx1k['discovery']['ratio_median'])} to {x(qx1k['club']['ratio_median'])} times slower than an explicit filter at 22,000 events; calling the helper once per query cut one test from {plain_ms(hs['shipped_definer_with_set'])} to {plain_ms(hs['set_based_definer_once'])}.",
    ],
    "abstract": abstract,
    "abstractParts": [
        {"label": "The problem", "text": "When every customer shares one database, a single forgotten protection can expose one customer's data to another, and an audit log can be quietly broken by requests that run at the same time. WayClub's own documents had once claimed protections the database did not have."},
        {"label": "What I did", "text": "I rebuilt WayClub's database at several points in its history and ran four local experiments: seeded security mistakes against its catalogue-reading tests, timed audited writes before and after a lock fix, hammered the audit chain with concurrent writers, and benchmarked row-level security against plain filters."},
        {"label": "What I found", "text": f"The fix cut a same-customer write's median wait behind a slow request from {plain_ms(b_same['p50'])} to {plain_ms(a_same['p50'])}, and the chain went from failing all {pre_inter['trials'] + pre_stress['trials']} concurrent trials to failing 0 of {post_trials}. The tests' original scope would have ignored {blindhead['new_scope'] - blindhead['old_scope']} tenant tables today. Row-level security made reads {x(qx1k['discovery']['ratio_median'])} to {x(qx1k['club']['ratio_median'])} times slower at 22,000 events."},
        {"label": "Why it matters", "text": "Security claims about a database should be queries that can fail, not sentences, and concurrency properties need concurrent tests. The same measurements found a performance cost that would have arrived with the first large customer, while it is still cheap to fix."},
    ],
    "contributions": [
        "A mutation analysis of catalogue-driven RLS assertions under old and new tenant-scope predicates, plus a scope-free release check, over eight seeded migration mistakes.",
        "A rebuilt-from-history measurement of the coverage gap: documented counts reproduced exactly at migration 022, and the old predicate's blind spot traced from 0 to 6 to 10 tables.",
        f"Evidence that commit-time chain allocation removes audit-chain forks (0 failures in {post_trials} concurrent trials) and restores per-tenant availability ({speedup:,.0f}-fold lower median same-tenant wait).",
        f"A measured, explained cost of definer-helper RLS policies (median {x(qx1k['discovery']['ratio_median'])} to {x(qx1k['club']['ratio_median'])} times an explicit filter at 22,000 events), with set-based and hoisted formulations that remove most of it.",
    ],
    "limitationsLine": "Pre-launch system with no real tenants or load, one developer, a loaded laptop, synthetic data, and a private codebase; one access-control gap remains enforced but unproven.",
    "aiStatement": "Yousof Selim designed the system and its security model and directed the work; much of the implementation and parts of this report were produced with AI coding assistants (Claude, Codex) under his direction and review. The experiments are reproducible from the artifact. This is a technical report and has not been peer reviewed.",
    "artifact": {"label": "Scripts, SQL, raw results and README (WayClub source is private)", "href": f"/research/{SLUG}/artifact/"},
    "sections": sections,
    "references": references,
    "evidence": json.load(open(os.path.join(ART, "results", "evidence.json"))),
}
json.dump(paper, open(OUT, "w"), indent=2, ensure_ascii=False)
print("words in sections:", words, "| abstract words:", len(abstract.split()), "| refs:", len(references))
