Module System¶
This document is the canonical reference for the Mozaiks module system — what a module is, what files it owns, how it integrates at runtime, and how it relates to capability packs.
What a Module Is¶
A module is a self-contained unit of deterministic business logic declared in an app workspace's modules/ directory. Modules provide:
- an action surface that AI agents call through platform module routes or
AppBackendPort - domain event emission after state changes
- optional notification and reaction rules
- optional admin panels mounted inside the unified
/adminshell
Modules are not AI workflows. They run without AI. Workflows call modules; modules do not contain orchestration or reasoning logic.
Workflow tool dispatch¶
Tools running in a live authenticated workflow may call mozaiksai.core.workflow.module_tools.dispatch_workflow_module_action(module, action, params). The helper obtains workflow/app/chat/actor identity from the active AG2 tool invocation and the principal from that run's server-owned WebSocket connection. Host scope hooks resolve membership before the existing module executor enforces action permissions and entitlements. Identity, permissions, and bearer tokens are never taken from model arguments. During a workflow handoff it allows up to two seconds for a missing or replaced socket to reconnect, resolving the live principal and permissions again on every attempt. The actor remains bound to the original immutable runtime identity. Expired or mismatched principals, permission denials, and revoked invocations fail closed. An action is never retried after execution starts. Headless runs without a live principal must use their own explicit server-owned dispatch contract; this helper does not grant a background bypass.
The Factory SecurityReadiness workflow uses this path to record findings in the first-party security_readiness module. app_id retains the runtime host scope; build_registry_id identifies the reviewed project, while build_id and artifact_version_id retain assessment lineage. List and summary actions accept an optional build_registry_id filter applied before the result limit. Source-local scanner keys become opaque persisted ids scoped to owner, app, project, and artifact. The workflow converts scanner evidence paths and recommendations into the module's evidence_ref and remediation fields. Missing persistence stays visible in the advisory review, and zero inspected files yields not_assessed. An empty findings collection does not prove a saved passing assessment; this module stores findings. The rule it applies to permissionless actions is the decision table in Generated Action Protection.
Canonical Module Shape¶
app/modules/{module_id}/
├── module.yaml # Required: identity, actions, capabilities
├── contracts/ # Optional companion manifests — include only what the module needs
│ ├── events.yaml # Domain events this module may publish
│ ├── reactions.yaml # Event reactions owned by this module
│ ├── notifications.yaml # Notification rules per event
│ ├── settings.yaml # User/app settings schema
│ ├── admin.yaml # Admin panels mounted into /admin/*
│ ├── profile.yaml # User profile page panels (optional)
│ ├── relationships.yaml # Current-user resource relationship providers
│ ├── service.yaml # Optional module service boundary
│ └── commercial.yaml # Optional module commercial metadata/policy
├── runtime_extensions.yaml # Optional: api_router / startup_service host hooks
└── backend/
├── __init__.py
├── handler.py # Required: thin dispatch, one method per declared action
├── service.py # Recommended: all business logic and event emission
├── repo.py # Recommended: MongoDB access layer, no logic
├── policy.py # Recommended: optional ownership preflight
├── schemas.py # Recommended: typed request/response + document shapes
├── {helper_files}.py # Optional: declared, justified, module-local support files
├── settings.py # Optional: settings hooks
└── admin.py # Optional: admin panel data hooks
### Backend file responsibilities
The backend layer is intentionally split into small, canonical files:
handler.pyis thin dispatch only. It exposes one method per declared action and delegates immediately.service.pyowns business logic, validation, and event emission after state is committed.repo.pyowns persistence access only. It does not validate, branch on product policy, or emit events.policy.pyprovides optional ownership preflight from the immutable persistence principal. Runtime persistence enforces declared collection tenancy; service.py owns domain authorization and transition checks.schemas.pyowns typed request/response objects, enums, and normalization helpers.
Helper files are allowed, but only as explicit module-local extensions of those canonical layers. They must be declared before generation, justified by a specific purpose, imported by a canonical layer or referenced by runtime_extensions.yaml, and kept under the module's own backend/ package. They should not become a catch-all for generic business logic.
Rules for helper files:
- declare them explicitly in the module's owned paths or generated stubs
- keep them module-local
- import them from a canonical layer, or reference them from
runtime_extensions.yaml - do not use them to bypass the handler/service/repo/policy/schema split
What is required¶
Only two things are required to produce a loadable module:
module.yaml— declares identity, handler class reference, permissions, and actionsbackend/handler.py+backend/__init__.py— implements one method per declared action
Everything else is optional. Add a companion manifest under contracts/ only when the module actually needs it.
module.yaml¶
Identity, capabilities, permissions, and action declarations.
schema_version: mozaiks.module.v1
module:
id: my_module
display_name: My Module
version: 1.0.0
description: What this module does.
owner: mozaiks
visibility: internal # public | internal | admin
handler: backend.handler:MyModuleHandler
permissions:
- id: my_module.read
description: Read data.
- id: my_module.manage
description: Create and update records.
actions:
- id: list_items
description: List items.
handler_method: list_items
api_surface: public_readonly # optional metadata; permissions still authorize access
input_schema: { type: object, properties: { limit: { type: integer } } }
output_schema: { type: object, required: [items, count] }
permissions: [my_module.read]
- id: create_item
description: Create an item.
handler_method: create_item
input_schema: { type: object, required: [name], properties: { name: { type: string } } }
output_schema: { type: object, required: [success] }
permissions: [my_module.manage]
emits: [domain.my_module.item_created]
# NOTE: the actions list is the module's complete dispatch authority.
# Only declared action ids resolve at runtime — a handler method that is not
# referenced by a declared action's handler_method is never dispatchable via
# module dispatch (HTTP or executor), and undeclared action ids fail closed
# with ACTION_NOT_FOUND.
# Optional: expose named capabilities for reaction routing and managed-capability wiring.
# Omit this section when reactions only target handler_method directly.
capabilities:
- capability_id: my_module.list
kind: action # action | workflow | page | transition | hosted
target: list_items
title: List Items
permissions: [my_module.read]
capabilities[] is optional. Use it when reactions need to route to a named capability_id (target.kind: capability) or when a managed capability must reference a named integration point. Each entry maps a stable capability_id to an action, workflow, page, or transition target. The runtime validates that capability_id values are unique within the module and that target references a declared action.
Async action budgets¶
actions[].timeout_seconds is an optional server-declared integer from 1 through 3600 seconds. Omitted or null keeps MODULE_ACTION_TIMEOUT_SECONDS (default 30; the existing process setting 0 disables the default timeout). A declared value replaces that default for this action only. Strings, booleans, fractional values, zero, and values outside the bounds are rejected during loading and generation.
For example, a module's internal background action may declare:
- id: process_exports
description: Process one queued export with bounded cleanup.
handler_method: process_exports
api_surface: internal
permissions: []
timeout_seconds: 1860
ModuleExecutor.register_loaded_module() carries the validated budget from the manifest. Direct server registration uses action_timeouts={"process_exports": 1860}; unknown action references fail. Caller params and HTTP request metadata cannot change the budget. Authorization and entitlement checks are unchanged.
The executor cancels timed-out async handlers and returns ACTION_TIMEOUT after their cancellation cleanup finishes. External cancellation still propagates. Handlers must cooperate with cancellation and bound their own cleanup; this is not a hard process kill deadline. Services retain ownership of subprocess cleanup, durable outcomes, and recovery leases, which must cover execution and cleanup. Synchronous handlers retain existing behavior and are not timed out; use async handlers for I/O or background work. A startup_service may schedule dispatch through the executor; it does not establish a separate action authority.
AppGenerator emits the same optional field, materialization validates it before writing YAML, and bundle admission reuses the runtime validator. Ordinary generated CRUD actions continue to inherit the default.
Action exposure¶
actions[].api_surface declares the intended external HTTP exposure of an action. Common values include public, public_readonly, internal, and admin_internal. When auth is enabled, the platform requires an authenticated principal for every external module HTTP action unless the action is explicitly declared public or public_readonly. Omit the field or use null for authenticated UI/API actions. internal and admin_internal are not authenticated HTTP surfaces: both reject HTTP dispatch, even for authenticated callers, and are reserved for trusted runtime/event calls. Local development with auth disabled permits anonymous calls to non-internal actions, but those calls use a concrete empty permission list.
Canonical get actions raise ModuleRecordNotFoundError (exported by mozaiksai.core.runtime) when the scoped lookup finds no record. The executor returns RECORD_NOT_FOUND, and the module HTTP routes return 404 with the same generic message for missing and foreign records. Other LookupError subclasses, including programming errors such as KeyError and IndexError, remain server errors.
If a module action's token guard raises TokenUsageDenied, the executor keeps the denial code and recovery metadata. Module HTTP dispatch returns 402 for INSUFFICIENT_TOKENS or 403 for TOKEN_USAGE_SCOPE_MISSING, with the guard's metadata under detail.extra_data. It does not turn those denials into a generic execution failure.
internal and admin_internal actions are rejected with 404 on the public module route, which also fails closed when the surface map was never populated by platform assembly. admin_internal actions are reachable only through the admin dispatch boundary (/api/admin/modules/{module}/{action}): a token-validated principal carrying an operator role/scope from the deny-by-default MOZAIKS_ADMIN_DISPATCH_ROLES allowlist, dispatched with an enforce-mode authority (declared permissions always checked; never trusted_bypass over HTTP, including local development) and an audit record carrying the dispatch surface and a params digest. internal actions have no HTTP path at all.
api_surface does not replace authorization. Runtime permission checks still use actions[].permissions, entitlement checks still use actions[].entitlement_gate, and the caller's granted permissions still come from the authenticated principal plus the platform module_scope_resolver. Anonymous public calls receive an empty permission list unless the app hook intentionally grants permissions, so a public action with declared permissions will still be denied for anonymous callers. Use public or public_readonly only for actions that are safe to expose without a user token, such as a plan catalog or landing-page read model. Use internal with permissions: [] for event-bus reactions and trusted runtime calls, not for anonymous HTTP traffic.
The platform host starts with the authenticated principal's auth scopes as the default granted permission list. Deployers can register a host-agnostic module_scope_resolver platform hook through RUNTIME_PLATFORM_EXTENSIONS to map principals to app-local tenants, workspaces, memberships, teams, or roles. The hook returns the canonical {app_id, user_id, tenant_id, workspace_id, permissions} scope for the module request; the OSS runtime does not know about any specific tenant product. The resolved scope is available to module policies as ctx.tenant_id, ctx.workspace_id, and ctx.permissions for resource-scoped checks inside handlers and services. The older module_permission_resolver hook remains supported as a compatibility layer after scope resolution.
actions[].entitlement_gate is optional. Set it to a capability_id string when the action belongs to the generated app's own SaaS feature-gate model and requires an active plan grant before executing. The ModuleExecutor checks EntitlementPort.check(capability_id, app_id=..., user_id=..., tenant_id=..., workspace_id=...) and returns ENTITLEMENT_REQUIRED on denial. user_id, tenant_id and workspace_id come from the verified identity: the token's claims, or a membership the scope hook asserted with verified_tenant_id or verified_workspace_id. app_id is the dispatch app. Plain resolved tenant_id and workspace_id never select a plan. Non-SaaS apps use NoOpEntitlementAdapter and are entirely unaffected — no configuration needed. Never set entitlement_gate on admin_internal actions.
actions:
- id: view_dashboard
description: View the analytics dashboard.
handler_method: view_dashboard
api_surface: public_readonly
entitlement_gate: analytics.dashboard # requires active analytics plan grant
permissions: [analytics.read]
input_schema: { type: object }
output_schema: { type: object, required: [data] }
For SaaS apps, app/config/subscriptions.yaml declares the plan catalog and, when runtime enforcement needs persisted assignments, an assignment_store data-contract alias. The platform wires the OSS ConfiguredEntitlementAdapter at startup and the adapter checks active assignment records before dispatch. When assignment_store.workspace_id_field is declared, assignment lookup is workspace-aware: exact app/tenant/workspace/user records are checked before broader tenant, workspace, user, or app-level fallback records. The user, tenant, and workspace come from the dispatch's verified identity, never from requested scope. For non-SaaS apps, no subscription config is needed.
This OSS primitive is not hosted-product licensing. Hosted products such as Mozaiks App may decide whether an app/workspace can use a proprietary hosted pack such as MozaiksPay, but that operator entitlement is enforced by the hosted product and its APIs. Generated apps only use entitlement_gate for their own end-user feature gates.
contracts/ Companion Manifests¶
Module Event/Reaction Contract¶
contracts/events.yamldeclares the events this module may emit. Event types must use a valid namespace owned by the emitting layer such asdomain.*,platform.*,hosted.*, or a branded managed-capability namespace such asmozaikspay.*. For ordinary app modules, emitted events inmodule.yaml.actions[].emitsmust be declared here and are normallydomain.*.contracts/reactions.yamlis the canonical reaction contract. It usesschema_version: mozaiks.reactions.v1, root keyreactions,event_typefor the incoming event, and nestedtarget.kindfor routing.- Reaction targets use one of four canonical kinds:
handlercallstarget.handler_methodon this module's handler class;capabilityinvokestarget.capability_id(must be declared in the receiving module'smodule.yaml capabilities[]array);notificationlinkstarget.notification_idto a rule incontracts/notifications.yaml; andservice_adaptercalls an app-owned service adapter declared bytarget.adapterplustarget.adapter_method. contracts/notifications.yamldeclares notification rules derived from events. It is not a reaction file and should not be confused withcontracts/reactions.yaml.contracts/subscriptions.yamlis not supported. Runtime rejects it so modules have one reaction-routing source of truth.
Canonical Example¶
app/modules/tasks/contracts/events.yaml
schema_version: mozaiks.events.v1
events:
- type: domain.tasks.task.completed
version: 1
description: Emitted when a task is completed.
producer: tasks
app/modules/tasks/contracts/reactions.yaml
schema_version: mozaiks.reactions.v1
reactions:
- id: update_project_progress
event_type: domain.tasks.task.completed
description: Update project progress when a task completes.
target:
kind: handler
handler_method: update_project_progress
Add update_project_progress to backend/handler.py and delegate to service.py.
contracts/events.yaml¶
Declare events this module may publish. Use domain.* namespace for app modules. Hosted product modules may use hosted.*; branded first-party managed capability modules may use a bounded product namespace such as mozaikspay.*.
schema_version: mozaiks.events.v1
events:
- type: domain.my_module.item_created
version: 1
description: Emitted when an item is created.
producer: my_module
payload_schema:
type: object
required: [item_id, owner_id]
properties:
item_id: { type: string }
owner_id: { type: string }
The runtime checks every ctx.emit in a module action before dispatch. It refuses an event when the action does not list it in emits, when the payload fails the event's payload_schema, or when that schema cannot be evaluated. A refused event is not dispatched, so no reaction or notification runs for it. It does not fail the action: the action's writes may already be committed, so the action keeps its handler's outcome.
ctx.emit returns normally either way, and its value says which happened:
- An
EventDispatchOutcome: the event reached the runtime bus. It contains synchronous, payload-free listener results. It may still report a failed or missing downstream reaction. - A
ModuleEventRejection: the event was refused. It carries the event id, the event type, the category (undeclared,value_invalidorschema_invalid) and the schema rule the event failed, never payload contents. None: no bus was wired, or a caller-supplied/test context gave no receipt.
An outbox that requires one consumer must check that exact reaction, not just the absence of a rejection or the dispatcher's overall success. A skipped, failed, missing, or merely invoked reaction does not acknowledge the outbox entry. A module handler returns ModuleReactionEffectAck(effect_id=...) only after verifying its own durable record. required.success then requires both that explicit effect reference and completion in the durable reaction ledger. The ok status alone records invocation; completed records a prior completion of the same scoped identity, and carries the stored effect reference on replay. Neither is an ACK without the reference and durable completion. An active lease, retry delay, dead letter, or permission skip remains unsuccessful. The outbox still owns durable delivery and controlled replay. It persists the event ID and reuses it for each emission; a new ID cannot inherit the previous event's completion evidence. It must keep the payload immutable for that ID because the reaction ledger does not compare replay payloads.
from mozaiksai.core.runtime.composition.module_event_router import (
ModuleReactionEffectAck, required_module_reaction,
)
async def on_item_created(self, ctx, *, item_id):
record_id = await self.service.apply_and_verify(ctx, item_id=item_id)
return ModuleReactionEffectAck(effect_id=record_id)
async def replay_outbox(ctx, outbox_event_id, payload):
outcome = await ctx.emit(
"domain.my_module.item_created", payload, event_id=outbox_event_id
)
required = required_module_reaction(
outcome, module_id="downstream", reaction_id="downstream.apply_item"
)
if not required.success:
... # keep the delivery pending for an authorized, idempotent retry
The rejection is also named on the dispatch result (ModuleResult.rejected_events) and on the dispatch audit. It is logged at ERROR as MODULE_EVENT_REJECTED and counted in ModuleExecutor.health(). The AppGenerator runtime smoke fails a generated bundle on any rejected event.
The emitted-event contract checks belong to the context ModuleExecutor builds for a dispatched action. Other contexts may not apply those checks:
- No event bus wired. Emitting does nothing and returns
None. - Caller-supplied context. A context passed to
ModuleExecutor.executekeeps its own emitter and may return a test-specific value. - Reaction handlers. A reaction handler's
ctx.emitsends its event straight to the bus and returns its dispatch receipt when wired. There the event router checks only the event'spayload_schema, and only when exactly one module declares a schema for that event type, before running that event's reactions. It drops an invalid event. It never checks a reaction-emitted event against anyemitslist. - Contexts that code builds itself, such as the stub context of the generated cron script.
contracts/reactions.yaml¶
Declare reactions to events published by other modules. Each reaction routes an event to a typed target: module handler, workflow/page capability, notification rule, or app-owned service adapter.
schema_version: mozaiks.reactions.v1
reactions:
- id: my_module.on_other_event
event_type: domain.other_module.something_happened
target:
kind: handler
handler_method: handle_something
Add the matching method to handler.py and delegate to service.
service_adapter is the bounded escape hatch for provider or hosted-product mechanics that belong under app/services/adapters/..., not inside a generated module. It is useful when a module owns the event contract and durable state, but the reaction must call app-owned integration code. The adapter target must use module.path:ClassName plus a method name; the runtime instantiates the adapter and passes the event payload, with optional event_type, envelope, and reaction arguments when the method declares them.
schema_version: mozaiks.reactions.v1
reactions:
- id: hosting.provision_ci
event_type: hosted.hosting.ci_provision.requested
target:
kind: service_adapter
adapter: app.services.adapters.ci.managed_ci_provisioner:ManagedCIProvisioner
adapter_method: handle
Service adapters must not own durable module facts, public user actions, permissions, subscription state, wallet state, or hosted-product entitlement policy. Keep those in modules or the hosted product that owns them.
contracts/notifications.yaml¶
Declare notification rules. Each entry maps an event to a set of recipients, channels, and a message template.
schema_version: mozaiks.notifications.v1
notifications:
- id: my_module.item_created.admin
event_type: domain.my_module.item_created
channels: [in_app, email]
audience:
roles: [admin]
template:
title: "New item"
body: "{{name}}"
contracts/settings.yaml¶
Declare user or app-level configurable settings owned by this module. Omit the file when the module has no settings.
contracts/service.yaml¶
Declare a stable service boundary owned by this module. Add this file only when another generated app, external client, operator surface, or module needs to discover and consume the module as a service.
This is names-only contract metadata. It may declare service ids, service class, safe public API routes, module actions, auth posture, generated-app handoff, required env handle names, forbidden generated outputs, provider-mechanics references, and validation references. It must not contain raw credentials, provider customer/account ids, provider price ids, webhook secrets, settlement rules, or executable provider code.
schema_version: mozaiks.service.v1
service_id: payments_api
status: active
service_class: app_api
label: Payments API
purpose: Expose payment status and checkout initiation through module actions.
owner_modules: [payments]
public_surfaces:
api_routes: []
module_actions:
- payments.create_checkout
- payments.get_checkout_status
auth:
method: app_api_key
issuer: payments
secret_delivery: env_handle
notes: Credentials are referenced by env handle only.
generated_app_handoff:
build_context: null
capability_pack_id: null
client_path: services/integrations/payments_client.py
facade_modules: [billing_portal]
required_env_handles: [PAYMENTS_API_BASE, PAYMENTS_API_KEY]
forbidden_generated_outputs: [services/adapters/payments/]
provider_mechanics_refs: []
validation_refs:
- tests/test_payments_service_contract.py
service.yaml does not make app/services/ a product layer. Provider mechanics still belong in app/services/adapters/ only when the app directly owns that provider integration, and durable facts still belong in modules.
contracts/commercial.yaml¶
Declare module-owned commercial metadata outside the core OSS subscription contract. Add this file only when the module owns safe price display, a module fee policy, service terms, usage-policy display, revenue-share metadata, or a custom money-flow boundary.
This file does not grant entitlements, write subscription assignments, process payments, or replace app/config/subscriptions.yaml. Core OSS monetization remains intentionally small: subscriptions, seats, feature gates, quotas, prepaid credits, token wallets, token allowances, and usage meters.
schema_version: mozaiks.commercial.v1
scope: module
summary: Commercial terms for checkout sessions owned by this module.
runtime_authority: module
items:
- id: marketplace_fee
label: Marketplace fee
status: active
kind: fee
display: 3.5%
amount_cents: null
currency: null
percentage_bps: 350
applies_to: [checkout_session]
source_refs:
- modules/payments/module.yaml
notes: Fee calculation happens in module service code after payment confirmation.
public_disclosure: Fees are shown before payment.
Use app/config/subscriptions.yaml for recurring plans, paid feature gates, quotas, token wallets, entitlement grants, and provider-neutral add-on product definitions shown on pricing or billing surfaces. Use module-owned commercial.yaml for commercial metadata that belongs to a specific module or service, including order/fulfillment policy that complements an add-on product. Central app-level registries, if present in an operator app, should be derived or compatibility summaries rather than the source of truth.
contracts/admin.yaml¶
Declare admin panels this module contributes to the unified /admin shell. Omit the file when the module has no admin panels.
contracts/profile.yaml¶
Declare panels this module contributes to the user profile page (/me). Only add this file when the module has user-scoped account data worth surfacing there — for example, activity summaries, notification preferences, or usage stats. Do not add it to every module.
Valid kind values: metrics (grid of labelled tiles), list (key/value list), component (registered React component). form is reserved and not yet implemented — the validator rejects it at load time.
Profile panels bind to module actions via action:. The platform calls that action at /api/me/profile-panels request time and attaches the result as data. Panel action failures return safe error metadata — they do not crash the profile page.
Profile panels must not expose admin-only actions or secrets. They do not replace or override /api/me identity.
schema_version: mozaiks.profile.v1
panels:
- id: activity-summary
title: Activity Summary
description: Account-level summary.
order: 30 # 1–998; identity=0, preferences=999
kind: metrics
action: get_activity_summary
fields:
- { id: total, label: Total, type: number }
- { id: status, label: Status, type: status }
See profile-panel-contract.md for the full contract reference.
runtime_extensions.yaml¶
Optional module-level host extension file.
Use runtime_extensions.yaml only when normal module actions are insufficient and the module needs to mount a custom route (api_router) or start a process-lifetime background service (startup_service) at host startup. It is not a turn-level hook file.
schema_version: mozaiks.runtime_extensions.v1
extensions:
- kind: api_router
entrypoint: backend.router:get_router
prefix: /webhooks
- kind: startup_service
entrypoint: backend.worker:MyService
profile: worker
Two kinds: - api_router — mounts a FastAPI APIRouter at host startup. Use for a module-local generic external webhook receiver or custom inbound callback route. - startup_service — starts a background service for the process lifetime. Use for a module-local audit/event subscriber or polling worker.
Startup services have two finite process profiles: host (the default when profile is omitted or null) and worker. The ordinary app host runs only host services. A separately operated process sets MOZAIKS_STARTUP_SERVICE_PROFILE=worker and starts only services declared with profile: worker. That process still loads the app and its module contracts; the profile selects startup services only. It does not mount or unmount API routers, grant action permissions, or create a background module dispatch bypass. An invalid process profile or a worker profile with no declared services aborts startup. A worker also aborts if its app or any module fails to load. A worker startup failure stops any worker services started earlier in that process.
Only profile: worker is an explicit manifest value; omit profile (or use null in generated contracts) for the ordinary host. A worker service must expose a callable start() method. The profile selector does not skip platform on_startup hooks, data migrations, event registration, or route mounting. Studio's preview-session maintenance runs only in the host profile; other process work must be safe in both processes or gated by its owner. Keep the worker host private and bound to loopback; the service profile alone does not create a no-listener daemon.
Keep profile absent for existing app services and all api_router entries. Configure a separate worker process only when the app has an operational boundary and a declared service that belongs there.
Runtime extension rules:
- entrypoints must be module-local backend files such as
backend.webhooks:get_routerorbackend.audit_subscriber:AuditSubscriber - entrypoint files must also be declared in generated backend outputs or Python stubs
- do not use runtime extensions for generic business logic; put that in
service.py - do not use runtime extensions for persistence/query code; put that in
repo.py - do not use runtime extensions for auth, tenancy, or scope helpers; put that in
policy.py - do not use runtime extensions for transport or WebSocket infrastructure
- do not use runtime extensions for workflow orchestration
Backend Layer Contract¶
Every module backend follows the same four-layer pattern.
handler.py — Thin dispatch only¶
One method per declared action. No logic, no ctx.db, no ctx.emit. Delegates everything to service.
from __future__ import annotations
from typing import Any
from .service import MyModuleService
class MyModuleHandler:
def __init__(self) -> None:
self.service = MyModuleService()
async def list_items(self, ctx, *, limit: int = 20) -> dict[str, Any]:
return await self.service.list_items(ctx, limit=limit)
async def create_item(self, ctx, *, name: str) -> dict[str, Any]:
return await self.service.create_item(ctx, name=name)
service.py — All business logic¶
Validates inputs, calls repo, emits events via ctx.emit() after commits. Never accesses ctx.db directly.
from __future__ import annotations
from typing import Any
from uuid import uuid4
from .schemas import coerce_limit, timestamp_now
from .repo import MyModuleRepo
class MyModuleService:
def __init__(self, repo: MyModuleRepo | None = None) -> None:
self.repo = repo or MyModuleRepo()
async def list_items(self, ctx, *, limit: int = 20) -> dict[str, Any]:
items = await self.repo.list(ctx, query={}, limit=coerce_limit(limit))
return {"items": items, "count": len(items)}
async def create_item(self, ctx, *, name: str) -> dict[str, Any]:
now = timestamp_now()
record = {
"item_id": str(uuid4()),
"name": name.strip(),
"status": "active",
"created_at": now,
"updated_at": now,
}
await self.repo.insert(ctx, record=record)
await ctx.emit(
"domain.my_module.item_created",
{"item_id": record["item_id"]},
)
return {"success": True, "item_id": record["item_id"]}
The persistence collection scopes the list and stamps the declared owner field on insert. Service code does not infer that field or require a policy call.
repo.py — Persistence access only¶
Pure data access. No business logic, no events, no validation.
Generated repo code uses ctx.persistence.collection(module_id, collection_name) with module/entity values aligned to data_contract and staged data/contract.json. It must not use ctx.db, call get_mongo_client(), or hardcode database names.
class ProjectsRepo:
async def _collection(self, ctx):
persistence = getattr(ctx, "persistence", None)
if persistence is None:
raise RuntimeError("Persistence is not available for this app context.")
return persistence.collection("projects", "projects")
async def list_projects(self, ctx, *, query=None, limit=50):
collection = await self._collection(ctx)
return await collection.find_many(query or {}, limit=limit)
The collection pair must match data/contract.json, for example module_id: projects and collection name: projects. A declared entity reference resolves to the same collection and ownership policy; unknown references fail closed. Non-persistent modules should not invent database logic.
Every operation resolves the current dispatch principal, including operations on a cached collection handle. HTTP, socket, and Page Ask dispatch preserve the authenticated actor. Registered host scope hooks may assert verified_workspace_id after membership verification; plain requested workspace_id is never ownership authority. Omission preserves token scope, while explicit None revokes workspace ownership. Auth-disabled local/test hosts use a logged development principal with workspace development; deployed hosts cannot enable that authority. Generated owned collections require app.json.authRequired=true and the canonical auth scaffold.
policy.py — Ownership preflight¶
Deterministically rendered helpers use ctx.persistence.principal and declared collection ownership. Runtime persistence enforces the same boundary on every operation without requiring helper calls. Missing authenticated identity and conflicting inserted owner fields fail closed. Domain authorization and transition checks belong in service.py.
schemas.py — Typed shapes and pure helpers¶
TypedDicts for document shapes. Pure helper functions (timestamp, coerce). No I/O.
Helper files — Declared module-local support only¶
Helper files are allowed when they keep the canonical layers clear. They must be declared before generation, live under the module's backend/ package, and have a specific purpose that is imported by a canonical layer or referenced by runtime_extensions.yaml.
Allowed generic examples:
backend.external_client.pyfor a generic external provider clientbackend.webhooks.pyfor a runtime extension routerbackend.audit_subscriber.pyfor a startup service helperbackend.notification_client.pyfor a notification delivery clientbackend.domain_rules.pyfor complex pure domain helpers that would bloatservice.py
Prohibited helper files:
- generic business logic that belongs in
service.py - persistence/query code that belongs in
repo.py - auth, tenancy, or scope logic that belongs in
policy.py - DTOs, typed shapes, or pure serialization helpers that belong in
schemas.py - transport or WebSocket infrastructure
- workflow orchestration
- arbitrary file splitting
Event Model¶
Modules emit domain events after committing state changes. The runtime routes these events to workflow triggers and notification rules.
Event namespace rules: - App modules use domain.* — e.g., domain.orders.order_placed - Hosted product modules use hosted.* — e.g., hosted.analytics.metric_recorded - First-party managed-capability modules may use a bounded product namespace such as mozaikspay.* when the event is part of that public capability contract rather than a generic app fact - Platform events use platform.* — owned by the runtime, not generated
Modules must declare every event they emit in contracts/events.yaml. The platform validates that emitted events match declared types on startup.
Event flow:
service.py → ctx.emit(event_type, payload)
→ UnifiedEventDispatcher
→ ModuleEventRouter
→ reactions.yaml → target.kind handler/capability/notification/service_adapter
→ notifications.yaml → notification stored in platform_notifications
→ orchestrator.yaml triggers → workflow start/resume
Modules do not know which workflows they trigger. The trigger contract is owned by the workflow's orchestrator.yaml.
App Metric Signals¶
Use ctx.metrics.track(...) for durable usage signals that are measurements rather than domain state changes: app views, feature usage, campaign impressions, campaign clicks, conversions, and funnel steps.
await ctx.metrics.track(
"feature.used",
subject_type="feature",
subject_id="search",
dimensions={"surface": "workspace"},
)
Metric events are generic and app-scoped. They should not encode hosted-product business rules such as payout percentages, sponsor pricing, subscription entitlements, or payment settlement. Modules that expose metric summaries to UI should wrap ctx.metrics.summarize(...) or ctx.metrics.funnel(...) behind an app-owned action so permissions and visibility stay explicit.
Capability Ownership Classification¶
Every module or feature capability belongs to one of five ownership classes. This determines who generates it, who consumes it, and whether OSS apps may include it.
| Class | Owner | Generation | OSS apps |
|---|---|---|---|
host_universal | Runtime/Platform | Never generate — always present | Yes, automatic |
framework_pack | Mozaiks framework | Select from pack catalog — don't regenerate | Yes, opt-in |
managed_capability | Mozaiks App (proprietary) | Not generated — licensed integration only | No |
generated_module | App-specific | AppGenerator generates contracts + stubs | Yes, per app |
external_adapter | External service | AppGenerator generates wiring + facade only | Adapter yes; engine no |
host_universal¶
Built into the runtime or platform. Every app gets it automatically.
Examples: WebSocket transport, event dispatch, session management, AG2 orchestration, admin shell, notification storage.
Rule: Never generate these. If an AppGenerator plan includes auth, websocket, notifications infrastructure, or user management as a module to build — that plan is wrong.
framework_pack¶
Optional reusable capability packs published by the Mozaiks framework. Apps select them from the catalog; AppGenerator does not regenerate the pack internals.
Examples: notifications pack, messaging pack, files pack, audit pack.
Rule: Reference the pack; expand pack-specific app overlay only (app-specific wiring, page composition, event flow declarations).
managed_capability¶
Licensed capability packs that depend on private Mozaiks App hosted services. OSS apps must not copy these.
Examples: generic_managed_analytics, managed_search.
Rule: Generate the integration facade and wiring for the app; the hosted service engine lives in the private product repo.
Managed capability access is operator-managed. The generated app may receive a facade module or thin service client for a managed capability, but it must not copy or enforce the hosted product's pack-access entitlement rules. If the generated app is itself a SaaS product, its end-user feature gates still use the OSS entitlement_gate and EntitlementPort contract described above.
generated_module¶
App-specific deterministic business logic. AppGenerator generates the full module contract and backend stubs for each one.
Examples: orders, inventory, profiles, campaigns.
Rule: Generate module.yaml, contracts/events.yaml, contracts/reactions.yaml, contracts/notifications.yaml, backend/handler.py, backend/service.py, backend/repo.py, backend/policy.py, backend/schemas.py.
external_adapter¶
A facade to an outside system. Generate the integration wiring (adapter, runtime_extensions.yaml for webhook receivers, event bridge) — not the external system itself.
Examples: generic external webhook receiver, notification bridge, search index sync adapter.
Rule: The real system lives outside Mozaiks. Generate only the facade and event bridge. Use runtime_extensions.yaml api_router for inbound webhooks.
AppGenerator and Modules¶
AppGenerator generates generated_module class capabilities. For each module in the build plan:
- Outputs
module.yaml— identity, actions, permissions, event references - Outputs
contracts/events.yaml— domain events the module publishes - Outputs
contracts/reactions.yaml— reactions to events from other modules (if any) - Outputs
contracts/notifications.yaml— notification rules (if any) - Outputs
contracts/profile.yaml— user profile page panels, when the module has user-scoped account data worth surfacing (activity summaries, usage stats, notification preferences). Do not emit for every module. - Outputs
contracts/service.yaml— service boundary metadata, when the module exposes a stable service to generated apps, external clients, operators, or other modules. Do not emit for every module. - Outputs
contracts/commercial.yaml— module-owned commercial metadata, when the module owns commercial display, fee policy, service terms, or custom money-flow metadata outsideapp/config/subscriptions.yaml. Do not emit for every module. - Outputs
backend/handler.py,backend/service.py,backend/repo.py,backend/policy.py,backend/schemas.py— backend stubs
AppGenerator does not generate: - auth, user management, session infrastructure → host_universal - notification delivery infrastructure, admin shell → host_universal - the user profile page or /api/me identity — both are host_universal - pack internals for framework packs → framework_pack - hosted service engines or external systems → managed_capability / external_adapter - contracts/profile.yaml with kind: form — form is reserved and rejected by the validator - app-level hosted service or monetization registries as primary truth — service and commercial metadata belongs in module-owned contracts when it is durable app behavior
Runtime Alignment Status¶
The following runtime behaviors are not yet implemented. They are listed here so engineering work is tracked against the canonical contract, not against the current runtime state.
| Canonical contract | Runtime status |
|---|---|
contracts/ subdirectory path | Fully wired — ModuleLoader loads from contracts/ subdir |
contracts/reactions.yaml | Canonical event reaction contract |
| All companion manifests optional | Fully wired — absent files yield None, not empty defaults |
settings.py injected into ctx | Settings manifest data is injected into ModuleContext.settings; module-local Python settings hooks remain future work |
contracts/reactions.yaml target routing | Fully wired — ModuleEventRouter resolves handler, capability, notification, and service_adapter targets from canonical reactions |
notifications.py audience hooks | Stored but not called by ModuleEventRouter |
| Module permissions enforcement | Fully wired in ModuleExecutor for external calls with concrete granted permissions |
| Auth-enabled HTTP module default | Fully wired — external HTTP calls require auth unless actions[].api_surface is public or public_readonly |
| Input/output schema validation | Fully wired in ModuleExecutor; input failures block dispatch and output failures warn |
Until the runtime is updated, generated modules should still follow the canonical contract shape. The runtime loader will be updated to support the new paths.
Cross References¶
- module-authoring-patterns.md — backend authoring patterns
- appgenerator-capability-planning.md — AppGenerator capability planning
- ../app/canonical-app-structure.md — full app workspace layout
- ../app/app-bundle-declaratives.md — declarative contract reference