Architecture¶
Three registries stacked on one another, and one adapter between two of them.
Resources what exists: fields, views, actions, policy
│
│ provider="db.main#contacts"
▼
Providers how to reach it: SQL, REST, queue, memory, composite
│ ── CapabilityShim fills in whatever a backend cannot do ──
│
▼
Connections named handles: engines, HTTP clients, broker channels
Nothing above the provider layer knows where data lives. Nothing below it knows what a view is.
The query language¶
Every read is a ListQuery. It carries a filter tree, a search term, sort keys,
a page — and, separately, a scope.
@dataclass(frozen=True, slots=True)
class ListQuery:
filter: Filter | None = None # what the user asked for
scope: Filter | None = None # what their identity permits
...
@property
def effective_filter(self) -> Filter | None:
return and_(self.filter, self.scope)
Keeping them apart is deliberate, and it is the single most load-bearing
decision in the codebase. Providers are required to read effective_filter,
never filter. A view that rebuilds the user's filters therefore cannot
drop the authorization restriction — the two live in different fields, and the
one that reaches the backend always includes both.
Resource.build_query() is the only sanctioned way to construct one, and it
fills in scope from the policy. Routes never build a ListQuery directly.
Capabilities and the shim¶
Backends differ enormously. A Postgres table can filter, sort, paginate, count and aggregate. A third-party JSON endpoint returns a list and nothing else.
Rather than write every view twice, each provider declares what it can do:
class Capabilities:
read, write, create, update, delete
server_filter, server_sort, server_search, server_paginate
total_count, aggregate
write_mode: "sync" | "async"
filter_ops: frozenset[Op]
CapabilityShim wraps any provider, emulates in Python whatever it declined,
and advertises the union. Views are written once, against a full contract.
Three rules make this safe:
Pushdown is all-or-nothing per stage. A backend that supports eq but not
icontains gets the whole filter emulated. Pushing half a filter tree and
evaluating the rest locally would need set-intersection semantics a row-based
pipeline cannot express.
Pagination is only pushed down when filtering and sorting were too. Otherwise the backend slices rows 1–25 and the shim filters those — paging over the wrong population. This produces plausible, wrong data rather than an error, which is why it is checked explicitly.
Emulated stages are stripped from the inner query. Many HTTP clients ignore
parameters they do not understand; leaving sort= on a query we intend to sort
locally would let a provider that half-honoured it double-apply the ordering.
The shim's row budget (max_rows) is enforced by raising TooManyRows, never
by truncating. A short result set looks like a valid answer, which is worse than
an error.
uv run crm capabilities shows, per resource, which stages are native and
which are emulated. The same table is on the /system page.
Providers compose¶
The three wrappers below all satisfy the same interface they wrap, which is why a resource can gain any of them without a view noticing.
| Wrapper | What it changes |
|---|---|
CapabilityShim |
Emulates the query stages the backend cannot do. Applied to everything. |
AuditingProvider |
Records every write with before/after values. Sits inside the shim, so it sees real writes and not the reads the shim performs. |
CompositeProvider |
Reads from one backend, writes to another. |
ReadOnly |
Refuses writes. |
UnionProvider |
Merges several backends into one row set — see Several databases. Each source is shimmed on its own first, so the union pushes filters down rather than fetching everything and filtering itself. |
Where the tables behind those backends live is not configured twice.
app.core.placement reads it back out of the resource declarations — a
resource naming db.archive#archived_deals is what says that table is in the
archive — and crm migrate and crm seed act on one database at a time from
that. Tables nothing claims belong to the default connection, so a
single-database deployment declares no placement at all.
Writes have three outcomes, not two¶
class WriteStatus(StrEnum):
OK = "ok" # stored, confirmed
PENDING = "pending" # accepted, not confirmed
ERROR = "error" # rejected
PENDING is what allows a message queue and a SQL table to share one form
pipeline. Publishing to a broker means the broker took the message — not that
the change was applied, and not that it ever will be. Reporting that as success
would tell the user something the next page load contradicts.
The UI renders a queued badge and a correlation reference instead.
Fields produce canonical Python values¶
Field.to_storage() returns a real date, not an ISO string. Serialising in
the field would bake in one backend's wire format: a typed SQL column wants the
object, a JSON API wants the string, a message body wants the string.
Adapting is each provider's job — SQLProvider._adapt() coerces to the column
type, RestProvider._jsonable() serialises for the body. This split is what
lets one field declaration serve every backend.
Request handling¶
One router serves every resource. A handler answers three audiences from one body, decided from request headers:
| Request | Response |
|---|---|
| ordinary browser GET | full page |
HX-Request: true |
HTML fragment |
Accept: application/json |
JSON |
View (in app/web/deps.py) carries the request, identity, registry,
templates and response helpers, so handlers take one dependency rather than six.
What the dependency does before the handler runs¶
build_view is the one thing every route resolves, which makes it the only
place a check cannot be forgotten. Three happen there rather than in handlers:
| Check | Why not in the handler |
|---|---|
| CSRF validation | a new route added without the call is not a test failure, it is a hole nobody notices — and one had been added that way |
| forced password change | not every route calls require_login; a list view checks require_can instead, and a gate some routes skip is not a gate |
| loading row-level grants | policy methods are synchronous, so their data must be fetched before any of them run |
The same reasoning applies at the middleware layer, which wraps error responses
too: a request id (X-Request-ID, tying a request to its audit rows and log
lines), a query count and timing, and compression. A request over the
configured thresholds logs one line naming the statement that repeated most —
which is how an N+1 announces itself. See scaling.md.
Saying something to the user¶
A message often accompanies a navigation: "Saved", "That link has expired", a one-time password. HTMX can carry it back in a header and show it without navigating; a plain form post cannot, because it answers with a redirect and the browser discards the header with the rest of that response.
So View.toast() picks the channel: a header for HTMX, and otherwise a short-
lived signed cookie that the next page renders and clears. Getting this wrong
is not cosmetic — it makes a button that worked look like a button that did
nothing.
Template resolution¶
Most-specific-first, cached unless template reload is on:
resources/<resource>/<view>.html → views/<view>.html
resources/<resource>/fields/display/<field>.html
→ fields/display/<widget>.html
→ fields/display/<type>.html
→ fields/display/text.html
Overriding one screen, or one column's rendering, never requires copying the machinery behind it. This is what makes the project a starter rather than a framework to fight.
Authorization¶
Two separate questions, deliberately answered by different mechanisms:
| Question | Mechanism | Failure |
|---|---|---|
| May they do this? | Policy.allows(op, identity, record) |
403 |
| Which rows exist for them? | Policy.scope(identity) → a Filter |
invisible |
| May they see this field? | Field.read_roles |
omitted |
The second is the important one. Because it is a filter folded into every
query, it holds on paths that never call list() — detail pages, edit forms,
inline cell saves, custom actions, CSV export, and the aggregates behind charts
and the dashboard. None of those routes contain authorization code.
A record outside the caller's scope reads as 404, not 403: confirming that a key exists is itself a disclosure.
Three ways to do something later¶
They are not alternatives; each is right for a different promise.
| What it is | Survives a restart? | Use it for | |
|---|---|---|---|
asyncio task |
a coroutine on this worker's event loop | no | work nobody is waiting on and nobody was promised — a delivery, a cache warm |
| job queue | a row in jobs, drained by crm worker |
yes | work that was accepted: an export somebody asked for, an outbound message |
crm notify-due |
a sweep run by cron | yes | work that is due at a time rather than caused by an event |
The queue is the middle one, and its design is in
scaling.md. Two things about it are
architectural rather than operational.
It is a resource like any other — provider="db.main#jobs" — so it is
visible as a screen, it works over any provider that supports a conditional
write, and its tests run against the in-memory provider rather than needing a
database. Nothing about it is specific to PostgreSQL.
And it is at-least-once, where the reminder sweep is at-most-once. That is not an inconsistency: a reminder is an email, where a rare duplicate is worse than a rare miss, and a job is code you wrote, which can be made idempotent. Each picks the bargain that suits what it carries.
Modules¶
Almost nothing ships. The default application is accounts, roles, permissions, the audit trail and notifications — the parts every internal tool needs and nobody wants to write twice. There are no business entities, because yours are not ours.
| Module | Loads | Contains |
|---|---|---|
core_identity |
always | users, API tokens |
core_access |
always | roles, permissions, audit log, notifications |
demo_crm |
opt-in | companies, contacts — the worked example |
demo_sales |
opt-in | deals, activities; board, calendar, charts, gantt, activity grid, dashboard, actions |
demo_remote |
opt-in | a REST-backed resource and a queued write |
CRM_MODULES names the optional modules to switch on. It is additive: the
two core_ modules load whatever it says, so no configuration can produce an
application nobody is able to administer.
A module is a package with a MANIFEST and a register(registry) function,
discovered by directory scan and by entry point. It may also carry two files
the loader looks for by name:
| File | Purpose |
|---|---|
schema.py |
tables, declared against app.schema.metadata — so crm migrate and crm seed see them |
seed.py |
a seed(ctx) coroutine filling them with sample data |
Both are imported only for modules that are enabled, and that ordering is
load-bearing. Every discovered package is imported to read its manifest,
including ones that will not be enabled; if an optional module declared its
tables in __init__.py, merely existing on disk would put them on the shared
metadata and into the next generated migration. Keeping them in a submodule is
what makes a module a real unit of deployment rather than a naming convention.
Modules load in dependency order, so a module may extend resources and views that an earlier one declared:
def register(registry):
registry.add_resource(deals)
# add to a resource this module did not declare
registry.resource("companies").view("list").add_columns("industry", after="name")
View specs are mutated in place rather than copied — an extension must affect the instance the registry already holds, or the change would apply to a copy nobody renders.