Melange v0.9.2
Melange v0.9.2 fixes a correctness bug where ListSubjects could return subjects that Check denies — a but not exclusion was dropped for subjects reached through certain tuple-to-userset paths. It also adds a suite-wide parity test that pins ListObjects / ListSubjects to Check so this class of bug can’t come back unnoticed. The fix is in generated SQL; run melange migrate to pick it up.
melange migrate to regenerate. Relations without a but not exclusion compile to byte-identical SQL, so only exclusion relations change.The bug
Reported as #80: for a relation like can_see: viewer but not blocked, ListSubjects returned subjects that Check correctly denied. The divergence only appeared when viewer’s positive side reached subjects through a recursive tuple-to-userset (viewer from parent) combined with a second path — a cross-type TTU (member from org) or a nested userset (group#member over a self-referential group). A blocked subject reachable via one of those paths leaked into the list even though Check denied it.
The reporter isolated the boundary precisely: pure recursion and flat usersets were fine; it was the combination that broke.
Root cause
ListSubjects builds its result as a union of “arms”, one per way a subject can be reached (direct grant, computed relation, parent recursion, cross-type TTU, userset expansion). Most arms already subtract the relation’s exclusion. But the arms that reach subjects by composing another relation’s subject list — the subject-first TTU arms — enumerated that source relation’s subjects without re-applying the outer but not. Two branches of the recursive list_subjects generator had this gap:
- the concrete-subject branch (the originally reported case), and
- the userset-filter branch (
group#member-style queries), found by a follow-up scan across the codebase.
Both now apply the exclusion — the concrete-subject arms subtract it directly, and the userset-filter arms validate each candidate against the full relation — so every arm agrees with Check.
Catching the whole class
The single reported bug pointed at a general risk: any list arm that composes another relation could drop a filter. To close that off, v0.9.2 adds a list-vs-check parity sweep to the test suite. For every ListObjects and ListUsers assertion across the whole conformance corpus, it also runs the list operation and compares the result against Check, in both directions, over the full set of subjects and objects in the fixture:
- no over-reporting — every subject/object the list returns is allowed by
Check(the exact bug shape above), and - no under-reporting — every candidate
Checkallows is returned by the list.
Check is the oracle here — it is already pinned against real OpenFGA — so any drift between the list functions and Check now fails the suite. This runs on every existing case with no per-test bookkeeping: 1300+ parity checks across both database schemas, all aligned.
Performance
The added exclusion is a filter the correct answer always needed. Relations without an exclusion generate byte-identical SQL. On a scaled benchmark of the fixed shape (hundreds of members, half of them blocked), the extra NOT EXISTS cost was within measurement noise — and the correct result set is smaller, so it moves fewer rows.
Migration
No breaking changes from v0.9.1. Regenerate the functions:
melange migrateIf you emit migration files, regenerate to pick up 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.9.2
# Regenerate and apply the corrected functions
melange migrate
# Go runtime
go get github.com/pthm/melange/melange@v0.9.2
# TypeScript runtime
npm install @pthm/melangeFeedback
Thanks to @RDust4 for the detailed report and reproduction on #80. Open an issue for bug reports or feature requests.
