openapi: 3.1.0 info: title: Sencho API version: 0.98.1 # x-release-please-version description: | REST API for Sencho, a self-hosted Docker Compose management dashboard. Sencho exposes a public API for automating stack deployments, managing webhooks, monitoring fleet health, and integrating with CI/CD pipelines. ## Authentication All authenticated endpoints accept a Bearer token in the `Authorization` header. Generate API tokens from **Settings > API Tokens** in the Sencho dashboard (admin role required). ``` Authorization: Bearer YOUR_API_TOKEN ``` ## Node Routing Sencho manages multiple nodes (local and remote). To target a specific node, include the `x-node-id` header or `?nodeId=` query parameter. If omitted, the request targets the default node. ## License Tiers Some endpoints require an Admiral license. Requests to gated endpoints on Community return `403` with `code: "PAID_REQUIRED"`. contact: name: Sencho url: https://sencho.io license: name: AGPL-3.0-only url: https://github.com/studio-saelix/sencho/blob/main/LICENSE servers: - url: "{protocol}://{host}:{port}" description: Your Sencho instance variables: protocol: default: https enum: [http, https] host: default: localhost port: default: "1852" security: - bearerAuth: [] tags: - name: Health description: Instance health check - name: Stacks description: Create, read, update, delete, and operate Docker Compose stacks - name: Containers description: List and manage running containers - name: API Tokens description: Manage scoped API tokens (admin role required) - name: Webhooks description: Configure and trigger deployment webhooks - name: Nodes description: Manage local and remote Sencho nodes - name: Fleet description: Multi-node fleet overview and snapshots - name: Observation description: Read-only projections for MCP clients and other AI observers, reached with an Observe Only token - name: Scheduled Tasks description: Configure recurring automated operations (admin role required) - name: Registries description: Manage private container registry credentials. Docker Hub, GHCR, and custom registries are available on every tier; AWS ECR requires Admiral. These endpoints are only accessible via browser sessions. API tokens receive `SCOPE_DENIED`. - name: Image Updates description: Check for available container image updates across stacks - name: Recovery description: Create, verify, and restore application-data recovery points for named Docker volumes on a node. The read calls (`GET`) work with a Read Only, Deploy Only, or Full Admin token; every write call needs a Full Admin token. A write call with any other scope, and every recovery call with an Observe Only token, gets `403 SCOPE_DENIED`. components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: API token generated from Settings > API Tokens. Scopes are `observe-only`, `read-only`, `deploy-only`, or `full-admin`. webhookSignature: type: apiKey in: header name: X-Webhook-Signature description: HMAC-SHA256 signature for webhook trigger requests. Compute as `sha256=HMAC(request_body, webhook_secret)`. parameters: stackName: name: stackName in: path required: true description: > Stack directory name. Must match `^[a-zA-Z0-9_-]+$` (alphanumeric characters, hyphens, and underscores only). Returns `400 Invalid stack name` if the name contains path separators, dots, spaces, or other special characters. schema: type: string pattern: '^[a-zA-Z0-9_-]+$' example: my-stack nodeId: name: x-node-id in: header required: false description: Target a specific node by ID. If omitted, the default node is used. Can also be passed as `?nodeId=` query parameter. schema: type: integer example: 1 nodeIdQuery: name: nodeId in: query required: false description: Target a specific node by ID (alternative to `x-node-id` header). schema: type: integer example: 1 idPath: name: id in: path required: true description: Numeric resource ID. schema: type: integer schemas: Error: type: object required: [error] properties: error: type: string description: Human-readable error message. code: type: string description: Machine-readable error code (e.g., `PAID_REQUIRED`, `SCOPE_DENIED`). enum: [PAID_REQUIRED, SCOPE_DENIED] SuccessMessage: type: object required: [message] properties: message: type: string SuccessBoolean: type: object required: [success] properties: success: type: boolean example: true UpdatePreviewImage: type: object required: [service, image, current_tag, next_tag, has_update, digest_update, tag_update, semver_bump, check_status] properties: service: { type: string } image: { type: string } current_tag: { type: string } next_tag: { type: ["string", "null"] } has_update: { type: boolean } digest_update: type: boolean description: Same-tag registry content drift; Compose pull can apply without changing the pin. tag_update: type: boolean description: A higher pinned semver exists; advisory until Compose is edited. semver_bump: type: string enum: [none, unknown, patch, minor, major] check_status: type: string enum: [ok, partial, failed, not_checkable] UpdatePreviewSummary: type: object required: [has_update, primary_image, current_tag, next_tag, semver_bump, update_kind, blocked, blocked_reason, has_build_services, rebuild_available, check_status] properties: has_update: { type: boolean } primary_image: { type: ["string", "null"] } current_tag: { type: ["string", "null"] } next_tag: { type: ["string", "null"] } semver_bump: type: string enum: [none, unknown, patch, minor, major] update_kind: type: string enum: [tag, digest, none] blocked: { type: boolean } blocked_reason: { type: ["string", "null"] } has_build_services: { type: boolean } rebuild_available: { type: boolean } check_status: type: string enum: [ok, partial, failed] UpdatePreview: type: object required: [stack_name, images, build_services, summary, rollback_target, changelog] properties: stack_name: { type: string } images: type: array items: $ref: "#/components/schemas/UpdatePreviewImage" build_services: type: array items: { type: string } summary: $ref: "#/components/schemas/UpdatePreviewSummary" rollback_target: { type: ["string", "null"] } changelog: { type: ["string", "null"] } LabelSource: type: string description: Provenance of a label. `unknown` when a container or image could not be inspected. enum: [compose, runtime, image, compose-system, unknown] LabelValue: type: object required: [key, value, source] properties: key: { type: string } value: type: string description: Redacted to `[redacted]` for secret-like keys unless the caller is an admin and passes `reveal=1`. source: { $ref: "#/components/schemas/LabelSource" } redacted: { type: boolean } behavior: { $ref: "#/components/schemas/LabelBehavior" } LabelBehavior: type: object required: [state] description: Whether Sencho acts on an `io.sencho.*` label. Present only on labels in that namespace. properties: state: type: string enum: [active, invalid, unsupported, inert] description: >- `active` is declared in the rendered Compose file and valid. `invalid` is declared but ignored, which includes a key Sencho reserves for containers it creates. `unsupported` is an `io.sencho.*` key Sencho does not define. `inert` comes from an image, the running container, or an unrendered model, so it never changes behavior. A deployed copy of `io.sencho.infrastructure` is `active` or `invalid`, because the Docker endpoint guard reads it there too. reason: { type: string, description: Fixed explanation that never repeats the label value. } summary: { type: string, description: What the label does when active. } LabelIndexContainerRef: type: object required: [id, name, stack, service] properties: id: { type: string } name: { type: string } stack: { type: string, nullable: true } service: { type: string, nullable: true } nodeId: { type: integer } nodeName: { type: string } LabelIndexRow: type: object description: One unique key/value/source and every container carrying it. required: [key, value, source, containers] properties: key: { type: string } value: { type: string } source: { $ref: "#/components/schemas/LabelSource" } redacted: { type: boolean } behavior: { $ref: "#/components/schemas/LabelBehavior" } containers: type: array items: { $ref: "#/components/schemas/LabelIndexContainerRef" } ContainerLabelRow: type: object required: [id, name, stack, service, state, labels] properties: id: { type: string } name: { type: string } stack: { type: string, nullable: true } service: { type: string, nullable: true } state: { type: string } labels: type: array items: { $ref: "#/components/schemas/LabelValue" } StackLabelReplica: type: object required: [id, name, state, runtimeLabels, onlyInCompose, onlyOnContainer, inBoth, changed] properties: id: { type: string } name: { type: string } state: { type: string } runtimeLabels: type: array items: { $ref: "#/components/schemas/LabelValue" } onlyInCompose: { type: array, items: { type: string } } onlyOnContainer: { type: array, items: { type: string } } inBoth: { type: array, items: { type: string } } changed: type: array items: { type: string } description: Keys declared in Compose and present at runtime but with a different value. inspectFailed: type: boolean description: Runtime labels could not be read for this replica; reconciliation was skipped. StackServiceLabelRow: type: object required: [service, declaredLabels, replicas] properties: service: { type: string } declaredLabels: type: array items: { $ref: "#/components/schemas/LabelValue" } replicas: type: array items: { $ref: "#/components/schemas/StackLabelReplica" } StackLabelInventory: type: object required: [stackName, renderable, services, partial, generatedAt] properties: stackName: { type: string } renderable: type: boolean description: False when the Compose model could not be rendered; declared provenance is then unknown. partial: type: boolean description: A replica or its image could not be fully inspected. generatedAt: { type: integer } services: type: array items: { $ref: "#/components/schemas/StackServiceLabelRow" } NodeLabelInventory: type: object required: [nodeId, containers, byLabel, partial, generatedAt] properties: nodeId: { type: integer } partial: { type: boolean } generatedAt: { type: integer } containers: type: array items: { $ref: "#/components/schemas/ContainerLabelRow" } byLabel: type: array items: { $ref: "#/components/schemas/LabelIndexRow" } FleetLabelInventory: type: object required: [nodes, aggregatedByLabel, nodeErrors, generatedAt] properties: generatedAt: { type: integer } nodes: type: array items: type: object required: [nodeId, nodeName, status, inventory, error] properties: nodeId: { type: integer } nodeName: { type: string } status: { type: string, enum: [ok, error] } inventory: nullable: true allOf: [{ $ref: "#/components/schemas/NodeLabelInventory" }] error: { type: string, nullable: true } aggregatedByLabel: type: array items: { $ref: "#/components/schemas/LabelIndexRow" } nodeErrors: type: object additionalProperties: { type: string } description: Map of node id to error for nodes that were unreachable or returned a malformed payload. FailureClassification: type: object description: Classified cause of a failed deploy or update, with a suggested next step. required: [reason, label, suggestion] properties: reason: type: string enum: - image_pull_failed - compose_render_failed - env_missing - port_conflict - bind_path_missing - permission_denied - container_exited - healthcheck_failed - dependency_unavailable - node_unreachable - unknown label: type: string description: Short display headline for the cause. suggestion: type: string description: Suggested next action, one sentence. Container: type: object properties: Id: type: string description: Docker container ID. Names: type: array items: type: string description: Container names (prefixed with `/`). Service: type: string description: Compose service name (if part of a stack). State: type: string description: Container state. enum: [running, exited, paused, restarting, dead, created] Status: type: string description: Human-readable status string (e.g., "Up 2 hours"). Ports: type: array items: type: object properties: PrivatePort: type: integer PublicPort: type: integer Node: type: object required: [id, name, type, compose_dir, is_default, created_at] properties: id: type: integer name: type: string type: type: string enum: [local, remote] compose_dir: type: string description: Base directory for Docker Compose stacks on this node. is_default: type: integer enum: [0, 1] description: Whether this is the default node (1 = true). api_url: type: string description: Remote node API URL (only for remote nodes). api_token: type: string description: Redacted API token for remote node authentication. created_at: type: integer description: Unix timestamp (seconds). Webhook: type: object required: [id, name, stack_name, action, secret, enabled, created_at] properties: id: type: integer node_id: type: integer description: Node the webhook executes against. name: type: string stack_name: type: string action: type: string enum: [deploy, restart, stop, start, pull, git-pull] secret: type: string description: Masked secret (e.g., `****...****`). Full secret is only returned on creation. enabled: type: integer enum: [0, 1] created_at: type: integer description: Unix timestamp (seconds). WebhookExecution: type: object properties: id: type: integer webhook_id: type: integer triggered_at: type: integer description: Unix timestamp (seconds). status: type: string response: type: string error: type: ["string", "null"] ApiToken: type: object required: [id, name, scope, user_id, created_at] properties: id: type: integer name: type: string scope: type: string enum: [observe-only, read-only, deploy-only, full-admin] user_id: type: integer created_at: type: integer description: Unix timestamp (seconds). expires_at: type: ["integer", "null"] description: Unix timestamp (seconds) or null for no expiry. revoked_at: type: ["integer", "null"] last_used_at: type: ["integer", "null"] FleetNodeOverview: type: object properties: id: type: integer name: type: string type: type: string enum: [local, remote] status: type: string enum: [online, offline] stats: type: ["object", "null"] description: Container statistics for the node. systemStats: type: ["object", "null"] description: Host system resource statistics. stacks: type: ["integer", "null"] description: Number of stacks on this node. FleetSnapshot: type: object required: [id, description, created_by, created_at, node_count, stack_count] properties: id: type: integer description: type: string created_by: type: string created_at: type: integer description: Unix timestamp (seconds). node_count: type: integer stack_count: type: integer skipped_nodes: type: string description: JSON-stringified array of skipped node names. FleetSnapshotDetail: allOf: - $ref: "#/components/schemas/FleetSnapshot" - type: object properties: fileDecryptWarnings: type: array description: Non-sensitive identifiers for snapshot files that could not be decrypted. items: type: object required: [nodeId, nodeName, stackName, filename] properties: nodeId: type: integer nodeName: type: string stackName: type: string filename: type: string nodes: type: array items: type: object properties: nodeId: type: integer nodeName: type: string stacks: type: array items: type: object properties: stackName: type: string files: type: array items: oneOf: - type: object required: [filename, content] properties: filename: type: string content: type: string description: Decrypted file body; may be an empty string. - type: object required: [filename, unavailable] properties: filename: type: string unavailable: type: boolean enum: [true] ScheduledTask: type: object required: [id, name, target_type, action, cron_expression, enabled, created_by, created_at, updated_at] properties: id: type: integer name: type: string target_type: type: string enum: [stack, fleet, system, container] target_id: type: ["string", "null"] description: Stack or container name (when target_type is `stack` or `container`). node_id: type: ["integer", "null"] description: Target node ID (when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] cron_expression: type: string description: Standard cron expression (5 fields). example: "0 3 * * *" enabled: type: integer enum: [0, 1] created_by: type: string created_at: type: integer description: Unix timestamp (seconds). updated_at: type: integer description: Unix timestamp (seconds). last_run_at: type: ["integer", "null"] next_run_at: type: ["integer", "null"] last_status: type: ["string", "null"] last_error: type: ["string", "null"] prune_targets: type: ["string", "null"] description: JSON-stringified array of prune targets (containers, images, networks, volumes). target_services: type: ["string", "null"] description: JSON-stringified array of service names to restart. prune_label_filter: type: ["string", "null"] description: Docker label filter for prune operations. ScheduledTaskRun: type: object required: [id, task_id, started_at, status, triggered_by] properties: id: type: integer task_id: type: integer started_at: type: integer description: Unix timestamp (seconds). completed_at: type: ["integer", "null"] triggered_by: type: string enum: [scheduled, manual] status: type: string enum: [pending, running, success, failed] output: type: ["string", "null"] error: type: ["string", "null"] Registry: type: object properties: id: type: integer name: type: string example: GitHub Container Registry url: type: string example: ghcr.io type: type: string enum: [dockerhub, ghcr, ecr, custom] username: type: string aws_region: type: ["string", "null"] description: AWS region (only present for ECR registries). created_at: type: integer description: Unix timestamp (seconds). RegistryCreate: type: object required: [name, url, type, username, secret] properties: name: type: string maxLength: 100 example: GitHub Container Registry url: type: string maxLength: 500 example: ghcr.io type: type: string enum: [dockerhub, ghcr, ecr, custom] username: type: string example: my-user secret: type: string description: Password, token, or access key. Write-only — never returned in GET responses. aws_region: type: string description: Required when `type` is `ecr`. example: us-east-1 RegistryTagList: type: object required: [tags, nextCursor, registryId, registryName, repository] properties: tags: type: array items: { type: string } example: [latest, "1.0"] nextCursor: type: ["string", "null"] description: Opaque cursor for the next page, or null when there are no more tags. registryId: type: integer registryName: type: string repository: type: string description: Path-safe repository name after Docker Hub library/ normalization when applicable. example: library/nginx ImageUpdateStatus: type: object properties: stack_name: type: string description: Name of the stack. has_updates: type: integer enum: [0, 1] description: Whether any images in this stack have newer versions available. last_checked: type: ["integer", "null"] description: Unix timestamp of the last check. ImageUpdateCheckStatus: type: object properties: checking: type: boolean description: "`true` if a scan is currently running." intervalMinutes: type: integer description: Configured fallback interval in minutes (15-1440). lastCheckedAt: type: ["integer", "null"] description: Unix epoch-ms of the last check start, or null if never checked. nextCheckAt: type: ["integer", "null"] description: Unix epoch-ms of the next scheduled check, or null when not scheduled. manualCooldownMinutes: type: integer description: Fixed cooldown ceiling in minutes for manual refresh. manualCooldownRemainingMs: type: integer description: Live remaining cooldown in milliseconds (0 when refresh is allowed). mode: type: string enum: [interval, cron] description: Active scheduling mode. cronExpression: type: ["string", "null"] description: 5-field cron expression when mode is 'cron', null otherwise. sidebarIndicators: type: boolean description: Whether sidebar update-status indicators are enabled. Controlled by the `image_update_sidebar_indicators` global setting. Default is `false`. ReadinessDomainKey: type: string enum: [connectivity, workloads, updates, recovery, security, control] description: One of the six readiness domains, in column order. ReadinessDomainState: type: string enum: [attention, degraded, unavailable, unknown, healthy] description: | Ordered worst first. A node's overall state is the first of its cell states found in this list. A known problem outranks a not-knowable one: `unavailable` (no evidence at all) ranks above `unknown` (incomplete evidence). ReadinessReasonCode: type: string description: | A closed vocabulary naming why a cell is not healthy. Each code belongs to one domain and maps to fixed copy in the client, which renders a code it does not know as "this check reported something this build does not know" rather than as healthy. `capability_absent` and `domain_error` apply to any domain. enum: - node_unreachable - pilot_disconnected - contact_stale - probe_timeout - workloads_exited - workloads_partial - workloads_unknown - status_evidence_degraded - status_evidence_stale - update_blocked - update_review_required - update_ready_with_warnings - rollback_not_ready - rollback_partial - snapshot_failed - stacks_unknown - summary_truncated - summary_stale - posture_partial - posture_action_needed - scanner_unavailable - scans_stale - scans_never_completed - control_paused - control_degraded - control_unknown - capability_absent - domain_error ReadinessTarget: type: object required: [surface] description: | Where a finding sends the operator. `surface` discriminates the variant and names an existing navigation affordance rather than a URL: `stack` carries `nodeId` and `stackName`; `auto-updates`, `fleet-snapshots` and `settings-nodes` carry nothing else; `security` carries a `tab` segment; `node-details` carries a `nodeId`. properties: surface: type: string enum: [stack, auto-updates, fleet-snapshots, security, node-details, settings-nodes] nodeId: type: integer stackName: type: string tab: type: ["string", "null"] description: Security tab segment, or null for the Security overview. ReadinessFinding: type: object required: [id, domain, nodeId, stack, code, severity, count, verdict, detail, target] properties: id: type: string description: Stable across refetches, so a client keys and de-duplicates on it. domain: $ref: "#/components/schemas/ReadinessDomainKey" nodeId: type: integer stack: type: ["string", "null"] description: Stack name for a stack-scoped finding, null otherwise. code: $ref: "#/components/schemas/ReadinessReasonCode" severity: $ref: "#/components/schemas/ReadinessDomainState" description: The state of the concern that produced this row; never `healthy`. count: type: integer description: How many items sit behind this row (stacks, containers, snapshots). verdict: type: ["object", "null"] description: | The canonical verdict this row restates, tagged by the vocabulary it holds: `{ "kind": "update", "value": "ready" | "ready_with_warnings" | "review_required" | "blocked" | "unknown" }` or `{ "kind": "rollback", "value": "ready" | "partial" | "not_ready" }`. Null when no verdict applies. detail: type: ["string", "null"] description: Pass-through of the canonical explanation, with sensitive values redacted. target: $ref: "#/components/schemas/ReadinessTarget" NodeDomainCell: type: object required: [state, reasonCode, counts, evidenceAgeMs, source] description: | One domain's state for one node. A healthy cell carries `reasonCode: null` by construction, so a client cannot look up an explanation for a state that needs none. properties: state: $ref: "#/components/schemas/ReadinessDomainState" reasonCode: oneOf: - $ref: "#/components/schemas/ReadinessReasonCode" - type: "null" description: Null when, and only when, `state` is `healthy`. counts: type: object description: | Tallies in the domain's own vocabulary (`running`, `blocked`, `unknown`, ...), never a score. A client renders the keys it knows and ignores the rest, so a newer hub can add one without breaking an older UI. additionalProperties: type: integer evidenceAgeMs: type: ["integer", "null"] description: | Age of the evidence behind this cell, or null when it is not knowable. Which stamp that is belongs to the domain: the newest where the newest input decided the answer, the oldest where every input contributed. source: type: string enum: [live, stored] description: | Whether this request's own read of the node produced the answer (`live`), or the hub answered from what it already had (`stored`). A `live` cell can still carry old evidence, which is `evidenceAgeMs`'s question rather than this one. NodeReachability: type: object required: [status, contactAt, contactSource, note, probedLive, latencyMs] properties: status: type: string enum: [online, offline, unknown] contactAt: type: ["integer", "null"] description: Epoch ms of the last contact this hub can vouch for. contactSource: type: string enum: [local, pilot_last_seen, last_successful_contact] note: type: string description: | How reachability was decided, as a closed set rather than free prose, so a client renders it without a fallback string. probedLive: type: boolean description: True when this request itself reached the node. latencyMs: type: ["integer", "null"] description: Round-trip time of the probe that produced this row, or null when there was none. FleetReadinessNode: type: object required: [id, name, type, mode, transport, reachability, state, cells, stackCount] properties: id: type: integer name: type: string type: type: string enum: [local, remote] mode: type: string enum: [proxy, pilot_agent] transport: type: string enum: [local, pilot, proxy, unreachable] description: Which transport the hub would use to reach this node. reachability: $ref: "#/components/schemas/NodeReachability" state: $ref: "#/components/schemas/ReadinessDomainState" description: The worst state among this node's cells. Nodes are returned worst first, then by name. cells: type: object description: | Keys are the subset of `domains` on the response that apply to this node. The `control` domain applies only to nodes that receive policy sync (proxy-mode remotes with an API URL and token), so this instance's own node and Pilot-agent nodes carry no `control` cell: a missing key means the domain does not apply. A domain absent from `domains` is one this caller was not told about (`domainsOmitted`). additionalProperties: $ref: "#/components/schemas/NodeDomainCell" stackCount: type: ["integer", "null"] description: Stacks known on this node, or null when it could not be read. FleetReadinessResponse: type: object required: [generatedAt, domains, domainsOmitted, summary, findings, nodes] properties: generatedAt: type: integer description: Epoch ms this response was assembled; the surface's "last checked" time. domains: type: array description: Domains present in this response, in column order. items: $ref: "#/components/schemas/ReadinessDomainKey" domainsOmitted: type: array description: | Domains withheld from this caller. Today the only member is `control`, omitted unless the caller passes the same admin check `GET /api/fleet/sync-status` uses; the key never appears in a node's cells. items: $ref: "#/components/schemas/ReadinessDomainKey" summary: type: object required: [nodes, findings] properties: nodes: type: object description: | Nodes whose worst domain state is each value, counted over the domains this response carries. A node counts once, so the values sum to `nodes.length`. properties: attention: { type: integer } degraded: { type: integer } unavailable: { type: integer } unknown: { type: integer } healthy: { type: integer } findings: type: object description: Findings across the fleet, by severity. properties: attention: { type: integer } degraded: { type: integer } unavailable: { type: integer } unknown: { type: integer } findings: type: array description: | Worst severity first, then by `id`. When a node's Connectivity cell carries a finding and a gated domain went unread for that same reason (no answer, a timeout, or a route the node does not serve), the domain keeps its unavailable cell but adds no finding. This applies only when `connectivity` is in `domains`. items: $ref: "#/components/schemas/ReadinessFinding" nodes: type: array items: $ref: "#/components/schemas/FleetReadinessNode" ObservationNode: type: object required: [id, name, type, transport, status, lastContactAt, cordoned] description: | One node as an observer may see it. The node URL, compose directory, enrollment credential, and cordon reason are withheld. properties: id: type: integer name: type: string type: type: string enum: [local, remote] transport: type: string enum: [local, proxy, pilot] status: type: string enum: [online, offline, unknown] lastContactAt: type: ["integer", "null"] description: > Epoch ms of the last contact this hub can vouch for, or null. The unit is the same for local, proxy, and Pilot nodes. cordoned: type: boolean ObservationStackSummary: type: object required: [name, status, running, total, source, isSelf] properties: name: type: string status: type: string enum: [running, exited, partial, unknown] running: type: ["integer", "null"] description: Running container count, or null when the node could not report it. total: type: ["integer", "null"] description: Total container count, or null when the node could not report it. source: type: string enum: [local, git] isSelf: type: boolean description: True when the stack is the instance serving the request. ObservationStackListData: type: object required: [degraded, total, nextCursor, stacks] properties: degraded: type: boolean description: True when the underlying status evidence was incomplete. total: type: integer description: > Total listed stacks on the node, across every page. A directory whose name has characters outside letters, digits, `_`, and `-` is not listed and is not counted. nextCursor: type: ["string", "null"] description: Name of the last stack on this page when more stacks exist, otherwise null. stacks: type: array items: $ref: "#/components/schemas/ObservationStackSummary" ObservationContainer: type: object required: [name, service, state, health, restartCount, exitCode, startedAt] properties: name: type: string service: type: ["string", "null"] state: type: string enum: [created, running, paused, restarting, removing, exited, dead, other] health: type: ["string", "null"] enum: [healthy, unhealthy, starting, none, null] restartCount: type: integer exitCode: type: ["integer", "null"] startedAt: type: ["string", "null"] description: ISO 8601 start time, or null when Docker does not report one. ObservationStackStatusData: type: object required: [stack, outcome, containers, truncated] properties: stack: type: string outcome: type: string enum: [ok, unavailable] description: | `unavailable` means the node could not read its own containers at that moment. It is a successful response carrying no container list, not an error. containers: type: array description: > At most 100 containers. Containers that are not running or are unhealthy come first, then those still starting, then the rest, each group ordered by name, so the cap drops healthy containers before it drops a problem one. items: $ref: "#/components/schemas/ObservationContainer" truncated: type: boolean description: True when the stack has more containers than the response carries. ObservationStackReadinessData: type: object required: [stack, update, rollback, unavailableReason] properties: stack: type: string update: type: ["object", "null"] description: The canonical update readiness verdict, or null when it was never evaluated. properties: verdict: type: string enum: [ready, ready_with_warnings, review_required, blocked, unknown] topReasonId: type: ["string", "null"] hasUpdate: type: boolean computedAt: type: integer rollback: type: ["object", "null"] description: The canonical rollback readiness verdict, or null when it was never evaluated. properties: overall: type: string enum: [ready, partial, not_ready] topReasonId: type: ["string", "null"] computedAt: type: integer unavailableReason: oneOf: - type: string enum: [summary_truncated, domain_error] - type: "null" description: Set only when both `update` and `rollback` are null. ObservationFleetCell: type: object required: [state, reasonCode, evidenceAgeMs, source] properties: state: $ref: "#/components/schemas/ReadinessDomainState" reasonCode: oneOf: - $ref: "#/components/schemas/ReadinessReasonCode" - type: "null" description: Null when, and only when, `state` is `healthy`. evidenceAgeMs: type: ["integer", "null"] description: Age of the evidence behind this cell, or null when it is not knowable. source: type: string enum: [live, stored] description: Whether this request's own read produced the answer, or the hub answered from stored evidence. ObservationFleetNode: type: object required: [id, name, transport, state, reachability, cells, stackCount] properties: id: type: integer name: type: string transport: type: string enum: [local, pilot, proxy, unreachable] state: $ref: "#/components/schemas/ReadinessDomainState" description: The worst state among this node's cells. reachability: type: object required: [status, contactAt, probedLive, latencyMs] properties: status: type: string enum: [online, offline, unknown] contactAt: type: ["integer", "null"] description: Epoch ms of the last contact this hub can vouch for. probedLive: type: boolean latencyMs: type: ["integer", "null"] cells: type: object description: | Keys are the subset of `domains` that apply to this node. A missing key means the domain does not apply, never that it is healthy. additionalProperties: $ref: "#/components/schemas/ObservationFleetCell" stackCount: type: ["integer", "null"] ObservationFleetFinding: type: object required: [id, domain, nodeId, stack, code, severity, count] description: | A finding with its free-text explanation and target withheld. The client reads `code`, and `severity` is never `healthy`. properties: id: type: string domain: $ref: "#/components/schemas/ReadinessDomainKey" nodeId: type: integer stack: type: ["string", "null"] code: $ref: "#/components/schemas/ReadinessReasonCode" severity: $ref: "#/components/schemas/ReadinessDomainState" description: The state of the concern that produced this row; never `healthy`. count: type: integer ObservationFleetHealthData: type: object required: [stale, domains, domainsOmitted, summary, nodes, findings, findingsTruncated] properties: stale: type: boolean description: True when the cache served an expired aggregate after a failed refresh. domains: type: array items: $ref: "#/components/schemas/ReadinessDomainKey" domainsOmitted: type: array description: Domains withheld from the observation surface. Today this is only `control`. items: $ref: "#/components/schemas/ReadinessDomainKey" summary: type: object required: [nodes, findings] properties: nodes: type: object description: Nodes whose worst domain state is each value. properties: attention: { type: integer } degraded: { type: integer } unavailable: { type: integer } unknown: { type: integer } healthy: { type: integer } findings: type: object description: Findings across the fleet, by severity. properties: attention: { type: integer } degraded: { type: integer } unavailable: { type: integer } unknown: { type: integer } nodes: type: array items: $ref: "#/components/schemas/ObservationFleetNode" findings: type: array description: Worst severity first, capped at 50 rows. items: $ref: "#/components/schemas/ObservationFleetFinding" findingsTruncated: type: boolean description: True when more than 50 findings exist. StackReadinessRow: type: object required: [stack, update, rollback, unavailableReason] properties: stack: type: string update: type: ["object", "null"] description: | The canonical update verdict, or null when it was never evaluated: `{ "verdict": "ready" | "ready_with_warnings" | "review_required" | "blocked" | "unknown", "computedAt": , "topReason": }`. `topReason` is redacted before it leaves the node. rollback: type: ["object", "null"] description: | The canonical rollback verdict, or null when it was never evaluated: `{ "overall": "ready" | "partial" | "not_ready", "computedAt": , "topReason": }`. unavailableReason: oneOf: - $ref: "#/components/schemas/ReadinessReasonCode" - type: "null" description: | Set only when both `update` and `rollback` are null, naming why neither verdict could be produced (the deadline, or a compute error). A stack the cap excluded has no row at all; the cap is reported by `truncated` instead. NodeStackReadinessSummary: type: object required: [generatedAt, truncated, stale, stacks] description: | Per-stack readiness as the node that owns the Docker socket computed it. The verdicts are the canonical update-readiness and rollback-readiness values passed through unchanged; this route adds no scoring. properties: generatedAt: type: integer description: Epoch ms of the pass that produced these verdicts, not of this request. truncated: type: boolean description: True when the stack cap or the deadline cut the pass short. stale: type: boolean description: | True when the verdicts served are older than the freshness window, which is what an earlier failed pass leaves behind. Neither the Updates nor the Recovery cell may be `healthy` while this is true. stacks: type: array description: Worst first; see `StackReadinessRow`. A stack missing from the array was not judged healthy, only left unevaluated. items: $ref: "#/components/schemas/StackReadinessRow" NodeReadinessEvidence: type: object required: [generatedAt, workloads, security] description: | One node's own workload and security facts, served by `GET /api/readiness/evidence` for the hub's fleet fan-out. Evidence, not verdicts: the hub owns the mapping from these facts to a domain state and reason code. properties: generatedAt: type: integer description: | Epoch ms this payload finished assembling, taken after both domains settled. Not the age of either domain: each carries its own `generatedAt` below, and a consumer ages a cell by its domain's. workloads: type: ["object", "null"] description: | This node's workload facts, or null when they could not be read: `{ "generatedAt": , "counts": { : }, "degraded": , "stale": , "problems": [{ "stack": , "status": "exited" | "unknown" | "partial" }] }`. Every status other than `running` is a problem, `unknown` and `partial` included. `degraded` and `stale` describe how the read itself went and are the only signal that a served payload is no longer current. security: type: ["object", "null"] description: | This node's security posture pass, or null when it could not be computed: `{ "generatedAt": , "posture": , "posturePartial": , "scannerAvailable": , "staleScans": , "failedScans": , "lastSuccessfulScanAt": }`. `posture` is authoritative over the fields beside it; `posturePartial` is true when any bounded pass behind the posture was capped, so a `0` beside it is ambiguous between "none" and "not counted". RecoveryVolumeProtection: type: object required: [capability, version, state, reason] description: | Whether a node can create and restore volume recovery points, and why not. A node reports `available` or `unavailable` about itself; `update_required` and `unknown` are the control instance's conclusions about a remote node. properties: capability: type: string example: volume-protection-v1 version: type: integer example: 1 state: type: string enum: [available, unavailable, update_required, unknown] reason: type: string description: | `engine_registered` when available. When unavailable: `engine_not_installed`, `schema_newer_than_build`, `recovery_store_unavailable`, `engine_starting`, `helper_unavailable`, `repository_not_initialized`, `repository_unavailable`, `repository_key_unavailable`. When update_required: `node_contract_older` or `hub_contract_older`. When unknown: `node_unreachable`, `node_refused`, `node_error`, `descriptor_invalid`, `descriptor_mismatch`. example: engine_registered RecoveryError: type: object required: [error, errorClass, code] properties: error: type: string description: Fixed, sanitized sentence for this class. Never raw engine output. errorClass: type: string enum: [invalid_request, permission_denied, capability_unavailable, update_required, schema_unsupported, identity_unknown, lock_held, lock_stale, repository_unavailable, repository_key_unavailable, source_unreadable, integrity_mismatch, attributes_not_restored, interrupted, transport_unreachable, node_refused, node_error, internal] code: type: string description: The error class upper-cased, for a client to switch on. example: LOCK_HELD reason: type: string description: | A stable token for the specific cause, present on some answers: `engine_starting` while the node is still starting, `repository_not_initialized` when no repository has been provisioned (both `503 capability_unavailable`), `repository_replaced` for a restore of a point in a replaced repository (`503 repository_unavailable`), and `not_cancellable`. RecoveryOperation: type: object required: [id, idempotencyKey, kind, storageId, pointId, actor, transport, state, errorClass, errorDetail, processEpoch, createdAt, updatedAt, completedAt] properties: id: type: string format: uuid idempotencyKey: type: string description: Caller-supplied key. A replay of the same key answers the same operation. kind: type: string enum: [create_point, verify_point, restore, repository_init] storageId: { type: ["string", "null"] } pointId: { type: ["string", "null"] } actor: { type: string } transport: type: string enum: [local, node_proxy, pilot_tunnel] state: type: string enum: [queued, running, succeeded, partial, failed, cancelled, completion_unknown] description: | `completion_unknown` means the process was lost mid-operation. It is never success and is never retried silently. errorClass: type: ["string", "null"] enum: [invalid_request, permission_denied, capability_unavailable, update_required, schema_unsupported, identity_unknown, lock_held, lock_stale, repository_unavailable, repository_key_unavailable, source_unreadable, integrity_mismatch, attributes_not_restored, interrupted, transport_unreachable, node_refused, node_error, internal, null] errorDetail: { type: ["string", "null"] } processEpoch: { type: string } createdAt: { type: integer } updatedAt: { type: integer } completedAt: { type: ["integer", "null"] } RecoveryProgress: type: object required: [operationId, phase, filesDone, bytesDone, bytesTotal] description: Bounded progress while an operation runs. Null once it settles. properties: operationId: { type: string } phase: type: string enum: [preparing, scanning, transferring, verifying, finalizing] filesDone: { type: integer } bytesDone: { type: integer } bytesTotal: type: ["integer", "null"] description: Null when the engine cannot estimate a total. RecoveryPoint: type: object required: [id, schemaVersion, storageId, storageGeneration, repositoryId, trigger, actor, sourceOperationId, sourceNodeKey, engineId, capabilityVersion, startedAt, kind, pathScope, status, snapshotId, consistency, capture, exclusions, completedAt, failureClass] properties: id: type: string format: uuid schemaVersion: { type: integer } storageId: { type: string } storageGeneration: type: integer description: The volume generation this point captured. A recreated volume is a new generation. repositoryId: { type: string } trigger: type: string enum: [manual, policy, pre_change, pre_restore] actor: { type: string } sourceOperationId: { type: string } sourceNodeKey: { type: string } engineId: { type: string, example: restic } capabilityVersion: { type: integer } startedAt: { type: integer } kind: type: string enum: [root, path] description: Only a `root` point of the current generation can count toward Protected. pathScope: type: ["string", "null"] description: The path a `path` point covers, relative to the volume root. status: type: string enum: [pending, succeeded, partial, failed, completion_unknown] snapshotId: { type: ["string", "null"] } consistency: type: string enum: [crash_consistent_live, crash_consistent_stopped, unknown] description: | An observation about the capture, not a claim about the application. `crash_consistent_live` means a container was using the volume while it was read; `crash_consistent_stopped` means nothing was observed using it and Sencho stopped nothing. capture: type: ["object", "null"] properties: fileCount: { type: integer } byteCount: { type: integer } skippedCount: type: integer description: Non-zero means the point is `partial` and never counts toward Protected. exclusions: type: array items: type: object required: [path, reason] properties: path: { type: string } reason: type: string enum: [policy_excluded, permission_denied, unreadable, vanished, unsupported_type] completedAt: { type: ["integer", "null"] } failureClass: type: ["string", "null"] description: The error class a failed point ended with. RecoveryVerification: type: object required: [id, pointId, method, result, verifiedAt, operationId] properties: id: { type: string } pointId: { type: string } method: type: string enum: [engine_check_metadata, engine_check_read_data] result: type: string enum: [verified, mismatch, error] description: A check that could not run is `error`, which never counts as verified. verifiedAt: { type: integer } operationId: { type: ["string", "null"] } RecoveryRootCoverage: type: object required: [storageKey, reason, coveringPointId] properties: storageKey: { type: ["string", "null"] } reason: type: string enum: [covered, no_point, in_progress, completion_unknown, stale, partial_point, failed_only, unverified, integrity_mismatch, older_generation, unsupported, inaccessible, identity_unknown, evidence_inconsistent] coveringPointId: { type: ["string", "null"] } RecoveryCoverageVerdict: type: object required: [state, reason, roots] properties: state: type: string enum: [protected, partially_protected, unprotected, unknown] reason: type: string enum: [all_roots_covered, some_roots_covered, no_root_covered, capability_unavailable, capability_update_required, capability_unknown, no_qualifying_storage, root_evidence_unknown] roots: type: array items: $ref: "#/components/schemas/RecoveryRootCoverage" RecoveryStorageRoot: type: object description: | One volume considered for protection. A root the caller may not read appears as `{ "access": "inaccessible" }` with no name, and still counts against the verdict. properties: volumeName: { type: string } storageId: { type: ["string", "null"] } eligible: { type: boolean } ineligibleReason: type: ["string", "null"] enum: [volume_missing, reserved_name, repository_volume, sencho_volume, anonymous_volume, non_local_driver, driver_options, null] reason: type: ["string", "null"] description: The sentence shown for a refused volume. coverage: type: string enum: [covered, no_point, in_progress, completion_unknown, stale, partial_point, failed_only, unverified, integrity_mismatch, older_generation, unsupported, inaccessible, identity_unknown, evidence_inconsistent] coveringPointId: { type: ["string", "null"] } access: type: string enum: [inaccessible] RecoveryRepositoryInfo: type: object required: [locator, fingerprint, secretState, createdAt, lastCheck] description: Configuration facts, returned only to a caller who may change settings. Never carries the password. properties: locator: type: string example: sencho-recovery-4f2a1c9d8e70-7b3d5a1e9c42-g1 fingerprint: type: ["string", "null"] description: The engine's own repository id, recorded when the repository was initialized. secretState: type: string enum: [absent, stored] createdAt: { type: integer } lastCheck: type: ["object", "null"] properties: method: type: string enum: [engine_check_metadata, engine_check_read_data] result: type: string enum: [verified, mismatch, error] checkedAt: { type: integer } RecoveryAcceptedOperation: type: object required: [operation, failureDomain, warning] properties: operation: $ref: "#/components/schemas/RecoveryOperation" failureDomain: type: string enum: [same_failure_domain] warning: type: string description: States that the repository shares this node's failure domain and is not an off-host copy. responses: Unauthorized: description: Authentication required. Provide a valid Bearer token. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: "Authentication required" Forbidden: description: Insufficient permissions or license tier. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: "This feature requires a Sencho Admiral license." code: "PAID_REQUIRED" NotFound: description: Resource not found. content: application/json: schema: $ref: "#/components/schemas/Error" InternalError: description: Internal server error. content: application/json: schema: $ref: "#/components/schemas/Error" RecoveryBadRequest: description: The request is not valid for this storage. content: application/json: schema: $ref: "#/components/schemas/RecoveryError" RecoveryForbidden: description: | The caller lacks the access this recovery action needs. An API token whose scope does not allow the call is refused before the route runs, with `code: SCOPE_DENIED` and no `errorClass`. content: application/json: schema: oneOf: - $ref: "#/components/schemas/RecoveryError" - $ref: "#/components/schemas/Error" RecoveryConflict: description: A live lock is held, an interrupted operation left a stale lock, the storage identity is unknown, a check found a mismatch, an operation was interrupted, or a remote node needs an update. content: application/json: schema: $ref: "#/components/schemas/RecoveryError" RecoveryUnavailable: description: | Volume protection is not available on this node, the repository is not reachable or its key is unavailable, the schema is unsupported, or the node could not be reached. A node that is still starting answers with `"reason": "engine_starting"`. content: application/json: schema: $ref: "#/components/schemas/RecoveryError" RecoveryInternalError: description: The recovery operation failed. content: application/json: schema: $ref: "#/components/schemas/RecoveryError" paths: # ── Health ────────────────────────────────────────────── /api/health: get: operationId: getHealth tags: [Health] summary: Health check description: Returns instance health status and uptime. No authentication required. Useful for uptime monitors and Docker HEALTHCHECK. security: [] responses: "200": description: Instance is healthy. content: application/json: schema: type: object required: [status, uptime] properties: status: type: string example: ok uptime: type: number description: Process uptime in seconds. /api/meta: get: operationId: getMeta tags: [Health] summary: Instance metadata description: Returns this Sencho instance's version and list of supported capabilities. No authentication required. Used by nodes to negotiate feature compatibility. security: [] responses: "200": description: Instance metadata. content: application/json: schema: type: object required: [version, capabilities] properties: version: type: string description: Sencho version (semver). example: "0.32.0" capabilities: type: array description: List of feature capabilities this instance supports. items: type: string example: ["stacks", "containers", "fleet", "auto-updates", "host-console"] imagePinKind: type: string nullable: true enum: [floating, semver, digest, unknown] description: How the Sencho service image is pinned in the operator's compose file. Omitted when self-update is unavailable or the compose file could not be read. updateBlocked: type: boolean description: True when the image is pinned in a way Fleet cannot repin automatically (digest or unresolved value). updateError: type: string description: Error from the last failed self-update attempt, when present. /api/system/update: post: operationId: triggerSelfUpdate tags: [Health] summary: Trigger self-update description: | Instructs this Sencho instance to pull the update image and recreate its own container. An optional release version in the request body drives semver repinning; floating tags are pulled without rewriting the compose file. Digest and unresolved pins are rejected with 409 before any pull runs. Returns 202 immediately; the actual update happens asynchronously after the response is sent. Requires the instance to be deployed via Docker Compose with the Docker socket mounted. requestBody: required: false content: application/json: schema: type: object properties: targetVersion: type: string description: Release version to update to (valid semver). responses: "202": description: Update initiated. content: application/json: schema: type: object properties: message: type: string example: "Update initiated. The server will restart shortly." "401": $ref: "#/components/responses/Unauthorized" "400": description: Invalid release version in request body. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Update blocked because the compose image cannot be repinned automatically. content: application/json: schema: type: object properties: error: type: string code: type: string "503": description: Self-update not available (not running in Docker Compose). content: application/json: schema: $ref: "#/components/schemas/Error" # ── Stacks ────────────────────────────────────────────── /api/stacks: get: operationId: listStacks tags: [Stacks] summary: List all stacks description: Returns an array of stack directory names on the target node. parameters: - $ref: "#/components/parameters/nodeId" - $ref: "#/components/parameters/nodeIdQuery" responses: "200": description: Array of stack names. content: application/json: schema: type: array items: type: string example: ["traefik", "portainer", "monitoring"] "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" post: operationId: createStack tags: [Stacks] summary: Create a new stack description: Creates a new stack directory with a default `compose.yaml` file. parameters: - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [stackName] properties: stackName: type: string description: Stack name. Alphanumeric characters, hyphens, and underscores only. pattern: "^[a-zA-Z0-9_-]+$" example: my-new-stack responses: "201": description: Stack created. content: application/json: schema: type: object required: [message, name] properties: message: type: string example: Stack created successfully name: type: string example: my-new-stack "400": description: Invalid stack name. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Stack already exists. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: Stack already exists "500": $ref: "#/components/responses/InternalError" /api/stacks/readiness-summary: get: operationId: getStacksReadinessSummary tags: [Stacks] summary: Readiness summary for this node's stacks description: | Computes the canonical update and rollback readiness verdicts for the stacks on this node, worst first, for the Fleet Readiness fan-out. It runs the same verdicts that `GET /api/stacks/{stackName}/update-readiness` and `GET /api/stacks/{stackName}/rollback-readiness` return one stack at a time, and adds no scoring of its own. The pass is bounded (an internal deadline, a stack cap, and limited concurrency) and cached briefly, so a truncated or stale result is reported as such rather than as a healthy one. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/nodeId" responses: "200": description: Per-stack readiness for the node. content: application/json: schema: $ref: "#/components/schemas/NodeStackReadinessSummary" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/platform/observation: get: operationId: listObservationStacks tags: [Observation] summary: List stacks for an AI observer description: | Returns one page of the stacks on a node, projected for an Observe Only token: name, status, running and total container counts, source, and whether the stack is this instance. Stacks are ordered by name; the cursor is the name of the last stack on the previous page, so a stack deleted between two pages does not shift the rest of the list. A directory whose name has characters outside letters, digits, `_`, and `-` is not listed, because no per-stack route accepts it. `degraded` reports that the underlying status evidence was incomplete. This is one of the five paths an Observe Only token may reach. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/nodeId" - name: cursor in: query required: false description: Name of the last stack on the previous page. Omit for the first page. schema: type: string pattern: '^[A-Za-z0-9_-]+$' - name: limit in: query required: false description: Page size. Defaults to 100 and accepts 1 to 200. schema: type: integer minimum: 1 maximum: 200 default: 100 responses: "200": description: One page of stacks wrapped in the observation envelope. content: application/json: schema: type: object required: [schemaVersion, generatedAt, data] properties: schemaVersion: type: integer enum: [1] generatedAt: type: integer description: Epoch ms of the evidence behind the answer. data: $ref: "#/components/schemas/ObservationStackListData" "400": description: Invalid cursor or limit (`INVALID_CURSOR` or `INVALID_LIMIT`), or `capability_unavailable` when a targeted remote does not advertise `agent-observation-v1`. content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: > Authentication required. Provide a valid Bearer token. On a remote node, a 401 that carries an `x-sencho-proxy: 1` response header came from the node rejecting the API token stored for it on the hub, not from the caller's own token. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "503": description: > The targeted remote node is down or did not give a usable answer to the capability check (`node_unreachable`). A disconnected Pilot tunnel or a node with no API URL or token configured also answers 503, without a code. A Distributed API Proxy node that answers the check but rejects the API token stored for it answers 401 without a code, not 503. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}: get: operationId: getStack tags: [Stacks] summary: Get stack compose file description: > Returns the compose file Sencho resolved for the stack. That file is `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`, and the `X-Compose-File` response header names it. A compose file that is a symlink to a path outside the stack directory (including another stack's directory), a stack directory that is itself a symlink, or a dangling symlink, is refused. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Raw compose file content. The `X-Compose-File` header is the basename that was read. headers: X-Compose-File: schema: type: string enum: [compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml] content: text/plain: schema: type: string "400": description: Invalid stack name. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: The compose file is a symlink outside the stack directory, the stack directory is itself a symlink, or the compose file is a dangling symlink. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" put: operationId: updateStack tags: [Stacks] summary: Update stack compose file description: > Writes the compose file that was opened. Send `X-Compose-File` with the basename from the matching GET. When that name no longer matches the file Sencho resolves, the response is 412 with code `compose_source_changed` and nothing is written. `If-Match` compares the file modification time; a mismatch is 412 with code `stack_file_changed`. When no compose file exists yet, the write creates `compose.yaml`. Requires `stack:edit` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: X-Compose-File in: header required: false schema: type: string enum: [compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml] description: Basename returned by the matching GET. - name: If-Match in: header required: false schema: type: string description: ETag from the matching GET. requestBody: required: true content: application/json: schema: type: object required: [content] properties: content: type: string description: Full compose file content. responses: "200": description: Stack saved. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Stack saved successfully "400": description: Invalid stack name or content. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "409": description: The compose file is a symlink outside the stack directory, the stack directory is itself a symlink, or the compose file is a dangling symlink. content: application/json: schema: $ref: "#/components/schemas/Error" "412": description: > `compose_source_changed` when the opened filename no longer matches the resolved file. `stack_file_changed` when `If-Match` does not match the file on disk. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteStack tags: [Stacks] summary: Delete stack description: Permanently deletes the stack directory and all its files. Requires `stack:delete` permission. With `pruneVolumes=true` the stack's volumes are removed too, unless the stack declares a recovery repository volume (or its volumes could not be read), in which case the delete is refused with `423` and nothing is stopped or removed. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: pruneVolumes in: query required: false schema: type: boolean description: When `true`, also removes the stack's compose volumes. Refused with `423` when the stack declares a recovery repository volume. responses: "200": description: Stack deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Stack deleted successfully "403": $ref: "#/components/responses/Forbidden" "423": description: Refused because `pruneVolumes=true` would remove a recovery repository volume, or the stack's volumes could not be read. The `code` is `recovery_volume_protected`. content: application/json: schema: type: object required: [error, code] properties: error: type: string code: type: string enum: [recovery_volume_protected] "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/envs: get: operationId: getStackEnvFiles tags: [Stacks] summary: List env file paths description: Returns the list of environment file paths referenced in the stack's compose file (`env_file` directives). parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: List of env file paths. content: application/json: schema: type: object required: [envFiles] properties: envFiles: type: array items: type: string example: [".env", "config/database.env"] "400": description: Invalid stack name. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/env-inventory: get: operationId: getStackEnvInventory tags: [Stacks] summary: Get environment inventory description: >- Returns a per-stack inventory of environment variables: each variable's source, whether Compose interpolates it or injects it into a container, and a status (present, missing, unused, duplicate, or unpersisted), plus likely-secret classification. Names only: a variable value is never read or returned. When the effective model cannot be rendered, `renderable` is false and the inventory is derived from the authored source alone. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: The environment inventory. content: application/json: schema: type: object required: [stackName, renderable, items, envFiles, summary] properties: stackName: type: string renderable: type: boolean description: False when the effective model could not be rendered; the inventory is then partial. items: type: array items: type: object required: [key, sources, usedForInterpolation, injectedIntoService, required, hasDefault, likelySecret, status] properties: key: type: string sources: type: array items: type: string enum: [compose-inline, env-file, dotenv, process-env, compose-ref] usedForInterpolation: type: boolean injectedIntoService: type: boolean required: type: boolean hasDefault: type: boolean likelySecret: type: boolean status: type: string enum: [present, missing, unused, duplicate, unpersisted] envFiles: type: array items: type: object properties: rawPaths: type: array items: type: string existence: type: string enum: [present, missing, unverifiable] required: type: boolean isInterpolationSource: type: boolean isInjectionSource: type: boolean declaringServices: type: array items: type: string summary: type: object properties: total: { type: integer } missing: { type: integer } unused: { type: integer } duplicate: { type: integer } unpersisted: { type: integer } likelySecret: { type: integer } "403": $ref: "#/components/responses/Forbidden" "404": description: Stack not found. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/label-inventory: get: operationId: getStackLabelInventory tags: [Stacks] summary: Get Docker label inventory for a stack description: >- Returns declared Compose labels and runtime container labels per service, with reconciliation hints (only in Compose, only on running container, present in both, or value changed when the values differ) and provenance for each runtime label (compose, image, runtime, compose-system, or unknown when a container or image cannot be inspected). Secret-like label values are redacted unless the caller is an admin and passes `reveal=1`. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: reveal in: query required: false schema: type: string enum: ['1', 'true'] responses: "200": description: Stack label inventory. content: application/json: schema: $ref: "#/components/schemas/StackLabelInventory" "403": $ref: "#/components/responses/Forbidden" "404": description: Stack not found. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/system/container-labels: get: operationId: getNodeContainerLabels tags: [Fleet] summary: Get the Docker label inventory for the active node description: >- Returns every container on the node with its labels and provenance (compose, image, runtime, compose-system, or unknown), plus an inverted `byLabel` index. Marks `partial` when a container or image inspect fails. Secret-like values are redacted unless the caller is an admin and passes `reveal=1`. Requires `node:read` permission. parameters: - $ref: "#/components/parameters/nodeId" - name: reveal in: query required: false schema: type: string enum: ['1', 'true'] responses: "200": description: Node container label inventory. content: application/json: schema: $ref: "#/components/schemas/NodeLabelInventory" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/fleet/container-labels: get: operationId: getFleetContainerLabels tags: [Fleet] summary: Get the Docker label inventory aggregated across the fleet description: >- Fans out to every node's container label inventory and aggregates the inverted index by key, value, and source. Unreachable or malformed nodes degrade into `nodeErrors` rather than failing the whole request. Secret-like values are redacted unless the caller is an admin and passes `reveal=1`. Requires `node:read` permission. parameters: - name: reveal in: query required: false schema: type: string enum: ['1', 'true'] responses: "200": description: Fleet-wide container label inventory. content: application/json: schema: $ref: "#/components/schemas/FleetLabelInventory" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/env: get: operationId: getStackEnv tags: [Stacks] summary: Get env file content description: Returns the content of an environment file. Defaults to `.env` if no `file` query parameter is specified. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: file in: query required: false description: Path to a specific env file (must be in the resolved env files list). schema: type: string responses: "200": description: Raw env file content (KEY=VALUE format). content: text/plain: schema: type: string "400": description: Invalid stack name or disallowed file path. content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Env file not found. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: Env file not found "500": $ref: "#/components/responses/InternalError" put: operationId: updateStackEnv tags: [Stacks] summary: Update env file description: Overwrites an environment file's content. Requires `stack:edit` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: file in: query required: false description: Path to a specific env file to update. schema: type: string requestBody: required: true content: application/json: schema: type: object required: [content] properties: content: type: string description: Full env file content (KEY=VALUE format). responses: "200": description: Env file saved. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Env file saved successfully "400": description: Invalid input. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/containers: get: operationId: getStackContainers tags: [Stacks] summary: List stack containers description: Returns all containers belonging to the specified stack. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Array of container objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Container" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/services: get: operationId: getStackServices tags: [Stacks] summary: List stack services description: Returns the service names defined in the stack's compose file. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Array of service names. content: application/json: schema: type: array items: type: string example: ["web", "db", "redis"] "400": description: Invalid stack name. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/observation/status: get: operationId: getObservationStackStatus tags: [Observation] summary: Stack container status for an AI observer description: | Returns one stack's containers projected for an Observe Only token: name, service, state, health, restart count, exit code, and start time. The response carries at most 100 containers and sets `truncated` when the stack has more. An `outcome` of `unavailable` is a successful response meaning the node could not read its own containers at that moment; the container list is empty and a later call may succeed. This is one of the five paths an Observe Only token may reach. Requires `stack:read` permission for the named stack. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Container facts wrapped in the observation envelope. content: application/json: schema: type: object required: [schemaVersion, generatedAt, data] properties: schemaVersion: type: integer enum: [1] generatedAt: type: integer description: Epoch ms of the evidence behind the answer. data: $ref: "#/components/schemas/ObservationStackStatusData" "400": description: Invalid stack name, or `capability_unavailable` when a targeted remote does not advertise `agent-observation-v1`. content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: > Authentication required. Provide a valid Bearer token. On a remote node, a 401 that carries an `x-sencho-proxy: 1` response header came from the node rejecting the API token stored for it on the hub, not from the caller's own token. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": description: The stack does not exist on the node (`STACK_NOT_FOUND`). content: application/json: schema: $ref: "#/components/schemas/Error" "503": description: > The targeted remote node is down or did not give a usable answer to the capability check (`node_unreachable`). A disconnected Pilot tunnel or a node with no API URL or token configured also answers 503, without a code. A Distributed API Proxy node that answers the check but rejects the API token stored for it answers 401 without a code, not 503. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/observation/readiness: get: operationId: getObservationStackReadiness tags: [Observation] summary: Stack update and rollback readiness for an AI observer description: | Returns one stack's canonical update and rollback readiness verdicts, projected for an Observe Only token. Each verdict slot carries the verdict, the id of its strongest reason, and when it was computed; the free-text description behind the verdict is withheld. `unavailableReason` is set to `summary_truncated` or `domain_error` only when both slots are null. This is one of the five paths an Observe Only token may reach. Requires `stack:read` permission for the named stack. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Readiness verdicts wrapped in the observation envelope. content: application/json: schema: type: object required: [schemaVersion, generatedAt, data] properties: schemaVersion: type: integer enum: [1] generatedAt: type: integer description: Epoch ms of the evidence behind the answer. data: $ref: "#/components/schemas/ObservationStackReadinessData" "400": description: Invalid stack name, or `capability_unavailable` when a targeted remote does not advertise `agent-observation-v1`. content: application/json: schema: $ref: "#/components/schemas/Error" "401": description: > Authentication required. Provide a valid Bearer token. On a remote node, a 401 that carries an `x-sencho-proxy: 1` response header came from the node rejecting the API token stored for it on the hub, not from the caller's own token. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": description: The stack does not exist on the node (`STACK_NOT_FOUND`). content: application/json: schema: $ref: "#/components/schemas/Error" "503": description: > The targeted remote node is down or did not give a usable answer to the capability check (`node_unreachable`). A disconnected Pilot tunnel or a node with no API URL or token configured also answers 503, without a code. A Distributed API Proxy node that answers the check but rejects the API token stored for it answers 401 without a code, not 503. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/deploy: post: operationId: deployStack tags: [Stacks] summary: Deploy stack description: | Runs `docker compose up -d` for the stack using atomic deployment with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Stack deployed successfully. content: application/json: schema: type: object required: [message] properties: message: type: string example: Deployed successfully healthGateId: type: string nullable: true description: | Id of the post-deploy health gate observation started for this deploy, for use with the health-gate endpoint. Null when the health gate is disabled on the node. "403": $ref: "#/components/responses/Forbidden" "409": description: | Deploy blocked before Compose ran. Common codes include a stack operation already in progress, a scan-policy block, or missing external networks (`code: missing_external_networks`). content: application/json: schema: type: object properties: error: type: string code: type: string example: missing_external_networks kind: type: string enum: [prompt, unsupported, unavailable, create_failed] networks: type: array items: type: object createdNames: type: array items: type: string remainingNames: type: array items: type: string "500": description: Deployment failed. content: application/json: schema: type: object required: [error] properties: error: type: string rolledBack: type: boolean description: Whether the stack was automatically rolled back. failure: $ref: "#/components/schemas/FailureClassification" "503": description: | Deploy blocked because Sencho could not render the Compose model or read Docker networking state to verify required external networks. /api/stacks/{stackName}/missing-external-networks: get: operationId: getMissingExternalNetworks tags: [Stacks] summary: List missing external networks for a stack description: | Returns whether declared `external: true` networks are missing on the target node, whether automatic creation is enabled, and per-network safety metadata. Requires `stack:read`. Used by interactive deploy preflight when the node advertises `guided-external-network-preflight`. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Missing-external-network envelope. content: application/json: schema: type: object required: [status, autoCreateEnabled, stackName, networks, declaredExternalCount] properties: status: type: string enum: [ok, render_unavailable, runtime_unavailable] autoCreateEnabled: type: boolean stackName: type: string declaredExternalCount: type: integer networks: type: array items: type: object required: [name, keys, declarations, safe, unsupportedFeatures, creationSpec] properties: name: type: string keys: type: array items: type: string declarations: type: array items: type: object safe: type: boolean blockReason: type: string unsupportedFeatures: type: array items: type: string creationSpec: type: object nullable: true "403": $ref: "#/components/responses/Forbidden" "404": description: Stack not found. content: application/json: schema: $ref: "#/components/schemas/Error" /api/stacks/{stackName}/down: post: operationId: downStack tags: [Stacks] summary: Tear down stack description: Runs `docker compose down` for the stack, removing containers and networks. The stack definition remains on disk. Requires `stack:deploy` permission. Pass `removeVolumes=true` to also remove compose volumes when the node advertises the `stack-down-remove-volumes` capability. A volume removal is refused with `423` when the stack declares a recovery repository volume, or when its volumes could not be read; nothing is stopped. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: removeVolumes in: query required: false schema: type: boolean description: When `true`, runs `docker compose down --volumes`. Only honored when the target node advertises `stack-down-remove-volumes`. responses: "200": description: Command started. content: application/json: schema: type: object required: [status] properties: status: type: string example: Command started "400": description: Volume removal requested on a node that does not support it. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "423": description: Volume removal refused because the stack declares a recovery repository volume, or its volumes could not be read. The `code` is `recovery_volume_protected`. content: application/json: schema: type: object required: [error, code] properties: error: type: string code: type: string enum: [recovery_volume_protected] "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/start: post: operationId: startStack tags: [Stacks] summary: Start stack containers description: Starts all stopped containers in the stack via the Docker Engine API. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Containers started. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "403": $ref: "#/components/responses/Forbidden" "404": description: No containers found for this stack. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/stop: post: operationId: stopStack tags: [Stacks] summary: Stop stack containers description: Stops all running containers in the stack via the Docker Engine API. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Containers stopped. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "403": $ref: "#/components/responses/Forbidden" "404": description: No containers found for this stack. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/restart: post: operationId: restartStack tags: [Stacks] summary: Restart stack containers description: Restarts all containers in the stack via the Docker Engine API. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Containers restarted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "403": $ref: "#/components/responses/Forbidden" "404": description: No containers found for this stack. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/update: post: operationId: updateStackImages tags: [Stacks] summary: Pull and recreate stack description: | Pulls latest images and recreates containers (`docker compose pull && up -d`) using atomic update with automatic rollback on failure. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Update completed. content: application/json: schema: type: object required: [status] properties: status: type: string example: Update completed healthGateId: type: string nullable: true description: | Id of the post-update health gate observation started for this update, for use with the health-gate endpoint. Null when the health gate is disabled on the node. recheckWarning: type: string description: | Present when Compose succeeded but post-update image verification still reports an available update or could not fully verify clearance. Optional for older nodes. "403": $ref: "#/components/responses/Forbidden" "500": description: Update failed. content: application/json: schema: type: object required: [error] properties: error: type: string rolledBack: type: boolean failure: $ref: "#/components/schemas/FailureClassification" /api/stacks/{stackName}/rollback: post: operationId: rollbackStack tags: [Stacks] summary: Rollback stack description: Restores the stack to its previous deployment state. Requires `stack:deploy` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Stack rolled back. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: 'Stack rolled back: compose and env files restored.' "403": $ref: "#/components/responses/Forbidden" "404": description: No backup available. content: application/json: schema: $ref: "#/components/schemas/Error" example: error: No backup available for this stack. "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/backup: get: operationId: getStackBackupStatus tags: [Stacks] summary: Check backup status description: Returns whether a deployment backup exists for this stack and when it was created. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Backup status. content: application/json: schema: type: object required: [exists] properties: exists: type: boolean timestamp: type: ["integer", "null"] description: Unix timestamp of the backup, or null if no backup exists. "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/update-preview: get: operationId: getStackUpdatePreview tags: [Stacks] summary: Compute image update preview (read-only) description: | Computes the current registry update preview for the stack without mutating persisted scanner state. Use POST when the client should reconcile sticky update indicators after an authoritative-negative result. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Update preview. content: application/json: schema: $ref: "#/components/schemas/UpdatePreview" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" post: operationId: reconcileStackUpdatePreview tags: [Stacks] summary: Compute update preview and reconcile sticky state description: | Computes the same preview as GET. When every image reports `check_status: ok` and `has_update` is false (mixed `ok` + `not_checkable` does not clear), deletes sticky partial, failed, and ok+true rows and sets `reconciled: true` only when a row was deleted. Existing ok+false rows are kept (`reconciled` stays false). Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Update preview, with reconcile outcome. content: application/json: schema: allOf: - $ref: "#/components/schemas/UpdatePreview" - type: object required: [reconciled] properties: reconciled: type: boolean description: | True when sticky update rows were cleared after an authoritative-negative preview. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/update-readiness: get: operationId: getStackUpdateReadiness tags: [Stacks] summary: Check update readiness description: | Computes an advisory readiness verdict for updating the stack right now, from the stored preflight result, open drift findings, live container health, the pending image change, the rollback backup slot, and node disk headroom. Advisory only: no verdict blocks an update. Requires `stack:read` permission. Pass `service` to scope the verdict to one Compose service on a multi-service stack. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: service in: query required: false schema: type: string description: | When set, readiness is scoped to this Compose service (multi-service stacks only). Stack-wide guardrails still apply; sibling and dependency signals become advisory. Single-service stacks reject this parameter with 400. responses: "200": description: Readiness report. content: application/json: schema: type: object required: [stack, computedAt, verdict, signals] properties: stack: type: string computedAt: type: integer description: Unix timestamp (ms) the report was computed. verdict: type: string enum: [ready, ready_with_warnings, review_required, blocked, unknown] signals: type: array items: type: object required: [id, status, title, detail, affectsVerdict] properties: id: type: string status: type: string enum: [ok, warning, attention, blocked, unknown] title: type: string detail: type: string affectsVerdict: type: boolean "400": description: Invalid service scope. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/effective-services: get: operationId: getStackEffectiveServices tags: [Stacks] summary: List effective Compose services description: | Returns the fail-closed effective service model for the stack (declared image, build presence, expected replicas, depends_on, healthcheck). Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Effective model result. content: application/json: schema: type: object required: [renderable] properties: renderable: type: boolean services: type: array items: type: object required: [name, declaredImage, hasBuild, expectedReplicas, dependsOn, hasHealthcheck] properties: name: type: string declaredImage: type: string nullable: true hasBuild: type: boolean expectedReplicas: type: integer dependsOn: type: array items: type: string hasHealthcheck: type: boolean code: type: string error: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/services/{serviceName}/update: post: operationId: updateStackService tags: [Stacks] summary: Update or rebuild one Compose service description: | Manually pulls or builds one declared service, then recreates only that service with `--no-deps --force-recreate`. Multi-service stacks only. Requires `stack:deploy`. Automation paths continue to update the full stack. parameters: - $ref: "#/components/parameters/stackName" - name: serviceName in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" responses: "200": description: Service update completed. content: application/json: schema: type: object required: [serviceName, healthGateId, observing, recoveryAvailable] properties: serviceName: type: string healthGateId: type: string nullable: true observing: type: boolean recoveryId: type: string nullable: true recoveryAvailable: type: boolean recheckWarning: type: string "400": description: Service update rejected (single-service stack, missing service, or unavailable capability). content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": description: Service update failed. content: application/json: schema: $ref: "#/components/schemas/Error" /api/stacks/{stackName}/services/{serviceName}/recovery: get: operationId: getStackServiceRecovery tags: [Stacks] summary: Latest active service recovery snapshot description: | Returns the newest active, unexpired recovery row for one service, or `recovery: null` when none is available. Used so Restore stays discoverable when Deploy Progress is disabled or dismissed. Requires `stack:deploy`. parameters: - $ref: "#/components/parameters/stackName" - name: serviceName in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" responses: "200": description: Recovery lookup completed. content: application/json: schema: type: object required: [recovery] properties: recovery: oneOf: - type: "null" - type: object required: [id, status, expiresAt, createdAt] properties: id: type: string status: type: string healthGateId: type: string nullable: true expiresAt: type: integer createdAt: type: integer majorityImageId: type: string declaredImageRef: type: string "400": description: Capability unavailable. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" /api/stacks/{stackName}/services/{serviceName}/restore: post: operationId: restoreStackService tags: [Stacks] summary: Restore one Compose service from a recovery snapshot description: | Retags the captured majority image onto the declared tag and recreates only that service. Body must include `recoveryId` from a prior service update. Requires `stack:deploy`. parameters: - $ref: "#/components/parameters/stackName" - name: serviceName in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [recoveryId] properties: recoveryId: type: string responses: "200": description: Service restore completed. content: application/json: schema: type: object required: [serviceName, healthGateId, observing, recoveryAvailable] properties: serviceName: type: string healthGateId: type: string nullable: true observing: type: boolean recoveryId: type: string nullable: true recoveryAvailable: type: boolean "400": description: Restore rejected (missing recoveryId or invalid binding). content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": description: Service restore failed. content: application/json: schema: $ref: "#/components/schemas/Error" /api/stacks/{stackName}/rollback-readiness: get: operationId: getStackRollbackReadiness tags: [Stacks] summary: Check rollback readiness description: | Reports what a rollback of this stack can actually restore: the backed up compose file, env coverage (variable names only, never values), the known previous image tag, and an explicit disclosure that volume and bind-mounted data are not covered by file backups. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" responses: "200": description: Rollback readiness report. content: application/json: schema: type: object required: [stack, computedAt, overall, items] properties: stack: type: string computedAt: type: integer overall: type: string enum: [ready, partial, not_ready] items: type: array items: type: object required: [id, state, label, detail] properties: id: type: string state: type: string enum: [ready, missing, unknown, not_covered] label: type: string detail: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/stacks/{stackName}/health-gate: get: operationId: getStackHealthGate tags: [Stacks] summary: Get post-update health gate result description: | Returns a post-deploy health gate observation for the stack: the run named by `gateId` (as returned in a deploy or update response), or the most recent run when omitted. Status `never-run` means no observation exists. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" - name: gateId in: query required: false schema: type: string description: A specific gate run id to read instead of the latest. responses: "200": description: Health gate report. content: application/json: schema: type: object required: [stack, status] properties: stack: type: string id: type: string nullable: true status: type: string enum: [observing, passed, failed, unknown, never-run] trigger: type: string nullable: true enum: [update, deploy, service_update, service_restore, null] reason: type: string nullable: true windowSeconds: type: integer nullable: true startedAt: type: integer nullable: true endedAt: type: integer nullable: true containers: type: array items: type: object required: [name, state, restarts] properties: name: type: string state: type: string description: Docker container state, or `missing` when it vanished mid-observation. health: type: string nullable: true restarts: type: integer service: type: string nullable: true role: type: string nullable: true enum: [primary, collateral, null] targetScope: type: string enum: [stack, service] description: Defaults to stack for legacy runs. serviceName: type: string nullable: true failureSource: type: string nullable: true enum: [primary, collateral, null] description: Set on failed service-scoped runs; null for stack and successful runs. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" # ── Containers ────────────────────────────────────────── /api/containers: get: operationId: listContainers tags: [Containers] summary: List all containers description: Returns all running containers on the target node. parameters: - $ref: "#/components/parameters/nodeId" - $ref: "#/components/parameters/nodeIdQuery" responses: "200": description: Array of container objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Container" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/containers/{id}/logs: get: operationId: streamContainerLogs tags: [Containers] summary: Stream container logs description: | Streams container logs as Server-Sent Events (SSE). The connection remains open and pushes new log lines as `data:` events in real time. parameters: - $ref: "#/components/parameters/idPath" - $ref: "#/components/parameters/nodeId" responses: "200": description: SSE log stream. content: text/event-stream: schema: type: string description: Each event is a `data:` line containing a JSON-encoded log string. "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/containers/{id}/start: post: operationId: startContainer tags: [Containers] summary: Start container description: Starts a stopped container. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" - $ref: "#/components/parameters/nodeId" responses: "200": description: Container started. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Container started "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/containers/{id}/stop: post: operationId: stopContainer tags: [Containers] summary: Stop container description: Stops a running container. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" - $ref: "#/components/parameters/nodeId" responses: "200": description: Container stopped. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Container stopped "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/containers/{id}/restart: post: operationId: restartContainer tags: [Containers] summary: Restart container description: Restarts a container. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" - $ref: "#/components/parameters/nodeId" responses: "200": description: Container restarted. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Container restarted "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" # ── API Tokens ────────────────────────────────────────── /api/api-tokens: post: operationId: createApiToken tags: [API Tokens] summary: Create API token description: | Generates a new scoped API token. The full token is only returned in the creation response and cannot be retrieved again. Requires admin role. **Note:** API tokens cannot create other API tokens. responses: "201": description: Token created. The `token` field contains the full JWT. Save it now; it will not be shown again. content: application/json: schema: type: object required: [id, token] properties: id: type: integer token: type: string description: Full JWT token. Store securely — this is the only time it's returned. "400": description: Validation error (missing/invalid fields, or maximum of 25 active tokens reached). content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "409": description: An active token with this name already exists. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" requestBody: required: true content: application/json: schema: type: object required: [name, scope] properties: name: type: string description: Human-readable token name. Must be unique among the user's active tokens. maxLength: 100 example: CI/CD Deploy Token scope: type: string description: | Permission scope: - `observe-only` - exactly five read-only observation endpoints; requires an expiry - `read-only` - GET requests only - `deploy-only` - Read + stack deploy/stop/restart operations - `full-admin` - All operations (except license, user, and token management) enum: [observe-only, read-only, deploy-only, full-admin] expires_in: type: ["integer", "null"] description: Token lifetime in days. Use `null` for no expiry. Required when `scope` is `observe-only`, where `null` is rejected. enum: [30, 60, 90, 365, null] example: 90 get: operationId: listApiTokens tags: [API Tokens] summary: List API tokens description: | Returns all API tokens for the current user. Token hashes are never exposed. Requires admin role. **Note:** API tokens cannot list other API tokens. responses: "200": description: Array of token metadata. content: application/json: schema: type: array items: $ref: "#/components/schemas/ApiToken" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/api-tokens/{id}: delete: operationId: revokeApiToken tags: [API Tokens] summary: Revoke API token description: | Permanently revokes an API token. Users can only revoke their own tokens. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Token revoked. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Invalid token ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ── Webhooks ──────────────────────────────────────────── /api/webhooks: get: operationId: listWebhooks tags: [Webhooks] summary: List webhooks description: Returns all configured webhooks with masked secrets. responses: "200": description: Array of webhook objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Webhook" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" post: operationId: createWebhook tags: [Webhooks] summary: Create webhook description: | Creates a new webhook for a stack. The webhook secret is auto-generated and only returned in the creation response. Requires admin role. requestBody: required: true content: application/json: schema: type: object required: [name, stack_name, action] properties: name: type: string maxLength: 100 example: GitHub Deploy Hook stack_name: type: string description: Target stack name. example: my-app action: type: string enum: [deploy, restart, stop, start, pull, git-pull] description: Action to execute when the webhook is triggered. `git-pull` requires a Git source attached to the stack. enabled: type: boolean default: true node_id: type: integer description: Node the webhook executes against. Defaults to the request's resolved node or the default node. responses: "201": description: Webhook created. The `secret` field is the HMAC signing key; save it now. content: application/json: schema: type: object required: [id, secret] properties: id: type: integer secret: type: string description: HMAC-SHA256 signing secret. Store securely; this is the only time it's returned in full. "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/webhooks/{id}: put: operationId: updateWebhook tags: [Webhooks] summary: Update webhook description: Updates webhook configuration. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: type: object properties: name: type: string maxLength: 100 stack_name: type: string action: type: string enum: [deploy, restart, stop, start, pull, git-pull] enabled: type: boolean node_id: type: integer description: Retarget the webhook to a different node. responses: "200": description: Webhook updated. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteWebhook tags: [Webhooks] summary: Delete webhook description: Permanently deletes a webhook. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Webhook deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/webhooks/{id}/history: get: operationId: getWebhookHistory tags: [Webhooks] summary: Get webhook execution history description: Returns the execution log for a webhook. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Array of execution records. content: application/json: schema: type: array items: $ref: "#/components/schemas/WebhookExecution" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/webhooks/{id}/trigger: post: operationId: triggerWebhook tags: [Webhooks] summary: Trigger webhook description: | Externally triggers a webhook action. This endpoint is public but requires a valid HMAC-SHA256 signature in the `X-Webhook-Signature` header. Sign the exact raw bytes of the request body; an empty body is rejected. Compute the signature as: `sha256=` + HMAC-SHA256(raw_request_body, webhook_secret). Every unauthenticated rejection (unknown id, disabled webhook, missing or invalid signature, empty body) returns the same `404` response so callers cannot enumerate webhook ids from the response surface. security: - webhookSignature: [] parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: type: object properties: action: type: string enum: [deploy, restart, stop, start, pull, git-pull] description: Override the default webhook action. Must be one of the allowed actions. responses: "202": description: Webhook accepted and action queued. content: application/json: schema: type: object required: [message, action] properties: message: type: string example: Webhook accepted action: type: string example: deploy "400": description: Authentication succeeded but the body's `action` override was not in the allowlist. content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Authentication failed. The webhook is unknown or disabled, the signature header is missing, the body was empty, or the signature did not match. Sencho returns the same response for every unauthenticated case. content: application/json: schema: $ref: "#/components/schemas/Error" "429": description: Rate limit exceeded. The trigger endpoint allows 500 requests per minute per source IP; CI/CD callers behind shared NAT may share that budget. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" # ── Nodes ─────────────────────────────────────────────── /api/nodes: get: operationId: listNodes tags: [Nodes] summary: List all nodes description: Returns all registered nodes (local and remote). responses: "200": description: Array of node objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Node" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" post: operationId: createNode tags: [Nodes] summary: Register a new node description: | Adds a new local or remote node. Only one local node is allowed per instance; attempting to create a second local node returns 409. Remote nodes require an API URL pointing to another Sencho instance and an API token for authentication. Requires `node:manage` permission. Node type is immutable after creation. **Note:** API tokens cannot manage nodes. requestBody: required: true content: application/json: schema: type: object required: [name, type] properties: name: type: string example: production-server type: type: string enum: [local, remote] compose_dir: type: string description: Base directory for Compose stacks (defaults to `/app/compose`). is_default: type: boolean description: Set as the default node. api_url: type: string description: Remote Sencho instance URL (required for remote nodes). example: https://sencho.example.com:1852 api_token: type: string description: API token for authenticating with the remote Sencho instance. responses: "200": description: Node created. content: application/json: schema: type: object required: [success, id] properties: success: type: boolean id: type: integer description: New node ID. warning: type: string description: Warning if the remote URL uses plain HTTP. "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "409": description: Node name already exists, or a local node already exists. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/nodes/{id}: get: operationId: getNode tags: [Nodes] summary: Get node details parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Node object. content: application/json: schema: $ref: "#/components/schemas/Node" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" put: operationId: updateNode tags: [Nodes] summary: Update node description: Updates node configuration. Node type is immutable after creation. Requires `node:manage` permission. parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: type: object properties: name: type: string compose_dir: type: string is_default: type: boolean api_url: type: string api_token: type: string responses: "200": description: Node updated. content: application/json: schema: type: object required: [success] properties: success: type: boolean warning: type: string "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteNode tags: [Nodes] summary: Delete node description: Removes a node from the fleet. The last local node cannot be deleted. Requires `node:manage` permission. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Node deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Cannot delete the only local node (or the default node). content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/nodes/{id}/test: post: operationId: testNodeConnection tags: [Nodes] summary: Test node connection description: Tests connectivity to a node. For remote nodes, pings the remote Sencho instance's health endpoint. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Connection test result. content: application/json: schema: type: object required: [success] properties: success: type: boolean message: type: string error: type: string "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/nodes/{id}/meta: get: operationId: getNodeMeta tags: [Nodes] summary: Get node capabilities description: "Returns the version and supported capabilities for a specific node. For local nodes, returns this instance's own metadata. For remote nodes, relays the response from the remote instance's `/api/meta` endpoint. Returns `{ version: null, capabilities: [] }` if the remote node is offline or doesn't support the meta endpoint." parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Node capability metadata. content: application/json: schema: type: object required: [version, capabilities] properties: version: type: string nullable: true description: Sencho version of the node, or null if unavailable. example: "0.31.0" capabilities: type: array description: List of feature capabilities the node supports. Empty array if unavailable. items: type: string example: ["stacks", "containers", "fleet"] "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" # ── Fleet ─────────────────────────────────────────────── /api/fleet/overview: get: operationId: getFleetOverview tags: [Fleet] summary: Fleet overview description: Returns an aggregated overview of all nodes including status, container stats, and stack counts. responses: "200": description: Array of fleet node overviews. content: application/json: schema: type: array items: $ref: "#/components/schemas/FleetNodeOverview" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/fleet/readiness: get: operationId: getFleetReadiness tags: [Fleet] summary: Fleet readiness description: | Aggregates readiness across six domains (connectivity, workloads, updates, recovery, security, control) into a fleet summary, a prioritized findings list, and a per-node matrix. Each node and each domain degrades independently: a node that does not answer keeps the domains the control instance could establish and reports the rest as unavailable or unknown with a reason, so one unreachable node neither blanks the page nor erases another node's results. Nothing is scored, and no state is reported as healthy unless it was actually established. Requires `node:read` permission. The `control` domain is withheld from callers that do not pass the administrative check, and is named in `domainsOmitted`. parameters: - name: domains in: query required: false schema: type: string description: | Comma-separated subset of `connectivity`, `workloads`, `updates`, `recovery`, `security`, `control`. Defaults to every domain. - name: nodeIds in: query required: false schema: type: string description: Comma-separated list of node ids to limit the response to. responses: "200": description: Fleet readiness aggregate. content: application/json: schema: $ref: "#/components/schemas/FleetReadinessResponse" "400": description: Invalid domains or nodeIds filter. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/fleet/observation/nodes: get: operationId: getObservationNodes tags: [Observation] summary: List nodes for an AI observer description: | Returns every node this hub knows, projected for an Observe Only token. Each row carries the node's type, the transport the hub would use to reach it, its status, the last contact this hub can vouch for, and whether it is cordoned. Node URLs, compose directories, enrollment credentials, and cordon reasons are withheld. This endpoint is answered by the hub the caller is signed into and is one of the five paths an Observe Only token may reach. Requires `node:read` permission. responses: "200": description: Node list wrapped in the observation envelope. content: application/json: schema: type: object required: [schemaVersion, generatedAt, data] properties: schemaVersion: type: integer enum: [1] generatedAt: type: integer description: Epoch ms of the evidence behind the answer. data: type: array items: $ref: "#/components/schemas/ObservationNode" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/fleet/observation/health: get: operationId: getObservationFleetHealth tags: [Observation] summary: Fleet readiness for an AI observer description: | Returns the fleet readiness aggregate projected for an Observe Only token: per-node domain cells, the findings behind them, state and severity counts, and a staleness flag. Findings are capped at 50, worst first. The `control` domain is withheld from this surface and named in `domainsOmitted`. The aggregate is cached for up to 15 seconds and shared across callers, so `generatedAt` and each cell's `evidenceAgeMs` describe the age of the evidence, not of the request. Dismissals applied in the Fleet Readiness view are not read here, because reading them would mutate dismissal state. This is one of the five paths an Observe Only token may reach. Requires `node:read` permission. responses: "200": description: Fleet readiness wrapped in the observation envelope. content: application/json: schema: type: object required: [schemaVersion, generatedAt, data] properties: schemaVersion: type: integer enum: [1] generatedAt: type: integer description: Epoch ms of the evidence behind the answer. data: $ref: "#/components/schemas/ObservationFleetHealthData" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/fleet/node/{nodeId}/stacks: get: operationId: getFleetNodeStacks tags: [Fleet] summary: List stacks on a fleet node description: Returns stack names from a specific fleet node. parameters: - name: nodeId in: path required: true schema: type: integer responses: "200": description: Array of stack names. content: application/json: schema: type: array items: type: string "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" "502": description: Failed to reach remote node. content: application/json: schema: $ref: "#/components/schemas/Error" "503": description: Remote node not configured. content: application/json: schema: $ref: "#/components/schemas/Error" /api/fleet/node/{nodeId}/stacks/{stackName}/containers: get: operationId: getFleetNodeStackContainers tags: [Fleet] summary: List containers in a fleet node stack description: Returns containers for a specific stack on a specific fleet node. parameters: - name: nodeId in: path required: true schema: type: integer - $ref: "#/components/parameters/stackName" responses: "200": description: Array of container objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Container" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" "503": description: Remote node not configured. content: application/json: schema: $ref: "#/components/schemas/Error" /api/fleet/update-status: get: operationId: getFleetUpdateStatus tags: [Fleet] summary: Fleet update status description: Returns version comparison and active update status for all nodes. Compares each node's version against the gateway's version. Local rows include compose pin details; remote rows expose only the safe pin kind and blocked flag from each node's public meta. responses: "200": description: Update status for all nodes. content: application/json: schema: type: object properties: nodes: type: array items: type: object properties: nodeId: type: integer name: type: string type: type: string enum: [local, remote] version: type: string nullable: true latestVersion: type: string online: type: boolean description: Whether the gateway could reach the node when it polled. Always `true` for the local row. When `false`, `version` is `null` and `updateAvailable` is `false`, so a `false` `updateAvailable` does not mean the node is up to date. updateAvailable: type: boolean updateStatus: type: string nullable: true enum: [updating, completed, timeout, failed, null] skipActive: type: boolean skippedVersion: type: string nullable: true imagePinKind: type: string nullable: true composeImageRef: type: string nullable: true targetImageRef: type: string nullable: true updateBlocked: type: boolean updateBlockedReason: type: string nullable: true "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" /api/fleet/nodes/{nodeId}/skip-version: post: operationId: skipNodeVersion tags: [Fleet] summary: Skip a node version description: Marks a version as skipped for a node, hiding the update prompt and excluding it from update-all. Admin only. parameters: - name: nodeId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: type: object required: [version] properties: version: type: string description: Semver version string to skip. responses: "204": description: Skip recorded. "400": description: Invalid parameters. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Node not found. delete: operationId: unskipNodeVersion tags: [Fleet] summary: Clear a node version skip description: Removes the skipped-version record for a node. Admin only. parameters: - name: nodeId in: path required: true schema: type: integer responses: "204": description: Skip cleared. "400": description: Invalid parameters. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Node not found. /api/fleet/update-status/release-notes: get: operationId: getFleetReleaseNotes tags: [Fleet] summary: Release notes for the latest Sencho version description: Returns the GitHub release body and URL for the latest Sencho release, used by the Changelog tab. responses: "200": description: Release notes. content: application/json: schema: type: object properties: releaseNotes: type: string nullable: true htmlUrl: type: string nullable: true "401": $ref: "#/components/responses/Unauthorized" /api/fleet/nodes/{nodeId}/update: post: operationId: triggerNodeUpdate tags: [Fleet] summary: Trigger node update description: Initiates a self-update on the specified node. For remote nodes, sends the update command via the proxy. For local nodes, triggers the update directly. parameters: - name: nodeId in: path required: true schema: type: integer responses: "202": description: Update initiated. content: application/json: schema: type: object properties: message: type: string "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Node not found. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: Update already in progress. content: application/json: schema: $ref: "#/components/schemas/Error" "503": description: Self-update not available on the target node. content: application/json: schema: $ref: "#/components/schemas/Error" /api/fleet/update-all: post: operationId: triggerFleetUpdateAll tags: [Fleet] summary: Update all outdated nodes description: Triggers self-update on all remote nodes running an older version than the gateway. Forwards the hub compare target as `targetVersion` so semver-pinned remotes can repin before recreate. Local nodes are excluded from bulk updates. responses: "202": description: Bulk update initiated. content: application/json: schema: type: object properties: updating: type: array items: type: string description: Names of nodes where update was triggered. skipped: type: array items: type: string description: Names of nodes that were skipped. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" /api/fleet/snapshots: post: operationId: createFleetSnapshot tags: [Fleet] summary: Create fleet snapshot description: Creates a point-in-time backup of all compose files across all nodes. Requires admin role. requestBody: required: false content: application/json: schema: type: object properties: description: type: string description: Optional description for the snapshot. example: Pre-migration backup responses: "201": description: Snapshot created. content: application/json: schema: $ref: "#/components/schemas/FleetSnapshot" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" get: operationId: listFleetSnapshots tags: [Fleet] summary: List fleet snapshots description: Returns paginated fleet snapshots. parameters: - name: limit in: query schema: type: integer default: 50 maximum: 100 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Paginated snapshot list. content: application/json: schema: type: object required: [snapshots, total] properties: snapshots: type: array items: $ref: "#/components/schemas/FleetSnapshot" total: type: integer "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/fleet/snapshots/{id}: get: operationId: getFleetSnapshot tags: [Fleet] summary: Get snapshot details description: Returns full snapshot details including all captured files grouped by node and stack. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Snapshot with file contents. content: application/json: schema: $ref: "#/components/schemas/FleetSnapshotDetail" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteFleetSnapshot tags: [Fleet] summary: Delete snapshot description: Permanently deletes a fleet snapshot. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Snapshot deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/fleet/snapshots/{id}/restore: post: operationId: restoreFleetSnapshot tags: [Fleet] summary: Restore from snapshot description: Restores a specific stack on a specific node from the snapshot. Optionally redeploys after restore. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: type: object required: [nodeId, stackName] properties: nodeId: type: integer description: Target node ID to restore to. stackName: type: string description: Stack to restore. redeploy: type: boolean default: false description: Whether to redeploy the stack after restoring files. responses: "200": description: Stack restored. content: application/json: schema: $ref: "#/components/schemas/SuccessMessage" example: message: Stack restored successfully. "400": description: Missing required fields. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "409": description: One or more snapshot files for the stack could not be decrypted. content: application/json: schema: type: object required: [error, code] properties: error: type: string code: type: string enum: [SNAPSHOT_FILE_UNAVAILABLE] "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks: get: operationId: listScheduledTasks tags: [Scheduled Tasks] summary: List scheduled tasks description: Returns all scheduled tasks. Requires admin role. responses: "200": description: Array of scheduled task objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/ScheduledTask" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" post: operationId: createScheduledTask tags: [Scheduled Tasks] summary: Create scheduled task description: | Creates a new recurring task. Action-target rules: - `restart` requires `target_type: stack` or `target_type: container` (with `target_id` and `node_id`) - `auto_stop` and `auto_start` accept `target_type: stack` or `target_type: container` - `snapshot` requires `target_type: fleet` - `prune` requires `target_type: system` Requires admin role. requestBody: required: true content: application/json: schema: type: object required: [name, target_type, action, cron_expression] properties: name: type: string example: Nightly restart target_type: type: string enum: [stack, fleet, system, container] target_id: type: string description: Stack or container name (required when target_type is `stack` or `container`). node_id: type: integer description: Target node ID (required when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] cron_expression: type: string description: Standard 5-field cron expression. example: "0 3 * * *" enabled: type: boolean default: true prune_targets: type: array items: type: string enum: [containers, images, networks, volumes] description: Resources to prune (only for `prune` action). target_services: type: array items: type: string description: Specific services to restart (only for `restart` action). prune_label_filter: type: string description: Docker label filter for prune operations. responses: "201": description: Task created. content: application/json: schema: $ref: "#/components/schemas/ScheduledTask" "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks/{id}: get: operationId: getScheduledTask tags: [Scheduled Tasks] summary: Get scheduled task parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Scheduled task object. content: application/json: schema: $ref: "#/components/schemas/ScheduledTask" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" put: operationId: updateScheduledTask tags: [Scheduled Tasks] summary: Update scheduled task description: Updates task configuration. Same validation rules as creation apply. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: type: object properties: name: type: string target_type: type: string enum: [stack, fleet, system, container] target_id: type: string description: Stack or container name when target_type is `stack` or `container`. node_id: type: integer action: type: string enum: [restart, snapshot, prune] cron_expression: type: string enabled: type: boolean prune_targets: type: array items: type: string target_services: type: array items: type: string prune_label_filter: type: string responses: "200": description: Task updated. content: application/json: schema: $ref: "#/components/schemas/ScheduledTask" "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteScheduledTask tags: [Scheduled Tasks] summary: Delete scheduled task description: Permanently deletes a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Task deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks/{id}/toggle: patch: operationId: toggleScheduledTask tags: [Scheduled Tasks] summary: Toggle task enabled/disabled description: Flips the enabled state of a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Task toggled. Returns updated task. content: application/json: schema: $ref: "#/components/schemas/ScheduledTask" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks/{id}/run: post: operationId: runScheduledTask tags: [Scheduled Tasks] summary: Run task immediately description: Executes the scheduled task immediately, regardless of its cron schedule. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Task executed. Returns updated task with `last_run_at`. content: application/json: schema: $ref: "#/components/schemas/ScheduledTask" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks/{id}/runs: get: operationId: listScheduledTaskRuns tags: [Scheduled Tasks] summary: List task execution history description: Returns paginated execution history for a scheduled task. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" - name: limit in: query schema: type: integer default: 50 - name: offset in: query schema: type: integer default: 0 responses: "200": description: Array of task run records. content: application/json: schema: type: array items: $ref: "#/components/schemas/ScheduledTaskRun" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/scheduled-tasks/{id}/runs/export: get: operationId: exportScheduledTaskRuns tags: [Scheduled Tasks] summary: Export task history as CSV description: Downloads the execution history for a scheduled task as a CSV file. Requires admin role. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: CSV file download. content: text/csv: schema: type: string description: "CSV with columns: Timestamp, Source, Status, Duration (s), Details" "400": description: Invalid task ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" # ── Readiness ─────────────────────────────────────────── /api/readiness/evidence: get: operationId: getReadinessEvidence tags: [Fleet] summary: Node readiness evidence description: | This node's readiness evidence: workload, security, and stored recovery facts, in the minimal shape the Fleet Readiness fan-out consumes. The control instance reads this during its fan-out, so a node that does not answer, or a domain it cannot produce, reads as unknown rather than as healthy. Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/nodeId" responses: "200": description: Readiness evidence for the node. content: application/json: schema: $ref: "#/components/schemas/NodeReadinessEvidence" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" # ── Registries ────────────────────────────────────────── /api/registries: get: operationId: listRegistries tags: [Registries] summary: List registries description: | Returns all configured private container registries. Secrets are never included in the response. Admin role required. Available on every tier. API tokens receive `SCOPE_DENIED`. responses: "200": description: Array of registry objects. content: application/json: schema: type: array items: $ref: "#/components/schemas/Registry" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" post: operationId: createRegistry tags: [Registries] summary: Create registry description: | Adds a new private container registry. The secret is encrypted at rest using AES-256-GCM. Admin role required. Docker Hub, GHCR, and custom registries are available on every tier. Creating an AWS ECR registry requires an Admiral license. API tokens receive `SCOPE_DENIED`. requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RegistryCreate" responses: "201": description: Registry created. content: application/json: schema: type: object required: [id] properties: id: type: integer "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/registries/{id}: put: operationId: updateRegistry tags: [Registries] summary: Update registry description: | Updates an existing registry. All fields are optional; only provided fields are changed. Admin role required. Updating a Docker Hub, GHCR, or custom registry is available on every tier. Updating an AWS ECR registry requires an Admiral license. API tokens receive `SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/idPath" requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/RegistryCreate" responses: "200": description: Registry updated. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Validation error. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" delete: operationId: deleteRegistry tags: [Registries] summary: Delete registry description: | Removes a registry and its encrypted credentials. Admin role required. Available on every tier. API tokens receive `SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Registry deleted. content: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" "400": description: Invalid registry ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalError" /api/registries/{id}/test: post: operationId: testRegistry tags: [Registries] summary: Test registry connection description: | Tests connectivity and authentication against a configured registry. Admin role required. Testing a Docker Hub, GHCR, or custom registry is available on every tier. Testing an AWS ECR registry requires an Admiral license. API tokens receive `SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/idPath" responses: "200": description: Test result. content: application/json: schema: type: object properties: success: type: boolean message: type: string "400": description: Invalid registry ID. content: application/json: schema: $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" # ── Image Updates ─────────────────────────────────────── /api/image-updates: get: operationId: getImageUpdates tags: [Image Updates] summary: Get image update status description: Returns the update availability status for all stacks. responses: "200": description: Array of update status objects per stack. content: application/json: schema: type: array items: $ref: "#/components/schemas/ImageUpdateStatus" "401": $ref: "#/components/responses/Unauthorized" "500": $ref: "#/components/responses/InternalError" /api/image-updates/refresh: post: operationId: refreshImageUpdates tags: [Image Updates] summary: Trigger image update check description: | Triggers a background check for newer image versions across all stacks. Rate limited to one refresh per 10 minutes. Requires admin role. responses: "200": description: Refresh started. content: application/json: schema: type: object properties: success: type: boolean message: type: string example: Image update check started in background. "401": $ref: "#/components/responses/Unauthorized" "429": description: Rate limited — wait at least 10 minutes between refreshes. content: application/json: schema: $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" /api/image-updates/status: get: operationId: getImageUpdateCheckStatus tags: [Image Updates] summary: Get scanner cadence status description: Returns the scanner's current status including scheduling mode and next check time. responses: "200": description: Scanner status. content: application/json: schema: $ref: "#/components/schemas/ImageUpdateCheckStatus" "401": $ref: "#/components/responses/Unauthorized" /api/image-updates/interval: put: operationId: setImageUpdateCheckSchedule tags: [Image Updates] summary: Set the detection schedule description: Configure the image-update check cadence. In interval mode only `minutes` is required. In cron mode also send `mode` and `cron`. requestBody: required: true content: application/json: schema: type: object required: [minutes] properties: minutes: type: integer minimum: 15 maximum: 1440 description: Fallback interval in minutes (used in interval mode, or as fallback when cron is unset). mode: type: string enum: [interval, cron] description: Scheduling mode. Omit to leave unchanged. cron: type: string description: 5-field cron expression (required when mode is 'cron'). Nicknames like @daily are accepted. responses: "200": description: Schedule updated. content: application/json: schema: $ref: "#/components/schemas/ImageUpdateCheckStatus" "400": $ref: "#/components/responses/Forbidden" "401": $ref: "#/components/responses/Unauthorized" "403": description: Non-admin users cannot change the schedule. /api/registries/{id}/tags: get: tags: [Registries] summary: List tags for a repository on a configured registry description: | Hub-only. Admin only. Credentials are resolved by exact registry ID. Upstream registry auth failures return 424 with a REGISTRY_* code, never HTTP 401. parameters: - name: id in: path required: true schema: { type: integer } - name: repository in: query required: true schema: { type: string } description: Path-safe repository name (no host or scheme) - name: cursor in: query required: false schema: { type: string } - name: limit in: query required: false schema: { type: integer, minimum: 1, maximum: 100, default: 50 } responses: '200': description: Tag page content: application/json: schema: $ref: "#/components/schemas/RegistryTagList" '400': description: Invalid repository or response '404': description: Registry or repository not found '424': description: Upstream registry auth or policy failure (not a Sencho session 401) '502': description: Upstream registry transport failure # ── Recovery ──────────────────────────────────────────── /api/recovery/node: get: operationId: getRecoveryNode tags: [Recovery] summary: Read this node's recovery status description: | Returns this node's recovery contract version and whether it can create and restore volume recovery points, with the reason when it cannot. The `operationId` echoes the dispatch id a control instance sent, so a caller can match the answer to its request. A node that predates this route answers `404`, which a control instance reads as an update being required. parameters: - $ref: "#/components/parameters/nodeId" responses: "200": description: This node's recovery descriptor. content: application/json: schema: type: object required: [contractVersion, volumeProtection, operationId] properties: contractVersion: type: integer example: 1 volumeProtection: $ref: "#/components/schemas/RecoveryVolumeProtection" operationId: type: ["string", "null"] description: The dispatch id a control instance sent, echoed back, or null for a direct call. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalError" /api/recovery/repository: get: operationId: getRecoveryRepository tags: [Recovery] summary: Read this node's recovery repository description: | Returns the repository's health, the reason it is not healthy, the failure domain, and the warning that points share this node's failure domain. The `repository` object names the volume, fingerprint, secret state, creation time, and the last check; it is returned only to a caller who may change these settings. The repository password is never returned by any shape. parameters: - $ref: "#/components/parameters/nodeId" responses: "200": description: Repository health, and configuration facts for a caller who may change settings. content: application/json: schema: type: object required: [health, reason, failureDomain, warning, initialized] properties: health: type: string enum: [available, unavailable, update_required, unknown] reason: type: string description: The capability reason, for example `engine_registered`, `helper_unavailable`, or `repository_not_initialized`. failureDomain: type: string enum: [same_failure_domain, separate, unknown] warning: type: string initialized: type: boolean repository: oneOf: - $ref: "#/components/schemas/RecoveryRepositoryInfo" - type: "null" description: Present only for a caller who may change settings. "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "500": $ref: "#/components/responses/RecoveryInternalError" post: operationId: provisionRecoveryRepository tags: [Recovery] summary: Provision or replace this node's recovery repository description: | Provisions the repository, or continues a provisioning attempt a crash left half done. Idempotent: the same key answers the operation it already created. With `replace: true`, registers a new repository volume generation and moves the active pointer to it; the previous repository and its points are kept. A request that builds or replaces the repository takes the node's repository lock, the lock every capture, verification, and restore holds, so it answers `409` and is not recorded while one of them (or another replacement) runs, or while a stale repository lock awaits a reviewed release. A repeated request on a working repository changes nothing and takes no lock. Requires permission to change system settings. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [idempotencyKey] properties: idempotencyKey: type: string minLength: 1 maxLength: 200 replace: type: boolean description: Provision a new repository and keep the previous one and its points. responses: "202": description: Provisioning admitted. content: application/json: schema: $ref: "#/components/schemas/RecoveryAcceptedOperation" "200": description: Replay of an earlier request with the same idempotency key. content: application/json: schema: $ref: "#/components/schemas/RecoveryAcceptedOperation" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "503": $ref: "#/components/responses/RecoveryUnavailable" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/storage: get: operationId: getRecoveryStorage tags: [Recovery] summary: List volumes and their coverage description: | With `?stack=`, returns the volumes that stack's rendered model declares. Without it, considers every volume on the node. Each root carries its eligibility, its coverage reason, and the covering point when there is one. A root the caller may not read appears as `{"access": "inaccessible"}` with its name withheld, and it still counts against the verdict. A volume recreated since its last capture reads `older_generation` at once, from Docker's creation time, without waiting for a new capture. parameters: - $ref: "#/components/parameters/nodeId" - name: stack in: query required: false description: | One stack directory name. Omit to consider every volume on the node. A stack that does not exist lists nothing; a repeated parameter or a name that is not a valid stack name answers `400`. schema: type: string responses: "200": description: Roots, the aggregate verdict, and this node's volume-protection capability. content: application/json: schema: type: object required: [roots, verdict, capability] properties: roots: type: array items: $ref: "#/components/schemas/RecoveryStorageRoot" verdict: $ref: "#/components/schemas/RecoveryCoverageVerdict" capability: $ref: "#/components/schemas/RecoveryVolumeProtection" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/storage/{storageId}/points: get: operationId: listRecoveryPoints tags: [Recovery] summary: List a volume's recovery points description: Returns the storage identity with every point recorded against its key, newest first, each with its verifications. A caller who may not read the storage is refused. The volume itself need not exist any more. parameters: - name: storageId in: path required: true description: Storage identity id, as returned in a root's `storageId`. schema: type: string - $ref: "#/components/parameters/nodeId" responses: "200": description: The storage identity and its points. content: application/json: schema: type: object required: [storage, points] properties: storage: type: object required: [id, locator, generation, state] properties: id: { type: string } locator: type: string description: The volume name. generation: type: integer description: Increments when the volume is recreated under the same name. state: type: string enum: [current, superseded] points: type: array items: type: object required: [point, verifications] properties: point: $ref: "#/components/schemas/RecoveryPoint" verifications: type: array items: $ref: "#/components/schemas/RecoveryVerification" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/storage/points: post: operationId: createRecoveryPoint tags: [Recovery] summary: Create a point of a volume description: | Admits a root capture of one named volume. Eligibility, readiness, dependent stacks, the access check, a generation probe, the operation record, and the locks all happen before the answer, so a held or stale lock is a real `409` rather than a background failure. A refused request is not recorded, so the same idempotency key can be sent again once the lock is gone. One repository lock covers the node: only one capture, verification, restore, or repository replacement runs at a time on it. A node with no repository answers `503 capability_unavailable` with reason `repository_not_initialized`. The capture runs in the background and is followed by a repository-wide metadata check before the operation ends. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [volumeName, idempotencyKey] properties: volumeName: type: string minLength: 2 maxLength: 255 pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]+$' description: A name Docker accepts for a volume. Anything else answers `400`. idempotencyKey: type: string minLength: 1 maxLength: 200 responses: "202": description: Capture admitted. content: application/json: schema: $ref: "#/components/schemas/RecoveryAcceptedOperation" "200": description: Replay of an earlier request with the same idempotency key. content: application/json: schema: $ref: "#/components/schemas/RecoveryAcceptedOperation" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "503": $ref: "#/components/responses/RecoveryUnavailable" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/points/{pointId}/verify: post: operationId: verifyRecoveryPoint tags: [Recovery] summary: Verify a point description: | Runs a restic check over the whole repository, so the result covers every point in it. `engine_check_metadata` is the default; `engine_check_read_data` also reads every pack file back. The result is appended as a verification on every point in the repository and never rewrites an older one. A check that finds damage ends the operation `failed` with `integrity_mismatch`. The source volume need not exist any more. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - name: pointId in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [idempotencyKey] properties: idempotencyKey: type: string minLength: 1 maxLength: 200 method: type: string enum: [engine_check_metadata, engine_check_read_data] default: engine_check_metadata responses: "202": description: Verification admitted. content: application/json: schema: type: object required: [operation] properties: operation: $ref: "#/components/schemas/RecoveryOperation" "200": description: Replay of an earlier request with the same idempotency key. content: application/json: schema: type: object required: [operation] properties: operation: $ref: "#/components/schemas/RecoveryOperation" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "503": $ref: "#/components/responses/RecoveryUnavailable" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/points/{pointId}/restore: post: operationId: restoreRecoveryPoint tags: [Recovery] summary: Restore a point into a new volume description: | Restores a point into a volume Sencho creates. The source volume need not exist any more. The target name must be a name Docker accepts for a volume (anything else answers `400` before an operation is recorded), must not exist, must not name one of Sencho's own volumes, must not start with `sencho-recovery-`, and needs the same access that changing a stack does when it starts with a known stack's name plus an underscore. Sencho records a restore intent, creates the target with that intent's label, proves it created it, restores with verification, and on failure removes only its own unused target. Extended-attribute-only failures end `partial` with `errorClass: attributes_not_restored` and an `xattrWarnings` count, and the volume is kept. A point in a replaced repository answers `503 repository_unavailable` with reason `repository_replaced`. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - name: pointId in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [targetVolumeName, idempotencyKey] properties: targetVolumeName: type: string minLength: 2 maxLength: 255 pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]+$' idempotencyKey: type: string minLength: 1 maxLength: 200 targetMode: type: string enum: [new_volume, new_path, staged_overwrite] description: Only `new_volume` is accepted; any other value is refused as an invalid request. default: new_volume responses: "202": description: Restore admitted. content: application/json: schema: type: object required: [operation] properties: operation: $ref: "#/components/schemas/RecoveryOperation" "200": description: Replay of an earlier request with the same idempotency key. content: application/json: schema: type: object required: [operation] properties: operation: $ref: "#/components/schemas/RecoveryOperation" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "503": $ref: "#/components/responses/RecoveryUnavailable" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/operations/{id}: get: operationId: getRecoveryOperation tags: [Recovery] summary: Read a recovery operation description: | Returns the operation with its bounded progress and the evidence it produced. `result` depends on the kind: `{point}` for a capture, `{point, verifications}` for a verification, `{intent}` for a restore, and `{repository}` for a repository provisioning. An unknown id answers 403, not 400, to a caller who cannot read every kind of operation, so the answer does not reveal which ids exist. parameters: - name: id in: path required: true description: Operation id. schema: type: string - $ref: "#/components/parameters/nodeId" responses: "200": description: The operation, its progress, and its result. content: application/json: schema: type: object required: [operation, progress, result] properties: operation: $ref: "#/components/schemas/RecoveryOperation" progress: oneOf: - $ref: "#/components/schemas/RecoveryProgress" - type: "null" result: type: ["object", "null"] description: Evidence for the operation kind. "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/operations/{id}/cancel: post: operationId: cancelRecoveryOperation tags: [Recovery] summary: Cancel a running operation description: | Asks a running operation to stop. A capture settles its point as failed before it settles the operation. A repository provisioning operation is refused as an invalid request (`reason: not_cancellable`), because a half-provisioned repository is settled by the next provisioning attempt. Access is checked first: a caller who may not configure the repository gets 403, and an unknown id answers 403 to a caller who cannot cancel every kind of operation. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - name: id in: path required: true schema: type: string - $ref: "#/components/parameters/nodeId" responses: "202": description: Cancellation requested. content: application/json: schema: type: object required: [operation] properties: operation: $ref: "#/components/schemas/RecoveryOperation" "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "500": $ref: "#/components/responses/RecoveryInternalError" /api/recovery/locks/release: post: operationId: releaseRecoveryLock tags: [Recovery] summary: Release a stale lock description: | Releases a lock whose owner ended `completion_unknown`, after review. A live lock is never released here; its owner releases it by finishing, and a request against a live lock answers `409` with `LOCK_HELD`. A repository lock requires permission to change system settings; a storage lock requires the same access a restore does, and the storage's volume need not exist any more. At startup, once no helper of the previous process remains, Sencho releases the repository lock of an interrupted provisioning, and, when restic's own locks are cleared, of interrupted captures, checks and restores itself; every storage lock waits for this call. Only a Full Admin API token may call it; any other scope is refused with `403 SCOPE_DENIED`. parameters: - $ref: "#/components/parameters/nodeId" requestBody: required: true content: application/json: schema: type: object required: [scope, resourceKey] properties: scope: type: string enum: [storage, repository] resourceKey: type: string minLength: 1 maxLength: 200 description: The storage key for `storage`, or the repository id for `repository`. responses: "200": description: The lock row that was released. content: application/json: schema: type: object required: [released] properties: released: type: object required: [scope, resourceKey, ownerOperationId, processEpoch, acquiredAt, staleSince] properties: scope: type: string enum: [storage, repository] resourceKey: { type: string } ownerOperationId: { type: string } processEpoch: { type: string } acquiredAt: { type: integer } staleSince: { type: ["integer", "null"] } "400": $ref: "#/components/responses/RecoveryBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/RecoveryForbidden" "409": $ref: "#/components/responses/RecoveryConflict" "500": $ref: "#/components/responses/RecoveryInternalError"