Melange v0.8.4
Melange v0.8.4 adds two new APIs: Explain, which returns the resolution trace for a single decision, and Expand, which returns an OpenFGA-shaped UsersetTree answering “who has access?”. Both ship as generated SQL functions, Go and TypeScript runtime methods, and melange explain / melange expand CLI commands. Every relation in the OpenFGA compatibility suite gets its own specialised function; nothing falls through to a dispatcher sentinel.
melange migrate to install the new explain_permission / expand_permission dispatchers and per-relation explain_<type>_<rel> / expand_<type>_<rel> functions. Existing Check and List code paths are unchanged.Explain
Check returns a boolean. Explain returns the tree behind it, including every attempted branch, the contributing tuples, and per-branch success or failure.
$ melange explain user:alice viewer document:1 ✓ user:alice has viewer on document:1 └── via userset: via [group#member] → group:engineering └── direct: user:alice → member → group:engineering
On a denied check, each attempted branch is recorded with the grant that would have satisfied it:
$ melange explain user:bob viewer document:1 ✗ user:bob does NOT have viewer on document:1 └── union of 3 branches ├── ✗ no direct grant ├── ✗ implied: implied via editor └── ✗ via userset: via [group#member] → group:engineering └── union of 1 branches └── ✗ no direct grant
The Go runtime returns the same data as a Trace value:
trace, err := checker.Explain(ctx,
melange.Object{Type: "user", ID: "alice"},
melange.Relation("viewer"),
melange.Object{Type: "document", ID: "1"},
)
fmt.Println(*trace.Result) // true / false
fmt.Println(trace.Root.Type) // melange.NodeUserset
fmt.Println(trace.Root.Children) // per-branch sub-traces with their own ResultCoverage
Explain covers everything Check does: direct grants, implied closure, single- and multi-level TTU, simple and complex userset references, intersection (any part shape, including IsThis, TTU, and per-part exclusion), and exclusion (single, multi, TTU, and intersection subtrahends). Wildcards like [user:*] surface as a NodeWildcard sentinel rather than enumerating every matching user. The full matrix lives in the Explaining Decisions guide.
Truncation
p_max_nodes caps the trace size. Set it per call with WithExplainMaxNodes(n) or --max-nodes n, or per session with SET melange.max_explain_nodes. The default is 100. When the cap fires, trace.Truncated is true. Precedence details are in the guide.
Expand
Expand returns the OpenFGA UsersetTree for an (object, relation) pair. Use it to drive an audit UI, generate an access-review export, or work out why a subject appears in a ListSubjects result.
$ melange expand document:1 viewer --db postgres://localhost/mydb
document:1#viewer • union of 2
├── document:1#viewer • users
│ ├── user:alice
│ ├── user:bob
│ └── group:eng#member
└── document:1#viewer • computed pointer
└── computed → document:1#editor (melange expand document:1#editor to chase)The returned tree mirrors openfgav1.UsersetTree field-for-field, so existing OpenFGA tooling can deserialise the JSON without an adapter. Leaf.Users inlines direct grants, wildcards, and userset references as OpenFGA-formatted strings. Leaf.Computed and Leaf.TupleToUserset come back as pointers, and the caller follows them with additional Expand calls when they need to.
tree, err := checker.Expand(ctx,
melange.Object{Type: "document", ID: "1"},
melange.Relation("viewer"),
)
for _, u := range tree.FlattenUsers() {
fmt.Println(u)
}If you want a flat, deduplicated user list instead, Checker.ExpandRecursive walks the Leaf.Computed and Leaf.TupleToUserset pointers until the graph is exhausted. It is cycle-safe and issues one round trip per distinct pointer.
Melange extensions
Two options extend Expand without changing the wire shape:
WithSubjectTypeFilter("user")narrowsLeaf.Usersto a single subject type. The tree structure for other types is preserved, only the concrete user lists get filtered.WithExpandMaxLeaf(100)caps eachLeaf.Userslist. Truncated leaves carry aUsersTruncated: trueflag so callers can render “N+ users” markers rather than silently under-report.
Both are also available on the CLI (--subject-type, --max-leaf) and in SQL as extra positional arguments on expand_permission. WithExpandMaxLeaf uses the same three-tier precedence as Explain: per-call, then the melange.max_expand_leaf session GUC, then the default (unbounded, matching OpenFGA).
Coverage
Every relation in the OpenFGA compatibility suite gets its own expand_* function, so no pairs are routed through a sentinel. Parity is enforced by a sweep that runs ExpandRecursive(...).contains(subject) against every listObjectsAssertions[*] entry, across both the upstream OpenFGA YAMLs and the local schema-melange variants.
Opt-in cache extensions
Cache has two new sibling interfaces, ExplainCache and ExpandCache, that sit behind their own type assertions on the Checker. Implementations that satisfy all three (including the built-in CacheImpl) get caching for every API in one wiring pass. Existing Check-only implementations keep working thanks to the type-assertion fall-through. Per-API cache keys include every option that affects output: MaxNodes for Explain, and SubjectType plus MaxLeaf for Expand. Clear() resets all three families in one call.
CLI palette
melange explain and melange expand now render in the OpenFGA openfga-dark theme, mapped to 24-bit ANSI true colour. Types are green, relations cyan, type restrictions mint, keywords grey. The ✓ and ✗ header markers render as bold white glyphs on a coloured background chip. Both commands accept --color=auto|always|never.
Doctor: Expand fan-out advisory
melange doctor has a new advisory that flags relations combining wildcards or recursive TTU rewrites. These are the shapes where a single Expand call can materialise arbitrarily large leaves, so the advisory suggests SET melange.max_expand_leaf as a session-level guardrail. It’s StatusPass: a hint, never a failure.
Performance
- Cross-type TTU
list_objects, subject-first (#64, thanks @baszalmstra). When a TTU’s linking relation crosses subject types (for example, listingdocuments ausercan see via afolder#parentlink), the renderer now starts from the subject index rather than opening the join on the object side and filtering. The old shape was measurably slower on high-cardinality object tables. No API change;melange migrateregenerates the affectedlist_*_objfunctions.
Bug fixes
- Dependency security bumps (#63).
vite,esbuild, and the OpenFGA reference client bumped to patched versions.
New contributors
- @baszalmstra made their first contribution in #64. Thanks Bas!
Docs
- New Expanding Permissions guide.
- Explaining Decisions rewritten, with the “not yet supported” sections removed.
- Caching extended with the new opt-in interfaces.
- The SQL API, CLI, Go API, and TypeScript API references all cover the new surface.
Migration notes
No breaking changes from v0.8.3. Run migrations to install the new functions:
melange migrateIf you use melange generate migration, regenerate to pick up the new functions:
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.4
# Or install the .deb / .rpm package from the GitHub release
# Apply migrations
melange migrate
# Explain a check
melange explain user:alice viewer document:1
# Expand a (object, relation)
melange expand document:1 viewer
melange expand document:1 viewer --recursive # flat user list
melange expand document:1 viewer --format=json | jq # raw UsersetTree
# Cap output size
melange explain user:alice viewer document:1 --max-nodes 50
melange expand document:1 viewer --max-leaf 100
# Go runtime
go get github.com/pthm/melange/melange@v0.8.4
# TypeScript runtime
npm install @pthm/melangeSee the Explaining Decisions and Expanding Permissions guides for the full API surface.
Known follow-ups
A few things are deferred but not blocking:
- Mixed-shape exclusion ordering in Expand. When a relation interleaves simple, TTU, and intersection exclusions in source order, the renderer emits them in shape order instead. Set semantics are preserved and no schema in the suite trips it, but fixing it properly needs an analyzer-level
OrderedSubtrahends. - Leaf-like complex Explain intersection sub-traces. The complex-part handler emits a labelled synthetic child rather than recursing. That’s correct for parity, but less rich than the plain-part recursive sub-traces.
Feedback
Open an issue for bug reports or feature requests.
