contributing/changelog.md
Changelog
Section titled “Changelog”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.
[Unreleased]
Section titled “[Unreleased]”- Security: mobile social sign-in no longer signs in whoever’s email the body names.
POST /auth/{provider}/mobiletookemailandprovider_user_idfrom the request body, found the account with that email, linked it and returned its tokens – no provider token was checked (theaccess_token/id_tokenthe 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-oauth2with the Google Sign-Inid_token(signature against Google’s keys, issuer, expiry, audience),apple-idwith itsid_token(the same checks, by social-core’s Apple backend) andfacebookwith anaccess_token(sent withappsecret_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 inauth.social_providers; any other provider is refused with 400MOBILE_SIGN_IN_NOT_SUPPORTED, and so isfacebookwithAPPSECRET_PROOFoff. 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 ofemail/provider_user_id–id_tokenfor Google and Apple,access_tokenfor Facebook; other providers must use the web flow.display_nameandphoto_urlare no longer read, andfirst_name/last_nameare used only by Apple. For native iOS Apple sign-in add the app’s bundle id toauth.social.apple.audience; for Google Sign-In pass the web client id as the server client id, or list the app’s client ids inauth.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(orverified_email) from the provider – Google and Apple send it – and otherwise fails with 409SOCIAL_EMAIL_NOT_VERIFIED. New social accounts areis_verifiedonly when the provider verified the email (they were alwaystrue). Theset_rolepipeline step is removed: it set an existingadminaccount’s role touserin memory for the first access token only (the next refresh restored it) and leftsystem/superadminalone. 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”) anddocs/guides/common-patterns.md(“Tenant Isolation”) told you to puttenant_id/organization_idin the JWT from anafter_loginhook, the latter with acreate_access_tokenfunction 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 toNULLand 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/schedulesreturns the new schedule.kirak.createreturns only the new id, so the route answered{"id": ..., "payload": {}}– nocron_expression,status,payloadornext_run_at. It now reads the row back and returns it likePUTdoes.- Deleting a dynamic schedule works.
DELETE /scheduler/schedules/{id}soft-deleted the row, butscheduler_jobshas 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_runis now the creation time. Existing rows keep their storedlast_run; a schedule stuck at0that 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 redeliveredaccount.updated,account.application.deauthorizedorpayment_method.detachedreported its event toafter_webhookagain (a hook callingupdate_merchant_feeran twice). It now answers a known event id withalready_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, thesend-*routes that spend on paid providers. Every route now refuses a revoked token with 401TOKEN_REVOKED, and fails closed with 503AUTH_UNAVAILABLEwhen 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 calledNotificationInbox/NotificationPreferenceswith positional arguments (fetch_unread(user_id, limit=limit)), but those methods take one params dict.GET /notifications/inbox/count/unreadandGET /notifications/preferencesalso wrapped an envelope in a second one. The routes now pass params dicts and return the methods’ envelopes:count/unreadanswersdata.count,preferencesanswersdata.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 theirnotification_preferencesmodel (it was missing the requiredpreference_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 readid/subwhile the auth module resolves a caller touser_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_urlandlistalso 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 exceptadmin, and firebefore_/after_hooks (before_delete,after_get_url, …). A path with a..segment is now a 400VALIDATION_ERRORon every storage operation; on the local provider it could reach another user’s directory. Breaking: a non-admin HTTP upload ofdocs/a.txtby user 42 is now stored at42/docs/a.txt(the returned URL says so), and non-admindelete/get_url/listreach 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.deletereturns the scoped key indata.path. - Every admin-tier role is treated as admin.
kirak.core.access_levels.ADMIN_ROLESdefines the admin tier asadmin,systemandsuperadmin, but modules kept their own lists: storage scoping exempted onlyadmin(asystemorsuperadmincaller with a user id was confined to its own{user_id}/), and the/admin/rate-limitsendpoints and the payments admin-only inputs and operations allowed onlyadminandsystem. Storage, payments, vector, notifications, scheduler, auth and the admin router now all useADMIN_ROLES. Behaviour change:superadmincallers can now use/admin/*and the admin/system-only payments operations, andsystem/superadminstorage calls use the path as given. A signed-in storage caller outside the admin tier with no user id is now refused with 403PERMISSION_DENIEDinstead 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_urlandlistare offered as tools, and a guest MCP server’s caller or an agent run for a guest reachesdispatch()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 401AUTHENTICATION_ERROR, as vector and payments already do. Breaking: code that callskirak.storage.*without an identity (your own route withoutset_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.pydoes 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/listworks on the local provider.listfailed with a 500STORAGE_ERRORwheneverupload_dirwas relative (the default,assets/media) or behind a symlink: each file’s resolved path was compared with the unresolvedupload_dir.- SVG is no longer a default image type.
storage.image_allowed_typesdefaulted tojpg,jpeg,png,webp,gif,svg, but Pillow cannot read SVG, so every SVGupload_imagepassed validation and then failed with a 500. The default is nowjpg,jpeg,png,webp,gif, and ansvgadded back is refused as a 400VALIDATION_ERRORnamingupload_file. Behaviour change: a project that setsvgexplicitly gets a 400 instead of a 500. - Animated GIFs and WebPs keep their animation.
upload_imagere-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_heightand compression are not applied), and their thumbnails show the first frame. - Approving a paused agent run works.
POST /ai/agent/resumeand/ai/agents/{name}/resumewith"approved": truefailed with a 500: they built the tool approvals withDeferredToolResults.build_results, which pydantic-ai does not have, and then read the HTTP status from asuccesskey 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_thresholdis applied. Every agent run compacted at 150000 tokens whateverkirak.jsonsaid, andnulldid not turn compaction off; the setting now reaches both run paths (JSON and streaming).- An agent’s
input_schemais checked. It was loaded and listed byGET /ai/agentsbut never applied.POST /ai/agents/{name}/runnow answers 400VALIDATION_ERROR(failures indetails.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 whoseinput_schemaits 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 namednotifications-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-tokenand/auth/verify-otptookip_addressanduser_agentfrom the request body, so a normal client recorded none and any client could record any value – including onauth.login.failureaudit events. These routes and the social routes now set both from the request (client IP honoringrate_limit.trusted_proxy_ips, theUser-Agentheader); a body value is ignored, and/docsandopenapi.jsonno longer list them as body fields. The social routes previously ignoredtrusted_proxy_ipsand recorded the proxy’s IP. - API keys work on MySQL/MariaDB.
POST /auth/api-keys/createsentexpires_atas an ISO string, which aDATETIMEcolumn rejects, so every create was a 500; the response’sidwas alwaysnull(so revoke byidwas impossible); a valid key was rejected with 401 because its stringexpires_atwas compared with adatetime; andGET /auth/api-keys/listwas a 500 for the same reason.expires_atis stored as a UTC datetime, and an unparseable one is a 400VALIDATION_ERROR. - Logging out and changing the password no longer revoke API keys.
POST /auth/logoutwithlogout_typeallorothers, andPOST /auth/change-password, deleted everyauth_tokensrow 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_SECRETis set. API keys were stored as an HMAC keyed byKIRAK_AUTH_JWT_SECRET_KEY, so rotating that secret invalidated every key;KIRAK_AUTH_JWT_OLD_SECRET_KEYdid not cover them. The new optionalKIRAK_AUTH_API_KEY_SECRETkeys 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. superadmincan manage other users’ API keys. Creating, listing and revoking another user’s API key accepted onlyadminandsystem, while the other admin auth routes (generate-verification-link) also acceptsuperadmin. All three now acceptadmin,systemandsuperadmin.- 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 thesubclaim, which failed for every UUID. - CLI output no longer masks the project path as
***. The secret-name pattern matched the shell’sPWDandOLDPWD, whose values are directories. - Dates sent as strings work on PostgreSQL. A filter on
created_at,updated_at,deleted_ator atimestamp/datetime/datefield with a string value (created_at__gte=2026-09-01over 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 fordatetimeanddatefields (onlytimestampfields were parsed). Filter values and written values on these fields are now parsed as ISO-8601 (2026-09-01,2026-09-01T12:00:00, withZor 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 adatefield) is now a 400VALIDATION_ERRORon 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 throughkirak.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/makemigrationsleft outkirak_rate_limits(fromkirak/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 theaimodule enabled,ai_conversationswas 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 runkirak db makemigrationsandkirak db migrateonce; the new migration creates the missing tables. - A create or upsert that leaves out a
requiredfield is a 400VALIDATION_ERROR, not a 500DATABASE_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 adefaultare not required in the payload. Bulk create reports the missing fields per record. kirak db makemigrationsno longer drops all developer tables on amodels/models.jsonlayout. Whenmodels/contained a single combinedmodels.json(the layoutcreate_kirak_app()examples use), the loader took the per-file branch and explicitly skippedmodels.json, so the target schema had no developer models. The diff then proposed dropping every developer table and overwrotemigrations/models_snapshot.jsonbefore review. Fixed by detectingmodels/models.jsonas a standalone combined file before the per-file scan. Projects with per-model files alongside amodels.json(the legacy skip) are unchanged.- Model
slugnow 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 andopenapi.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.mdanddocs/reference/http-api.mdalready documented this behavior.) - Auth cookie is now read as a third credential. With
auth.cookie_nameset,get_auth_dependency()now reads the named cookie as priority 3 (afterAuthorization: Bearer, afterX-API-Key). A browser that sends only the cookie is authenticated; a present-but-invalid cookie raises rather than downgrading to guest. The cookie’sSameSiteattribute was corrected fromNonetoLax, which covers same-site subdomain deployments and provides better CSRF protection. Cross-site deployments continue to use theAuthorizationheader. kirak modulesnow reports provider installation counts. Modules such aspayments,notifications,schedulerandvectorhave no module-level pip extra (their providers do), soinstalled: truewas always shown even when no provider SDK was available.module_summarynow includesproviders_installedandproviders_totalcounts (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 validatewas not affected and continues to reportprovider_not_installedper provider.
Removed
Section titled “Removed”-
Breaking: the
rate_limitsJWT claim no longer overrides per-model rate limits.check_rate_limitraised a model’smax_requestswhen the Bearer token carried{"rate_limits": {"<model>:<op>": n}}, but Kirak’s own access tokens never carry that claim and anafter_loginhook 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’srate_limitconfig instead (for example a separate model or operation for the paid tier). -
Breaking:
GET /ai/agent/runsandkirak/ai/run_log.pyare removed. Every agent run wrote the first 200 characters of its prompt and of its output to Redis, and/ai/agent/runsreturned 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/runsand/monitoring/ai/runs/{run_id}(monitoring bearer token; no prompt or output text), now listed with the other/monitoring/ai/*endpoints indocs/reference/monitoring.md. Migration: add"monitoring"tomodulesand read run history from/monitoring/ai/runswithKIRAK_MONITORING_INGESTION_KEY; existingai:runs:*Redis keys expire on their own within 7 days. -
Breaking: agent files use the
toolsmap, not an array.toolsnow uses the same map format as MCP server files:{"customer.fetch": {}, "order.fetch": {"requires_confirmation": true}}. Refs are lowercase (customer.fetch, notCustomer.fetch). The string shorthand ("Customer.fetch") andtype: "mcp"entries are removed.kirak validate agentsnow also checks that every tool ref names a real operation.ToolRefis removed fromkirak.aipublic exports;ToolDeclarationfromkirak.core.tool_declarationsreplaces 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
mcpmodule.create_kirak_app(include_mcp=..., mcp_prefix=...),mount_routers(include_mcp=..., mcp_prefix=...), themcp_api_key_requiredkey inkirak.json(now rejected at startup with a message) and the SSE endpoints/mcp/sseand/mcp/messages/are gone, with the server that exposed every model as nine tools and took atokenorapi_keyargument on each call. Migration: add"mcp"tomodulesinkirak.json, and write one file per server inmcp/listing the tools it offers ("tools": {"orders.fetch": {}, ...}), itsaccesslevel and rate limit; clients connect to/mcp/<name>/withAuthorization: Bearer <JWT or API key>instead of passing credentials as tool arguments. Seedocs/modules/mcp.md. -
Breaking: Swagger UI (
/docs) and ReDoc (/redoc) are no longer served./docsis now the Markdown API reference;/openapi.jsonstays where it was. Migration: importopenapi.jsoninto an API client such as Postman, Insomnia or Bruno. Passingdocs_urlorredoc_urltocreate_kirak_app()still works as plain FastAPI, but not on thedocs.pathused by Kirak. -
kirak.core.doc_generatoris removed (python -m kirak.core.doc_generator --models ... --output ...). It wrote static per-model Markdown files and listedupsertandrestorewith the wrong HTTP methods. A running app now describes its own API atGET /docs. -
Breaking: agent files are now checked against a schema, and unknown keys are rejected. Until now
agents/*.jsonchecked 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 againstkirak/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_authin agent files is rejected. It was documented but never read; who may call an agent is set byaccess("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 toaccess. The example agents anddocs/modules/ai.mdno longer use it. -
kirak.core.manifest.MANIFEST_SCHEMAandkirak.core.models_schema.MODELS_SCHEMAare removed. The schemas are now JSON files: usekirak.schemas.load_schema("manifest" | "models" | "model" | "agent"), orkirak.schemas.validator(name)for a validator that resolves the references between them. -
Breaking: the
ai_restrictedtool flag in agent JSON files is removed. A tool the agent must not use is simply left out of itstoolslist. An agent file whose tool entry still setsai_restrictedfails to load with a message saying so, rather than silently handing that tool to the agent. Theblockedstatus 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(usernamesandboxuses the sandbox API),termii(account base URL,generic/dndroute),fastsms. Push:expo(Expo push tokens, batches of 100, per-token failures reported like FCM). Each calls the provider’s HTTP API withhttpx, so none needs an extra install; each has a credential check (check()) exceptexpo, which has no endpoint for it. The email providers send attachments. Settings and secrets are indocs/modules/notifications.mdand the generated provider reference. - Monitoring records why an agent’s tool call failed.
ai_tool_callshas a newerror_messagecolumn, returned byGET /monitoring/ai/runs/{run_id}: the tool’s"<ExceptionType>: <message>", with emails,key=valuesecrets and bearer tokens masked (redact_text) and at most 300 characters. An existingmetrics.dbgets the column on startup. auth.access_token_expire_minutesinkirak.json: the access token lifetime in minutes, for lifetimes under an hour. When set it wins overaccess_token_expire_hours(whole hours, minimum 1), for the token expiry,expiresIn, and the auth cookie’smax_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).nullwhere there is no vendor (localstorage, thedatabasequeue,smtp, the generic webhook, and the protocol social backendssaml,oidc,openid,cas,email,username, and the defunctmineid). Every installed social login backend is covered. Declared onProviderSpec.website,AIModelProviderSpec.websiteandSOCIAL_WEBSITESinkirak/catalog/specs/auth.py; a third-party provider sets aWEBSITEclass attribute. A test fails when a built-in provider has none. - MCP servers from
mcp/*.json(themcpmodule). With"mcp"inkirak.jsonmodules, each JSON file inmcp/is an MCP server at/mcp/<name>/, for any MCP client:name,description,instructions(sent to clients),access(Guest,Userby default,Admin,System),rate_limit_per_minute(per caller, per IP for guests) and atoolsmap of the model and module operations it offers ("orders.fetch": {},"payments.refund_payment": {"requires_confirmation": true}). Tool input schemas come frommodels.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’sdataandpagination, and Kirak errors come back as tool errors. Callers authenticate withAuthorization: Bearer <JWT or API key>orX-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 codeduplicate_mcp_server).kirak schemaandkirak newwrite.kirak/mcp.schema.json, andkirak newmapsmcp/*.jsonto it. Python tools:@kirak.mcp("<name>").tool(registered inon_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’stimeoutanswersTOOL_TIMEOUT. Confirmation: arequires_confirmationtool 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 isCONFIRMATION_DECLINED, and clients on an older protocol getCONFIRMATION_UNAVAILABLEand the tool does not run. Output schemas where the shape is fixed (fetch,search,exists, and module operations that declare aresultin the catalog: the vector operations exceptdelete, and the saved-payment-method list, detach and set-default). With themonitoringmodule on, each call is recorded in a newmcp_callstable, read throughGET /monitoring/mcp/summaryandGET /monitoring/mcp/calls. Module operation specs gain an optionalresult(inkirak modules <name> --json). Tooling:kirak validate mcp(andvalidatewith no kind) checksmcp/*.jsonlike startup does, takes drafts with--fileor--override mcp:<name>=PATH, and warns aboutmcp_without_mcp_moduleandtoo_many_mcp_tools;kirak infolistsmcp_servers;GET /docslists each server’s path, access level and tools;kirak new’sAGENTS.mdnamesmcp/. Seedocs/modules/mcp.md. - A shared shape for declaring tools:
tools.schema.jsonandkirak.core.tool_declarations. Atoolsmap keys each tool by the operation it runs –"orders.fetch": {},"payments.refund_payment": {"requires_confirmation": true}– with the optionsname,description,requires_confirmation,timeoutand, for module operations,parameters.tool_declarationsreads a file without losing duplicate keys (loads_config), turns the map intoToolDeclarations (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 theirtoolsarray for now and move to this shape later.kirak schemaandkirak newnow also write.kirak/tools.schema.json. kirak new’sAGENTS.mdteaches “Kirak first” – configuration, models with an explicitaccessblock, modules and providers before Python; data only through the facade andkirak.graphql()(never an own database connection or raw SQL); own routes and jobs set the caller withset_user_context;kirak validate --strictafter every edit – and points to the coding-agent plugins (kirak-agent-kit). Still under 60 lines.- Dev MCP server:
kirak_cataloglists social login and AI model providers with the newlistingargument (social_providers,ai_model_providers), the same data as those parts ofkirak catalog --json. - Provider credential checks:
await provider.check(). Every provider base class (payments, notifications, storage, vector, scheduler) now hascheck(live=True, write=False, timeout=10.0), returning aCheckResult(kirak.core.provider_check:statusok/warning/unverified/failed, acode, a one-linemessage, non-secretdetail). The caller builds the provider from explicit config – itskirak.jsonsettings 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.jsonor the environment. The live check is the cheapest read-only call that proves the credentials (nothing billed, sent, created or changed;write=Truemakes a storage provider also write and delete one object);live=Falseruns 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;databasehas 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;paypalandairwallex: an OAuth token, and for PayPal the webhook named bywebhook_id;telrhas 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 includemail.send;smtp: connect, STARTTLS and log in, then quit;twilio: the account’s status;firebaseandapn: a push to a device token that cannot exist, which proves the key and delivers nothing;huawei: an OAuth token;slack:auth.testwith thechat:writescope;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 awarning(test_mode_key). New codes indocs/reference/problem-codes.md(credentials_invalid,permission_denied,resource_not_found,provider_unreachable,provider_timeout, …).ProviderSpec.verifiessays 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 itcheck()reportscheck_not_supported. Helpers inkirak.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. Seedocs/contributing/adding-a-provider.md“Credential check”. - Social login credential checks:
await check_social_backend(name, settings, secrets)(kirak.auth.social).settingsis the backend’sauth.socialblock,secretsits secret environment variables by name; nothing is read fromkirak.jsonor 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’sbad_verification_code) means the client id and secret were accepted,invalid_clientmeans they were not,redirect_uri_mismatchthat the redirect URI was refused. Facebook and TikTok get aclient_credentialstoken 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_idorteam_idshows asinvalid_client. Returns aCheckResultlikeprovider.check(); an answer it cannot classify isunverified.FastAPIStrategytakes an optionalenvironmapping to read secrets from (default: the process environment). Seedocs/authentication/social-auth.md“Checking the credentials”. - A new project installs its database driver.
kirak new --database mysql|postgres(defaultmysql) writes the matchingdatabaseblock, and the scaffoldedpyproject.tomldepends onkirak[mysql]orkirak[postgres]instead of plainkirak; it also sets[tool.setuptools] packages = [], without whichpip install -e .failed on the flat layout. The next steps start withpip install -e ..kirak validatereports a missing driver as adatabase_driver_not_installederror (--extrasis honoured),kirak infoas a warning. Existing projects: changekiraktokirak[mysql](orkirak[postgres]) inpyproject.tomland 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, orkirak.logwhen monitoring is off), and the running app’sGET /docs(localhost only). Install withpip install "kirak[dev-mcp]". Separate from the runtimemcpmodule. Seedocs/guides/ai-coding-agents.md.python -m kirakruns the CLI, for when thekirakscript 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, theopenapi.jsonlink), authentication, the response envelope, the CRUD endpoints (one table for every model, with what each returns), a compact block per model (fields withread-onlymarked, 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,bto those models; every response has anETagforIf-None-Match. A Not Supported list states what agents most often assume exists; a fetch response is shown whole, withpaginationnext todata; ownership rules readuser (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 inkirak/catalog/specs/resources.py(entries without a URL are not shown).docsinkirak.jsonturns it off (enabled), moves it (path) or requires a credential (public: false). Seedocs/guides/api-docs-endpoint.md.openapi.jsonlists 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 inx-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/docsand inkirak 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.publicinkirak.json(defaulttrue):falsemakesGET /openapi.jsonrequire a signed-in caller or an API key, asdocs.publicdoes for/docs. The two are separate; until nowdocs.public: falseleftopenapi.json, which describes the same models and access rules, open to anyone. Seedocs/guides/api-docs-endpoint.md.- Catalogued API error codes. The stable
errorvalues callers branch on are now declared once:CORE_ERROR_CODESinkirak/catalog/specs/core.pyandModuleSpec.error_codes(auth and storage so far)./docslists those of the running app,kirak modules <name>reports them (error_codes), and the tables indocs/concepts/response-envelope.mdanddocs/reference/http-api.mdare 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 listedUSER_NOT_FOUND, which Kirak never returns: a valid token whose user is gone answersNOT_FOUND. Payments, notifications, ai and vector codes are not catalogued yet. ModuleSpec.system_apimarks modules whose routes serve operators and tooling (monitoring, scheduler)./docslists 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 overhttpx, not PayPal’s SDK): one-time payments, capture, verify, refunds, subscriptions (cancel, plan change, pause/resume), reading disputes, and webhooks.initiate_paymentcreates an order foramountxquantity(amountis the unit amount, as with Stripe; intentCAPTURE,custom_id= the transaction id) and returns its approval link, and refund webhooks markREFUNDEDonce the refunds reachamountxquantity; the order is captured from theCHECKOUT.ORDER.APPROVEDwebhook orverify_paymentwith onePayPal-Request-Idper order (anORDER_ALREADY_CAPTUREDanswer is treated as captured), and onlyPAYMENT.CAPTURE.COMPLETEDcompletes it.refund_paymenttakes the capture id and forwardsidempotency_keyasPayPal-Request-Id(askirak-<sha256 of operation:user_id:key>, with the operationrefund,initiateoroff_sessionso one key never collides across operations, since PayPal scopes it per account and caps it at 108 chars;initiate_paymentdoes the same). Events:payment_completed,payment_failed(PAYMENT.CAPTURE.DECLINED/DENIED),refund_completed,refund_pending. Subscriptions:type: "subscription"withsubscription_plan_id(a PayPal plan id) creates the subscription (custom_id= the transaction id) and returns its approve link;BILLING.SUBSCRIPTION.*webhooks keepsubscriptionsin sync (subscription_updated,subscription_cancelled,subscription_past_due); the firstPAYMENT.SALE.COMPLETEDcompletes the initial transaction (payment_completed), later ones save a renewal per sale id (subscription_renewed);expires_onfollows PayPal’s next billing time; subscription payments cannot be refunded throughrefund_paymentyet.cancel_subscriptionis immediate only (at_period_endisNOT_SUPPORTED, 501),update_subscriptionreturns PayPal’s approve link for the buyer,pause_subscription/resume_subscriptionsuspend/activate.get_disputereads a dispute;submit_dispute_evidenceisNOT_SUPPORTED(PayPal takes evidence only as a multipart file upload);CUSTOMER.DISPUTE.*reportdispute_created/dispute_updated. Saved methods (Vault v3, needs reference-transaction approval and vaulting enabled on the PayPal account):save_payment_methodon a one-time order vaults the buyer’s PayPal account, saved with its consent from the order whenPAYMENT.CAPTURE.COMPLETEDarrives and before the payment is completed (a subscription with the flag isNOT_SUPPORTED);VAULT.PAYMENT-TOKEN.CREATEDactivates a pending token (payment_method_saved),VAULT.PAYMENT-TOKEN.DELETEDrevokes it (payment_method_removed);detach_payment_methoddeletes the token,set_default_payment_methodis local only; saving without paying and attaching a token areNOT_SUPPORTED.charge_off_sessioncreates one order on the vault token (stored_credentialmerchant/subsequent, hashedPayPal-Request-Id) that PayPal captures in the same call; the row staysPROCESSING(with the capture id, so a replay returns the samegateway_payment_id) untilPAYMENT.CAPTURE.COMPLETED, a 422 decline isFAILEDwithdecline_code(kept by a laterPAYMENT.CAPTURE.DECLINED),PAYER_ACTION_REQUIREDisREQUIRES_ACTION, and a timeout/5xx/429 isPROCESSING. Each delivery is verified online through PayPal’sverify-webhook-signatureAPI (raw body posted back unmodified) and deduplicated on the eventid; there is no time window onPAYPAL-TRANSMISSION-TIME, since PayPal retries for up to 3 days. SecretsKIRAK_PAYMENT_<INSTANCE>_CLIENT_ID,_CLIENT_SECRET,_WEBHOOK_ID;environmentissandbox(default) orlive. Seedocs/modules/payments.md“PayPal”. - Paddle payment provider (
type: paddle, Paddle Billing, merchant of record; no extra install – it calls Paddle’s REST API overhttpx, sincepaddle-python-sdkneeds Python >= 3.11): one-time payments, verify, refunds and webhooks.initiate_paymentcreates a Paddle transaction from the caller’sprice_idor a non-catalog unit price (amount/currency/name, billedquantitytimes, producttax_categoryfrom the newdefault_tax_categorysetting, defaultstandard), withcustom_datanaming the transaction and instance, and returns itscheckout.url– the account’s default payment link on an approved domain whose page loads Paddle.js (orcheckout_url); no URL fails withPADDLE_CHECKOUT_NOT_CONFIGURED(500).transaction.completedcompletes the row once withamount= grand total (tax included, all units),quantity= 1 (the ordered quantity kept inipn_dump.quantity),net_amount= earnings andcurrency(payment_completed); renewals (origin: subscription_recurring) save asubscription_renewalrow per Paddle transaction id (subscription_renewed);transaction.canceledispayment_failed,transaction.payment_failedis not final and reports nothing.refund_paymentcreates a refund adjustment (full, or a partial amount of a single-item transaction) that Paddle approves later:adjustment.createdreportsrefund_pending, approval records the refund (refund_completed), rejection records nothing; chargeback adjustments reportdispute_created. Webhooks are verified locally (HMAC-SHA256 ofts:body, anyh1), with no time window onts, and deduplicated onevent_id. Subscriptions:type: "subscription"needs a recurringprice_id;subscription.*webhooks keepsubscriptionsin sync (status upper case, plan, quantity,expires_onfrom the current billing period), reportingsubscription_updated,subscription_past_dueorsubscription_cancelledby the status set (CANCELEDis final); renewals also update the subscription’s amount; plan-change prorations are saved assubscription_updaterows with no event.cancel_subscription(immediate orat_period_end),update_subscription(new_plan_id,proration_billing_mode, defaultprorated_immediately),pause_subscription/resume_subscription, andget_billing_portal(a Paddle customer portal session).charge_off_sessioncharges an active subscription (provider+subscription_id, no saved method; elseNOT_SUPPORTED) with a tagged one-off price;transaction.completed(originsubscription_charge) completes it once; a 4xx isFAILEDwith Paddle’s error code, a timeout/5xx/429PROCESSING.charge_off_sessionnow uses a named provider that implements off-session charges but not saved methods without looking up a payment method (payment_meta.payment_method_idisnullthen). Custom-provider authors: a provider implementingSupportsOffSessionChargewithoutSupportsPaymentMethodsnow receivesmethod=Nonewhen named inprovider, and must charge from its own params (as Paddle does withsubscription_id) and validate them itself; providers withSupportsPaymentMethodsare unchanged. Disputes and saved methods are not supported. SecretsKIRAK_PAYMENT_<INSTANCE>_API_KEY,_WEBHOOK_SECRET;environmentissandbox(default) orproduction. Seedocs/modules/payments.md“Paddle”. - Paystack payment provider (
type: paystack; no extra install – it calls Paystack’s REST API overhttpx, sincepaystack-sdkhas had no release since 2022): one-time payments, verify, refunds, subscriptions (cancel, manage link), reading disputes, saved cards, off-session charges and webhooks. Configuration: secretKIRAK_PAYMENT_<INSTANCE>_SECRET_KEY(also the webhook HMAC key);environmentistest(default) orliveand must match the key prefix;success_urlis sent ascallback_urlwhen absolute. Amounts are integer subunits = base amount x 100 for every currency, including XOF and RWF (CURRENCY_EXPONENTS). Payments:initiate_paymentneedspayment_email, stores a random referencekirak-<32 hex>as the row’stransaction_idbefore callingPOST /transaction/initialize(amountxquantity,metadatanaming the transaction and instance) and returns Paystack’sauthorization_urlasurl.charge.successis never trusted alone: the row is completed once (payment_completed, withamountandcurrency) only afterGET /transaction/verify/{reference}reportssuccess(orreversed, so a refund can then be recorded) for the same reference, currency and amount (a fee-inclusiveamountwith an exactrequested_amountalso matches) and itsmetadata, where present, names this transaction and instance; a mismatch writes nothing and is acknowledged, whilefailed,abandoned, an in-progress status or an unknown verify outcome writes nothing and fails (500) so Paystack redelivers.verify_paymentsetsPROCESSINGon a confirmed success andFAILEDonly onfailed. Refunds:refund_paymentqueues a full or partial refund (a partialamountis in the transaction’s own currency);refund.pending/refund.processingreportrefund_pending,refund.processedrecords it with a running total keyed byrefund_reference(refund_completed;REFUNDEDnever moves back),refund.failed/refund.needs-attentionare 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"withsubscription_plan_id(a plan code) addsplanto the initialize call; the firstcharge.successis confirmed by verify on that plan. Since no Paystack event names both the charge and the subscription it creates, the subscription is bound throughGET /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 cardsignature) and was not created before the charge (both PaystackcreatedAttimes);subscription.createretries the binding (redelivered while a matching first charge from the last 48 hours is not completed yet, reportingsubscription_updated); an ambiguous match binds nothing and is logged. The first charge and each renewal set the subscription’s amount to the verifiedrequested_amountwhen it equals the plan’s amount (fees passed to the customer excluded), else the charged amount. Renewals come from a paidinvoice.update, re-verified by its reference, onesubscription_renewalrow per reference (subscription_renewed,PAST_DUE/NON_RENEWINGback toACTIVE,expires_onnever moved back; the renewal’s owncharge.successis only logged);invoice.payment_failedsetsPAST_DUE(subscription_past_due),subscription.not_renewNON_RENEWING(also without a status;subscription_updated),subscription.disableCANCELLED/COMPLETE(subscription_cancelled).cancel_subscriptiondisables immediately (at_period_endisNOT_SUPPORTED); there is no plan change or pause.get_billing_portalreturns Paystack’s manage link. Disputes are read-only:get_disputereads one,submit_dispute_evidenceisNOT_SUPPORTED, andcharge.dispute.*reportdispute_created/dispute_updatedwithout changing the row. Saved cards:save_payment_methodon a one-time payment saves the verified charge’sauthorizationbefore the row is completed (never for a settled row), only when Paystack reports itreusable: gateway idauthorization_code, customer code, brand/last4/expiry, consent, and the cardsignatureand customer email inmeta(only that email can charge it). The newest code of a card replaces the user’s older methods of the instance with the samesignature(the default moves to it first; the older codes are revoked in Kirak only and stay chargeable at Paystack).save_payment_methodon a subscription, saving without paying and attaching a token areNOT_SUPPORTED.detach_payment_methodcallsPOST /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_methodis local only. Off-session:charge_off_sessionstores a new random reference on thePENDINGrow (never derived from theidempotency_key; retries are replayed from the row) and callsPOST /transaction/charge_authorizationwith the saved email:successleaves the rowPROCESSING(returnedCOMPLETED) for the verifiedcharge.successto complete once,pausedisREQUIRES_ACTIONwith Paystack’sauthorization_urlasaction_url,failedor a 400 isFAILEDwithdecline_code, and a timeout/5xx/429/unreadable reply orduplicate_referenceisPROCESSINGwith outcome unknown. Custom-provider authors:charge_off_sessionmay now returnaction_urlwithREQUIRES_ACTION; the operation keeps it and stores it for replays instead of creating a recovery checkout (unchanged when a provider returns none).GET /payments/providersreportsbilling_portal,dispute_read,off_session,payment_methods,subscription_cancelandsubscriptions. Seedocs/modules/payments.md“Paystack”. - Flutterwave payment provider (
type: flutterwave, API v3; no extra install – it calls Flutterwave’s REST API overhttpx, sincerave_pythonneeds 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: secretsKIRAK_PAYMENT_<INSTANCE>_SECRET_KEYandKIRAK_PAYMENT_<INSTANCE>_SECRET_HASH(the dashboard’s webhook secret hash, both required);environmentistest(default) orlive, a key without that mode’s prefix is logged;success_urlis sent asredirect_urlwhen absolute;countryis the merchant’s 2-letter ISO country code, checked at startup (off-session charges need it and an absolutesuccess_url, elseCONFIGURATION_ERRORbefore anything is saved). Amounts are major-unit decimal strings with ISO 4217 exponents.initiate_paymentneedspayment_email, stores a randomtx_refkirak-<32 hex>as the row’stransaction_idbefore callingPOST /v3/payments(amountxquantity,metanaming the transaction and instance) and returns the payment link asurl. Webhooks are checked by theverif-hashheader (constant time; missing or wrong -> 400), deduplicated on the sha256 of the raw body (events have no id), and never trusted alone:charge.completedre-reads the transaction by id (GET /v3/transactions/{id}/verify) and completes the row once (payment_completed) only for asuccessfultransaction with the sametx_ref, currency, anamount(notcharged_amount) of at leastamountxquantity, andmeta, where present, naming this transaction and instance. A checkout can have several attempts under onetx_ref: a failed attempt keeps the rowPENDING(noted inipn_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= thetx_ref, optionalflutterwave_transaction_id) setsPROCESSINGon a confirmed success and never fails a checkout (a failed attempt of a settled payment reports no outcome).refund_paymentrefunds a completed payment (409 before). A refund is recorded when Flutterwave accepts it (completed,processing,pending-momoorcompleted-*), under Flutterwave’s refund id, with a running total (REFUNDEDnever moves back), both byrefund_paymentand by the refund webhook, which Flutterwave sends only when its support enables it (a bare refund object, re-read withGET /v3/refunds/{id}, recorded only for the transaction that completed the row, reportingrefund_completed). A failed refund or payout (meta.disburse_statusfailed) is not recorded, only noted inipn_dump.refund_issue; a payout that fails after the refund was recorded leaves the row refunded. Events for atx_refno row of the instance holds are acknowledged without a write. Subscriptions:type: "subscription"needssubscription_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 isPLAN_CURRENCY_MISMATCHand one notactivePLAN_NOT_ACTIVE, 400, nothing saved), a plan with an amount sets the price (rowamount= the plan’s,quantity1), andpayment_planis added to the payment link (card only). The first charge is confirmed like a one-time payment; its subscription is then bound throughGET /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 (asubscriptionsrow plus the row’ssubscription_id;payment_completedcarriessubscription_id); none yet, several or an unfiltered reply complete the payment unbound, andverify_paymenton the completed row (or a latercharge.completedof another attempt of the same checkout) retries the binding of a completed, unbound first charge: callverify_paymentafter apayment_completedfor a subscription that lackssubscription_id. Renewals are not recorded (nosubscription_renewed: a renewal charge names no subscription; one on a plan is only logged).subscription.cancelled(no subscription id) marksCANCELLEDonly this instance’s rows with the event’s email and plan thatGET /v3/subscriptions?email=&plan=&status=cancelledlists (paged), reportingsubscription_cancelledonly 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_subscriptioncallsPUT /v3/subscriptions/{id}/cancel(immediate only;at_period_endisNOT_SUPPORTED) and marks the rowCANCELLEDwhen Flutterwave answers it cancelled (no event is reported for it); plan change, pause and the billing portal areNOT_SUPPORTED. Disputes are read-only:get_disputereads a chargeback by its id (GET /v3/chargebacks?id=),submit_dispute_evidenceisNOT_SUPPORTED, and the opt-inchargeback.initiated(dispute_created) andchargeback.accepted/.declined/.lost(dispute_updated; the undocumentedchargeback.won/.reversedare mapped the same way in case they are sent) are re-read byflw_refand 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_methodon 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 inmetathe charge’s email (a token charges only with it),card_identity(first 6 + last 4 digits + expiry; Flutterwave has no card fingerprint) andtoken_expires_at(the charge’s time plus one year, the token’s documented life). Of the user’s methods of the instance with the samecard_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_methodon a subscription, saving without paying and attaching a token areNOT_SUPPORTED;detach_payment_methodis local only (Flutterwave has no token delete API, so the token stays usable there until it expires);set_default_payment_methodis local only. Off-session:charge_off_sessionrefuses 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 randomtx_refon thePENDINGrow (never derived from theidempotency_key; retries are replayed from the row) and callsPOST /v3/tokenized-charges(token, saved email, amount, currency, country, tx_ref,redirect_url=success_url,meta):successful(no-auth accounts only) leaves the rowPROCESSING(returnedCOMPLETED) for the verifiedcharge.completedto complete once,pendingwithmeta.authorization.redirect(3-D Secure, the default) isREQUIRES_ACTIONwith that URL asaction_url, anotherpendingisPROCESSING,failedor a 400 isFAILEDwithdecline_code, any other 4xx marks the rowFAILEDand raises, and a timeout/5xx/429/unreadable reply isPROCESSINGwith outcome unknown (callverify_paymentif no webhook settles it). A failed attempt on the 3-D Secure page leaves the rowREQUIRES_ACTION(like a checkout); a verified failed charge of aPROCESSINGoff-session row, which no customer can retry, marks itFAILEDand reportspayment_failed(also throughverify_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 stillPENDINGfails the webhook so it is redelivered.GET /payments/providersreportsdispute_read,off_session,payment_methods,subscription_cancelandsubscriptions. Seedocs/modules/payments.md“Flutterwave”. - Mercado Pago payment provider (
type: mercadopago; no extra install – it calls Mercado Pago’s REST API overhttpx, since themercadopagopackage 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: secretsKIRAK_PAYMENT_<INSTANCE>_ACCESS_TOKENandKIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRET(the application’s Webhooks secret signature, both required);environmentissandbox(default; the sandbox checkout link when returned) orproduction;success_urlis 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}, elseAMOUNT_NOT_REPRESENTABLE). Payments:initiate_paymentstores a randomexternal_referencekirak-<32 hex>on the row before creating the preference (metadatanaming the transaction and instance) and returns itsinit_pointasurl. Webhooks are verified byx-signature(hex HMAC-SHA256 ofid:<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. Apaymentnotification completes the row once (payment_completed, withamountandcurrency) only for anapprovedpayment with the row’sexternal_reference, currency, atransaction_amountof at leastamountxquantityandmetadata, where present, naming this transaction and instance; a rejected or cancelled attempt keeps the rowPENDING(the buyer can retry), and a pending Pix/boleto completes on a later notification.verify_paymentsetsPROCESSINGon a confirmed approved payment and never fails the row.refund_paymentsends a randomX-Idempotency-Key; an approved refund is recorded at once, one in process when a laterpaymentnotification shows it approved (refund_completed), keyed by refund id with a running total (REFUNDEDnever moves back). Subscriptions:type: "subscription"withsubscription_plan_id(a preapproval plan id,active, in the requested currency; elsePLAN_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 (aPENDINGsubscriptionsrow and the row’ssubscription_id) and returns itsinit_point.subscription_authorized_paymentre-reads the charge: the first approved one completes the row (payment_completed, subscriptionACTIVE), later ones save asubscription_renewalrow per payment id (subscription_renewed), a retried charge setsPAST_DUE(subscription_past_due); the subscription’s ownpaymentnotifications are acknowledged.subscription_preapprovalstores status, amount and next payment date (subscription_updated,subscription_cancelledwhen the status changed;CANCELLEDis final).cancel_subscription(immediate only),pause_subscription/resume_subscriptionandupdate_subscription(another plan’s amount x quantity, same frequency and currency, elsePLAN_INTERVAL_MISMATCH) change the preapproval. Disputes are read-only (get_dispute;topic_chargebacks_whreportsdispute_created/dispute_updatedfor 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/providersreportsdispute_read,pause,subscription_cancel,subscription_lifecycle,subscription_updateandsubscriptions. Seedocs/modules/payments.md“Mercado Pago”. - Xendit payment provider (
type: xendit, Invoices; no extra install – it calls Xendit’s REST API overhttpx, sincexendit-pythonneeds Python >= 3.10): one-time payments, verify, refunds and webhooks. Configuration: secretsKIRAK_PAYMENT_<INSTANCE>_SECRET_KEYandKIRAK_PAYMENT_<INSTANCE>_CALLBACK_TOKEN(the dashboard’s webhook verification token; both required);environmentistest(default) orliveand must match the key’sxnd_development_/xnd_production_prefix;success_urlis 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}, elseAMOUNT_NOT_REPRESENTABLE).initiate_paymentstores a randomexternal_idkirak-<32 hex>on the row before creating the invoice (metadatanaming the transaction and instance), keeps the invoice id and returnsinvoice_urlasurl. Webhooks are checked byx-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 aPAID/SETTLEDinvoice with the row’sexternal_id, invoice id, currency, apaid_amountof at leastamountxquantityand matching Kirakmetadata; anEXPIREDinvoice marks itFAILED(payment_failed).verify_paymentsetsPROCESSING(paid) orFAILED(expired).refund_paymentcallsPOST /refundswith the invoice id and a randomIdempotency-key; a refund is recorded when Xendit reports itSUCCEEDED, at once or from the re-readrefund.succeededwebhook (refund_completed, running total,REFUNDEDnever moves back). Subscriptions, disputes, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501);GET /payments/providersreports no optional capability. Seedocs/modules/payments.md“Xendit”. - Airwallex payment provider (
type: airwallex, Payment Links; no extra install – it calls Airwallex’s REST API overhttpx, since Airwallex publishes no Python SDK): one-time payments, verify, refunds, reading disputes and webhooks. Configuration: secretsKIRAK_PAYMENT_<INSTANCE>_CLIENT_ID,_API_KEYand_WEBHOOK_SECRET(all required);environmentissandbox(default, the demo host) orproduction. 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_paymentstores a random referencekirak-<32 hex>on the row before creating a single-use payment link (metadatanaming the transaction and instance), keeps the link id and returns the link’surl. Webhooks are verified byx-signature(hex HMAC-SHA256 ofx-timestamp+ raw body; no time window) and deduplicated on the event id.payment_link.paidre-reads the link and its latest successful payment intent and completes the row once (payment_completed) only for aPAIDlink with the row’s reference and aSUCCEEDEDintent in the row’s currency for at leastamountxquantity; otherwise the webhook fails so Airwallex retries.refund_paymentcreates a refund with a randomrequest_idand metadata naming the reference;ACCEPTED/SETTLEDrefunds are recorded at once or fromrefund.accepted/refund.settled(refund_completed, running total,REFUNDEDnever moves back).get_disputereads a dispute;payment_dispute.*reportdispute_created/dispute_updatedfor the row the disputed intent completed. Subscriptions, dispute evidence, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501);GET /payments/providersreportsdispute_read. Seedocs/modules/payments.md“Airwallex”. - Omise payment provider (
type: omise, Opn Payments Links; no extra install – it calls Omise’s REST API overhttpx, since theomisepackage keeps the API key in module-global state, which two instances would share): one-time payments, verify, refunds, reading disputes and webhooks. Configuration: secretKIRAK_PAYMENT_<INSTANCE>_SECRET_KEY(required) andKIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRET(the dashboard’s base64 webhook secret; optional);environmentistest(default) orliveand must match the key. Amounts are integer minor units with ISO 4217 exponents.initiate_paymentcreates a single-use link and stores its id as the row’stransaction_idbefore returning itspayment_uri. Webhooks are verified byOmise-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.completere-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 leastamountxquantity; a failed charge keeps the rowPENDING. Since Omise does not guarantee webhook retries,verify_paymentreads the link’s charges and completes the row itself when one is confirmed (no event).refund_paymentrefunds the completing charge and records it at once;refund.createrecords refunds once (refund_completed), including dashboard ones.get_disputereads a dispute;dispute.*reportdispute_created/dispute_updated. Subscriptions, dispute evidence, saved payment methods and off-session charges are not supported (NOT_SUPPORTED, 501);GET /payments/providersreportsdispute_read. Seedocs/modules/payments.md“Omise”. - Telr payment provider (
type: telr, Hosted Payment Page; no extra install – it calls Telr’sorder.jsonoverhttpx; Telr publishes no Python SDK): one-time payments, verify and transaction advice. Configuration:store_idand an absolutesuccess_urlin kirak.json (both required), secretsKIRAK_PAYMENT_<INSTANCE>_AUTH_KEYandKIRAK_PAYMENT_<INSTANCE>_ADVICE_SECRET(both required);environmentistest(default, test orders) orlive. Amounts are major-unit decimal strings with ISO 4217 decimals (three for KWD, BHD, OMR, JOD).initiate_paymentstores a random cart idkirak-<32 hex>on the row beforemethod: "create", keeps the order ref and returns the payment pageurl; a Telrerroranswer marks the rowFAILED(TELR_ERROR). The order is always read withmethod: "check"before the row changes: aPaidorder of the row’s cart id, currency and amount with an authorised transaction completes it once; an expired or cancelled order fails it.verify_paymentdoes 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 validtran_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/providersreports no optional capability. Seedocs/modules/payments.md“Telr”. currencycolumn ontransactionsandsubscriptions(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 Stripeinvoice.payment_succeededand Razorpaysubscription.chargednow write it, upper case. A completed-payment webhook result (payment_completed,subscription_renewed) also carriescurrencyinresult["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 andinvoice.payment_succeeded, Razorpay and Square reportcurrencyalone, so read the amount from the transaction row there. Rows written before this column existed stayNULL– nothing is backfilled or guessed. Seedocs/modules/payments.md“Money & Amounts” and “Events”.- Vector module (
kirak.vector, addvectortomodules): vector stores and embeddings for retrieval-augmented generation. Two independent provider categories, configured undervector.storeandvector.embeddinginkirak.jsonlike the other modules’ named provider instances: storespinecone(no extra install) ands3_vectors(Amazon S3 Vectors,pip install "kirak[vector-s3vectors]"), embedding providersopenai,google(Gemini) andollama(no extra installs). Operationscreate_index,delete_index,list_indexes,describe_index,upsert(items carryvaluesortext, which is embedded first),delete(byidsorfilter),fetch,search(byvectorortext),embedandembed_batch, each withbefore_/after_hooks. The module has no HTTP endpoints by design (index management is infrastructure,embedwould spend the app’s quota, rawsearch/fetchwould 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.upsertandsearchreject 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>andKIRAK_VECTOR_EMBEDDING_<INSTANCE>_<FIELD>. Custom providers:kirak.vector.register_provider("store" | "embedding", type, cls)or the entry-point groupskirak.vector_store_providersandkirak.vector_embedding_providers. Seedocs/modules/vector.mdandexamples/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 infoalso shows it, with the project’s details.- Generated reference tables.
scripts/gen_reference.pynow also writes, fromkirak.catalogand the JSON Schemas, the modules list, every module’skirak.jsonkeys, every provider’s settings and secrets, the environment variables Kirak reads and the AI model providers, todocs/reference/generated/.docs/reference/configuration.md,docs/reference/monitoring.mdand the newdocs/guides/ai-coding-agents.mdguide 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 newscaffolds for editors and coding agents.kirak.jsonandmodels/posts.jsonget a"$schema"key, the schemas are written to.kirak/,.vscode/settings.jsonmapskirak.json,models/*.jsonandagents/*.jsonto them,.env.exampleis generated from the project’skirak.json(the variableskirak envlists), and a shortAGENTS.mdtells a coding agent whichkirakto run, the facts commands and to validate after every edit.kirak new --heremakes 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 validateandkirak.validation.validate_project(): every problem in a project’s models,kirak.jsonand 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 inkirak.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). Seedocs/reference/cli.md.kirak envandkirak.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-filechecks another runtime’s names). Every variable Kirak reads is now declared inkirak/catalog/specs/, and a test fails on an undeclared one. Seedocs/reference/cli.md.- Social login providers in
kirak catalog(social_providers) and a regenerateddocs/reference/social-providers.md: every backend of the installed social-core plus Kirak’s own, with a readable name, description, protocol, class path,kirak.jsonblock and keys, secret environment variable, the extra social-core settings its code reads (and which of themkirak.jsoncannot supply yet), and the package to install when one is missing. kirak modules,kirak providersandkirak catalog, and thekirak.catalogPython 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 akirak.jsonexample; 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.--extraschecks against the extras of a target runtime instead of the current Python. Built-in modules and providers are now declared once inkirak/catalog/specs/(ModuleSpec,ProviderSpec); the provider registries and each class’sSECRET_FIELDSread from there, and tests check every spec against its code. Providers from other packages are listed from their entry points. Seedocs/reference/cli.mdanddocs/contributing/adding-a-provider.md.kirak infoshows a project’s Kirak version, enabled modules, models, agents, migrations and whether its exported schemas are current, read from the files only.--jsononkirak infoandkirak db statuswrites 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.envappears in it. Seedocs/reference/cli.md#json-output.kirak schemaand packaged JSON Schemas formodels.json(models.schema.json, andmodel.schema.jsonfor one model or one file inmodels/),kirak.json(manifest.schema.json) and agent files (agent.schema.json), shipped inkirak/schemas/with a description of every key.kirak schemawrites 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 inmodels.json, a model file,kirak.json,kirak.local.jsonor an agent file; Kirak ignores that key when loading. Startup logs a warning when.kirak/holds schemas from another Kirak version. Seedocs/reference/cli.md.- Breaking: three ways to register a hook are removed:
HookBuilder.register(),BaseModule.register_hook()andKirak.register_hook(). Two forms remain: the decorator (@kirak.on(model).hook(event), or call it directly askirak.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). Seedocs/concepts/hooks.md. - Breaking: the
debuganddisable_graphql_introspectionkeys are removed fromkirak.json. Neither had any effect (nothing readdebug, and GraphQL introspection is always rejected). Akirak.jsonthat still has one fails to load with a message; delete the key. Uselog_levelto control logging. - Breaking: the bare
paymentsandnotificationspip extras are removed. Installpayments-stripe,payments-razorpay,payments-squareorall-payments, andnotifications-ses,notifications-sns,notifications-sendgrid,notifications-smtp,notifications-firebase,notifications-twilio,notifications-apnorall-notifications.pip install "kirak[payments]"and"kirak[notifications]"from an older guide now fail. kirak.local.json: optional per-environment file merged overkirak.json(objects merge, lists and values replace) so the database host,base_urland CORS origins can differ per environment without environment variables. Seedocs/reference/configuration.md.- Notification channels
imandwebhook:kirak.notifications.send_im(Slack, Discord, Telegram) andsend_webhook(a signed HTTP POST to your own systems), each with named provider instances, adefault_providerand an optionalproviderper call, configured undernotifications.imandnotifications.webhookinkirak.json. HTTP routes/notifications/send-imand/notifications/send-webhook; hooksbefore_/after_send_imandbefore_/after_send_webhook;send_multiacceptsimandwebhookas channels; users can opt out of each through channel preferences. Webhook requests carryX-Kirak-TimestampandX-Kirak-Signature(sha256=HMAC over<timestamp>.<body>) when the instance has a secret, and a per-call URL is refused unless the instance setsallow_url_override. Custom providers subclassIMProviderorWebhookProvider(entry-point groupskirak.notifications_im_providersandkirak.notifications_webhook_providers). Seedocs/modules/notifications.mdandexamples/16_custom_im_provider.py. - New notification providers: Twilio (
sms,kirak[notifications-twilio]), Apple Push (pushtypeapn,kirak[notifications-apn]) and Huawei Push Kit (pushtypehuawei, no extra install). kirak.notifications.send_inboxandPOST /notifications/send-inbox: the name for the in-app call.send_notificationand/send-notificationremain 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 (default3600). Seedocs/modules/scheduler.md.QueueBackend._stale_after(): for custom scheduler backends, the number of seconds a job must have been running beforereset_stalemay reset it.scheduler.queues(kirak.json): extra queue names the worker consumes besidesdefault_queue(default[]). Seedocs/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": Truein the webhook result to skip the completed hook on a repeated delivery. Seedocs/contributing/adding-a-provider.md.- Provider instances for Payments, Storage, Notifications and the Scheduler:
kirak.jsonnow lists named provider instances per module ("providers": {"main": {"type": "aws", ...}}) with adefault_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). Seedocs/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)andkirak.scheduler.register_provider(type, cls)(all also usable as decorators), plus entry-point discovery in the groupskirak.payments_providers,kirak.storage_providers,kirak.notifications_{email,sms,push}_providersandkirak.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 inkirak.jsonis rejected. - Scheduler runs several backends at once: every configured backend is connected at startup and gets its own worker loop and concurrency limit.
enqueuetakesproviderand returns it;@kirak.scheduler.cron(..., provider=)fires a cron on a named backend. Dynamic (database-defined) schedules always run on the singledatabaseprovider. 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_agentnow 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 fromagents/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. Seedocs/modules/ai.md. agent(name).tooldecorator (kirak.ai): scopes a plain function to one named agent as aKirakTool, usable viakirak.agent(agent_name).tool. The JSON’srequires_confirmationandtimeouttake precedence over the decorator’s defaults.- Models directory support:
models_pathcan now point to a directory of*.jsonfiles; all files are merged into one config on startup with collision detection (ConfigurationErroron duplicate model names). - Full Redis integration (
kirak[redis]): SetKIRAK_REDIS_URLto 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.jsonmanifest (kirak/core/manifest.py): Optional project-level config file searched for next tomodels.jsonor in cwd. Supportsmodules,cors,rate_limit, andcustomkeys. Module list fromkirak.jsonis used as the enabled-module default whencreate_kirak_app()is called withoutmodules=. Accessible at runtime askirak.manifest. Full JSON Schema validation on load.- Per-model API rate limiting (
rate_limitkey 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 withRetry-Afterheader. Uses Redis sliding window whenKIRAK_REDIS_URLis set; falls back to DB fixed-window. - MCP server module (
kirak/mcp/): Introspectskirak.modelsto generate one MCP tool per model per operation (fetch,search,count,exists,create,update,upsert,delete,destroy). Tools require a JWTtokenparameter and respect the same RBAC as the REST API. Enable viainclude_mcp=Trueincreate_kirak_app(); MCP clients connect at/mcp/sse(SSE transport). Install withpip 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 optionalmodels_path; falls back to a built-inauth_models.jsoncoveringusersandauth_tokens. Wires up auth hooks viaon_auth_readycallback. 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_checkoutandupdate_merchant_feenow run throughdispatchlike the Auth, AI, Storage and Notifications operations. Seedocs/modules/payments.md. data["event"]onafter_webhook: the provider-neutral event name (payment_completed,refund_completed, …), taken from the provider’sEVENT_MAP;Nonefor an unmapped event or a repeated delivery.BaseModule.validate_startup(): a module can raiseConfigurationErrorat startup, outside the router-mount error handling.- Refunds are recorded on the
transactionsrow and deduped by the gateway’s own refund id. New nullable columnsrefunded_onandrefunded_meta({"refunds": [{"id", "amount", "at"}]});PaymentProvider._record_refund(transaction_id, refund_id, amount, ipn_dump)setsREFUNDEDand appends the refund in one update, returningFalse(writing nothing) when that refund id is already recorded. Adopted in Stripe, Razorpay and Square. Stripe/Stripe Connect checkout rows also now store the realpayment_intentintransaction_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) andSupportsPaymentMethods(interface only, no implementer yet) inkirak/payments/providers/capabilities.py. A provider opts in by also subclassing the mixin;PaymentProvider.capabilitiesintrospects 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 throughdispatchlike every other operation and raiseNOT_SUPPORTED(501) for a provider that has not opted in. Subscription-lifecycle/pause operations authorize by the subscription row’s ownuser_id(never a request-supplied one); disputes are admin/system only, same reasoning asrefund_payment. - Razorpay implements
SupportsSubscriptionLifecycle,SupportsPauseandSupportsDisputes: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 .../pauseand.../resume),get_dispute/submit_dispute_evidence(Razorpay calls the latter “contest a dispute”; the evidence dict is forwarded as-is, same as Stripe). Thesubscription.pausedandsubscription.resumedwebhook events are now handled (mapped toafter_subscription_updated, reusing the existingsubscription.updatedhandler) so a pause/resume API call’s effect onpayments_subscriptionsis kept in sync the same way Stripe relies on its own webhooks for this. - Square implements
SupportsSubscriptionLifecycle,SupportsPauseandSupportsDisputes:cancel_subscription(Square’s cancel endpoint always schedules cancellation for the end of the current billing period; there is no immediate-cancel variant, soat_period_endhas no effect),update_subscription(Square’sswap_planaction; Square has no per-subscription quantity concept, soquantityis ignored),pause_subscription/resume_subscription(Square’s own PAUSED status was already handled by the existingsubscription.updatedwebhook handler, so no webhook changes were needed),get_dispute/submit_dispute_evidence(Square’s dispute status field is calledstate, notstatus; evidence submission is a create-then-submit flow –params["evidence"]["evidence_text"]is uploaded viacreate_evidence_textand then finalized withsubmit_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 (includingbilling_portalandconnect, 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 optionalidempotency_keyin params short-circuits a repeat call with the same(user_id, idempotency_key)to the original transaction instead of reaching the provider again. idempotency_keyis now forwarded to Stripe’s own API onrefund_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_paymentdid not accept anidempotency_keyat all before this.idempotency_keyis now forwarded to Razorpay’srefund_paymentas theX-Refund-Idempotencyheader, 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 anidempotency_keyon 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 newwebhook_eventsmodel (event_key = "service:event_id", unique). Adopted in Stripe’shandle_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.closedmark the transactionDISPUTED/DISPUTE_WON/DISPUTE_LOSTand reportdispute_created/dispute_updatedthroughafter_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_atto now (300s tolerance); missingcreated_atis allowed through since freshness cannot be judged either way. user_id,customer_idandsubscription_plan_idsurfaced in the Stripe checkout webhook result:user_idwas 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):
SupportsPaymentMethodschanged shape. It now requiresdetach_payment_methodandset_default_payment_method(each receives{"method": <payment_methods row>});start_payment_method_setupandattach_payment_methodare optional and default toNOT_SUPPORTED(501);list_payment_methodsis gone (the payments module reads its ownpayment_methodstable). No built-in provider implemented the old interface. NewSupportsOffSessionChargemixin (charge_off_session), reported as theoff_sessioncapability. - Saved payment methods and off-session charges:
kirak.payments.setup_payment_method,attach_payment_method,list_payment_methods,detach_payment_method,set_default_payment_methodandcharge_off_session, routes under/payments/methods, new modelspayment_customersandpayment_methods(run your migrations),initiate_payment(save_payment_method=True, consent=...), and webhook eventspayment_method_saved,payment_method_removedandpayment_action_required. The gateway keeps the card; Kirak stores only its ids.charge_off_sessionis admin/system only and requiresidempotency_key. New nullable uniquetransactions.idempotency_scopecolumn ("<user_id>:<idempotency_key>", run your migrations) makes two concurrent charges with the same key charge once; a replay returns the originaltransaction_id,status,gateway_payment_idand, when present,decline_code/action_url.save_payment_method=TrueraisesNOT_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 returnsFAILED(decline_codeBAD_REQUEST_ERROR) instead of raising. Seedocs/modules/payments.md. - Stripe Connect off-session charges:
charge_off_sessionon astripe_connectinstance acceptsmerchant_user_idorstripe_account_idand charges the saved method as a destination charge carrying the merchant’s platform fee, same fee/merchant checks asconnect_checkout. New optionalKIRAK_PAYMENT_<INSTANCE>_PLATFORM_WEBHOOK_SECRET: Stripe delivers these events (and saved-payment-method andconnect_checkoutevents) from a separate platform-account webhook endpoint with its own signing secret;handle_webhooktrieswebhook_secretfirst, thenplatform_webhook_secret. Seedocs/modules/payments.md#stripe-connect. - Granular subscription/dispute capability mixins and a
subscriptionsflag:SupportsCancelSubscription(cancel_subscription) andSupportsUpdateSubscription(update_subscription) inkirak/payments/providers/capabilities.py, withSupportsSubscriptionLifecyclenow the combination of both (unchanged for Stripe, Razorpay and Square, which already subclass it directly and so implement both). LikewiseSupportsDisputeRead(get_dispute) andSupportsDisputeEvidence(submit_dispute_evidence), withSupportsDisputesnow their combination.operations/capabilities.pychecks the granular mixin per operation, andGET /payments/providersreports 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. NewPaymentProvider.SUPPORTS_SUBSCRIPTIONSclass flag (defaultTrue), reported as thesubscriptionscapability: a provider whose checkout cannot start a subscription sets itFalse, andinitiate_paymentthen raisesNOT_SUPPORTED(501) for"type": "subscription"instead of mis-starting one. Seedocs/modules/payments.md. public_urlandaclsettings forawsandwasabistorage instances.public_url(a CDN or custom domain) becomes the base of the URLs that uploads,get_urlwithoutexpiresandlistreturn, instead of the bucket’s own URL; presigned URLs still point at the bucket.acl(privateorpublic-read) is sent with every upload; unset, no ACL is sent, as before. Any otheraclvalue is aConfigurationError. Seedocs/modules/storage.md“Public URLs of S3-type instances”.- Storage providers
r2(Cloudflare R2),spaces(DigitalOcean Spaces) andcubbit(Cubbit DS3). All three are S3-compatible and come withkirak[storage]; unlikeaws, they need bothKIRAK_STORAGE_<INSTANCE>_ACCESS_KEYand..._SECRET_KEY.r2builds its endpoint fromaccount_idandjurisdiction(default,eu,fedramp) and returns public URLs only throughpublic_url, since R2’s S3 endpoint is never public;aclis refused (R2 has no object ACLs).spacestakes the datacenter inaws_region(e.g.fra1), uploads withaclpublic-readby default and returnshttps://<bucket>.<region>.digitaloceanspaces.comURLs (setpublic_urlfor the CDN).cubbitdefaults tohttps://s3.cubbit.euandeu-west-1.check()lists one object (and withwrite, writes and deletes one), naming the service in its messages;r2andspacesalso checkaccount_id/aws_regionoffline. Seedocs/modules/storage.md“Providers”. - Storage providers
ovh(OVHcloud Object Storage) andb2(Backblaze B2). S3-compatible, withkirak[storage]; both keys are required.aws_regionpicks the endpoint (https://s3.<region>.io.cloud.ovh.net,https://s3.<region>.backblazeb2.com) and is checked offline.ovhreturnshttps://<bucket>.s3.<region>.io.cloud.ovh.netURLs and acceptsacl;b2returnshttps://s3.<region>.backblazeb2.com/<bucket>/<path>URLs and refusesacl, since B2 sets ACLs per bucket and rejects a different one on a file. Seedocs/modules/storage.md“Providers”. - Storage provider
gcs(Google Cloud Storage), installed withpip install "kirak[storage-gcs]"(includeskirak[storage]). Credentials:KIRAK_STORAGE_<INSTANCE>_CREDENTIALS_JSON(a service account key) or, when unset, Application Default Credentials; with neither, building the instance is aConfigurationError. Settings:bucket,project_id,public_url, andsigning_service_account, which signsget_url(expires=...)URLs through IAMsignBlobwhen there is no key. Deleting a missing file succeeds, as on S3;listfollows every page.check()lists one object (withwrite, uploads and deletes one) and, under ADC withsigning_service_account, signs one URL: a refused signature is awarning(permission_denied);detail.signed_urlssays whether signed URLs will work. New helperkirak.core.provider_check.gcs_callfor custom providers, likeaws_call. Seedocs/modules/storage.md“Google Cloud Storage”. - Storage provider
azure(Azure Blob Storage), installed withpip install "kirak[storage-azure]"(includeskirak[storage]);kirak[all-storage]installs every storage SDK. Uses the async SDK. Credentials, first match wins:KIRAK_STORAGE_<INSTANCE>_CONNECTION_STRING;account_namewith..._ACCOUNT_KEY; oraccount_namealone 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;listfollows 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 (withwrite, uploads and deletes one) on a client of its own, and under an Azure identity also gets a user delegation key: a refusal is awarning(permission_denied); a malformedaccount_keyfails offline. New helperkirak.core.provider_check.azure_call. Seedocs/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 withcreate_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.
Changed
Section titled “Changed”- 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 inkirak db makemigrations, so a model of yours with the same name – ausersmodel inmodels/, which earlier versions let you use to extend the users table – is silently replaced by the built-in one, fields,id_typeandaccessrules included.kirak validatesaid such a model “replaces” the built-in one; the warning (model_name_reserved) now says it is ignored. Thecreate_auth_app()docstring (which requiredmodels_pathto defineusers),docs/getting-started/installation.md(“extend the built-in users model”),docs/authentication/standalone-auth.mdand the examples no longer suggest otherwise, anddocs/concepts/models.mdstates the rule. Migration: move fields you added tousersinto a model of your own linked byuser_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 indataand dropcount(the number of rows returned). Migration: read these values fromdata; use the length ofdatainstead ofcount. - Behaviour change: cron expressions are range-checked.
@kirak.scheduler.cron(...)andPOST/PUT /scheduler/schedulesaccepted 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 raiseValueError(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 showsnext_run_at: null. - Behaviour change:
before_enqueuechanges 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 orrun_atthe job did not have. Changes a hook makes topayload,queue,priority,run_atandmax_retriesnow apply to the job;taskandprovidercannot be changed. The hook data now also haspriorityandmax_retries, and the enqueue result has every field the job was stored with. Abefore_enqueuehook that returns something other than the envelope,{"data": {...}}orNonenow raisesTypeErrorbefore anything is queued. - Breaking:
auth_prefixreplaces the/authpath instead of being put in front of it. The auth router carried its own/authprefix andauth_prefixwas added before it, socreate_kirak_app(auth_prefix="/api/auth")served/api/auth/auth/login, andcreate_auth_app(), whose default was"/auth", served/auth/auth/loginout of the box.auth_prefixnow works likemodule_prefixesandadmin_prefix: it is the whole path of the auth routes, and the default (None) is/authfor both factories andKirak.mount_routers(). The links Kirak builds follow it: verification and password reset links, the default socialredirect_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 noauth_prefixtocreate_kirak_app()are unaffected. The Kirak client SDK calls the default/authroutes. Migration: an app that passedauth_prefix="/api"tocreate_kirak_app()(routes at/api/auth/*) passes"/api/auth"to keep the same URLs. Acreate_auth_app()service that relied on/auth/auth/*passesauth_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 onAUTH_UNAVAILABLE. - Breaking: requests are authenticated from the access token’s claims, not the
userstable. Every request with a Bearer token (or auth cookie) read the user’s fullusersrow, password hash included, and a hook or nestedkirak.*call in the request authenticated again (blacklist andusersqueries each time;/auth/metwice). The caller’suser_id,roleandemailnow come from the signed token, and a credential is checked once per HTTP request. A plain authenticatedfetchruns 3 SQL statements instead of 4;/auth/meruns 2 instead of 4 and still reads theusersrow 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 – setauth.access_token_expire_minuteslow (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 bykirak newdeclare>=3.10too. Migration: run Kirak on Python 3.10+; no code changes. - The
mcpanddev-mcpextras 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. Withmcp1.x still installed,kirak dev mcpnow says to upgrade instead of failing on an import. Migration:pip install -U "kirak[dev-mcp]"(orkirak[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 itsparamskeys in the catalog, with type, description, required, allowed values and defaults; keys only some provider types read list them inx-kirak-providers(e.g.reasononpayments.refund_paymentis read by Airwallex, Paddle, Square and Xendit). Each operation also says itseffect(read,writeordestructive, the strongest of any provider:verify_paymentiswritebecause PayPal captures during it), whether it istool_safe(callable with JSON arguments: webhooks, file uploads and browser redirect flows are not), and which provider types support it (providers,nullfor all).kirak modules <name> --json,kirak catalog --json,kirak.catalog.module(name)and the dev MCP’skirak_catalogreturn a module’soperationsas these objects instead of a list of names;catalog_formatis now2. Migration: code that readoperationsas names reads[op["name"] for op in module["operations"]]. Declared inkirak/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.jsonandGET /docsdescribe module request bodies. Module routes tookpayload: dict, soopenapi.jsonshowed 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), andopenapi.jsongives it that operation’s description,x-kirak-operation,x-kirak-effectand, 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./docslists 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/rundocstring no longer claims it takes inlinetoolsandmodel.kirak new’s samplepostsmodel is owner-only on every operation. Users read, search, count, change and delete only their own posts;createcarries theuser_id = {user_id}condition, so Kirak fills in the owner and a client can no longer create a post for someone else;destroyandrestoreare admin-only. The old sample let any user create a post with anyuser_idand read everyone’s posts. Existing projects keep their model;docs/getting-started/installation.mdshows the new one.examples/4_custom_routes.pyrewritten. It used raw SQL and custom routes that never set the caller (so every Kirak call in them ran as guest), on the internaluserstable without authentication. It now shows routes that run as their caller, reports built withkirak.graphql()aggregates, and an admin-only report;examples/models/transactions.jsongainssearchrules for the aggregates.- An AWS key pair set by half is a
ConfigurationError. A storageaws/wasabior notificationsaws_ses/aws_snsinstance with only one ofaccess_key/secret_key, or a vectors3_vectorsinstance with only one ofaccess_key_id/secret_access_key, now fails when the provider is built withConfigurationErrornaming the missing one, instead of botocore’sPartialCredentialsError. Set both or neither (neither uses boto3’s default credential chain, as before). - Shared helpers for payment providers:
PaymentProvidergains_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’sidempotency_keyand the transaction’s random reference),_create_renewal(a renewal row inserted under a uniqueidempotency_scope, so concurrent deliveries record one row),_report_verify_completion(reports averify_paymentcompletion from the next webhook, once),_json_numberand_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.pyadds the full ISO 4217 minor-unit table (ISO_EXPONENTS, ~165 codes) andexponent()/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_paramsnow callsexponent()after upper-casingcurrency; a well-formed but unrecognized 3-letter code (previously accepted and defaulted to 2 decimals downstream) is now rejected asINVALID_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_paymentwith anamounton such a row still fails withUNSUPPORTED_CURRENCY(400): rows created before the upgrade with a non-ISO currency can only be refunded in full (omitamount) or at the gateway. Seedocs/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 -> Stripeamount500); 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 throughPaymentProvider._to_gateway_amount()/_from_gateway_amount()(new helpers using each provider’sMAJOR_UNITS/CURRENCY_EXPONENTSclass attributes) instead ofint(round(float(amount))); amounts read back from a gateway (Stripe invoiceamount_paid/amount_due, refunds and subscription checkout totals, Square refunds, Razorpaysubscription.chargedand refunds) are converted with_from_gateway_amountbefore 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’sapplication_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 theconnect_checkoutresponse, itsipn_dumpand its Stripe metadata all report the fee in Kirak’s minor units, not Stripe’s. refund_payment’s partial-refundamountnow converts using the original transaction’s currency whencurrencyis 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 thetransactionsrow whosetransaction_idmatchespayment_id, preferring a row of the resolved provider instance; with no matching row, the old default-currency behaviour is unchanged. Seedocs/modules/payments.md“refund_payment”.- Breaking: embeddings moved from the AI module to the new Vector module.
kirak.ai.embed()andPOST /ai/embedare removed, and so is theai.embedding_modelkey inkirak.json(akirak.jsonthat 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 undervector.embedding(typesopenai,google,ollama; each needsmodel), put its key inKIRAK_VECTOR_EMBEDDING_<INSTANCE>_API_KEYinstead ofOPENAI_API_KEY/GOOGLE_API_KEY, addvectortomodules, and replacekirak.ai.embed({"text": ...})withkirak.vector.embed({"text": ...}). There is no replacement HTTP endpoint forPOST /ai/embed: the Vector module exposes none, so an app that needs one adds its own route that callskirak.vector.embed. The response keepsdata.embeddinganddata.dimensions;data.modelanddata.usageare gone, and the per-callmodelparameter is replaced byprovider(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 (seedocs/modules/vector.md). Seedocs/modules/vector.md. - Startup reports every problem in
models.json,kirak.jsonand agent files, not just the first. The messages are unchanged, one per line. kirak dbcommands run in the project root. They look forkirak.jsonfrom 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 straymigrations/there.jsonschema4.18 or later is required (was 4.17), for the local resolution of references between the packaged schemas.- Cleanup: built-in payment providers’
EVENT_MAPvalues no longer carry theafter_prefix. Stripe, Stripe Connect, Razorpay and Square now write"payment_completed"instead of"after_payment_completed", matching the neutral nameafter_webhookalready reported (neutral_event()stripped the prefix either way, so this has no effect ondata["event"]or any other runtime behavior). Custom providers may still write either form. POST /auth/request-reset-passwordalways answers with the neutral message:If your email is registered, you will receive reset instructionsis 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_ERRORorINTERNAL_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, plusclient_keyfor TikTok andteam_idandkey_idfor Apple),auth.forgot_password_page_urlandauth.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_PASSWORDstays an environment variable);KIRAK_BASE_URL,KIRAK_AUTH_HOSTandKIRAK_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_ORIGINSandcors.origins_env->cors.origins(akirak.jsonwithorigins_envfails to load);LOGO,SUPPORT_EMAIL,WEBSITE_URL,LOGIN_URLand the four colour variables ->branding.*(branding.login_urlnow 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_URLandKIRAK_AUTH_APPLE_WEB_AUTH_CALLBACK_URL->auth.forgot_password_page_urlandauth.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>_KEYand_REDIRECT_URIare no longer read for other social-core providers (useauth.social.<provider>); theirSOCIAL_AUTH_<BACKEND>_SECRETstill is.CorsManifest.resolve_origins(),CorsManifest.origins_envandkirak.auth.templates.template_settingsare removed.get_database_config()andconnect_to_db()take the manifest’sDatabaseManifest. A retired variable that is still set is not read; startup logs a warning that names thekirak.jsonkey that replaces it. Seedocs/reference/configuration.md. - Breaking: provider configuration in
kirak.jsonmoved toproviders. Removed keys, all replaced bydefault_providerandproviders: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"}). Akirak.jsonthat 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 ownKIRAK_PAYMENT_STRIPE_CONNECT_SECRET_KEY. Storage:KIRAK_STORAGE_<INSTANCE>_ACCESS_KEY/_SECRET_KEY. Notifications:KIRAK_NOTIFICATION_<CHANNEL>_<INSTANCE>_*replaces the sharedKIRAK_NOTIFICATION_AWS_*,_SENDGRID_API_KEY,_SMTP_USERNAMEand_SMTP_PASSWORD. Scheduler:KIRAK_SCHEDULER_<INSTANCE>_URL. The fallbacksKIRAK_PAYMENT_DEFAULT_SERVICE,KIRAK_PAYMENT_SUCCESS_URL,KIRAK_PAYMENT_CANCEL_URL,KIRAK_PAYMENT_CONNECT_REFRESH_URLandKIRAK_PAYMENT_CONNECT_RETURN_URLare removed. - Breaking: payments: the
payment_servicepayload key is removed; useprovider(an instance name, which must be listed inkirak.json). Thetransactionsandsubscriptionspayment_servicecolumn now stores the instance name.Payments._WEBHOOK_HOOK_MAPandPayments._payment_providersare removed; each provider declares its ownEVENT_MAPandWEBHOOK_SIGNATURE_HEADER, and custom providers are registered withregister_provider.PaymentConfig,create_payment_providerand thePaymentServiceenum 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 includeprovider.create_storage_providerandStorageConfigare 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 subclassEmailProvider,SMSProviderandPushProviderand take(config, notifications). The three provider factories andNotificationConfigare removed. - Breaking: scheduler errors are
KirakExceptions:SCHEDULER_NOT_STARTED(503, wasRuntimeError),TASK_NOT_REGISTERED(400, wasLookupError),INVALID_PAYLOAD(400, wasValueErrorfor secret-looking payload keys) andQUEUE_BACKEND_ERROR(503).QueueBackendsubclasses take(config, scheduler);create_queue_backendandSchedulerConfigare removed. Job introspection and dynamic schedules require a provider of typedatabase. - 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 defaultdescription=parameter changed from"API built with Kirak Framework"to"API built with Kirak Runtime"– overridedescription=explicitly if your app depended on the old default string. - Breaking: unified log file: every module now writes to one shared
{LOG_PATH}/kirak.logfile 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 atkirak.loginstead. - Cookie
max_agein auth router now derived fromKIRAK_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_destroynow readsKIRAK_BULK_CHUNK_SIZEfrom the environment (was using a hardcoded 500 default).- Breaking: payments
require_authnow defaults totrue: 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’suser_id(or the caller holdsadmin/system). Set"payments": {"require_auth": false}inkirak.jsonto restore the old open-by-default behavior. Seedocs/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_amountby 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 indocs/modules/payments.md#money--amounts. subscriptions.product_idrenamed toplan_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 topayments_subscriptions(previously onlypayments_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_updatedandafter_merchant_deauthorizedno longer fire, and registering one fails at startup. Registerafter_webhookand checkresult["data"]["event"](payment_completed, …). A provider’sEVENT_MAPvalues may keep theafter_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_authon, the caller must ownuser_idor 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 (typicallysystem), the same convention Auth and CRUD use.refund_paymentandupdate_merchant_feeare admin/system only. 401 and 403 now use the canonical error envelope.get_router()no longer takesrequire_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_refundedused to collapse every refund intoREFUNDEDregardless of amount. It now compares the cumulativeamount_refundedto the charge’s ownamountand reportsPARTIALLY_REFUNDEDfor a refund that covers less than the full charge.PARTIALLY_REFUNDEDwas also added toSETTLED_STATUSES, so a delayed or duplicate completion event cannot clobber a partially-refunded transaction back towardCOMPLETED. - Breaking: removed the dead
PaymentsManifest.currencyfield (kirak.json->payments.currency). Declared and schema-validated but read by nothing – every provider already hardcodes its own default currency. Akirak.jsonthat still sets it now fails validation; delete the key. connect_checkoutnow validates its params before calling the provider, the samevalidate_payment_paramscheckinitiate_paymentalready runs. A missingamount/name/user_idused to reachStripeConnectProvider’s unguarded int/float conversion and surface as a rawKeyError-> opaque 500 instead of a clean 400.- Stripe Checkout is no longer card-only.
initiate_paymentstopped sendingpayment_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 callinginitiate_paymentwith"type": "subscription"against astripe_connectinstance now raisesNOT_SUPPORTED(501) instead of silently creating a one-time payment-mode session; thesubscriptionscapability is absent forstripe_connectinGET /payments/providers.connect_checkout(POST /payments/connect/checkout) enforces the same flag: it used to call the provider’sinitiate_paymentdirectly with no check, so a subscription-type Connect checkout silently created a one-time payment-mode session while storingtransaction_type"subscription"on the row. - New neutral event
subscription_past_due. Stripecustomer.subscription.updatedwith statuspast_dueorunpaid, and Razorpaysubscription.pending/subscription.halted, now reportdata["event"] = "subscription_past_due"instead ofsubscription_updated, so the event means the same thing on every provider. Other statuses are unchanged. If yourafter_webhookhook checksresult["data"]["event"] == "subscription_updated"for these cases, check forsubscription_past_dueinstead.refund_pendingis also a new neutral event name (PayPal and Paddle report it). Stripecustomer.subscription.updatedwith statuspast_dueorunpaidnow also carriesevent_typecustomer.subscription.updated.past_dueinresult["data"](it is how the event maps tosubscription_past_due): anafter_webhookhook that matches the rawevent_type == "customer.subscription.updated"misses these deliveries and must also match the new name. Seedocs/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 withset_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:systemis not a superuser – it needs a{"role": "system"}rule in the model’saccessblock for each operation, and bypasses only field-level security; while the hooks of aset_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” indocs/guides/crud-operations.md;docs/reference/middleware.md,docs/concepts/access-control.mdanddocs/concepts/hooks.mdcorrected. The “Raw SQL Query in a Hook” pattern is replaced by a GraphQL aggregate (raw SQL skips access rules);docs/guides/graphql.mdnow says aggregates are checked againstsearchrules and names the result keys. No code change. - S3 and Wasabi storage blocked the event loop, and
liststopped 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.listreturned only the first page ofListObjectsV2(at most 1000 files) and now follows every page. A Wasabi credential check that fails now names Wasabi, not S3, in its message. Anaws_regionthat is not a region name (e.g. with a space) is aConfigurationErrornaming it, instead of botocore’sInvalidRegionError. search_termdid nothing on search. The search text was read only fromsearchorq, while/docs,openapi.json, the guides (GET /posts/search?search_term=...,kirak.search("products", {"search_term": ...})) and themcpmodule’s search tool all sendsearch_term; it was taken as a filter on a column of that name instead.search_termis now read first, thensearch, thenq, 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
accessblock allows every operation without a credential. It is the opposite: such a model denies every operation for every role (startup logs a warning;strict_accessmakes 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 issearch_term. POST /graphqlreached models markedinternal: true. The REST routes returnNOT_FOUNDfor an internal model, but GraphQL root fields served every internal model its access rules allowed, e.g. a signed-in user’s ownusersrow andpayment_methods, ordestroyTransactionsfor 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
createBlogPostwas turned intoblog_post, so a model namedblogPost(orPosts) 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.currencywas documented but is rejected bykirak.json(the currency is passed with each call);KIRAK_AUTH_BCRYPT_ROUNDSandKIRAK_AUTH_PASSWORD_COMPLEXITYwere documented as environment variables but areauth.bcrypt_roundsandauth.password_complexityinkirak.json. The tables are now generated. - Wrong descriptions of monitoring keys in the
kirak.jsonschema (shown bykirak modules monitoringand now in the docs).monitoring.enabled,log_capture_enabledandlog_user_idwere described as switches but are not read;sample_rateandslow_request_threshold_mswere described as sampling requests, but they sample per-operation timings (every request is recorded). - A project created by
kirak newdid not start. The scaffold wrote its sample model tomodels/models.json, which is skipped inside amodels/directory (startup failed with “No *.json files found in models directory”), and itskirak.jsonhad apayments.currencykey the schema rejects. The sample model is nowmodels/posts.jsonand the key is gone. Projects created before: move each model inmodels/models.jsonto its ownmodels/<name>.json(without the{"<name>": ...}wrapper) and removepayments.currency. instagramsocial login used social-core’s backend instead of Kirak’s. Kirak ships its own Instagram backend (Meta’s Instagram Login, with the synthesizedinstagram_<id>@social.kirakemail described indocs/authentication/social-auth.md), but the generated provider catalog mappedinstagramto social-core’s backend, which uses the retired Basic Display API. Kirak’s own backends inkirak/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.pywas 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_catalogis removed;kirak.auth.social.catalog(_CATALOG) is removed: usekirak.auth.social.discovery.social_backends()orkirak.catalog. Enablingsaml,shopifyorgoogle-onetapwithout the packages their social-core extra adds now fails at startup with thepip install "social-auth-core[...]"command (google-onetapused to fail only at login). - A pip-installed
kirakwas missingai/ai_models.jsonand the auth page templates.[tool.setuptools.package-data]did not list them, so a non-editable install (a Docker image, a wheel) had noai_conversationsmodel 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 underkirak/is in it. - The example projects’ model files did not load.
examples/01_hello_api,02_blog,03_hooksandexamples/modelsstill used the old{"<model>": {...}}wrapper inside each file (and02_blog/models/posts.jsonheld two models), which the per-file models directory reads as a model named after the file with notable. Each file now holds one model, named after the file;02_bloghasposts.jsonandcomments.json. - A plain
pip install kirakcould not start an app:jinja2was missing. The auth module, which every app loads, renders its verify-email and reset-password pages withJinja2Templates, butjinja2was 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
parametersschema passed omitted optional arguments asnull. The operation received e.g.top_k=Noneinstead of notop_k, soVector.searchrejected the call (INVALID_PARAMS) instead of applying its default. Arguments the model leaves out, or sends asnull, 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_resulttrace entry, and so everyai_tool_callsmonitoring record, hadduration_msofnull: 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 nocurrent_userkwarg, butresolve_native_toolwas callingop_fn(params=kwargs, current_user=current_user), causing aTypeErroron dispatch for any native tool ref other thanVector.*. The wrapper now sets the user context var and callsop_fn(params=kwargs)without passingcurrent_useras 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_toolnow setswrapped.__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 receivedOpenAICompaction._build_compactiontried both imports unconditionally and returned whichever succeeded first, so a Gemini or OpenAI agent always gotAnthropicCompactionif the Anthropic SDK was installed. It now takesmodel: strand branches on the provider prefix:anthropic:usesAnthropicCompaction,openai:/openai-responses:usesOpenAICompaction, and all other providers useTieredCompactionfrompydantic-ai-harness(clearing old tool results, then sliding the message window) with anImportErrorfallback 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 openadditionalProperties: trueschema and had to infer arguments from the docstring alone.ToolRefandKirakToolnow carry the declared schema, and_resolve_toolswraps 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-harnessTieredCompaction(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 viaai.compaction_token_thresholdinkirak.json(default: 150,000);nulldisables compaction. The same threshold is now forwarded toAnthropicCompactionandOpenAICompaction. - Agent function tools fail as guests when run outside an HTTP request. Python function tools registered with
@kirak.agent("name").toolthat 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_toolsnow wraps everyKirakToolfunction with a context-restoring shell (_wrap_with_user_context) so the caller’scurrent_useris set for the tool’s entire execution. Native tools (fromresolve_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_datafor 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’scustom_idis now<row id>:<kirak_ref>and Paddle’scustom_dataaddskirak_ref, a random reference kept in the row’spayment_metathat the fallback requires. Objects made before this change still match rows made before it. The docs also note that PayPal’sPayPal-Request-Iddepends on the user id and key only, so keys should be unique across databases. - A gateway object without a
kirak_refstill 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 nokirak_refeither (Stripe off-session charges, Razorpay payments, Square off-session charges). - Stripe, Stripe Connect, Square and Razorpay sent the caller’s
idempotency_keyto 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 randomkirak_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_paymentandcancel_subscription/update_subscription/pause_subscription/resume_subscriptionfound 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 thatupdate_subscriptionaccepts any plan id and how to restrict it with abefore_update_subscriptionhook. - Payment amounts:
0and booleans were accepted. A one-time payment orcharge_off_sessionforamount: 0reached the gateway (which refuses it) and a booleantruewas read as 1. Both are nowINVALID_PARAMS(400); a subscription may still passamount: 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_sessionandinitiate_paymentlooked up earlier calls by user andidempotency_keyonly, 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_idwaskirak-<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. Thereference_idis nowkirak-<row id>-<16 random hex>, with the random part kept in the row’spayment_meta(kirak_ref), and must match. Akirak-<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.capturedandpayment.failedfound the row by the numericdb_transaction_idin 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’spayment_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’spayment_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_paymentnow 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
PENDINGfor 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_paymentnever reportedpayment_completed: the later sale advice found the row settled and reported nothing, soafter_webhooknever 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_paymentnever reportedpayment_completed: the latercharge.completefound the row settled and reported nothing. The firstcharge.completefor the completing charge now reports it once. - Flutterwave:
charge_off_sessionreportedCOMPLETEDfor a charge not verified yet: asuccessfulanswer left the rowPROCESSING(completed later by the re-verifiedcharge.completed) but returnedCOMPLETED. It now returns the row’s own status,PROCESSINGuntil the webhook completes it; fulfil onpayment_completed. - Paystack:
charge_off_sessionreportedCOMPLETEDfor a charge not verified yet: asuccessanswer left the rowPROCESSING(completed later by the re-verifiedcharge.success) but returnedCOMPLETED. It now returns the row’s own status,PROCESSINGuntil the webhook completes it; fulfil onpayment_completed. Two concurrent deliveries of one renewalinvoice.updatecould save two renewal rows; the row is now inserted with a uniqueidempotency_scope, so the second reportsalready_processed. Removing a card no longer reads the subscriptions’ first charges one by one. - Airwallex: a retried
refund_paymentcould refund twice: therequest_idwas always random; it is now derived from the caller’sidempotency_keyand the transaction’s random reference (random without a key). - Xendit: a retried
refund_paymentcould refund twice: theIdempotency-keywas always random; it is now derived from the caller’sidempotency_keyand 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 itspaymentnotification was matched to the subscription’s first row and dropped; it is now matched to its ownsubscription_renewalrow first. A retriedrefund_paymentcould refund twice: theX-Idempotency-Keywas always random; it is now derived from the caller’sidempotency_keyand 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 uniqueidempotency_scope, so the second reportsalready_processed.update_subscriptionnow stores the newplan_idon the subscription. - Square: a payment link ignored
quantity: it was aquick_paylink for the unit price, so the buyer paid for one unit while the row recordedamountxquantity(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’sreference_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’sreference_idwas 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 signedcreated_at; the window is gone and deliveries are deduplicated on the event’sevent_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.createdcarries 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 apayment_idis now skipped instead of matching rows with notransaction_id). Razorpay retries were rejected: a 5-minute window on the signedcreated_atrefused Razorpay’s own retries of the original payload (sent for up to 24 hours), leaving paymentsPENDING; the window is gone, and deliveries are deduplicated on thex-razorpay-event-idheader instead. - Stripe Connect: a partial refund marked the whole payment
REFUNDED; it is nowPARTIALLY_REFUNDEDuntil Stripe’s cumulativeamount_refundedreaches the charge, as for Stripe. A disconnected merchant stayed active:account.application.deauthorizedread the account id fromdata.object(the Application,ca_...), so no merchant row matched; it now uses the event’saccount, and the merchant is markedDEAUTHORIZEDwith 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_succeededlooked it up by payment intent; an invoice withbilling_reasonsubscription_create, or already recorded under its invoice id, is now a repeat. Subscription expiry no longer depends onSubscription.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 underparent.subscription_detailson that API. Astripeand astripe_connectinstance on one account no longer report the other instance’s checkout aspayment_completed(the result is markedalready_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_methodfails withPAYMENT_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_methodnow raisesMethodOwnedByAnotherUser(aKirakException, 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. razorpayfailed to import with setuptools 81 or later: razorpay 1.x (latest 1.4.2) importspkg_resources, which setuptools 81 removed, so an environment with a newer setuptools broke it.payments-razorpay,all-paymentsandallnow pinsetuptools>=68,<81.- Added unique columns were not unique:
kirak db makemigrationswroteALTER TABLE ... ADD COLUMNfor a new field and dropped its"unique": true, so a database upgraded by migration had no unique constraint while a freshCREATE TABLEdid. An added unique column now also getsCREATE UNIQUE INDEX uq_<table>_<column>on MySQL and PostgreSQL. Migrations generated before this fix lack the index; add it by hand. - Breaking:
KIRAK_HOOK_TIMEOUTandLOG_PATHare no longer read: Kirak copiedhook_timeout_secondsandlog_pathfromkirak.jsoninto these two environment variables withsetdefaultand read them back, so a variable set in the environment silently overrodekirak.json.kirak.jsonis now the only source. If you set either variable, move the value intokirak.json("hook_timeout_seconds": 15,"log_path": "/var/log/myapp"); without alog_paththe log goes tologsunder 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_multiexample indocs/modules/notifications.mdand 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
successflag in notification and webhook results: the response ofsend_email,send_smsandsend_push, and the result of every payment provider’shandle_webhook, used to carry the provider’s"success": trueinsidedatanext to the envelope’s ownstatus. A returned result already means the send or the event worked (failures raise), so the flag is dropped:datais now{"id": ...}instead of{"success": true, "id": ...}. A notification provider that returns{"success": false}anyway now raisesPROVIDER_SEND_FAILED(500) with the provider’serrortext instead of producing a success response. Stripe, Stripe Connect and Razorpay already worked this way; Square now does too. Custom providers can leavesuccessout of what they return; if they include it, it is removed. Readresponse["status"](or catchKirakException) instead ofresponse["data"]["success"]. - The database scheduler provider’s
tablesetting broke the module: the queue backend used the configured table for enqueue, claiming, acks and retries, while the jobs routes,/scheduler/schedulesand database-defined cron always used the fixedscheduler_jobsmodel. 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 alwaysscheduler_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 newscaffold, 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_CONFIGUREDand thethumbnails=Truevalidation error namedKIRAK_NOTIFICATION_EMAIL_FROM_ADDRESS,KIRAK_NOTIFICATION_ATTACHMENT_BASE_PATHandKIRAK_STORAGE_DEFAULT_THUMBNAILS. They now name thekirak.jsonsettings that are read:notifications.email_from_address,notifications.attachment_base_pathandstorage.default_thumbnails. - Razorpay silently undercharged on subscription
quantity> 1:_create_subscriptionhardcoded"quantity": 1in 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’squantity, matching_create_payment_link’s existing pattern. - Stripe subscription-renewal transactions used lowercase
payment_status:_on_invoice_succeeded/_on_invoice_failedwrote"paid"/"failed"instead of the module’s uppercase convention (COMPLETED/FAILED), so every Stripe subscription-renewal transaction was invisible to any uppercase status filter, includingSETTLED_STATUSESand 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
quantitywas never stored:_activate_subscriptionreceived it as a parameter and never used it, so every Stripe subscription’squantitycolumn 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_retriesandretry_backoffhad no effect) and no record. A failed job is now republished with its attempt count and retried afterretry_backoff; a job that has used up its attempts is published to<queue>.failedwith 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_atwas 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 futurerun_atgoes there directly. - Restarting one app instance could run another instance’s jobs a second time: at startup every job in
runningwas put back in the queue, including jobs other instances were still executing. Recovery now resets only jobs that have been running longer thanjob_timeoutplus 60 seconds, on the database and Redis backends (RabbitMQ was never affected), at startup and then every minute. Redis jobs now record astarted_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_timeoutand retries it. Jobs that legitimately run longer than one hour must raisejob_timeout. After a crash, orphaned jobs are recovered after aboutjob_timeoutinstead 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.queuesinkirak.jsonnow lists the extra queues to consume; the worker runs one loop per queue on every provider (RabbitMQ consumes each queue).default_queueis always consumed. A provider’sconcurrencylimit 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 inscheduler.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; andafter_refund_completedwas tied torefund.created, when a refund is normally stillPENDING. Refund events are now unwrapped and handled for bothrefund.createdandrefund.updated. Only aCOMPLETEDrefund marks the transactionREFUNDEDand runsafter_refund_completed; aPENDING,REJECTEDorFAILEDrefund changes nothing and runs no refund hook. The webhook result’sevent_typeisrefund.completedin that case, with Square’s own name insquare_event_type. Addrefund.updatedto your Square webhook subscription, or refunds that settle later are never recorded. - Renewal hooks ran again on a repeated delivery: Stripe
invoice.payment_succeededand Razorpaysubscription.chargedalready noticed a repeat but still firedafter_subscription_renewed. A repeat now reportsalready_processedand 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 samepayment_intent, so the retry was skipped and the subscription stayedPAYMENT_FAILED. - Stripe ran
after_payment_completedfor payments that had not been paid: a checkout session paid by a delayed method (bank debit) is delivered ascheckout.session.completedwithpayment_statusunpaid, and it ran the completed hook. It now recordsPENDINGand fires no hook. Stripe and Stripe Connect now also handlecheckout.session.async_payment_succeeded(runsafter_payment_completed, once) andcheckout.session.async_payment_failed(recordsFAILED, runsafter_payment_failed). Add these two events to your Stripe webhook endpoints, or delayed payments stayPENDING. - A completed payment reported twice ran
after_payment_completedtwice: 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 sendspayment.updatedon every change) fired the hook again. The transaction is now markedCOMPLETEDwith one conditional update that skips transactions alreadyCOMPLETEDorREFUNDED; a repeat reportsalready_processedandPayments.handle_webhookskips that event’s hook (after_webhookstill 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.createdororder.updatedmoved aCOMPLETEDtransaction back toPROCESSINGorPENDING. Neither can overwrite aCOMPLETEDorREFUNDEDtransaction now, andorder.updatedno longer writesCOMPLETEDitself, because only the payment event may complete a transaction. - Square fired
after_payment_completedfor payments that were not completed:payment.created,payment.updatedandorder.updatedall 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:COMPLETEDfiresafter_payment_completed,FAILEDfiresafter_payment_failed, other statuses fire no payment hook, andorder.updatedfires none. The webhook result’sevent_typeis nowpayment.completedorpayment.failedfor those two, with Square’s own name in the newsquare_event_typefield. - Several Stripe instances in one app used the wrong key:
StripeProviderandStripeConnectProviderset the SDK’s process-globalstripe.api_keyin 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 passesapi_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_urlandlisthidKirakExceptions: every error, including a client error, was converted to a generic 500STORAGE_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 handledKirakException(register’sverification_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.jsonfailed 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 includesid. Previously the reserved column was omitted from the read-field list, sofetchnever selected it, andupdate/delete/restorerejectedwhere: {"id": ...}with “No valid WHERE conditions provided”. RLS conditions such asid = {user_id}depend onidbeing queryable. - Refresh token hash collision:
generate_refresh_tokennow includes ajticlaim. Without it, two refresh tokens minted for the same user in the same second were byte-identical, producing a duplicate-key crash onauth_tokens.token_hashduring rotation (e.g. login immediately followed by/auth/refresh-token). /auth/me500 on serialized datetimes:_get_current_usernow normalisescreated_at/last_login_atthroughmake_timezone_aware()before calling.isoformat(). Kirak’s response serializer already renders datetimes as ISO strings, so the direct.isoformat()call raisedAttributeErroronce the column was populated.- GraphQL
FileNotFoundError:KirakGraphQLTranslatorno longer readsmodels.jsonfrom disk on every request; models are passed in-memory fromkirak.models. - GraphQL PostgreSQL placeholders:
_execute_simple_queryandSQLJoinBuilder.build_querynow usedialect.placeholder(index)instead of hardcoded%s. _bulk_deletePostgreSQL placeholder:deleted_at = %sslot replaced withget_placeholder(0)for dialect-correct query building.- Bulk delete/destroy transactions:
_bulk_deleteand_bulk_destroynow wrap all chunk iterations in a singleasync 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.pynow 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 otherKirakExceptionsubclasses are now caught by a registered@app.exception_handler(KirakException)in bothcreate_app()andcreate_kirak_app(), returning correct HTTP status codes and sanitized JSON instead of bare 500s.- GraphQL RLS bypass:
_has_model_accessnow delegates toAccessPolicyEngine.check_and_get_rls(), covering both the legacyauthblock and the newaccessblock 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}")inrestore.pywhich could log user PII at DEBUG level. - Pydantic V2 deprecation:
ErrorResponseinerror_response.pymigrated from deprecatedclass Configtomodel_config = ConfigDict(...). - Legacy social auth files removed:
kirak/auth/services/google.py,github.py, andapple.pydeleted – all traffic now routes through thesocial-corebackend introduced in sec.2.5. - Registration blocked by exists probe:
_register’s duplicate-email pre-check callskirak.exists("users", ...)as thesystemrole, but the built-inauth_models.jsonusersmodel had noexistsaccess rule. Since read operations now raisePermissionDenied(aKirakException) instead ofHTTPException, that denial propagated through_registerand aborted every signup with “Role system not authorized to check existence of users”. Added"exists": [{"role": "system"}]to theusersaccess block, and_registernow only re-raises the intentionalEMAIL_ALREADY_EXISTSfrom the probe – any other failure falls through to creation where the unique constraint still catches real duplicates. existsresponse key:/{model}/existsnow returnsdata: {"exists": <bool>}as documented; the implementation was returningdata: {"record_exists": <bool>}, so_register’s duplicate check (which readsdata.exists) never detected an existing email.- PostgreSQL: writing or filtering by a timezone-aware datetime failed.
asyncpgrefuses a timezone-aware value for aTIMESTAMPcolumn, which is what Kirak creates forcreated_atand everydatetimefield, so any create, update, fetch or delete given one (for exampledatetime.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 aTIMESTAMPcodec that converts a timezone-aware value to UTC and otherwise behaves asasyncpg’s own (naive values, dates and infinity unchanged).TIMESTAMPTZcolumns are not affected.
Security
Section titled “Security”- Breaking:
GET /payments/providersneeds a signed-in caller whenpayments.require_authis on (the default). It listed every provider instance’s name, type and capabilities to anyone. It now runs as thelist_providersoperation (withbefore_list_providers/after_list_providershooks) 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_secretwas 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 forgedcheckout.session.completedcould mark any transaction paid and runafter_webhookfulfilment. WithoutKIRAK_PAYMENT_<INSTANCE>_WEBHOOK_SECRETevery Stripe webhook now fails withWEBHOOK_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_blacklisttable) prevents reuse after logout. - Rate limiting on auth endpoints via
kirak_rate_limitstable. - Hook execution timeout enforced via
KIRAK_HOOK_TIMEOUT. - Social login via
social-auth-corewith 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.
- Bcrypt password hashing at rounds controlled by
[0.1.1] - 2026-01-21
Section titled “[0.1.1] - 2026-01-21”- Database Agnostic Architecture: Full support for both MySQL and PostgreSQL
- Switch databases via
DB_TYPEenvironment variable (mysqlorpostgres) - Unified
DatabaseDriverandDialectabstraction layer - MySQL driver using
asyncmy, PostgreSQL driver usingasyncpg
- Switch databases via
- Dialect System for portable custom SQL queries:
dialect.placeholder(index)- Returns%s(MySQL) or$1, $2, $3(PostgreSQL)dialect.placeholders_str(count)- Generates multiple placeholdersdialect.ilike_condition()- Portable case-insensitive searchdialect.supports_returning- Check RETURNING clause supportdialect.upsert_query()- Database-specific UPSERT syntax
- New example
7_database_agnostic.pywith portable query patterns
Changed
Section titled “Changed”- Updated
examples/README.mdwith database agnostic documentation - Updated
examples/.env.examplewith database switching instructions
[0.1.0] - 2026-01-13
Section titled “[0.1.0] - 2026-01-13”- 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
Architecture Changes
Section titled “Architecture Changes”- 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
Kirakprovides unified access to all modules