Skip to content

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.base is the model foundation (models, fields, mixins, managers, querysets, and model emission declarations). It must not import angee.compose, angee.graphql, or addon packages.
  • angee.graphql is the addons/angee/graphql folder addon's GraphQL runtime (schema assembly, Strawberry helpers, serving, subscriptions, and SDL commands). It may import angee.base, never angee.compose.
  • angee.compose is the build-time composer. It may import angee.base and discover plain Django addon configs, but no serving module (asgi, urls, views, consumers, signals, models, graphql) may import angee.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.compose just 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 no AppConfig flag and no Angee base config to subclass. An addon needs an apps.py only to run a Python seam (ready() / import_models()); otherwise Django's auto-created AppConfig is enough. The declarative contract — depends_on (the ordering contract) plus the contribution seams — lives in addon.toml and is read through angee.addons.addon_contract.
  • The contribution seams default to what the addon directory reveals; an explicit manifest entry only overrides that default. schema.py (defining schemas) → the GraphQL bucket, permissions.zed → the REBAC contribution, web/package.json → the web package (its name), mcp_tools.py (defining register) → 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: graphql reads contract.schemas, resources reads the [resources] tiers, the web projector reads [web].package / [web].codegen, the MCP server reads [mcp].tools, REBAC sync discovers an adjacent permissions.zed by convention, stable serving imports conventional urls.py / asgi.py, runtime emission reads model-level runtime = True, and settings composition reads the addon's optional autoconfig.py.
  • There is a single app set and a single boot. DJANGO_SETTINGS_MODULE points at angee.compose.settings, which imports the project's settings contract (settings.yaml or settings.py beside manage.py). YAML projects declare INSTALLED_APPS and ANGEE_RUNTIME_DIR; Python projects may declare those same facts directly. angee.compose.settings loads the project contract and calls Composer(globals()).compose_settings(), which expands the addon dependency closure and sorts the resulting app set, then gives Django the resolved AppConfig instances in INSTALLED_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 as angee.graphql expand 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 build and angee clean may 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.Resource as a normal addon source model and emits it under the resources label. angee.base must not import angee.resources.
  • Refer to an emitted concrete model through the app registry (apps.get_model("resources", "Resource")), never by importing the generated runtime/ tree.

