From 331842e6acf4e5d84a2ea98c7710ce95d6b44850 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 19:56:11 +0800 Subject: [PATCH 1/5] =?UTF-8?q?docs(adr):=20ADR-2=20draft=20=E2=80=94=20De?= =?UTF-8?q?ployment=20Control=20Plane=20(read/write=20+=20MCP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the design decided in the dev thread: - generic glossary (Agent/Instance/Deployment/Fleet/Spec/Status/phase/ AgentState/Discriminators/RuntimeDriver); vendor terms only in drivers. - read model: Spec+Status, phase=AgentState, 6-state<->k8s mapping + traps. - write model: apply() primitive; create/scale/stop are sugar; dry-run/diff. - MCP adapter: server oabctl, tools deploy_list/get/apply/scale/stop. - authorization DEFERRED to ADR-3 (interim: AWS credential ceiling only, documented risk + trusted-callers rule). - non-goals: authz (ADR-3), controller/S3/reconcile (ADR-4), k8s/compose drivers, exec/cp/sync/bootstrap/schedule via MCP. Co-Authored-By: Claude Opus 4.8 --- docs/adr/deployment-control-plane.md | 177 +++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 docs/adr/deployment-control-plane.md diff --git a/docs/adr/deployment-control-plane.md b/docs/adr/deployment-control-plane.md new file mode 100644 index 0000000..6807f91 --- /dev/null +++ b/docs/adr/deployment-control-plane.md @@ -0,0 +1,177 @@ +# ADR: Deployment Control Plane — read/write model + MCP + +- **Status:** Proposed +- **Date:** 2026-08-08 +- **Author:** @brettchien +- **Reviewers:** +- **Tracking issues:** builds on [agent-lifecycle](./agent-lifecycle.md) (ADR-1) + +> **Y-statement.** In the context of operating agents across runtimes on top of +> the vendored `oabctl` engine, facing the need for agents and future front-ends +> to observe and control deployments without vendor lock-in, we decided a +> **generic read model (Spec/Status/phase) + write model (`apply` primitive) + +> an MCP adapter (`deploy_*`)**, to make a Studio control plane every front-end +> (CLI / TUI / GUI / agent) shares, **accepting that per-caller authorization is +> deferred** (interim: the AWS credential ceiling only). + +--- + +## 1. Context & Problem + +ADR-1 defined the canonical 6-state agent lifecycle. We now need the control +plane that (a) **observes** deployments and reports their state, and (b) lets a +caller apply **basic control** (create / scale / stop) — reachable by an agent +as a first-class MCP citizen, and later by a TUI/GUI. + +We build on the vendored `oabctl` (the ECS provisioner engine, kept close to +upstream). The vocabulary must be **generic** so swapping the underlying engine +or cloud is a driver change, not a rewrite. + +## 2. Decision Drivers + +- **No vendor lock-in** — agent-facing vocabulary is generic; vendor terms live + only in the driver. +- **One substrate, many front-ends** — CLI / TUI / GUI / MCP all consume the + same models; none re-implements observation or control. +- **Declarative & idempotent** — one write primitive; imperative verbs are sugar. +- **Safe by construction** — reads are free; writes are explicit, support + dry-run, and fail closed at the credential boundary. + +## 3. Glossary (generic vocabulary) + +Vendor-specific terms (ECS/task/ARN/S3/Fargate…) appear **only** inside a +`RuntimeDriver`. Everything above speaks these: + +| Term | Meaning | (driver-level equivalent) | +|---|---|---| +| **Agent** | The managed logical entity | — | +| **Instance** | One running copy of an agent | ECS task / k8s pod / compose container | +| **Deployment** | An Agent's declared desired unit → N Instances | ECS service / k8s Deployment / compose service | +| **Fleet** | A set of Deployments | — | +| **Spec** | Desired: `identity + version + scale + runtime + configRef` | `.spec` | +| **Status** | Observed bundle: `phase + conditions + …` | `.status` | +| **phase** | Field on Status; value is an `AgentState` (the 6 states) | `.status.phase` | +| **AgentState** | The 6 lifecycle states (ADR-1) | — | +| **Discriminators** | `desiredStatus · accepting_work · health · identity_verified` | ADR-1 | +| **RuntimeDriver** | Per-runtime translation layer (the only place vendor terms live) | Controller/Operator | +| **config** | The agent's app config (`config.toml`); a field within Spec | — | + +## 4. Read model + +`Instance = Spec (desired) + Status (observed)`; **state is observed, never part +of Spec** (ADR-1). A driver observes native signals and projects them onto: + +- `Status.phase` — one `AgentState` per Instance (ADR-1's `classify()`). +- `Status.conditions[]` — orthogonal facts (ready, superseded, …). +- Rolled up per Deployment: a Deployment's phase derives from its Instances + (e.g. any `Starting` → progressing; ≥1 `Running` at desired scale → available). + +**Observation types are generic** (`Deployment`, `Instance`, `Status`, `phase`); +ECS strings (`ACTIVE`/`DRAINING`/task ARN) stay inside the ECS driver. The +current `studio-cp::ServiceStatus` (ECS-flavoured) is replaced by generic types +here. + +### 6-state ⇄ k8s (mapping, with traps) + +k8s has no single lifecycle enum; it is `phase + conditions + probes + +deletionTimestamp`. Traps to document so k8s intuition doesn't misread us: + +- **`Running` is stricter than k8s** — ours = k8s `phase=Running ∧ Ready=True`. +- **`Paused` has no per-Pod k8s analog**, and is **not** k8s `Deployment.spec.paused` (that is rollout-pause). +- **`Stopping`** = k8s "Terminating" (`deletionTimestamp≠null`), which is not a `.status.phase` value. +- **`Stopped`** = k8s `Succeeded`/`Failed`; we keep one state + a death `cause`. +- **`Unhealthy`** is a first-class state; k8s expresses it via conditions/probes + `Unknown`. +- **`identity_verified`** (latch) ≈ k8s `startupProbe` first success. + +## 5. Write model + +One idempotent primitive: **`apply(Spec)`** — reconcile observed toward desired. +Named intents are **sugar over apply** (differ only in delta + guardrail): + +| intent | reduces to | note | +|---|---|---| +| create | `apply(new Spec)` | first-time; provisions identity | +| scale | `apply(Spec with new replicas)` | count only; no new identity | +| stop / delete | `apply(absence)` / replicas→0 | destructive | + +- **create is not a separate operation** — `apply` covers it. +- **dry-run / diff**: `apply` supports a preview mode that returns *what would + change* without mutating — a safety valve, and important for agents (look + before leap). + +## 6. MCP adapter + +The control plane is exposed as an MCP server so an agent operates it +first-class. Every front-end (CLI/TUI/GUI) is a downstream client of the same +models; the MCP server is one adapter. + +- **Server name:** `oabctl` (this control plane serves only the Studio/openab + universe). +- **Tools** (generic verbs; server namespace disambiguates — no `oabctl_` prefix): + +| tool | kind | maps to | +|---|---|---| +| `deploy_list` | read | list Deployments + phase | +| `deploy_get` | read | one Deployment's Spec + Status (+ Instance phases) | +| `deploy_apply` | write | the declarative primitive (supports dry-run) | +| `deploy_scale` | write | change replicas | +| `deploy_stop` | write | destructive | + +Excluded from MCP: `exec`/`cp`/`sync` (shell into containers — blast radius), +`bootstrap` (infra, one-time), `schedule` (automation). See Non-goals. + +### Authorization — DEFERRED (read carefully) + +Per-caller authorization is **out of scope for ADR-2** and deferred to ADR-3. + +- **Interim posture:** the only gate on writes is the **AWS credential the + `oabctl` process runs as** (its task role / credential file). This is a + **coarse ceiling** that *cannot distinguish callers* — every caller reaching + the MCP server shares the credential's full power. +- **Consequence / known risk:** until ADR-3, any caller wired to the write tools + can do anything the credential allows. Therefore, interim operating rule: + **least-privilege the `oabctl` role, and wire the write tools only to trusted + callers.** +- ADR-3 adds the **per-caller / per-verb / per-scope** layer (default-deny + allowlist, destructive-confirm, namespace scoping, audit; caller identity + CP-verified per ADR-1). Two identities: the process (credential) vs the calling + agent (ADR-3). + +## 7. Exposure seam (oabctl → Studio) + +oabctl exposes status as a **library API returning data** (not CLI table output): +`oabctl::service_status(...) -> Vec` (added in PR #2). Studio +consumes it in `studio-cp` and maps it onto the generic read model. Vendored +oabctl stays additive/clean so changes are upstream-contributable. + +## 8. Alternatives Considered + +- **MCP tools named `oabctl_*`** — rejected: re-introduces vendor lock-in in the + agent-facing vocabulary and duplicates the server namespace. +- **Pure declarative MCP (`apply` + `get` only)** — rejected as the surface: + agents get clearer, individually-guardable intents (`scale`/`stop`); they + still compile to `apply`. +- **Read-only ADR-2 (defer all writes)** — rejected: we enable basic write now, + and defer *authorization* instead (§6). + +## 9. Consequences + +- Studio's read model + every front-end speak the generic glossary; ECS terms + are confined to the driver. +- Writes are enabled but only credential-gated until ADR-3 — a documented, + time-boxed risk, not a silent hole. +- `studio-cp`'s ECS-flavoured types are replaced by generic ones. + +## 10. Non-goals (deferred) + +- **Per-caller authorization / guardrails** → ADR-3. +- **Controller + reconcile loop + State Store (S3) + `observedGeneration`** → + ADR-4 (until then, front-ends live-observe on demand; no durable status store). +- **k8s / compose drivers** → later (ECS driver first). +- **`exec`/`cp`/`sync`/`bootstrap`/`schedule` via MCP** → out of scope. + +## 11. More Information + +Glossary here graduates to a repo-wide `GLOSSARY.md`. Format follows MADR + +Nygard + Y-statement, per [ADR-1](./agent-lifecycle.md) and +[`docs/review-runbook.md`](../review-runbook.md). From de2b9b3776c49e3843f946f9d7f4cc0842a1e894 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 21:10:21 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(adr):=20ADR-2=20v2=20=E2=80=94=20fold?= =?UTF-8?q?=203-item=20review=20revision=20(Falcon/Mira/Jellyfish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Deployment.status != AgentState: Instance.status.phase = AgentState; Deployment.status = replica counters (desired/current/ready/available) + Conditions (mirrors k8s). phase is Instance-level only. 2. Write model reaches Paused: add cordon/resume sugar + deploy_cordon/ deploy_resume MCP tools (admission=accepting_work is a Spec field); dry_run is a first-class required param on every write tool. 3. Exposure seam granularity: ServiceStatus must wrap per-Task InstanceStatus; driver aggregates DescribeServices (Deployment counters/rollout) + DescribeTasks (Instance 4-axis -> phase). Deferrals unchanged (authz->ADR-3, controller/S3->ADR-4), reviewers PASS. Co-Authored-By: Claude Opus 4.8 --- docs/adr/deployment-control-plane.md | 57 +++++++++++++++++++++------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/adr/deployment-control-plane.md b/docs/adr/deployment-control-plane.md index 6807f91..16560b5 100644 --- a/docs/adr/deployment-control-plane.md +++ b/docs/adr/deployment-control-plane.md @@ -48,9 +48,11 @@ Vendor-specific terms (ECS/task/ARN/S3/Fargate…) appear **only** inside a | **Instance** | One running copy of an agent | ECS task / k8s pod / compose container | | **Deployment** | An Agent's declared desired unit → N Instances | ECS service / k8s Deployment / compose service | | **Fleet** | A set of Deployments | — | -| **Spec** | Desired: `identity + version + scale + runtime + configRef` | `.spec` | -| **Status** | Observed bundle: `phase + conditions + …` | `.status` | -| **phase** | Field on Status; value is an `AgentState` (the 6 states) | `.status.phase` | +| **Spec** | Desired: `identity + version + scale + admission + runtime + configRef` | `.spec` | +| **admission** | Spec field (`accepting_work`): may this Instance take new work | (readiness gate) | +| **Instance.Status** | Observed per Instance: `phase + conditions + …` | Pod `.status` | +| **phase** | Field on an **Instance**'s Status; value is an `AgentState` — **Instance-level only** | Pod `.status.phase` | +| **Deployment.Status** | Observed per Deployment: replica counters `desired/current/ready/available` + Conditions — **not** an `AgentState` | Deployment `.status` | | **AgentState** | The 6 lifecycle states (ADR-1) | — | | **Discriminators** | `desiredStatus · accepting_work · health · identity_verified` | ADR-1 | | **RuntimeDriver** | Per-runtime translation layer (the only place vendor terms live) | Controller/Operator | @@ -61,15 +63,19 @@ Vendor-specific terms (ECS/task/ARN/S3/Fargate…) appear **only** inside a `Instance = Spec (desired) + Status (observed)`; **state is observed, never part of Spec** (ADR-1). A driver observes native signals and projects them onto: -- `Status.phase` — one `AgentState` per Instance (ADR-1's `classify()`). -- `Status.conditions[]` — orthogonal facts (ready, superseded, …). -- Rolled up per Deployment: a Deployment's phase derives from its Instances - (e.g. any `Starting` → progressing; ≥1 `Running` at desired scale → available). +- **Instance-level** — `Instance.status.phase` is one `AgentState` (ADR-1's + `classify()`), plus `conditions[]` (ready, superseded, …). +- **Deployment-level** — `Deployment.status` is **replica counters** + (`desired / current / ready / available`) + Deployment `conditions[]` (e.g. + `Available`, `Progressing`). **It is NOT an `AgentState`**: a Deployment with N + Instances (say one `Running`, one `Unhealthy`) has no single lifecycle value — + it has *counts*. (Mirrors k8s exactly: a Pod has a `phase`; a Deployment has + replica counts + conditions, never a phase.) **Observation types are generic** (`Deployment`, `Instance`, `Status`, `phase`); ECS strings (`ACTIVE`/`DRAINING`/task ARN) stay inside the ECS driver. The current `studio-cp::ServiceStatus` (ECS-flavoured) is replaced by generic types -here. +here (see §7). ### 6-state ⇄ k8s (mapping, with traps) @@ -92,12 +98,18 @@ Named intents are **sugar over apply** (differ only in delta + guardrail): |---|---|---| | create | `apply(new Spec)` | first-time; provisions identity | | scale | `apply(Spec with new replicas)` | count only; no new identity | +| **cordon** | `apply(Spec with admission=off)` → Instance `Paused` | stop taking new work, **stay alive** (drain / isolate); reversible | +| **resume** | `apply(Spec with admission=on)` → Instance `Running` | uncordon | | stop / delete | `apply(absence)` / replicas→0 | destructive | - **create is not a separate operation** — `apply` covers it. -- **dry-run / diff**: `apply` supports a preview mode that returns *what would +- **cordon / resume** are the write path to ADR-1's `Paused` state (admission = + `accepting_work`, a Spec field). Without them the write model could not reach a + state ADR-1 defines. `cordon` is *not* `stop`: it keeps the Instance alive and + resumable. +- **dry-run / diff**: every write supports a preview that returns *what would change* without mutating — a safety valve, and important for agents (look - before leap). + before leap). See §6 for `dry_run` as a first-class tool parameter. ## 6. MCP adapter @@ -111,12 +123,17 @@ models; the MCP server is one adapter. | tool | kind | maps to | |---|---|---| -| `deploy_list` | read | list Deployments + phase | -| `deploy_get` | read | one Deployment's Spec + Status (+ Instance phases) | -| `deploy_apply` | write | the declarative primitive (supports dry-run) | +| `deploy_list` | read | list Deployments + `Deployment.status` counters | +| `deploy_get` | read | one Deployment's Spec + status (+ each Instance's phase) | +| `deploy_apply` | write | the declarative primitive | | `deploy_scale` | write | change replicas | +| `deploy_cordon` | write | admission=off → `Paused` (stay alive) | +| `deploy_resume` | write | admission=on → `Running` | | `deploy_stop` | write | destructive | +**`dry_run: bool` is a first-class, required parameter on every write tool** — +in the pre-authz interim it is the structured safety preview an agent relies on. + Excluded from MCP: `exec`/`cp`/`sync` (shell into containers — blast radius), `bootstrap` (infra, one-time), `schedule` (automation). See Non-goals. @@ -144,6 +161,20 @@ oabctl exposes status as a **library API returning data** (not CLI table output) consumes it in `studio-cp` and maps it onto the generic read model. Vendored oabctl stays additive/clean so changes are upstream-contributable. +**Granularity requirement.** `DescribeServices` gives only *service-level* data +(running/desired counts, service status) — it cannot yield a Task's `lastStatus` +(`PROVISIONING`/`ACTIVATING`/`RUNNING`/`STOPPED`), container `healthStatus`, or +`stopCode`. Those per-Task facts are exactly what ADR-1's four discriminators +(`identity_verified`, `health`, …) need, so a service-level `ServiceStatus` +alone **cannot produce the 6-state**. Therefore the seam must expose **per-Task +`InstanceStatus`**: the driver aggregates + +- `DescribeServices` → **Deployment**-level counters + rollout state, and +- `DescribeTasks` → each **Instance**'s 4 discriminators → `phase` (`AgentState`). + +`ServiceStatus` thus wraps a `Vec`; the ECS specifics stay inside +the driver. + ## 8. Alternatives Considered - **MCP tools named `oabctl_*`** — rejected: re-introduces vendor lock-in in the From f0f818bb429949d8f6537ac85f97177566f89829 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 22:34:45 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(adr):=20ADR-2=20v3=20=E2=80=94=20resol?= =?UTF-8?q?ve=20D1-D6=20+=20healthCheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D4 (correctness): §4 latch k8s mapping startupProbe -> Ready-first-True (startupProbe fires before Ready; latching there breaks ADR-1 F1's Starting/Unhealthy split). Aligns with §7 ECS lastStatus-ever-RUNNING. - D1: §5 apply framed as on-demand one-shot; continuous reconcile = ADR-4. - D2: split stop (scale->0, reversible, Spec kept) from delete (absence, destructive); §6 adds deploy_delete alongside deploy_stop. - D3: authz interim 'trusted callers' framed as a deployment-time assumption, not a technical control. - D6: glossary config = content referenced by Spec.configRef (not a Spec field). - healthCheck: §7 ECS conformance — task must define a container healthCheck (parallel to compose), else healthStatus UNKNOWN breaks the health axis. - D5 (server name oabctl): dismissed — already decided. Co-Authored-By: Claude Opus 4.8 --- docs/adr/deployment-control-plane.md | 45 +++++++++++++++++++--------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/docs/adr/deployment-control-plane.md b/docs/adr/deployment-control-plane.md index 16560b5..a507126 100644 --- a/docs/adr/deployment-control-plane.md +++ b/docs/adr/deployment-control-plane.md @@ -56,7 +56,7 @@ Vendor-specific terms (ECS/task/ARN/S3/Fargate…) appear **only** inside a | **AgentState** | The 6 lifecycle states (ADR-1) | — | | **Discriminators** | `desiredStatus · accepting_work · health · identity_verified` | ADR-1 | | **RuntimeDriver** | Per-runtime translation layer (the only place vendor terms live) | Controller/Operator | -| **config** | The agent's app config (`config.toml`); a field within Spec | — | +| **config** | The agent's app config (`config.toml`) — the *content* referenced by Spec's `configRef` (delivered out-of-band, not inlined in Spec) | — | ## 4. Read model @@ -87,22 +87,34 @@ deletionTimestamp`. Traps to document so k8s intuition doesn't misread us: - **`Stopping`** = k8s "Terminating" (`deletionTimestamp≠null`), which is not a `.status.phase` value. - **`Stopped`** = k8s `Succeeded`/`Failed`; we keep one state + a death `cause`. - **`Unhealthy`** is a first-class state; k8s expresses it via conditions/probes + `Unknown`. -- **`identity_verified`** (latch) ≈ k8s `startupProbe` first success. +- **`identity_verified`** (latch) ≈ k8s **`Ready` condition first True** (= our + `Running`: readiness passed **and** the CP lease exchange completed) — **not** + `startupProbe` first success. `startupProbe` fires *before* `Ready`; latching + there would mark a started-but-never-`Ready` pod `Unhealthy` instead of + `Starting`, breaking ADR-1's F1 split. (ECS equivalent, §7: `lastStatus` ever + reached `RUNNING`.) ## 5. Write model -One idempotent primitive: **`apply(Spec)`** — reconcile observed toward desired. -Named intents are **sugar over apply** (differ only in delta + guardrail): +One idempotent primitive: **`apply(Spec)`** — converge observed toward desired +**on demand** (a one-shot apply that computes the diff and applies it once). The +*continuous background reconcile loop* is **ADR-4**, not ADR-2. Named intents are +**sugar over apply** (differ only in delta + guardrail): -| intent | reduces to | note | -|---|---|---| -| create | `apply(new Spec)` | first-time; provisions identity | -| scale | `apply(Spec with new replicas)` | count only; no new identity | -| **cordon** | `apply(Spec with admission=off)` → Instance `Paused` | stop taking new work, **stay alive** (drain / isolate); reversible | -| **resume** | `apply(Spec with admission=on)` → Instance `Running` | uncordon | -| stop / delete | `apply(absence)` / replicas→0 | destructive | +| intent | reduces to | reversible? | note | +|---|---|---|---| +| create | `apply(new Spec)` | — | first-time; provisions identity | +| scale | `apply(Spec, new replicas)` | — | count only; no new identity | +| **cordon** | `apply(Spec, admission=off)` → `Paused` | ✅ | stop new work, **stay alive** (drain / isolate) | +| **resume** | `apply(Spec, admission=on)` → `Running` | ✅ | uncordon | +| **stop** | `apply(Spec, replicas→0)` | ✅ | scale to zero; **Spec kept** → Instances `Stopping`→`Stopped` | +| **delete** | `apply(absence)` — remove the Spec | ❌ | **destructive**; the Deployment is gone | - **create is not a separate operation** — `apply` covers it. +- **stop ≠ delete.** `stop` scales to zero but keeps the Spec (reversible — + `resume`/`scale` brings it back). `delete` removes the Spec entirely + (destructive). They are distinct intents with distinct reversibility, so they + are not one row. - **cordon / resume** are the write path to ADR-1's `Paused` state (admission = `accepting_work`, a Spec field). Without them the write model could not reach a state ADR-1 defines. `cordon` is *not* `stop`: it keeps the Instance alive and @@ -129,7 +141,8 @@ models; the MCP server is one adapter. | `deploy_scale` | write | change replicas | | `deploy_cordon` | write | admission=off → `Paused` (stay alive) | | `deploy_resume` | write | admission=on → `Running` | -| `deploy_stop` | write | destructive | +| `deploy_stop` | write | scale→0; **reversible** (Spec kept) | +| `deploy_delete` | write | remove the Deployment; **destructive** | **`dry_run: bool` is a first-class, required parameter on every write tool** — in the pre-authz interim it is the structured safety preview an agent relies on. @@ -146,9 +159,10 @@ Per-caller authorization is **out of scope for ADR-2** and deferred to ADR-3. **coarse ceiling** that *cannot distinguish callers* — every caller reaching the MCP server shares the credential's full power. - **Consequence / known risk:** until ADR-3, any caller wired to the write tools - can do anything the credential allows. Therefore, interim operating rule: + can do anything the credential allows. The interim operating rule — **least-privilege the `oabctl` role, and wire the write tools only to trusted - callers.** + callers** — is a **deployment-time assumption, not a technical control**: the + system does not itself verify caller trust before ADR-3. - ADR-3 adds the **per-caller / per-verb / per-scope** layer (default-deny allowlist, destructive-confirm, namespace scoping, audit; caller identity CP-verified per ADR-1). Two identities: the process (credential) vs the calling @@ -170,6 +184,9 @@ alone **cannot produce the 6-state**. Therefore the seam must expose **per-Task `InstanceStatus`**: the driver aggregates - `DescribeServices` → **Deployment**-level counters + rollout state, and +- **(conformance)** the ECS task must define a container **`healthCheck`** — else + `healthStatus` stays `UNKNOWN` and the `health` discriminator is unknowable + (the ECS parallel to the compose healthcheck requirement), and - `DescribeTasks` → each **Instance**'s 4 discriminators → `phase` (`AgentState`). `ServiceStatus` thus wraps a `Vec`; the ECS specifics stay inside From 6bd9c94d7efb978e7b703fa0784f832eef6aebc6 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 8 Aug 2026 22:47:58 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(adr):=20ADR-2=20v4=20=E2=80=94=20N1=20?= =?UTF-8?q?+=20N2=20(final=20review=20nits)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N1: admission's driver-equivalent is not 'readiness gate' — it is app/CP-level and orthogonal to Ready (ADR-1 F2: accepting_work is CP-controlled, separate from health). Same class of error as D4. - N2: remove raw deploy_apply from the MCP surface — exposing it bypasses the individually-guardable sugar verbs (contradicts the §8 rationale). apply stays the internal primitive; creating a new Spec is a restricted provisioning action, not a first-class operate tool (gating in ADR-3). Co-Authored-By: Claude Opus 4.8 --- docs/adr/deployment-control-plane.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/adr/deployment-control-plane.md b/docs/adr/deployment-control-plane.md index a507126..a8148cf 100644 --- a/docs/adr/deployment-control-plane.md +++ b/docs/adr/deployment-control-plane.md @@ -49,7 +49,7 @@ Vendor-specific terms (ECS/task/ARN/S3/Fargate…) appear **only** inside a | **Deployment** | An Agent's declared desired unit → N Instances | ECS service / k8s Deployment / compose service | | **Fleet** | A set of Deployments | — | | **Spec** | Desired: `identity + version + scale + admission + runtime + configRef` | `.spec` | -| **admission** | Spec field (`accepting_work`): may this Instance take new work | (readiness gate) | +| **admission** | Spec field (`accepting_work`): may this Instance take new work | — (app/CP-level, orthogonal to `Ready`) | | **Instance.Status** | Observed per Instance: `phase + conditions + …` | Pod `.status` | | **phase** | Field on an **Instance**'s Status; value is an `AgentState` — **Instance-level only** | Pod `.status.phase` | | **Deployment.Status** | Observed per Deployment: replica counters `desired/current/ready/available` + Conditions — **not** an `AgentState` | Deployment `.status` | @@ -137,7 +137,6 @@ models; the MCP server is one adapter. |---|---|---| | `deploy_list` | read | list Deployments + `Deployment.status` counters | | `deploy_get` | read | one Deployment's Spec + status (+ each Instance's phase) | -| `deploy_apply` | write | the declarative primitive | | `deploy_scale` | write | change replicas | | `deploy_cordon` | write | admission=off → `Paused` (stay alive) | | `deploy_resume` | write | admission=on → `Running` | @@ -147,6 +146,13 @@ models; the MCP server is one adapter. **`dry_run: bool` is a first-class, required parameter on every write tool** — in the pre-authz interim it is the structured safety preview an agent relies on. +**Raw `apply` is *not* an MCP tool.** `apply` stays the internal primitive; the +surface exposes only the individually-guardable sugar verbs above. Exposing +`deploy_apply` would let a caller submit an arbitrary Spec and thereby bypass +every per-verb guard (§8) — so it is omitted. Creating/provisioning a **new** +Deployment (a raw new Spec) is likewise a **restricted provisioning action**, not +a first-class operate tool; its gating lands in ADR-3. + Excluded from MCP: `exec`/`cp`/`sync` (shell into containers — blast radius), `bootstrap` (infra, one-time), `schedule` (automation). See Non-goals. @@ -197,8 +203,10 @@ the driver. - **MCP tools named `oabctl_*`** — rejected: re-introduces vendor lock-in in the agent-facing vocabulary and duplicates the server namespace. - **Pure declarative MCP (`apply` + `get` only)** — rejected as the surface: - agents get clearer, individually-guardable intents (`scale`/`stop`); they - still compile to `apply`. + agents get clearer, individually-guardable intents (`scale`/`cordon`/`stop`); + they still compile to `apply` internally. Raw `apply` is deliberately *not* + surfaced (it would bypass the per-verb guards); creating a new Spec is a + restricted provisioning action, not a first-class operate tool. - **Read-only ADR-2 (defer all writes)** — rejected: we enable basic write now, and defer *authorization* instead (§6). From 2756c05dc7e21db8a286bd1f14562609cf56c050 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sun, 9 Aug 2026 00:26:35 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(adr):=20mark=20ADR-2=20Accepted=20?= =?UTF-8?q?=E2=80=94=20three-way=20LGTM=20(Mira/Jellyfish/Falcon)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/adr/deployment-control-plane.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/deployment-control-plane.md b/docs/adr/deployment-control-plane.md index a8148cf..3414c6a 100644 --- a/docs/adr/deployment-control-plane.md +++ b/docs/adr/deployment-control-plane.md @@ -1,9 +1,9 @@ # ADR: Deployment Control Plane — read/write model + MCP -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-08-08 - **Author:** @brettchien -- **Reviewers:** +- **Reviewers:** Mira (ECS), Jellyfish (control-plane), Falcon (MCP) — all LGTM - **Tracking issues:** builds on [agent-lifecycle](./agent-lifecycle.md) (ADR-1) > **Y-statement.** In the context of operating agents across runtimes on top of