Skip to content
Melange v0.8.4

Melange v0.8.4

July 1, 2026·pthm
pthm

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.

No breaking changes from v0.8.3. Run 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 Result

Coverage

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") narrows Leaf.Users to a single subject type. The tree structure for other types is preserved, only the concrete user lists get filtered.
  • WithExpandMaxLeaf(100) caps each Leaf.Users list. Truncated leaves carry a UsersTruncated: true flag 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, listing documents a user can see via a folder#parent link), 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 migrate regenerates the affected list_*_obj functions.

Bug fixes

  • Dependency security bumps (#63). vite, esbuild, and the OpenFGA reference client bumped to patched versions.

New contributors

Docs

Migration notes

No breaking changes from v0.8.3. Run migrations to install the new functions:

melange migrate

If you use melange generate migration, regenerate to pick up the new functions:

melange generate migration \
  --schema melange/schema.fga \
  --output db/migrations \
  --git-ref main

Try 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/melange

See 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.