Skip to content

Migrating to Karni 0.2

Every change on this page is recorded in Karni's machine-readable changelog (packages/karni/karni/changelog/0.2.0.toml). karni upgrade reads it and rewrites what can be rewritten safely. It reports everything else with a link to the section below.

Upgrading

Run the upgrade before you raise the karni pin, while 0.1 is still installed: karni upgrade reads the installed version to know where the project starts.

pipx upgrade karni-cli        # the CLI carries the changelog and the codemods
karni upgrade --dry-run       # a unified diff of every change; writes nothing
karni upgrade                 # needs a clean git tree (or --allow-dirty)
# then: pin karni to 0.2 in pyproject.toml, reinstall, run `karni check` and the tests

If you already installed 0.2, pass the version you came from: karni upgrade --from 0.1.0. Codemods only rewrite code that still uses the old form, so running one twice changes nothing.

The final report has three parts:

  • Applied: every codemod that changed code, with each file and its number of rewrites.
  • Could not fix automatically: changes that need a person, with the code locations a detector found and the link to the note here.
  • Skipped: codemods with nothing to change, or that could not run (for example a TypeScript codemod on a machine without Node.js).

Deprecated names still work in 0.2. Each one raises a karni.deprecation.KarniDeprecationWarning naming its replacement, the release that removes it and its section on this page. To find any your tests still reach, run them with -W error::karni.deprecation.KarniDeprecationWarning.

Deprecated in 0.2, removed in 1.0

all_objects

karni upgrade rewrites it (v1_0.all_objects_to_without).

# before
Book.all_objects.filter(shelf=shelf)

# after
Book.objects.without("soft_delete", reason="<auto: migrated from all_objects>").filter(shelf=shelf)

Replace the generated reason with your own: it is logged every time the scope is lifted. One behaviour changed even before removal. all_objects used to drop every default-manager filter, and now it lifts only soft_delete, so it can never bypass a scope a module adds later, such as a tenant scope.

The codemod rewrites X.all_objects only where X names a class: a capitalised name, cls, self.model, type(obj). Django does not let you reach a manager through an instance, so obj.all_objects cannot be Karni's alias and is left alone. The codemod leaves two cases for you and lists them in the report:

  • the project defines its own all_objects on some class, so a reference could be either manager;
  • the next call needs a manager rather than a queryset, as in Book.all_objects.get_queryset() or Book.all_objects.without(...).

all_with_deleted()

karni upgrade rewrites it (v1_0.all_with_deleted_to_without).

# before
Book.objects.all_with_deleted()
shelf.books.all_with_deleted()

# after
Book.objects.without("soft_delete", reason="<auto: migrated from all_with_deleted()>")
shelf.books.without("soft_delete", reason="<auto: migrated from all_with_deleted()>")

On a related manager the relation filter stays. If the project defines its own all_with_deleted method, the codemod cannot tell the two apart. It then reports every call instead of rewriting it.

deleted_only()

karni upgrade rewrites it (v1_0.deleted_only_to_without_dead).

# before
Book.objects.deleted_only()

# after
Book.objects.without("soft_delete", reason="<auto: migrated from deleted_only()>").dead()

SoftDeleteManagerMixin

karni upgrade renames it everywhere (v1_0.soft_delete_manager_mixin_to_scoped): in imports, in base-class lists, in attribute access through the module, and in dotted-path strings. An import alias (import ... as M) is kept.

# before
from karni.managers import SoftDeleteManagerMixin

class EventManager(SoftDeleteManagerMixin, Manager.from_queryset(EventQuerySet)):
    pass

# after
from karni.managers import ScopedManagerMixin

class EventManager(ScopedManagerMixin, Manager.from_queryset(EventQuerySet)):
    pass

It is the same class: the mixin now runs every named scope (see models), so the old name was too narrow.

object_permission_classes

Manual. karni upgrade lists every viewset that sets it. It cannot translate an arbitrary permission class into access predicates.

# before
class EventViewSet(BaseViewSet):
    model = Event
    owner_field = "organizer"
    object_permission_classes = [IsOwner]

# after
from karni.api import Access, Authenticated, Owned

class EventViewSet(BaseViewSet):
    model = Event
    access = Access(read=Authenticated(), write=Owned("organizer"))

Until 1.0 the classes still run after the access object check, so they can only narrow. The Access control guide maps the common rules onto Owned, HasRole, Where and |/&. IsOwner, RolePermission and ObjectPermissionsMixin stay available for plain DRF views.