Rules

  • Domain behavior lives on models, managers, and querysets.
  • Manager/QuerySet canon: chainable read scopes live on a *QuerySet exposed through Manager.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 and system_context reason. 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 generated runtime/ 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. Use AngeeDataModel/SqidMixin for 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_value instead of branching on field type from outside; ask model._meta (get_field, label_lower) and Field.value_from_object rather than re-decoding model shape; surface query behavior through Manager.from_queryset; and give objects classmethod factories and deconstruct-style methods to construct and serialize themselves. This is the backend application of Find the owner in AGENTS.md and the Django-Native Rule above.
  • Compose behavior onto the class that owns the data. Settings construction belongs on Composer; runtime model materialization belongs on Runtime. 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 AppConfig module 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 after ready() — and the ASGI application factory's import of Django/Channels serving modules after pytest-django or django.setup() owns setup. Mark such a deferral with a comment naming the reason; everywhere else, hoist. Within Angee's own source (angee/ and addons/angee/) these are the only function-local imports allowed — phase-1 deferrals, ASGI setup-order deferrals, and TYPE_CHECKING blocks. Probe optional or generated modules with importlib.util.find_spec (verifying each parent first) rather than try/except ImportError, so an absent generated runtime/ 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__.py whose 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 angee namespace.
  • Source models are abstract. Concrete apps are emitted by the composer.
  • Keep Django Meta for Django and library-owned options such as rebac_resource_type; Angee extension facts live on the owning model class.
  • angee.base declares model markers, decorators, and field projection facts; angee.compose interprets 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, and angee_currency_field on the field; angee.data.field_classification reads those declarations and does not special-case addon-owned field classes.
  • Manually ordered rows use FractionalRankField (NOT NULL) plus a database UniqueConstraint over their context fields and rank. Use the field's append/between API; FractionalRankExhausted means enqueue jobs.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 declare permissions.zed beside the owning app. Permission sync is the library's own manage.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-style ImplClassField only 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 conceptDjango 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 rowmodel extends. The base row remains the same domain object; extension fields are additive and may be blank/off. OIDC login fields on integrate.OAuthClient are the canonical shape.
    • Only behaviour differs, open set (addons contribute impls) while persisted fields stay the sameone concrete model + angee.base.impl.ImplClassField naming 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, never CharField(choices=…).StateField wraps django-choices-field's TextChoicesField, so strawberry-django renders a native GraphQL enum straight from the choices_enum. A plain CharField with choices renders as a bare String and silently drops the enum at the API boundary — never use it for an enumerated value. StateField is 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 a PartyPerson/Organization split has no kind column. Reach for a child model, not a StateField, when the kinds carry their own fields (e.g. a Person linking to an iam.User that an Organization never has).
  • A hand-written @strawberry.type owes the boundary the same enum. The rule above is not about models — it is about the API boundary, so a state: str field on a plain strawberry type has the identical defect: it crosses as a bare String and pushes the vocabulary check onto every client at runtime. Declare the closed set as an enum.StrEnum exposed with @strawberry.enum and 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.PairingState is the reference). Keep the enum next to the vocabulary it names, not in schema.py, when a worker-side owner must return it without importing the console schema.
  • Integration implementations are concrete integration children. The top-level integrate.Integration row 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 own backend_class when several SDK/protocol adapters share that child's persisted shape. Do not store a second generic impl_class on 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 through autoconfig (a yamlconf dotted key, "ANGEE_…_CLASSES.<key>": "<dotted.path>"). So a writable column never feeds import_string (the path comes from composed, trusted settings, like an addon's schemas reference), the available impls are a composition fact rather than a base-model import, a project can remap a key to its own class, and manage.py check validates every configured path imports and subclasses base_class. Because every addon has contributed by schema-build time the key set is closed, so the field is a TextChoicesField and strawberry-django renders the GraphQL enum natively (like StateField). It therefore requires a non-empty registry: an addon whose impl set could otherwise be empty registers a noop/null-object default (storage's local; integrate's none VCS 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 schemas mapping in conventional schema.py modules. Each named schema contributes into fixed buckets (query, mutation, subscription, types, extensions, type_extensions, input_extensions); Angee merges buckets across addons and builds one Strawberry Schema per 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), not from 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 an ImplClassField registry (settings-keyed, one impl class per key — use it only when each key has genuinely distinct implementation code, not as a workaround for a closed TextChoices); add a field onto another addon's GraphQL type with native strawberry_django.type(RuntimeModel, name="UpstreamType", extend=True), listed in the type_extensions bucket — Strawberry owns the extension merge and strawberry-django resolves any relation projection from its model registry (e.g. iam_integrate_oidc adds fields to OAuthClientType without integrate importing it); add fields onto another addon's handwritten GraphQL input with native strawberry.input(name="UpstreamInput", extend=True) listed in input_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 model extends: 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 (kind lives on the row). Above that layer the words diverge on purpose — the REBAC actor keeps its species (agents/agent is never collapsed into a user for permission evaluation); attribution converges through actor_user_id and 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_to projection, or queryset filter. Scope roles live on the scope definition, and scoped models derive arms from them (scope->viewer for read, scope->editor for 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_reader now require share, unlike their former ungated tuple writes.
  • Posture is data, not schema. permissions.extends.zed fragments 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#directoryiam/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_context and resolve the actor with @rebac_subject; a bare Model.objects.create() under an actor is denied.
  • A per-row create permission cannot gate an insert (the unsaved row has no id → deny). Gate explicitly with a preflight (has_access("write") / rebac.check_new), then insert via row.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 in angee/role:admin. Admin-gate a table-less/synthetic resource with a managed=False abstract anchor model (passes rebac.E009, emits no table) plus that const admin, and keep an | angee/role:admin#member arm in member or rebac.W004 fires.
  • 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 through effective_member: relation manager: storage/role // rebac:const=storage_admin with permission … = manager->effective_member (mirror of admin->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_member userset denies. The const target role namespace needs its own definition + managed=False anchor 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_revision when 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.zed declares the seam (for example, the in-repo spaces addon extends messaging/thread); a consumer addon that needs its own role adds it from its own permissions.extends.zed (sibling to permissions.zed), owned by angee.compose.permissions. The tests/extcontrib fixture keeps the mechanical merge covered. Each definition <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 to read = (<base>) + (<term>)). The composer merges every fragment into its target's owning package at build time, emits the merged effective zed to runtime/permissions/<package>.zed, and repoints that package's AppConfig.rebac_schema at it, so rebac sync / rebac check / reconcile_permissions all 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 by rebac sync (content hash) and angee build --check (the emitted file); the contribution is revisioned by the contributing addon (@rebac_schema_revision in 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-addon permissions.zed to name a domain role (accountant, salesperson, …) is a bug — the vocabulary belongs in the consumer addon that owns the concern.
  • There is no rebac_roles command — grant roles with rebac.roles.grant. A superuser created without a real save() (bulk_create, loaddata, or skipped as unchanged) is never in angee/role:admin#member, so const-admin reach fails until re-granted.
  • Never select_related a 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 under system_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 registry mode (angee.base autoconfig) while bare tests/settings.py runs the library's denormalized default — 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/get kwargs, Q objects, and values/values_list/order_by/annotate field 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 under override_settings(REBAC_LOCAL_BACKEND_STORAGE="registry").
  • Derive operator/edge token scope from <ns>/role:<id>#effective_member (folds in role-hierarchy includes), never roles_of/roleRefs (a direct-grants UX hint that under-grants).
  • rebac sync persists the zed into DB Schema* tables, and the system checks gate every subcommand on that persisted state — so editing the zed can deadlock the sync. Unstick with rebac --skip-checks sync --force-overwrite --yes then rebac 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-free reconcile_permissions first; it prunes stale package-managed schema rows before makemigrations / rebac sync can run.
  • When an addon removes its last REBAC resource, keep an empty package-owned permissions.zed with a bumped schema revision until old package-managed rows have been pruned; deleting the file makes rebac sync skip the package and strand stale definitions in existing databases.

Pitfalls

  • Never mix select() with buffered readline() 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 with os.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.ordering columns silently join the DISTINCT projection, so a single-column values_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=None on 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-local worker_shutting_down event, 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 with expires= of one tick so a saturated or absent worker never accumulates a backlog (angee.integrate.session + angee.integrate.tasks are 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_threadsafe onto 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 act permission is (assignee − requester) + admin (separation of duties), so requester == assignee locks the owner out of their own decision. Leave requester unset 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; compose ThreadedModelMixin and reverse GenericRelations 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_create and queryset update paths, where signals do not run.

  • ScoredLinkMixin is 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_permissions run for the social/* to posts/* 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.lifecycle records what the operator asked for; how far a runtime handshake actually got belongs on runtime_status/sync_progress. Overloading one lifecycle value with "not finished yet" — a WhatsApp channel that stayed disconnected until 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_prefix of its own is an int_… 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.tasks is the reference: ensure_bridge_sessions selects CONNECTED and reconciles the live desire to it, and gates on runtime_status so 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=ERROR disables 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_lifecycle returns 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_pairing reports OK unconditionally for this reason. Keep the gate on the shared runtime_status rather 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 Integration cannot be overridden by an Integration child. The composer emits a child as class Child(Integration, AbstractChild), so the parent's abstract source precedes the child's own source in the MRO and shadows it — angee.integrate.models.Integration wins over angee.messaging.models.Channel. A seam a child must override therefore belongs on a base that follows the child source (Bridge owns start_live / stop_live / _next_sync_at for exactly this reason), never on Integration. A parent verb that needs child behaviour has to compose instead: reach the concrete row by the primary key it shares (sync_integration is the precedent) — and note that walking bridge_models fans a query across every installed bridge's table, so it is not free.

  • hasura_model_resource create full_cleans the input, so model + input defaults must agree. The Hasura model-resource create path builds a dummy instance from the input and calls full_clean() before saving — two traps follow. (1) A JSONField(default=dict) (or default=list) needs blank=True: Django counts {}/[] as blank, so a blank=False container default fails full_clean ("cannot be blank") on every create. (2) An optional create-input field over a non-null column must default to strawberry.UNSET, never NoneNone is submitted as an explicit null that overwrites the model default (e.g. status/config), and full_clean then rejects the null. Mirror this for any new hasura_model_resource input.

  • 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 run tool 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 .. Bare uv run pytest/mypy fail to spawn.

  • Celery periodic tasks accept timestamp when 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-run manage.py schema (+ --check). A missing runtime/schemas/*.graphql makes Vite ENOENT and the SPA silently fails to mount (every e2e fails at list load) while :5173 still returns 200; check runtime/schemas/ before chasing app/test regressions. (The dev server regenerates it for you — see the runserver pitfall — but a manual angee build outside angee dev still needs the explicit schema step.)

  • 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 delete permission themselves, then run the owned destructive work under system_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-authorized on_delete=CASCADE children 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 a post_delete receiver 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 --fake repair. 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 with use_in_migrations = True (iam's UserManager, inherited from Django's) rides into the historical model, so objects inside a RunPython is REBAC-scoped and raises MissingActorError under strict mode — a migration is a system operation; use model._base_manager.using(db). And a fresh-DB migrate never 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 conventional migrations/ package, and declare them through ordered [[migrations]] in addon.toml. Their pure applies(ProjectState) guard must select the exact old state, skip the complete new or absent state, and fail on recognized partial states. Copy-local RunPython functions must use historical models from apps and _base_manager, and clear the default ordering with .order_by() before a queryset write — Meta.ordering may 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. Explicit angee build is 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. The AgentRuntime an agent's runtime_class selects (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's CLAUDE_CODE_OAUTH_TOKEN but OpenCode reads only ANTHROPIC_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 (the OPENCODE_ANTHROPIC_AUTH_PLUGIN build 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 dev serves via Angee's runserver override, not uvicorn --reload.angee.compose ships a runserver that runs ASGI_APPLICATION under 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 generated runtime/ (each child re-emits before its reloader snapshots), so a model edit reloads once. Don't reintroduce uvicorn --reload/--reload-dir heuristics in the stack template. The boot regenerates the SDL when ANGEE_DEV_SDL=1 (set only by that command), so a live edit refreshes runtime/schemas/*.graphql and Vite HMRs; schema --check stays a real drift gate because management commands never import angee.asgi. Generated files (runtime models + SDL) are written atomically via angee.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 default sys.exit(3) can wedge the child on a dead listener. Install pywatchman for 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's ingress.port (the leased edge_port). Two stacks sharing a name: 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 leased operator.port_pool.edge); keep name:/edge_port workspace-unique when adding a stack or service template.

  • makemigrations must name every changed app — include resources (and base) or resources load fails with no 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 as addon.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_rule is evaluated over sibling StepRun rows.

  • Never trust a workflow step to self-limit. The engine owns max_steps and 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) + c when combining exclusion with union.

  • Give a model an opaque public id by mixing in SqidMixin and declaring sqid_prefix = "abc_" — the one fact that varies per model. The shared angee.base.fields.SqidField reads that prefix in contribute_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 where django_sqids.SqidsField crashes 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 writable Patch.status String takes the lowercase model value ("disabled"). This holds inside F6 nested line inputs too: a child enum/choices column is a String on 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 modelHasuraLines(node=…)'s child fields reconstruct through resource_fields(node, model) (the same classifier the parent uses), because the node owns a choices column's wire enum values and an M2M's kind:"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 readOnly in a form. A column the server owns (audit, derived, or default-only) must be left out of the resource's insertable/writable set so it never enters the generated input type. Marking the form control readOnly only hides the widget: the field still rides the input, and the form's Field.defaultValue seeds and submits a value for it, so the client can write a column the server owns. Resource-level exclusion is the one authorization gate; readOnly is presentation, not authorization.

  • Validation surfaces two ways — Django ValidationError flows through extensions.validationErrors (camelCased), but GraphQL input-coercion errors fire before resolvers and never reach it, so guard required inputs client-side from rootFields.requiredCreateFields.

  • In test-client logins pass the backendforce_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-axes at the authenticate(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 plain FOR UPDATE there, so the helper is the greppable contract that keeps lock intent explicit and backend-gated. HierarchyMixin path maintenance and save_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 HierarchyMixin consumer declares its scope fields — the mixin never probes by column name. A subtree that must stay inside a tenant or other scope declares hierarchy_scope_fields = ("scope",) (a ClassVar tuple; 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. StateField transitions guarded by save_state get an optimistic-concurrency guard for free: the committed source is re-read under the same lock before the write, so a lost race raises TransitionNotAllowed instead of double-applying (e.g. double-posting a ledger).

  • Django 6 refreshes F()/expression fields back onto the instance via UPDATE ... RETURNING before post_save. A save(update_fields=…) whose fields hold expressions (F("count") + 1, Greatest(…)) leaves the instance carrying the DB-true resolved values, not the expression objects — the post_save receiver (and any changes publisher) 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. Capture current_actor() before the elevated block and rebind the returned instance with .with_actor(actor) after save.

  • Publishers wire during angee.graphql app ready(), not schema build or schema import. GraphQL schema modules declare subscription surfaces; GraphQLSchemas connects publishers from declared changes metadata 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 the ObjectRef whose read permission 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 declare changes() for the model to join the change feed.

  • AngeeModel managers/querysets must keep the canon. If a model customizes objects, its queryset class must derive from AngeeQuerySet; otherwise shared methods such as public-id lookup, actor scoping, and elevated reads drift between models.

  • MIGRATION_MODULES may 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.

  • EncryptedField keys are bound to model._meta.label_lower plus field name. Renaming a model/app/field changes the derived key. Plan ANGEE_FERNET_KEYS/MultiFernet rotation 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_classification with 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 gitignored runtime/*/migrations/*.py that imports a moved module before makemigrations.

  • 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's Python-urllib default; an honest client UA passes. angee.integrate.oauth.client owns 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 inside angee.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_context through each outbound HTTPS call. Outbound code uses the stdlib default context (ssl.create_default_context()), which OpenSSL resolves against the system trust store and honours SSL_CERT_FILE/SSL_CERT_DIR. A dev mac trusts via Homebrew ca-certificates; an environment that lacks a CA store (a minimal container, a bare CI/agent sandbox) is fixed there — install OS ca-certificates, or export SSL_CERT_FILE="$(python -m certifi)" at bootstrap — not by adding certifi plumbing 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-rolling urlopen + context.

  • An ImplClassField builds its enum at model-import time from its registry_setting — the key→path mapping (e.g. ANGEE_STORAGE_BACKEND_CLASSES) is supplied by the owning addon's autoconfig, so every settings module that installs the addon must carry a non-empty mapping, including a bare module that skips the composer (tests/settings.py declares storage, integration, VCS, inference, and OAuth provider registries explicitly). An empty registry raises ImproperlyConfigured at 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 own name and vendor silently creates an OpenAI provider row.

  • Never name an addon module after a third-party top-level package it imports.unittest discovery inserts the discovery-root directory onto sys.path, so an addon's mcp.py that does from mcp.server… import … becomes an importable top-level mcp that shadows the real package — ModuleNotFoundError: 'mcp' is not a package during a test run, while a single-module run and manage.py check pass. Name such a module for its role, not the library (the MCP tool seam infers — or resolves a [mcp].tools override to — mcp_tools.py, not mcp.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 *Config for the AppConfig.
  • 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_underscore for internal. Settings and constants are UPPER_SNAKE.
  • camelCase only when extending an external API that uses it (e.g. Django's unittest assertions). 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.

sh
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 host

Use 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.

Released under the AGPL-3.0 License.