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()); a model-less addon may also keep one so its AppConfig docstring is the addon's contract home (an incubated addon declaring a face it owns but has not built). 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 wheel and the GraphQL folder addon have a one-way dependency rule that layering tests enforce:
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 theaddons/angee/graphqlfolder addon's 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 defaults own the ordered always-on core prefix (django_yamlconf,angee.compose, contenttypes, REBAC, reversion, simple-history,angee.base,angee.jobs); folder addons such asangee.graphqlexpand from the project's declared roots. 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.
- External side effects and DB reflection are separate phases. File edits, daemon calls, network calls, and other non-DB effects never run inside
transaction.atomic; the following DB mutation path names its transaction owner andsystem_contextreason. Platform install, agent provisioning, OAuth flows, and resources loading all follow this two-phase shape. - 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. - A marked catalogue model belongs to exactly one resource tier for all seeders; the resources loader enforces each manifest tier against the model's declared
catalogue_tier. - 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,hasura_model_resource,changes, aggregate builders) instead of reimplementing ORM, permission, or serialization behavior. - Model-backed
hasura_model_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, all narrow: a dependency that is genuinely optional at runtime (isolate it behind its own module), 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()— and the ASGI application factory's import of Django/Channels serving modules after pytest-django ordjango.setup()owns setup. 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, ASGI setup-order 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. angee.basedeclares model markers, decorators, and field projection facts;angee.composeinterprets them during emission. If runtime code consumes a source-model structural marker, the composer carries it onto the emitted concrete class instead of callers inheriting or probing the abstract source.- Field classes own data-resource classification declarations. Field authors set
angee_widget,angee_scalar_hint, andangee_currency_fieldon the field;angee.data.field_classificationreads those declarations and does not special-case addon-owned field classes. - Manually ordered rows use
FractionalRankField(NOT NULL) plus a databaseUniqueConstraintover their context fields and rank. Use the field's append/between API;FractionalRankExhaustedmeans enqueuejobs.rebalance_fractional_ranks, never guess an epsilon or reuse a rank. Rebalance context keys are model field names, not attnames —{"project": pk}, never{"project_id": pk}. 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.impl.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 (integrate.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). - A hand-written
@strawberry.typeowes the boundary the same enum. The rule above is not about models — it is about the API boundary, so astate: strfield on a plain strawberry type has the identical defect: it crosses as a bareStringand pushes the vocabulary check onto every client at runtime. Declare the closed set as anenum.StrEnumexposed with@strawberry.enumand type the field as it; the member value stays the serialized/stored token and the upper-case member name is the wire value (angee.integrate.live.PairingStateis the reference). Keep the enum next to the vocabulary it names, not inschema.py, when a worker-side owner must return it without importing the console schema. - 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.
- One attribution vocabulary (the layered-principal rule). Any column that answers "who did this" — audit stamps, history users, revision authors — is an FK to
AUTH_USER_MODEL, never a species-specific FK and never a string subject column. At the database layer,user = service account = actor = principal: one table represents every principal, person or service (kindlives on the row). Above that layer the words diverge on purpose — the REBAC actor keeps its species (agents/agentis never collapsed into a user for permission evaluation); attribution converges throughactor_user_idand the subject-type resolver registry. See the glossary's Principal/Actor/Service account entries. - Visibility and access are REBAC-native, always. Put relations and permission arms on the model's zed and let the store scope reads; never stand authorization up with a Python provider,
visible_toprojection, or queryset filter. Scope roles live on the scope definition, and scoped models derive arms from them (scope->viewerfor read,scope->editorfor write). - A
read__<field>-gated field is never filterable, sortable, groupable, or aggregatable. - Declared record-share delegates enforce their mapped permission: messaging's
grant_reader/revoke_readernow requireshare, unlike their former ungated tuple writes. - Posture is data, not schema.
permissions.extends.zedfragments are additive-only, so narrowable defaults ship as seeded tuples (the shared wildcard pattern). Platform-wide tuple-driven visibility uses a const-backed singleton relation on each row (for example,auth/user#directory→iam/directory:main) plus a seed on that singleton (iam/directory:main#reader), never base schema arms or per-row fan-out a deployment cannot omit. - 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. - Const-backing is the one canon for tuple-free role reach. A resource that grants a named role (e.g.
storage_admin,accounting_admin) declares a const-backed relation to the role namespace and arrows througheffective_member:relation manager: storage/role // rebac:const=storage_adminwithpermission … = manager->effective_member(mirror ofadmin->member). Never a pinned-id userset allowed subject (storage/role:storage_admin#effective_member): nobody writes the per-row tuple it needs and the local backend never synthesises one — the subject-set walk scans relations only, so a permission-typed#effective_memberuserset denies. The const target role namespace needs its owndefinition+managed=Falseanchor model (like the resource's const admin), because a non-member check walks the arrow into<ns>/role#admin; without the anchor that const cannot resolve and the evaluator raises instead of returning a clean deny. Bump the package@rebac_schema_revisionwhen migrating a def to the const shape. - A consumer addon contributes domain relations to another addon's definition additively — never by editing the target zed. The owning addon's
permissions.zeddeclares the seam (for example, the in-repo spaces addon extendsmessaging/thread); a consumer addon that needs its own role adds it from its ownpermissions.extends.zed(sibling topermissions.zed), owned byangee.compose.permissions. Thetests/extcontribfixture keeps the mechanical merge covered. Eachdefinition <target> { … }block in the fragment names an existing definition and lists the relations it contributes and the permission arms it unions in (permission read = <term>merges toread = (<base>) + (<term>)). The composer merges every fragment into its target's owning package at build time, emits the merged effective zed toruntime/permissions/<package>.zed, and repoints that package'sAppConfig.rebac_schemaat it, sorebac sync/rebac check/reconcile_permissionsall read the additive superset with no library change. The merge fails fast on a relation-name collision (base or two contributors), an arm whose permission the base does not declare, and a target no installed package declares; contributors merge in sorted package order. Functional drift is caught byrebac sync(content hash) andangee build --check(the emitted file); the contribution is revisioned by the contributing addon (@rebac_schema_revisionin its fragment, echoed into the merged file's@rebac_extended_by), so the base addon does not bump its revision for an additive extension. Editing a framework/base-addonpermissions.zedto name a domain role (accountant,salesperson, …) is a bug — the vocabulary belongs in the consumer addon that owns the concern. - 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. - The relationship store has two storage modes: composed projects run the FK-backed
registrymode (angee.baseautoconfig) while baretests/settings.pyruns the library'sdenormalizeddefault — a registry-only break therefore passes unit tests and fails live. Since django-zed-rebac 0.14 the registry queryset storage-translates the whole read API —filter/exclude/getkwargs,Qobjects, andvalues/values_list/order_by/annotatefield names — so query with the natural denormalized names; instance attributes (row.subject_id) are portable too (the registry manager eager-joins them). Any read shape beyond that API must be verified in both modes — regression-test permission-hub surfaces underoverride_settings(REBAC_LOCAL_BACKEND_STORAGE="registry"). - 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
- Never mix
select()with bufferedreadline()on a subprocess pipe. A buffered wrapper may consume several complete records while the file descriptor becomes non-readable, stranding those records behind the readiness check; it can also block mid-line past the stop cadence. Read raw bytes withos.read()into a manual newline accumulator and drain complete buffered lines before the next readiness wait.
Hard-won traps — the wise learn from others' mistakes (docs/guidelines.md).
.values_list(...).distinct()must clear the model's default ordering.Meta.orderingcolumns silently join the DISTINCT projection, so a single-columnvalues_list("owner_id").distinct()returns one row per source row, not per owner — a loop over it repeats its whole body once per row (a 4-hour beat tick that should take a second, live-measured). Append.order_by()(or order only by the selected columns) before.distinct()on every values/values_list distinct read.A long-lived Celery task needs the three-check wake loop. A session task that outlives the tick (a live chat connection) runs on a dedicated queue's threads-pool worker — the threads pool enforces no time limits, so queue isolation is the protection and
time_limit=Noneon the task is only defense-in-depth. Its loop must wake on a bound (shorter than the reconciler tick) and check: (1) the persisted desired-state, so a cooperative stop never waits on an idle socket; (2) a process-localworker_shutting_downevent, or a warm SIGTERM wedges behind the pool's blocking join until SIGKILL; (3) that it still holds its advisory lock — Postgres advisory locks are connection-scoped, and a DB reconnect drops the lock under a live process, so the holder must exit for a clean reconciler restart instead of racing a duplicate against shared state. The reconciler enqueues withexpires=of one tick so a saturated or absent worker never accumulates a backlog (angee.integrate.session+angee.integrate.tasksare the references).An asyncio vendor SDK owns its loop on the live session's connection thread. Create and run that loop where the vendor connects; the task thread remains the only persistence owner. When task-thread work must call an async vendor hook (for example, downloading media during ingest), schedule the coroutine with
asyncio.run_coroutine_threadsafeonto that owning loop and wait with a finite timeout. Never create a second loop around a coroutine bound to the live client.A gate whose assignee is the run owner must not also set that owner as requester. The decision
actpermission is(assignee − requester) + admin(separation of duties), sorequester == assigneelocks the owner out of their own decision. Leaverequesterunset when the assignee defaults to the run creator.Run every changed test module standalone. A full suite's file order can leak concrete test models into the shared registry and mask a missing registration; a broad run does not replace the direct module run.
Seeded rows selected by clients carry a resource-assigned stable key. Select them by that stable key, never by a mutable display name.
GraphQL authorization tests include a non-admin reader. Admin-only tests neither pin deny-hard-fail behavior nor expose a leaked
sudo()scope.Foreign write paths defer parties bookkeeping until commit and contain its failures. Follow the OIDC/ingest precedent: schedule the parties-owned work with
transaction.on_commit, catch and log callback failures, and let the already-successful foreign write continue.Polymorphic edges write at the canonical MTI level. Route their targets through
angee.base.canonical_record_target; composeThreadedModelMixinand reverseGenericRelations on that same canonical ancestor.Derived columns have two drift classes and two owners. Signals own instance saves/deletes, cascades, and queryset deletes; idempotent repair passes own
bulk_createand querysetupdatepaths, where signals do not run.ScoredLinkMixinis the scored-suggestion shape, not a permission owner. A subclass that needs REBAC side effects overrides the transition; never add REBAC writes to the shared mixin.Upgrading this refactor is an operator-run reconciliation. Downstream consumers need repoint/merge data migrations for tags and thread-attachment child-content-type edges, then one
manage.py reconcile_permissionsrun for thesocial/*toposts/*zed rename; do not hide either step in startup.State columns are
StateField; guarded changes go through transition methods, never direct assignment.A lifecycle column is declared intent, never proof of achievement.
integrate.Integration.lifecyclerecords what the operator asked for; how far a runtime handshake actually got belongs onruntime_status/sync_progress. Overloading one lifecycle value with "not finished yet" — a WhatsApp channel that stayeddisconnecteduntil its worker proved a JID — makes that value unusable as a stop signal, because a guard cannot tell "the operator released this" from "still connecting". So a worker never writes the lifecycle back: a logout or a rejected account is an outcome, not a request, and recording it as one reverts the operator within a tick. It reports the failure on the runtime axis and stops itself through the desired-state it also owns.An Integration child's second intent axis is the reconciler's to close. A child declaring no
sqid_prefixof its own is anint_…row, so the generic lifecycle actions (integrate/schema.py,IntegrationActionMutation) move it without the owning addon in the call path. They can only know the one axis every Integration has, so any second axis a child adds — a live desired-state, a subscription — is unset on rows that arrive that way. Two independently writable intent axes need a declared precedence and one owner that reconciles the other: let the child's reconciler select on the lifecycle and drive its own axis from it, rather than select on its own axis and trust something else to have set it. A child's runtime guards must equally honour the lifecycle itself — test the one state that runs, not the one state you happen to stop on.angee.integrate.tasksis the reference:ensure_bridge_sessionsselects CONNECTED and reconciles the live desire to it, and gates onruntime_statusso a known-broken handshake is not redispatched forever. Reconcile only when the axes disagree: the write has no dirty check and publishes a subscription event, so an unconditional one broadcasts a no-op edit per row per tick.A latching gate needs a reset the operator's verb owns. A reconciler that skips rows on
runtime_status=ERRORdisables itself until something clears the error — so the repair verb must clear it itself, not rely on a lifecycle edge doing it as a side effect.set_lifecyclereturns early when the row already reads the target, so a CONNECTED+ERROR row repaired by a verb that only declares CONNECTED clears nothing and gets exactly the one dispatch the verb enqueues directly; lose that (worker down, queue saturated, a restart) and the row is skipped forever.resume_channel_pairingreports OK unconditionally for this reason. Keep the gate on the sharedruntime_statusrather than a private health key: a private one is a further axis the generic verbs cannot clear, so it reintroduces the same latch on the generic path.A method on
Integrationcannot be overridden by an Integration child. The composer emits a child asclass Child(Integration, AbstractChild), so the parent's abstract source precedes the child's own source in the MRO and shadows it —angee.integrate.models.Integrationwins overangee.messaging.models.Channel. A seam a child must override therefore belongs on a base that follows the child source (Bridgeownsstart_live/stop_live/_next_sync_atfor exactly this reason), never onIntegration. A parent verb that needs child behaviour has to compose instead: reach the concrete row by the primary key it shares (sync_integrationis the precedent) — and note that walkingbridge_modelsfans a query across every installed bridge's table, so it is not free.hasura_model_resourcecreatefull_cleans the input, so model + input defaults must agree. The Hasura model-resource create path 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 newhasura_model_resourceinput.A
strawberry_django.field(only=[...])hint must list every column the resolver dereferences. Include columns read by shared properties the resolver delegates to; otherwise selecting that field alone can defer-load the missing column per row.A structural marker consumed after runtime emission must be emitted too. A non-inherited
__dict__source-model marker stops at the abstract source unless the composer carries it into the concrete runtime class body.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.Celery periodic tasks accept
timestampwhen a scheduler supplies one. Static Celery beat ticks call without it, but tests and future scheduler backends may inject a Unix timestamp. Keep wrappers tolerant of both shapes.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.)Moving a custom field between modules changes its migration
deconstruct()path. Reconcile every on-disk migration in the same change: source migrations get the schema-identical dotted-path edit; generated runtime migrations are regenerated from source, and downstream consumers must regenerate their own runtime output.Explicit delete preflight plus elevated destructive work must test both branches. Storage's soft-delete path and messaging's threaded-record delete path check the public
deletepermission themselves, then run the owned destructive work undersystem_context; the library's denial-audit signal is skipped on the explicit deny branch by design, so add a deny-path regression whenever you use this shape. Do not hide independently-authorizedon_delete=CASCADEchildren under an elevated parent cascade; dependent rows must derive delete through the parent in their own zed relation, like the workflows Step/Edge pattern.Instance
save()/delete()overrides do not run on cascade or bulk queryset paths. Lifecycle side effects that must survive those paths belong on Django signals; Agent's service-user deactivation is apost_deletereceiver for this reason.Regenerating the example's runtime migrations orphans existing dev databases. The example's
runtime/(migrations included) is deliberately untracked and greenfield: a branch that regenerates its migrations produces a fresh file set whose names/numbering no longer match a live dev DB's applied history, and Django then re-applies schema that already exists (duplicate column). The remedy is a dev-DB reset (.angee/data/db.sqlite3— back it up first), not surgical--fakerepair. Production consumers commit their runtime migrations and never hit this.Data migrations access REBAC-scoped models through
_base_manager, and backfills need a rows-present proof. A manager withuse_in_migrations = True(iam'sUserManager, inherited from Django's) rides into the historical model, soobjectsinside aRunPythonis REBAC-scoped and raisesMissingActorErrorunder strict mode — a migration is a system operation; usemodel._base_manager.using(db). And a fresh-DBmigratenever executes a row-dependent backfill body: prove backfills against a database that has rows (the agents service-user backfill failed only on live dev DBs for this reason).Addon-owned runtime migrations are append-only, self-contained history. Put source modules in
runtime_migrations/, not Django's conventionalmigrations/package, and declare them through ordered[[migrations]]inaddon.toml. Their pureapplies(ProjectState)guard must select the exact old state, skip the complete new or absent state, and fail on recognized partial states. Copy-localRunPythonfunctions must use historical models fromappsand_base_manager, and clear the default ordering with.order_by()before a queryset write —Meta.orderingmay name live-model alias fields (sqid) that historical models cannot resolve; never import current models. Once an origin has materialized downstream, never edit its source or copied runtime file — ship a new named declaration. Explicitangee buildis the only writer; normal boot remains migration-write-free. "Never edit" includes mechanical reformatting: the composer pins each source's sha256 at materialization and refuses drift at the next build, so formatters/linters must exclude**/runtime_migrations(the addon repos' ruff configs do).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: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.Demo resource tiers are additive across installed addons. A deployment that needs demo rows but not one permissive seed must exclude that exact entry with
ANGEE_RESOURCE_EXCLUDED_ENTRIES, keyed asaddon.name:resources/path.yaml; do not edit the addon seed or fork the loader.Workflow step implementations persist continuation state in
resume_state. Pre-suspend side effects must be idempotent because resume replays from the journal row, not process memory.Workflow joins count rows, not broker messages.
join_ruleis evaluated over siblingStepRunrows.Never trust a workflow step to self-limit. The engine owns
max_stepsand budget enforcement.Invalid decision resolution re-opens the decision. It increments the attempt audit and leaves journal history immutable.
zed exclusion binds loosest. Parenthesize
(a - b) + cwhen combining exclusion with union.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"). This holds inside F6 nested line inputs too: a child enum/choices column is aStringon the line insert input (write the lowercase value), while the child node projects it as an enum (read UPPERCASE); an M2M child column is[ID](public sqids in and out).F6 line-cell metadata is projected from the child node surface, not the bare model —
HasuraLines(node=…)'s child fields reconstruct throughresource_fields(node, model)(the same classifier the parent uses), because the node owns a choices column's wire enum values and an M2M'skind:"list"relation target. A writable child column the node does not expose falls back to the model reconstruction, which still cannot carry enum/list — so expose any enum/M2M line cell on the child node.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.Server-owned fields are excluded from the write surface, never merely
readOnlyin a form. A column the server owns (audit, derived, or default-only) must be left out of the resource'sinsertable/writableset so it never enters the generated input type. Marking the form controlreadOnlyonly hides the widget: the field still rides the input, and the form'sField.defaultValueseeds and submits a value for it, so the client can write a column the server owns. Resource-level exclusion is the one authorization gate;readOnlyis presentation, not authorization.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="angee.iam.auth.ModelBackend"); the default backend order is chosen for runtime authentication concerns and may not be the session reload backend a focused test wants.Login throttling belongs at the IAM auth seam. Do not add per-view or per-test throttles; IAM composes
django-axesat theauthenticate(request=...)backend/signal path, so the password GraphQL mutation stays a thin caller.Row locks must keep the SQLite floor. Wrap
select_for_update()through the owning queryset/manager's feature-gated helper (AngeeQuerySet.lock_if_supported); SQLite is a supported backend and Django 6 silently drops plainFOR UPDATEthere, so the helper is the greppable contract that keeps lock intent explicit and backend-gated.HierarchyMixinpath maintenance andsave_state's transition guard both route their lock through it.Task locks are advisory, row locks are authoritative. Celery task bodies may use
angee.jobs.locks.task_lock()to prevent duplicate workers from doing the same external work, but persisted state transitions still use model/queryset row locks, constraints, and idempotent managers. Do not hold row locks during network IO.A
HierarchyMixinconsumer declares its scope fields — the mixin never probes by column name. A subtree that must stay inside a tenant or other scope declareshierarchy_scope_fields = ("scope",)(aClassVartuple; FKs compare by stored id); the mixin rejects a reparent or create under a parent that differs on any listed field. It is generic and iam-free — there is no scope-field-name fallback, so a scoped tree that omits the declaration silently accepts a parent outside its scope.StateFieldtransitions guarded bysave_stateget an optimistic-concurrency guard for free: the committed source is re-read under the same lock before the write, so a lost race raisesTransitionNotAllowedinstead of double-applying (e.g. double-posting a ledger).Django 6 refreshes
F()/expression fields back onto the instance viaUPDATE ... RETURNINGbeforepost_save. Asave(update_fields=…)whose fields hold expressions (F("count") + 1,Greatest(…)) leaves the instance carrying the DB-true resolved values, not the expression objects — thepost_savereceiver (and anychangespublisher) sees the true row. Never "restore" a locally recomputed value (prior + 1) after such a save: it stomps the RETURNING value and undercounts whenever a concurrent write advanced the column further.A gated factory that uses
sudo()must restore the actor before returning. Elevated writes may be necessary to create the row, but callers continue under the original actor. Capturecurrent_actor()before the elevated block and rebind the returned instance with.with_actor(actor)after save.Publishers wire during
angee.graphqlappready(), not schema build or schema import. GraphQL schema modules declare subscription surfaces;GraphQLSchemasconnects publishers from declaredchangesmetadata after app population, so building a schema no longer mutates process-global signal state.Change events read through the row unless the model declares another read anchor. A target-derived child or polymorphic edge may implement
change_read_resource()and return theObjectRefwhosereadpermission governs the event. The publisher captures that anchor before deletion, so create, update, and delete all use the same authorization boundary.Workflow event triggers consume the declared change feed. A trigger's target model must declare
changes(); otherwise validation tells the addon to declarechanges()for the model to join the change feed.AngeeModelmanagers/querysets must keep the canon. If a model customizesobjects, its queryset class must derive fromAngeeQuerySet; otherwise shared methods such as public-id lookup, actor scoping, and elevated reads drift between models.MIGRATION_MODULESmay be assigned during app populate only for generated runtime apps. That exception belongs to composed settings/runtime boot; do not use it as an addon-local shortcut or a way to hide source-model migration state.EncryptedFieldkeys are bound tomodel._meta.label_lowerplus field name. Renaming a model/app/field changes the derived key. PlanANGEE_FERNET_KEYS/MultiFernetrotation before such a rename, and treat one corrupt row as a row-local unreadable value, not as a reason to break list queries.Data-resource field widgets are backend-owned vocabulary. Add or rename widget keys in
angee.data.field_classificationwith the matching frontend renderer; resource callers declare fields, not ad hoc widget strings.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.Implementation subclasses must replace every inherited semantic default that changes. See
ImplBase.effective_defaults()for the merge contract. An OpenAI-compatible backend that omits its ownnameandvendorsilently creates an OpenAI provider row.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 feeds yamlconf only the project root's own settings.yaml plus an explicit YAMLCONF_CONFFILE, so ancestor settings.yaml files never contribute (a project nested under another Angee stack root boots on its own settings, not the enclosing stack's). 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
(cd "$angee_root" && uv run manage.py angee build --check) # against the stack hostUse 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.