Skip to content
Melange v0.11.0

Melange v0.11.0

November 9, 2026·pthm
pthm

Melange v0.11.0 lets you choose which part of the generated SQL API a database gets, and adds per-relation accessible_* functions for filtering your own queries by access. Paged lists now do the work of one page, generated functions are planned once per session, and depth limits and cycles follow OpenFGA. It also fixes two ways a check or a migration could grant more than the model says.

Security fixes, run melange migrate and upgrade the runtime. check_permission could allow a userset subject such as group:eng#member on a relation whose grant is constrained by an intersection or exclusion. Separately, an unforced melange migrate that removed a relation dropped its functions with CASCADE, which also dropped any RLS policy that called them. Both are fixed in the generated SQL and the migrator. The Go runtime also fixes contextual tuples being ignored on a reused session.

Breaking: some schemas with cycles through and or but not are now rejected, list functions raise M2002 past the depth limit instead of returning a truncated result, and migrate refuses to drop functions that other objects depend on. See Upgrading.

Upgrading

  1. Install v0.11.0 and run melange validate. A relation that resolves back to itself on the same object through an intersection or exclusion operand is now rejected; see Depth limits and cycles.
  2. Run melange migrate, or regenerate with melange generate migration. Migrate adds an exposure column to melange_migrations in place. If it refuses with a list of dependent objects, read Migrations no longer drop dependents.
  3. Upgrade the runtimes: go get github.com/pthm/melange/melange@v0.11.0 and npm install @pthm/melange@0.11.0. The Go runtime carries the contextual tuples fix and both map the new M2003 error.
  4. If your code calls list_accessible_objects or list_accessible_subjects on deep hierarchies, handle M2002. A list that used to return a truncated result past 24 or 25 levels now raises, as OpenFGA’s ListObjects and ListUsers do.

With no sql: section in melange.yaml, every relation and function family is still installed, so the public functions answer as before.

If you call check_<type>_<relation> functions directly from views or RLS policies: every check function is now declared COST 1000, which can change the plans of those queries.

Security fixes

Userset subjects on constrained grants

When the subject was itself a userset, check_permission allowed it whenever a userset tuple on the object matched, without applying the rest of the grant. For

