Platform integration
One app.manifest.yaml declares your contract; the SDK wires in auth, audit, events, plans and quotas. Here is how a platform-native app fits together.
What you're building
A platform-native app on SynaptaGrid Platform is two things:
- One declarative file —
app.manifest.yaml, checked in atbackend/app.manifest.yaml. It is the contract between your app and the platform: your permissions, roles, plan features, quotas, events, audit vocabulary, tenancy model and operator settings all live here. Seventeen backend services in the platform ship one today (AuthN, AuthZ, Audit, Notifications, Comments, Support, the Control Plane itself, and more). - A thin FastAPI app built on the Python runtime SDK
egav-app-sdk(import packageegav_app_sdk, itself layered onegav-base). You write your routes and your domain logic; the SDK wires in everything the manifest declared.
The pieces are conventional by design — a declarative manifest reconciled to desired state, CloudEvents on the wire, OIDC at the door — no private dialect to learn.
The payoff is what you don't build. Once your manifest is published to the Control Plane, your app inherits:
| You declare (in the manifest) | You inherit (from the platform) |
|---|---|
resource_types, cp_permissions, portal_permissions, roles | A live authorization engine — per-action checks resolved against AuthZ |
features, quota blocks on integer features | Plan gating and metered quotas, with per-plan values bound in the Control Plane |
events.emits / events.consumes, kafka topic ownership | A CloudEvents v1.0 event bus with a transactional outbox and customer webhooks |
audit.event_types | A tamper-evident, SOC 2-style audit trail |
gdpr.export / gdpr.erasure | A platform-driven data-export and cascading-erasure workflow |
config (tabs → sections → groups → fields) | A generated operator Settings UI in the Control Plane |
tenancy.storage | Per-instance or shared-with-filter multi-tenant isolation |
The rest of this page walks the arc: the manifest, the shape of the app, the gates that protect a route, and how to publish and emit an event. Each deeper topic has its own page — see Where to go next.
The manifest is the contract
Every manifest has the same Backstage-style top level: apiVersion, kind, metadata, and spec — all four required. apiVersion is always the constant platform.synaptagrid.io/v1 and kind is always Application.
metadata requires name (the machine code, ^[a-z][a-z0-9-]*$), display_name, version (semver) and publisher. Inside spec, the only required section is features — everything else (events, audit, gdpr, tenancy, config, resource_types, …) is optional and added as your app grows.
The contract is enforced by a strict JSON Schema (Draft 2020-12, additionalProperties: false at every level) shipped inside the SDK. Validation runs in two passes:
- JSON Schema — structural validation against
app-manifest.schema.json. - Cross-field checks the schema can't express — e.g. every role's permissions must resolve within its own scope list, every emitted topic must match one of your
kafka.owned_topic_patterns, notification(code, channel)pairs must be unique, and any app that classifies personal data must declare a GDPR erasure path.
You run the same validation locally with the SDK's CLI (egav-app-sdk manifest validate), and the Control Plane re-runs the exact SDK code path on publish — so what passes on your machine is what the platform accepts.
Here is a complete, schema-valid manifest for a minimal app — validate your own the same way before publishing:
apiVersion: platform.synaptagrid.io/v1 # const
kind: Application # const
metadata:
name: hello-world # machine code, ^[a-z][a-z0-9-]*$
display_name: Hello World
version: 0.1.0 # semver
publisher: getek
spec:
cp_permissions:
- hello_world.greetings.read
- hello_world.greetings.write
cp_roles:
- code: hello_world_editor
name: Hello World Editor
permissions:
- hello_world.greetings.read
- hello_world.greetings.write
features: # the ONLY required spec section
- code: hello_world.write
name: Create greetings
description: Create new greetings.
ui_hint: Allow creating and updating greetings.
type: boolean
default: false
health:
liveness: /health
readiness: /health
# optional as you grow: resource_types, portal_permissions/roles,
# events, kafka, audit, gdpr, tenancy, config, notification_templates ...# The canonical validation entrypoint the CLI and the Control Plane both call.
# Pass 1 = JSON Schema; pass 2 = cross-field checks only if the schema passed.
SCHEMA_PACKAGE = "egav_app_sdk.manifest.schema"
SCHEMA_FILENAME = "app-manifest.schema.json" # $id .../app-manifest/1.34.json
def validate_manifest(data: dict[str, Any]) -> list[str]:
errors = _schema_errors(data)
if not errors:
errors.extend(_cross_field_errors(data))
return errorsWhat you declare, section by section
spec is a set of independent sections, each owned by a different platform concern. The keys are precise — these are the real ones, not friendly aliases:
- Identity & access —
resource_types[](ReBAC: acode, itsactions[], andinstance_roles[]), pluscp_permissions/portal_permissionsandcp_roles/portal_roles. Authorization is spread across these keys, not a singleauthz:block. - Plans & quotas —
features[]are definitions only (code,name,typeofboolean/integer/string,default). Per-plan values are bound in the Control Plane, never in the manifest. Anintegerfeature may carry aquotablock (source: cumulative | daily | per_request). - Events —
events.emits[](each needsid,topic,transport(outboxorplatform_events),schema_ref,versionand an ADR-009audienceoftenant-private/org-broadcast/platform-broadcast) andevents.consumes[], alongsidekafka(owned_topic_patterns,consumer_group_pattern,dead_letter_topic). - Audit —
audit.event_types[]: the exact vocabulary your app is allowed to emit, each with acode,description,actor_typeandmetadata_keys[]. - Privacy —
gdpr.export/gdpr.erasure/gdpr.consent; endpoints must live under/v1/{app_code}/internal/anderasure.modeis one ofcascade | anonymize | hard_delete. - Tenancy —
tenancy.storage(required):per_instance_db,per_org_dborshared_with_filter. See Data placement. - Operator settings —
config(tabs → sections → groups → fields) drives the Control Plane's Settings UI. New operator-tunable settings go here, not into raw environment variables.
The two snippets below show the two most-asked-about sections — the AuthZ resource surface and an event you emit — copied from shipping manifests.
# AuthZ surface: spec.resource_types[] (ReBAC) with per-instance roles.
spec:
resource_types:
- code: support.comment
name: Support Comment
description: A reply on a support ticket. Owner = author_guid.
actions: [read, create, update, delete]
instance_roles:
- code: owner
name: Owner (author)
permissions:
- support.comment.update
- support.comment.delete# Events: every emitted topic must match spec.kafka.owned_topic_patterns
# (cross-field validated). audience is REQUIRED. emit_policy: subscribed
# keeps an integration event dormant until a listener/webhook subscribes.
spec:
kafka:
owned_topic_patterns:
- authz.role.*
- authz.role_assignment.*
consumer_group_pattern: authz-{instance_guid}
dead_letter_topic: authz.dlq
events:
emits:
- id: authz.role.created
topic: authz.role.created
transport: outbox # REQUIRED: outbox | platform_events
description: A role was created.
schema_ref: manifest-schemas/events/authz-role-created.v1.json
version: 1
audience: tenant-private
webhook_subscribable: true
emit_policy: subscribedThe shape of a platform-native app
The runtime side mirrors the manifest. You build the app with create_saas_app(...), mount one router per route tier, and resolve the caller's tenant and user through a single dependency.
create_saas_app(manifest=..., settings=..., instance_resolver=..., routers=...)wires the manifest into a FastAPI app.make_public_router/make_portal_router/make_cp_router/make_internal_routerproduce the four route tiers, mounted under the prefix hierarchy/v1/{service}/{public|cp|internal|portal}/*— one auth model per tier.get_app_context(request)returns anAppContextwith the resolveduser_guidand tenanttenant_application_instance_guid, so your handler never parses tokens or headers itself.
The example below is trimmed from the SDK's hello-world reference app — the smallest complete platform-native app, used by the SDK test suite.
from fastapi import Depends, Request
from egav_app_sdk import (
AppContext, get_app_context, requires_feature, enforce_quota,
make_portal_router, create_saas_app, load_manifest,
)
MANIFEST = load_manifest("app.manifest.yaml")
portal = make_portal_router(MANIFEST.identity.app_code)
@portal.post(
"/greetings",
dependencies=[
Depends(requires_feature("hello_world.write")), # plan gate -> 403
Depends(enforce_quota("hello_world.greetings.count")), # quota gate -> 429
],
)
async def create_greeting(request: Request, ctx: AppContext = Depends(get_app_context)):
body = await request.json()
# ctx.user_guid and ctx.tenant_application_instance_guid are resolved by the SDK
...
app = create_saas_app(
manifest=MANIFEST, settings=_Settings(), instance_resolver=_instance_resolver,
routers={"public": public, "portal": portal, "cp": cp, "internal": internal},
)Protecting a route: the gates you inherit
A tenant-facing write is protected by a short, composable stack of FastAPI dependencies — each one owned by a different platform service, each failing closed:
- Authentication + authorization —
RequireAuthz(resource, action)fromegav-authz-clientvalidates the portal Bearer JWT and checks the permission against AuthZ in one dependency. (Its siblingRequireCPAuthzguards Control Plane operator routes.) - Active subscription —
require_active_subscription(402 Payment Required if the org's subscription has lapsed). - Plan feature —
require_feature(code)(403 if the feature is off for the org's plan). - Quota —
require_quota(code, current_usage=...)(429 if the counter is exhausted).
Gates 2–4 come from egav_cp_client.feature_gates. Trust tiers are isolated cryptographically: portal and Control Plane tokens carry different JWT audiences (<base>:portal vs <base>:cp), so a wrong-tier token is rejected by the JWKS/RS256 verification before any database lookup.
The status-code contract is stable and worth designing against: 401 auth — including a wrong-tier token, which fails the JWKS/RS256 audience check and is rejected as an invalid token — 402 subscription lapsed, 403 permission or feature denied, 429 quota exceeded, 503 a dependency (AuthZ, Control Plane, Redis) is unreachable. No gate ever degrades to allow. The Auth & gates page covers the full four-tier model.
from fastapi import APIRouter, Depends, Request
from egav_authz_client import RequireAuthz
from egav_cp_client.feature_gates import (
require_active_subscription, require_feature, require_quota,
)
router = APIRouter()
async def project_count(request: Request) -> int:
return await count_projects(request.state.identity.org_guid)
@router.post(
"/v1/my-saas/portal/projects",
dependencies=[
Depends(RequireAuthz("project", "create")), # authN (portal JWT) + authZ
Depends(require_active_subscription), # 402 if lapsed
Depends(require_feature("projects")), # 403 if feature off
Depends(require_quota("max_projects", current_usage=project_count)), # 429 if over
],
)
async def create_project(payload: ProjectCreate):
return await save_project(payload)Publishing, and emitting your first event
Publish registers your manifest with the Control Plane, which then fans the sections out — syncing AuthZ resources and roles, plan features, the audit catalogue and notification templates. The CLI reads EGAV_CP_URL and EGAV_CP_API_KEY and POSTs to /v1/control-plane/internal/applications/{app_code}/manifest with an x-api-key header.
Emit an event by declaring it under spec.events.emits[] and then, in your route's transaction, calling enqueue_event_if_listener(...) (or the ungated enqueue_event(...)). This writes a single row to the transactional outbox in the caller's transaction — you commit; a separate outbox poller then serializes the row as a CloudEvents v1.0 EventEnvelope and publishes it to Kafka in structured content mode. You never hand-build an event dict: the envelope is the one serializer, mapping event_type → type, aggregate_id → subject (also the Kafka partition key), payload → data.
Because emission is transactional and post-commit, emit on the committed path of a long-lived process — a one-shot script that exits before the poller runs will appear to lose the event. And an event marked emit_policy: subscribed stays dormant until a listener or webhook subscribes. The Events & webhooks page covers the full envelope, retries and signing.
export EGAV_CP_URL=https://api.your-domain.example:5207 # the Control Plane API host
export EGAV_CP_API_KEY=<your app's caller key> # see Quickstart, step 4
# Validate locally, then publish to the Control Plane:
egav-app-sdk manifest validate backend/app.manifest.yaml
egav-app-sdk manifest publish backend/app.manifest.yaml --app-code my-saas
# POST {cp}/v1/control-plane/internal/applications/my-saas/manifest (x-api-key)
# -> Manifest PUBLISHED: app_code='my-saas' version='0.1.0'from egav_app_sdk.events import enqueue_event_if_listener
# `emit_policy_resolver` and `listener_lookup` are the gate's two collaborators,
# built ONCE at startup (see make_listener_gated_emitter) and reused per call.
# Inside the route's transaction (`with get_transaction_context(db):`) — you commit.
await enqueue_event_if_listener(
db,
aggregatetype="my-saas.project.created", # CloudEvents 'type' / manifest emits[].id
aggregateid=str(project.guid), # CloudEvents 'subject' / Kafka partition key
type="created",
payload=payload, # GUID-only per compliance policy
org_guid=str(org_guid),
app_instance_guid=str(app_instance_guid),
emit_policy_resolver=_get_resolver().emit_policy_for, # required kwarg
listener_lookup=_listener_lookup, # required kwarg
)
# The outbox poller turns this row into a CloudEvents v1.0 EventEnvelope on Kafka.Where to go next
This page is the map. Each leg has a dedicated guide:
- Quickstart — install the SDK, scaffold
app.manifest.yaml, ship one protected route, publish, and emit an event, end to end. - The app manifest — the full
specreference, the strict schema, the cross-field rules, and the section map linking every domain below. - Identity & access — permissions and roles (RBAC), resource types and role derivations (ReBAC), synced into AuthZ.
- Plans, features & quotas — plan features, metered quotas with enforcement policies, and one-off purchasable products.
- Events & webhooks — the CloudEvents
EventEnvelope, the outbox pattern, customer webhook subscriptions, HMAC signing and retries. - Settings & config — the
configblock that renders the Control Plane Settings UI (tabs, sections, groups, typed fields). - Lifecycle hooks — the internal endpoints CP calls on provision, deprovision, plan change, config reload and upgrade.
- Scheduled tasks — cron jobs the platform runner fires against your app, with targets, retries and scope.
- Notifications — email, in-app, SMS, Slack and push templates plus re-brandable email layouts.
- Audit — the declarative audit-event catalogue on a tamper-evident, hash-chained trail.
- GDPR & compliance — export, erasure and consent endpoints plus data classification.
- Tenancy & deployment — storage and isolation options, deployment topology, compute sizing, required env and infrastructure.
- AI surface — providers, agents, tools, skills, RAG corpora, memory, evals and budgets.
- Make your app AI-ready — turn a manifest
tools[]declaration into a tool Claude and ChatGPT can call over MCP. - Auth & the five gates — the four route tiers (
public/portal/cp/internal), JWT audiences, and theRequireAuthz+ subscription/feature/quota gate stack. - SDKs & clients — the Python and TypeScript app runtimes, the shared platform clients (
egav-authz-client,egav-cp-client,egav-audit-client, …), and the Go, Rust, PHP and Node client SDKs.
If you build one thing today, build the hello-world example from the SDK — it exercises the manifest, all four tier routers, the feature and quota gates, lifecycle and event handlers, and imperative audit in one small app.