Skip to content

Architecture

This page describes how Kirak is structured internally. It is intended for contributors and developers who want to understand how the runtime works, not just how to use it. If you are building an application, start with Getting Started.


Kirak reads your model JSON files at startup and uses them to drive every decision: which database tables to create, which API routes to generate, which roles can access which operations, and which fields are searchable. You describe your data and access policy; Kirak handles the rest.

The same JSON that defines your schema also defines your security policy. Changing the model file changes the API, the migration, and the access rules – all from one place.


Every request goes through the same pipeline regardless of which model or operation it targets:

HTTP Request
|
v
Body-size-limit middleware
Rejects requests over 10 MB (Content-Length) with a 413 before any parsing.
|
v
SecurityHeadersMiddleware
Sets X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy.
|
v
RequestContextMiddleware
Assigns a UUID request_id; injects request_id and user_id into log context.
|
v
MonitoringMiddleware
No-op unless the monitoring module is enabled; otherwise records request timing.
|
v
FastAPI Router
Selects the route handler for the model and operation.
|
v
dispatch() pipeline
1. Resolve current_user from JWT (or bypass in script mode)
2. process_request(): coerce query-string types to Python types
3. run_hooks(before_{operation}, payload)
4. AccessPolicyEngine.check_and_get_rls(): verify role and apply RLS condition
5. Execute operation: validate fields -> build SQL -> execute -> serialize
6. run_hooks(after_{operation}, result)
|
v
JSONResponse -> Client

Hooks and access checks are separate steps. Access control is always enforced before any operation executes, regardless of what hooks do.


A single Kirak object coordinates the whole runtime: it loads models, manages the database connection pool, initialises modules, and exposes all CRUD operations as methods. Application code accesses it via request.app.get_kirak() or app.state.kirak.

Optional modules (notifications, payments, ai, storage, scheduler, monitoring) are lazy-loaded: they are imported and initialised only on first access. Unused modules cost nothing at startup.

Every model in models.json defines two things:

Key Purpose
schema Data structure: field types, constraints, defaults, search behaviour
access Security policy: who can call each operation and under what row-level conditions

Schema and access are intentionally separate so you can change your data structure without touching your security rules, and vice versa.

Four fields are managed automatically by Kirak – never put them in your schema block:

Field When set
id INSERT
created_at INSERT
updated_at INSERT and UPDATE
deleted_at Soft-delete; cleared on restore

Kirak provides 11 CRUD operations per model:

Operation HTTP Description
fetch GET Query records with filters, pagination, ordering
search GET Full-text search across searchable fields
count GET Count matching records
exists GET Boolean existence check
create POST Insert one or many records
update PUT Update single or bulk records
upsert PUT Insert or update on conflict
delete DELETE Soft delete (sets deleted_at)
destroy DELETE Hard delete (permanent)
restore PATCH Un-soft-delete a record
graphql POST GraphQL query interface

Each operation runs through the full hook + permission + validation + SQL pipeline.

Hooks are registered in on_kirak_ready and run before or after any operation or auth event. They receive the payload (before hooks) or result (after hooks) and must return it.

Hooks can be sync (def) or async (async def), and the two behave differently. A sync hook that raises stops the hook chain immediately and rejects the operation – use this for validation or policy checks that must block the request. An async hook that raises or times out is logged and skipped; the chain continues with the previous payload – use this for side effects that should not block the request. Business logic belongs in hooks, not in runtime code. Hooks run serially in registration order.

The AccessPolicyEngine runs on every request before any SQL executes. It combines RBAC, row-level security, and field-level security into a single stateless decision:

  1. Does the model have an access block? If not: deny (403) – there is no open mode.
  2. Is the current user’s role listed for this operation? If not: 403.
  3. Does the matching rule have a condition? If yes: append parameterised WHERE clause.
  4. Does the matching rule have access.fields? If yes: restrict which fields the role may read or write – fields absent from access.fields are unrestricted. See Access Control.

All condition values are parameterised – never string-interpolated into SQL.

Every endpoint returns the same shape:

{
"statusCode": 200,
"status": "success",
"message": "Records fetched successfully",
"data": [...],
"pagination": {
"limit": 20,
"page": 1,
"total": 156,
"total_pages": 8,
"has_more": true
}
}

The pagination key is present only when the caller passed page or offset; it is absent from single-record and write responses.

Errors use the same envelope with "status": "error" and a sanitised message. Database errors never expose internal SQL to clients.


All modules – Auth, CrudEngine, Notifications, Payments, Storage, Scheduler, AI, Monitoring, Vector, MCP – extend BaseModule. BaseModule provides:

  • Hook registry (defaultdict keyed by (model_name, hook_type))
  • dispatch() pipeline: auth -> before hooks -> operation -> after hooks
  • run_hooks() with asyncio timeout and error isolation
  • Rotating log file with request correlation IDs
  • execute_query() for raw SQL access (skips access rules and hooks; prefer the CRUD operations and GraphQL)
  • HookBuilder for fluent hook registration

Auth and CrudEngine are always loaded. Optional modules are lazy-loaded on first access using double-checked locking to prevent duplicate initialisation under concurrent requests.

Modules that ship bundled assets (email templates, for example) resolve them through resolve_asset(module, *parts, assets_path=None) (kirak/core/asset_resolver.py) instead of hardcoding a path. It checks, in order:

  1. {assets_path or "assets"}/{module}/{parts} under the project root – lets a project override a built-in asset without touching Core
  2. The core package’s own bundled copy, as a fallback

Path traversal outside these two roots is rejected. This is how, for example, a project can override the built-in welcome email template by dropping its own assets/notifications/templates/email/welcome.py in the project root – no Core changes needed.

See Building Custom Modules for how to build and register your own module.


When using create_kirak_app():

STARTUP
1. on_startup(app) -- external services (Redis, etc.)
2. Kirak(app, models_path) -- load + validate models.json, load kirak.json (+ kirak.local.json merged over it)
3. kirak.connect() -- create DB pool, init Auth + CrudEngine
3b. Resolve effective modules -- caller arg > kirak.json > empty
4. app.state.kirak = kirak -- make instance available to routes
5. on_kirak_ready(kirak) -- register hooks, wire custom logic
6. kirak.mount_routers() -- attach all FastAPI routes
7. scheduler.start() -- if scheduler in modules
---- APPLICATION RUNS ----
SHUTDOWN
8. scheduler.stop() -- drain job queue
9. close_redis_client() -- if Redis was used
10. kirak.disconnect() -- close DB pool
11. on_shutdown(app) -- external service cleanup

Hooks registered in step 5 are guaranteed to be in place before the first request.


KirakException (500) -- base
+-- ValidationError (400) -- invalid input
+-- AuthenticationError (401) -- missing or invalid JWT
+-- PermissionDenied (403) -- role not allowed
+-- NotFoundError (404) -- resource does not exist
+-- DatabaseError (500) -- DB error; message sanitised before client response
+-- ConfigurationError (500) -- invalid models.json or runtime config

All are importable from the top-level package: from kirak import ValidationError, PermissionDenied.


Concern Library
Web framework FastAPI
Configuration Pydantic Settings
MySQL driver asyncmy
PostgreSQL driver asyncpg
JWT PyJWT
Password hashing bcrypt
Social OAuth social-auth-core
TOTP / MFA pyotp
Image processing Pillow
GraphQL graphql-core
MCP transport mcp (optional extra)
Environment vars python-dotenv

Topic Document
Build a custom module Building Custom Modules
How to contribute How to Contribute
All application APIs Getting Started