diff --git a/docs/README.md b/docs/README.md index 6e45c00..f8a275a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,13 @@ +# Operator docs (hand-written) + +| Doc | Topic | +| --- | --- | +| [subactor-deployment.md](subactor-deployment.md) | Subactor / sub.actor / `*.subactor.com` topology via redeploy | +| [fleet.md](fleet.md) | Fleet & device registry | +| [patterns.md](patterns.md) | Common migration patterns | +| [observe.md](observe.md) | Observe mode | +| [dsl-migration.md](dsl-migration.md) | DSL migration notes | + # redeploy ![version](https://img.shields.io/badge/version-0.1.0-blue) ![python](https://img.shields.io/badge/python-%3E%3D3.11-blue) ![coverage](https://img.shields.io/badge/coverage-unknown-lightgrey) ![functions](https://img.shields.io/badge/functions-985-green) diff --git a/docs/subactor-deployment.md b/docs/subactor-deployment.md new file mode 100644 index 0000000..68bb5e1 --- /dev/null +++ b/docs/subactor-deployment.md @@ -0,0 +1,211 @@ +# Subactor deployment through `semcod/redeploy` + +HOME: `semcod` (`redeploy`, toolkit). +Runtime facts and authority: **ADOPT** from Subactor +(`platform/config/deployment-bindings`, `www-sub-actor/docs/deployment.md`, +knowledge entries). Host fitness: **ADOPT** `wellmanifest/policy-dsl` +(`subactor.host/production-server/v1`, `subactor.host/rpi5/v1`). + +This document answers: **how Subactor is deployed today**, and **how operators +drive that topology with `redeploy`** (detect → plan → apply / fleet). + +> Status observed: **2026-08-15**. Live public `*.subactor.com` and current +> `sub.actor` publish path are **Plesk**. Full Docker VPS + Traefik for +> Platform/`control.sub.actor` is the **documented target** in +> `www-sub-actor/docs/deployment.md`, not yet the live Founder Control host. + +--- + +## 1. Topology (as-is vs target) + +```text + ┌─────────────────────────────────────┐ + │ Plesk 217.160.250.222 (edge) │ + Internet ──────────────►│ DNS / TLS / mail / static / PHP │ + │ Node Toolkit (founder origin) │ + └──────────────┬──────────────────────┘ + │ + ┌─────────────────────────────┼─────────────────────────────┐ + │ │ │ + *.subactor.com founder.subactor.com sub.actor + static / PHP sites Node proxy → :19081 Plesk httpdocs + (SFTP plesk:// sync) SSH -R from Lenovo (binding: + → Caddy :18081 → Control deployment:sub-actor:production) + +TARGET (policy-aligned Docker VPS — FORBID Compose inside Plesk): + + Internet ──► Docker VPS (Traefik ACME DNS-01) + ├─ https://sub.actor / *.sub.actor (portal image sha-*) + └─ https://control.sub.actor (Platform Control router) + Plesk remains DNS/TLS/mail/WWW edge only +``` + +### Domain map (SSOT) + +Source of truth: `subactor/platform/config/deployment-bindings/registry.v1.json` +and `public-pages.json`. Provider for listed sites today: **`plesk`**. + +| Domain | Role today | Deploy mechanism | +| --- | --- | --- | +| `subactor.com` | Marketing / main site | Plesk SFTP sync | +| `www.subactor.com`, `docs.subactor.com`, `docs-stage.subactor.com` | Docs / www | Plesk SFTP | +| `logo.subactor.com`, `status.subactor.com`, `contracts.subactor.com`, … | Brand / status | Plesk SFTP | +| `identity.subactor.com`, `chat.subactor.com` | Surface placeholders | Plesk SFTP | +| `auth.subactor.com` | Auth **bootstrap** static site (not full IdP yet) | Plesk SFTP; project `projekty/auth-subactor-com` | +| `founder.subactor.com` | Public Founder Control origin | Plesk Node `plesk-origin` + SSH reverse tunnel | +| `sub.actor` | SaaS portal publish | Plesk webspace `sub.actor/httpdocs` (live); Docker Traefik path documented | +| `control.sub.actor` | Authenticated Control | Documented on Docker VPS; live Founder still via `founder.subactor.com` | +| `*.sub.actor` tenants | Tenant vhosts | Documented Traefik wildcard; live depends on portal stack | + +`control.subactor.com` is **not** in the bindings registry. + +--- + +## 2. Current procedures (canonical scripts) + +### A. Static / PHP public pages (`*.subactor.com`, often `sub.actor`) + +1. Exact deployment binding + plan hash (Digital Twin / Control). +2. `plesk://host/site/command/sync` (SFTP) — credentials from Vault scopes, not + in Git. +3. HTTP/hash read-back on the bound URL. +4. Entry points: Planfile tickets, `platform/scripts/deploy-public-pages.mjs`, + project reconciliation. + +**redeploy role:** inventory in `examples/subactor/fleet.yaml`; verify steps in +`01-plesk-edge-verify` (curl EQL). Publish mutate stays Subactor `plesk://` +(authority + binding), not a free-form rsync from redeploy. + +### B. Founder public Control (`founder.subactor.com`) + +1. Publish `projekty/founder-subactor-com/plesk-origin/{app.js,package.json}` to + Plesk docroot `founder.subactor.com/`. +2. Keep `founder-plesk-tunnel.service` on Lenovo + (`platform/scripts/founder-plesk-tunnel.sh`) → `prototypowanie.pl:19081`. +3. Doctor: `platform/scripts/founder-public-origin-doctor.sh`. +4. EQL: `/` → 401 Basic; `/founder/form` → 200; `/__origin_health` → 200. + +**redeploy role:** `02-founder-origin-verify` migration (health probes only). + +### C. Platform Compose (lab / future VPS) + +```bash +cd /opt/subactor/src/platform # or sibling checkout +./scripts/deploy-stack.sh deploy +``` + +Posture from host `.env` (`SUBACTOR_DEPLOYMENT_TOPOLOGY`, +`CONTROL_DEPLOYMENT_POSTURE`). Never `compose down -v` in deploy procedures. + +### D. Documented full portal + Platform (Docker VPS target) + +From `www-sub-actor/docs/deployment.md`: + +1. Host fit per Policy DSL (16 GiB min / 32 GiB recommended). +2. `capture-platform-lock.sh` → `sync-platform.sh` (exact SHAs). +3. Secrets on host only; GHCR image `ghcr.io/subactor/www-sub-actor:sha-*`. +4. `deploy-all.sh docker ` → Platform then portal. +5. Health: `https://sub.actor/readyz`, tenant resolve smoke. +6. GitHub Environments `staging` / `production` + `release.sh` over SSH. + +**redeploy role:** `03-docker-vps-platform` migration wraps the same SSH command +surface for detect/plan/apply and fleet targeting. + +### E. auth.subactor.com + +Static bootstrap only. Full SSO/OIDC federation is **not** the current runtime. +Do not treat `auth.subactor.com` as the production IdP until a binding + provider +say so. + +--- + +## 3. Using `redeploy` for Subactor + +Install: `pipx install redeploy` (see root README). + +```bash +cd examples/subactor + +# Fleet inventory (roles / tags — no secrets) +redeploy fleet --file fleet.yaml + +# Plan only (no SSH) +redeploy run 03-docker-vps-platform/migration.yaml --plan-only + +# Dry-run against a configured host (set SUBACTOR_VPS_SSH in env / edit host) +redeploy run 03-docker-vps-platform/migration.yaml --dry-run + +# Verify-only edge probes (read-only) +redeploy run 01-plesk-edge-verify/migration.yaml --plan-only +redeploy run 02-founder-origin-verify/migration.yaml --plan-only +``` + +### Safety rules (fail closed) + +| Rule | Why | +| --- | --- | +| No Compose stack inside Plesk | Policy `FORBID RUN_COMPOSE_STACK_INSIDE_PLESK` | +| No Control port 8181 on public host | Policy `FORBID BIND_CONTROL_PORT_8181_PUBLIC` | +| No mutable `main` multi-repo deploy | Use `components.lock` / `sha-*` images | +| No secrets in `migration.yaml` / Git | Host files + Vault; placeholders only here | +| `redeploy apply` ≠ production authority | Subactor grant / binding still required for mutate | +| Validator merge ≠ production apply | Separate POA / Planfile / SSH release | + +--- + +## 4. Example package layout + +```text +examples/subactor/ +├── README.md +├── fleet.yaml # roles: plesk-edge, founder-origin, docker-vps, rpi5-edge +├── 01-plesk-edge-verify/ +│ ├── migration.yaml # curl EQL for public pages +│ └── redeploy.yaml +├── 02-founder-origin-verify/ +│ ├── migration.yaml # founder EQL + tunnel hint +│ └── redeploy.yaml +└── 03-docker-vps-platform/ + ├── migration.yaml # deploy-all.sh / release surface + └── redeploy.yaml +``` + +Hosts in examples use placeholders: + +- `deploy@SUBACTOR_VPS_IP` — Docker production VPS +- `operator@LENOVO_LAB` — tunnel / Control lab host +- Plesk verify steps run from the operator machine (no Plesk root required) + +Replace placeholders before `--dry-run` / apply. Prefer environment-specific +copies outside Git for real IPs. + +--- + +## 5. Cross-links (do not duplicate SSOT) + +| Concern | Canonical location | +| --- | --- | +| Host MUST/SHOULD tables | `wellmanifest/policy-dsl` + `www-sub-actor/docs/deployment.md` §1 | +| Portal/PayPal/ACME/GitHub release | `www-sub-actor/docs/deployment.md` | +| Domain → docroot bindings | `subactor/platform/config/deployment-bindings/registry.v1.json` | +| Founder origin ops | `subactor/projekty/founder-subactor-com/README.md` | +| Auth bootstrap | `subactor/projekty/auth-subactor-com/README.md` | +| redeploy CLI mechanics | `semcod/redeploy/README.md`, `docs/fleet.md` | + +When this document disagrees with Subactor registries or live knowledge, **Subactor +SSOT wins**; update this file in the same change set. + +--- + +## 6. Suggested operator sequence (target VPS cutover) + +1. `redeploy run 01-plesk-edge-verify/... --plan-only` — baseline public EQL. +2. Qualify VPS with Policy DSL production-server profile. +3. Bootstrap Docker host (`www-sub-actor/scripts/host-bootstrap.sh`). +4. Pin sources (`capture-platform-lock` / `sync-platform`). +5. `redeploy run 03-docker-vps-platform/... --dry-run` then apply under grant. +6. Point `sub.actor` / `*.sub.actor` A/AAAA at VPS; keep Plesk for `*.subactor.com` + edge until intentionally migrated. +7. Move Founder public URL to `https://control.sub.actor/founder` only after + Control is healthy on Traefik; keep tunnel as rollback until EQL green. +8. Re-run verify migrations; backup Postgres / `acme-data` / secrets. diff --git a/examples/README.md b/examples/README.md index bb4289a..8d68ac8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -41,10 +41,15 @@ They are not useful as: ```text examples/ -├── yaml/ # supported, tested examples -└── md/ # three supported markdown subset examples plus prototypes +├── yaml/ # supported, tested examples +├── md/ # three supported markdown subset examples plus prototypes +└── subactor/ # Subactor Plesk edge + Docker VPS fleet (docs + verify wrappers) ``` +Subactor operators: start at +[`subactor/README.md`](subactor/README.md) and +[`../docs/subactor-deployment.md`](../docs/subactor-deployment.md). + ## Notes - Several YAML examples use named library steps via `StepLibrary`. diff --git a/examples/subactor/01-plesk-edge-verify/migration.yaml b/examples/subactor/01-plesk-edge-verify/migration.yaml new file mode 100644 index 0000000..ccbf174 --- /dev/null +++ b/examples/subactor/01-plesk-edge-verify/migration.yaml @@ -0,0 +1,58 @@ +# 01 — Plesk edge verify (read-only documentation surface) +# Usage: redeploy run examples/subactor/01-plesk-edge-verify/migration.yaml --plan-only +# +# Mutating Plesk publish stays in Subactor (plesk:// + deployment bindings). +# Hosts below are placeholders; verify steps are local curl from the planner. + +name: "subactor plesk-edge verify (public pages)" +description: >- + Read-only EQL probes for live Plesk-hosted Subactor surfaces. + Does not sync docroots. + +source: + strategy: docker_full + host: "operator@localhost" + app: subactor-edge + version: "live" + domain: subactor.com + remote_dir: /tmp/subactor-redeploy-noop + +target: + strategy: docker_full + host: "operator@localhost" + app: subactor-edge + version: "live" + domain: subactor.com + remote_dir: /tmp/subactor-redeploy-noop + verify_url: https://subactor.com/ + verify_version: "live" + +extra_steps: + - id: eql_subactor_com + action: ssh_cmd + description: "EQL marketing root responds" + command: >- + curl -fsS -o /dev/null -w '%{http_code}\n' --max-time 20 https://subactor.com/ + | grep -E '^(200|301|302)$' + risk: low + + - id: eql_auth_bootstrap + action: ssh_cmd + description: "auth.subactor.com bootstrap is reachable (static, not full IdP)" + command: >- + curl -fsS -o /dev/null -w '%{http_code}\n' --max-time 20 https://auth.subactor.com/ + | grep -E '^(200|301|302)$' + risk: low + + - id: eql_sub_actor + action: ssh_cmd + description: "sub.actor responds (live path may still be Plesk httpdocs)" + command: >- + curl -fsS -o /dev/null -w '%{http_code}\n' --max-time 20 https://sub.actor/ + | grep -E '^(200|301|302|401|403)$' + risk: low + +notes: + - "SSOT bindings: subactor/platform/config/deployment-bindings/registry.v1.json" + - "auth.subactor.com is informational bootstrap — not production OIDC yet" + - "Prefer --plan-only until hosts are intentionally set for dry-run" diff --git a/examples/subactor/01-plesk-edge-verify/redeploy.yaml b/examples/subactor/01-plesk-edge-verify/redeploy.yaml new file mode 100644 index 0000000..87ccc4b --- /dev/null +++ b/examples/subactor/01-plesk-edge-verify/redeploy.yaml @@ -0,0 +1,5 @@ +spec: migration.yaml +host: operator@localhost +app: subactor-edge +domain: subactor.com +remote_dir: /tmp/subactor-redeploy-noop diff --git a/examples/subactor/02-founder-origin-verify/migration.yaml b/examples/subactor/02-founder-origin-verify/migration.yaml new file mode 100644 index 0000000..3387a70 --- /dev/null +++ b/examples/subactor/02-founder-origin-verify/migration.yaml @@ -0,0 +1,67 @@ +# 02 — Founder public origin verify +# Usage: redeploy run examples/subactor/02-founder-origin-verify/migration.yaml --plan-only +# +# Architecture (live 2026-08-15): +# Internet → founder.subactor.com (Plesk TLS + Node) +# → 127.0.0.1:19081 ← SSH -R from Lenovo +# → Caddy :18081 → hr-control + +name: "subactor founder.subactor.com origin verify" +description: >- + Read-only EQL for Founder public origin. Does not restart the tunnel unit. + +source: + strategy: docker_full + host: "operator@LENOVO_LAB" + app: founder-origin + version: "live" + domain: founder.subactor.com + remote_dir: /tmp/subactor-redeploy-noop + +target: + strategy: docker_full + host: "operator@LENOVO_LAB" + app: founder-origin + version: "live" + domain: founder.subactor.com + remote_dir: /tmp/subactor-redeploy-noop + verify_url: https://founder.subactor.com/__origin_health + verify_version: "live" + +extra_steps: + - id: eql_founder_root_unauthorized + action: ssh_cmd + description: "GET / without credentials must be 401 Basic" + command: >- + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 https://founder.subactor.com/); + test "$code" = "401" && echo founder-root-401-ok + risk: low + + - id: eql_founder_form + action: ssh_cmd + description: "GET /founder/form returns Founder HTML" + command: >- + curl -fsS --max-time 20 https://founder.subactor.com/founder/form + | grep -q 'Subactor' && echo founder-form-ok + risk: low + + - id: eql_origin_health + action: ssh_cmd + description: "Origin health JSON endpoint" + command: >- + curl -fsS --max-time 20 https://founder.subactor.com/__origin_health + | grep -qi . && echo origin-health-ok + risk: low + + - id: hint_tunnel_unit + action: ssh_cmd + description: "Remind operator to check user systemd tunnel on Lenovo" + command: >- + echo "Check: systemctl --user status founder-plesk-tunnel.service"; + echo "Script: platform/scripts/founder-plesk-tunnel.sh" + risk: low + +notes: + - "Project docs: subactor/projekty/founder-subactor-com/README.md" + - "Without tunnel, proxied paths return 502" + - "Target cutover: https://control.sub.actor/founder after Docker VPS is live" diff --git a/examples/subactor/02-founder-origin-verify/redeploy.yaml b/examples/subactor/02-founder-origin-verify/redeploy.yaml new file mode 100644 index 0000000..82d794e --- /dev/null +++ b/examples/subactor/02-founder-origin-verify/redeploy.yaml @@ -0,0 +1,5 @@ +spec: migration.yaml +host: operator@LENOVO_LAB +app: founder-origin +domain: founder.subactor.com +remote_dir: /tmp/subactor-redeploy-noop diff --git a/examples/subactor/03-docker-vps-platform/migration.yaml b/examples/subactor/03-docker-vps-platform/migration.yaml new file mode 100644 index 0000000..dc7aff9 --- /dev/null +++ b/examples/subactor/03-docker-vps-platform/migration.yaml @@ -0,0 +1,75 @@ +# 03 — Docker VPS Platform + portal (documented target topology) +# Usage: +# export SUBACTOR_VPS_SSH=deploy@YOUR_IP +# # edit host below or substitute +# redeploy run examples/subactor/03-docker-vps-platform/migration.yaml --plan-only +# redeploy run examples/subactor/03-docker-vps-platform/migration.yaml --dry-run +# +# Canonical Subactor scripts (authority remains there): +# www-sub-actor/scripts/deploy-all.sh +# www-sub-actor/scripts/release.sh +# platform/scripts/deploy-stack.sh +# +# Preconditions: host fits subactor.host/production-server/v1; +# pinned components.lock; secrets on host; PORTAL_IMAGE=ghcr.io/...:sha-* + +name: "subactor docker VPS — platform + portal (pinned)" +description: >- + SSH surface for pinned deploy-all.sh. Placeholders only — set host and image + before dry-run/apply. + +source: + strategy: docker_full + host: "deploy@SUBACTOR_VPS_IP" + app: www-sub-actor + version: "sha-PREVIOUS" + domain: sub.actor + remote_dir: /opt/subactor + +target: + strategy: docker_full + host: "deploy@SUBACTOR_VPS_IP" + app: www-sub-actor + version: "sha-COMMIT" + domain: sub.actor + remote_dir: /opt/subactor + # compose_files / env_file live only on the VPS after sync-platform; + # keep them out of the example so `redeploy run --plan-only` lints cleanly. + verify_url: https://sub.actor/readyz + verify_version: "sha-COMMIT" + +extra_steps: + - id: forbid_plesk_compose_reminder + action: ssh_cmd + description: "Assert operator intent — Compose must not run inside shared Plesk" + command: >- + echo "POLICY: FORBID RUN_COMPOSE_STACK_INSIDE_PLESK"; + echo "POLICY: FORBID BIND_CONTROL_PORT_8181_PUBLIC"; + echo "Control only via https://control.sub.actor" + risk: low + insert_before: sync_env + + - id: deploy_all_pinned + action: ssh_cmd + description: "Run canonical deploy-all.sh with pinned portal image" + command: >- + test -n "${PORTAL_IMAGE:-}" || { echo "Set PORTAL_IMAGE=ghcr.io/subactor/www-sub-actor:sha-COMMIT"; exit 2; }; + cd /opt/subactor/www-sub-actor && + CONTAINER_ENGINE=docker PORTAL_IMAGE="$PORTAL_IMAGE" + ./scripts/deploy-all.sh docker "$PORTAL_IMAGE" + risk: high + timeout: 3600 + + - id: smoke_readyz + action: ssh_cmd + description: "Portal readyz" + command: >- + curl -fsS --max-time 30 https://sub.actor/readyz && echo readyz-ok + risk: low + rollback_on_failure: false + +notes: + - "Do not copy developer .env to production without audit" + - "GitHub release path: release.sh over SSH — secrets stay on host" + - "Backup Postgres, acme-data, Platform volumes before apply" + - "RPi5 is a different fleet device — never use this migration for full Platform on Pi" diff --git a/examples/subactor/03-docker-vps-platform/redeploy.yaml b/examples/subactor/03-docker-vps-platform/redeploy.yaml new file mode 100644 index 0000000..233bf30 --- /dev/null +++ b/examples/subactor/03-docker-vps-platform/redeploy.yaml @@ -0,0 +1,5 @@ +spec: migration.yaml +host: deploy@SUBACTOR_VPS_IP +app: www-sub-actor +domain: sub.actor +remote_dir: /opt/subactor diff --git a/examples/subactor/README.md b/examples/subactor/README.md new file mode 100644 index 0000000..364067e --- /dev/null +++ b/examples/subactor/README.md @@ -0,0 +1,29 @@ +# Subactor examples for `redeploy` + +Operational package for Subactor edge + Docker VPS topology. + +Narrative: [`../../docs/subactor-deployment.md`](../../docs/subactor-deployment.md). + +## Commands + +```bash +# From repo root +redeploy fleet --file examples/subactor/fleet.yaml + +redeploy run examples/subactor/01-plesk-edge-verify/migration.yaml --plan-only +redeploy run examples/subactor/02-founder-origin-verify/migration.yaml --plan-only +redeploy run examples/subactor/03-docker-vps-platform/migration.yaml --plan-only + +# After editing host placeholders: +redeploy run examples/subactor/03-docker-vps-platform/migration.yaml --dry-run +``` + +## What these examples do / do not do + +| Example | Does | Does not | +| --- | --- | --- | +| `01-plesk-edge-verify` | Document curl EQL for public pages | Mutate Plesk docroots | +| `02-founder-origin-verify` | Document Founder origin probes | Start/stop the SSH tunnel | +| `03-docker-vps-platform` | Wrap documented `deploy-all.sh` surface | Invent secrets or unpinned `main` | + +Publish to Plesk remains Subactor `plesk://` + deployment bindings. diff --git a/examples/subactor/fleet.yaml b/examples/subactor/fleet.yaml new file mode 100644 index 0000000..ea9a8d6 --- /dev/null +++ b/examples/subactor/fleet.yaml @@ -0,0 +1,77 @@ +# Subactor fleet inventory (roles only — no secrets) +# Usage: redeploy fleet --file examples/subactor/fleet.yaml +# +# Replace ssh_host placeholders before targeting a live device. +# Plesk edge is verify-only from the operator workstation (ssh_host empty). + +devices: + - id: plesk-edge-subactor + name: "Plesk edge — *.subactor.com + current sub.actor publish" + arch: amd64 + strategy: docker_full + ssh_host: "" + remote_dir: "" + version: "edge-live" + domain: "subactor.com" + stage: prod + tags: [prod, plesk, edge, subactor, verify-only] + expectations: + - https_reachable + color: "#64748b" + notes: "Provider=plesk via Subactor deployment-bindings; redeploy does not SFTP here." + + - id: founder-origin-tunnel + name: "Founder public origin (Plesk Node + Lenovo tunnel)" + arch: amd64 + strategy: docker_full + ssh_host: "operator@LENOVO_LAB" + remote_dir: "" + version: "founder-live" + domain: "founder.subactor.com" + stage: prod + tags: [prod, founder, tunnel, subactor, verify-only] + expectations: + - ssh_reachable + - https_reachable + color: "#f59e0b" + notes: "systemd --user founder-plesk-tunnel.service on Lenovo." + + - id: docker-vps-platform + name: "Docker VPS — Platform + www-sub-actor (target topology)" + arch: amd64 + strategy: docker_full + ssh_host: "deploy@SUBACTOR_VPS_IP" + remote_dir: "/opt/subactor" + version: "sha-PINNED" + env_file: "envs/subactor-vps.env" + domain: "sub.actor" + compose_files: + - "src/platform/docker-compose.yml" + - "src/platform/docker-compose.production.yml" + apps: [platform, www-sub-actor] + stage: prod + tags: [prod, vps, docker, platform, portal, subactor] + expectations: + - has_docker + - has_docker_compose + - ssh_reachable + - https_reachable + - no_k3s + color: "#10b981" + notes: "FORBID shared Plesk Docker; Control only via control.sub.actor router." + + - id: rpi5-portal-edge + name: "RPi5 portal edge / lab (not full Platform)" + arch: arm64 + strategy: docker_full + ssh_host: "pi@RPI5_IP" + remote_dir: "/opt/www-sub-actor" + version: "sha-PINNED" + domain: "portal.edge.example" + stage: lab + tags: [lab, rpi5, arm64, portal, subactor] + expectations: + - has_docker + - ssh_reachable + color: "#3b82f6" + notes: "Policy subactor.host/rpi5/v1 — FORBID full Platform production."