Backend Guidelines
Backend code is Python, Django, and the composer. It owns data, permissions, transport-neutral business behavior, and generated contracts.
Follow the shared development process and coding principles in docs/guidelines.md for every task; the rules below are the backend-specific layer applied during the Build step.
Stack
The opinionated stack in docs/stack.md is the source of truth for backend libraries and what each one owns. Check it before adding a dependency or hand-rolling a concern. Python dependency setup belongs in pyproject.toml and uv.lock.
Django-Native Rule
Angee is not a second framework on top of Django. It is a build-time composer for Django apps.
Before adding an Angee abstraction, ask: does Django already have an object, method, or convention that owns this fact?
Use Django's native owners:
- App facts live on
AppConfig. - Model behavior lives on models, managers, and querysets.
- Value coercion lives on fields.
- Command dispatch lives in Django management commands and
argparse. - Table names, app labels, migrations, and model metadata follow Django defaults.
Angee code should own only the composition seam: discovering addons, ordering them deterministically, emitting runtime apps, merging schemas, syncing resources, and failing fast on collisions.
A wrapper must prove it adds a real new concept. If it only forwards, normalizes, or renames a Django object, delete it.
Django-native also means app-native. Addons are reusable Django apps with conventional files: addon.toml declares the addon contract (and its presence is the marker that makes the app an addon), models.py owns data and row behavior, managers.py owns reusable row-set APIs when they outgrow the model module, schema.py owns Strawberry declarations, permissions.zed owns REBAC structure, mcp_tools.py owns MCP tool registration, forms.py owns Django form validation/presentation, admin.py owns Django admin presentation, and management/commands/ owns CLI parsing. apps.py is optional — an addon needs one only to run a Python seam (ready() / import_models()). Do not add a parallel registry, loader, or naming convention until the native Django surface is proven insufficient.
Before adding backend structure, pass the Django architecture gate:
- Use Django's object first: model, field, manager, queryset,
AppConfig, management command, URLconf, migration, setting, or admin/form hook. - Keep Django apps reusable. An addon may depend on declared upstream addon contracts, but it should not know the host project, consumer addon, route layout, or generated runtime package by import.
- Keep policy on Django owners and details at the edge. GraphQL resolvers, commands, resources, webhooks, OAuth callbacks, and vendor clients translate inputs to model/manager/queryset/service calls; they do not re-decide model rules, permissions, implementation keys, or schema shape.
- If two addons need the same backend behavior, move it to the base addon or framework owner that both compose. Do not copy a resolver, resource loader, SDK wrapper, settings parser, or permission rule sideways.
Package Layering
The framework core is three packages with a one-way dependency rule that a test enforces:
angee.baseis the model foundation (models, fields, mixins, managers, querysets, and model emission declarations). It must not importangee.compose,angee.graphql, or addon packages.angee.graphqlis the GraphQL runtime (schema assembly, Strawberry helpers, serving, subscriptions, and SDL commands). It may importangee.base, neverangee.compose.angee.composeis the build-time composer. It may importangee.baseand discover plain Django addon configs, but no serving module (asgi,urls,views,consumers,signals,models,graphql) may importangee.compose.
The same one-way rule extends downward to the base addons under addons/angee/ (angee.resources, angee.iam, angee.integrate, angee.operator, angee.storage): an addon may import angee.base and angee.graphql, but never angee.compose. The resource subsystem (angee.resources) is itself a base addon, not part of the core — it owns the resource ledger described below.
Rules that follow from the layering:
- Addon discovery is a Django app-registry concern, not a build-only concern: serving code such as schema building enumerates Django's installed app configs and reads only the declaration attributes it owns. Serving code never imports
angee.composejust to list addons. - An Angee addon is a Django app marked by a co-located
addon.toml. The manifest's presence is the marker (angee.addons.is_angee_addon); there is noAppConfigflag and no Angee base config to subclass. An addon needs anapps.pyonly to run a Python seam (ready()/import_models()); otherwise Django's auto-createdAppConfigis enough. The declarative contract —depends_on(the ordering contract) plus the contribution seams — lives inaddon.tomland is read throughangee.addons.addon_contract. - The contribution seams default to what the addon directory reveals; an explicit manifest entry only overrides that default.
schema.py(definingschemas) → the GraphQL bucket,permissions.zed→ the REBAC contribution,web/package.json→ the web package (itsname),mcp_tools.py(definingregister) → the MCP tools. So a conventional addon declares only[addon]identity +depends_on+ metadata (and any ordered[resources]tiers); it spells a seam out in the manifest only to override the convention (a non-default web package, or[web].codegen). The dependency graph, resource tiers, and metadata are never inferred — order and intent are not path-derivable. Each lifecycle step then reads only the contract it owns:graphqlreadscontract.schemas,resourcesreads the[resources]tiers, the web projector reads[web].package/[web].codegen, the MCP server reads[mcp].tools, REBAC sync discovers an adjacentpermissions.zedby convention, stable serving imports conventionalurls.py/asgi.py, runtime emission reads model-levelruntime = True, and settings composition reads the addon's optionalautoconfig.py. - There is a single app set and a single boot.
DJANGO_SETTINGS_MODULEpoints atangee.compose.settings, which imports the project's settings contract (settings.yamlorsettings.pybesidemanage.py). YAML projects declareINSTALLED_APPSandANGEE_RUNTIME_DIR; Python projects may declare those same facts directly.angee.compose.settingsloads the project contract and callsComposer(globals()).compose_settings(), which expands the addon dependency closure and sorts the resulting app set, then gives Django the resolvedAppConfiginstances inINSTALLED_APPS. Framework boot apps (angee.compose,angee.base,angee.graphql) arrive through that same graph rather than a parallel hardcoded list. In app-populate phase 2,ComposeConfig.import_models()checks the generated runtime and imports concrete model modules before normal app model imports continue.angee buildandangee cleanmay emit stale runtime sources during that hook only so Django can finish loading the generated model registry; no build/run app-set split exists. - The resource ledger is owned by the resource addon. The composer discovers
angee.resources.models.Resourceas a normal addon source model and emits it under theresourceslabel.angee.basemust not importangee.resources. - Refer to an emitted concrete model through the app registry (
apps.get_model("resources", "Resource")), never by importing the generatedruntime/tree.
Rules
- Domain behavior lives on models, managers, and querysets.
- Manager/QuerySet canon: chainable read scopes live on a
*QuerySetexposed throughManager.from_queryset(...). Factories and mutations stay on the manager that owns the write. - Model methods own instance invariants, state transitions, validation, and side-effect boundaries tied to one row. Managers own factories, upserts, reconcile/load flows, and writes that begin from a model class. QuerySets own chainable read predicates and reusable scoping. If a resolver, view, or command repeats a filter predicate, promote it to a QuerySet; if it mutates row state, promote it to a model or manager method.
- Cross-addon and generated-model references go through Django's app registry (
apps.get_model,apps.get_app_config,apps.get_app_configs) and_meta. Never import generatedruntime/modules or rediscover model/app facts by string parsing. - GraphQL resolvers stay thin. They resolve the runtime model, actor/context, and input object, then delegate to the model, manager/queryset, action, or aggregate builder that owns the rule. If a resolver branches on field names, status values, permissions, or implementation variants, the owner is missing a method.
- GraphQL schema files declare Strawberry types, inputs, filters, buckets, and field-level resolver glue. They bind to composed runtime models and compose library primitives (
strawberry-django,crud,changes, aggregate builders) instead of reimplementing ORM, permission, or serialization behavior. - Model-backed
hasura_resource(...)surfaces expose sqid public identity. UseAngeeDataModel/SqidMixinfor concrete rows. For third-party Django models that Angee exposes but does not own, pass an explicit sqid public identity decoder to the resource instead of creating source-addon migration state. A raw primary-key compatibility path must be explicit and source-test-only. - Management commands stay thin. They parse CLI arguments, load settings/context, and call the owner. Command modules should not contain reusable business logic, import generated runtime models directly, or duplicate resource/composer/schema behavior.
- Vendor SDK clients are details. Keep SDK request/response quirks in the provider addon or backend class that owns that vendor, and map them into Angee-owned models/actions at the boundary. Do not let SDK field names become framework/domain names unless they are the domain vocabulary.
- Source model discovery should follow Django model inheritance and explicit model-owned declarations, not naming or field-shape heuristics.
- Put behavior on the object that owns the shape, the Django way: coerce values with
Field.to_python/get_prep_valueinstead of branching on field type from outside; askmodel._meta(get_field,label_lower) andField.value_from_objectrather than re-decoding model shape; surface query behavior throughManager.from_queryset; and give objects classmethod factories anddeconstruct-style methods to construct and serialize themselves. This is the backend application of Find the owner inAGENTS.mdand the Django-Native Rule above. - Compose behavior onto the class that owns the data. Settings construction belongs on
Composer; runtime model materialization belongs onRuntime. Keep a module-level function only for orchestration that genuinely has no owner, and prefer forming a cohesive class even then. A dataclass that only holds fields while a sibling module mutates and emits from it is a missing class. Organizing behavior into named files and classes is what keeps the framework consistent and normalized: a class is a fixed home that forces related behavior together and resists the drift that loose, scattered functions invite. - Imports go at the top of the module. A function-local or deferred import is a smell that a module boundary is wrong — an import cycle, or a layer reaching across a seam — so fix the seam (move the shared fact to its owning module, or invert the dependency) instead of hiding the import inside a function. Two exceptions, both narrow: a dependency that is genuinely optional at runtime (isolate it behind its own module), and Django's app-loading order — an
AppConfigmodule is imported in app-populate phase 1, before the registry is ready, so it must defer importing model classes (and signal wiring that pulls them in) until a method runs afterready(). Mark such a deferral with a comment naming the reason; everywhere else, hoist. Within Angee's own source (angee/andaddons/angee/) these are the only function-local imports allowed — phase-1 deferrals andTYPE_CHECKINGblocks. Probe optional or generated modules withimportlib.util.find_spec(verifying each parent first) rather thantry/except ImportError, so an absent generatedruntime/reads as "not built yet," not a swallowed error. - A pure renderer that takes its owner and returns a value with no other state may stay a module-level function in the owner's module; make it a method only when it reads more than one field of the owner or shares state with sibling helpers.
- A package
__init__.pywhose sole job is re-exporting a stable public API is a compatibility surface;__all__is allowed there (the usual "avoid__all__" rule targets ordinary modules). - When restructuring or lifting existing code, reconstruct each module from its contract, tests, and these guidelines — do not paste or mechanically port the old code, and do not keep the old modules importable inside the
angeenamespace. - Source models are abstract. Concrete apps are emitted by the composer.
- Keep Django
Metafor Django and library-owned options such asrebac_resource_type; Angee extension facts live on the owning model class. runtime/, generated schemas, migrations, and codegen stubs are output. Change the source, not the artifact.- REBAC is structural and owned by
django-zed-rebac. Addons declarepermissions.zedbeside the owning app. Permission sync is the library's ownmanage.py rebac sync. Use the library's field-backed relations (// rebac:field=...) when a relationship is already represented by a Django FK or one-to-one field. See the REBAC section below for this project's fail-closed posture and its traps. - For a vendor-backed capability, keep catalogue models pure metadata, model the connection shape at the row that stores its fields, and put the provider adapter choice on that owning row as a
backend_class-styleImplClassFieldonly when the persisted shape is otherwise the same. Name things in the domain's own terms, and keep side-effecting work on the operator — Django stays the catalogue. - Choosing how a row selects per-variant behaviour. Classify by what varies:
- The row is one mutually exclusive concrete kind of a parent concept → Django child model. The parent owns common identity, permissions, lifecycle, listing, and cross-kind actions; each concrete child owns its fields, tabs, actions, and row behavior. Use this when a parent plus required one-to-one "related model" would otherwise be manual polymorphism.
- A downstream addon adds optional capability fields to the same kind of row → model
extends. The base row remains the same domain object; extension fields are additive and may be blank/off. OIDC login fields onintegrate.OAuthClientare the canonical shape. - Only behaviour differs, open set (addons contribute impls) while persisted fields stay the same → one concrete model +
angee.base.fields.ImplClassFieldnaming a non-model strategy/client/backend class. Name the field by the role it plays (backend_class,provider_type), not by a generic "implementation" label. One table (unified list/reconcile, no field duplication); the impl is an explicit per-row choice, never derived from a vendor slug (a vendor can have several impls/accounts). - Only behaviour differs, closed framework-known set → a
StateField+ an eager handler registry (iam.credentials.register_handler/handler_for). The row stores the enum value; the kind projects as a GraphQL enum.
- Enum-backed fields use
StateField, neverCharField(choices=…).StateFieldwraps django-choices-field'sTextChoicesField, so strawberry-django renders a native GraphQL enum straight from thechoices_enum. A plainCharFieldwithchoicesrenders as a bareStringand silently drops the enum at the API boundary — never use it for an enumerated value.StateFieldis for an actual closed enum the row carries (status, platform, source, kind-of-credential). A type discriminator that selects mutually-exclusive concrete kinds is not an enum field at all — it is a Django child model (the first branch above): the concrete child is the kind, so aParty→Person/Organizationsplit has nokindcolumn. Reach for a child model, not aStateField, when the kinds carry their own fields (e.g. aPersonlinking to aniam.Userthat anOrganizationnever has). - Integration implementations are concrete integration children. The top-level
integrate.Integrationrow is the shared connection identity and lifecycle. Concrete integration kinds such as inference providers and VCS bridges are child models; their forms open from the integration surface and contribute implementation-specific tabs/related tables. A child model may carry its ownbackend_classwhen several SDK/protocol adapters share that child's persisted shape. Do not store a second genericimpl_classon the child: the child model is the integration implementation; the backend field is the adapter. - A row-selected impl is stored as a registry key, never a dotted path.
ImplClassField(base_class=…, registry_setting=…)stores a short key and resolves it against a Django setting mapping keys to dotted import paths; an addon contributes its impl into that setting throughautoconfig(a yamlconf dotted key,"ANGEE_…_CLASSES.<key>": "<dotted.path>"). So a writable column never feedsimport_string(the path comes from composed, trusted settings, like an addon'sschemasreference), the available impls are a composition fact rather than a base-model import, a project can remap a key to its own class, andmanage.py checkvalidates every configured path imports and subclassesbase_class. Because every addon has contributed by schema-build time the key set is closed, so the field is aTextChoicesFieldandstrawberry-djangorenders the GraphQL enum natively (likeStateField). It therefore requires a non-empty registry: an addon whose impl set could otherwise be empty registers a noop/null-object default (storage'slocal; integrate'snoneVCS client), so a composition always has one selectable impl and the enum is never empty. - Cross-addon dependencies are one-way (e.g.
integrate → iam, never the reverse); reject a bridge/diamond addon that would couple both ways. - GraphQL authoring is native Strawberry. Addons expose a
schemasmapping in conventionalschema.pymodules. Each named schema contributes into fixed buckets (query,mutation,subscription,types,extensions,type_extensions,input_extensions); Angee merges buckets across addons and builds one StrawberrySchemaper name. - GraphQL types and enums bind to the composed runtime model, never the abstract source class. Resolve the model with
apps.get_model("app", "Model")(the concrete emitted class), notfrom app.models import Model(the abstract source); the runtime class is the post-composition source of truth for fields, relations, and choices. A registry-backed enum (ImplClassField) is read off the runtime field, so the GraphQL enum already reflects every addon's contributions. - Extension is symmetric across five axes — extend, never edit the owner — and the schema is built after the runtime is composed, so all five apply post-composition with the dependency staying one-way (downstream reaches up; the upstream never references down). Add a concrete subtype of a parent row with a Django child model when exactly one concrete kind applies; add a field to another addon's model with an
extends = "app.Model"source model when the same row gains optional capability fields; add a value to an open enum with anImplClassFieldregistry (settings-keyed, one impl class per key — use it only when each key has genuinely distinct implementation code, not as a workaround for a closedTextChoices); add a field onto another addon's GraphQL type with nativestrawberry_django.type(RuntimeModel, name="UpstreamType", extend=True), listed in thetype_extensionsbucket — Strawberry owns the extension merge and strawberry-django resolves any relation projection from its model registry (e.g.iam_integrate_oidcadds fields toOAuthClientTypewithoutintegrateimporting it); add fields onto another addon's handwritten GraphQL input with nativestrawberry.input(name="UpstreamInput", extend=True)listed ininput_extensions. Input extensions are the write-side equivalent: they name the target input and add fields only; Strawberry merges multiple donors additively in addon order and fails fast on field-name collisions. Type and input extensions are global-additive, like a modelextends: the field lands on the target wherever it appears (the bucket only gates registration), so reference a field type that some bucket lacks and that bucket's build fails loudly rather than leaking. - Use symbolic model references across addon boundaries; avoid import cycles.
- Build output must be byte-deterministic.
REBAC
REBAC is owned by django-zed-rebac (see the Rules entry and docs/stack.md). This project runs fail-closed: REBAC_STRICT_MODE=True and REBAC_SUPERUSER_BYPASS=False, so every actor — superusers included — reaches data through REBAC, never a queryset bypass.
- Bracket every server-side read/write in
system_context/asystem_contextand resolve the actor with@rebac_subject; a bareModel.objects.create()under an actor is denied. - A per-row
createpermission cannot gate an insert (the unsaved row has no id → deny). Gate explicitly with a preflight (has_access("write")/rebac.check_new), then insert viarow.sudo()+save();.sudo()never auto-clears, so follow with.with_actor(actor). - Model universal-admin reach as a const-backed relation (
relation admin: angee/role // rebac:const=admin, no tuple or FK) resolving membership inangee/role:admin. Admin-gate a table-less/synthetic resource with amanaged=Falseabstract anchor model (passesrebac.E009, emits no table) plus that const admin, and keep an| angee/role:admin#memberarm inmemberorrebac.W004fires. - There is no
rebac_rolescommand — grant roles withrebac.roles.grant. A superuser created without a realsave()(bulk_create, loaddata, or skipped as unchanged) is never inangee/role:admin#member, so const-admin reach fails until re-granted. - Never
select_relateda REBAC-guarded relation into an actor-scoped queryset — it fails live ("loaded N rows outside actor scope") while passing unit tests. Resolve the field elevated by FK id undersystem_context, and verify by rendering the live page, not just the test. - Derive operator/edge token scope from
<ns>/role:<id>#effective_member(folds in role-hierarchyincludes), neverroles_of/roleRefs(a direct-grants UX hint that under-grants). rebac syncpersists the zed into DBSchema*tables, and the system checks gate every subcommand on that persisted state — so editing the zed can deadlock the sync. Unstick withrebac --skip-checks sync --force-overwrite --yesthenrebac sync; never smoke-test a zed against the shared example DB.- If a removed or renamed definition in an otherwise composed package fails
rebac.E009, run the check-freereconcile_permissionsfirst; it prunes stale package-managed schema rows beforemakemigrations/rebac synccan run. - When an addon removes its last REBAC resource, keep an empty package-owned
permissions.zedwith a bumped schema revision until old package-managed rows have been pruned; deleting the file makesrebac syncskip the package and strand stale definitions in existing databases.
Pitfalls
Hard-won traps — the wise learn from others' mistakes (docs/guidelines.md).
crud()createfull_cleans the input, so model + input defaults must agree.strawberry-django's create builds a dummy instance from the input and callsfull_clean()before saving — two traps follow. (1) AJSONField(default=dict)(ordefault=list) needsblank=True: Django counts{}/[]as blank, so ablank=Falsecontainer default failsfull_clean("cannot be blank") on every create. (2) An optional create-input field over a non-null column must default tostrawberry.UNSET, neverNone—Noneis submitted as an explicit null that overwrites the model default (e.g.status/config), andfull_cleanthen rejects the null. Mirror this for any newcrud()input.uv runtool shebangs are stale — run Python tools by module:uv run python -m pytest,uv run python -m mypy angee addons,uv run python -m ruff check .. Bareuv run pytest/mypyfail to spawn.- Regenerate the SDL after
angee build— re-runmanage.py schema(+--check). A missingruntime/schemas/*.graphqlmakes Vite ENOENT and the SPA silently fails to mount (every e2e fails at list load) while:5173still returns 200; checkruntime/schemas/before chasing app/test regressions. (The dev server regenerates it for you — see therunserverpitfall — but a manualangee buildoutsideangee devstill needs the explicitschemastep.) - Agent runtime auth is a
(runtime × provider × credential-kind)fact, not provider-only. TheAgentRuntimean agent'sruntime_classselects (angee.agents.runtimes) owns how a credential becomes container env and the synced secret payload (auth_env/auth_secret_value) — the same Anthropic OAuth token feeds Claude Code'sCLAUDE_CODE_OAUTH_TOKENbut OpenCode reads onlyANTHROPIC_API_KEY. The inference backend stays the owner of vendor-native primitives (api_key_env, the credential value). A runtime that cannot consume a credential kind refuses it in the readiness gate, never rendering a service that silently degrades to a fallback model. OpenCode + Personal-Plans OAuth is off by default (ANGEE_OPENCODE_OAUTH_ENABLED): it needs a community auth plugin baked into the opencode image (theOPENCODE_ANTHROPIC_AUTH_PLUGINbuild arg) and using a Pro/Max token there violates Anthropic's ToS — enabling it without the plugin silently drops Anthropic from OpenCode's model list. angee devserves via Angee'srunserveroverride, notuvicorn --reload.angee.composeships arunserverthat runsASGI_APPLICATIONunder uvicorn supervised by Django's follow-imports autoreloader (mirrors Daphne's override). It needs no--reload-dir: Django watches imported source — consumer/base addons, framework core, and editable deps — and never the generatedruntime/(each child re-emits before its reloader snapshots), so a model edit reloads once. Don't reintroduceuvicorn --reload/--reload-dirheuristics in the stack template. The boot regenerates the SDL whenANGEE_DEV_SDL=1(set only by that command), so a live edit refreshesruntime/schemas/*.graphqland Vite HMRs;schema --checkstays a real drift gate because management commands never importangee.asgi. Generated files (runtime models + SDL) are written atomically viaangee.fs.write_atomic. The override also hard-exits the autoreloader child on reload: open uvicorn/channels WebSocket work can leave non-daemon runtime threads alive, so Django's defaultsys.exit(3)can wedge the child on a dead listener. Installpywatchmanfor event-based (vs 1s-poll) reload.- Each running stack needs a unique compose project name and edge port. The stack
name:becomes the docker-compose project name, and the agent chat WebSocket the browser opens rides the stack'singress.port(the leasededge_port). Two stacks sharing aname:— e.g. every dev workspace defaulting tonotes-angee— make Compose merge their containers into one project: one stack's agent ends up fronted by another stack's edge (or none), and the chat socket 1006s ("no response from the edge"). The dev workspace template scopes both per workspace (project_name: "${inputs.example}-${workspace.name}"and a leasedoperator.port_pool.edge); keepname:/edge_portworkspace-unique when adding a stack or service template. makemigrationsmust name every changed app — includeresources(andbase) orresources loadfails withno such table: resources_resource.- A resource yaml loads only when listed in the addon's
addon.toml[resources]manifest ({tier = [paths]}); an unlisted file silently loads nothing. - Give a model an opaque public id by mixing in
SqidMixinand declaringsqid_prefix = "abc_"— the one fact that varies per model. The sharedangee.base.fields.SqidFieldreads that prefix incontribute_to_class; don't re-declare the column. The field is NULL-safe by design, because a sqid can be selected through a nullable join wheredjango_sqids.SqidsFieldcrashes on a NULL (REBAC// rebac:field=arrows run over nullable FKs). - A status field is read/write-asymmetric — GraphQL serializes it on read as the uppercase enum NAME (
ACTIVE) but the writablePatch.statusStringtakes the lowercase model value ("disabled"). - Intersect write-only fields out of the read/return selection — a field absent from the SDL read type (e.g.
password) makes the detail query invalid and the form loads blank if it is selected. - Validation surfaces two ways — Django
ValidationErrorflows throughextensions.validationErrors(camelCased), but GraphQL input-coercion errors fire before resolvers and never reach it, so guard required inputs client-side fromrootFields.requiredCreateFields. - In test-client logins pass the backend —
force_login(user, backend="django.contrib.auth.backends.ModelBackend"); the default pinsRebacBackend, whoseget_userfails outside actor scope and yieldsAnonymousUser. - After adding or moving an addon run
pnpm install, and delete any stale gitignoredruntime/*/migrations/*.pythat imports a moved module beforemakemigrations. - OAuth/OIDC outbound requests must send an honest, non-browser User-Agent. Anthropic's token-endpoint edge 429s spoofed browser/curl User-Agents with a
rate_limit_error(before any auth check) and 403s urllib'sPython-urllibdefault; an honest client UA passes.angee.integrate.oauth.clientowns the value (_USER_AGENT); never reintroduce a browser spoof or fall back to urllib's default. - Anthropic's JSON OAuth token exchange must echo redirect
state. Standard OAuth validates state before the token POST and does not send it, but Anthropic's public-client JSON token endpoint rejects that request as malformed without the state field. Keep the exception insideangee.integrate.oauth.client's JSON shim; do not move it to the frontend, generated callback route, or generic form-token path. - TLS trust is an environment concern, not a per-call one. Which CA roots we trust is owned by the runtime, set once — never threaded as an
ssl_contextthrough each outbound HTTPS call. Outbound code uses the stdlib default context (ssl.create_default_context()), which OpenSSL resolves against the system trust store and honoursSSL_CERT_FILE/SSL_CERT_DIR. A dev mac trusts via Homebrewca-certificates; an environment that lacks a CA store (a minimal container, a bare CI/agent sandbox) is fixed there — install OSca-certificates, orexport SSL_CERT_FILE="$(python -m certifi)"at bootstrap — not by addingcertifiplumbing to call sites. Backend outbound HTTP has one owner already:angee.integrate.http.HttpClient(self.http), which builds the one context; route new outbound calls through it rather than hand-rollingurlopen+ context. - An
ImplClassFieldbuilds its enum at model-import time from itsregistry_setting— the key→path mapping (e.g.ANGEE_STORAGE_BACKEND_CLASSES) is supplied by the owning addon'sautoconfig, so every settings module that installs the addon must carry a non-empty mapping, including a bare module that skips the composer (tests/settings.pydeclares storage, integration, VCS, inference, and OAuth provider registries explicitly). An empty registry raisesImproperlyConfiguredat import — give the addon a noop/null-object default so the set is never empty. The column stores the key (local), never a dotted path. - Never name an addon module after a third-party top-level package it imports.
unittestdiscovery inserts the discovery-root directory ontosys.path, so an addon'smcp.pythat doesfrom mcp.server… import …becomes an importable top-levelmcpthat shadows the real package —ModuleNotFoundError: 'mcp' is not a packageduring a test run, while a single-module run andmanage.py checkpass. Name such a module for its role, not the library (the MCP tool seam infers — or resolves a[mcp].toolsoverride to —mcp_tools.py, notmcp.py).
Framework Contracts
Framework contracts should be self-explaining in code. Add docstrings to public modules, classes, methods, functions, declarative manifest attributes, and public module-level constants. Add docstrings to private helpers when their role is not obvious from the function name and signature. Do not maintain a parallel spec, field inventory, or model API list for behavior that can live clearly beside the code.
The addon's addon.toml is the declarative manifest (its contract owner is angee.addons.AddonContract); when an addon carries a Python seam, its AppConfig owns addon-local interpretation. Use Django's own facts before adding an Angee fact: the addon root is AppConfig.path, source models live in models.py, and GraphQL contributions live in schema.py. Put validation, normalization, and path resolution for one addon on the object that owns the data — its AppConfig (the ready() / import_models() seam is the reason an addon adds an apps.py), a model/manager, or a runtime build object for composition — not on loose functions; keep a function loose only for orchestration no single object owns. Put the manifest keys and their exact authoring forms in the AddonContract docstring, not in this guideline.
Before decomposing backend code, classify each fact by its Django owner:
- Persisted choices live beside the model field, usually as model-owned
TextChoices. - Row-set behavior lives on managers and querysets.
- Instance behavior lives on model methods and properties.
- Addon declaration and path-resolution behavior lives on
AppConfig. - Management commands parse arguments and dispatch to the owning model, manager, service, or composer function.
- Compatibility facades exist only for an explicit compatibility promise.
The project settings contract declares project facts; Angee owns Django composition wiring. By default, keep settings.yaml beside manage.py and set only the deliberate composition facts there, especially INSTALLED_APPS and ANGEE_ADDON_DIRS / ANGEE_RUNTIME_DIR. ANGEE_PROJECT_SETTINGS may point at a project Python settings module when the project needs one. angee.compose.settings loads Python settings first, overlays settings.yaml with django-yamlconf, evaluates angee.compose.defaults as the base Django settings module, and asks Composer(globals()).compose_settings() to compose INSTALLED_APPS, MIGRATION_MODULES, import paths, and addon autoconfig. Addon autoconfig uses yamlconf-style SETTINGS keys: plain keys are defaults, :append / :prepend keys always merge, dotted keys update nested dictionaries, :raw protects literal braces, and declared ANGEE_* addon settings may be overlaid by same-named process environment values from the stack. Use settings.py only when the project truly needs Python-computed settings. Angee treats yamlconf errors as Django configuration failures and rejects implicit ancestor settings.yaml files; only the project file and an explicit YAMLCONF_CONFFILE may contribute file-backed settings. Generic typed yamlconf environment overrides still require :jsonenv. Anchor project defaults to BASE_DIR, never to the current working directory.
Keep angee as a namespace package. Do not add an __init__.py at either namespace root (angee/ for the framework, addons/angee/ for the base addons); split addon distributions must be able to contribute packages under the shared angee.* namespace.
Avoid __all__ unless a module has a concrete star-import or compatibility requirement. Public API should usually be obvious from module names, object names, and docstrings.
Naming
Naming is structural: Django and the composer both locate code by name, so a wrong name is a broken contract, not a style nit. Django is the reference — match it exactly.
- Modules are lowercase, single-word, named by role:
models.py,managers.py,admin.py,forms.py,urls.py,apps.py,signals.py,mixins.py,validators.py,fields.py,backends.py. - Structural directories are fixed and discovered by name — never rename them:
migrations/,management/commands/,templatetags/,templates/,backends/. - Packages / addons are short and lowercase — no CamelCase, no stray underscores (
auth,contenttypes,storage) — and match the addon label. - Classes are PascalCase with a role suffix that mirrors the module:
*Field,*Mixin,*Manager,*QuerySet,*Form,*Admin, and*Configfor theAppConfig. - Methods / functions are snake_case and verb-first from a stable vocabulary:
get_*(accessors),is_*/has_*(booleans),as_*/to_*/from_*(conversions),create_*/save_*/delete_*(mutations);_leading_underscorefor internal. Settings and constants areUPPER_SNAKE. - camelCase only when extending an external API that uses it (e.g. Django's
unittestassertions). Otherwise never.
Checks
Run the narrowest relevant check while editing, then the broad check before handoff:
Before adding a backend abstraction, search for the native owner first: rg "AppConfig|schemas|permissions|resources|autoconfig", rg "QuerySet|Manager.from_queryset", and rg "apps.get_model|get_app_configs". If the change introduces or extends a seam, add a focused guard in the owning test area: layering in tests/test_layering.py, addon/AppConfig contracts in app tests, settings/autoconfig/app graph behavior in tests/test_settings.py, runtime emission in tests/test_compose.py, and schema composition in GraphQL tests.
uv run python -m ruff check . --no-cache
uv run python -m mypy angee addons
uv run python -m vulture
uv run python -m pytest
uv run examples/notes-angee/manage.py angee build --checkUse the python -m module form (see Pitfalls: bare uv run pytest/mypy fail to spawn on this repo's venv). If a command is not wired yet, say so plainly.