Manual. karni upgrade lists every reference. The replacement takes a queryset and the serializer that will render it, not expand paths, so the calling code changes shape:

# before
from karni.api.expansion import related_lookups

select, prefetch = related_lookups(Event, expand)
queryset = Event.objects.select_related(*select).prefetch_related(*prefetch)

# after
from karni.api.optimizer import optimize

queryset = optimize(Event.objects.all(), EventSerializer)

Pass scope= when each level must obey a viewset's read scope (scoped_related_lookups). Inside a BaseViewSet, self.optimize_queryset(qs) does that for you. See Query optimisation.

Breaking in 0.2

Agent guidance files

karni upgrade migrates them (v0_2.agent_guidance). Projects scaffolded by 0.1 have hand-written AGENTS.md, CLAUDE.md, .cursorrules and .claude/commands/*.md. In 0.2 these files are adapters that karni rules sync generates. The codemod:

  1. moves module blocks to .karni/modules/<x>.md;
  2. copies every line you added to Karni's text into karni.rules.py, verbatim;
  3. lists every line you removed as a # REVIEW: comment and in the report, because only you know whether it meant "disable this rule";
  4. backs the originals up under .karni/backup/ and generates the adapters.

Files that do not look like Karni's are left untouched and reported. See agents.

Every BaseViewSet declares access

Manual. karni check lists every viewset that is missing it (the viewset-access rule). Until you add one, the viewset denies every request.

# before (0.1: anonymous reads were allowed by the project default)
class EventViewSet(BaseViewSet):
    model = Event

# after
from karni.api import Access, Owned, Public

class EventViewSet(BaseViewSet):
    model = Event
    access = Access(read=Public(), write=Owned("organizer"))

access = Unscoped(reason="...") keeps a viewset outside the access system on purpose. With access in place, an object outside the read scope answers 404, and one that is readable but not writable answers 403. See Access control.

DEFAULT_PERMISSION_CLASSES

Manual. Karni's default changed from IsAuthenticatedOrReadOnly to IsAuthenticated. It no longer gates a BaseViewSet that declares access: the declaration is the gate, so a permissive project default cannot open it. It still covers plain DRF views and Unscoped viewsets, which therefore no longer allow anonymous reads. To keep a view public, say so on that view:

class StatusView(APIView):
    permission_classes = [AllowAny]

Relations and expansion follow the target's read scope

Manual: review API clients. A writable relation field accepts only primary keys that the requester can read through the target model's viewset. A PK outside that scope fails exactly like a missing one. ?expand= embeds only readable rows, and an unreadable to-one target renders as null. A client that relied on linking or embedding rows it cannot read sees validation errors or nulls. See Access control.

KARNI_MAX_PAGE_SIZE defaults to 100

Manual: review API clients. ?page_size= is capped at KARNI_MAX_PAGE_SIZE, which defaults to 100. In 0.1 the cap was a hard-coded 500. A client asking for more silently gets 100 rows per page. Raise the setting if you must, or page through:

# settings.py
KARNI_MAX_PAGE_SIZE = 250

Unpaginated lists need Unpaginated(...)

Manual. pagination_class = None on a BaseViewSet is a class-definition error. Boot also refuses REST_FRAMEWORK["PAGE_SIZE"] = None and a non-Karni default pagination class. Declare an unpaginated list explicitly:

# before
class CountryViewSet(BaseViewSet):
    pagination_class = None

# after
from karni.api import Unpaginated

class CountryViewSet(BaseViewSet):
    pagination = Unpaginated(max_rows=300, reason="fixed ISO list for a picker")

See Pagination.

Query budgets fail tests

Manual. Every list, retrieve, create, update and destroy action has a query budget. Karni's pytest plugin runs tests with KARNI_QUERY_BUDGET_MODE = "raise", so an action over budget now fails its test with a report that names the repeated statement. Fix the N+1 first (see Query optimisation). Raise a budget only with a reason:

from karni.api import Budget

class ReportViewSet(BaseViewSet):
    query_budget = Budget(list=25, reason="per-row signed URLs, measured")

See Query budgets.

Roles as data

migrate now reconciles the role tables with KARNI_ROLES (ADR 0002): the shipped default declares staff (every permission, every row), user and customer (their own rows), so three groups and their Role rows appear on the next migrate, and every group that already existed gets a Role row (slug from its name). Nothing is deleted — karni sync-roles reports roles the declaration no longer names and leaves them. Declare your own set in KARNI_ROLES to replace the default; HasRole(...) checks keep working, and Can("app_label.model:action") is now available in access declarations (it used to raise E108, now retired). Set KARNI_ROLES_AUTOSYNC = False to keep migrate from touching the tables. See roles as data.

Row-level scoping

The admin now applies the roles' grants to its rows (ADR 0002, P2): a staff user sees, in the changelist, search, filters, autocomplete, relation choices and the change form, only the rows their grants reach for view, and may change or delete a row only when it is inside the matching grant's scope. Django's model permissions still decide which screens exist. The visible change: a staff user who holds Django permissions for a model but no Karni grant now sees no rows of it. Give their role a grant in KARNI_ROLES ("shop.order:*": "all", or "own" / "related:<key>") and run karni sync-roles; the shipped staff role holds every grant, and superusers always do. An admin that must show everything its Django permissions admit opts out with scope_exempt = True and a scope_exempt_reason (E210 without one); karni check lists every exemption.

On the API, access = Granted() is new and is what karni make viewset writes by default (it used to write Access(read=Authenticated(), write=Authenticated())): the roles' grants for the viewset's model and the current action, row scope included. Existing declarations are untouched. A viewset may set scope_exempt = True with a reason too. See Access control and the admin's row scope.

Field-level visibility

class Karni: field_roles on a model hides fields per role on the API, in ?expand=, in the schema and in the admin (ADR 0002, P3). Two behaviour changes come with it, whether or not a model declares rules:

  • ?ordering= accepts only the fields the request's serializer reads back. BaseViewSet no longer sets ordering_fields = "__all__", which let any model field — including APIMeta.write_only ones — order the list (an ordering oracle over values the API never returns). A ?ordering= on a field the serializer does not return is ignored, as an invalid ordering always was. A viewset that needs more sets ordering_fields itself.
  • search_fields may not name a write-only field (karni.E131 at boot): a search over a value the API never returns is a membership oracle. Remove the field from search_fields or from APIMeta.write_only.

assert_crud gained a visibility check; it is skipped for resources whose model declares no field_roles. See field visibility.

Change history

karni.audit is a new installed app (karni_settings() adds it): run karni migrate once for its ChangeLog table. Nothing is recorded until a model opts in with class Karni: audit = True. Every BaseViewSet gains a GET /<resource>/<id>/history/ route (404, and absent from the schema, unless the model is audited). karni check gains the audited-bulk-write rule (K113). See change history.

Permission matrix

RolePermission gains an origin column (declared for every existing row): run karni migrate once. Grants now change in the admin — Roles → Permission matrix — or through POST api/auth/permissions/matrix/, with guard rails and a confirmation diff; the Permission and RolePermission admins are read-only. Two things about karni sync-roles changed with it: a grant set in the matrix (origin = "matrix") is left alone and listed as overridden in the report (and --json gains overridden), and a declared grant the declaration no longer names is removed outright — it used to be soft-deleted, which kept the unique constraint and blocked re-granting it. A declared grant revoked in the matrix is a row with scope none; nothing in KARNI_ROLES can declare that scope. See permission matrix.

Import and export

karni.transfer is a new installed app (karni_settings() adds it) and karni.audit gains a ChangeBatch table and a batch column: run karni migrate once. Every BaseViewSet gains GET /<resource>/export/ and the /<resource>/import/… routes, gated by the declaration's create or write; every Karni admin gains the export actions and the import page (karni_transfer = False opts an admin out). openpyxl is a new core dependency. The import and export actions are not counted against query budgets unless query_budget names them. See import and export.

Write interception

Every BaseModel save() and delete() now sends karni.signals.pre_write before any SQL; a receiver may raise KarniWriteDeferred to stop the write (the API answers 202, the admin shows the message). Nothing changes for a project with no receiver. A model that overrides save() must still call super().save() for the hook — and karni-approvals — to see the write. See write interception.

Vetoes at the gates

karni.vetoes is a registry the API gate (AccessPermission) and the admin (has_add/change/delete_permission, the actions menu, the Import page) consult after the access declaration admitted an action. Nothing changes for a project that registers no veto. A project that overrides has_delete_permission or get_actions on a Karni admin and does not call super() loses the veto check there — call super() or self.vetoed(request, action) yourself. karni-impersonation is the first user; see vetoes.

Architecture rules scan the project without BASE_DIR

karni check, pytest --pyargs karni.testing.architecture and the scaling check used to scan only apps under settings.BASE_DIR, which a Karni scaffold never defines — so in every generated project they scanned nothing and passed. Discovery now falls back to the parent of the settings package (the project root). A project that has accumulated violations will see them on the next karni check; each names its rule and fix, and every rule has the inline opt-out with a reason.