Symptom
Every example app (simple_task, contact_manager, support_tickets, ops_dashboard, fieldtest_hub) and every downstream user app that follows the documented pattern produces ~10-20 lint warnings of this shape on every dazzle validate:
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.read but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.create but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.update but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.delete but has no matching scope rule — will see 0 records
... × 5+ roles × N entities
simple_task alone surfaces ~24 such warnings; support_tickets, ops_dashboard, fieldtest_hub each surface ~15-30.
Root cause
The DSL convention every example app follows is:
entity User "Team Member":
permit:
list: role(admin) or role(manager)
read: role(admin) or role(manager)
create: role(admin)
update: role(admin)
delete: role(admin)
scope:
list: all
as: admin, manager
— i.e. one list: scope rule is written, and the same predicate is implicitly understood to govern reads, updates, deletes.
But the runtime resolver in src/dazzle/rbac/matrix.py:188 does strict per-operation matching:
def _find_scope_for_role(scopes, operation, role) -> ScopeRule | None:
for scope in scopes:
if scope.operation != operation:
continue
...
A list: scope rule does NOT match an incoming read decision. The result is PERMIT_NO_SCOPE — and per the matrix-builder's comment, that means the runtime returns 0 records for permitted-but-unscoped operations. Every example app silently has broken read/update/delete paths for admin/manager roles.
The lint warning is correct. The DSL pattern is incomplete. But every example app has the same omission, and no docs section tells authors they need per-op scope rules — the impression from docs/reference/predicate-algebra.md is that scope: is a per-entity row-filter, not a per-operation one.
The design question
Three options. They are not equivalent — each is a real product decision the framework owes its users.
Option A — Implicit inheritance: list: → read:
The narrowest fix. When read: is missing on an entity, fall back to the list: scope predicate. Justification: a row that's visible in a list is, by every existing example app's expectation, visible to read. Inheritance only goes list: → read: because the asymmetry is intuitive (you can list things you can also read; you can read things you can't list).
update: / delete: / create: continue to require explicit scope rules — because those are actions, not visibility predicates.
Implementation: _find_scope_for_role accepts a read query, falls through to list: if no read: matches.
Pros: Removes ~80% of the lint noise on every app. Matches the implicit mental model. No CHANGELOG breakage — existing per-op DSL still works (declared read: wins over inherited).
Cons: New implicit semantic. Has to be documented. Slight risk that someone intended read: to be more permissive than list: (admin can list nothing, but can read by direct id) — though that pattern is rare.
Option B — Strict per-op stays; mass-update every example app
Every example app gets scope: read: …, update: …, delete: …, create: … rules added explicitly. The warning becomes load-bearing — anyone who sees it has actually under-specified RBAC.
Pros: Most explicit. Forces authors to think about each operation.
Cons: 5 example apps × ~5 entities × 5 ops = ~125 new DSL lines for example apps. Every downstream app also has to do this. The framework demands more boilerplate than the average user needs. The "make sense by default" property is lost.
Option C — Default-deny is too aggressive; relax to PERMIT_FILTERED via permit's own condition
If the user wrote permit: read: role(admin) with no scope, the matrix could treat that as PERMIT (full access) for the role, not PERMIT_NO_SCOPE → 0 records. Permit alone is enough; scope is opt-in row filtering. The lint warning becomes a suggestion ("consider adding a scope rule") not a defect indicator ("will see 0 records").
Pros: Aligns with the v0.46.4-era convention where permit: … alone was the standard. Restores the lint warning to being informational.
Cons: Loses default-deny ergonomics — admin gets full unfiltered access if read: scope is missing, which may surprise authors expecting strict gating.
Recommendation
Option A is the smallest credible change. Cycle 117 (this cycle) is filing this issue to defer the decision to a human review — the design question is too consequential to bake in autonomously.
Once a decision lands:
- If A → linker change + tests + CHANGELOG under Changed + add a Guidance note to predicate-algebra.md
- If B → ~125-line DSL update across all example apps + CHANGELOG under Changed (DSL semantics tightened) + lint message clarification
- If C → matrix.py change + CHANGELOG under Changed (lint demoted from warning → info) + add an explicit "default-deny via missing-scope" opt-in pragma
Discovered by
/improve cycle 115 example-apps Tier 1 sweep (filed as row 100 in dev_docs/improve-backlog.md). Pattern verified against simple_task and support_tickets; identical shape in the other three example apps confirmed by Tier 1 lint output.
This issue is a design proposal, not a bug. Auto-classifying as discussion or needs-design would be appropriate.
Symptom
Every example app (
simple_task,contact_manager,support_tickets,ops_dashboard,fieldtest_hub) and every downstream user app that follows the documented pattern produces ~10-20 lint warnings of this shape on everydazzle validate:simple_task alone surfaces ~24 such warnings; support_tickets, ops_dashboard, fieldtest_hub each surface ~15-30.
Root cause
The DSL convention every example app follows is:
entity User "Team Member": permit: list: role(admin) or role(manager) read: role(admin) or role(manager) create: role(admin) update: role(admin) delete: role(admin) scope: list: all as: admin, manager— i.e. one
list:scope rule is written, and the same predicate is implicitly understood to govern reads, updates, deletes.But the runtime resolver in
src/dazzle/rbac/matrix.py:188does strict per-operation matching:A
list:scope rule does NOT match an incomingreaddecision. The result isPERMIT_NO_SCOPE— and per the matrix-builder's comment, that means the runtime returns 0 records for permitted-but-unscoped operations. Every example app silently has broken read/update/delete paths for admin/manager roles.The lint warning is correct. The DSL pattern is incomplete. But every example app has the same omission, and no docs section tells authors they need per-op scope rules — the impression from
docs/reference/predicate-algebra.mdis thatscope:is a per-entity row-filter, not a per-operation one.The design question
Three options. They are not equivalent — each is a real product decision the framework owes its users.
Option A — Implicit inheritance:
list:→read:The narrowest fix. When
read:is missing on an entity, fall back to thelist:scope predicate. Justification: a row that's visible in a list is, by every existing example app's expectation, visible to read. Inheritance only goeslist: → read:because the asymmetry is intuitive (you can list things you can also read; you can read things you can't list).update:/delete:/create:continue to require explicit scope rules — because those are actions, not visibility predicates.Implementation:
_find_scope_for_roleaccepts areadquery, falls through tolist:if noread:matches.Pros: Removes ~80% of the lint noise on every app. Matches the implicit mental model. No CHANGELOG breakage — existing per-op DSL still works (declared
read:wins over inherited).Cons: New implicit semantic. Has to be documented. Slight risk that someone intended
read:to be more permissive thanlist:(admin can list nothing, but can read by direct id) — though that pattern is rare.Option B — Strict per-op stays; mass-update every example app
Every example app gets
scope: read: …, update: …, delete: …, create: …rules added explicitly. The warning becomes load-bearing — anyone who sees it has actually under-specified RBAC.Pros: Most explicit. Forces authors to think about each operation.
Cons: 5 example apps × ~5 entities × 5 ops = ~125 new DSL lines for example apps. Every downstream app also has to do this. The framework demands more boilerplate than the average user needs. The "make sense by default" property is lost.
Option C — Default-deny is too aggressive; relax to
PERMIT_FILTEREDvia permit's own conditionIf the user wrote
permit: read: role(admin)with no scope, the matrix could treat that as PERMIT (full access) for the role, not PERMIT_NO_SCOPE → 0 records. Permit alone is enough; scope is opt-in row filtering. The lint warning becomes a suggestion ("consider adding a scope rule") not a defect indicator ("will see 0 records").Pros: Aligns with the v0.46.4-era convention where
permit: …alone was the standard. Restores the lint warning to being informational.Cons: Loses default-deny ergonomics — admin gets full unfiltered access if
read:scope is missing, which may surprise authors expecting strict gating.Recommendation
Option A is the smallest credible change. Cycle 117 (this cycle) is filing this issue to defer the decision to a human review — the design question is too consequential to bake in autonomously.
Once a decision lands:
Discovered by
/improvecycle 115 example-apps Tier 1 sweep (filed as row 100 indev_docs/improve-backlog.md). Pattern verified against simple_task and support_tickets; identical shape in the other three example apps confirmed by Tier 1 lint output.This issue is a design proposal, not a bug. Auto-classifying as
discussionorneeds-designwould be appropriate.