Melange v0.8.6
Melange v0.8.6 fixes six cases where ListObjects and ListSubjects returned the wrong set — over- or under-reporting subjects under exclusions, wildcards, and usersets — and trims another round of redundant SQL out of every generated function. It also rebases the performance reference on real measurements across four dataset sizes. Every change is in generated SQL; run melange migrate to pick it up.
melange migrate to regenerate the check, list, and dispatcher functions. Your Check and List call sites are unchanged.Correctness fixes
These are pre-existing bugs in the list functions. If your schema uses any of these shapes, ListObjects / ListSubjects could disagree with Check — the fixes bring them back in line.
- Exclusions under a userset are subtracted. A
but notexclusion could be dropped when a subject was reached through a userset expansion, soListSubjectsreturned subjects that should have been excluded. Root-level exclusions now apply after the userset expands. - Excluded wildcards are subtracted. A
[user:*]grant sitting under abut notwas not removed, so an excluded wildcard could still surface every subject. - Self-referential-userset exclusions apply at every level. For
active_member: member but not bannedover a self-referentialmember: [group#member], banned subjects are now removed all the way down the chain, not just at the top. - Userset grants resolve inside an intersection. An
and-composed relation whose first operand was a direct userset assignment ([group#member] and …) failed to resolve the userset and under-reported the intersection. check_permission_bulkreturns one row per request. A known object type paired with an unknown relation hit both a per-type branch and the fallback, producing twofalserows for a single request. Bulk results are now deduplicated.- A closure-hoist guard is name-anchored. A substring match in an internal safety check could misfire on a relation whose name was a prefix of another. It now matches the whole name.
Leaner generated SQL
Compiling a model that exercises every generator code path produces about 40% less SQL than v0.8.5 — down from ~1.7 MB to ~1.0 MB, across 42 fewer functions. The trimmed SQL is work the planner was evaluating for no result:
- Check functions only carry the no-wildcard specialization when the relation can actually reach a wildcard.
- Closure lookup tables are computed once per function instead of re-inlined at every reference.
- Dead scaffolding is gone: recursive machinery in non-recursive
list_objects, CTEs that were never read,SET search_pathin functions that only touch qualified names, redundantDISTINCTunder aUNION, andLIMIT 1insideEXISTS. - Intersection and composed list parts render as direct sets and semi-joins rather than per-candidate filters.
Generated functions are now marked PARALLEL RESTRICTED, so PostgreSQL can use them in parallel query plans where it previously could not. There is no API change — melange migrate regenerates the functions.
Performance, measured
The performance reference is rebased on a single model that exercises every generator code path, benchmarked across four dataset sizes from ~4K to ~840K tuples — a ~220× spread. The headline shape of the numbers:
check,expand,explain, and bulk check are constant time. Latency is flat across the full ~220× range of dataset sizes; growing the data does not move it.ListObjectsscales with the result set, not the tuple count. A query that returns a few objects stays roughly flat as the dataset grows ~220×. A query that walks a broad recursive graph grows with the paths it visits, not the table.ListSubjectsscales with the number of subjects that have access. For a broadly-granted relation this is genuinely expensive: over the ~220× dataset growth (and ~125× more users), listing every subject of a deeply-inherited relation goes from tens of milliseconds to seconds.
The previous doc claimed ListSubjects was constant time; that was an artifact of a small-result test model. “Who can see this” over a broad, recursive, wildcard-bearing relation has to expand the whole reachable subject graph. Use it for batch and offline work, and use Check for “does this user have access”. Pagination does not reduce the cost — the graph is walked to produce an ordered page regardless of LIMIT.
How this was verified
The correctness fixes came out of two additions to the test suite:
- A kitchen-sink model — one
.fgaschema that wires every relation feature and list strategy together the way a real model tangles them (recursive folders, cross-type TTU, closure-inherited exclusions, userset-of-userset), with a differential test comparing every generated function against a hand-computed oracle. - A real OpenFGA oracle — an opt-in run mode (
MELANGE_OPENFGA_ORACLE=1,just test-openfga-oracle) that replays the whole conformance corpus through an in-process OpenFGA server. This checks melange’s expected values against upstream OpenFGA itself, not just against melange. The full corpus — 354 check, 335 list-objects, 319 list-users assertions — now passes against real OpenFGA.
Migration notes
No breaking changes from v0.8.5. Regenerate the functions:
melange migrateIf you use melange generate migration, regenerate to emit the corrected SQL:
melange generate migration \
--schema melange/schema.fga \
--output db/migrations \
--git-ref mainTry it out
# Install / upgrade CLI
brew install pthm/melange/melange
# Or pull the container image
docker pull ghcr.io/pthm/melange:v0.8.6
# Or install the .deb / .rpm package from the GitHub release
# Regenerate and apply the corrected functions
melange migrate
# Go runtime
go get github.com/pthm/melange/melange@v0.8.6
# TypeScript runtime
npm install @pthm/melangeSee the performance reference for the full measurements and How it works for the compile-time model behind the generated SQL.
Feedback
Open an issue for bug reports or feature requests.
