description: The Karni module contract: how an installable app plugs into a project with one command and one URL include, with karni-payments as the reference implementation.¶
The Karni module contract¶
A Karni module is an installable Django app that plugs into a Karni project
with one CLI command and at most one URL include — no settings editing,
no serializer/router/admin boilerplate. karni-payments
(modules/karni-payments/) is the reference implementation; this page is
precise enough to build a module without reading Karni's source.
karni make module <name> writes the skeleton below (modules/karni-<name>/).
Naming¶
| Thing | Name | Example |
|---|---|---|
| PyPI distribution | karni-<x> |
karni-payments |
| Django app module | karni_<x> (top-level, dot-free) |
karni_payments |
| App label | karni_<x> (must equal the module name) |
karni_payments |
| npm UI package | @karni/<x>-ui |
@karni/payments-ui |
karni add <x> derives all of these from <x>, so the convention is what
makes the CLI work. Third parties: the @karni npm scope and the karni-
PyPI prefix belong to the Karni project — for first-party-style wiring your
distribution must still be named karni-<x> and your app karni_<x>
(PyPI allows this; squatting unrelated packages under the prefix is not
welcome), but any other npm name is fine for your UI package (the @karni
scope convention is a recommendation, not a requirement).
Required contents¶
A module ships, at minimum:
karni-<x>/
pyproject.toml # dist name, karni pin, entry points, package data
README.md # settings table, wiring, escape hatches
karni_<x>/
__init__.py
apps.py # AppConfig, label karni_<x>
conf.py # DEFAULTS fragment + get() accessor
models.py # on karni.models.BaseModel / SoftDeleteModel
viewsets.py # on karni.api.BaseViewSet (auto-registered)
migrations/ # real, committed migrations
AGENTS.fragment.md # agent guidance surfaced by `karni rules` + AGENTS.md
urls.py # OPTIONAL — only for non-API URLs (webhooks etc.)
tests/ # standalone suite with its own settings module
1. Models and viewsets on Karni bases¶
Models inherit karni.models.BaseModel (or SoftDeleteModel), declare
APIMeta for API exposure and AdminMeta for the derived admin. Viewsets
inherit karni.api.BaseViewSet and must live in karni_<x>/viewsets.py
(or karni_<x>/api/viewsets.py) — that is where the auto-router's
autodiscover() looks; a viewset defined elsewhere never registers.
State whether your module ships admin code: the default is no
admin.py — the derived admin built from AdminMeta (karni-payments does
this). If you must ship explicit admin classes, say so in the README;
explicit registrations always beat autodiscovery.
2. Committed migrations — append-only within a major¶
Generate migrations with a scratch settings module and commit them. Within a major version migrations are append-only: never edit or re-number a shipped migration — projects have already applied it. Squash only across a major release.
3. The settings fragment (karni.settings entry point)¶
This is how "apply a settings fragment" works without ever editing a
project's settings.py — the fragment ships in the module:
# karni_<x>/conf.py
import environ
from django.conf import settings
env = environ.Env()
DEFAULTS = {
"KARNI_PAYMENTS_PROVIDER": env("KARNI_PAYMENTS_PROVIDER", default="stripe"),
# ... every setting your module reads, env-driven where sensible
}
def get(name):
return getattr(settings, name, DEFAULTS[name])
karni_settings() merges every installed fragment after BASE and the
profile overlay and before project overrides — project settings always
win. Hard rules (violations raise ImproperlyConfigured naming your entry
point):
- New top-level names only. A key that collides with Karni's BASE, the
profile overlay or another fragment is an error. There is no deep merge in
v0.1 — fragments extend the settings vocabulary, they never retune Karni.
In particular: never put
INSTALLED_APPS(or any Django builtin) in a fragment; app wiring iskarni add's job. - Import-light. The fragment module must import without Django
configured: plain data +
django-environreads only, and it must not touchdjango.conf.settingsat import time. Fragments are merged in entry-point-name order and each dict is deep-copied, so later mutation of project settings never corrupts yourDEFAULTS. - Per-profile values: fragments merge before the profile is observable
through Django, so read
os.environ["DJANGO_ENV"]inside the fragment if a default must differ between dev and prod (the documented hatch). - Read through your own accessor. Module code reads settings via
conf.get(name)→getattr(settings, name, DEFAULTS[name]), so the module also works in projects that configure Django withoutkarni_settings(). The merge is convenience, not correctness. - Credentials default to
""and are validated lazily at use (clear error naming the env var), never at boot.
4. The karni dependency MUST be range-pinned¶
Karni 0.x minors may break; a module declares the minor it was built
against. When Karni reaches 1.0 the pin becomes karni>=1,<2 and Karni's
public-API deprecation policy applies: warn one minor, remove next
major — modules get a full minor release of warnings before an API they
use disappears, and must follow the same policy toward their own users.
5. AGENTS.fragment.md — agent guidance that travels with the module¶
A ≤15-line Markdown fragment inside the app package (next to
apps.py), pinned as package data so it survives wheel builds:
[tool.hatch.build.targets.wheel]
packages = ["karni_<x>"]
artifacts = ["karni_<x>/AGENTS.fragment.md"]
Contents: what the module provides, the one urls.py include line (if any),
the env vars, and the module's hard rules (karni-payments: "never call
provider SDKs directly", "never trust client amounts"). karni add <x>
reads it through the project's interpreter
(importlib.resources.files("karni_<x>")) and records it in the project as
.karni/modules/<x>.md (committed). From there it reaches agents through
the CLI — karni rules prints it under [module:<x>] — and through the
generated adapters: karni rules sync renders it into AGENTS.md (and
.junie/AGENTS.md) between markers:
Recording is idempotent (re-running karni add replaces the file). If the
module is not installed yet, karni add tells the user to run it again
after installing. See Karni for AI agents.
6. Optional urls.py — explicit include, documented¶
Viewsets ride the auto-router and need no URL wiring. Only raw
endpoints (webhooks, callbacks) go in a karni_<x>/urls.py, and the module
documents the single include line in its README and its
AGENTS.fragment.md (karni add prints it too):
7. Optional karni.checks hooks¶
A module may register build-failing checks that manage.py karni_check
runs:
[project.entry-points."karni.checks"]
payments = "karni_payments.checks:run_checks" # () -> list[str] of failures
7b. Optional karni.scopes: model scopes¶
A module can add a scope, a named filter that every Karni default manager applies (see models.md). Installing the module turns it on. No project code changes:
[project.entry-points."karni.scopes"]
tenant = "karni_tenancy.scopes:TenantScope" # a karni.scopes.Scope subclass or instance
namemust be unique (core ownssoft_delete).orderplaces the scope in the pipeline (soft_deleteis 10).applies_to(model)decides which models it covers. Honour a per-model opt-out read throughkarni.scopes.scope_option(model, name), which readsclass ScopeMeta: <name> = ...on the model.filter(queryset)runs when a queryset is built, which can be import time. Read request or task context when the SQL compiles, and raiseScopeContextMissing(model, name, fix=...)when none is bound. The scope fails closed.- A load failure, a duplicate name or a bad attribute raises
ImproperlyConfigurednaming the entry point (system checkkarni.E002). Projects reorder or disable scopes withKARNI_SCOPES.
8. Tests — standalone, with fakes¶
tests/ runs with python -m pytest inside the module, against the
module's own settings module (pytest ini in the module's pyproject.toml):
sqlite + locmem cache + CELERY_TASK_ALWAYS_EAGER + karni_settings() +
the app installed. Ship a TestProvider-style fake for every external
service you integrate (in-memory, deterministic ids, real signature math)
and document it — projects use it in their own suites. Cover your model
constraints, services, API actions, permission gates and owner scoping.
Modules must pass Karni's architecture rules in their own CI — run the enforcement suite against your app:
(models on BaseModel, viewsets on BaseViewSet with an access
declaration, no filter_queryset_for_user override without a reason, no raw SQL outside
sql//migrations/, no direct HTTP clients outside services//
providers/, no redundant serializers — each rule has the documented
# karni: allow(<rule>): <reason> opt-out.)
9. README¶
Install line (with extras), the settings table (setting, env var, default, meaning), the include line, security notes, and every escape hatch — the repo rule applies to modules too: no abstraction without an override path (settings-driven dotted paths, overridable methods, swappable classes), each documented where the abstraction is.
Frontend package (optional)¶
A module's UI package (@karni/<x>-ui or your own scope):
- composes
@karni/uiprimitives (Button,useKarniClient,useResourceList, …) — never a parallel table/form/query stack; - peer-depends on
@karni/ui(pin the 0.x minor:"^0.2.0"— 0.x minors may break), react 18/19 and@tanstack/react-query; - styles with tokens only (the
--karni-*CSS variables) — no raw hex, no Tailwind palette imports; - registers its components in the role registry
(
registerComponentRole("checkout-button", …)) — feature-detect and skip when the registry export is absent; - passes the
@karni/eslint-configpreset; - calls the API through the
KarniClientfrom context (useKarniClient().request(...)against resource-relative paths like/payments/checkout/) and invalidateskarniKeys-based query keys so resource views refresh.
Wiring lifecycle (what karni add <x> does)¶
- adds
karni-<x>to[project].dependenciesin the project'spyproject.toml(idempotent, parsed — never string-matched); - inserts
"karni_<x>"into the# karni:appsmarker block inconfig/settings.py(idempotent); - records
AGENTS.fragment.mdas.karni/modules/<x>.md(read through the project interpreter; prints "run again after installing" when the module is not importable yet) and refreshes the agent adapters, sokarni rulesandAGENTS.mdcarry the module's guidance; - prints the
urls.pyinclude line and the install +make migratereminder.karni addnever touches the network — the user installs.
Settings then arrive via the entry-point fragment at the next process start;
migrations apply with make migrate. Total project diff for a new module:
one dependency line, one INSTALLED_APPS entry, one optional include line.
Patterns worth copying from karni-payments¶
- Role-gated detail actions × owner scoping: one
accessdeclaration. The acting role must be part ofread, or role-gated detail actions can never reach other users' objects —get_object()looks rows up through the read scope. The action gates itself with@action(access=...):
class PaymentViewSet(BaseViewSet):
model = Payment
access = Access(read=Owned("user") | HasRole("payments-admin"), write=Owned("user"))
@action(detail=True, methods=["post"], access=HasRole("payments-admin"))
def refund(self, request, pk=None):
payment = self.get_object() # any payment for an admin, 403 for others
...
A role-only resource is access = Access(read=HasRole("x"), write=HasRole("x")):
HasRole is both the gate (403) and the row policy (no rows), so it stays
closed even if someone swaps the permission classes.
- Read-only resources: override
create/update/partial_update/destroyto raiseMethodNotAllowedinstead of playinghttp_method_namesgames — extra@actionPOST routes keep working. - Webhook pipeline: verify signature → persist a dedupe row
(
UniqueConstraint(provider, event_id)) in the view's owntransaction.atomic→transaction.on_commit(lambda: task.delay(...))→ all state changes in a locked, idempotent Celery task. Nothing state-changing in the request cycle. - External SDKs: imported lazily, behind an extra
(
karni-payments[stripe]), with the install hint in the error.
Checklist¶
- [ ] dist
karni-<x>, appkarni_<x>, labelkarni_<x> - [ ]
dependencies = ["karni>=0.1,<0.2"](range-pinned) - [ ] models on
BaseModel,APIMeta+AdminMetadeclared; admin shipped as derived-only (or the README says otherwise) - [ ] viewsets on
BaseViewSet, inviewsets.py, auto-registered - [ ] migrations generated, committed, append-only within a major
- [ ]
karni.settingsfragment: new names only, import-light, env-driven, read back throughconf.get() - [ ]
AGENTS.fragment.md≤15 lines, inside the package, pinned as package data - [ ] optional
urls.pywith the include line documented in README + fragment - [ ] standalone test suite (own settings, sqlite, eager Celery, service
fakes) green with
python -m pytest - [ ] architecture rules pass (
pytest --pyargs karni.testing.architecture) - [ ] every abstraction has a documented escape hatch
- [ ] deprecations: warn one minor, remove next major
Modules in this repository¶
| Module | What it adds | Install |
|---|---|---|
karni-payments (modules/karni-payments/) |
hosted checkout, refunds and signature-verified webhooks for Stripe and Razorpay, with a test provider; @karni/payments-ui |
pip install karni-payments && karni add payments |
karni-approvals (modules/karni-approvals/) |
maker-checker approvals: a model opts in with class Karni: requires_approval = [...] and a guarded change becomes a pending change a second person applies or rejects — queue in the admin, /api/pending-changes/, notifications, timeouts, every decision in karni.audit; @karni/approvals-ui |
pip install karni-approvals && karni add approvals |
karni-impersonation (modules/karni-impersonation/) |
"log in as user" for support, done safely: an explicit impersonate permission (never implied by staff), no privilege escalation, a banner naming both identities with a one-click exit, a session record, every write attributed to both identities in karni.audit, a time limit and a hard block on deleting, exporting and payment changes; the threat model is in its README; @karni/impersonation-ui |
pip install karni-impersonation && karni add impersonation |
Each module's README is its reference (settings table, wiring, escape
hatches). karni-approvals is built on one core hook — every BaseModel
write sends karni.signals.pre_write and a
receiver may defer it — which any module can use the same way.
karni-impersonation is built on another — karni.vetoes,
a registry the API and admin gates consult after the access declaration
admitted an action — and on karni.audit's batches.