Skip to main content

Documentation

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:

  1. One declarative file — app.manifest.yaml, checked in at backend/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).
  2. A thin FastAPI app built on the Python runtime SDK egav-app-sdk (import package egav_app_sdk, itself layered on egav-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, rolesA live authorization engine — per-action checks resolved against AuthZ
features, quota blocks on integer featuresPlan gating and metered quotas, with per-plan values bound in the Control Plane
events.emits / events.consumes, kafka topic ownershipA CloudEvents v1.0 event bus with a transactional outbox and customer webhooks
audit.event_typesA tamper-evident, SOC 2-style audit trail
gdpr.export / gdpr.erasureA platform-driven data-export and cascading-erasure workflow
config (tabs → sections → groups → fields)A generated operator Settings UI in the Control Plane
tenancy.storagePer-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:

  1. JSON Schema — structural validation against app-manifest.schema.json.
  2. 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:

backend/app.manifest.yamlyaml
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 ...
python
# 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 errors

What 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: a code, its actions[], and instance_roles[]), plus cp_permissions / portal_permissions and cp_roles / portal_roles. Authorization is spread across these keys, not a single authz: block.
  • Plans & quotas — features[] are definitions only (code, name, type of boolean/integer/string, default). Per-plan values are bound in the Control Plane, never in the manifest. An integer feature may carry a quota block (source: cumulative | daily | per_request).
  • Events — events.emits[] (each needs id, topic, transport (outbox or platform_events), schema_ref, version and an ADR-009 audience of tenant-private / org-broadcast / platform-broadcast) and events.consumes[], alongside kafka (owned_topic_patterns, consumer_group_pattern, dead_letter_topic).
  • Audit — audit.event_types[]: the exact vocabulary your app is allowed to emit, each with a code, description, actor_type and metadata_keys[].
  • Privacy — gdpr.export / gdpr.erasure / gdpr.consent; endpoints must live under /v1/{app_code}/internal/ and erasure.mode is one of cascade | anonymize | hard_delete.
  • Tenancy — tenancy.storage (required): per_instance_db, per_org_db or shared_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.

yaml
# 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
yaml
# 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: subscribed

The 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_router produce 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 an AppContext with the resolved user_guid and tenant tenant_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.

python
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:

  1. Authentication + authorization — RequireAuthz(resource, action) from egav-authz-client validates the portal Bearer JWT and checks the permission against AuthZ in one dependency. (Its sibling RequireCPAuthz guards Control Plane operator routes.)
  2. Active subscription — require_active_subscription (402 Payment Required if the org's subscription has lapsed).
  3. Plan feature — require_feature(code) (403 if the feature is off for the org's plan).
  4. 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.

python
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.

bash
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'
python
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 spec reference, 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 config block 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 the RequireAuthz + 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.