{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://manaurum.com/standards/manifest_v2.schema.json",
  "title": "Manaurum Application Manifest v2",
  "description": "Canonical schema for Manaurum tenant application manifests, version 2 (Platform v2). Built up test-first; each constraint added in response to a failing test. See backend/app/services/manifest_v2_validator.py + tests/services/test_manifest_v2_validator.py.",
  "type": "object",
  "required": [
    "manifest_version",
    "manaurum_sdk_version",
    "app_id",
    "name",
    "version",
    "runtime"
  ],
  "additionalProperties": false,
  "properties": {
    "manifest_version": {
      "const": "2",
      "description": "Manifest schema version. Pinned at \"2\" for the lifetime of v2."
    },
    "manaurum_sdk_version": {
      "const": "2",
      "description": "Major SDK version the application is built against."
    },
    "app_id": {
      "type": "string",
      "minLength": 1,
      "description": "Globally unique application identifier (see \u0412\u00a74.1)."
    },
    "name": {
      "type": "string",
      "minLength": 1,
      "description": "Human-readable name shown in shell UI."
    },
    "version": {
      "type": "string",
      "pattern": "^\\d+\\.\\d+\\.\\d+$",
      "description": "Semver MAJOR.MINOR.PATCH (no pre-release/build metadata)."
    },
    "runtime": {
      "type": "object",
      "description": "Hosting mode + mode-specific config. See \u0412\u00a76.",
      "required": [
        "mode"
      ],
      "properties": {
        "mode": {
          "enum": [
            "hosted",
            "byo",
            "dev"
          ],
          "description": "Hosting mode. One of hosted | byo | dev."
        },
        "api_routes": {
          "type": "array",
          "description": "API-route declaration table consumed by the Core gateway proxy. Each item declares one path glob + the auth mode the gateway must apply when proxying requests that match it. Default-deny per Q-02: a route that is not declared here is not served (gateway returns 404). Static / non-API routes (HTML, JS, CSS, /healthz) are NOT declared here \u0432\u0402\u201d they are always anonymous on `<slug>.apps.manaurum.com`. See CONTRACTS.md \u0412\u00a77.2.",
          "items": {
            "type": "object",
            "required": [
              "path",
              "auth"
            ],
            "additionalProperties": false,
            "properties": {
              "path": {
                "type": "string",
                "pattern": "^/",
                "description": "Path glob the Core gateway matches inbound requests against. Must start with `/`. Supports trailing `*` wildcard (e.g. `/api/orders/*`). Exact paths (e.g. `/api/kiosk/menu`) match only that path."
              },
              "auth": {
                "enum": [
                  "user",
                  "anonymous"
                ],
                "description": "How the Core gateway authenticates requests matching `path`. `user`: gateway mints a 60s user_context JWT signed by Core and injects it as `X-Manaurum-User-Context`; the container verifies via Core public key. The user bearer is NEVER forwarded to the container. `anonymous`: gateway proxies the request with no user_context (kiosk/public endpoints \u0432\u0402\u201d explicit declaration required, no implicit anonymous fallback)."
              }
            }
          }
        }
      }
    },
    "data": {
      "type": "object",
      "description": "Storage-mode declaration (CONTRACTS section 6 / V2_DEVELOPER_GUIDE section 6). Omit this block to get the default managed mode: the deploy pipeline provisions one Postgres schema + a scoped login role per (app, tenant) and injects DATABASE_URL into the container. Set `none` or `byo` to opt OUT of the managed schema so the deploy needs no DDL-capable DSN (MANAURUM_DDL_DSN) on Core. At most one of none/byo/shared should be set; if more than one is true the pipeline treats the app as not needing a managed schema.",
      "additionalProperties": false,
      "properties": {
        "none": {
          "type": "boolean",
          "description": "Stateless app: no managed Postgres schema is provisioned and no DATABASE_URL is injected. Persist via the os.files / os.kv capabilities only. The deploy skips CREATE SCHEMA / CREATE ROLE, so no MANAURUM_DDL_DSN is required on Core to deploy this app."
        },
        "byo": {
          "type": "boolean",
          "description": "Bring-your-own database: the platform provisions no managed schema and guarantees nothing about tenant isolation. The developer supplies and scopes their own connection string. Like `none`, the deploy skips schema/role provisioning and needs no MANAURUM_DDL_DSN."
        },
        "shared": {
          "type": "boolean",
          "description": "Single shared schema across all tenants with a tenant_id column the developer filters on every query. Use only for genuine cross-tenant analytics; tenant admins see an isolation warning at install. Still a managed schema, so MANAURUM_DDL_DSN is required at deploy."
        },
        "connection_cap": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "description": "Max Postgres connections per app process for the managed schema. Default 20; values above 20 require operator justification (CONTRACTS section 10.1 / GUIDE section 6)."
        }
      }
    },
    "migrate_command": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Migration command argv invoked by the deploy pipeline once per (app, tenant). See \u0412\u00a710.3."
    },
    "migration": {
      "type": "object",
      "description": "Migration metadata. ``breaking: true`` enters the per-tenant approval workflow per \u0412\u00a710.3.1.",
      "properties": {
        "breaking": {
          "type": "boolean"
        },
        "reason": {
          "type": "string"
        },
        "rollback_strategy": {
          "type": "string"
        }
      }
    },
    "visibility": {
      "type": "object",
      "description": "Publication scope (\u0412\u00a75.10). Default if omitted: {mode: private}.",
      "properties": {
        "mode": {
          "enum": [
            "private",
            "public",
            "allow_list"
          ]
        },
        "tenants": {
          "type": "array",
          "items": {
            "type": "string"
          }
        }
      }
    },
    "platforms": {
      "type": "object",
      "description": "Platform support declarations. Both desktop and mobile should be explicitly declared.",
      "properties": {
        "desktop": {
          "type": "object",
          "properties": {
            "supported": { "type": "boolean", "default": true, "description": "Whether the app supports desktop." }
          }
        },
        "mobile": {
          "type": "object",
          "properties": {
            "supported": { "type": "boolean", "default": false, "description": "Whether the app supports mobile." },
            "optimized": { "type": "boolean", "default": false, "description": "Whether the app has mobile-optimized UI." },
            "entrypoint": { "type": "string", "format": "uri", "description": "Optional separate HTTPS entrypoint for mobile." },
            "supportLevel": {
              "type": "string",
              "enum": ["full", "adaptive", "fallback", "none"],
              "default": "none",
              "description": "full = dedicated mobile UI, adaptive = responsive, fallback = desktop UI with warning, none = blocked."
            },
            "navigationPattern": {
              "type": "string",
              "enum": ["stack", "tabs", "list-detail", "single-view", "composer-first"],
              "description": "Primary navigation pattern the app uses on mobile."
            }
          }
        }
      }
    },
    "frontend": {
      "type": "object",
      "description": "Frontend bundle / entry-point declaration. See \u0412\u00a75.4.",
      "properties": {
        "entry_point": {
          "type": "string"
        },
        "bundle_path": {
          "type": "string"
        },
        "icon": {
          "type": "string"
        },
        "window": {
          "type": "object",
          "properties": {
            "default_width": {
              "type": "integer",
              "minimum": 320
            },
            "default_height": {
              "type": "integer",
              "minimum": 240
            }
          }
        }
      }
    },
    "requires_capabilities": {
      "type": "array",
      "description": "Capabilities the app needs from Core. See \u0412\u00a75.5.",
      "items": {
        "type": "object",
        "required": [
          "name",
          "version"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "version": {
            "type": "string"
          },
          "quota_per_tenant_per_day": {
            "type": "object"
          }
        }
      }
    },
    "optional_capabilities": {
      "type": "array",
      "description": "Capabilities the app can use if the installer grants them, but does not require to function. App Store v2 reads this list to compute the optional grant set the tenant admin sees at install time. Shape mirrors requires_capabilities so A-1 can extract both arrays with the same accessor. See \u0412\u00a75.5.",
      "items": {
        "type": "object",
        "required": [
          "name",
          "version"
        ],
        "properties": {
          "name": {
            "type": "string"
          },
          "version": {
            "type": "string"
          },
          "quota_per_tenant_per_day": {
            "type": "object"
          }
        }
      }
    },
    "agent_capabilities": {
      "type": "array",
      "description": "Agent tools this app exposes to the OS Assistant (MAN-454). Each entry registers one capability the assistant can call to read from or write to the app on the user's behalf. On deploy, Core upserts one agent_capabilities row per entry; at request time the runtime builds an agent tool per row (for apps the user has installed) and dispatches calls server-to-server to the app's container via an implicit `/agent/<name>` route (auth: user). See docs/handoff/AGENT_TOOLS_INTEGRATION.md (Path C).",
      "items": {
        "type": "object",
        "required": [
          "name",
          "description",
          "input_schema"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "pattern": "^[a-z][a-z0-9_]*$",
            "description": "Capability identifier \u2014 snake_case verb_noun (e.g. `add_diary_entry`). Lowercase letters / digits / underscore, must start with a letter. Used to build the agent tool name and the implicit `/agent/<name>` route the gateway proxies to your container."
          },
          "description": {
            "type": "string",
            "minLength": 1,
            "maxLength": 400,
            "description": "What the capability does, written for the LLM (include positive + negative usage examples). Hard-capped at 400 chars to match the agent runtime's Tool validator."
          },
          "input_schema": {
            "type": "object",
            "description": "JSON Schema for the capability input. Must be `type: object`. Validated against the model's tool-call arguments at dispatch time."
          },
          "is_write": {
            "type": "boolean",
            "description": "True if the capability mutates tenant state. Triggers AgentAction row creation, idempotency dedup, and post-turn notification events. Default false."
          },
          "routing_hints": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Optional keyword hints to help the assistant decide when to use this capability. Informational."
          },
          "example": {
            "type": "object",
            "description": "Optional example input payload, surfaced to the model as a usage hint."
          }
        }
      }
    },
    "provides": {
      "type": "object",
      "description": "Inter-app contracts the app exposes. See \u0412\u00a75.6.",
      "properties": {
        "rpc": {
          "type": "array"
        },
        "events": {
          "type": "array"
        }
      }
    },
    "consumes": {
      "type": "object",
      "description": "Inter-app contracts the app depends on. See \u0412\u00a75.6.",
      "properties": {
        "rpc": {
          "type": "array"
        },
        "events": {
          "type": "array"
        }
      }
    },
    "webhooks": {
      "type": "array",
      "description": "Inbound webhooks from external systems. NOTE (v2.x): platform webhook gateway is DEFERRED \u2014 this field is parsed and validated for shape but Core does NOT yet expose `POST /apps/<app_id>/webhooks/<name>` and does NOT forward to the app's `path`. For v2.x, expose your own public webhook handler from the v2 app container (declared via `runtime.api_routes` with `auth: \"anonymous\"`) and verify the signature inside the app. The platform-gateway behaviour described in CONTRACTS.md \u00a75.7 + \u00a711.1 lands in v2.next once the per-tenant URL routing strategy is finalised; manifests authored today are forward-compatible.",
      "items": {
        "type": "object",
        "required": [
          "name",
          "path",
          "signature"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "pattern": "^[a-z][a-z0-9_-]*$",
            "description": "Webhook identifier \u0432\u0402\u201d used in the public Core URL `/apps/<app_id>/webhooks/<name>`. Lowercase letters / digits / underscore / hyphen, must start with a letter."
          },
          "path": {
            "type": "string",
            "pattern": "^/",
            "description": "App-side handler path the gateway forwards verified webhook payloads to. Must start with `/`. The gateway calls the container with `Caller-System: webhook-gateway` per CONTRACTS \u0412\u00a77.6."
          },
          "signature": {
            "type": "object",
            "required": [
              "method",
              "header",
              "secret_ref"
            ],
            "additionalProperties": false,
            "description": "Required signature-verification block. Anonymous webhooks are not supported \u0432\u0402\u201d every external system that posts to a Manaurum webhook URL must sign the payload.",
            "properties": {
              "method": {
                "enum": [
                  "hmac-sha256"
                ],
                "description": "Signature algorithm. v2 supports `hmac-sha256` only; additional algorithms require a contract change."
              },
              "header": {
                "type": "string",
                "minLength": 1,
                "description": "HTTP header name the upstream system uses to carry the signature (e.g. `X-Iiko-Signature`)."
              },
              "secret_ref": {
                "type": "string",
                "minLength": 1,
                "description": "Name of the per-(app, tenant) secret (set via `os.secrets.set` / `manaurum app set-secret`) holding the shared key. The gateway reads it through `os.secrets.get` at verification time; the app never sees it."
              }
            }
          }
        }
      }
    },
    "schedules": {
      "type": "array",
      "description": "Cron schedules. NOTE (v2.x): platform cron is DEFERRED \u2014 this field is parsed and validated for shape but Core does NOT yet invoke the handler. For v2.x, run an in-container scheduler (APScheduler / cron-style library) and reference this field as documentation of intended schedules. The platform-cron behaviour described in CONTRACTS.md \u00a75.8 + \u00a711.2 lands in v2.next; manifests authored today will forward-compatible.",
      "items": {
        "type": "object",
        "required": [
          "name",
          "cron",
          "handler_path"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "pattern": "^[a-z][a-z0-9_-]*$",
            "description": "Schedule identifier \u0432\u0402\u201d used in the Core URL `/apps/<app_id>/cron/<name>`. Lowercase letters / digits / underscore / hyphen, must start with a letter."
          },
          "cron": {
            "type": "string",
            "minLength": 9,
            "description": "Standard 5-field cron expression (`minute hour dom month dow`). Validated for syntactic shape at deploy time."
          },
          "timezone": {
            "type": "string",
            "minLength": 1,
            "description": "IANA timezone name (e.g. `Europe/Moscow`). Defaults to `UTC` when omitted."
          },
          "handler_path": {
            "type": "string",
            "pattern": "^/",
            "description": "App-side handler path the scheduler POSTs to. Must start with `/`. Called with `Caller-System: cron-scheduler` and the body carries `tenant_id` per CONTRACTS \u0412\u00a77.6."
          },
          "timeout_seconds": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3600,
            "description": "Per-firing handler timeout. Core kills the request past this; the scheduler records the firing as failed but does not retry. Default 60s when omitted; max 1h."
          }
        }
      }
    },
    "tenant_config": {
      "type": "object",
      "description": "Per-tenant configuration. See \u0412\u00a75.9.",
      "properties": {
        "schema": {
          "type": "string"
        },
        "required_at_install": {
          "type": "boolean"
        }
      }
    },
    "metadata": {
      "type": "object",
      "description": "Informational metadata. See \u0412\u00a75.11.",
      "properties": {
        "category": {
          "type": "string"
        },
        "tags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "description": {
          "type": "string"
        },
        "homepage": {
          "type": "string"
        },
        "support_email": {
          "type": "string"
        },
        "source_url": {
          "type": "string"
        }
      }
    },
    "offline": {
      "type": "object",
      "additionalProperties": false,
      "description": "Offline-capability declaration (Manaurum Edge). Features that stay live during a WAN outage, cloud-owned reference data replicated read-only to the on-site box, and append-only streams the site owns. See docs/superpowers/specs/2026-05-30-offline-usability-design.md.",
      "properties": {
        "features": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Operation identifiers that remain usable while the WAN is down; everything else greys out via app.offline.can()."
        },
        "reference_data": {
          "type": "array",
          "items": { "type": "string" },
          "description": "Cloud-owned datasets replicated read-only to the box (never edited offline)."
        },
        "streams": {
          "type": "array",
          "description": "Append-only event streams the site owns offline.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": ["name", "type"],
            "properties": {
              "name": { "type": "string" },
              "type": {
                "enum": ["ledger", "state"],
                "description": "ledger = pure append (every event kept; order-independent). state = latest-event-per-key wins on fold."
              },
              "key": {
                "type": "string",
                "description": "Entity key for state streams (latest event per key wins on reconnect)."
              },
              "entity_type": {
                "type": "string",
                "description": "Optional. The app_records entity_type this state stream's key references; when set, the cloud verifies on reconnect that the referenced entity still exists and records a deleted_entity_ref anomaly (dropping the event) if it was deleted. Only meaningful for type:\"state\"; omit it to skip the check (current behaviour)."
              },
              "conflict_policy": {
                "enum": ["grey_out", "optimistic_reconcile"],
                "description": "Resolution for ops touching a cross-partition shared pool: grey_out (disabled offline) or optimistic_reconcile (sell optimistically, resolve on reconnect)."
              }
            }
          }
        }
      }
    }
  }
}