Skip to content

contributing/changelog.md

All notable changes to the Kirak runtime will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

  • Security: mobile social sign-in no longer signs in whoever’s email the body names. POST /auth/{provider}/mobile took email and provider_user_id from the request body, found the account with that email, linked it and returned its tokens – no provider token was checked (the access_token / id_token the docs asked for were never read), and it worked for any {provider}, enabled or not. Anyone could get tokens for any account, admins included. The route now accepts only providers whose token Kirak can check was issued to this app, then runs the web flow’s pipeline, so the account is the one the provider vouches for: google-oauth2 with the Google Sign-In id_token (signature against Google’s keys, issuer, expiry, audience), apple-id with its id_token (the same checks, by social-core’s Apple backend) and facebook with an access_token (sent with appsecret_proof). A provider’s user API accepts an access token issued to any app, so an access token alone would let another app the user signed in to replay it here. {provider} must be in auth.social_providers; any other provider is refused with 400 MOBILE_SIGN_IN_NOT_SUPPORTED, and so is facebook with APPSECRET_PROOF off. New setting: auth.social.google.audience, the client ids a Google ID token may be issued to (default [client_id]). Breaking: clients send the SDK’s token instead of email / provider_user_id – id_token for Google and Apple, access_token for Facebook; other providers must use the web flow. display_name and photo_url are no longer read, and first_name / last_name are used only by Apple. For native iOS Apple sign-in add the app’s bundle id to auth.social.apple.audience; for Google Sign-In pass the web client id as the server client id, or list the app’s client ids in auth.social.google.audience. The per-email rate limit of this route is gone (the body has no email); the per-IP limit stays.
  • Security: social sign-in links to an existing account only when the provider verified the email. The web pipeline linked a new provider to any account with the same email, which lets anyone who registers that address unverified at a provider take the account over. Linking now needs email_verified (or verified_email) from the provider – Google and Apple send it – and otherwise fails with 409 SOCIAL_EMAIL_NOT_VERIFIED. New social accounts are is_verified only when the provider verified the email (they were always true). The set_role pipeline step is removed: it set an existing admin account’s role to user in memory for the first access token only (the next refresh restored it) and left system / superadmin alone. A linked account now keeps its role. Breaking: GitHub and Facebook send no verification flag, so their sign-ins no longer link to an existing account with the same email.
  • The multi-tenant access patterns work as documented. docs/concepts/access-control.md (“Tenant isolation”) and docs/guides/common-patterns.md (“Tenant Isolation”) told you to put tenant_id / organization_id in the JWT from an after_login hook, the latter with a create_access_token function that does not exist. Kirak’s access tokens have a fixed claim set and the hook runs after signing, so the {tenant_id} condition always resolved to NULL and returned no rows. Both pages now resolve the tenant in your own route and pass it in the identity (set_user_context({**caller, "tenant_id": ...})), whose keys condition placeholders read; on Kirak’s own routes such rules fail closed.
  • Rare cron expressions no longer stall the event loop. Finding the next run stepped one minute at a time on the event loop, for every cron every 60 seconds: about 0.5 s per tick for a yearly expression and 4.5 s for one that never matches. It now skips days and hours that cannot match (under 3 ms in the worst case).
  • POST /scheduler/schedules returns the new schedule. kirak.create returns only the new id, so the route answered {"id": ..., "payload": {}} – no cron_expression, status, payload or next_run_at. It now reads the row back and returns it like PUT does.
  • Deleting a dynamic schedule works. DELETE /scheduler/schedules/{id} soft-deleted the row, but scheduler_jobs has no soft delete, so every call failed with “Soft delete not enabled”. The definition row is now deleted; the jobs it spawned are kept.
  • A new dynamic schedule waits for its time. Schedules were created with last_run: 0, and from the epoch almost every cron expression is already due, so a new schedule fired on the next worker tick (within 60 s) whatever its expression; one with no match in 1970-1971 (e.g. 0 0 29 2 *) never fired. last_run is now the creation time. Existing rows keep their stored last_run; a schedule stuck at 0 that never fired can be recreated.
  • Stripe Connect skips a redelivered webhook event. It was the only payments provider that did not record processed event ids in payments_webhook_events, so a redelivered account.updated, account.application.deauthorized or payment_method.detached reported its event to after_webhook again (a hook calling update_merchant_fee ran twice). It now answers a known event id with already_processed: true, per instance, like the other providers.
  • Logged-out tokens are refused on /notifications/*. The notifications router decoded the access token itself and never checked the logout blacklist, so a revoked token kept working on every notifications route until it expired – for an admin or system token, the send-* routes that spend on paid providers. Every route now refuses a revoked token with 401 TOKEN_REVOKED, and fails closed with 503 AUTH_UNAVAILABLE when the blacklist cannot be read, as the other routes do. Sending to a user is unchanged: recipients receive notifications whether or not they are signed in.
  • The notifications inbox and preferences routes work. All ten /notifications/inbox* and /notifications/preferences* routes answered 500: they called NotificationInbox / NotificationPreferences with positional arguments (fetch_unread(user_id, limit=limit)), but those methods take one params dict. GET /notifications/inbox/count/unread and GET /notifications/preferences also wrapped an envelope in a second one. The routes now pass params dicts and return the methods’ envelopes: count/unread answers data.count, preferences answers data.channels / data.events. The router tests had replaced the inbox with mocks that accept any call; a test now drives the real classes. The docs’ inbox and preferences examples had the same mistake and are corrected, as is their notification_preferences model (it was missing the required preference_type).
  • Storage operations are confined to the caller’s files. The docs said non-admin uploads are stored under {user_id}/, but over HTTP no storage operation was scoped: the storage router never set the request context, so every call ran as a guest, and the scoping read id / sub while the auth module resolves a caller to user_id. Any signed-in user could overwrite any file (POST /storage/upload/*), delete it (DELETE /storage/delete), get a presigned URL for it (GET /storage/url?expires=...) or list every key in the bucket (GET /storage/list); delete, get_url and list also skipped scoping when called in code with a user context. All five operations now run through the module’s dispatch with the caller, apply the {user_id}/ prefix for every role except admin, and fire before_ / after_ hooks (before_delete, after_get_url, …). A path with a .. segment is now a 400 VALIDATION_ERROR on every storage operation; on the local provider it could reach another user’s directory. Breaking: a non-admin HTTP upload of docs/a.txt by user 42 is now stored at 42/docs/a.txt (the returned URL says so), and non-admin delete / get_url / list reach only keys under the caller’s prefix. Files uploaded before this change sit outside any user’s prefix: only an admin-tier token (or code that sets the system identity) can reach them, so move them under {user_id}/ or serve them through an admin path. delete returns the scoped key in data.path.
  • Every admin-tier role is treated as admin. kirak.core.access_levels.ADMIN_ROLES defines the admin tier as admin, system and superadmin, but modules kept their own lists: storage scoping exempted only admin (a system or superadmin caller with a user id was confined to its own {user_id}/), and the /admin/rate-limits endpoints and the payments admin-only inputs and operations allowed only admin and system. Storage, payments, vector, notifications, scheduler, auth and the admin router now all use ADMIN_ROLES. Behaviour change: superadmin callers can now use /admin/* and the admin/system-only payments operations, and system / superadmin storage calls use the path as given. A signed-in storage caller outside the admin tier with no user id is now refused with 403 PERMISSION_DENIED instead of reaching every file unscoped.
  • Storage refuses guests. A guest caller used every storage path as given, so it could read, overwrite, list or delete every stored file. delete, get_url and list are offered as tools, and a guest MCP server’s caller or an agent run for a guest reaches dispatch() in the same shape as code that set no identity, so either one could reach every file. All five operations now refuse a guest with 401 AUTHENTICATION_ERROR, as vector and payments already do. Breaking: code that calls kirak.storage.* without an identity (your own route without set_user_context(caller), a job, a script, startup code) now gets that 401. In your own route, set the request’s caller (set_user_context(caller), see docs/guides/crud-operations.md, “Who a direct call runs as”; examples/8_storage.py does it with a route dependency). For work no user owns, set the system identity {"role": "system", "user_id": None, "token": None}, which uses the path as given. The /storage/* routes already required a signed-in caller and are unchanged.
  • GET /storage/list works on the local provider. list failed with a 500 STORAGE_ERROR whenever upload_dir was relative (the default, assets/media) or behind a symlink: each file’s resolved path was compared with the unresolved upload_dir.
  • SVG is no longer a default image type. storage.image_allowed_types defaulted to jpg,jpeg,png,webp,gif,svg, but Pillow cannot read SVG, so every SVG upload_image passed validation and then failed with a 500. The default is now jpg,jpeg,png,webp,gif, and an svg added back is refused as a 400 VALIDATION_ERROR naming upload_file. Behaviour change: a project that set svg explicitly gets a 400 instead of a 500.
  • Animated GIFs and WebPs keep their animation. upload_image re-encoded every image, which kept only the first frame. Animated images are now stored as uploaded (type, content and size are still checked; image_max_width / image_max_height and compression are not applied), and their thumbnails show the first frame.
  • Approving a paused agent run works. POST /ai/agent/resume and /ai/agents/{name}/resume with "approved": true failed with a 500: they built the tool approvals with DeferredToolResults.build_results, which pydantic-ai does not have, and then read the HTTP status from a success key the response envelope does not have. kirak.ai.resume_agent() had the same first failure. The routes now answer with the run’s own status.
  • ai.compaction_token_threshold is applied. Every agent run compacted at 150000 tokens whatever kirak.json said, and null did not turn compaction off; the setting now reaches both run paths (JSON and streaming).
  • An agent’s input_schema is checked. It was loaded and listed by GET /ai/agents but never applied. POST /ai/agents/{name}/run now answers 400 VALIDATION_ERROR (failures in details.errors) for a body that breaks it, before the model is called; the run’s own keys (stream, conversation_id, …) are not checked against it. Behaviour change: an agent whose input_schema its callers did not follow now refuses those requests.
  • kirak[all] installs every non-database extra. It left out Twilio (notifications-twilio), APNs (notifications-apn) and the AI module’s MCP toolsets (ai-mcp), and allowed a boto3 older than Amazon S3 Vectors needs (vector-s3vectors). It now includes all of them.
  • The AWS SNS SMS provider points at notifications-sns. Its install hint (kirak validate, kirak providers, the generated provider reference) and the docs still named notifications-ses, from before email and SMS got separate extras. Both install only boto3, so existing installs keep working.
  • Sessions and audit events record the client’s real IP and user agent. /auth/login, /auth/refresh-token and /auth/verify-otp took ip_address and user_agent from the request body, so a normal client recorded none and any client could record any value – including on auth.login.failure audit events. These routes and the social routes now set both from the request (client IP honoring rate_limit.trusted_proxy_ips, the User-Agent header); a body value is ignored, and /docs and openapi.json no longer list them as body fields. The social routes previously ignored trusted_proxy_ips and recorded the proxy’s IP.
  • API keys work on MySQL/MariaDB. POST /auth/api-keys/create sent expires_at as an ISO string, which a DATETIME column rejects, so every create was a 500; the response’s id was always null (so revoke by id was impossible); a valid key was rejected with 401 because its string expires_at was compared with a datetime; and GET /auth/api-keys/list was a 500 for the same reason. expires_at is stored as a UTC datetime, and an unparseable one is a 400 VALIDATION_ERROR.
  • Logging out and changing the password no longer revoke API keys. POST /auth/logout with logout_type all or others, and POST /auth/change-password, deleted every auth_tokens row of the user, so “log out all devices” or a password change also killed the user’s server-side API keys. They now delete refresh tokens only. A password reset still deletes everything, API keys included. Behaviour change: revoke API keys explicitly (DELETE /auth/api-keys/revoke) when they must end with the session.
  • API keys survive a JWT secret rotation when KIRAK_AUTH_API_KEY_SECRET is set. API keys were stored as an HMAC keyed by KIRAK_AUTH_JWT_SECRET_KEY, so rotating that secret invalidated every key; KIRAK_AUTH_JWT_OLD_SECRET_KEY did not cover them. The new optional KIRAK_AUTH_API_KEY_SECRET keys the hash instead (default: the JWT secret, so nothing changes until it is set). Keys created before it was set are still found by their JWT-secret hash until the JWT secret changes; recreate them to move them onto the new secret. Kirak logs a warning at startup while it is unset.
  • superadmin can manage other users’ API keys. Creating, listing and revoking another user’s API key accepted only admin and system, while the other admin auth routes (generate-verification-link) also accept superadmin. All three now accept admin, system and superadmin.
  • Change-password and the MFA routes no longer fail with 401 for a signed-in user. The auth module dispatched its own operations without an authenticator, so every caller resolved to guest.
  • Users with UUID primary keys can authenticate. The per-request user lookup ran int() on the sub claim, which failed for every UUID.
  • CLI output no longer masks the project path as ***. The secret-name pattern matched the shell’s PWD and OLDPWD, whose values are directories.
  • Dates sent as strings work on PostgreSQL. A filter on created_at, updated_at, deleted_at or a timestamp / datetime / date field with a string value (created_at__gte=2026-09-01 over REST, created_at: {gte: "2026-09-01"} in GraphQL) reached asyncpg as a string and was a 500 (GraphQL: “GraphQL execution failed”); MySQL accepted it. Create and update had the same failure for datetime and date fields (only timestamp fields were parsed). Filter values and written values on these fields are now parsed as ISO-8601 (2026-09-01, 2026-09-01T12:00:00, with Z or an offset converted to UTC) on both engines. Behaviour change: a string that is not an ISO-8601 date ("today", "2026-13-01", a date-time on a date field) is now a 400 VALIDATION_ERROR on MySQL too, where it used to reach the database.
  • Agent native tools can name a model. A native tool ref such as "Tasks.fetch" was looked up only as a module (kirak.tasks), so every model ref failed with “No kirak module for native tool” when the agent called it; only module refs ("Vector.search") worked. Refs now run through kirak.core.operation_refs: a model’s CRUD operation (fetch, search, count, exists, create, update, upsert, delete, destroy, restore; the owner matched as a model key exactly, else lowercased) or a module operation declared in the catalog, as the caller. Breaking: a module ref must name a declared operation (kirak modules NAME --json); other methods of a module (e.g. Payments.prune_webhook_events) are refused. The docs’ "Customer.get" / "Customer.list" examples named operations that never existed; they are now "Customer.fetch". Agent files keep their shape.
  • Migrations now create every table Kirak itself uses. kirak db init / makemigrations left out kirak_rate_limits (from kirak/core/core_models.json), so the rate limiter logged “relation kirak_rate_limits does not exist” and failed open – rate limiting was off in every new project. With the ai module enabled, ai_conversations was neither migrated nor known to the CRUD engine, so saved conversations failed. The runtime, migrations and validation now read one list built from the module specs (kirak/core/builtin_models.py). Migration: existing projects run kirak db makemigrations and kirak db migrate once; the new migration creates the missing tables.
  • A create or upsert that leaves out a required field is a 400 VALIDATION_ERROR, not a 500 DATABASE_ERROR. Only the fields that were sent were validated, so a missing one reached the database’s NOT NULL constraint. Fields filled by an ownership condition or with a default are not required in the payload. Bulk create reports the missing fields per record.
  • kirak db makemigrations no longer drops all developer tables on a models/models.json layout. When models/ contained a single combined models.json (the layout create_kirak_app() examples use), the loader took the per-file branch and explicitly skipped models.json, so the target schema had no developer models. The diff then proposed dropping every developer table and overwrote migrations/models_snapshot.json before review. Fixed by detecting models/models.json as a standalone combined file before the per-file scan. Projects with per-model files alongside a models.json (the legacy skip) are unchanged.
  • Model slug now controls the URL segment for CRUD routes, OpenAPI paths, and GraphQL roots. A model with "slug": "articles" (key "blog_posts") is now reachable at /articles/fetch, /articles/create, etc. in both REST and openapi.json. GraphQL query and mutation roots also use the slug (articles, createArticles). Without a slug the model key is used, so existing apps are unaffected. (model.schema.json, docs/concepts/models.md and docs/reference/http-api.md already documented this behavior.)
  • Auth cookie is now read as a third credential. With auth.cookie_name set, get_auth_dependency() now reads the named cookie as priority 3 (after Authorization: Bearer, after X-API-Key). A browser that sends only the cookie is authenticated; a present-but-invalid cookie raises rather than downgrading to guest. The cookie’s SameSite attribute was corrected from None to Lax, which covers same-site subdomain deployments and provides better CSRF protection. Cross-site deployments continue to use the Authorization header.
  • kirak modules now reports provider installation counts. Modules such as payments, notifications, scheduler and vector have no module-level pip extra (their providers do), so installed: true was always shown even when no provider SDK was available. module_summary now includes providers_installed and providers_total counts (built-in providers only). The list view adds a PROVIDERS column (N/M); the detail view adds a “Providers: N of M installed” line when fewer than all providers are ready. kirak validate was not affected and continues to report provider_not_installed per provider.
  • Breaking: the rate_limits JWT claim no longer overrides per-model rate limits. check_rate_limit raised a model’s max_requests when the Bearer token carried {"rate_limits": {"<model>:<op>": n}}, but Kirak’s own access tokens never carry that claim and an after_login hook cannot add it (the token is signed before the hook runs), so it only worked for tokens an application signed itself. The override and its docs are removed; the claim is ignored. Migration: an app that minted its own tokens with this claim sets the higher limit in the model’s rate_limit config instead (for example a separate model or operation for the paid tier).

  • Breaking: GET /ai/agent/runs and kirak/ai/run_log.py are removed. Every agent run wrote the first 200 characters of its prompt and of its output to Redis, and /ai/agent/runs returned those entries for all users to any signed-in user – one user could read the start of another’s prompts and answers. Its “newest first” was also a sorted sample of an arbitrary Redis scan. Run history is the monitoring module’s: GET /monitoring/ai/runs and /monitoring/ai/runs/{run_id} (monitoring bearer token; no prompt or output text), now listed with the other /monitoring/ai/* endpoints in docs/reference/monitoring.md. Migration: add "monitoring" to modules and read run history from /monitoring/ai/runs with KIRAK_MONITORING_INGESTION_KEY; existing ai:runs:* Redis keys expire on their own within 7 days.

  • Breaking: agent files use the tools map, not an array. tools now uses the same map format as MCP server files: {"customer.fetch": {}, "order.fetch": {"requires_confirmation": true}}. Refs are lowercase (customer.fetch, not Customer.fetch). The string shorthand ("Customer.fetch") and type: "mcp" entries are removed. kirak validate agents now also checks that every tool ref names a real operation. ToolRef is removed from kirak.ai public exports; ToolDeclaration from kirak.core.tool_declarations replaces it for programmatic use.

    Migration: convert the array to a map and lowercase the owner segment:

    "tools": ["Customer.fetch", {"type": "native", "ref": "Order.fetch"}]

    becomes:

    "tools": {"customer.fetch": {}, "order.fetch": {}}
  • Breaking: the old MCP server is replaced by the mcp module. create_kirak_app(include_mcp=..., mcp_prefix=...), mount_routers(include_mcp=..., mcp_prefix=...), the mcp_api_key_required key in kirak.json (now rejected at startup with a message) and the SSE endpoints /mcp/sse and /mcp/messages/ are gone, with the server that exposed every model as nine tools and took a token or api_key argument on each call. Migration: add "mcp" to modules in kirak.json, and write one file per server in mcp/ listing the tools it offers ("tools": {"orders.fetch": {}, ...}), its access level and rate limit; clients connect to /mcp/<name>/ with Authorization: Bearer <JWT or API key> instead of passing credentials as tool arguments. See docs/modules/mcp.md.

  • Breaking: Swagger UI (/docs) and ReDoc (/redoc) are no longer served. /docs is now the Markdown API reference; /openapi.json stays where it was. Migration: import openapi.json into an API client such as Postman, Insomnia or Bruno. Passing docs_url or redoc_url to create_kirak_app() still works as plain FastAPI, but not on the docs.path used by Kirak.

  • kirak.core.doc_generator is removed (python -m kirak.core.doc_generator --models ... --output ...). It wrote static per-model Markdown files and listed upsert and restore with the wrong HTTP methods. A running app now describes its own API at GET /docs.

  • Breaking: agent files are now checked against a schema, and unknown keys are rejected. Until now agents/*.json checked only a few fields, so a mistyped key ("max_turn": 50) was silently ignored and the default used, and a wrongly typed value ("max_turns": "ten") failed only when the agent ran. Every key is now validated at startup against kirak/schemas/agent.schema.json (types, ranges, tool entries), and a file with an unknown key fails to load with a message naming it. Migration: remove or correct the keys named in the error.

  • Breaking: requires_auth in agent files is rejected. It was documented but never read; who may call an agent is set by access ("Guest" for a public agent, the default "User" requires a signed-in caller). A file that still has it fails to load with a message pointing to access. The example agents and docs/modules/ai.md no longer use it.

  • kirak.core.manifest.MANIFEST_SCHEMA and kirak.core.models_schema.MODELS_SCHEMA are removed. The schemas are now JSON files: use kirak.schemas.load_schema("manifest" | "models" | "model" | "agent"), or kirak.schemas.validator(name) for a validator that resolves the references between them.

  • Breaking: the ai_restricted tool flag in agent JSON files is removed. A tool the agent must not use is simply left out of its tools list. An agent file whose tool entry still sets ai_restricted fails to load with a message saying so, rather than silently handing that tool to the agent. The blocked status in agent traces and AI monitoring tool records went with it.

  • Twelve more notification providers, without SDKs. Email: mailgun (US/EU region, domain check), postmark (message streams), sparkpost (US/EU), brevo, resend. SMS: vonage, plivo, messagebird, africastalking (username sandbox uses the sandbox API), termii (account base URL, generic / dnd route), fastsms. Push: expo (Expo push tokens, batches of 100, per-token failures reported like FCM). Each calls the provider’s HTTP API with httpx, so none needs an extra install; each has a credential check (check()) except expo, which has no endpoint for it. The email providers send attachments. Settings and secrets are in docs/modules/notifications.md and the generated provider reference.
  • Monitoring records why an agent’s tool call failed. ai_tool_calls has a new error_message column, returned by GET /monitoring/ai/runs/{run_id}: the tool’s "<ExceptionType>: <message>", with emails, key=value secrets and bearer tokens masked (redact_text) and at most 300 characters. An existing metrics.db gets the column on startup.
  • auth.access_token_expire_minutes in kirak.json: the access token lifetime in minutes, for lifetimes under an hour. When set it wins over access_token_expire_hours (whole hours, minimum 1), for the token expiry, expiresIn, and the auth cookie’s max_age.
  • Every provider in the catalog names its vendor’s website (website), for tools such as Kirak Studio to show a logo: payments, notifications, storage, scheduler and vector providers (kirak providers --json), the AI model providers and the social login providers (kirak catalog --json). null where there is no vendor (local storage, the database queue, smtp, the generic webhook, and the protocol social backends saml, oidc, openid, cas, email, username, and the defunct mineid). Every installed social login backend is covered. Declared on ProviderSpec.website, AIModelProviderSpec.website and SOCIAL_WEBSITES in kirak/catalog/specs/auth.py; a third-party provider sets a WEBSITE class attribute. A test fails when a built-in provider has none.
  • MCP servers from mcp/*.json (the mcp module). With "mcp" in kirak.json modules, each JSON file in mcp/ is an MCP server at /mcp/<name>/, for any MCP client: name, description, instructions (sent to clients), access (Guest, User by default, Admin, System), rate_limit_per_minute (per caller, per IP for guests) and a tools map of the model and module operations it offers ("orders.fetch": {}, "payments.refund_payment": {"requires_confirmation": true}). Tool input schemas come from models.json (filters with operators, paging, data / where) and from the module catalog, with read-only and destructive hints; arguments are checked before anything runs; every call runs as the caller under the models’ access rules; results are the operation’s data and pagination, and Kirak errors come back as tool errors. Callers authenticate with Authorization: Bearer <JWT or API key> or X-API-Key (401 / 403 / 429 before the server sees the request). Streamable HTTP per MCP 2026-07-28 on the MCP Python SDK v2, stateless, so several workers need no sticky routing. Startup refuses invalid server files (schema, duplicate JSON keys, duplicate names, refs that name no operation; new code duplicate_mcp_server). kirak schema and kirak new write .kirak/mcp.schema.json, and kirak new maps mcp/*.json to it. Python tools: @kirak.mcp("<name>").tool (registered in on_kirak_ready) adds a function to a server, with its input schema from the signature and its description from the docstring; it runs as the caller, and startup refuses one on an undeclared server (unknown_mcp_server) or with a name a declared tool has. A tool’s timeout answers TOOL_TIMEOUT. Confirmation: a requires_confirmation tool runs only after a person approves that exact call through the client (MCP 2026-07-28 multi round-trip: a confirmation form, then a retry with the answer); declined is CONFIRMATION_DECLINED, and clients on an older protocol get CONFIRMATION_UNAVAILABLE and the tool does not run. Output schemas where the shape is fixed (fetch, search, exists, and module operations that declare a result in the catalog: the vector operations except delete, and the saved-payment-method list, detach and set-default). With the monitoring module on, each call is recorded in a new mcp_calls table, read through GET /monitoring/mcp/summary and GET /monitoring/mcp/calls. Module operation specs gain an optional result (in kirak modules <name> --json). Tooling: kirak validate mcp (and validate with no kind) checks mcp/*.json like startup does, takes drafts with --file or --override mcp:<name>=PATH, and warns about mcp_without_mcp_module and too_many_mcp_tools; kirak info lists mcp_servers; GET /docs lists each server’s path, access level and tools; kirak new’s AGENTS.md names mcp/. See docs/modules/mcp.md.
  • A shared shape for declaring tools: tools.schema.json and kirak.core.tool_declarations. A tools map keys each tool by the operation it runs – "orders.fetch": {}, "payments.refund_payment": {"requires_confirmation": true} – with the options name, description, requires_confirmation, timeout and, for module operations, parameters. tool_declarations reads a file without losing duplicate keys (loads_config), turns the map into ToolDeclarations (parse_tools) and checks every ref against the app’s models and enabled modules (tool_problems: unknown_tool_ref, unknown_tool_operation, tool_module_not_enabled, tool_not_callable, duplicate_tool_name, …). Groundwork for the new MCP module’s server files; agent files keep their tools array for now and move to this shape later. kirak schema and kirak new now also write .kirak/tools.schema.json.
  • kirak new’s AGENTS.md teaches “Kirak first” – configuration, models with an explicit access block, modules and providers before Python; data only through the facade and kirak.graphql() (never an own database connection or raw SQL); own routes and jobs set the caller with set_user_context; kirak validate --strict after every edit – and points to the coding-agent plugins (kirak-agent-kit). Still under 60 lines.
  • Dev MCP server: kirak_catalog lists social login and AI model providers with the new listing argument (social_providers, ai_model_providers), the same data as those parts of kirak catalog --json.
  • Provider credential checks: await provider.check(). Every provider base class (payments, notifications, storage, vector, scheduler) now has check(live=True, write=False, timeout=10.0), returning a CheckResult (kirak.core.provider_check: status ok / warning / unverified / failed, a code, a one-line message, non-secret detail). The caller builds the provider from explicit config – its kirak.json settings plus its secrets by field name, e.g. AwsS3Provider({"aws_bucket": "assets", "access_key": ..., "secret_key": ..., "name": "main"}, None) – so credentials can be checked before a deploy or from a UI; nothing is read from .env, kirak.json or the environment. The live check is the cheapest read-only call that proves the credentials (nothing billed, sent, created or changed; write=True makes a storage provider also write and delete one object); live=False runs only the checks that need no network. Timeouts and network failures are reported, not raised, and every secret value and URL password is masked in the result. Checks: storage (aws, wasabi: list one object; local: the directory), scheduler (redis: PING; rabbitmq: connect and open a channel; database has no credentials of its own), vector (pinecone: list indexes; s3_vectors: get the vector bucket; openai, google, ollama: the configured model exists), payments (stripe: the balance; stripe_connect: the platform account; razorpay, paddle, paystack, flutterwave, mercadopago, xendit, omise: an account or balance read; square: the configured location; paypal and airwallex: an OAuth token, and for PayPal the webhook named by webhook_id; telr has no read-only call, so only its settings are checked), notifications (aws_ses: the sending quota; aws_sns: the SMS sandbox status; sendgrid: the key’s scopes include mail.send; smtp: connect, STARTTLS and log in, then quit; twilio: the account’s status; firebase and apn: a push to a device token that cannot exist, which proves the key and delivers nothing; huawei: an OAuth token; slack: auth.test with the chat:write scope; discord, telegram: the bot; the generic webhook cannot be checked without sending, so only its url is). Payment providers report whether their key or environment is for test or live mode (detail.mode); a test-mode key, an SES or SNS sandbox and a Twilio trial account are a warning (test_mode_key). New codes in docs/reference/problem-codes.md (credentials_invalid, permission_denied, resource_not_found, provider_unreachable, provider_timeout, …). ProviderSpec.verifies says what each check calls; kirak providers <module> <type> and the provider tables show it. Custom-provider authors: implement _check_live() (and optionally _check_offline()) to give your provider a check; without it check() reports check_not_supported. Helpers in kirak.core.provider_check: http_get / http_request (one request, no retries), http_failure (classifies a non-2xx answer), aws_call (one boto3 call, AWS errors classified), and _mode_result() on the provider for a test/live mode report. See docs/contributing/adding-a-provider.md “Credential check”.
  • Social login credential checks: await check_social_backend(name, settings, secrets) (kirak.auth.social). settings is the backend’s auth.social block, secrets its secret environment variables by name; nothing is read from kirak.json or the environment. For OAuth 2 and OpenID Connect backends – most of social-core’s – it sends the backend’s own token request, built as login builds it, with an authorization code no provider issued: invalid_grant (or GitHub’s bad_verification_code) means the client id and secret were accepted, invalid_client means they were not, redirect_uri_mismatch that the redirect URI was refused. Facebook and TikTok get a client_credentials token instead, OAuth 1 backends a request token; SAML and OpenID 2 have offline checks only. Apple’s client secret is signed from the private key, so a wrong key, key_id or team_id shows as invalid_client. Returns a CheckResult like provider.check(); an answer it cannot classify is unverified. FastAPIStrategy takes an optional environ mapping to read secrets from (default: the process environment). See docs/authentication/social-auth.md “Checking the credentials”.
  • A new project installs its database driver. kirak new --database mysql|postgres (default mysql) writes the matching database block, and the scaffolded pyproject.toml depends on kirak[mysql] or kirak[postgres] instead of plain kirak; it also sets [tool.setuptools] packages = [], without which pip install -e . failed on the flat layout. The next steps start with pip install -e .. kirak validate reports a missing driver as a database_driver_not_installed error (--extras is honoured), kirak info as a warning. Existing projects: change kirak to kirak[mysql] (or kirak[postgres]) in pyproject.toml and add [tool.setuptools] packages = [].
  • kirak dev mcp: a dev MCP server for coding agents. A coding tool (Claude Code, Codex, Copilot, …) starts it over stdio – no port – and gets read-only tools returning the same JSON as the facts commands: project info, modules and providers, config-file schemas, validation (drafts included, nothing written), environment variables, migration status and preview. Live tools report what the app has been doing, from the files it writes in development: recent errors with tracebacks, recent requests, one request’s full trace, recent AI agent runs (from the monitoring store, or kirak.log when monitoring is off), and the running app’s GET /docs (localhost only). Install with pip install "kirak[dev-mcp]". Separate from the runtime mcp module. See docs/guides/ai-coding-agents.md.
  • python -m kirak runs the CLI, for when the kirak script is not on PATH.
  • GET /docs: the running app describes its HTTP API for AI agents. One Markdown document: a quick reference (models, paths, auth, modules, the openapi.json link), authentication, the response envelope, the CRUD endpoints (one table for every model, with what each returns), a compact block per model (fields with read-only marked, access rules, owner fields that create fills from the caller, rate limits), a GraphQL section (introspection is disabled, so this is its schema), each enabled module’s endpoints and notes, and your own routes. Internal models are left out. Built once at startup from the loaded models and mounted routes. The authentication section walks through the session flow (register, login, me, refresh, logout: what each sends and returns), and the CRUD section shows example requests on one of the app’s models, written for a named role. ?role=<role> narrows the document to what that role can use and ?models=a,b to those models; every response has an ETag for If-None-Match. A Not Supported list states what agents most often assume exists; a fetch response is shown whole, with pagination next to data; ownership rules read user (own records: user_id = their user_id); the auth routes are grouped (session, password, email verification, phone OTP, MFA, social sign-in, API keys) with a one-line purpose and their body fields; the admin, monitoring and scheduler routes are named under System APIs but no longer listed; and the Quick Reference links the official SDKs and coding-agent integrations listed in kirak/catalog/specs/resources.py (entries without a URL are not shown). docs in kirak.json turns it off (enabled), moves it (path) or requires a credential (public: false). See docs/guides/api-docs-endpoint.md.
  • openapi.json lists every model’s endpoints. The generic /{model_name}/... CRUD paths are replaced by one set per model (/posts/fetch, /posts/create, …) with request and record schemas, security, the envelopes, rate-limit headers and Kirak rules in x-kirak-* extensions; requests are handled as before. "openapi": {"enabled": false} turns it off.
  • Module notes for API callers (ModuleSpec.api_notes): the few things an agent gets wrong per module, shown in /docs and in kirak modules <name> (api_notes).
  • The auth routes have OpenAPI tags and summaries (auth: session, auth: password, …), so API clients group them too; their internal docstrings (FlutterFlow notes, “JWT required”) no longer appear as descriptions.
  • openapi.public in kirak.json (default true): false makes GET /openapi.json require a signed-in caller or an API key, as docs.public does for /docs. The two are separate; until now docs.public: false left openapi.json, which describes the same models and access rules, open to anyone. See docs/guides/api-docs-endpoint.md.
  • Catalogued API error codes. The stable error values callers branch on are now declared once: CORE_ERROR_CODES in kirak/catalog/specs/core.py and ModuleSpec.error_codes (auth and storage so far). /docs lists those of the running app, kirak modules <name> reports them (error_codes), and the tables in docs/concepts/response-envelope.md and docs/reference/http-api.md are generated from them. A test fails when core, or a module that lists its codes, raises a code it does not list. The hand-written table listed USER_NOT_FOUND, which Kirak never returns: a valid token whose user is gone answers NOT_FOUND. Payments, notifications, ai and vector codes are not catalogued yet.
  • ModuleSpec.system_api marks modules whose routes serve operators and tooling (monitoring, scheduler). /docs lists them, and the core admin routes, under a last “System APIs” section instead of Modules; kirak modules <name> reports it (system_api).
  • Payments.prune_webhook_events(older_than_days=30) deletes the webhook dedup records (payments_webhook_events) older than the cutoff and returns how many went; nothing removed them before, so the table only grew. Call it from a scheduled job. Fewer than 7 days is refused, since gateways retry for days. The payments docs also now say what happens when a provider instance is renamed (its rows keep the old name) and how to rename one safely.
  • PayPal payment provider (type: paypal, no extra install – it calls PayPal’s REST API over httpx, not PayPal’s SDK): one-time payments, capture, verify, refunds, subscriptions (cancel, plan change, pause/resume), reading disputes, and webhooks. initiate_payment creates an order for amount x quantity (amount is the unit amount, as with Stripe; intent CAPTURE, custom_id = the transaction id) and returns its approval link, and refund webhooks mark REFUNDED once the refunds reach amount x quantity; the order is captured from the CHECKOUT.ORDER.APPROVED webhook or verify_payment with one PayPal-Request-Id per order (an ORDER_ALREADY_CAPTURED answer is treated as captured), and only PAYMENT.CAPTURE.COMPLETED completes it. refund_payment takes the capture id and forwards idempotency_key as PayPal-Request-Id (as kirak-<sha256 of operation:user_id:key>, with the operation refund, initiate or off_session so one key never collides across operations, since PayPal scopes it per account and caps it at 108 chars; initiate_payment does the same). Events: payment_completed, payment_failed (PAYMENT.CAPTURE.DECLINED/DENIED), refund_completed, refund_pending. Subscriptions: type: "subscription" with subscription_plan_id (a PayPal plan id) creates the subscription (custom_id = the transaction id) and returns its approve link; BILLING.SUBSCRIPTION.* webhooks keep subscriptions in sync (subscription_updated, subscription_cancelled, subscription_past_due); the first PAYMENT.SALE.COMPLETED completes the initial transaction (payment_completed), later ones save a renewal per sale id (subscription_renewed); expires_on follows PayPal’s next billing time; subscription payments cannot be refunded through refund_payment yet. cancel_subscription is immediate only (at_period_end is NOT_SUPPORTED, 501), update_subscription returns PayPal’s approve link for the buyer, pause_subscription/resume_subscription suspend/activate. get_dispute reads a dispute; submit_dispute_evidence is NOT_SUPPORTED (PayPal takes evidence only as a multipart file upload); CUSTOMER.DISPUTE.* report dispute_created/dispute_updated. Saved methods (Vault v3, needs reference-transaction approval and vaulting enabled on the PayPal account): save_payment_method on a one-time order vaults the buyer’s PayPal account, saved with its consent from the order when PAYMENT.CAPTURE.COMPLETED arrives and before the payment is completed (a subscription with the flag is NOT_SUPPORTED); VAULT.PAYMENT-TOKEN.CREATED activates a pending token (payment_method_saved), VAULT.PAYMENT-TOKEN.DELETED revokes it (payment_method_removed); detach_payment_method deletes the token, set_default_payment_method is local only; saving without paying and attaching a token are NOT_SUPPORTED. charge_off_session creates one order on the vault token (stored_credential merchant/subsequent, hashed PayPal-Request-Id) that PayPal captures in the same call; the row stays PROCESSING (with the capture id, so a replay returns the same gateway_payment_id) until PAYMENT.CAPTURE.COMPLETED, a 422 decline is FAILED with decline_code (kept by a later PAYMENT.CAPTURE.DECLINED), PAYER_ACTION_REQUIRED is REQUIRES_ACTION, and a timeout/5xx/429 is PROCESSING. Each delivery is verified online through PayPal’s verify-webhook-signature API (raw body posted back unmodified) and deduplicated on the event id; there is no time window on PAYPAL-TRANSMISSION-TIME, since PayPal retries for up to 3 days. Secrets KIRAK_PAYMENT_<INSTANCE>_CLIENT_ID, _CLIENT_SECRET, _WEBHOOK_ID; environment is sandbox (default) or live. See docs/modules/payments.md “PayPal”.
  • Paddle payment provider (type: paddle, Paddle Billing, merchant of record; no extra install – it calls Paddle’s REST API over httpx, since paddle-python-sdk needs Python >= 3.11): one-time payments, verify, refunds and webhooks. initiate_payment creates a Paddle transaction from the caller’s price_id or a non-catalog unit price (amount/currency/name, billed quantity times, product tax_category from the new default_tax_category setting, default standard), with custom_data naming the transaction and instance, and returns its checkout.url – the account’s default payment link on an approved domain whose page loads Paddle.js (or checkout_url); no URL fails with PADDLE_CHECKOUT_NOT_CONFIGURED (500). transaction.completed completes the row once with amount = grand total (tax included, all units), quantity = 1 (the ordered quantity kept in ipn_dump.quantity), net_amount = earnings and currency (payment_completed); renewals (origin: subscription_recurring) save a subscription_renewal row per Paddle transaction id (subscription_renewed); transaction.canceled is payment_failed, transaction.payment_failed is not final and reports nothing. refund_payment creates a refund adjustment (full, or a partial amount of a single-item transaction) that Paddle approves later: adjustment.created reports refund_pending, approval records the refund (refund_completed), rejection records nothing; chargeback adjustments report dispute_created. Webhooks are verified locally (HMAC-SHA256 of ts:body, any h1), with no time window on ts, and deduplicated on event_id. Subscriptions: type: "subscription" needs a recurring price_id; subscription.* webhooks keep subscriptions in sync (status upper case, plan, quantity, expires_on from the current billing period), reporting subscription_updated, subscription_past_due or subscription_cancelled by the status set (CANCELED is final); renewals also update the subscription’s amount; plan-change prorations are saved as subscription_update rows with no event. cancel_subscription (immediate or at_period_end), update_subscription (new_plan_id, proration_billing_mode, default prorated_immediately), pause_subscription/resume_subscription, and get_billing_portal (a Paddle customer portal session). charge_off_session charges an active subscription (provider + subscription_id, no saved method; else NOT_SUPPORTED) with a tagged one-off price; transaction.completed (origin subscription_charge) completes it once; a 4xx is FAILED with Paddle’s error code, a timeout/5xx/429 PROCESSING. charge_off_session now uses a named provider that implements off-session charges but not saved methods without looking up a payment method (payment_meta.payment_method_id is null then). Custom-provider authors: a provider implementing SupportsOffSessionCharge without SupportsPaymentMethods now receives method=None when named in provider, and must charge from its own params (as Paddle does with subscription_id) and validate them itself; providers with SupportsPaymentMethods are unchanged. Disputes and saved methods are not supported. Secrets KIRAK_PAYMENT_<INSTANCE>_API_KEY, _WEBHOOK_SECRET; environment is sandbox (default) or production. See docs/modules/payments.md “Paddle”.
  • Paystack payment provider (type: paystack; no extra install – it calls Paystack’s REST API over httpx, since paystack-sdk has had no release since 2022): one-time payments, verify, refunds, subscriptions (cancel, manage link), reading disputes, saved cards, off-session charges and webhooks. Configuration: secret KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY (also the webhook HMAC key); environment is test (default) or live and must match the key prefix; success_url is sent as callback_url when absolute. Amounts are integer subunits = base amount x 100 for every currency, including XOF and RWF (CURRENCY_EXPONENTS). Payments: initiate_payment needs payment_email, stores a random reference kirak-<32 hex> as the row’s transaction_id before calling POST /transaction/initialize (amount x quantity, metadata naming the transaction and instance) and returns Paystack’s authorization_url as url. charge.success is never trusted alone: the row is completed once (payment_completed, with amount and currency) only after GET /transaction/verify/{reference} reports success (or reversed, so a refund can then be recorded) for the same reference, currency and amount (a fee-inclusive amount with an exact requested_amount also matches) and its metadata, where present, names this transaction and instance; a mismatch writes nothing and is acknowledged, while failed, abandoned, an in-progress status or an unknown verify outcome writes nothing and fails (500) so Paystack redelivers. verify_payment sets PROCESSING on a confirmed success and FAILED only on failed. Refunds: refund_payment queues a full or partial refund (a partial amount is in the transaction’s own currency); refund.pending/refund.processing report refund_pending, refund.processed records it with a running total keyed by refund_reference (refund_completed; REFUNDED never moves back), refund.failed/refund.needs-attention are logged and noted on the row. Webhooks are verified locally (hex HMAC-SHA512 of the raw body, constant-time, no timestamp) and, since Paystack events have no id, deduplicated on the sha256 of the raw body; events for references no row of the instance holds are acknowledged without a write. Subscriptions: type: "subscription" with subscription_plan_id (a plan code) adds plan to the initialize call; the first charge.success is confirmed by verify on that plan. Since no Paystack event names both the charge and the subscription it creates, the subscription is bound through GET /subscription?customer=&plan= only when exactly one active subscription there is held by no transaction of the instance, is not saved for another user, is on the first charge’s card (authorization_code, else card signature) and was not created before the charge (both Paystack createdAt times); subscription.create retries the binding (redelivered while a matching first charge from the last 48 hours is not completed yet, reporting subscription_updated); an ambiguous match binds nothing and is logged. The first charge and each renewal set the subscription’s amount to the verified requested_amount when it equals the plan’s amount (fees passed to the customer excluded), else the charged amount. Renewals come from a paid invoice.update, re-verified by its reference, one subscription_renewal row per reference (subscription_renewed, PAST_DUE/NON_RENEWING back to ACTIVE, expires_on never moved back; the renewal’s own charge.success is only logged); invoice.payment_failed sets PAST_DUE (subscription_past_due), subscription.not_renew NON_RENEWING (also without a status; subscription_updated), subscription.disable CANCELLED/COMPLETE (subscription_cancelled). cancel_subscription disables immediately (at_period_end is NOT_SUPPORTED); there is no plan change or pause. get_billing_portal returns Paystack’s manage link. Disputes are read-only: get_dispute reads one, submit_dispute_evidence is NOT_SUPPORTED, and charge.dispute.* report dispute_created/dispute_updated without changing the row. Saved cards: save_payment_method on a one-time payment saves the verified charge’s authorization before the row is completed (never for a settled row), only when Paystack reports it reusable: gateway id authorization_code, customer code, brand/last4/expiry, consent, and the card signature and customer email in meta (only that email can charge it). The newest code of a card replaces the user’s older methods of the instance with the same signature (the default moves to it first; the older codes are revoked in Kirak only and stay chargeable at Paystack). save_payment_method on a subscription, saving without paying and attaching a token are NOT_SUPPORTED. detach_payment_method calls POST /customer/authorization/deactivate (a 404 still revokes the row; a card that started one of the user’s subscriptions that has not ended is revoked locally only, since Paystack may renew on that code); set_default_payment_method is local only. Off-session: charge_off_session stores a new random reference on the PENDING row (never derived from the idempotency_key; retries are replayed from the row) and calls POST /transaction/charge_authorization with the saved email: success leaves the row PROCESSING (returned COMPLETED) for the verified charge.success to complete once, paused is REQUIRES_ACTION with Paystack’s authorization_url as action_url, failed or a 400 is FAILED with decline_code, and a timeout/5xx/429/unreadable reply or duplicate_reference is PROCESSING with outcome unknown. Custom-provider authors: charge_off_session may now return action_url with REQUIRES_ACTION; the operation keeps it and stores it for replays instead of creating a recovery checkout (unchanged when a provider returns none). GET /payments/providers reports billing_portal, dispute_read, off_session, payment_methods, subscription_cancel and subscriptions. See docs/modules/payments.md “Paystack”.
  • Flutterwave payment provider (type: flutterwave, API v3; no extra install – it calls Flutterwave’s REST API over httpx, since rave_python needs Python >= 3.10 and targets the v2 APIs): one-time payments, verify, refunds, subscriptions on a payment plan (cancel), reading chargebacks, saved cards, off-session charges and webhooks. Configuration: secrets KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY and KIRAK_PAYMENT_<INSTANCE>_SECRET_HASH (the dashboard’s webhook secret hash, both required); environment is test (default) or live, a key without that mode’s prefix is logged; success_url is sent as redirect_url when absolute; country is the merchant’s 2-letter ISO country code, checked at startup (off-session charges need it and an absolute success_url, else CONFIGURATION_ERROR before anything is saved). Amounts are major-unit decimal strings with ISO 4217 exponents. initiate_payment needs payment_email, stores a random tx_ref kirak-<32 hex> as the row’s transaction_id before calling POST /v3/payments (amount x quantity, meta naming the transaction and instance) and returns the payment link as url. Webhooks are checked by the verif-hash header (constant time; missing or wrong -> 400), deduplicated on the sha256 of the raw body (events have no id), and never trusted alone: charge.completed re-reads the transaction by id (GET /v3/transactions/{id}/verify) and completes the row once (payment_completed) only for a successful transaction with the same tx_ref, currency, an amount (not charged_amount) of at least amount x quantity, and meta, where present, naming this transaction and instance. A checkout can have several attempts under one tx_ref: a failed attempt keeps the row PENDING (noted in ipn_dump.last_failed_attempt, no event) and a later success completes it; an in-progress status or an unknown outcome writes nothing and fails the webhook (500). verify_payment (payment_id = the tx_ref, optional flutterwave_transaction_id) sets PROCESSING on a confirmed success and never fails a checkout (a failed attempt of a settled payment reports no outcome). refund_payment refunds a completed payment (409 before). A refund is recorded when Flutterwave accepts it (completed, processing, pending-momo or completed-*), under Flutterwave’s refund id, with a running total (REFUNDED never moves back), both by refund_payment and by the refund webhook, which Flutterwave sends only when its support enables it (a bare refund object, re-read with GET /v3/refunds/{id}, recorded only for the transaction that completed the row, reporting refund_completed). A failed refund or payout (meta.disburse_status failed) is not recorded, only noted in ipn_dump.refund_issue; a payout that fails after the refund was recorded leaves the row refunded. Events for a tx_ref no row of the instance holds are acknowledged without a write. Subscriptions: type: "subscription" needs subscription_plan_id, a numeric payment plan id; the plan is read first (GET /v3/payment-plans/{id}: a plan whose amount is not a number is an unknown outcome, a plan in another currency is PLAN_CURRENCY_MISMATCH and one not active PLAN_NOT_ACTIVE, 400, nothing saved), a plan with an amount sets the price (row amount = the plan’s, quantity 1), and payment_plan is added to the payment link (card only). The first charge is confirmed like a one-time payment; its subscription is then bound through GET /v3/subscriptions?transaction_id= only when exactly one listed subscription is active, on the row’s plan and email, held by no other row and not saved for another user (a subscriptions row plus the row’s subscription_id; payment_completed carries subscription_id); none yet, several or an unfiltered reply complete the payment unbound, and verify_payment on the completed row (or a later charge.completed of another attempt of the same checkout) retries the binding of a completed, unbound first charge: call verify_payment after a payment_completed for a subscription that lacks subscription_id. Renewals are not recorded (no subscription_renewed: a renewal charge names no subscription; one on a plan is only logged). subscription.cancelled (no subscription id) marks CANCELLED only this instance’s rows with the event’s email and plan that GET /v3/subscriptions?email=&plan=&status=cancelled lists (paged), reporting subscription_cancelled only when a row was written; an empty list fails the webhook so the redelivery checks again, and the event is not deduplicated on its body hash (a second genuine cancellation on the same plan has the same bytes). cancel_subscription calls PUT /v3/subscriptions/{id}/cancel (immediate only; at_period_end is NOT_SUPPORTED) and marks the row CANCELLED when Flutterwave answers it cancelled (no event is reported for it); plan change, pause and the billing portal are NOT_SUPPORTED. Disputes are read-only: get_dispute reads a chargeback by its id (GET /v3/chargebacks?id=), submit_dispute_evidence is NOT_SUPPORTED, and the opt-in chargeback.initiated (dispute_created) and chargeback.accepted/.declined/.lost (dispute_updated; the undocumented chargeback.won/.reversed are mapped the same way in case they are sent) are re-read by flw_ref and reported for the transaction that completed a row of the instance, without changing it; a chargeback the list does not show yet fails the webhook so it is redelivered. Saved cards: save_payment_method on a one-time payment saves the card token of the verified charge (data.card.token, read from verify, since the webhook has none) before the row is completed (never for a settled row): brand/last4/expiry, consent, and in meta the charge’s email (a token charges only with it), card_identity (first 6 + last 4 digits + expiry; Flutterwave has no card fingerprint) and token_expires_at (the charge’s time plus one year, the token’s documented life). Of the user’s methods of the instance with the same card_identity, only the token that expires last is kept, even when an older checkout’s webhook is handled after a newer one’s (the default moves to it first; the others are revoked in Kirak only); a token already active on the instance for another user is not written (logged); one that user removed is taken over. save_payment_method on a subscription, saving without paying and attaching a token are NOT_SUPPORTED; detach_payment_method is local only (Flutterwave has no token delete API, so the token stays usable there until it expires); set_default_payment_method is local only. Off-session: charge_off_session refuses a card with no saved email (MISSING_PAYMENT_EMAIL) or a card or token past its expiry (PAYMENT_METHOD_EXPIRED, 400) before anything is saved, stores a new random tx_ref on the PENDING row (never derived from the idempotency_key; retries are replayed from the row) and calls POST /v3/tokenized-charges (token, saved email, amount, currency, country, tx_ref, redirect_url = success_url, meta): successful (no-auth accounts only) leaves the row PROCESSING (returned COMPLETED) for the verified charge.completed to complete once, pending with meta.authorization.redirect (3-D Secure, the default) is REQUIRES_ACTION with that URL as action_url, another pending is PROCESSING, failed or a 400 is FAILED with decline_code, any other 4xx marks the row FAILED and raises, and a timeout/5xx/429/unreadable reply is PROCESSING with outcome unknown (call verify_payment if no webhook settles it). A failed attempt on the 3-D Secure page leaves the row REQUIRES_ACTION (like a checkout); a verified failed charge of a PROCESSING off-session row, which no customer can retry, marks it FAILED and reports payment_failed (also through verify_payment), but only when it is the charge the row holds and the row holds no success verify confirmed (a success only the tokenized-charges answer reported does not block it), so a late failed attempt never fails a charge that went through; a failed charge arriving while the row is still PENDING fails the webhook so it is redelivered. GET /payments/providers reports dispute_read, off_session, payment_methods, subscription_cancel and subscriptions. See docs/modules/payments.md “Flutterwave”.
  • Mercado Pago payment provider (type: mercadopago; no extra install – it calls Mercado Pago’s REST API over httpx, since the mercadopago package needs Python >= 3.10 since 3.5.0): Checkout Pro payments (cards, Pix, boleto, OXXO, …), verify, refunds, subscriptions (cancel, amount change, pause/resume), reading chargebacks and webhooks. Configuration: secrets KIRAK_PAYMENT_<INSTANCE>_ACCESS_TOKEN and KIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRET (the application’s Webhooks secret signature, both required); environment is sandbox (default; the sandbox checkout link when returned) or production; success_url is the return URL when absolute (required for subscriptions). Amounts are major-unit JSON numbers built exactly from the decimal; COP must be whole pesos (CURRENCY_EXPONENTS = {"COP": 0}, else AMOUNT_NOT_REPRESENTABLE). Payments: initiate_payment stores a random external_reference kirak-<32 hex> on the row before creating the preference (metadata naming the transaction and instance) and returns its init_point as url. Webhooks are verified by x-signature (hex HMAC-SHA256 of id:<data.id from the query>;request-id:<x-request-id>;ts:<ts>;, constant time, no time window) and deduplicated on the sha256 of the raw body; notifications carry only an id, so the object is always re-read. A payment notification completes the row once (payment_completed, with amount and currency) only for an approved payment with the row’s external_reference, currency, a transaction_amount of at least amount x quantity and metadata, where present, naming this transaction and instance; a rejected or cancelled attempt keeps the row PENDING (the buyer can retry), and a pending Pix/boleto completes on a later notification. verify_payment sets PROCESSING on a confirmed approved payment and never fails the row. refund_payment sends a random X-Idempotency-Key; an approved refund is recorded at once, one in process when a later payment notification shows it approved (refund_completed), keyed by refund id with a running total (REFUNDED never moves back). Subscriptions: type: "subscription" with subscription_plan_id (a preapproval plan id, active, in the requested currency; else PLAN_NOT_ACTIVE/PLAN_CURRENCY_MISMATCH, nothing saved) creates a pending preapproval on the plan’s terms with the row’s reference (a preapproval with the plan id itself needs a card token), binds it at once (a PENDING subscriptions row and the row’s subscription_id) and returns its init_point. subscription_authorized_payment re-reads the charge: the first approved one completes the row (payment_completed, subscription ACTIVE), later ones save a subscription_renewal row per payment id (subscription_renewed), a retried charge sets PAST_DUE (subscription_past_due); the subscription’s own payment notifications are acknowledged. subscription_preapproval stores status, amount and next payment date (subscription_updated, subscription_cancelled when the status changed; CANCELLED is final). cancel_subscription (immediate only), pause_subscription/resume_subscription and update_subscription (another plan’s amount x quantity, same frequency and currency, else PLAN_INTERVAL_MISMATCH) change the preapproval. Disputes are read-only (get_dispute; topic_chargebacks_wh reports dispute_created/dispute_updated for a payment that completed a row of the instance). Saved cards and off-session charges are not supported (a saved card needs its security code for every charge); there is no billing portal. GET /payments/providers reports dispute_read, pause, subscription_cancel, subscription_lifecycle, subscription_update and subscriptions. See docs/modules/payments.md “Mercado Pago”.
  • Xendit payment provider (type: xendit, Invoices; no extra install – it calls Xendit’s REST API over httpx, since xendit-python needs Python >= 3.10): one-time payments, verify, refunds and webhooks. Configuration: secrets KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY and KIRAK_PAYMENT_<INSTANCE>_CALLBACK_TOKEN (the dashboard’s webhook verification token; both required); environment is test (default) or live and must match the key’s xnd_development_/xnd_production_ prefix; success_url is the invoice’s redirect URL when absolute. Amounts are major-unit JSON numbers built exactly from the decimal; IDR must be whole rupiah (CURRENCY_EXPONENTS = {"IDR": 0}, else AMOUNT_NOT_REPRESENTABLE). initiate_payment stores a random external_id kirak-<32 hex> on the row before creating the invoice (metadata naming the transaction and instance), keeps the invoice id and returns invoice_url as url. Webhooks are checked by x-callback-token (constant time) and deduplicated on the sha256 of the raw body; an invoice callback re-reads the invoice and completes the row once (payment_completed) only for a PAID/SETTLED invoice with the row’s external_id, invoice id, currency, a paid_amount of at least amount x quantity and matching Kirak metadata; an EXPIRED invoice marks it FAILED (payment_failed). verify_payment sets PROCESSING (paid) or FAILED (expired). refund_payment calls POST /refunds with the invoice id and a random Idempotency-key; a refund is recorded when Xendit reports it SUCCEEDED, at once or from the re-read refund.succeeded webhook (refund_completed, running total, REFUNDED never moves back). Subscriptions, disputes, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501); GET /payments/providers reports no optional capability. See docs/modules/payments.md “Xendit”.
  • Airwallex payment provider (type: airwallex, Payment Links; no extra install – it calls Airwallex’s REST API over httpx, since Airwallex publishes no Python SDK): one-time payments, verify, refunds, reading disputes and webhooks. Configuration: secrets KIRAK_PAYMENT_<INSTANCE>_CLIENT_ID, _API_KEY and _WEBHOOK_SECRET (all required); environment is sandbox (default, the demo host) or production. Calls use a bearer token from /api/v1/authentication/login, cached per instance until shortly before it expires and fetched again once on a 401. Amounts are major-unit JSON numbers built exactly from the decimal. initiate_payment stores a random reference kirak-<32 hex> on the row before creating a single-use payment link (metadata naming the transaction and instance), keeps the link id and returns the link’s url. Webhooks are verified by x-signature (hex HMAC-SHA256 of x-timestamp + raw body; no time window) and deduplicated on the event id. payment_link.paid re-reads the link and its latest successful payment intent and completes the row once (payment_completed) only for a PAID link with the row’s reference and a SUCCEEDED intent in the row’s currency for at least amount x quantity; otherwise the webhook fails so Airwallex retries. refund_payment creates a refund with a random request_id and metadata naming the reference; ACCEPTED/SETTLED refunds are recorded at once or from refund.accepted/refund.settled (refund_completed, running total, REFUNDED never moves back). get_dispute reads a dispute; payment_dispute.* report dispute_created/dispute_updated for the row the disputed intent completed. Subscriptions, dispute evidence, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501); GET /payments/providers reports dispute_read. See docs/modules/payments.md “Airwallex”.
  • Omise payment provider (type: omise, Opn Payments Links; no extra install – it calls Omise’s REST API over httpx, since the omise package keeps the API key in module-global state, which two instances would share): one-time payments, verify, refunds, reading disputes and webhooks. Configuration: secret KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY (required) and KIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRET (the dashboard’s base64 webhook secret; optional); environment is test (default) or live and must match the key. Amounts are integer minor units with ISO 4217 exponents. initiate_payment creates a single-use link and stores its id as the row’s transaction_id before returning its payment_uri. Webhooks are verified by Omise-Signature (hex HMAC-SHA256 of <timestamp>.<raw body> with the decoded secret; two signatures accepted during a rotation) or, without a secret, by reading the event back, and deduplicated on the event id. charge.complete re-reads the charge and completes the row once (payment_completed) only for a successful, paid charge of the row’s link in its currency for at least amount x quantity; a failed charge keeps the row PENDING. Since Omise does not guarantee webhook retries, verify_payment reads the link’s charges and completes the row itself when one is confirmed (no event). refund_payment refunds the completing charge and records it at once; refund.create records refunds once (refund_completed), including dashboard ones. get_dispute reads a dispute; dispute.* report dispute_created/dispute_updated. Subscriptions, dispute evidence, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501); GET /payments/providers reports dispute_read. See docs/modules/payments.md “Omise”.
  • Telr payment provider (type: telr, Hosted Payment Page; no extra install – it calls Telr’s order.json over httpx; Telr publishes no Python SDK): one-time payments, verify and transaction advice. Configuration: store_id and an absolute success_url in kirak.json (both required), secrets KIRAK_PAYMENT_<INSTANCE>_AUTH_KEY and KIRAK_PAYMENT_<INSTANCE>_ADVICE_SECRET (both required); environment is test (default, test orders) or live. Amounts are major-unit decimal strings with ISO 4217 decimals (three for KWD, BHD, OMR, JOD). initiate_payment stores a random cart id kirak-<32 hex> on the row before method: "create", keeps the order ref and returns the payment page url; a Telr error answer marks the row FAILED (TELR_ERROR). The order is always read with method: "check" before the row changes: a Paid order of the row’s cart id, currency and amount with an authorised transaction completes it once; an expired or cancelled order fails it. verify_payment does this (Telr retries an advice only 3 times, 5 seconds apart, so call it when the buyer returns; no event). A transaction advice must carry a valid tran_check (SHA1 of the advice secret and the transaction fields) for this store; an authorised sale advice runs the same check (payment_completed), an authorised refund advice of the completing transaction is recorded (refund_completed, running total). refund_payment, subscriptions, disputes, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501; refunds need Telr’s per-store Remote API); GET /payments/providers reports no optional capability. See docs/modules/payments.md “Telr”.
  • currency column on transactions and subscriptions (nullable, 3-letter upper-case ISO code) – run your migrations. Every provider’s checkout/payment-link/order creation, PaymentProvider._save_off_session_transaction, and the renewal rows created by Stripe invoice.payment_succeeded and Razorpay subscription.charged now write it, upper case. A completed-payment webhook result (payment_completed, subscription_renewed) also carries currency in result["data"] when the gateway or row has one. amount (Kirak minor units) is carried next to it only by PayPal, Paddle and Stripe/Stripe Connect off-session charges (payment_intent.succeeded); Stripe checkout and invoice.payment_succeeded, Razorpay and Square report currency alone, so read the amount from the transaction row there. Rows written before this column existed stay NULL – nothing is backfilled or guessed. See docs/modules/payments.md “Money & Amounts” and “Events”.
  • Vector module (kirak.vector, add vector to modules): vector stores and embeddings for retrieval-augmented generation. Two independent provider categories, configured under vector.store and vector.embedding in kirak.json like the other modules’ named provider instances: stores pinecone (no extra install) and s3_vectors (Amazon S3 Vectors, pip install "kirak[vector-s3vectors]"), embedding providers openai, google (Gemini) and ollama (no extra installs). Operations create_index, delete_index, list_indexes, describe_index, upsert (items carry values or text, which is embedded first), delete (by ids or filter), fetch, search (by vector or text), embed and embed_batch, each with before_/after_ hooks. The module has no HTTP endpoints by design (index management is infrastructure, embed would spend the app’s quota, raw search/fetch would expose the whole index); apps call it from their own routes, hooks and agent tools. Index management and writes need an admin or system caller; reads need any authenticated caller. upsert and search reject a vector whose length differs from the index’s dimension (VECTOR_DIMENSION_MISMATCH). Embedding providers batch large inputs and retry rate limits and server errors with backoff. Secrets: KIRAK_VECTOR_STORE_<INSTANCE>_<FIELD> and KIRAK_VECTOR_EMBEDDING_<INSTANCE>_<FIELD>. Custom providers: kirak.vector.register_provider("store" | "embedding", type, cls) or the entry-point groups kirak.vector_store_providers and kirak.vector_embedding_providers. See docs/modules/vector.md and examples/17_vector_rag.py, 18_custom_vector_provider.py, 19_document_ingestion_recipe.py.
  • kirak --version (or -V) prints the installed Kirak version, e.g. kirak 0.1.1. kirak info also shows it, with the project’s details.
  • Generated reference tables. scripts/gen_reference.py now also writes, from kirak.catalog and the JSON Schemas, the modules list, every module’s kirak.json keys, every provider’s settings and secrets, the environment variables Kirak reads and the AI model providers, to docs/reference/generated/. docs/reference/configuration.md, docs/reference/monitoring.md and the new docs/guides/ai-coding-agents.md guide include them instead of hand-written copies, and a test fails when they are out of date. New contributor pages: docs/contributing/adding-a-module.md, docs/contributing/changing-a-schema.md.
  • kirak new scaffolds for editors and coding agents. kirak.json and models/posts.json get a "$schema" key, the schemas are written to .kirak/, .vscode/settings.json maps kirak.json, models/*.json and agents/*.json to them, .env.example is generated from the project’s kirak.json (the variables kirak env lists), and a short AGENTS.md tells a coding agent which kirak to run, the facts commands and to validate after every edit. kirak new --here makes the current directory the project, keeping files that already exist.
  • kirak db makemigrations --dry-run [--json] shows the MySQL and PostgreSQL SQL the next migration would contain, without writing the migration file or the snapshot and without a database connection.
  • kirak validate and kirak.validation.validate_project(): every problem in a project’s models, kirak.json and agent files at once, each with a stable code (docs/reference/problem-codes.md, generated). It runs the checks startup runs plus unknown and missing provider settings, secrets written in kirak.json, missing relationship targets, decimal scale above precision, extras that are not installed and unset environment variables. Drafts can be checked before they are written (--file, --override, stdin). See docs/reference/cli.md.
  • kirak env and kirak.catalog.env_requirements(): every environment variable a project needs – core, auth, enabled modules, each configured provider instance’s secrets, enabled social logins, the AI model providers its agents use – with purpose, whether required and whether set (names only, never values; --env-names-file checks another runtime’s names). Every variable Kirak reads is now declared in kirak/catalog/specs/, and a test fails on an undeclared one. See docs/reference/cli.md.
  • Social login providers in kirak catalog (social_providers) and a regenerated docs/reference/social-providers.md: every backend of the installed social-core plus Kirak’s own, with a readable name, description, protocol, class path, kirak.json block and keys, secret environment variable, the extra social-core settings its code reads (and which of them kirak.json cannot supply yet), and the package to install when one is missing.
  • kirak modules, kirak providers and kirak catalog, and the kirak.catalog Python package: every module and provider Kirak offers – installed or not, with install hints – and, per provider, its settings (type, default, required), secrets with their environment variable pattern, capability mixins and a kirak.json example; per module, its settings schema, internal models and hook events; plus core facts, social login and AI model providers. Inside a project they also report enabled modules and configured instances. --extras checks against the extras of a target runtime instead of the current Python. Built-in modules and providers are now declared once in kirak/catalog/specs/ (ModuleSpec, ProviderSpec); the provider registries and each class’s SECRET_FIELDS read from there, and tests check every spec against its code. Providers from other packages are listed from their entry points. See docs/reference/cli.md and docs/contributing/adding-a-provider.md.
  • kirak info shows a project’s Kirak version, enabled modules, models, agents, migrations and whether its exported schemas are current, read from the files only. --json on kirak info and kirak db status writes one JSON document (kirak_version, command, ok, data, problems) with exit codes 0 (no errors), 1 (errors found) and 2 (not a Kirak project), for coding agents and scripts; no secret value from the environment or .env appears in it. See docs/reference/cli.md#json-output.
  • kirak schema and packaged JSON Schemas for models.json (models.schema.json, and model.schema.json for one model or one file in models/), kirak.json (manifest.schema.json) and agent files (agent.schema.json), shipped in kirak/schemas/ with a description of every key. kirak schema writes them to .kirak/ in the project, stamped with the installed Kirak version; kirak schema --print <name> prints one. The loaders validate with the same files. Point an editor at them with a top-level "$schema" key in models.json, a model file, kirak.json, kirak.local.json or an agent file; Kirak ignores that key when loading. Startup logs a warning when .kirak/ holds schemas from another Kirak version. See docs/reference/cli.md.
  • Breaking: three ways to register a hook are removed: HookBuilder.register(), BaseModule.register_hook() and Kirak.register_hook(). Two forms remain: the decorator (@kirak.on(model).hook(event), or call it directly as kirak.on(model).hook(event)(handler) for programmatic registration) and hook classes (register_hook_class/register_hook_classes, for grouping and isolating a project’s hooks). kirak.on("posts").register("after_create", handler) -> kirak.on("posts").hook("after_create")(handler); kirak.register_hook("posts", "after_create", handler) -> the same; kirak.auth.register_hook("after_login", handler) -> kirak.auth.hook("after_login")(handler). See docs/concepts/hooks.md.
  • Breaking: the debug and disable_graphql_introspection keys are removed from kirak.json. Neither had any effect (nothing read debug, and GraphQL introspection is always rejected). A kirak.json that still has one fails to load with a message; delete the key. Use log_level to control logging.
  • Breaking: the bare payments and notifications pip extras are removed. Install payments-stripe, payments-razorpay, payments-square or all-payments, and notifications-ses, notifications-sns, notifications-sendgrid, notifications-smtp, notifications-firebase, notifications-twilio, notifications-apn or all-notifications. pip install "kirak[payments]" and "kirak[notifications]" from an older guide now fail.
  • kirak.local.json: optional per-environment file merged over kirak.json (objects merge, lists and values replace) so the database host, base_url and CORS origins can differ per environment without environment variables. See docs/reference/configuration.md.
  • Notification channels im and webhook: kirak.notifications.send_im (Slack, Discord, Telegram) and send_webhook (a signed HTTP POST to your own systems), each with named provider instances, a default_provider and an optional provider per call, configured under notifications.im and notifications.webhook in kirak.json. HTTP routes /notifications/send-im and /notifications/send-webhook; hooks before_/after_send_im and before_/after_send_webhook; send_multi accepts im and webhook as channels; users can opt out of each through channel preferences. Webhook requests carry X-Kirak-Timestamp and X-Kirak-Signature (sha256= HMAC over <timestamp>.<body>) when the instance has a secret, and a per-call URL is refused unless the instance sets allow_url_override. Custom providers subclass IMProvider or WebhookProvider (entry-point groups kirak.notifications_im_providers and kirak.notifications_webhook_providers). See docs/modules/notifications.md and examples/16_custom_im_provider.py.
  • New notification providers: Twilio (sms, kirak[notifications-twilio]), Apple Push (push type apn, kirak[notifications-apn]) and Huawei Push Kit (push type huawei, no extra install).
  • kirak.notifications.send_inbox and POST /notifications/send-inbox: the name for the in-app call. send_notification and /send-notification remain as aliases with the same behaviour.
  • scheduler.job_timeout (kirak.json): seconds a job may run before it is stopped and retried, also the basis for recovering jobs orphaned by a crash (default 3600). See docs/modules/scheduler.md.
  • QueueBackend._stale_after(): for custom scheduler backends, the number of seconds a job must have been running before reset_stale may reset it.
  • scheduler.queues (kirak.json): extra queue names the worker consumes besides default_queue (default []). See docs/modules/scheduler.md.
  • PaymentProvider._claim_completion(transaction_id, data): marks a transaction completed once and returns whether this call did it. Custom payment providers use it with "already_processed": True in the webhook result to skip the completed hook on a repeated delivery. See docs/contributing/adding-a-provider.md.
  • Provider instances for Payments, Storage, Notifications and the Scheduler: kirak.json now lists named provider instances per module ("providers": {"main": {"type": "aws", ...}}) with a default_provider. Several instances can be active at once, including two of the same type. Callers choose one with "provider": "<instance name>" in the payload (JSON body, form field or query parameter on the HTTP routes); omitted means the default. Only listed instances can be used. Notifications have one set per channel (notifications.email, .sms, .push). See docs/contributing/adding-a-provider.md.
  • Custom providers without a kirak-core change: kirak.payments.register_provider(type, cls), kirak.storage.register_provider(type, cls), kirak.notifications.register_provider(channel, type, cls) and kirak.scheduler.register_provider(type, cls) (all also usable as decorators), plus entry-point discovery in the groups kirak.payments_providers, kirak.storage_providers, kirak.notifications_{email,sms,push}_providers and kirak.scheduler_providers, so a pip-installed package can add a type with no registration code.
  • Per-instance secrets: each instance reads its own environment variables, KIRAK_<MODULE>_<INSTANCE>_<FIELD> (Notifications add the channel: KIRAK_NOTIFICATION_EMAIL_<INSTANCE>_<FIELD>). A provider only ever sees its own config, and a secret written in kirak.json is rejected.
  • Scheduler runs several backends at once: every configured backend is connected at startup and gets its own worker loop and concurrency limit. enqueue takes provider and returns it; @kirak.scheduler.cron(..., provider=) fires a cron on a named backend. Dynamic (database-defined) schedules always run on the single database provider.
  • kirak.core.providers.assert_provider_conforms: a static check for provider tests (base class, abstract methods, TYPE_NAME, SECRET_FIELDS, constructor signature, EVENT_MAP).
  • AI module rebuilt on pydantic-ai (kirak[ai]): kirak.ai.generate/chat/structured/run_agent now run through pydantic-ai, adding conversation sessions (Redis-backed, conversation_id), history trimming to the model’s context window plus Anthropic’s server-side compaction (no settings), declarative agents loaded from agents/ JSON files with auto-registered per-agent HTTP endpoints, human-in-the-loop tool approval (requires_confirmation -> APPROVAL_REQUIRED / POST /ai/agent/resume), goal-seeking loops (goal_condition, max_iterations), an LLM reviewer (reviewer_model, quality_rubric), prompt-injection and credential-leak guards, and run history logging. See docs/modules/ai.md.
  • agent(name).tool decorator (kirak.ai): scopes a plain function to one named agent as a KirakTool, usable via kirak.agent(agent_name).tool. The JSON’s requires_confirmation and timeout take precedence over the decorator’s defaults.
  • Models directory support: models_path can now point to a directory of *.json files; all files are merged into one config on startup with collision detection (ConfigurationError on duplicate model names).
  • Full Redis integration (kirak[redis]): Set KIRAK_REDIS_URL to activate Redis-backed sliding-window rate limiter, per-key-TTL token blacklist, and per-key-TTL OTP storage – all transparently replacing DB-backed implementations with zero code changes. DB implementations remain as fallback when Redis is not configured. Redis auth client is gracefully closed on app shutdown.
  • kirak.json manifest (kirak/core/manifest.py): Optional project-level config file searched for next to models.json or in cwd. Supports modules, cors, rate_limit, and custom keys. Module list from kirak.json is used as the enabled-module default when create_kirak_app() is called without modules=. Accessible at runtime as kirak.manifest. Full JSON Schema validation on load.
  • Per-model API rate limiting (rate_limit key in models.json): Add "rate_limit": {"max_requests": 100, "window_seconds": 60, "per": "ip"} to any model to enforce rate limits on all its CRUD routes. Supports "per": "ip" (default), "per": "user" (JWT sub), or "per": "model" (global). Returns HTTP 429 with Retry-After header. Uses Redis sliding window when KIRAK_REDIS_URL is set; falls back to DB fixed-window.
  • MCP server module (kirak/mcp/): Introspects kirak.models to generate one MCP tool per model per operation (fetch, search, count, exists, create, update, upsert, delete, destroy). Tools require a JWT token parameter and respect the same RBAC as the REST API. Enable via include_mcp=True in create_kirak_app(); MCP clients connect at /mcp/sse (SSE transport). Install with pip install 'kirak[mcp]'.
  • Standalone auth micro-service (create_auth_app()): Deploy the auth module as a self-contained FastAPI service without exposing CRUD routes. Accepts an optional models_path; falls back to a built-in auth_models.json covering users and auth_tokens. Wires up auth hooks via on_auth_ready callback. Existing embedded-mode (kirak.auth) is fully backwards-compatible.
  • API doc generator: kirak docs generate --output docs/api/ produces one Markdown file per model with endpoints, field schema, and access roles.
  • Payments operations fire before_<op> / after_<op> hooks: initiate_payment, verify_payment, refund_payment, get_billing_portal, webhook, onboard_merchant, create_onboarding_link, get_merchant_status, connect_checkout and update_merchant_fee now run through dispatch like the Auth, AI, Storage and Notifications operations. See docs/modules/payments.md.
  • data["event"] on after_webhook: the provider-neutral event name (payment_completed, refund_completed, …), taken from the provider’s EVENT_MAP; None for an unmapped event or a repeated delivery.
  • BaseModule.validate_startup(): a module can raise ConfigurationError at startup, outside the router-mount error handling.
  • Refunds are recorded on the transactions row and deduped by the gateway’s own refund id. New nullable columns refunded_on and refunded_meta ({"refunds": [{"id", "amount", "at"}]}); PaymentProvider._record_refund(transaction_id, refund_id, amount, ipn_dump) sets REFUNDED and appends the refund in one update, returning False (writing nothing) when that refund id is already recorded. Adopted in Stripe, Razorpay and Square. Stripe/Stripe Connect checkout rows also now store the real payment_intent in transaction_id (was stuck on the checkout session id), so refund lookups by payment intent find the row.
  • Payment capability mixins and introspection: SupportsSubscriptionLifecycle (cancel_subscription, update_subscription), SupportsPause (pause_subscription, resume_subscription), SupportsDisputes (get_dispute, submit_dispute_evidence) and SupportsPaymentMethods (interface only, no implementer yet) in kirak/payments/providers/capabilities.py. A provider opts in by also subclassing the mixin; PaymentProvider.capabilities introspects which ones a given instance implements. Stripe, Razorpay and Square implement all three built ones. The six new operations (cancel_subscription, update_subscription, pause_subscription, resume_subscription, get_dispute, submit_dispute_evidence) run through dispatch like every other operation and raise NOT_SUPPORTED (501) for a provider that has not opted in. Subscription-lifecycle/pause operations authorize by the subscription row’s own user_id (never a request-supplied one); disputes are admin/system only, same reasoning as refund_payment.
  • Razorpay implements SupportsSubscriptionLifecycle, SupportsPause and SupportsDisputes: cancel_subscription (POST /subscriptions/:id/cancel, cancel_at_cycle_end), update_subscription (PATCH /subscriptions/:id, plan/quantity/schedule_change_at), pause_subscription/resume_subscription (POST .../pause and .../resume), get_dispute/submit_dispute_evidence (Razorpay calls the latter “contest a dispute”; the evidence dict is forwarded as-is, same as Stripe). The subscription.paused and subscription.resumed webhook events are now handled (mapped to after_subscription_updated, reusing the existing subscription.updated handler) so a pause/resume API call’s effect on payments_subscriptions is kept in sync the same way Stripe relies on its own webhooks for this.
  • Square implements SupportsSubscriptionLifecycle, SupportsPause and SupportsDisputes: cancel_subscription (Square’s cancel endpoint always schedules cancellation for the end of the current billing period; there is no immediate-cancel variant, so at_period_end has no effect), update_subscription (Square’s swap_plan action; Square has no per-subscription quantity concept, so quantity is ignored), pause_subscription/resume_subscription (Square’s own PAUSED status was already handled by the existing subscription.updated webhook handler, so no webhook changes were needed), get_dispute/submit_dispute_evidence (Square’s dispute status field is called state, not status; evidence submission is a create-then-submit flow – params["evidence"]["evidence_text"] is uploaded via create_evidence_text and then finalized with submit_evidence. Square also supports file evidence via a separate binary-upload endpoint, which does not fit this generic dict-based interface and is not supported here).
  • GET /payments/providers: lists each configured instance’s name, type, and capabilities (including billing_portal and connect, detected separately from the 4 ABC mixins), so a caller/UI can check what is available before calling a capability-gated operation instead of finding out from a 501.
  • Idempotency-key support on initiate_payment: an optional idempotency_key in params short-circuits a repeat call with the same (user_id, idempotency_key) to the original transaction instead of reaching the provider again.
  • idempotency_key is now forwarded to Stripe’s own API on refund_payment (regular and Connect) and checkout session creation (regular and Connect), when the caller supplies one. This protects the outbound call itself from a network-level retry (a timeout, a double-click, infra retrying an HTTP call) creating a second refund or checkout session at Stripe – initiate_payment’s own idempotency-key check only short-circuits a distinct, later call, not the same call’s own retry. refund_payment did not accept an idempotency_key at all before this.
  • idempotency_key is now forwarded to Razorpay’s refund_payment as the X-Refund-Idempotency header, Razorpay’s documented idempotency mechanism for that endpoint, when the caller supplies one. Payment Links and Subscriptions have no documented Razorpay idempotency mechanism and are unchanged.
  • Square’s outbound idempotency keys now reuse the caller’s idempotency_key: refund_payment, payment-link/order creation and subscription-link creation already sent an idempotency_key on every call, but generated a fresh random one each time, so a retry of the same logical request got a different key and Square’s own dedup never recognized it as a duplicate. They now reuse the caller-supplied key when present, falling back to a generated one only when absent (unchanged behavior for callers that do not supply one).
  • Shared webhook-event dedup: PaymentProvider._kirak_event_already_processed/_kirak_mark_event_processed, backed by a new webhook_events model (event_key = "service:event_id", unique). Adopted in Stripe’s handle_webhook, which now checks before dispatching and marks after a successful dispatch. Razorpay/Square adoption is follow-up work.
  • Stripe implements SupportsDisputes: charge.dispute.created/charge.dispute.closed mark the transaction DISPUTED/DISPUTE_WON/DISPUTE_LOST and report dispute_created/dispute_updated through after_webhook.
  • Stripe Connect now handles charge.refunded: previously had no refund-webhook handling at all, so a refund on a Connect checkout never updated the transaction or reported an event.
  • Webhook replay-protection timestamp checks for Razorpay and Square: both only did an HMAC comparison before, so a captured valid payload+signature pair could be replayed indefinitely. Both now compare the signed created_at to now (300s tolerance); missing created_at is allowed through since freshness cannot be judged either way.
  • user_id, customer_id and subscription_plan_id surfaced in the Stripe checkout webhook result: user_id was previously only set for the subscription path (never for one-time payments); the Stripe customer id and the subscription’s plan (Price ID) were computed internally but never returned, so a hook reacting to a payment had no way to update its own app’s user/billing records.
  • Breaking (custom payment providers only): SupportsPaymentMethods changed shape. It now requires detach_payment_method and set_default_payment_method (each receives {"method": <payment_methods row>}); start_payment_method_setup and attach_payment_method are optional and default to NOT_SUPPORTED (501); list_payment_methods is gone (the payments module reads its own payment_methods table). No built-in provider implemented the old interface. New SupportsOffSessionCharge mixin (charge_off_session), reported as the off_session capability.
  • Saved payment methods and off-session charges: kirak.payments.setup_payment_method, attach_payment_method, list_payment_methods, detach_payment_method, set_default_payment_method and charge_off_session, routes under /payments/methods, new models payment_customers and payment_methods (run your migrations), initiate_payment(save_payment_method=True, consent=...), and webhook events payment_method_saved, payment_method_removed and payment_action_required. The gateway keeps the card; Kirak stores only its ids. charge_off_session is admin/system only and requires idempotency_key. New nullable unique transactions.idempotency_scope column ("<user_id>:<idempotency_key>", run your migrations) makes two concurrent charges with the same key charge once; a replay returns the original transaction_id, status, gateway_payment_id and, when present, decline_code/action_url. save_payment_method=True raises NOT_SUPPORTED (501) on a provider that cannot save during a payment (Razorpay, Stripe Connect; SupportsPaymentMethods.SAVES_DURING_PAYMENT). Detaching the default promotes the user’s newest other active method. A Razorpay recurring-payment decline returns FAILED (decline_code BAD_REQUEST_ERROR) instead of raising. See docs/modules/payments.md.
  • Stripe Connect off-session charges: charge_off_session on a stripe_connect instance accepts merchant_user_id or stripe_account_id and charges the saved method as a destination charge carrying the merchant’s platform fee, same fee/merchant checks as connect_checkout. New optional KIRAK_PAYMENT_<INSTANCE>_PLATFORM_WEBHOOK_SECRET: Stripe delivers these events (and saved-payment-method and connect_checkout events) from a separate platform-account webhook endpoint with its own signing secret; handle_webhook tries webhook_secret first, then platform_webhook_secret. See docs/modules/payments.md#stripe-connect.
  • Granular subscription/dispute capability mixins and a subscriptions flag: SupportsCancelSubscription (cancel_subscription) and SupportsUpdateSubscription (update_subscription) in kirak/payments/providers/capabilities.py, with SupportsSubscriptionLifecycle now the combination of both (unchanged for Stripe, Razorpay and Square, which already subclass it directly and so implement both). Likewise SupportsDisputeRead (get_dispute) and SupportsDisputeEvidence (submit_dispute_evidence), with SupportsDisputes now their combination. operations/capabilities.py checks the granular mixin per operation, and GET /payments/providers reports the granular capability names (subscription_cancel, subscription_update, dispute_read, dispute_evidence) alongside the existing combined ones (subscription_lifecycle, disputes) for a provider that implements both halves – lets a gateway that can cancel a subscription but not change its plan, or read a dispute but not submit evidence, opt into only the half it supports. New PaymentProvider.SUPPORTS_SUBSCRIPTIONS class flag (default True), reported as the subscriptions capability: a provider whose checkout cannot start a subscription sets it False, and initiate_payment then raises NOT_SUPPORTED (501) for "type": "subscription" instead of mis-starting one. See docs/modules/payments.md.
  • public_url and acl settings for aws and wasabi storage instances. public_url (a CDN or custom domain) becomes the base of the URLs that uploads, get_url without expires and list return, instead of the bucket’s own URL; presigned URLs still point at the bucket. acl (private or public-read) is sent with every upload; unset, no ACL is sent, as before. Any other acl value is a ConfigurationError. See docs/modules/storage.md “Public URLs of S3-type instances”.
  • Storage providers r2 (Cloudflare R2), spaces (DigitalOcean Spaces) and cubbit (Cubbit DS3). All three are S3-compatible and come with kirak[storage]; unlike aws, they need both KIRAK_STORAGE_<INSTANCE>_ACCESS_KEY and ..._SECRET_KEY. r2 builds its endpoint from account_id and jurisdiction (default, eu, fedramp) and returns public URLs only through public_url, since R2’s S3 endpoint is never public; acl is refused (R2 has no object ACLs). spaces takes the datacenter in aws_region (e.g. fra1), uploads with acl public-read by default and returns https://<bucket>.<region>.digitaloceanspaces.com URLs (set public_url for the CDN). cubbit defaults to https://s3.cubbit.eu and eu-west-1. check() lists one object (and with write, writes and deletes one), naming the service in its messages; r2 and spaces also check account_id / aws_region offline. See docs/modules/storage.md “Providers”.
  • Storage providers ovh (OVHcloud Object Storage) and b2 (Backblaze B2). S3-compatible, with kirak[storage]; both keys are required. aws_region picks the endpoint (https://s3.<region>.io.cloud.ovh.net, https://s3.<region>.backblazeb2.com) and is checked offline. ovh returns https://<bucket>.s3.<region>.io.cloud.ovh.net URLs and accepts acl; b2 returns https://s3.<region>.backblazeb2.com/<bucket>/<path> URLs and refuses acl, since B2 sets ACLs per bucket and rejects a different one on a file. See docs/modules/storage.md “Providers”.
  • Storage provider gcs (Google Cloud Storage), installed with pip install "kirak[storage-gcs]" (includes kirak[storage]). Credentials: KIRAK_STORAGE_<INSTANCE>_CREDENTIALS_JSON (a service account key) or, when unset, Application Default Credentials; with neither, building the instance is a ConfigurationError. Settings: bucket, project_id, public_url, and signing_service_account, which signs get_url(expires=...) URLs through IAM signBlob when there is no key. Deleting a missing file succeeds, as on S3; list follows every page. check() lists one object (with write, uploads and deletes one) and, under ADC with signing_service_account, signs one URL: a refused signature is a warning (permission_denied); detail.signed_urls says whether signed URLs will work. New helper kirak.core.provider_check.gcs_call for custom providers, like aws_call. See docs/modules/storage.md “Google Cloud Storage”.
  • Storage provider azure (Azure Blob Storage), installed with pip install "kirak[storage-azure]" (includes kirak[storage]); kirak[all-storage] installs every storage SDK. Uses the async SDK. Credentials, first match wins: KIRAK_STORAGE_<INSTANCE>_CONNECTION_STRING; account_name with ..._ACCOUNT_KEY; or account_name alone with the app’s Azure identity (DefaultAzureCredential). Settings: container, account_name, account_url (sovereign clouds, Azurite), public_url. Uploads set the blob’s content type; deleting a missing blob succeeds; list follows every page. get_url(expires=...) returns a SAS URL signed with the account key or, under an Azure identity, a cached user delegation key (at most 7 days). check() lists one blob (with write, uploads and deletes one) on a client of its own, and under an Azure identity also gets a user delegation key: a refusal is a warning (permission_denied); a malformed account_key fails offline. New helper kirak.core.provider_check.azure_call. See docs/modules/storage.md “Azure Blob Storage”.
  • Storage providers can close their clients on shutdown. StorageProvider.close() (async, a no-op by default) is called for every instance that was created, when an app built with create_kirak_app(modules=["storage", ...]) shuts down; Storage.close() does it, and one failing provider does not keep the others open. ProviderSet.created() lists the instances built so far.
  • Breaking: built-in models cannot be overridden. Kirak’s own tables (users, auth_tokens, auth_social, …, and the tables of enabled modules) are loaded after your models, at runtime and in kirak db makemigrations, so a model of yours with the same name – a users model in models/, which earlier versions let you use to extend the users table – is silently replaced by the built-in one, fields, id_type and access rules included. kirak validate said such a model “replaces” the built-in one; the warning (model_name_reserved) now says it is ignored. The create_auth_app() docstring (which required models_path to define users), docs/getting-started/installation.md (“extend the built-in users model”), docs/authentication/standalone-auth.md and the examples no longer suggest otherwise, and docs/concepts/models.md states the rule. Migration: move fields you added to users into a model of your own linked by user_id.
  • Breaking: the scheduler routes return the standard envelope. Every /scheduler/* success response was {"success": true, ...} with the values at the top level. They are now {"statusCode", "status", "message", "data"} like the rest of Kirak: POST /scheduler/enqueue -> data: {job_id, provider, run_at}, GET /scheduler/registered -> data: {tasks, crons, providers, default_provider}, POST /scheduler/schedules/{id}/run -> data: {job_id, run_at}, DELETE /scheduler/jobs/{id} -> data: {job_id}, DELETE /scheduler/schedules/{id} -> data: {id}; the list routes keep the rows in data and drop count (the number of rows returned). Migration: read these values from data; use the length of data instead of count.
  • Behaviour change: cron expressions are range-checked. @kirak.scheduler.cron(...) and POST/PUT /scheduler/schedules accepted values outside a field’s range (99 99 * * *) and expressions that can never fire (0 0 31 2 *); both registered and silently never ran. They now raise ValueError (422 over HTTP), as do a step below 1 and a range that matches nothing (5-3). Weekday 7 is still accepted as Sunday. Migration: an app that registered such an expression fails at startup with a message naming it; fix or remove it. Stored schedules are not re-checked; a bad one keeps never firing and shows next_run_at: null.
  • Behaviour change: before_enqueue changes are queued. The hook’s return value was merged into the enqueue result but the job was queued with the original values, so the result could report a queue or run_at the job did not have. Changes a hook makes to payload, queue, priority, run_at and max_retries now apply to the job; task and provider cannot be changed. The hook data now also has priority and max_retries, and the enqueue result has every field the job was stored with. A before_enqueue hook that returns something other than the envelope, {"data": {...}} or None now raises TypeError before anything is queued.
  • Breaking: auth_prefix replaces the /auth path instead of being put in front of it. The auth router carried its own /auth prefix and auth_prefix was added before it, so create_kirak_app(auth_prefix="/api/auth") served /api/auth/auth/login, and create_auth_app(), whose default was "/auth", served /auth/auth/login out of the box. auth_prefix now works like module_prefixes and admin_prefix: it is the whole path of the auth routes, and the default (None) is /auth for both factories and Kirak.mount_routers(). The links Kirak builds follow it: verification and password reset links, the default social redirect_uri ({base_url}{auth_prefix}/{provider}/callback), and the built-in reset page’s API call (it posted to a hardcoded /auth/reset-password). Apps that pass no auth_prefix to create_kirak_app() are unaffected. The Kirak client SDK calls the default /auth routes. Migration: an app that passed auth_prefix="/api" to create_kirak_app() (routes at /api/auth/*) passes "/api/auth" to keep the same URLs. A create_auth_app() service that relied on /auth/auth/* passes auth_prefix="/auth/auth", or moves its clients (and registered OAuth redirect URIs) to /auth/*.
  • Breaking: the token blacklist fails closed. A database or Redis error while checking the blacklist let the token through, and a failed blacklist write on logout was only logged at debug level while logout reported success – so a logged-out token could keep working. Both now fail with 503 AUTH_UNAVAILABLE (auth requests and the storage routes alike), and logout blacklists the access token before deleting the refresh token, so a failed logout deletes nothing and can be retried. Rate limiting still fails open. Migration: during a blacklist outage, authenticated requests get 503 instead of succeeding; clients should retry rather than sign out on AUTH_UNAVAILABLE.
  • Breaking: requests are authenticated from the access token’s claims, not the users table. Every request with a Bearer token (or auth cookie) read the user’s full users row, password hash included, and a hook or nested kirak.* call in the request authenticated again (blacklist and users queries each time; /auth/me twice). The caller’s user_id, role and email now come from the signed token, and a credential is checked once per HTTP request. A plain authenticated fetch runs 3 SQL statements instead of 4; /auth/me runs 2 instead of 4 and still reads the users row for the profile. Migration: a role change, deactivation or deletion now takes effect at the user’s next token refresh (at most the access token lifetime) instead of on the next request – set auth.access_token_expire_minutes low (e.g. 5) if it must apply quickly. Access conditions can use {user_id}, {role} and {email}; any other {placeholder} (for example {first_name}) now resolves to NULL and matches no rows.
  • Breaking: Kirak needs Python 3.10 or newer (requires-python = ">=3.10"; Python 3.9 reached end of life in October 2025). The MCP Python SDK v2 that the MCP extras now use needs 3.10. Projects scaffolded by kirak new declare >=3.10 too. Migration: run Kirak on Python 3.10+; no code changes.
  • The mcp and dev-mcp extras install the MCP Python SDK v2 (mcp>=2.2,<3, the 2026-07-28 MCP spec: stateless Streamable HTTP, multi round-trip requests). The dev MCP server (kirak dev mcp) moved to it with no change in behaviour: same tools, same results. With mcp 1.x still installed, kirak dev mcp now says to upgrade instead of failing on an import. Migration: pip install -U "kirak[dev-mcp]" (or kirak[mcp]).
  • Breaking for catalog readers: module operations carry typed parameters. Every operation of every module (auth, payments, notifications, vector, storage, ai: 64 in all) now declares its params keys in the catalog, with type, description, required, allowed values and defaults; keys only some provider types read list them in x-kirak-providers (e.g. reason on payments.refund_payment is read by Airwallex, Paddle, Square and Xendit). Each operation also says its effect (read, write or destructive, the strongest of any provider: verify_payment is write because PayPal captures during it), whether it is tool_safe (callable with JSON arguments: webhooks, file uploads and browser redirect flows are not), and which provider types support it (providers, null for all). kirak modules <name> --json, kirak catalog --json, kirak.catalog.module(name) and the dev MCP’s kirak_catalog return a module’s operations as these objects instead of a list of names; catalog_format is now 2. Migration: code that read operations as names reads [op["name"] for op in module["operations"]]. Declared in kirak/catalog/specs/<module>.py (OperationSpec, ParamSpec); a test fails when an operation or a payments provider reads a params key its spec does not declare.
  • openapi.json and GET /docs describe module request bodies. Module routes took payload: dict, so openapi.json showed their body as an empty object. A module route now names the operation it runs (openapi_extra=route_operation("payments.initiate_payment"), kirak.core.route_operation), and openapi.json gives it that operation’s description, x-kirak-operation, x-kirak-effect and, for a JSON body, the operation’s parameters as the request body schema, without the ones the route takes from its path or query or fills itself. /docs lists the body fields under each module endpoint (Body: user_id* (string), amount* (integer), ...), only those the configured providers use. Hand-written field lists were removed from the auth route summaries and the payments and AI route docstrings; the /agent/run docstring no longer claims it takes inline tools and model.
  • kirak new’s sample posts model is owner-only on every operation. Users read, search, count, change and delete only their own posts; create carries the user_id = {user_id} condition, so Kirak fills in the owner and a client can no longer create a post for someone else; destroy and restore are admin-only. The old sample let any user create a post with any user_id and read everyone’s posts. Existing projects keep their model; docs/getting-started/installation.md shows the new one.
  • examples/4_custom_routes.py rewritten. It used raw SQL and custom routes that never set the caller (so every Kirak call in them ran as guest), on the internal users table without authentication. It now shows routes that run as their caller, reports built with kirak.graphql() aggregates, and an admin-only report; examples/models/transactions.json gains search rules for the aggregates.
  • An AWS key pair set by half is a ConfigurationError. A storage aws/wasabi or notifications aws_ses/aws_sns instance with only one of access_key / secret_key, or a vector s3_vectors instance with only one of access_key_id / secret_access_key, now fails when the provider is built with ConfigurationError naming the missing one, instead of botocore’s PartialCredentialsError. Set both or neither (neither uses boto3’s default credential chain, as before).
  • Shared helpers for payment providers: PaymentProvider gains _record_refund_total (a refund recorded with a running-total status, for gateways that send no cumulative refunded total), _refund_idempotency_key (a refund key derived from the caller’s idempotency_key and the transaction’s random reference), _create_renewal (a renewal row inserted under a unique idempotency_scope, so concurrent deliveries record one row), _report_verify_completion (reports a verify_payment completion from the next webhook, once), _json_number and _is_number. The Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr providers now use them instead of their own copies. Custom providers can use them too.
  • Payments: unknown currency codes are now rejected with 400. kirak/payments/utils/currency.py adds the full ISO 4217 minor-unit table (ISO_EXPONENTS, ~165 codes) and exponent()/to_gateway()/from_gateway()/format_major() helpers for converting Kirak’s integer minor units to and from a gateway’s own amount representation, without ever rounding (AMOUNT_NOT_REPRESENTABLE, 400, when a gateway’s exponent would lose precision). validate_payment_params now calls exponent() after upper-casing currency; a well-formed but unrecognized 3-letter code (previously accepted and defaulted to 2 decimals downstream) is now rejected as INVALID_PARAMS (400) instead. Amounts a webhook reads back from a gateway (money that already moved) are never rejected: with a currency not in the table – for example BGN, withdrawn 2026-01-01, on a row created before the upgrade – the amount is read best-effort as an integer (unconverted from a minor-unit gateway, assuming 2 decimals from a major-unit one such as PayPal) and a warning is logged, the same as when the gateway sends no currency, so the webhook still succeeds (PaymentProvider._from_gateway_amount_lenient). refund_payment with an amount on such a row still fails with UNSUPPORTED_CURRENCY (400): rows created before the upgrade with a non-ISO currency can only be refunded in full (omit amount) or at the gateway. See docs/modules/payments.md “Money & Amounts”.
  • Stripe and Stripe Connect: ISK and UGX amounts are now converted to Stripe’s 2-decimal representation. Both currencies transitioned to zero-decimal at ISO 4217, but Stripe still requires them represented as 2-decimal with the decimal part always 00 (e.g. 5 ISK/UGX -> Stripe amount 500); previously they were sent as-is, undercharging by 100x (source: docs.stripe.com/currencies “Special cases”, confirmed 2026-09-24). HUF and TWD need no such override – that page’s “must be a whole number” rule for them is for manual payouts, not charges. Stripe, Stripe Connect, Square and Razorpay now build every gateway amount through PaymentProvider._to_gateway_amount() / _from_gateway_amount() (new helpers using each provider’s MAJOR_UNITS/CURRENCY_EXPONENTS class attributes) instead of int(round(float(amount))); amounts read back from a gateway (Stripe invoice amount_paid/amount_due, refunds and subscription checkout totals, Square refunds, Razorpay subscription.charged and refunds) are converted with _from_gateway_amount before being stored. Square and Razorpay already use ISO 4217 minor units for every currency they support, so this is a no-op for them; all other currencies are unchanged for every provider. Stripe Connect’s application_fee_amount (checkout and off-session) is computed from Kirak’s own minor units, like every other amount in the API – only the value actually sent to Stripe is converted – and the connect_checkout response, its ipn_dump and its Stripe metadata all report the fee in Kirak’s minor units, not Stripe’s.
  • refund_payment’s partial-refund amount now converts using the original transaction’s currency when currency is omitted, instead of the provider’s own hardcoded default (usd for Stripe/Stripe Connect, GBP for Square, INR for Razorpay). Without this, an ISK/UGX-style override never applied to a refund, sending 1/100 of the intended amount to Stripe. The lookup is by the transactions row whose transaction_id matches payment_id, preferring a row of the resolved provider instance; with no matching row, the old default-currency behaviour is unchanged. See docs/modules/payments.md “refund_payment”.
  • Breaking: embeddings moved from the AI module to the new Vector module. kirak.ai.embed() and POST /ai/embed are removed, and so is the ai.embedding_model key in kirak.json (a kirak.json that still has it fails to load with a message pointing here). The AI module is now agent and prompt orchestration only. Migration: configure an embedding provider under vector.embedding (types openai, google, ollama; each needs model), put its key in KIRAK_VECTOR_EMBEDDING_<INSTANCE>_API_KEY instead of OPENAI_API_KEY/GOOGLE_API_KEY, add vector to modules, and replace kirak.ai.embed({"text": ...}) with kirak.vector.embed({"text": ...}). There is no replacement HTTP endpoint for POST /ai/embed: the Vector module exposes none, so an app that needs one adds its own route that calls kirak.vector.embed. The response keeps data.embedding and data.dimensions; data.model and data.usage are gone, and the per-call model parameter is replaced by provider (an embedding instance name). Example: "ai": {"embedding_model": "openai:text-embedding-3-small"} becomes "vector": {"embedding": {"default_provider": "openai", "providers": {"openai": {"type": "openai", "model": "text-embedding-3-small"}}}}. Cohere, Bedrock, VoyageAI and sentence-transformers embeddings (previously reachable through pydantic-ai) have no built-in provider; register a custom one (see docs/modules/vector.md). See docs/modules/vector.md.
  • Startup reports every problem in models.json, kirak.json and agent files, not just the first. The messages are unchanged, one per line.
  • kirak db commands run in the project root. They look for kirak.json from the current directory upwards and run there, so they work from any subdirectory of a project. Before, they used the current directory, and from a subdirectory created a stray migrations/ there.
  • jsonschema 4.18 or later is required (was 4.17), for the local resolution of references between the packaged schemas.
  • Cleanup: built-in payment providers’ EVENT_MAP values no longer carry the after_ prefix. Stripe, Stripe Connect, Razorpay and Square now write "payment_completed" instead of "after_payment_completed", matching the neutral name after_webhook already reported (neutral_event() stripped the prefix either way, so this has no effect on data["event"] or any other runtime behavior). Custom providers may still write either form.
  • POST /auth/request-reset-password always answers with the neutral message: If your email is registered, you will receive reset instructions is now returned for an unknown address, a successful send, and any failure while generating the link or sending the email. Previously a registered address got a different message on success and a 500 (EMAIL_ERROR or INTERNAL_ERROR) on failure. Clients that showed the old success text should show their own confirmation instead. Failures are logged at error level ([RESET_REQUEST]).
  • Breaking: environment variables are for secrets only; every other setting is in kirak.json. New keys: database (type, host, port, name, user, pool_min, pool_max, pool_recycle_seconds), base_url, project_name, bulk_chunk_size, branding (logo, support_email, website_url, login_url, four colours), cors.origins, auth.social.<provider> (client_id, redirect_uri, plus client_key for TikTok and team_id and key_id for Apple), auth.forgot_password_page_url and auth.apple_web_callback_url. Migration: DB_TYPE, DB_HOST, DB_PORT, DB_USER, DB_NAME, DB_POOL_MIN, DB_POOL_MAX, DB_POOL_RECYCLE -> database.* (DB_PASSWORD stays an environment variable); KIRAK_BASE_URL, KIRAK_AUTH_HOST and KIRAK_AUTH_HTTPS -> base_url (the OAuth host and scheme now come from it); KIRAK_PROJECT_NAME -> project_name; KIRAK_BULK_CHUNK_SIZE -> bulk_chunk_size; KIRAK_CORS_ORIGINS and cors.origins_env -> cors.origins (a kirak.json with origins_env fails to load); LOGO, SUPPORT_EMAIL, WEBSITE_URL, LOGIN_URL and the four colour variables -> branding.* (branding.login_url now defaults to an empty string, was /login); KIRAK_AUTH_<PROVIDER>_CLIENT_ID, _CLIENT_KEY, _TEAM_ID, _KEY_ID, _REDIRECT_URI -> auth.social.<provider>; KIRAK_AUTH_FORGOT_PASSWORD_PAGE_URL and KIRAK_AUTH_APPLE_WEB_AUTH_CALLBACK_URL -> auth.forgot_password_page_url and auth.apple_web_callback_url. Client secrets, the Apple private key, DB_PASSWORD, the JWT and verification keys and provider credentials stay in the environment. SOCIAL_AUTH_<BACKEND>_KEY and _REDIRECT_URI are no longer read for other social-core providers (use auth.social.<provider>); their SOCIAL_AUTH_<BACKEND>_SECRET still is. CorsManifest.resolve_origins(), CorsManifest.origins_env and kirak.auth.templates.template_settings are removed. get_database_config() and connect_to_db() take the manifest’s DatabaseManifest. A retired variable that is still set is not read; startup logs a warning that names the kirak.json key that replaces it. See docs/reference/configuration.md.
  • Breaking: provider configuration in kirak.json moved to providers. Removed keys, all replaced by default_provider and providers: payments.default_service, payments.square_environment, payments.square_location_id, payments.square_webhook_notification_url, payments.stripe_connect_refresh_url, payments.stripe_connect_return_url; storage.upload_service, storage.upload_dir, storage.aws_bucket, storage.aws_region, storage.endpoint_url; notifications.email_service, notifications.sms_service, notifications.push_service, notifications.aws_region, notifications.smtp_host, notifications.smtp_port, notifications.smtp_use_tls, notifications.firebase_credentials_path, notifications.firebase_project_id; scheduler.backend, scheduler.table. Provider settings now live in the instance entry (for example {"type": "square", "location_id": "...", "environment": "sandbox"}). A kirak.json that still has the old keys fails validation.
  • Breaking: provider environment variables are per instance. Stripe, Razorpay and Square variables keep their names for an instance named after its type (KIRAK_PAYMENT_STRIPE_SECRET_KEY), except Stripe Connect, which now has its own KIRAK_PAYMENT_STRIPE_CONNECT_SECRET_KEY. Storage: KIRAK_STORAGE_<INSTANCE>_ACCESS_KEY / _SECRET_KEY. Notifications: KIRAK_NOTIFICATION_<CHANNEL>_<INSTANCE>_* replaces the shared KIRAK_NOTIFICATION_AWS_*, _SENDGRID_API_KEY, _SMTP_USERNAME and _SMTP_PASSWORD. Scheduler: KIRAK_SCHEDULER_<INSTANCE>_URL. The fallbacks KIRAK_PAYMENT_DEFAULT_SERVICE, KIRAK_PAYMENT_SUCCESS_URL, KIRAK_PAYMENT_CANCEL_URL, KIRAK_PAYMENT_CONNECT_REFRESH_URL and KIRAK_PAYMENT_CONNECT_RETURN_URL are removed.
  • Breaking: payments: the payment_service payload key is removed; use provider (an instance name, which must be listed in kirak.json). The transactions and subscriptions payment_service column now stores the instance name. Payments._WEBHOOK_HOOK_MAP and Payments._payment_providers are removed; each provider declares its own EVENT_MAP and WEBHOOK_SIGNATURE_HEADER, and custom providers are registered with register_provider. PaymentConfig, create_payment_provider and the PaymentService enum are removed.
  • Breaking: storage: local files are served at /media/<instance name>/<path> (was /media/<path>); every local instance is mounted, not only the default. Upload responses include provider. create_storage_provider and StorageConfig are removed.
  • Breaking: notification providers raise on failure: a failed send raises KirakException (EMAIL_ERROR, SMS_ERROR, PUSH_ERROR, HTTP 500) instead of returning a success response containing {"success": false}. Push raises only when no token was delivered. The email, SMS and push provider classes now subclass EmailProvider, SMSProvider and PushProvider and take (config, notifications). The three provider factories and NotificationConfig are removed.
  • Breaking: scheduler errors are KirakExceptions: SCHEDULER_NOT_STARTED (503, was RuntimeError), TASK_NOT_REGISTERED (400, was LookupError), INVALID_PAYLOAD (400, was ValueError for secret-looking payload keys) and QUEUE_BACKEND_ERROR (503). QueueBackend subclasses take (config, scheduler); create_queue_backend and SchedulerConfig are removed. Job introspection and dynamic schedules require a provider of type database.
  • Breaking: the KIRAK_AI_* variables documented for AI keys were never read: provider SDKs read their own standard variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, CO_API_KEY).
  • Kirak is now described as a runtime, not a framework, across the docs, README, pyproject.toml, and default string values. create_app()/create_kirak_app()’s default description= parameter changed from "API built with Kirak Framework" to "API built with Kirak Runtime" – override description= explicitly if your app depended on the old default string.
  • Breaking: unified log file: every module now writes to one shared {LOG_PATH}/kirak.log file instead of its own {slug}.log. The log format (DEFAULT_LOG_FORMAT) is unchanged, and each line still carries the emitting module’s name via %(name)s – only the file layout changed. Anyone tailing or parsing per-module log files needs to point at kirak.log instead.
  • Cookie max_age in auth router now derived from KIRAK_AUTH_ACCESS_TOKEN_EXPIRE_HOURS (default 1 h) instead of a hardcoded 30-day value – browser cookies now expire with the token.
  • Logout “others” now executes a single DELETE ... WHERE user_id = ? AND token_hash != ? query instead of N individual destroy calls.
  • _bulk_destroy now reads KIRAK_BULK_CHUNK_SIZE from the environment (was using a hardcoded 500 default).
  • Breaking: payments require_auth now defaults to true: every payments HTTP route except the webhook endpoint now requires a valid JWT/API key by default, and the router enforces that the caller’s own user ID matches the request’s user_id (or the caller holds admin/system). Set "payments": {"require_auth": false} in kirak.json to restore the old open-by-default behavior. See docs/modules/payments.md#authentication--authorization.
  • Breaking: payment amounts now stored as minor units for every provider: Stripe, Square, and Stripe Connect previously divided transactions.amount/net_amount by 100 before storing (major units), while Razorpay stored raw minor units – meaning the same column meant different things depending on which provider wrote the row. All four providers now store integer minor units consistently. A one-time backfill for pre-existing Stripe/Square/Stripe-Connect rows is documented in docs/modules/payments.md#money--amounts.
  • subscriptions.product_id renamed to plan_id. It was never populated by any provider. All three providers (Stripe, Razorpay, Square) now write the gateway’s plan/price id into it on subscription creation and on a plan change; Square also gains its first-ever write to payments_subscriptions (previously only payments_transactions).
  • BREAKING: the named payments webhook hooks were removed. after_payment_completed, after_payment_failed, after_subscription_renewed, after_subscription_updated, after_subscription_cancelled, after_refund_completed, after_merchant_account_updated and after_merchant_deauthorized no longer fire, and registering one fails at startup. Register after_webhook and check result["data"]["event"] (payment_completed, …). A provider’s EVENT_MAP values may keep the after_ prefix; it is dropped.
  • BREAKING: payments authorization moved from the router into the operations. The HTTP routes behave as before, but a call made while a request is being handled is now checked wherever it enters (with require_auth on, the caller must own user_id or be admin/system); calls from your own code with no request and no user context set are now refused the same way, as an unauthenticated guest – a script or hook that calls a Payments operation directly must set a user context first (typically system), the same convention Auth and CRUD use. refund_payment and update_merchant_fee are admin/system only. 401 and 403 now use the canonical error envelope. get_router() no longer takes require_auth.
  • Payments provider CRUD calls restore the previous user context after running as the system user, so hooks that run afterwards no longer keep system privileges.
  • Stripe refund status now distinguishes partial from full: _on_charge_refunded used to collapse every refund into REFUNDED regardless of amount. It now compares the cumulative amount_refunded to the charge’s own amount and reports PARTIALLY_REFUNDED for a refund that covers less than the full charge. PARTIALLY_REFUNDED was also added to SETTLED_STATUSES, so a delayed or duplicate completion event cannot clobber a partially-refunded transaction back toward COMPLETED.
  • Breaking: removed the dead PaymentsManifest.currency field (kirak.json -> payments.currency). Declared and schema-validated but read by nothing – every provider already hardcodes its own default currency. A kirak.json that still sets it now fails validation; delete the key.
  • connect_checkout now validates its params before calling the provider, the same validate_payment_params check initiate_payment already runs. A missing amount/name/user_id used to reach StripeConnectProvider’s unguarded int/float conversion and surface as a raw KeyError -> opaque 500 instead of a clean 400.
  • Stripe Checkout is no longer card-only. initiate_payment stopped sending payment_method_types: ["card"], so Checkout offers every payment method enabled in your Stripe Dashboard (wallets, bank debits, local methods). To keep card-only, disable the other methods in the Dashboard.
  • Stripe Connect now declares SUPPORTS_SUBSCRIPTIONS = False. Connect checkout only ever creates a payment-mode session (destination charge), so calling initiate_payment with "type": "subscription" against a stripe_connect instance now raises NOT_SUPPORTED (501) instead of silently creating a one-time payment-mode session; the subscriptions capability is absent for stripe_connect in GET /payments/providers. connect_checkout (POST /payments/connect/checkout) enforces the same flag: it used to call the provider’s initiate_payment directly with no check, so a subscription-type Connect checkout silently created a one-time payment-mode session while storing transaction_type "subscription" on the row.
  • New neutral event subscription_past_due. Stripe customer.subscription.updated with status past_due or unpaid, and Razorpay subscription.pending/subscription.halted, now report data["event"] = "subscription_past_due" instead of subscription_updated, so the event means the same thing on every provider. Other statuses are unchanged. If your after_webhook hook checks result["data"]["event"] == "subscription_updated" for these cases, check for subscription_past_due instead. refund_pending is also a new neutral event name (PayPal and Paddle report it). Stripe customer.subscription.updated with status past_due or unpaid now also carries event_type customer.subscription.updated.past_due in result["data"] (it is how the event maps to subscription_past_due): an after_webhook hook that matches the raw event_type == "customer.subscription.updated" misses these deliveries and must also match the new name. See docs/modules/payments.md “Events”.
  • Docs said direct calls outside a request run as the system user; they run as guest. kirak.fetch(...) and the other facade operations called from a scheduler job, a cron task, startup code or a script have no identity unless the code sets one with set_user_context(...), so they run as guest and a model without a guest rule denies them. The scheduler worker sets no identity. The same holds in your own FastAPI routes: Kirak sets the request context only in its own routes, so a route looks up the caller (kirak.auth.get_current_user({"request": request})) and sets it; hooks fired by Kirak’s routes do run as the caller. Also documented: system is not a superuser – it needs a {"role": "system"} rule in the model’s access block for each operation, and bypasses only field-level security; while the hooks of a set_user_context(...) call run, the context is cleared. The scheduler named-task example and the nightly-report pattern now set an identity. New section “Who a direct call runs as” in docs/guides/crud-operations.md; docs/reference/middleware.md, docs/concepts/access-control.md and docs/concepts/hooks.md corrected. The “Raw SQL Query in a Hook” pattern is replaced by a GraphQL aggregate (raw SQL skips access rules); docs/guides/graphql.md now says aggregates are checked against search rules and names the result keys. No code change.
  • S3 and Wasabi storage blocked the event loop, and list stopped at 1000 files. Upload, delete and list called boto3 directly inside the async methods, so every other request waited while a file was sent; they now run in a worker thread. list returned only the first page of ListObjectsV2 (at most 1000 files) and now follows every page. A Wasabi credential check that fails now names Wasabi, not S3, in its message. An aws_region that is not a region name (e.g. with a space) is a ConfigurationError naming it, instead of botocore’s InvalidRegionError.
  • search_term did nothing on search. The search text was read only from search or q, while /docs, openapi.json, the guides (GET /posts/search?search_term=..., kirak.search("products", {"search_term": ...})) and the mcp module’s search tool all send search_term; it was taken as a filter on a column of that name instead. search_term is now read first, then search, then q, and none of them is ever a filter.
  • The docs described a “dev mode” that does not exist. Several pages said a model without an access block allows every operation without a credential. It is the opposite: such a model denies every operation for every role (startup logs a warning; strict_access makes it an error). The access-control, architecture, tutorial, installation, security-hardening, troubleshooting and configuration pages now say so, and mention the * role (any role, guest included). The tutorial’s search example also used ?q=; the parameter is search_term.
  • POST /graphql reached models marked internal: true. The REST routes return NOT_FOUND for an internal model, but GraphQL root fields served every internal model its access rules allowed, e.g. a signed-in user’s own users row and payment_methods, or destroyTransactions for an admin, going around the owning module. Over HTTP, GraphQL now rejects internal models from a root field like REST does; a relationship join into one still works. kirak.graphql() from Python is unchanged.
  • GraphQL mutations could not reach models whose name has capital letters. The mutation root createBlogPost was turned into blog_post, so a model named blogPost (or Posts) could be queried but never mutated. Mutation roots now resolve against the real model names.
  • Configuration docs listed settings that do not exist, or in the wrong place. payments.currency was documented but is rejected by kirak.json (the currency is passed with each call); KIRAK_AUTH_BCRYPT_ROUNDS and KIRAK_AUTH_PASSWORD_COMPLEXITY were documented as environment variables but are auth.bcrypt_rounds and auth.password_complexity in kirak.json. The tables are now generated.
  • Wrong descriptions of monitoring keys in the kirak.json schema (shown by kirak modules monitoring and now in the docs). monitoring.enabled, log_capture_enabled and log_user_id were described as switches but are not read; sample_rate and slow_request_threshold_ms were described as sampling requests, but they sample per-operation timings (every request is recorded).
  • A project created by kirak new did not start. The scaffold wrote its sample model to models/models.json, which is skipped inside a models/ directory (startup failed with “No *.json files found in models directory”), and its kirak.json had a payments.currency key the schema rejects. The sample model is now models/posts.json and the key is gone. Projects created before: move each model in models/models.json to its own models/<name>.json (without the {"<name>": ...} wrapper) and remove payments.currency.
  • instagram social login used social-core’s backend instead of Kirak’s. Kirak ships its own Instagram backend (Meta’s Instagram Login, with the synthesized instagram_<id>@social.kirak email described in docs/authentication/social-auth.md), but the generated provider catalog mapped instagram to social-core’s backend, which uses the retired Basic Display API. Kirak’s own backends in kirak/auth/social/backends/ now take precedence over a social-core backend of the same name.
  • The social provider list could fall behind social-core. kirak/auth/social/catalog.py was a generated copy and listed six names (echosign, exacttarget, pocket, runkeeper, skyrock, withings) the installed social-core no longer has. The backends are now read from the installed packages at startup (parsed, not imported, in about 0.15 s), so the accepted names always match the installed version. python -m kirak.auth.scripts.generate_catalog is removed; kirak.auth.social.catalog (_CATALOG) is removed: use kirak.auth.social.discovery.social_backends() or kirak.catalog. Enabling saml, shopify or google-onetap without the packages their social-core extra adds now fails at startup with the pip install "social-auth-core[...]" command (google-onetap used to fail only at login).
  • A pip-installed kirak was missing ai/ai_models.json and the auth page templates. [tool.setuptools.package-data] did not list them, so a non-editable install (a Docker image, a wheel) had no ai_conversations model for the AI module and no forgot-password, reset-password and response pages. Editable installs read the source tree and did not show it. Both are now packaged, and a test builds a wheel and checks that every data file under kirak/ is in it.
  • The example projects’ model files did not load. examples/01_hello_api, 02_blog, 03_hooks and examples/models still used the old {"<model>": {...}} wrapper inside each file (and 02_blog/models/posts.json held two models), which the per-file models directory reads as a model named after the file with no table. Each file now holds one model, named after the file; 02_blog has posts.json and comments.json.
  • A plain pip install kirak could not start an app: jinja2 was missing. The auth module, which every app loads, renders its verify-email and reset-password pages with Jinja2Templates, but jinja2 was only declared by the notification extras, so an install without them failed with “jinja2 must be installed to use Jinja2Templates”. It is now a base dependency.
  • Native tools with a declared parameters schema passed omitted optional arguments as null. The operation received e.g. top_k=None instead of no top_k, so Vector.search rejected the call (INVALID_PARAMS) instead of applying its default. Arguments the model leaves out, or sends as null, are now omitted.
  • Vector provider calls were missing from monitoring’s provider_duration_ms. Store and embedding providers now report their time like every other module’s providers.
  • AI Monitoring recorded no tool durations. Every tool_result trace entry, and so every ai_tool_calls monitoring record, had duration_ms of null: the line that stamps a tool’s start time was lost when the AI Monitoring and AI module changes were merged. Restored.
  • AI native tools fail with TypeError for Payments, Notifications, Storage, Auth, and Scheduler. Every module except Vector declares its operation as op(params) with no current_user kwarg, but resolve_native_tool was calling op_fn(params=kwargs, current_user=current_user), causing a TypeError on dispatch for any native tool ref other than Vector.*. The wrapper now sets the user context var and calls op_fn(params=kwargs) without passing current_user as a kwarg, matching how every module’s dispatch reads the caller.
  • AI native tool names contain a dot (Vector.search), which OpenAI and Anthropic reject. Provider APIs require tool names to match [a-zA-Z0-9_-]{1,128}; a dotted name like "Vector.search" caused a 400 from both providers before the agent could run. resolve_native_tool now sets wrapped.__name__ = ref.replace(".", "_").lower() so the name pydantic-ai registers is "vector_search". The original ref is still captured in the closure so routing is unaffected.
  • AI compaction ignores the agent’s model: every agent received AnthropicCompaction, OpenAI agents never received OpenAICompaction. _build_compaction tried both imports unconditionally and returned whichever succeeded first, so a Gemini or OpenAI agent always got AnthropicCompaction if the Anthropic SDK was installed. It now takes model: str and branches on the provider prefix: anthropic: uses AnthropicCompaction, openai:/openai-responses: uses OpenAICompaction, and all other providers use TieredCompaction from pydantic-ai-harness (clearing old tool results, then sliding the message window) with an ImportError fallback to no compaction.
  • AI native tool parameter schema ignored. An agent JSON tool entry with a "parameters" key had it silently dropped; the LLM received an open additionalProperties: true schema and had to infer arguments from the docstring alone. ToolRef and KirakTool now carry the declared schema, and _resolve_tools wraps the native function so pydantic-ai inlines the declared fields as the typed LLM-facing schema. Tools without "parameters" are unchanged.
  • AI compaction not available for non-Anthropic/OpenAI models. Gemini and other providers had no compaction strategy; long conversations would overflow the context window without any graceful handling. Kirak now uses pydantic-ai-harness TieredCompaction (clearing old tool results, then sliding the message window) for every provider that does not have a native server-side strategy. The token threshold is configurable via ai.compaction_token_threshold in kirak.json (default: 150,000); null disables compaction. The same threshold is now forwarded to AnthropicCompaction and OpenAICompaction.
  • Agent function tools fail as guests when run outside an HTTP request. Python function tools registered with @kirak.agent("name").tool that call Kirak modules (e.g. kirak.vector.search(...)) were refused as unauthenticated guests in scripts and scheduler jobs. dispatch’s preset branch clears the user context before running any operation, so by the time the tool executed a nested Kirak call there was no context to resolve. _resolve_tools now wraps every KirakTool function with a context-restoring shell (_wrap_with_user_context) so the caller’s current_user is set for the tool’s entire execution. Native tools (from resolve_native_tool) were already managing the context themselves and are unaffected. Only script-mode (no HTTP request) was broken; over HTTP the router’s request context let dispatch resolve the caller normally.
  • PayPal and Paddle: another Kirak database on the same account could complete a row that had not stored its gateway id yet. Both match by the stored PayPal order/capture id or Paddle transaction id, and fall back to the row id in custom_id/custom_data for a row that has none yet (an initiate call with an unknown outcome, or an off-session charge whose webhook beats the charge call). That id is numbered the same way in every database. PayPal’s custom_id is now <row id>:<kirak_ref> and Paddle’s custom_data adds kirak_ref, a random reference kept in the row’s payment_meta that the fallback requires. Objects made before this change still match rows made before it. The docs also note that PayPal’s PayPal-Request-Id depends on the user id and key only, so keys should be unique across databases.
  • A gateway object without a kirak_ref still matched any row by id. Kept for objects made before references existed, it also let another Kirak database on the same account, still running older code, match rows the new code made. Such an object now matches only a row that has no kirak_ref either (Stripe off-session charges, Razorpay payments, Square off-session charges).
  • Stripe, Stripe Connect, Square and Razorpay sent the caller’s idempotency_key to the gateway as it was. A gateway’s keys span the whole account, so one key used for two operations (an order id for a checkout and then its refund) made the second call fail with the gateway’s idempotency error, and two Kirak databases on one account could send the same key; with Square, identical bodies could even return the other database’s payment link. The key sent is now derived from the caller’s, the operation, and an id only this database’s object has: the gateway payment id for a refund, or the row’s random kirak_ref (now also on Stripe, Stripe Connect and Square checkout rows) for a checkout, payment link or off-session charge. A retry of the same call still sends the same key. Custom-provider authors: PaymentProvider._gateway_idempotency_key(operation, anchor, params) derives such a key.
  • Ownership checks could read another provider instance’s row: verify_payment and cancel_subscription/update_subscription/pause_subscription/resume_subscription found the owner of a payment or subscription id on any instance, then acted on the instance the caller named. The owner is now read from the row of the instance that acts. The payments docs now also say that update_subscription accepts any plan id and how to restrict it with a before_update_subscription hook.
  • Payment amounts: 0 and booleans were accepted. A one-time payment or charge_off_session for amount: 0 reached the gateway (which refuses it) and a boolean true was read as 1. Both are now INVALID_PARAMS (400); a subscription may still pass amount: 0, since its plan sets the price. The payment and webhook validators no longer log the full parameters at DEBUG level (buyer email and phone, raw webhook body and signature header), only the parameter names and the provider instance.
  • An off-session charge could silently do nothing: charge_off_session and initiate_payment looked up earlier calls by user and idempotency_key only, so a key already used for a checkout (an order id used for both, say) returned that checkout as an “idempotent replay” and nothing was charged, with no error. Each operation now replays only its own earlier calls.
  • Square: another Kirak database on the same seller account could complete this app’s off-session charges. An off-session payment’s reference_id was kirak-<row id>, which the other database numbers the same way, and a payment webhook arriving before the charge call had stored the order id was matched by it. The reference_id is now kirak-<row id>-<16 random hex>, with the random part kept in the row’s payment_meta (kirak_ref), and must match. A kirak-<row id> reference from before this change is still matched by id.
  • Razorpay: another Kirak database on the same Razorpay account could complete or fail this app’s payments. payment.captured and payment.failed found the row by the numeric db_transaction_id in the payment’s notes, and did not check the instance either, so the other database’s payment for its transaction 5 completed this app’s transaction 5 (and a missing row was retried for 24 hours). Payment links, subscriptions and off-session orders now carry a random reference (kirak_ref, kept in the row’s payment_meta) that must match; a payment carrying another reference is acknowledged and left alone. Links made before this change carry none and are still matched by id.
  • Stripe and Stripe Connect: another Kirak database on the same Stripe account could complete this app’s payments. Stripe sends every event to every endpoint, and a checkout completion, an off-session charge and a saved-card setup session were matched by the numeric transaction id (or user id) in the event’s metadata, which the other database numbers the same way: its checkout for its transaction 5 marked this app’s transaction 5 paid and ran after_webhook, and its setup session could save its customer’s card for this app’s user with the same id. Now a checkout completes only the row that stored its session id; an off-session charge carries a random reference (kirak_ref, kept in the row’s payment_meta) that must match; a setup session is saved only for the Stripe customer this instance made for that user. Anything else is acknowledged and left alone. Off-session charges made before this change carry no reference and are still matched by id. initiate_payment now fails, and returns no checkout url, when the session id cannot be stored on the row.
  • Stripe: with two Stripe instances on one Stripe account, a payment could stay PENDING for good: Stripe sends each event to both instances’ webhook endpoints, and processed events were recorded under one key for every Stripe instance. When the instance an event was not for received it first, it ignored the event but marked it processed, so the instance it was for skipped it as a repeat. Events are now recorded per instance (stripe:<instance>:<event id>). An event processed before this change and redelivered after it is handled again; completions and refunds are still recorded once.
  • Telr: a payment completed by verify_payment never reported payment_completed: the later sale advice found the row settled and reported nothing, so after_webhook never saw the payment. The first sale advice of the completing transaction now reports it once. An authorised sale advice whose order was not paid yet was acknowledged, so Telr stopped retrying; it now fails (500) so Telr sends it again. A paid order that does not match the row is still acknowledged.
  • Omise: a payment completed by verify_payment never reported payment_completed: the later charge.complete found the row settled and reported nothing. The first charge.complete for the completing charge now reports it once.
  • Flutterwave: charge_off_session reported COMPLETED for a charge not verified yet: a successful answer left the row PROCESSING (completed later by the re-verified charge.completed) but returned COMPLETED. It now returns the row’s own status, PROCESSING until the webhook completes it; fulfil on payment_completed.
  • Paystack: charge_off_session reported COMPLETED for a charge not verified yet: a success answer left the row PROCESSING (completed later by the re-verified charge.success) but returned COMPLETED. It now returns the row’s own status, PROCESSING until the webhook completes it; fulfil on payment_completed. Two concurrent deliveries of one renewal invoice.update could save two renewal rows; the row is now inserted with a unique idempotency_scope, so the second reports already_processed. Removing a card no longer reads the subscriptions’ first charges one by one.
  • Airwallex: a retried refund_payment could refund twice: the request_id was always random; it is now derived from the caller’s idempotency_key and the transaction’s random reference (random without a key).
  • Xendit: a retried refund_payment could refund twice: the Idempotency-key was always random; it is now derived from the caller’s idempotency_key and the transaction’s random reference (random without a key).
  • Mercado Pago: refunds of a renewal were never recorded: a renewal’s payment carries the subscription’s external_reference, so its payment notification was matched to the subscription’s first row and dropped; it is now matched to its own subscription_renewal row first. A retried refund_payment could refund twice: the X-Idempotency-Key was always random; it is now derived from the caller’s idempotency_key and the transaction’s random reference (random without a key). Two concurrent deliveries of one renewal charge could save two renewal rows; the row is now inserted with a unique idempotency_scope, so the second reports already_processed. update_subscription now stores the new plan_id on the subscription.
  • Square: a payment link ignored quantity: it was a quick_pay link for the unit price, so the buyer paid for one unit while the row recorded amount x quantity (and a separate order was created and never used). The link is now created from an order whose line item carries the quantity and the transaction’s reference_id. Other Square traffic failed the webhook: a payment or order that is not Kirak’s (a POS sale on the same seller account) returned 500 and was retried for days, and an order’s reference_id was read as a Kirak row id (a numeric one could overwrite an unrelated row); such events are now acknowledged, orders are matched only by the order id stored on this instance’s row, and order lookups are scoped to the instance. Square retries were rejected by a 5-minute window on the signed created_at; the window is gone and deliveries are deduplicated on the event’s event_id. Square SDK calls now run in a worker thread (asyncio.to_thread) instead of blocking the event loop.
  • Razorpay: refunds were never recorded: refund.created carries both the refund and its payment, and the payment entity was handed to the refund handler, so the lookup ran with no payment id (and a refund without a payment_id is now skipped instead of matching rows with no transaction_id). Razorpay retries were rejected: a 5-minute window on the signed created_at refused Razorpay’s own retries of the original payload (sent for up to 24 hours), leaving payments PENDING; the window is gone, and deliveries are deduplicated on the x-razorpay-event-id header instead.
  • Stripe Connect: a partial refund marked the whole payment REFUNDED; it is now PARTIALLY_REFUNDED until Stripe’s cumulative amount_refunded reaches the charge, as for Stripe. A disconnected merchant stayed active: account.application.deauthorized read the account id from data.object (the Application, ca_...), so no merchant row matched; it now uses the event’s account, and the merchant is marked DEAUTHORIZED with charges disabled.
  • Stripe: the first invoice of a subscription was also recorded as a renewal, since checkout completion stores it under the invoice id and invoice.payment_succeeded looked it up by payment intent; an invoice with billing_reason subscription_create, or already recorded under its invoice id, is now a repeat. Subscription expiry no longer depends on Subscription.current_period_end, which API 2025-03-31.basil (stripe 12.x) moved onto the subscription items: it is read from either place, and the fallback adds one plan interval in calendar days, weeks, months or years instead of 30 days per interval; invoices name their subscription under parent.subscription_details on that API. A stripe and a stripe_connect instance on one account no longer report the other instance’s checkout as payment_completed (the result is marked already_processed).
  • A saved payment method could be handed to another user: saving a gateway method id already saved on the instance for another user refreshed that row, or reactivated it when revoked with the new buyer’s consent and email while keeping the old owner, so a card a user removed could be charged again. An active or pending method of another user is now never written: attach_payment_method fails with PAYMENT_METHOD_OWNED_BY_ANOTHER_USER (409), and a save while paying, from a setup page or from a webhook is skipped with a warning (the payment still completes). Applies to every provider with saved methods. Custom-provider authors: method_store.save_method now raises MethodOwnedByAnotherUser (a KirakException, 409) in this case; catch it where a save must not fail a webhook. A method the other user removed (REVOKED) is taken over by the new user, with their consent and meta, instead of blocking them.
  • razorpay failed to import with setuptools 81 or later: razorpay 1.x (latest 1.4.2) imports pkg_resources, which setuptools 81 removed, so an environment with a newer setuptools broke it. payments-razorpay, all-payments and all now pin setuptools>=68,<81.
  • Added unique columns were not unique: kirak db makemigrations wrote ALTER TABLE ... ADD COLUMN for a new field and dropped its "unique": true, so a database upgraded by migration had no unique constraint while a fresh CREATE TABLE did. An added unique column now also gets CREATE UNIQUE INDEX uq_<table>_<column> on MySQL and PostgreSQL. Migrations generated before this fix lack the index; add it by hand.
  • Breaking: KIRAK_HOOK_TIMEOUT and LOG_PATH are no longer read: Kirak copied hook_timeout_seconds and log_path from kirak.json into these two environment variables with setdefault and read them back, so a variable set in the environment silently overrode kirak.json. kirak.json is now the only source. If you set either variable, move the value into kirak.json ("hook_timeout_seconds": 15, "log_path": "/var/log/myapp"); without a log_path the log goes to logs under the working directory, and the default timeout is still 5 seconds. Kirak no longer writes these variables into the process environment either.
  • Multi-channel response documentation: the send_multi example in docs/modules/notifications.md and the notifications README showed a response shape the module never returned. Both now show the real envelope (data.results, each channel’s own envelope or {"success": false, "error": ...}).
  • Breaking: no success flag in notification and webhook results: the response of send_email, send_sms and send_push, and the result of every payment provider’s handle_webhook, used to carry the provider’s "success": true inside data next to the envelope’s own status. A returned result already means the send or the event worked (failures raise), so the flag is dropped: data is now {"id": ...} instead of {"success": true, "id": ...}. A notification provider that returns {"success": false} anyway now raises PROVIDER_SEND_FAILED (500) with the provider’s error text instead of producing a success response. Stripe, Stripe Connect and Razorpay already worked this way; Square now does too. Custom providers can leave success out of what they return; if they include it, it is removed. Read response["status"] (or catch KirakException) instead of response["data"]["success"].
  • The database scheduler provider’s table setting broke the module: the queue backend used the configured table for enqueue, claiming, acks and retries, while the jobs routes, /scheduler/schedules and database-defined cron always used the fixed scheduler_jobs model. Any other name meant migrations never created the table, or, if it was created by hand, the API could not see the jobs and a fired schedule tagged the wrong row. The setting is removed: the job table is always scheduler_jobs. A provider entry that still says "table": "scheduler_jobs" keeps working; any other value now fails at startup with a message telling you to remove it. The docs, kirak new scaffold, example config and Studio no longer write it.
  • Error messages told users to set environment variables that no longer exist: MISSING_FROM_EMAIL, ATTACHMENT_BASE_PATH_NOT_CONFIGURED and the thumbnails=True validation error named KIRAK_NOTIFICATION_EMAIL_FROM_ADDRESS, KIRAK_NOTIFICATION_ATTACHMENT_BASE_PATH and KIRAK_STORAGE_DEFAULT_THUMBNAILS. They now name the kirak.json settings that are read: notifications.email_from_address, notifications.attachment_base_path and storage.default_thumbnails.
  • Razorpay silently undercharged on subscription quantity > 1: _create_subscription hardcoded "quantity": 1 in both the actual subscription-creation API call to Razorpay and the saved transaction row, dropping any caller-requested quantity (Razorpay quantity multiplies the plan amount – 5 seats at $100/plan = $500/invoice). Both now read the caller’s quantity, matching _create_payment_link’s existing pattern.
  • Stripe subscription-renewal transactions used lowercase payment_status: _on_invoice_succeeded/_on_invoice_failed wrote "paid"/"failed" instead of the module’s uppercase convention (COMPLETED/FAILED), so every Stripe subscription-renewal transaction was invisible to any uppercase status filter, including SETTLED_STATUSES and the bundled example’s own revenue query. Fixing this also required fixing _on_invoice_succeeded’s retry-after-failure dedup check, which compared against the same lowercase "failed" string.
  • Stripe subscription quantity was never stored: _activate_subscription received it as a parameter and never used it, so every Stripe subscription’s quantity column silently stayed at its schema default of 1 regardless of what was actually bought. The checkout session’s own line item already had the correct quantity, so this was a data-integrity bug, not a billing one – the customer was still charged correctly.
  • A failed job was lost on RabbitMQ: the worker’s retry step did nothing on this backend and the message was then acknowledged, so a job that raised was dropped with no retry (max_retries and retry_backoff had no effect) and no record. A failed job is now republished with its attempt count and retried after retry_backoff; a job that has used up its attempts is published to <queue>.failed with the error. Existing RabbitMQ deployments get two new queues per named queue (<queue>.delay, <queue>.failed); nothing else needs changing.
  • RabbitMQ redelivered a job that was not yet due in a tight loop: a message with a future run_at was returned to the queue at once, so it was redelivered over and over until its time came. It now waits in <queue>.delay. A job enqueued with a future run_at goes there directly.
  • Restarting one app instance could run another instance’s jobs a second time: at startup every job in running was put back in the queue, including jobs other instances were still executing. Recovery now resets only jobs that have been running longer than job_timeout plus 60 seconds, on the database and Redis backends (RabbitMQ was never affected), at startup and then every minute. Redis jobs now record a started_at; a Redis job queued by an older version has none and counts as orphaned.
  • Jobs could run forever: the worker never stopped a job. It now stops a job that exceeds job_timeout and retries it. Jobs that legitimately run longer than one hour must raise job_timeout. After a crash, orphaned jobs are recovered after about job_timeout instead of at the next restart.
  • Jobs on a named scheduler queue never ran: the worker polled only default_queue, so a job enqueued with "queue": "emails" (as the docs showed) stayed pending forever. scheduler.queues in kirak.json now lists the extra queues to consume; the worker runs one loop per queue on every provider (RabbitMQ consumes each queue). default_queue is always consumed. A provider’s concurrency limit is shared by its queues. Enqueueing to a queue that is not consumed by this app still works, and logs a warning once per queue. If you enqueue to named queues, list them in scheduler.queues.
  • Square refunds never reached the refund handler and ran the refund hook too early: the handler read the wrapper Square sends (data.object.refund) instead of the refund, so it always failed with ‘Missing order_id’ and Square kept retrying; and after_refund_completed was tied to refund.created, when a refund is normally still PENDING. Refund events are now unwrapped and handled for both refund.created and refund.updated. Only a COMPLETED refund marks the transaction REFUNDED and runs after_refund_completed; a PENDING, REJECTED or FAILED refund changes nothing and runs no refund hook. The webhook result’s event_type is refund.completed in that case, with Square’s own name in square_event_type. Add refund.updated to your Square webhook subscription, or refunds that settle later are never recorded.
  • Renewal hooks ran again on a repeated delivery: Stripe invoice.payment_succeeded and Razorpay subscription.charged already noticed a repeat but still fired after_subscription_renewed. A repeat now reports already_processed and skips the hook. The renewal handlers save the transaction last, after extending the subscription, so a renewal that failed part-way is retried in full (before, the retry found the saved transaction and never extended the subscription). Razorpay now fails the webhook if the transaction cannot be saved; it ignored that before. Stripe no longer mistakes a successful retry of a failed invoice for a repeat: the failed attempt is saved under the same payment_intent, so the retry was skipped and the subscription stayed PAYMENT_FAILED.
  • Stripe ran after_payment_completed for payments that had not been paid: a checkout session paid by a delayed method (bank debit) is delivered as checkout.session.completed with payment_status unpaid, and it ran the completed hook. It now records PENDING and fires no hook. Stripe and Stripe Connect now also handle checkout.session.async_payment_succeeded (runs after_payment_completed, once) and checkout.session.async_payment_failed (records FAILED, runs after_payment_failed). Add these two events to your Stripe webhook endpoints, or delayed payments stay PENDING.
  • A completed payment reported twice ran after_payment_completed twice: Stripe, Stripe Connect, Razorpay and Square did not check whether the transaction was already completed, so a redelivered webhook (Stripe retries any non-2xx response; Square sends payment.updated on every change) fired the hook again. The transaction is now marked COMPLETED with one conditional update that skips transactions already COMPLETED or REFUNDED; a repeat reports already_processed and Payments.handle_webhook skips that event’s hook (after_webhook still fires). A database error while marking the payment now fails the webhook, so the gateway retries, instead of being ignored. A transaction that does not exist now fails Stripe and Stripe Connect webhooks.
  • Square events arriving out of order overwrote a completed payment: a late payment.created or order.updated moved a COMPLETED transaction back to PROCESSING or PENDING. Neither can overwrite a COMPLETED or REFUNDED transaction now, and order.updated no longer writes COMPLETED itself, because only the payment event may complete a transaction.
  • Square fired after_payment_completed for payments that were not completed: payment.created, payment.updated and order.updated all mapped to the completed hook whatever the payment’s status, so an approved, pending, canceled or failed payment ran fulfilment code. The hook now depends on the status: COMPLETED fires after_payment_completed, FAILED fires after_payment_failed, other statuses fire no payment hook, and order.updated fires none. The webhook result’s event_type is now payment.completed or payment.failed for those two, with Square’s own name in the new square_event_type field.
  • Several Stripe instances in one app used the wrong key: StripeProvider and StripeConnectProvider set the SDK’s process-global stripe.api_key in their constructors, so every instance used whichever key was set last and a request could be charged to another instance’s account. Each provider now keeps its own key and passes api_key= on every Stripe call, and the global is never set.
  • Password reset revealed registered addresses: the reset endpoint hides whether an address exists, but a failed email send or link generation returned an error only for registered addresses, and the success message differed from the unknown-address message. All outcomes are now identical to the caller.
  • Storage delete, get_url and list hid KirakExceptions: every error, including a client error, was converted to a generic 500 STORAGE_ERROR.
  • Failed email, SMS and push sends looked successful: providers returned {"success": false} and the operations wrapped it in a success envelope. The auth flows that already handled KirakException (register’s verification_email_sent, request_otp’s OTP rollback) now actually see failures.
  • Firebase push with a second instance or an existing Firebase app: initialisation was skipped whenever any Firebase app already existed. Each instance now initialises its own named app.
  • Custom payment providers never received a webhook signature: the route looked up the signature header from a hardcoded table of built-in names. Each provider now declares WEBHOOK_SIGNATURE_HEADER.
  • examples/kirak.json failed manifest validation: stale AI, auth, notifications and storage keys removed; a test now loads it through the real loader.
  • Implicit primary key not readable: ModelManager.get_allowed_fields_for_role(..., "read") now always includes id. Previously the reserved column was omitted from the read-field list, so fetch never selected it, and update/delete/restore rejected where: {"id": ...} with “No valid WHERE conditions provided”. RLS conditions such as id = {user_id} depend on id being queryable.
  • Refresh token hash collision: generate_refresh_token now includes a jti claim. Without it, two refresh tokens minted for the same user in the same second were byte-identical, producing a duplicate-key crash on auth_tokens.token_hash during rotation (e.g. login immediately followed by /auth/refresh-token).
  • /auth/me 500 on serialized datetimes: _get_current_user now normalises created_at / last_login_at through make_timezone_aware() before calling .isoformat(). Kirak’s response serializer already renders datetimes as ISO strings, so the direct .isoformat() call raised AttributeError once the column was populated.
  • GraphQL FileNotFoundError: KirakGraphQLTranslator no longer reads models.json from disk on every request; models are passed in-memory from kirak.models.
  • GraphQL PostgreSQL placeholders: _execute_simple_query and SQLJoinBuilder.build_query now use dialect.placeholder(index) instead of hardcoded %s.
  • _bulk_delete PostgreSQL placeholder: deleted_at = %s slot replaced with get_placeholder(0) for dialect-correct query building.
  • Bulk delete/destroy transactions: _bulk_delete and _bulk_destroy now wrap all chunk iterations in a single async with conn.transaction(), preventing partial deletes on crash.
  • Upsert TOCTOU race condition: The check-then-create/update pattern replaced with a native atomic INSERT ... ON DUPLICATE KEY UPDATE (MySQL) / INSERT ... ON CONFLICT DO UPDATE (PostgreSQL).
  • Search count query: Count query in search.py now built from components (SELECT COUNT(*) ... FROM ... WHERE ...) instead of string manipulation that could truncate WHERE clauses containing “ORDER BY” in field names.
  • KirakException -> HTTP responses: DatabaseError, ValidationError, PermissionDenied, and other KirakException subclasses are now caught by a registered @app.exception_handler(KirakException) in both create_app() and create_kirak_app(), returning correct HTTP status codes and sanitized JSON instead of bare 500s.
  • GraphQL RLS bypass: _has_model_access now delegates to AccessPolicyEngine.check_and_get_rls(), covering both the legacy auth block and the new access block with RLS conditions.
  • GraphQL exception leak: Internal exception details no longer returned in the GraphQL error response; full traceback is logged at ERROR level and a sanitized message returned.
  • Token hashes in logs: Removed auth.logger.info(f"all_tokens {all_tokens}") which was logging security-sensitive token hashes at INFO level.
  • Query params in logs: Removed kirak.logger.debug(f"Query params: {query_params}") in restore.py which could log user PII at DEBUG level.
  • Pydantic V2 deprecation: ErrorResponse in error_response.py migrated from deprecated class Config to model_config = ConfigDict(...).
  • Legacy social auth files removed: kirak/auth/services/google.py, github.py, and apple.py deleted – all traffic now routes through the social-core backend introduced in sec.2.5.
  • Registration blocked by exists probe: _register’s duplicate-email pre-check calls kirak.exists("users", ...) as the system role, but the built-in auth_models.json users model had no exists access rule. Since read operations now raise PermissionDenied (a KirakException) instead of HTTPException, that denial propagated through _register and aborted every signup with “Role system not authorized to check existence of users”. Added "exists": [{"role": "system"}] to the users access block, and _register now only re-raises the intentional EMAIL_ALREADY_EXISTS from the probe – any other failure falls through to creation where the unique constraint still catches real duplicates.
  • exists response key: /{model}/exists now returns data: {"exists": <bool>} as documented; the implementation was returning data: {"record_exists": <bool>}, so _register’s duplicate check (which reads data.exists) never detected an existing email.
  • PostgreSQL: writing or filtering by a timezone-aware datetime failed. asyncpg refuses a timezone-aware value for a TIMESTAMP column, which is what Kirak creates for created_at and every datetime field, so any create, update, fetch or delete given one (for example datetime.now(timezone.utc)) raised a database error. In the payments module this broke every recorded refund (refunded_on) and every subscription expiry update (expires_on) on PostgreSQL; MySQL was not affected. The PostgreSQL pool now registers a TIMESTAMP codec that converts a timezone-aware value to UTC and otherwise behaves as asyncpg’s own (naive values, dates and infinity unchanged). TIMESTAMPTZ columns are not affected.
  • Breaking: GET /payments/providers needs a signed-in caller when payments.require_auth is on (the default). It listed every provider instance’s name, type and capabilities to anyone. It now runs as the list_providers operation (with before_list_providers/after_list_providers hooks) and refuses an anonymous request with 401; any signed-in role may call it. A page that showed payment options before sign-in must now call it with the user’s token, or from your backend.
  • Stripe accepted forged webhooks when webhook_secret was not set: the Stripe SDK verifies the signature as an HMAC keyed with the secret, and with an empty secret anyone can compute it, so a forged checkout.session.completed could mark any transaction paid and run after_webhook fulfilment. Without KIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRET every Stripe webhook now fails with WEBHOOK_SECRET_NOT_CONFIGURED (500), as Razorpay, Square, PayPal, Paddle and Stripe Connect already did. Set the secret if Stripe webhooks should be processed.
  • P1-P4 hardening (carried forward from previous sprints):
    • Bcrypt password hashing at rounds controlled by KIRAK_AUTH_BCRYPT_ROUNDS (default 13).
    • Password complexity enforced via KIRAK_AUTH_PASSWORD_COMPLEXITY.
    • JWT access-token blacklist (auth_token_blacklist table) prevents reuse after logout.
    • Rate limiting on auth endpoints via kirak_rate_limits table.
    • Hook execution timeout enforced via KIRAK_HOOK_TIMEOUT.
    • Social login via social-auth-core with stateless HMAC bridge and encrypted credential storage (KIRAK_AUTH_ENCRYPTION_KEY).
    • Apple web auth callback URL configured via KIRAK_AUTH_APPLE_WEB_AUTH_CALLBACK_URL.

  • Database Agnostic Architecture: Full support for both MySQL and PostgreSQL
    • Switch databases via DB_TYPE environment variable (mysql or postgres)
    • Unified DatabaseDriver and Dialect abstraction layer
    • MySQL driver using asyncmy, PostgreSQL driver using asyncpg
  • Dialect System for portable custom SQL queries:
    • dialect.placeholder(index) - Returns %s (MySQL) or $1, $2, $3 (PostgreSQL)
    • dialect.placeholders_str(count) - Generates multiple placeholders
    • dialect.ilike_condition() - Portable case-insensitive search
    • dialect.supports_returning - Check RETURNING clause support
    • dialect.upsert_query() - Database-specific UPSERT syntax
  • New example 7_database_agnostic.py with portable query patterns
  • Updated examples/README.md with database agnostic documentation
  • Updated examples/.env.example with database switching instructions

  • Initial release of Kirak framework
  • Model-driven CRUD engine with 11 operations (fetch, create, update, delete, destroy, upsert, search, count, exists, restore, graphql)
  • JWT-based authentication with social login support (Google, GitHub, Apple, Instagram)
  • Payment processing integration (Stripe, Razorpay)
  • Email, SMS, and push notifications
  • AI integration (OpenAI, Gemini)
  • Hook system for extensibility
  • Request context middleware
  • Comprehensive model validation
  • Permission-based access control
  • Unified Instance Pattern for single-point access
  • Foundation + CRUD engine unified in kirak/core/
  • Extension modules: auth, payments, notifications, ai, storage, scheduler, mcp
  • Main facade class Kirak provides unified access to all modules