ADR 0004 · Workspace CRD + CalibanTask resolve-and-pin
- Status: accepted
- Date: 2026-07-11
- Source: Prospero K8s Config Plane design (umbrella design doc:
docs/superpowers/plans/2026-07-11-workspace-crd-and-resolve-and-pin.md) · caliban-operator #11 · epic #274 · cross-repo: prospero mirror (prospero#141), chart RBAC (helm-charts#30) · builds on ADR 0001
Context
ADR 0001 put the workspace (sources,
model/provider config) inline on CalibanTask.spec.workspace. Prospero's K8s Config
Plane epic needs a UI where a user edits workspace-level config (git sources, which
model providers are available, which Secret backs each) independently of, and prior
to, launching any task. With the config living only inline on a CalibanTask,
prospero has nowhere to persist it: there is no object to CRUD before a task exists,
and no durable home for it once a task completes and its CalibanTask is deleted.
Prospero's existing k8s-config-plane endpoint has no backing resource to write to and
returns 405 Method Not Allowed for exactly this reason.
The epic's shape for this is explicit: prospero becomes a pure editor of a CRD — it CRUDs Kubernetes objects and lets the operator, not prospero, own reconciliation and credential access. That requires the config to be its own first-class, independently-lifecycled resource, and it requires prospero to never need Secret read access (the operator remains the only component that reads Secret values — a pre-existing, load-bearing security boundary, not a new one introduced here).
Forces:
- Config must outlive any single task and be editable without one. Sources and
provider bindings are workspace-scoped, shared across many
CalibanTasks, and meaningfully exist before the first task is created. - Multiple named providers, not one. A workspace may bind a cheap/local model
(e.g. a local
openai-compatible server such as llama.cpp) for routine work and a stronger hosted model (e.g. Anthropic) for harder tasks; a task picks one by name at submission time, not at workspace-authoring time. - Prospero must never read Secrets. Its CRUD surface is the
Workspace/CalibanTaskspecs only;credentialsRefcarries a Secret name + key, never a value, so the config plane can be edited without grantingsecretsRBAC to prospero. - Immutable-run guarantee must survive the split. #283 and #366 (ADRs 0002–0003)
established that a running task's config is stable for its lifetime. Moving
provider config out of the task's own spec and into a separately-editable
Workspacemust not let a config edit reach into a task that's already running. - Pre-
v1alpha1API, no migration machinery. As in ADR 0001,v1alpha1buys the freedom to reshape the CR without a conversion webhook; this ADR spends that budget on the workspace split rather than deferring it.
Decision
-
A first-class
WorkspaceCRD (caliban.caliban-ai.dev/v1alpha1, namespaced, status subresource, shortnamecws) holds the durable, shared config:sources(the git checkouts), a namedproviderslist (each{name, kind, baseUrl, model, credentialsRef}— acredentialsRefis a by-name Secret+key reference, never a value), an optionaldefaultProvider, non-secretenv, and defaultisolation.WorkspaceStatuscarriesphase(Pending → Ready | Failed— validation is synchronous, so there is no observable intermediate reconciling state),conditions,observedGeneration, and a human-readablemessage. The CRD YAML is generated from the Rust types and golden-tested in sync, per ADR 0001's discipline. -
A pure validation function backs a lightweight status-only reconciler.
validate_workspace(spec, secret_present: Fn(&str, &str) -> bool)checks unique provider names, a resolvabledefaultProvider, and that everycredentialsRefnames an existing Secret key — the existence check only. The controller suppliessecret_presentby calling the Kubernetes API; the operator is the sole reader of Secret contents in the system, unchanged from before this ADR but now made structural: nothing else needssecretsRBAC because nothing else touchesWorkspacereconciliation. The controller's only side effect is patchingWorkspace.status— it creates no pods, no children; provisioning stays entirely in theCalibanTaskreconcile (ADR 0002). -
CalibanTaskreferences aWorkspaceby name and resolves-and-pins at admission.CalibanTaskSpecgainsworkspaceRef: { name }(required) andproviderRef: Option<String>(which named provider to bind; falls back to the workspace'sdefaultProvider, then to the sole provider if there's exactly one).resolve_workspace(spec, provider_ref)is a pure function that flattens the chosen provider and the workspace's sources/env/isolation into a singleResolvedWorkspace, erroring on a danglingworkspaceRef/providerRefor an ambiguous default. TheCalibanTaskreconcile calls it once and pins the result intoCalibanTaskStatus.resolvedWorkspace; if that field is already set, laterWorkspaceedits are not re-resolved into it — the pod is always built from the pinned snapshot, preserving the immutable-run guarantee across the split. -
Inline
CalibanTask.spec.workspaceis removed, not deprecated. Per ADR 0001'sv1alpha1bet, this is a breaking, pre-v1 change with no conversion shim: existingCalibanTaskCRs must be re-created against the new schema (workspaceRef/providerRef) with a companionWorkspaceapplied first. There is no dual-write or fallback path to the old inline shape. -
Sample CRs are the cross-repo contract fixture.
deploy/samples/workspace.yamlanddeploy/samples/calibantask.yamlare committed, mutually-consistent examples (the sample task'sproviderRefnames a provider the sample workspace defines) and are golden-tested here (workspace::tests::sample_manifests_deserialize— both deserialize against the Rust types andresolve_workspacesucceeds against them). Prospero's mirror types are validated against these same fixtures (prospero#141), so this is the shared fixture two repos check against independently.
Consequences
- Prospero gets a durable object to edit and no path to Secrets. Workspace
authoring (sources, provider bindings) no longer requires an existing task, and
prospero's CRUD surface never needs
secretsRBAC —credentialsRefis name-only. This is the change that turns the405from the epic's Context into a real endpoint. - The immutable-run guarantee is preserved, now across two objects instead of
one. A running task's pod is built from
status.resolvedWorkspace, pinned once; editing the backingWorkspace(rotating a provider, changing a source) affects only tasks admitted after the edit, never one already running. This mirrors ADR 0002's status-derivation discipline (compute, compare, patch only on change) applied to a resolve instead of a derive. - This is a breaking API change with no migration. Every existing
CalibanTaskin any cluster must be replaced (workspaceRef+ a companionWorkspace), and every caller (prospero, any manual manifests) must move off inlinespec.workspacein lockstep with this operator version. Acceptable pre-1.0 per ADR 0001'sv1alpha1bet; would not be acceptable post-graduation. - The operator's own cluster RBAC needs a
Workspace/workspaces/statusgrant. This is a small addition to the ClusterRole ADR 0002 already required forCalibanTask; the actual chart change is cross-repo and deferred to helm-charts#30, not addressed here. - Multi-namespace tenancy remains deferred.
workspaceRefis a same-namespace, by-name reference (mirroringWorkspaceRef's singlenamefield); aCalibanTaskcannot bind to aWorkspacein another namespace. Cross-namespace workspace sharing, if ever needed, is a later ADR. - Prospero's mirror types can rot independently of this repo. The sample CRs are the shared contract, but there is no compile-time link between this repo's Rust types and prospero's — a schema change here that isn't reflected in prospero#141's mirror surfaces only at prospero's own test run, not here.