Skip to main content

Documentation

The app manifest

The full app.manifest.yaml reference — every spec section, the strict schema, and the cross-field rules.

One file, one contract

Every platform-native app ships a single app.manifest.yaml at {service}/backend/app.manifest.yaml. It is the declarative contract between your service and the Control Plane: authorization resources, plan features, events, audit vocabulary, GDPR endpoints, tenancy, and operator settings are all declared here, and publishing the file wires them into the platform.

The top level is Backstage-style — apiVersion, kind, metadata, spec — and the canonical contract is a strict JSON Schema (Draft 2020-12) shipped inside the SDK. additionalProperties: false applies at nearly every level, so an unknown or misspelled key is a hard error, not a silent no-op. A few objects deliberately stay open — spec.compute and config fields among them — where the richer metadata is validated by the Control Plane itself rather than the schema. The schema is versioned independently of apiVersion: its $id is https://synaptagrid.io/schemas/app-manifest/1.34.json (title Synaptagrid App Manifest (1.34)), while apiVersion stays platform.synaptagrid.io/v1.

Validation happens in the same code twice: the egav-app-sdk CLI validates locally against the shipped schema, and the Control Plane re-runs that exact SDK validation on publish before it fans your sections out to AuthZ, notifications, the scheduler, and the event bus. There is one source of truth for what a valid manifest is.

layout.txttext
your-service/
  backend/
    app.manifest.yaml   <-- the contract
    app/
      ...

# Canonical schema (the contract this file is validated against):
#   egav-app-sdk/egav_app_sdk/manifest/schema/app-manifest.schema.json
#   $id:   https://synaptagrid.io/schemas/app-manifest/1.34.json
#   Draft: JSON Schema 2020-12, additionalProperties:false (a few objects deliberately open)

The envelope: apiVersion, kind, metadata, spec

Four keys are required at the root: apiVersion, kind, metadata, spec.

  • apiVersion is the constant platform.synaptagrid.io/v1.
  • kind is the constant Application.
  • metadata requires name, display_name, version, and publisher. name is the machine code — unique across the platform, matching ^[a-z][a-z0-9-]*$. version is semver. publisher matches ^[a-z][a-z0-9-]*$ and references the CP publishers table. Everything else in metadata (tagline, categories, tags, release_status, icon/logo URLs, and more) is optional catalogue detail. Pricing is not set in the manifest: an operator assembles plans and prices in the Control Plane (see plans).
  • spec holds the functional declarations. Its only required field is features — every other section is optional and adds a capability.

Here is a minimal, schema-valid envelope (abridged from a real service manifest):

app.manifest.yamlyaml
apiVersion: platform.synaptagrid.io/v1   # const
kind: Application                         # const
metadata:
  name: notifications          # ^[a-z][a-z0-9-]*$  — the machine code
  display_name: Notifications
  version: 1.0.4               # semver
  owner: platform-team
  publisher: getek            # ^[a-z][a-z0-9-]*$
  release_status: ga
spec:
  features:                    # the ONLY required spec section
  - code: notifications.channels.email.enabled
    name: Email channel
    description: Enable email notifications for the org.
    ui_hint: Toggle ON to allow email sends.
    type: boolean
    default: true

spec.features — plan features and quotas

spec.features is the one required spec section. It defines the entitlements your app exposes; it does not set their per-plan values — those are bound in the Control Plane when an operator assembles a plan. Each feature declares code, name, description, ui_hint, and type (boolean, integer, or string), plus an optional unit, default, marketing labels, and show_on_pricing (see plans).

Unlocks: feature gates. Once published, a feature code can gate a route or a call at runtime (a disabled feature returns 403, an exceeded quota returns 429).

Only type: integer features may carry a quota block, which tells CP how to aggregate reported usage: source is cumulative, daily, or per_request. An optional policy (allow, notify, block, overage, suspend; default notify) sets the platform-default behaviour at the limit — overridable per plan and per org.

