Skip to content

A Circle is the tenancy unit

The app was built for one pair and says so everywhere: Item.objects is read unscoped, the push fan-out is "every other row in the table", and partner_of is, by its own docstring, a guess. A Circle replaces that global assumption with a real boundary — the people who share one calendar, and the owner of everything in it.

A Circle holds at most two Members for now, but is modelled as n, so the cap is a rule that can be lifted rather than a schema that must be rewritten.

The shape

Circle and Membership live in their own app, shared_calendar.circles. Everything points at circles; circles points at nothing.

Membership is a table, not a circle FK on the user. Both model n perfectly well, so n-modelling alone does not decide it: what decides it is that joining is an event with its own facts — when it happened, and which invite it came from. Those belong to the association, not to the person. A Membership row is the thing an invite creates and a departure deletes, which keeps "left the Circle" from meaning "edit a column on the user".

The two caps live in different places, deliberately:

  • One Circle per person is unique=True on Membership.user. It is an invariant with no lifecycle, so the database holds it.
  • Two Members per Circle is a service rule inside a transaction, and is not a constraint. A count-based constraint is not expressible in Django and SQLite without a trigger or a denormalised counter, and either one bakes 2 into the schema — the exact thing modelling as n was meant to avoid. Lifting the cap should be a line, not a migration.

When a Circle is born

At approval, not at signup. Signup is public and reviewed by hand, so a signed-up person may never become a user at all; creating their Circle at signup means a Circle per spam registration. Approval is the moment a person becomes able to use the app, which is exactly the moment they need somewhere to put Items. One transaction approves the person, creates the Circle and creates the Membership.

The rule that falls out:

A person who arrives through the front door gets a Circle. A person who arrives through an invite joins one.

An invitee is exempt from approval precisely because they are not founding anything. This also gives "belongs to no Circle" a bounded meaning: signed up but not yet approved, or invited but not yet accepted.

Consequences

  • A Circle has no name. Nothing renders one: there is one Circle per person and no switcher. A name would be a column that is blank at creation and never read. Adding a nullable CharField later is the cheapest migration there is.
  • accounts/services.py is deleted. Its whole content is an apology for the missing schema, and the schema has arrived. The lookup moves onto the Circle as other_members(user), returning a queryset — n-shaped, like the model.
  • The context processor keeps injecting a singular partner as other_members(user).first() for the six template spots that read it. That one line is where the cap meets the read path: the data access stays n-shaped, the presentation is 2-shaped, and lifting the cap changes the sidebar rather than the boundary.
  • Two concurrent accepts could seat three people, since nothing in the database forbids it. Closed with select_for_update on the Circle row and a test, not with a constraint.