Item reads are scoped by the manager¶
ADR-0006 says every Item read carries two filters,
circle=… and deleted_at__isnull=True, and that both belong in one place. This decides
the place, and makes forgetting it loud.
The decision¶
Item.objects raises when asked for an unscoped queryset. There are exactly two ways
to read Items:
Item.objects.for_member(user): the Items of the Circleuseris a Member of, Deleted ones hidden. It filters oncircle__memberships__user=user, so a person with no Circle gets no Items rather than an exception. The one way past the lid isfor_member(user, include_deleted=True), which undo uses. The Circle filter cannot be lifted from here at all.Item.unscoped: a plain manager, named to be seen in review. Reserved for the expiry sweep (which is global by nature),create_item, the admin, and management commands that deliberately act across Circles.
Reads may stay in views. The boundary is the queryset, not the module, so the house rule "never write an Item from a view" does not need a read-side twin that would have to be remembered.
Why a manager that raises¶
The options were: a scoped default manager; moving every read into services.py; a
middleware-set current Circle that managers read; and passing the Circle into every call.
- Reads only in services is a house rule, and a house rule is exactly what the next view forgets.
- A current Circle from middleware is implicit global state: invisible at the call site, awkward under ASGI, and absent in a management command, which has no request.
- Passing the scope explicitly is right, and is what
for_member(user)does. But on its own nothing makes a caller do it:get_object_or_404(Item, pk=pk)still compiles. - A default manager that returns nothing is safe but quiet. A forgotten scope shows up as an empty wishlist, which reads as "no data" rather than "bug".
Raising turns every forgotten scope into a 500 on the first test that touches it. The
idiom this codebase already uses four times, get_object_or_404(Item, pk=…), reads the
default manager, so it fails too. That is the point. Writes are unaffected: Model.save()
and cascades go through _base_manager.
Consequences¶
- A cross-Circle access is a 404, because the Item is not in the scoped queryset.
Ids are sequential, and a 403 would confirm that the Item exists.
PermissionDeniedstays for "yours, but not allowed", and since ADR-0006 nothing is in that category. - Services check Membership too.
_require_creatorbecomes_require_member(item, actor), called by every operation on an existing Item: update, schedule, done, delete, undo. Over HTTP it never fires, because the 404 comes first. It is there for callers holding an Item fromItem.unscoped. A 403 from it means someone bypassed the scope. create_itemstampsactor.membership.circleand raisesPermissionDeniedwhen the actor has no Membership. On a write the queryset cannot help, so the service is the last line.- The admin sees every Circle.
ItemAdminreads throughItem.unscopedand shows and filters bycircle. The operator who approves signups is the operator who reads the admin. - Tests prove the boundary rather than one view's memory of it, in three layers: the
manager raises and
for_memberscopes; a two-Circle test walks the URL resolver for everyitemsroute with apkand asserts an outsider gets 404 on GET and POST, so a new route is covered the day it is added; and the other Circle's Item never appears in a list partial, a Kind chip count, a month or week grid, or via?item=<pk>. The raising manager also makes every existing view test fail if its view reads unscoped.