features.yamlyaml
spec:
  features:
  # Boolean feature — a simple on/off entitlement
  - code: notifications.channels.slack.enabled
    name: Slack channel
    description: Enable Slack webhook delivery.
    ui_hint: Toggle ON to allow Slack sends.
    type: boolean
    default: false

  # Integer feature with a metered quota (only integer features may carry `quota`)
  - code: notifications.send_per_day
    name: Daily send quota
    description: Maximum notifications (all channels combined) sent per day per org.
    ui_hint: Cap on daily notification sends.
    type: integer
    unit: sends
    default: 1000
    quota:
      source: daily        # cumulative | daily | per_request

Authorization: resource_types, permissions, and roles

Authorization is not one authz: block — it spreads across several top-level spec keys so each trust tier stays separate:

  • spec.cp_permissions / spec.cp_roles — operator-tier codes that gate /cp/* routes.
  • spec.portal_permissions / spec.portal_roles — tenant-tier codes that gate /portal/* routes.
  • spec.resource_types — ReBAC resource types. Each carries its own actions[] and instance-scoped instance_roles[] (for example, the owner of a comment can update and delete it). Optional spec.role_derivations and spec.permission_catalog add relation-based grants and display metadata.

Unlocks: on publish, CP writes these into a new version of the AuthZ catalog — resource types, actions, permissions and roles — which every organization reads, so RequireAuthz(resource, action) checks resolve immediately.

Cross-field validation enforces scope: a role's permissions[] must resolve within its own list (cp_roles against cp_permissions, portal_roles against portal_permissions). Glob entries like *.read are allowed, but a glob that matches nothing is an error.

authz.yamlyaml
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
  # paired with spec.cp_permissions / spec.cp_roles (operator tier)
  # and     spec.portal_permissions / spec.portal_roles (tenant tier)

spec.events and spec.kafka — the event contract

Your app's event contract lives under spec.events (with emits[] and consumes[]) plus spec.kafka for topic ownership. Every platform event is serialized as a CloudEvents v1.0 envelope on the wire; the manifest is where you declare which events exist.

Each emit requires id, topic, transport (outbox or platform_events, no default), schema_ref, version, and audience. audience is the ADR-009 routing scope and has no fallback — you must pick tenant-private, org-broadcast, or platform-broadcast so the dispatcher knows how to fan out. webhook_subscribable: true exposes the event to customer webhook subscriptions, and emit_policy: subscribed makes it listener-gated — the event is produced only when a listener or webhook is registered, so integration events stay dormant until an integrator subscribes.

Each consume requires id, topic, and schema_ref, and typically names the producer plus either a handler_consumer_group (in-process Kafka consumer) or a handler_endpoint under /v1/<service>/internal/* (HTTP-dispatched). The /events/ segment some services use is a naming convention, not a schema rule — the schema only requires the /v1/{service}/internal/ prefix.

Unlocks: topic provisioning, webhook subscribability, and delivery routing. Validation enforces that every emitted topic matches one of spec.kafka.owned_topic_patterns, and CP rejects manifests whose owned patterns overlap another app's.

events.yamlyaml
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        # must match an owned_topic_patterns entry
      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         # REQUIRED — no fallback (ADR-009)
      webhook_subscribable: true
      emit_policy: subscribed          # listener-gated
consumes.yamlyaml
spec:
  events:
    consumes:
    - id: comments.mention.created
      topic: comments.mention.created
      schema_ref: manifest-schemas/events/comments-mention.v1.json
      version: 1
      producer: comments                        # must resolve to comments' emits[].id
      handler_consumer_group: notifications-consumer-group

spec.audit — the audit catalogue

spec.audit.event_types[] is the declarative catalogue of every audit action your app emits, so a SOC-2 reviewer can see the full vocabulary at publish time. Each entry requires a dotted code (matching ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$) and a description, plus optional actor_type (user, system, or either) and metadata_keys[] documenting the keys the event may carry.

Unlocks: a published, reviewable audit vocabulary. The code here is the exact action your service passes to the audit client, so the catalogue and the emit sites stay aligned.

audit.yamlyaml
spec:
  audit:
    event_types:
    - code: automation.task.assigned
      description: A user / role / group was added as a subject of a HITL task at task creation.
      actor_type: system
      metadata_keys:
      - task_guid
      - subject_user_guid
      - relation_code
    - code: automation.task.claimed
      description: A user took the active claim on a HITL task.
      actor_type: user
      metadata_keys:
      - task_guid
      - lease_seconds

spec.gdpr and spec.compliance — privacy touch points

spec.gdpr declares the internal endpoints the Control Plane calls during its cascade workflow: export, erasure, and consent. Every GDPR endpoint must sit on the internal auth tier and match ^/v1/{app_code}/internal/. erasure requires both an endpoint and a mode (cascade, anonymize, or hard_delete); set async: true with a status_endpoint for long-running deletes.

spec.compliance classifies your data — data_classification, retention_days, and the personal-data fields (a legacy pii_fields list, or the typed Fides dataset).

Unlocks: participation in unified data export and right-to-erasure. This is enforced: if spec.compliance classifies personal data, validation fails unless spec.gdpr.erasure is declared, and any Fides category must be a known one.

gdpr-compliance.yamlyaml
spec:
  compliance:
    data_classification: confidential
    retention_days:
      user_pii: 365
      audit_logs: 730
      soft_deleted_records: 30
  gdpr:
    export:
      endpoint: /v1/notifications/internal/gdpr/export    # ^/v1/{app_code}/internal/
    erasure:
      endpoint: /v1/notifications/internal/gdpr/erase
      mode: cascade                                        # cascade | anonymize | hard_delete
      async: true
      status_endpoint: /v1/notifications/internal/gdpr/erase/{request_id}

spec.tenancy — data layer and isolation

spec.tenancy declares how your app's data is isolated. storage is required and is one of per_instance_db, per_org_db or shared_with_filter. Where each table of your app lives, and how your code reaches it, is covered in Data placement. The optional options[] list enumerates per-component isolation upgrades (db, redis, kafka, and more) — each with a code, a provisioning method, and an optional default: true.

Unlocks: what cp-fleet provisions per (instance, region) when a customer accepts a plan — from a shared pool reference to a dedicated cluster.

tenancy.yamlyaml
spec:
  tenancy:
    storage: per_instance_db             # required: per_instance_db | per_org_db | shared_with_filter
    options:
    - component: db
      code: shared_db
      method: shared_pool_ref
      default: true
    - component: db
      code: dedicated_db
      method: pulumi_provision_dedicated_db_cluster
    - component: redis
      code: dedicated_redis
      method: pulumi_provision_dedicated_redis

spec.config vs spec.required_env

Two different mechanisms, often confused:

  • spec.config declares operator-tunable settings as tabs → sections → groups → fields. This drives the Control Plane Settings UI, and it replaced the old standalone <service>.manifest.yaml. New operator settings — feature flags, TTLs, retry counts, rotation policies — belong here, not in raw environment variables.
  • spec.required_env declares the environment variables the Control Plane injects at provision time (each mapped to a source such as cp, db, redis, kafka, secret, or static). Missing required entries fail provisioning loudly.

Unlocks: a typed, operator-facing settings surface (spec.config) and correct runtime wiring at deploy (spec.required_env).

required-env.yamlyaml
spec:
  required_env:
  - { name: APP_API_KEY, source: secret, source_field: inbound_api_key, secret: true }
  - { name: CONTROL_PLANE_BASE_URL, source: cp, source_field: base_url }
  - { name: CONTROL_PLANE_API_KEY, source: cp, source_field: api_key, secret: true }
  - { name: DATABASE_URI, source: db, source_field: uri, secret: true }
  - { name: KAFKA_BOOTSTRAP_SERVERS, source: kafka, source_field: bootstrap_servers }
  # operator-tunable settings go under spec.config (tabs/sections/groups/fields),
  # NOT here — required_env is for provision-time wiring only.

Validation is two-pass

validate_manifest() runs two passes, and the second only runs if the first is clean:

  1. JSON Schema (Draft 2020-12) against the shipped app-manifest.schema.json. Because additionalProperties: false is set on nearly every object, unknown keys fail here (the few deliberately-open objects, like spec.compute and config fields, are the exception).
  2. Cross-field checks the schema cannot express, including:

- each role's permissions[] resolves within its own scope list (glob supported; a zero-match glob is an error); - every emitted topic matches a spec.kafka.owned_topic_patterns entry (and owned_topic_patterns must be non-empty when emits[] is present); - notification template (code, channel) pairs are unique; - spec.compliance personal data must declare a spec.gdpr.erasure path, and every Fides category must be known; - an action's required_permission and gated_by_feature must reference declared codes.

The Control Plane re-runs this same validate_manifest() on publish (through its validate_manifest_yaml wrapper), so what passes locally is what CP accepts on publish.

A service also registers its manifest at startup, and that check is stricter. A service built on egav_base sends its manifest to CP's config registration when it starts. That registration refuses two manifests the local validator accepts, and the service then starts without its settings (manifest.pre_config_publish_failed in its log):

  • spec.config.tabs is required. Declare at least one tab. The egav-template scaffold ships networking (CORS) and infrastructure (database pool, rate limits) tabs; keep them when you start from it.
  • metadata.version must be 1.0.0 or later. The config version is the major number of metadata.version, and it must be at least 1, so 0.x.y is rejected (version: 0 is less than the minimum of 1). Start at 1.0.0 and bump from there.
validator.pypython
SCHEMA_PACKAGE = "egav_app_sdk.manifest.schema"
SCHEMA_FILENAME = "app-manifest.schema.json"

def validate_manifest(data: dict[str, Any]) -> list[str]:
    errors = _schema_errors(data)          # pass 1: JSON Schema (Draft 2020-12)
    if not errors:
        errors.extend(_cross_field_errors(data))   # pass 2: cross-field rules
    return errors

Validate and publish

Installing egav-app-sdk also installs its CLI. Validate locally against the shipped schema, then publish to the Control Plane:

  • egav-app-sdk manifest validate <path> runs both passes against the bundled schema — no network needed.
  • egav-app-sdk manifest publish <path> --app-code <code> reads EGAV_CP_URL and EGAV_CP_API_KEY from the environment and POSTs the manifest (with event schemas inlined) to POST {cp}/v1/control-plane/internal/applications/{app_code}/manifest, authenticated with x-api-key.

On publish, CP re-validates and then syncs your sections outward — AuthZ resources and roles, plan features, the audit catalogue, notification templates and layouts, scheduled tasks, and the event registry.

publish.shbash
# 1) Validate locally (offline, against the bundled JSON Schema)
egav-app-sdk manifest validate backend/app.manifest.yaml

# 2) Publish to the Control Plane
export EGAV_CP_URL=https://cp.your-domain.example
export EGAV_CP_API_KEY=control-plane-api-key
egav-app-sdk manifest publish backend/app.manifest.yaml --app-code notifications
# -> POST {cp}/v1/control-plane/internal/applications/{app_code}/manifest  (x-api-key)

Every spec section at a glance

The full set of spec sections in the 1.34 schema. Only features is required; each other section adds a capability when present. Every domain below has a dedicated deep-dive page — this table is the map.

Section(s)Declares / unlocksFull page
features, productsPlan entitlements, metered quotas, one-off SKUsPlans, features & quotas
cp_permissions/cp_roles, portal_permissions/portal_roles, resource_types, role_derivations, permission_catalogRBAC + ReBAC authorization, synced into AuthZIdentity & access
events, kafkaEmitted/consumed events + topic ownershipEvents & webhooks
config, config_schemaOperator Settings UI + boot config keysSettings & config
lifecycle_hooksProvision / deprovision / plan-change / config-reload / upgrade hooksLifecycle hooks
scheduled_tasksCron jobs synced into the CP schedulerScheduled tasks
notification_templates, notification_layoutsTemplates + re-brandable email layoutsNotifications
auditDeclarative audit-event catalogueAudit
gdpr, complianceExport / erasure / consent + data classificationGDPR & compliance
tenancy, deployment, compute, infrastructure, required_env, health, status, depends_onStorage/isolation, topology, sizing, env wiring, healthTenancy & deployment
aiProviders, agents, tools, skills, corpora, memory, evals, budgetsAI surface
apis, workflow_categoriesDeclared internal API actions; workflow buckets— (this page)

One caution when reading platform code: the top-level manifests under egav-control-plane/backend/manifests/infra/* share this same apiVersion/kind shape but describe platform infrastructure components, not tenant apps — the per-service contract is always {service}/backend/app.manifest.yaml.

spec-required.jsonjson
// $defs.Spec
{
  "type": "object",
  "additionalProperties": false,
  "required": ["features"]   // the only mandatory spec section
}