Koherent adds provenance-aware audit logging to Django applications that expose a Strawberry GraphQL API. It is a thin layer over django-simple-history: every change to a tracked model is recorded as a history row, and each row is attributed to who changed it, which client/app they used, and — crucially — the verified task it happened under.
It answers questions like "which automated run touched this record, on whose authority, and
what exactly did it change?" and exposes those answers directly in your GraphQL schema as a
queryable, filterable provenance field.
pip install koherent # pulls in django-simple-history, authentikate, kanteKoherent is a Django app. Add it to INSTALLED_APPS after authentikate (it relies on
the authentikate user/client/organization context):
INSTALLED_APPS = [
# ...
"authentikate",
"koherent",
"your_app",
]
AUTH_USER_MODEL = "authentikate.User"Koherent is responsible for three things and nothing else:
- Recording — a
ProvenanceFieldon your model captures every create/update/delete as a history row (via django-simple-history). - Attributing — a signal stamps each history row with the acting
user, theclient(app), and aTaskresolved from the request's verified provenance token. - Exposing — Strawberry types (
ProvenanceEntry,Task) and a flat, semantic filter (ProvenanceFilterMixin) surface that history in your GraphQL API.
It does not authenticate requests, mint tokens, or define the transport — those belong to
authentikate and
kante respectively (see below). Koherent is the piece
that sits between them and turns an authenticated, context-carrying request into a durable,
queryable audit trail.
Note: Koherent is built for the Arkitekt / Rekuest ecosystem. The "task" it tracks is a Rekuest task, attested by a signed provenance token.
Every history row (a ProvenanceEntry) carries:
| Field | Meaning |
|---|---|
user |
The user who made the change (history_user). |
client |
The OAuth client / app that made the change, if any. |
task |
The verified task the change ran under, if any. |
kind |
CREATE / UPDATE / DELETE. |
date |
When the change happened. |
effective_changes |
The per-field old→new diff for that row. |
The task is a Task row built once per task from the provenance token's claims
(task id and its parent/root, the root human causer, the executing agent, the issuer,
the single-use token id, and an args hash).
A task id (a.k.a. correlation_id / context_id) groups together every change made
during one logical run. In Arkitekt, when a user calls an app through a Rekuest, all of that
app's mutations carry the same task id — so you can later find, audit, or revert every
change a single run produced.
Rekuest provenance token (signed, EdDSA)
│
┌──────────────▼───────────────┐
kante │ HttpContext / WsContext │ ← GraphQL transport + context
└──────────────┬───────────────┘
│ request
┌──────────────▼───────────────┐
authn. │ AuthentikateExtension │ ← verifies token, sets user/client/
│ → user, client, organization │ organization + provenance on request
│ → verified ProvenanceToken │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
koher. │ KoherentExtension │ ← reads provenance token into a contextvar
└──────────────┬───────────────┘
│ model.save()
┌──────────────▼───────────────┐
│ simple_history signal │ ← stamps history row with user/client/Task
│ → ProvenanceEntry rows │
└───────────────────────────────┘
This is the heart of why provenance groups changes. The grouping is not done by Koherent — it falls out of how a rekuest-next agent runs a task.
When a user calls a registered function on an agent:
from rekuest_next import register
from service.api import create_model, update_model # generated GraphQL clients
@register
def do_some_transactions(name: str) -> Model:
"""Every GraphQL call below is grouped under one task."""
z = create_model(name=name) # mutation #1
f = update_model(id=z.id, name="renamed") # mutation #2
return fthe agent receives an Assign message carrying an opaque, server-signed provenance token
for that single task. While the function runs, rekuest-next holds that token in an
AssignmentHelper stored in a contextvar (current_assignation_helper), and a GraphQL link
in its client chain — ContextLink — transparently stamps the token onto every outgoing
operation:
So create_model and update_model above — and any other call the function makes — all leave
the agent carrying the same Rekuest-Task header. The token is set once per task execution
and reused for the lifetime of that execution (Python's contextvars propagate it to every
coroutine the function spawns); the application code does nothing special.
On the server side, AuthentikateExtension verifies that Rekuest-Task token and Koherent's
get_or_create_task() resolves it to a Task row, deduplicating by the task id
(tsk): the first mutation creates the row, every later mutation with the same token reuses
it (a warm Task.objects.filter(...).first() lookup, cached in a contextvar per request). The
result: every history row produced anywhere in that one @register execution links back to a
single Task, and you can later query "show me everything task X changed."
Long-running tasks span many separate HTTP requests over time; because the
Taskis keyed by the token's task id rather than by request, they all still collapse onto the same row.
Add a ProvenanceField to any model you want to audit. Importing the field also registers the
history signal that does the attribution.
from django.db import models
from koherent.fields import ProvenanceField
class MyModel(models.Model):
your_field = models.CharField(max_length=1000, null=True, blank=True)
provenance = ProvenanceField() # records & attributes every changeProvenanceField is a simple_history.HistoricalRecords whose generated history model mixes
in koherent.models.ProvenanceEntryModel (the client / task columns). The history rows are
reachable via the reverse relation provenance_entries (the default related_name).
Run python manage.py makemigrations && migrate to create the history table and the Task
table.
Expose the provenance on your Strawberry types and wire up the extension. Order matters:
AuthentikateExtension must run before KoherentExtension.
import strawberry
import strawberry_django
from strawberry_django.optimizer import DjangoOptimizerExtension
from authentikate.strawberry.extension import AuthentikateExtension
from koherent.strawberry.extension import KoherentExtension
from koherent.strawberry import ProvenanceEntry, ProvenanceFilterMixin
from your_app import models
@strawberry_django.filter_type(models.MyModel)
class MyModelFilter(ProvenanceFilterMixin):
your_field: strawberry_django.filters.FilterLookup[str] | None
@strawberry_django.type(models.MyModel, filters=MyModelFilter)
class MyModel:
id: strawberry.ID
your_field: str
# The reverse relation is `provenance_entries`; expose it as `provenance`.
provenance: list[ProvenanceEntry] = strawberry_django.field(
field_name="provenance_entries"
)
# authentikate's types carry Apollo Federation @key directives, so use a federation schema.
schema = strawberry.federation.Schema(
query=Query,
mutation=Mutation,
extensions=[AuthentikateExtension, KoherentExtension, DjangoOptimizerExtension],
)Any mutation that calls model.save() now produces an attributed history row automatically — no
explicit logging call needed.
query {
myModel(id: "1") {
id
provenance {
kind
date
user { sub }
task { taskId agentClientId }
effectiveChanges { field oldValue newValue }
}
}
}effectiveChanges is batched through a DataLoader, so selecting it across many instances stays
a constant number of queries.
ProvenanceFilterMixin adds a flat, exact-match provenance filter to a model's filter
type — no nested traversal required:
query {
myModels(filters: { provenance: { taskId: "task-a", kind: CREATE } }) { id }
}Available predicates: taskId, agentClientId, issuer, changedBy (user sub),
kind, changedSince, and changedBefore.
authentikate owns identity; Koherent consumes it.
- Models —
AUTH_USER_MODELisauthentikate.User, and Koherent'sTask.assigner/ProvenanceEntry.clientpoint at authentikate'sUserandClient. Tasks are scoped to an authentikateOrganization. - The verified provenance token —
AuthentikateExtensionvalidates the incoming Rekuest provenance token (authentikate.provenance.ProvenanceToken, a separate EdDSA trust domain configured underAUTHENTIKATE["provenance"]) and attaches it to the request. There is no static-token bypass for provenance — the signature is always checked. - The auth context — Koherent's history signal reads
authentikate.vars.get_user(),get_client(),get_organization(), andget_token()to stamp each row and to build theTaskfrom the token's claims (tsk,ptk,rtk,rcb,sub,act.sub,act.cid,iss,jti,ahs,aha). The root human causer (rcb) is resolved to a localUserwhen one exists; otherwise the task is recorded with a null assigner but the raw sub is kept.
Because of this dependency, AuthentikateExtension must precede KoherentExtension in the
schema's extension list — Koherent reads what authentikate has already put on the request.
kante provides the GraphQL transport (ASGI app) and the
request context that both extensions build on.
- Context types —
KoherentExtensionswitches on kante'sHttpContextandWsContext. For HTTP requests it sets the request's provenance token into a context variable for the duration of the operation; for websockets it does not (the connection — and its headers — is shared across operations, so there is no reliable per-operation provenance). - Mutations over websockets are rejected. Since the task context can't be tracked per operation on a persistent socket, Koherent raises rather than record an unattributed change.
Info— resolvers use kante'sInfotype; theeffective_changesDataLoader is cached on the kante request so it lives exactly one request.
from koherent.fields import ProvenanceField, HistoricForeignKey, GenericRelation
from koherent.models import Task, ProvenanceEntryModel
from koherent import get_current_provenance, get_current_task
from koherent.strawberry import (
KoherentExtension,
ProvenanceEntry, # the history-row GraphQL type
Task, # the task GraphQL type
ProvenanceFilter, # the flat provenance filter
ProvenanceFilterMixin, # drop-in mixin adding a `provenance` filter
)uv sync
uv run pytest # test_project + testing_module exercise the full stack