define viewer: [user, group#member] and member from owner

group:eng#member was a viewer with no member on the owner. The same happened through an implied relation whose own grant sat in an intersection or under an exclusion. Wildcard tuples (user:*) also matched userset subjects in some probes, which OpenFGA never does.

Userset subjects now match only grants that stand alone, and an intersection’s direct part has to hold its other parts too. OpenFGA v1.22.1 agrees on every case. Checks for plain subjects (user:alice) were not affected.

Migrations no longer drop dependents

When a relation was removed, an unforced melange migrate dropped its functions with CASCADE. Views, materialized views, RLS policies, rules and BEGIN ATOMIC functions that called them were dropped too, and dropping an RLS policy widens access to its table.

Migrate now refuses before applying anything and lists the dependents:

Error: migration refused (rerun with --force to drop the dependents): migration would drop functions that other database objects depend on; nothing was applied (remove or change the dependents, or force the migration to drop them with CASCADE):
  policy docs_read on table docs depends on accessible_document_viewer

Unforced drops have no CASCADE, so a dependent created after the check makes PostgreSQL refuse the drop (2BP01) instead of losing it. UP files from melange generate migration drop without CASCADE as well; DOWN files still cascade. --force keeps the old behaviour. See Dependent objects.

Contextual tuples on a reused session

Contextual tuples shadow melange_tuples with a temporary view. PL/pgSQL caches each statement’s parse tree with the table name already resolved, so on a pooled session that had already made its temp schema, a contextual request that followed a plain one read the base view: CheckWithContextualTuples denied and ListObjectsWithContextualTuples left out what the tuples granted. The Checker now runs DISCARD PLANS after creating the view.

Cleanup now drops the view under its own timeout rather than the request’s context, and closes the connection if the drop fails. With a driver that rejects a cancelled request but keeps the session open, the view used to stay on the pooled session and grant its tuples to later requests. pgx and lib/pq close such sessions and were not affected.

Choosing the SQL API

A model compiles to a check, two list, an expand, an explain and an accessible function per relation, and most relations exist only to build others. The new sql: section names the relations an application queries and the function families it uses:

sql:
  relations:
    include: ["can_*"]
    exclude: ["document#can_admin"]
  functions:
    list_subjects: false
    expand: false
    explain: false

Migrate installs the exposed relations and every relation they resolve through, so an exposed relation always answers completely. On one production model, exposing check and lists for the can_* relations installed 42% of the per-relation functions.

Behaviour
Exposed relationAnswered by the public functions as before
Relation the model defines but does not exposePublic functions raise M2003. The Go runtime maps it to a *ValidationError, TypeScript to ValidationError
Family turned offIts public function raises M2003 for every relation
Type or relation the model does not define0, an empty set or the sentinel, as before

Patterns are relation or type#relation with * and ? globs. The section is declarative: migrate records the exposure in melange_migrations, removing the section widens back to the default on the next migrate, and the flags (--include-relation, --exclude-relation, --no-<family>) apply to one run. melange status and melange doctor report drift between the local and recorded exposure.

The exposure shapes the API; it is not an access-control boundary. The per-relation functions of an installed relation stay callable. See Exposure and SQL API Settings.

Filtering your own queries

WHERE id IN (SELECT object_id FROM list_accessible_objects(...)) computes the whole access set inside opaque PL/pgSQL and plans the rest of the query against a guess. Three new functions are built for this instead.

accessible_{type}_{relation}

A LANGUAGE sql function per listable relation, which PostgreSQL inlines into your query, so your filters, sort and limit are planned with the access set:

SELECT d.*
FROM documents d
WHERE d.id::text IN (SELECT * FROM accessible_document_viewer('user', '123'))
  AND d.archived = false
ORDER BY d.created_at DESC
LIMIT 50;

On the kitchen-sink model at 1M tuples, a page of 50 open documents by name with can_view went from 158 ms to 42 ms (17K visible) and from 608 ms to 270 ms (113K visible) against the list_accessible_objects form.

accessible_{type}_{relation}_by_{link}

A relation whose every grant comes through one link to one parent type (can_read: reader from org) also gets a parent-key form. It returns the parent IDs, so you filter on the link column and an index on (organization_id, created_at) serves the page:

SELECT s.*
FROM subjects s
WHERE s.organization_id IN (SELECT * FROM accessible_subject_can_read_by_org('user', '123'))
ORDER BY s.created_at DESC
LIMIT 50;

For a viewer of an organization with a million subjects, a count took about 4 s with accessible_subject_can_read and 70 ms with the parent-key form. A LATERAL page per parent took 0.9 ms. It is exact when each object has a single link held in that column.

accessible_objects

accessible_objects(subject_type, subject_id, relation, object_type) takes the relation by name and plans in constant time. It matches the per-relation function up to a few thousand objects and is slower beyond that (1.3 s against 0.34 s for a page of a 1M-subject organization).

Long names are truncated with a hash, so don’t spell them by hand. Generated clients carry the exact names (see below), and melange functions lists them. See accessible_{type}_{relation}.

Seeing the SQL API

melange functions lists the API a model compiles to: every function per relation with its exact name, kind, signature, an example call, and why a family is missing (not exposed, family off, on demand, not eligible). melange functions show document#viewer shows one relation. With --db or --env it reads the recorded model and exposure and compares them with pg_proc: missing functions, other signatures, stale overloads, bodies edited in the database, orphans and codegen skew. Output is text or --format json.

Studio gets a SQL API page with the same catalog. Each relation leads with a copyable call for each thing an application does with it (check, list, filter a query, filter by a parent column, expand, explain), followed by its functions. Studio’s pages also get larger type, more spacing, and neutral badges for expected states such as “not exposed” and “on demand”.

Doctor now reports as orphans only the functions the last migration recorded, so a function of your own called check_invoice is no longer flagged.

Generated clients

melange generate client now emits the exact name of every accessible_* function migrate installs for an exposed relation:

  • Go: SQLAccessibleDocumentViewer, SQLAccessibleSubjectCanReadByOrg and an SQLAccessibleFunction(type, relation) lookup.
  • TypeScript: AccessibleFunctions[type][relation] and AccessibleByParentFunctions[type][relation][link] in sql.ts.

The relation constants follow sql.relations, narrowed or replaced by generate.client.relations. --filter still works and is deprecated.

melange generate client --check regenerates in memory and compares with the files on disk. With --db or --env it also checks every name the client emits against the database: not installed (and why), another signature, a name owned by another relation, or a relation the config exposes and the database doesn’t. When generate.client.runtime is set, doctor runs the same checks as a Generated Client category; they warn by default and fail with --strict-client.

Explain and expand on demand

With explain: false or expand: false, those functions are not installed. melange explain, melange expand and Studio still work: they compile the family from the recorded model, create it in pg_temp for one rolled-back transaction, and run the call. This needs a writable primary, the TEMP privilege, a recorded model and the same melange version. On the kitchen-sink model one Explain took 28 ms on demand against 0.25 ms installed.

The Go and TypeScript runtimes do not compile on demand; Checker.Explain and Checker.Expand report M2003. See Explain and Expand on Demand.

Migrations

  • Fix hints that work. Doctor used to suggest melange migrate where migrate would do nothing: a function dropped by hand under an unchanged schema, an undecodable stored model, or a database migrated by a newer melange. It now asks the migrator what an unforced migrate would do (Migrator.Plan) and suggests plain migrate, --force with the reason and what it would drop, or the newer melange.
  • Unrecorded migrations keep their exposure. MigrateWithTypes writes no migration record, so an exposure change through it left the previous functions installed and nothing recorded. It now returns *migrator.UnrecordedExposureChangeError; change the exposure through Migrate, MigrateWithOptions or the CLI.
  • Dry runs list the orphan drops and their dependents, and the script re-checks dependents when applied.

Depth limits and cycles

melange now resolves the same depth OpenFGA does and fails the same way past it:

Beforev0.11.0
CheckRaised at 25 hopsResolves 25 hops, raises M2002 at 26, counting steps as OpenFGA does
Recursive list_objects and list_subjectsTruncated silently past 25 levelsRaise M2002 (24 levels list, 25 fail)
Nested groups (member: [user, group#member]) in listsTruncated past 25 levels; cycles walked until the capEach group expanded once; raise M2002 at 25
A userset or cross-type step at the end of a parent chainNot counted in list depthCounted, so 23 hops plus that step list and 24 fail
Userset-filter list_subjects on a parent cycleRecursed until stack depth limit exceededWalks ancestors once

Schema validation changes in both directions:

  • Accepted: parent lookups that cycle across types while changing relation (folder.a: b from parent, project.b: a from parent). OpenFGA accepts these.
  • Rejected: a relation that resolves back to itself on the same object through an intersection or exclusion operand (a: [user] or (b and c) with b: [user] or a). OpenFGA rejects these with 2056; melange accepted them, and with the new step counting their checks would recurse without bound.

One known difference remains: for x but not y from parent, OpenFGA can resolve deeper than 26 when the subject has no y tuples; melange raises from 27.

Other fixes

  • Excluded intersections of parent lookups (r: v but not (a from owner and b from owner)) now filter on the link’s allowed types. Under a restricted exposure the missing types left the parent’s check functions uninstalled, and the check allowed what OpenFGA denies. The exposure fuzzer found it before release.
  • Intersections with a parent lookup ([user] and viewer from parent) no longer reference viewer on the object’s own type, which installed extra functions under a restricted exposure and made some list compositions fall back to per-row checks.
  • Nested group walks match the userset relation exactly. They used LIKE, where an underscore in a relation name matched any character.

Performance

Paged lists

A paged list_objects call used to build the whole list, deduplicate and sort it, then cut the page. Each arm of the query now keeps only the IDs after the cursor, up to the page size, and the arms merge in ID order. Lists through a parent read at most a page per parent.

On the SaaS benchmark at 1M tuples, the first page of 50 contacts for support staff across 70 organizations went from 162 ms to 3.5 ms. One shape is slower: a viewer who reaches many parents with few objects each. On the repository fixture at 1M, pages of pull requests went from 0.4 to 2.6 ms (10) and from 2.3 to 13.2 ms (500) locally.

Planning

  • Generated functions plan once per session. Every per-relation function sets plan_cache_mode = force_generic_plan and jit = off. PL/pgSQL used to plan each statement for its first five calls, which on a view over many tables cost about 3 ms per check. On production-shaped data, first calls on a new session dropped to 0.04x (lists) and 0.05x (checks) of the previous build. JIT had turned a 0.02 ms list into 15 ms.
  • Check functions call each other directly instead of through the check_permission_internal dispatcher when the target is known when the SQL is generated. Kitchen-sink bodies go from 249 dispatcher calls to 3.
  • Userset arms fold at plan time for plain subjects, so cached plans leave them out.

Lists

  • Intersection list_subjects finds candidates from direct tuples and parent lookups instead of checking every subject in the tuples table. On a 5.1M-tuple application model, all 175 measured calls finished under 100 ms, where 30 had hit a 20 s timeout.
  • Recursive lists walk breadth first and expand each object once at its shortest distance. Overlapping grants and cycles are 2 to 7x faster on synthetic hierarchies.
  • Subject-keyed lookups use subject_id = ANY(ARRAY(...)), which reaches each view branch’s subject index. Doctor’s new subject_lookup_indexes check reports branches without one.
  • Complex userset grants are looked up by member userset ID: kitchen-sink can_edit 35.9 ms to 1.8 ms locally.

Cloud SQL

The Performance reference now uses a November run of 0.11.0 on Cloud SQL for PostgreSQL 18.6. The same day, v0.10.3 ran on the same instance shape, client and PostgreSQL version, with 0.11.0’s benchmark harness, so both runs share fixtures and indexes. Medians of six runs, ms:

Benchmarkv0.10.3v0.11.0
SaaS ListSubjects 1M, readers of the largest org’s contacts206,74325.3
SaaS ListObjects 1M, first page of 50 contacts, partner user3,4576.1
SaaS ListObjects 1M, first page of 50 contacts, support staff2,93014.9
SaaS ListObjects 1M, first page of 50 contacts, staff, first call on a new session3,011139.6
SaaS ListObjects 1M, orgs staff can read55.42.2
ListObjects 1M, pull requests, page of 10117.56.9
ListObjects 1M, pull requests, page of 500113.422.1
ListObjects 1M, repositories, all pages14.52.3
Kitchen sink ListObjects 1M, document can_edit69.55.4
Kitchen sink ListObjects 1M, folder viewer49.08.0
Kitchen sink ListObjects 1M, document can_view434.8156.4
Check 1M, inherited permission0.5430.463

Some results are slower:

Benchmarkv0.10.3v0.11.0
SaaS Check 1M, org can_read, partner user0.9061.167
SaaS Check 100K, incident can_comment, small-org member1.071.62
SaaS ListObjects 1M, orgs whose settings staff can read, first call on a new session136.8192.4
Kitchen sink ListObjects 10K, document can_view29.837.6
Contextual tuples, 1 tuple11.713.2

Of the 439 benchmarks outside the OpenFGA suite, 180 are more than 10% faster and 33 more than 10% slower, with a geometric mean of 2.0x faster. Most of the slower ones are SaaS checks: 17 of 149 checks are more than 10% slower and 33 more than 10% faster. Of the 2,209 OpenFGA suite benchmarks, 1,418 are more than 10% faster and 109 more than 10% slower (geometric mean 1.11x). The raw output of the 0.11.0 run is in benchmarks/2026-11-cloud-sql/.

Testing

  • Accessible functions against OpenFGA. For every list and check assertion in the OpenFGA suite, the accessible and parent-key functions must return the expected objects: 1,330 list and 1,834 check comparisons.
  • Exposure fuzzing. The generated nested-model fuzzers now migrate two thirds of inputs under a random restricted exposure, compare the accessible and parent-key functions with OpenFGA, and require M2003 for every unexposed relation. TestOpenFGA_RestrictedExposure replays 232 OpenFGA cases with only the asserted relations exposed.
  • Paging. Every list_objects assertion is also checked in pages of 1 and 2.
  • SaaS benchmark. BenchmarkSaaS models a multi-tenant application with tiered roles, partner organizations, staff access, a 27-branch view and skewed data, at 100K, 1M and 5M tuples, including the first calls on a new session. Run it with just bench-saas 1M.

Try it out

# Upgrade the CLI
brew upgrade pthm/tap/melange          # Homebrew
scoop update melange                   # Scoop
go install github.com/pthm/melange@v0.11.0

# Or pull the container image
docker pull ghcr.io/pthm/melange:v0.11.0

# Check the schema, then replace the generated SQL
melange validate
melange migrate

# See the SQL API the database has
melange functions --db

# Runtimes
go get github.com/pthm/melange/melange@v0.11.0
npm install @pthm/melange@0.11.0

See the installation guide for every channel.

Feedback

Report security issues through private vulnerability reporting. Open an issue for bugs or feature requests.