{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Apps API",
    "version": "2026-09-17",
    "description": "Owner-scoped app hosting; every route requires sign-in and app.deploy. Publishing configuration/operations are opt-in behind APP_PUBLISH_CONFIG_READ / APP_PUBLISH_OPERATIONS. Live mutations additionally require APP_DEPLOY_LIVE=1, APP_PUBLISH_PILOT=1 and the target in APP_PUBLISH_TARGET_IDS; operator onboarding and a pinned target-local policy image are prerequisites. Production checkpoint 2026-09-18: platform deployed and managed pilot enabled for existing Apps target 4, with six inventoried apps; custom-host ownership TXT proof and first managed route adoption remained pending. This dated checkpoint is not general availability: discover capabilities and each owned app's configuration at runtime. Existing apps need verified inventory; unknown legacy policy is never inferred public. Domain/policy operations do not build, create releases or restart application containers; env PATCH does restart the current release. Accepted intent and validated immutable source survive client disconnects. Prefer: respond-async opts deploy/rollback into 202 operations; old clients retain the final 200/502 envelope. App env, hashes, SSH commands and arbitrary remote logs are never returned. A partial container update can affect runtime even when routing is restored; code rollback does NOT undo migrations. Apps use the UI, repository CLI or REST API, not MCP tools. APP_ACME_EMAIL does not enable renewal alerts; monitoring and email delivery require separate configuration and acceptance."
  },
  "servers": [
    {
      "url": "https://www.pitchstation.ai"
    }
  ],
  "security": [
    {
      "pat": []
    }
  ],
  "components": {
    "securitySchemes": {
      "pat": {
        "type": "http",
        "scheme": "bearer",
        "description": "Personal Access Token (pst_…) whose user holds the app.deploy capability"
      }
    },
    "schemas": {
      "App": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "slug": {
            "type": "string",
            "description": "DNS-safe name; the app serves at <slug>.<target base domain>"
          },
          "name": {
            "type": "string"
          },
          "targetId": {
            "type": "integer"
          },
          "webService": {
            "type": "string",
            "description": "The compose service that receives traffic"
          },
          "containerPort": {
            "type": "integer"
          },
          "hostPort": {
            "type": "integer",
            "description": "Allocated by the platform, permanent for the app"
          },
          "healthPath": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "draft",
              "deploying",
              "live",
              "failed",
              "stopped"
            ]
          },
          "currentRelease": {
            "type": "integer",
            "nullable": true
          },
          "envKeys": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Names of stored env keys — values are never returned"
          },
          "migrateConfigured": {
            "type": "boolean",
            "description": "Whether a command is configured; command text is redacted."
          },
          "publishing": {
            "$ref": "#/components/schemas/PublishingConfig"
          }
        }
      },
      "PlanStep": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "where": {
            "type": "string"
          },
          "display": {
            "type": "string"
          },
          "secret": {
            "type": "boolean",
            "description": "Step carries secret material; its command is never logged"
          }
        }
      },
      "PublicationPolicy": {
        "type": "object",
        "properties": {
          "access": {
            "type": "string",
            "enum": [
              "public",
              "private",
              "unknown"
            ]
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "eventAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "applicationPolicy": {
            "type": "string",
            "enum": [
              "unknown",
              "none",
              "restricted"
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "unknown",
              "platform",
              "mixed"
            ]
          },
          "expired": {
            "type": "boolean"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "PolicyPatch": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "access": {
            "type": "string",
            "enum": [
              "public",
              "private"
            ]
          },
          "password": {
            "type": "string",
            "writeOnly": true,
            "description": "12–72 UTF-8 bytes. Omission preserves an existing gate. Explicit public removes only the platform gate. Never returned; retain separately."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "eventAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "applicationPolicy": {
            "type": "string",
            "enum": [
              "unknown",
              "none",
              "restricted"
            ]
          }
        }
      },
      "ExpectedContent": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "marker"
        ],
        "properties": {
          "marker": {
            "type": "string",
            "minLength": 12,
            "maxLength": 256
          },
          "assets": {
            "type": "array",
            "maxItems": 16,
            "items": {
              "type": "string",
              "description": "Same-app absolute path; no URL, query, fragment or traversal."
            }
          }
        }
      },
      "Domains": {
        "type": "object",
        "properties": {
          "primary": {
            "type": "string",
            "example": "example.com"
          },
          "aliases": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "hostname",
                "mode"
              ],
              "properties": {
                "hostname": {
                  "type": "string"
                },
                "mode": {
                  "type": "string",
                  "enum": [
                    "serve",
                    "redirect"
                  ]
                }
              }
            }
          },
          "platform": {
            "type": "string",
            "enum": [
              "retain",
              "redirect",
              "disabled"
            ]
          },
          "detach": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Explicit detach only; omitted existing hosts remain attached. Claims release only after confirmed owned-route removal."
          },
          "expected": {
            "$ref": "#/components/schemas/ExpectedContent"
          }
        }
      },
      "PublishingConfig": {
        "type": "object",
        "properties": {
          "appId": {
            "type": "integer"
          },
          "revision": {
            "type": "integer"
          },
          "appliedRevision": {
            "type": "integer"
          },
          "etag": {
            "type": "string"
          },
          "provenance": {
            "type": "string",
            "enum": [
              "new",
              "inventory_verified",
              "unknown"
            ]
          },
          "domains": {
            "$ref": "#/components/schemas/Domains"
          },
          "policy": {
            "$ref": "#/components/schemas/PublicationPolicy"
          },
          "envRevision": {
            "type": "integer"
          },
          "envAppliedRevision": {
            "type": "integer"
          },
          "expected": {
            "$ref": "#/components/schemas/ExpectedContent"
          }
        },
        "description": "Desired and applied state are separate; env applied state is independently encrypted. A legacy app without inventory returns status:inventory_required instead."
      },
      "PublishingError": {
        "type": "object",
        "required": [
          "code",
          "message",
          "retryable",
          "actor"
        ],
        "properties": {
          "code": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "nextAction": {
            "type": "string"
          },
          "retryable": {
            "type": "boolean"
          },
          "actor": {
            "type": "string",
            "enum": [
              "publisher",
              "operator"
            ]
          },
          "appId": {
            "type": "integer"
          },
          "operationId": {
            "type": "string"
          },
          "currentRevision": {
            "type": "integer"
          },
          "at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Operation": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "appId": {
            "type": "integer"
          },
          "action": {
            "type": "string",
            "enum": [
              "deploy",
              "rollback",
              "domains",
              "policy",
              "env"
            ]
          },
          "revision": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "waiting_external",
              "cancel_requested",
              "cancelled",
              "succeeded",
              "failed"
            ]
          },
          "phase": {
            "type": "string"
          },
          "statusUrl": {
            "type": "string"
          },
          "attempt": {
            "type": "integer"
          },
          "nextRunAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "cleanupState": {
            "type": "string"
          },
          "error": {
            "$ref": "#/components/schemas/PublishingError"
          },
          "result": {
            "type": "object",
            "description": "Redacted effective state / readiness / recovery; never commands, payloads or secrets."
          },
          "steps": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "index": {
                  "type": "integer"
                },
                "name": {
                  "type": "string"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "running",
                    "succeeded",
                    "failed",
                    "compensated"
                  ]
                },
                "startedAt": {
                  "type": "string",
                  "format": "date-time"
                },
                "finishedAt": {
                  "type": "string",
                  "format": "date-time",
                  "nullable": true
                },
                "error": {
                  "$ref": "#/components/schemas/PublishingError"
                }
              }
            }
          }
        }
      }
    },
    "parameters": {
      "AppId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      },
      "IfMatch": {
        "name": "If-Match",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Exact configuration ETag, e.g. \"app-7-v3\". Missing:428, stale:409; same-key accepted retries bypass CAS."
      },
      "Idempotency": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_.-]{8,128}$"
        },
        "description": "Required for configuration/env apply and async deploy/rollback; same key/body returns one operation, changed body returns409."
      },
      "Prefer": {
        "name": "Prefer",
        "in": "header",
        "schema": {
          "type": "string",
          "enum": [
            "respond-async"
          ]
        },
        "description": "Opt-in 202 response. Omit for the legacy final 200/502 deploy/rollback result."
      }
    }
  },
  "paths": {
    "/api/apps/targets": {
      "get": {
        "summary": "List your container-runtime deploy targets",
        "description": "Only targets promoted to runtime=container appear. Register a host first with POST /api/website/targets (one VPS can serve both static sites and apps), then promote it.",
        "responses": {
          "200": {
            "description": "targets[] with capacity (maxApps, cpuCores, memMb) and appsRunning"
          }
        }
      }
    },
    "/api/apps/targets/{id}/runtime": {
      "put": {
        "summary": "Promote a deploy target to a container runtime",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "runtime": {
                    "type": "string",
                    "enum": [
                      "container",
                      "static"
                    ]
                  },
                  "maxApps": {
                    "type": "integer"
                  },
                  "cpuCores": {
                    "type": "integer"
                  },
                  "memMb": {
                    "type": "integer"
                  },
                  "tlsMode": {
                    "type": "string",
                    "enum": [
                      "per-host",
                      "wildcard"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated target. Existing static targets are untouched by default — only a promoted target can host apps."
          }
        }
      }
    },
    "/api/apps": {
      "get": {
        "summary": "List your apps",
        "responses": {
          "200": {
            "description": "apps[]"
          }
        }
      },
      "post": {
        "summary": "Create an app",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug",
                  "targetId",
                  "webService"
                ],
                "properties": {
                  "slug": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "targetId": {
                    "type": "integer"
                  },
                  "webService": {
                    "type": "string"
                  },
                  "containerPort": {
                    "type": "integer",
                    "default": 3000
                  },
                  "healthPath": {
                    "type": "string",
                    "default": "/"
                  },
                  "migrateCmd": {
                    "type": "string",
                    "description": "Run via `docker compose run --rm <webService> <cmd>` BEFORE the container is recreated; non-zero exit aborts the deploy. Also the place for idempotent seeding."
                  },
                  "env": {
                    "type": "object",
                    "description": "Stored encrypted (requires DEPLOY_SECRET on the server). Written to the release's .env at deploy time alongside PORT and HOST_PORT."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "App with allocated hostPort",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/App"
                }
              }
            }
          },
          "409": {
            "description": "TARGET_FULL (host at its maxApps ceiling) or slug taken"
          }
        }
      }
    },
    "/api/apps/{id}": {
      "get": {
        "summary": "App detail + releases",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "app + releases[]"
          }
        }
      },
      "delete": {
        "summary": "Remove the app from the control plane",
        "description": "Deliberately does NOT touch the host: containers, volumes and releases stay until stopped on purpose — forgetting an app must never silently destroy its data. Managed hostname claims are retained; deletion never authorizes another tenant to take over a live route. Active or uncertain operations block deletion.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "deleted, with a note about what was left running"
          }
        }
      }
    },
    "/api/apps/{id}/env": {
      "put": {
        "summary": "Replace the app's stored environment",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "env": {
                    "type": "object"
                  }
                },
                "required": [
                  "env"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "App with the new envKeys (names only). Takes effect on the next deploy."
          }
        },
        "description": "Compatibility replace-all, stored-only. No required If-Match; if supplied it is enforced. PATCH always requires CAS. Both reject reserved PORT/HOST_PORT/COMPOSE_*/DOCKER_* keys, multiline/NUL values and invalid names. Managed state reports independent stored/applied env revisions."
      },
      "patch": {
        "summary": "Preview/apply encrypted env set/unset, restarting only the existing release",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "set": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string"
                    }
                  },
                  "unset": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Changed/preserved key names only; restartRequired."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/deploy": {
      "post": {
        "summary": "Deploy a release (dry run by default)",
        "description": "Immutable gzip USTAR/PAX intake (80MiB compressed,256MiB expanded,20k files). Reject traversal, links, devices, duplicate paths and recognized secret files/key material before202. Server sourceDir requires an explicit APP_SOURCE_ROOTS descendant. Managed deploy omits no saved domains/gate: omission preserves; empty previewPassword is invalid; public is an explicit policy change. Rendered plans redact exec and secret data. New hosting requires nginx/per-host TLS; unsupported legacy adapters stay on their existing rail until reviewed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          },
          {
            "$ref": "#/components/parameters/Prefer"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tarballBase64": {
                    "type": "string"
                  },
                  "releaseTs": {
                    "type": "string",
                    "description": "Release identifier; defaults to the server clock"
                  },
                  "note": {
                    "type": "string"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "previewPassword": {
                    "type": "string",
                    "description": "Managed apps: explicit private-gate rotation,12–72bytes; omission retains existing gate. Uninventoried legacy rail retains old request-scoped behavior until migrated."
                  },
                  "fqdn": {
                    "type": "string",
                    "description": "Serve on a full custom domain instead of <slug>.<base domain>"
                  },
                  "sourceDir": {
                    "type": "string",
                    "description": "Privileged server-side source within APP_SOURCE_ROOTS; prefer tarballBase64."
                  },
                  "altNames": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "With explicit fqdn, legacy directly served aliases; new domain plans support canonical redirects."
                  },
                  "expected": {
                    "$ref": "#/components/schemas/ExpectedContent"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Dry run: { plan: { hostname, liveUrl, steps: PlanStep[] } }. Live: { released: true, liveUrl, log }.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "Failed operation; inspect actual completed steps and recovery state. A changed container or database is not automatically restored by routing compensation."
          }
        }
      }
    },
    "/api/apps/{id}/rollback": {
      "post": {
        "summary": "Roll back to a previous healthy release",
        "description": "Re-runs compose from the kept release directory (container recreate — seconds of downtime), health-checked before activation. WARNING: restores code only; a migration run by the abandoned release is still applied. Managed rollback retains persisted hosts/policy and last applied env instead of the historical release .env. Omitted release resolves once per idempotency key.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          },
          {
            "$ref": "#/components/parameters/Prefer"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "releaseId": {
                    "type": "integer",
                    "description": "Defaults to the most recent previously-live release"
                  },
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "rolledBackTo + log (or the dry-run plan)"
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/apps/{id}/preview": {
      "post": {
        "summary": "Mint a preview credential",
        "description": "Returns { previewUrl, credential: { user: 'preview', password } } once per call, but does not activate a gate. Retain the credential securely. For an existing managed app use a publication-policy plan/apply to set private access without a content deployment. For a private first deployment, pass previewPassword in the protected deploy body. A document share is not an app access gate; platform public access cannot remove application-managed login or expiry.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "previewUrl + one-time credential"
          }
        }
      }
    },
    "/api/apps/{id}/stop": {
      "post": {
        "summary": "Stop the app's containers",
        "description": "Frees CPU; keeps volumes and release directories. Redeploy to bring it back.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok + log"
          }
        }
      }
    },
    "/api/apps/publishing-capabilities": {
      "get": {
        "summary": "Publishing rollout capabilities (requires app.deploy)",
        "description": "Discover configuration-read and operation feature flags. These booleans do not assert that a particular target is allowed, an app is inventoried, DNS ownership is verified, or a publication is ready. Read the owned app's domain-config and handle CONFIG_REVIEW_REQUIRED / LIVE_DISABLED explicitly; never enable flags or bypass proof based only on this response.",
        "responses": {
          "200": {
            "description": "configuration / operations / liveRequiresPilot / supportedAdapter / asyncOptIn"
          }
        }
      }
    },
    "/api/apps/{id}/domain-config": {
      "get": {
        "summary": "Unified desired/applied configuration, plans, DNS records, operations and readiness",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Owner-scoped detail; Cache-Control:no-store, ETag when managed."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      },
      "put": {
        "summary": "Plan or apply domains",
        "description": "Dry-run defaults true; a plan expires after 15 minutes. Dry run can persist a plan/claim but does not touch the host/provider DNS. Apply requires dryRun:false + the reviewed planId, If-Match and Idempotency-Key. Target-wide fenced activation; unknown outcomes require operator reconciliation.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "domains": {
                    "$ref": "#/components/schemas/Domains"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved dry-run plan, exact DNS records, policy and zero-build/restart counters."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/publication-policy": {
      "patch": {
        "summary": "Plan or apply policy",
        "description": "Dry-run defaults true; a plan expires after 15 minutes. Dry run can persist a plan/claim but does not touch the host/provider DNS. Apply requires dryRun:false + the reviewed planId, If-Match and Idempotency-Key. Target-wide fenced activation; unknown outcomes require operator reconciliation.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "$ref": "#/components/parameters/IfMatch"
          },
          {
            "$ref": "#/components/parameters/Idempotency"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "dryRun": {
                    "type": "boolean",
                    "default": true
                  },
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "policy": {
                    "$ref": "#/components/schemas/PolicyPatch"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved dry-run plan, exact DNS records, policy and zero-build/restart counters."
          },
          "202": {
            "description": "Durable operation accepted. Poll Location/statusUrl; disconnect does not cancel it.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              },
              "Retry-After": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "accepted": {
                      "type": "boolean"
                    },
                    "duplicate": {
                      "type": "boolean"
                    },
                    "operation": {
                      "$ref": "#/components/schemas/Operation"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/domain-config/verify": {
      "post": {
        "summary": "Bounded DNS/ownership check, without issuance or host mutation",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "planId": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-host authoritative and two-recursive A/AAAA/CNAME/CAA observations, expected target and typed failure. Refreshes coalesced for 30 seconds."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/operations": {
      "get": {
        "summary": "Read owner-scoped operations",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Latest30 redacted operations."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/readiness": {
      "get": {
        "summary": "Read owner-scoped readiness",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Five-minute revision/release-bound evidence. HTTP/TLS/policy/identity/assets only; interactiveSmoke is not_run unless separately verified."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      },
      "post": {
        "summary": "Run bounded public HTTPS readiness checks",
        "description": "DNS-pinned public IPs only; TLS authentication and hostname validation remain enabled. Redirect allowlist, aggregate request/byte/time budgets, short-lived private probe credentials, refresh coalescing. Never certifies application JS interaction.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "ready:false with per-host failure is a valid check result; applied does not mean publicly ready."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/publication-health": {
      "get": {
        "summary": "Read owner-scoped publication-health",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deduplicated per-host certificate/readiness transitions, notification delivery status and recovery."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/publication-receipt": {
      "get": {
        "summary": "Read owner-scoped publication-receipt",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          }
        ],
        "responses": {
          "200": {
            "description": "Redacted URLs, policy, applied/desired revisions and scoped verification; password handoff stays separate."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/operations/{operationId}": {
      "get": {
        "summary": "Resume observing an accepted operation",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Redacted durable operation and ordered steps."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/operations/{operationId}/retry": {
      "post": {
        "summary": "Retry safe failed steps of the same operation",
        "description": "Cancellation cannot undo migrations or terminate an uncertain SSH process. Ambiguous outcomes are quarantined, not replayed. TLS rate-limited attempts have a minimum one-hour cooldown and may require a longer issuer window.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current operation."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    },
    "/api/apps/{id}/operations/{operationId}/cancel": {
      "post": {
        "summary": "Request cancellation at a safe checkpoint",
        "description": "Cancellation cannot undo migrations or terminate an uncertain SSH process. Ambiguous outcomes are quarantined, not replayed. TLS rate-limited attempts have a minimum one-hour cooldown and may require a longer issuer window.",
        "parameters": [
          {
            "$ref": "#/components/parameters/AppId"
          },
          {
            "name": "operationId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current operation."
          },
          "400": {
            "description": "Invalid input or missing/mismatched idempotency key."
          },
          "403": {
            "description": "app.deploy required."
          },
          "404": {
            "description": "Missing, cross-owner resource, or disabled feature."
          },
          "409": {
            "description": "Conflict, live disabled, unknown inventory, policy/DNS failure or reconciliation required."
          },
          "428": {
            "description": "Required If-Match missing."
          },
          "502": {
            "description": "Safe typed failure; consult effective state and recovery metadata."
          }
        }
      }
    }
  }
}
