Skip to content

Rejections and alterations

Every rejection the control plane returns carries a machine-readable code, the field path that failed, the value it received and the accepted form. Branch on the code; never match on the message text, which is written for a person and changes when the wording improves.

{
"code": "healthcheck_timeout_below_floor",
"field": "healthCheck.timeout",
"received": "4s",
"expected": ">= 10s, or omit the field",
"why": "a timed-out exec is SIGKILLed and reparented to a PID 1 that never reaps it",
"doc": "https://odysseus.delta-telematics.ca/docs/reference/manifest/deployment#healthcheck"
}
Validated against - · pkg/api/validation_response.go

A rejection means nothing was stored. Fix the named field and send it again.

Generated from Rule. Hand edits to this table are overwritten on the next build — change the Go doc comment, or the generator.

Code Field Accepted form Why
anchored_secret_dropped secrets keep the entry {name: %q, vault_path: %q, anchor: %q}, or remove volume %q in the same update to release the anchor the update drops a secret anchored to a volume it still mounts; the container would be recreated without the credential its own data directory was initialized with
anchored_secret_repointed <computed> %q — the path the material at volume %q was initialized from. To rotate, use POST /api/v1/deployments/{name}/secrets/rotate; to release the anchor, remove volume %q in the same update changing the path drifts the spec hash and recreates the container, which then authenticates against an unchanged data directory with material it has never seen
deployment_name_invalid name lowercase letters, digits and hyphens only, starting with a letter and ending with a letter or digit — e.g. %q the name is used verbatim as a container name, a Traefik router id, a DNS alias on the tenant network and a Consul KV path segment, and every one of those refuses uppercase, underscores, dots and spaces
deployment_name_too_long name at most %d characters, e.g. %q the name becomes a DNS label for the container, and a label longer than 63 characters cannot be resolved by sibling containers
deployment_not_found name, deployment_id the name of a deployment that already exists — list them with GET /api/v1/deployments a PUT updates an existing record and cannot create one; POST /api/v1/deployments creates
dvm_recommendation_apply_invalid_spec spec a change that leaves the volume valid — see the underlying message the platform will not write a volume spec that its own validator rejects
dvm_recommendation_bad_parameter parameters.&lt;computed&gt; <computed> <computed>
dvm_recommendation_insufficient_nodes parameters.target_replicas %d enrolled node(s) — one for the primary plus one per replica. Enroll %d more, or apply a smaller replica count. every replica must sit on a distinct node, so N replicas need N+1 nodes and the platform will not place two copies on one machine
dvm_recommendation_not_applicable id the id of a recommendation whose “applicable” field is true — read GET /api/v1/dvm/recommendations and act on those <computed>
dvm_recommendation_not_found id the id of a recommendation returned by GET /api/v1/dvm/recommendations for this tenant — re-read that list to get current ids recommendations are recomputed from live volume state on every read, so one disappears as soon as the condition it described stops holding
dvm_recommendation_stale_measurement parameters.target_size_bytes a size of at least %d bytes, which is what the volume currently holds the volume has grown since this recommendation was computed, and resizing it below its own contents would under-declare live data
dvm_recommendation_tenant_required X-Tenant-ID a tenant id, e.g. “X-Tenant-ID: acme” — platform admins pick one with the tenant selector in the dashboard header a recommendation is about one tenant’s volumes, and answering without a tenant would return an empty list that looks like a real answer
dvm_recommendation_unknown_action parameters.action one of: %s, %s an action verb the apply endpoint does not implement would otherwise return success while doing nothing
dvm_recommendation_unknown_priority priority one of: %s — for example ?priority=%s filtering on a priority that does not exist would return an empty list that reads as a real answer
dvm_recommendation_unknown_type type one of: %s — for example ?type=%s filtering on a kind that does not exist would return an empty list that reads as a real answer
dvm_recommendation_volume_missing volume_id a volume that still exists in this tenant — read GET /api/v1/dvm/volumes the recommendation was computed from a volume that has since been deleted or moved, so applying it would write a spec for something that is not there
healthcheck_command_missing healthCheck.command a command the image can run, e.g. [“wget”,“-qO-”,“http://127.0.0.1:8080/health”] an exec declaration with no command produces no container healthcheck at all
healthcheck_retries_below_floor healthCheck.retries set >= %d, or omit retries to take Docker’s default one unhealthy container degrades the whole deployment, and retries is the only damper
healthcheck_timeout_below_floor healthCheck.timeout set >= %s, or omit timeout to take Docker’s 30s default a timed-out exec is SIGKILLed and reparented to a PID 1 that never reaps it
healthcheck_type_inert healthCheck.type set type: “exec” with a command the image can run — shell form [“wget -qO- http://127.0.0.1:%d%s”] or argv [“wget”,“-qO-”,“http://127.0.0.1:%d%s”] an inert declaration silently SATISFIES the dependsOn: healthy and rolling-update readiness gates it was meant to guard
healthcheck_type_unsupported healthCheck.type the only accepted value is “exec” no other type produces a container healthcheck
image_not_found image an image reference whose manifest already exists in the registry — push it first with the exact tag you are deploying, or correct a typo in the tag the registry confirmed no manifest exists for this exact reference; admitting the write would destroy the running container and only then discover the image cannot be pulled — the failure measured on odysseus-marketing 2026-08-21, where a healthy container was torn down twice for a tag that had never been pushed
image_required image a pinned reference, e.g. “nginx:1.27” — never :latest there is nothing to run without one
job_node_candidates_unresolvable nodeId a node with a reachable agent — enroll or adopt one, then retry no node with a reachable agent could be listed, so no target can be honoured — either none is enrolled or the gateway could not be read, and the two are indistinguishable from here
job_node_not_found nodeId one of: %s a job has no scheduler, so an unknown node is not a degraded placement — it is a job that dispatches and retries forever against a machine that is not there
manifest_wrong_kind kind kind: Deployment — or send this document to its own endpoint a Job and a Deployment are different primitives with different fields, and decoding one as the other would drop the fields it does not share
name_required name a lowercase DNS-style name, e.g. “web-api” the name is the deployment’s identity and its container name prefix
network_node_candidates_unresolvable nodeId enroll a node for this tenant, then create the network no nodes are registered for this tenant, so there is nowhere to create the network — either none is enrolled or the node store is unreachable, and the two are indistinguishable from here
network_node_not_a_candidate nodeId one of: %s a Docker network exists only on the host it was created on, and this tenant cannot place workloads on the node you named — the network would be unreachable from every container you could run
network_node_required nodeId one of: %s a Docker network is created on ONE host, so with more than one node available the platform cannot know which you meant — picking for you is what made networks appear on a node nobody expected
network_option_unsupported_on_agent &lt;computed&gt; omit it — or create the network directly on the node until agent support lands the node agent’s network wire format carries only name, driver, labels and internal, so this value cannot reach the host and the network would be created without it
node_candidates_unresolvable nodeId enroll a node for this tenant, or omit nodeId to let the scheduler place this deployment no node is registered under this tenant, so no target can be honoured — either none has been enrolled or the node store could not be read, and the two are indistinguishable from here
node_claim_release_no_tenant X-Tenant-ID the UUID of the tenant giving up its claim a node under two tenants has two claims, so a request that names no tenant names no claim and could remove either one
node_not_a_placement_candidate nodeId one of: %s (or omit nodeId to let the scheduler decide) placement only considers this deployment’s own tenant’s enrolled nodes, so a node outside that set would be accepted here and then silently overridden at dispatch
node_owned_by_another_tenant nodeName a node name not already registered to another tenant a node belongs to exactly one tenant and is never shared. The node agent returns every container on a machine to whoever presents a token for that machine’s tenant, because it trusts this; a second registration is what would let one tenant’s workload be placed on another’s hardware
plaintext_credential_in_environment &lt;computed&gt;, environment.%s remove environment.%s and reference Vault instead: “secrets”: [{“name”: %q, “vault_path”: “deployments/%s”}] Consul stores environment values in clear text: they appear in every spec export and backup, and anyone who can read the <computed> can read the secret
query_parameter_unknown &lt;computed&gt; <computed> <computed>
registry_credential_store_unavailable image nothing — your request is well-formed. Retry once the control plane reports healthy; an operator should check that ODYSSEUS_CONSUL_ADDRESS is set and that /healthz reports consul connected the control plane could not reach its own credential store, so it cannot tell whether this image is pullable, and it will not accept a write it cannot check when the reason is its own fault rather than the registry’s — a registry that is merely unreachable is allowed through with a warning
replicas_negative replicas 0 or greater — use 0 to stop the deployment without deleting it a negative replica count has no meaning; scaling to 0 is how you stop something
replicas_out_of_range replicas an integer between 0 and %d (max_replicas_per_deployment; raise it in the control plane’s config if your cluster genuinely runs more) a runaway replica count exhausts every node in the fleet before any quota can catch it; scale-to-zero (0) is allowed, negatives are meaningless
reserved_infrastructure_name name any name other than %q — that one is reserved platform-wide because the control plane connects to it as %s (configured in %s). If you meant to change the platform’s own database, edit infrastructure/docker-compose.yml on the host; it is deliberately not an Odysseus deployment. If you want a database of your own, %q or %q are free. %s is a service the control plane needs in order to record what it does, so it cannot be a workload the control plane starts — at a cold boot the control plane would be waiting for a container it has not started yet, and the audit trail for that boot would be lost
rotation_transition_invalid phase one of %v from phase %q the overlap window guarantees that at no point are zero credentials valid, and that guarantee only holds if phases advance in order
secret_anchor_invalid &lt;computed&gt; one of: “process”, “external”, or “volume(<volume-id>)” — e.g. “volume(codecheck-db-data)” the anchor tells the platform whether this credential may ever be re-minted; an unrecognised value would be silently ignored, which is the failure this field exists to prevent
secret_anchor_volume_ephemeral &lt;computed&gt; a volume with type “volume” or “bind”; %q is type %q. Use “process” if the material is genuinely recreated with the container tmpfs is ephemeral by definition — it is discarded with the container, so it anchors nothing and an anchor on it would grant a false guarantee
secret_anchor_volume_not_declared &lt;computed&gt; volume(<id>) naming a volume this spec declares; declared here: %v an anchor pointing at a volume the workload does not mount protects nothing — the material would be re-minted on the next recreate and the container would authenticate with a credential its datastore does not have
secret_compose_no_reference &lt;computed&gt; a template containing at least one {{secret.<resource>.<key>}} a template with no secret reference is a constant and belongs in environment:, where it is visible and diffable
secret_compose_resource_undeclared &lt;computed&gt; a resource this deployment declares: %v a template may only read a resource the workload already references, so a typo is caught here instead of failing closed at dispatch with no container
secret_compose_template_empty &lt;computed&gt; postgresql://user:{{secret.<resource>.<key>}}@host:5432/db an empty template renders to an empty env var, which the consumer reads as unset
secret_compose_template_malformed &lt;computed&gt; {{secret.<resource>.<key>}} — lowercase DNS resource, env-var-shaped key an unrecognised placeholder would be delivered to the container as literal braces, and the workload would connect with the wrong credential
secret_generate_axis_conflict generate either bytes/encoding (random bytes rendered as text) OR length/charset (characters drawn from an alphabet) — not both the two describe different things and the platform would have to ignore one of them, which it will not do silently
secret_generate_below_entropy_floor generate.length length >= %d with charset %q (or omit length for the default %d) length %d over a %d-character alphabet is %.0f bits; the platform floor is %d bits, below which offline brute force is a budget question rather than a physics one
secret_generate_bytes_out_of_range generate.bytes %d..%d (omit for the default %d) %d bytes is %d bits; the platform floor is %d bits, and above %d bytes the encoded value exceeds the %d-character ceiling Vault is sized for
secret_generate_charset_unknown generate.charset one of %q, %q, %q an unknown alphabet cannot be entropy-checked, so the platform cannot promise the value meets its floor
secret_generate_encoding_unknown generate.encoding one of %q, %q, %q the platform cannot render bytes in an encoding it does not implement, and guessing one would hand your app material it cannot decode
secret_generate_key_duplicate &lt;computed&gt; each key listed once a duplicate would be minted twice and the second write would silently win
secret_generate_keys_required generate.keys at least one key, e.g. [“password”] a generate block with no keys mints nothing and silently produces a ref to an empty document, which fails closed at dispatch
secret_generate_length_out_of_range generate.length 1..%d a negative length is meaningless and an oversized one would be refused by Vault after a partial write
secret_generate_would_persist secrets[%d].generate nil — call StripSecretAuthoring before persisting a stored generate block would be re-evaluated on every rollback and could mint material a datastore does not know
secret_key_invalid &lt;computed&gt; an env-var name matching [A-Za-z_][A-Za-z0-9_]*, e.g. “S3_ACCESS_KEY” the document key becomes the container env var verbatim, and a leading digit is not assignable in sh
secret_key_provided_and_generated &lt;computed&gt; declare the key in values OR in generate.keys, not both the platform would have to choose between your value and a minted one, and either choice is a silent alteration
secret_material_would_persist secrets[%d].values an empty values map — call StripSecretAuthoring before persisting Consul KV holds refs only; a stored value would enter the spec hash, every revision, and every rollback
secret_mount_path_unsupported secrets[%d].mount_path remove mount_path; render the file at container start from the env value with an entrypoint delivery is env-only (WP2 ruling D1) — a file mount would put the value on a disk the control plane does not control
secret_name_invalid secrets[%d].name an env-var prefix: [A-Za-z0-9_]+, e.g. “DB_CREDS” the name is prefixed onto every key resolved from this document (NAME_key), so it must itself be legal environment-variable material
secret_path_name &lt;computed&gt; a DNS label after the namespace: lowercase letters, digits and inner hyphens, 1-63 characters, e.g. “resources/codecheck-db” the name is compared against workload names to detect a credential referenced under two namespaces at once; a path that names no document cannot take part in that comparison and resolves to nothing at dispatch
secret_path_namespace &lt;computed&gt;, namespace <computed> only these namespaces are granted by the control plane’s Vault policy and by tenant application policies; anything else resolves as a 403 at dispatch and the workload goes Degraded with no container
secret_path_namespace_split secrets[%d].vault_path one namespace per credential — use %q for every reference to %q the same credential under two namespaces is two Vault documents that must be kept identical by hand; that duplication is what the resources/ namespace exists to remove
secret_path_shape &lt;computed&gt;, resource <computed> the first segment is the namespace that every Vault policy grant — the control plane’s and each tenant application’s — is written against; a path outside a granted namespace resolves as a 403, and a 403 is indistinguishable from an empty secret to a caller that does not classify it
secret_rotation_unsupported secrets[%d].rotation “restart”, or omit the field — it defaults to restart the container picks up new material only by being recreated; signal/webhook/file_watch were never implemented and would silently do nothing
secret_value_empty &lt;computed&gt; a non-empty string an empty value resolves to an empty env var, which most images treat as unset and then fail obscurely at runtime
secret_value_too_long &lt;computed&gt; at most %d bytes Vault KV v2 caps a document and an oversized value would be rejected at write time, after other keys were already stored
secret_vault_path_not_relative secrets[%d].vault_path a relative path with no leading “/” and no “..” elements, e.g. “deployments/codecheck-db” the path is appended to the tenant’s own Vault namespace; a leading slash or “..” would escape it and reach another tenant’s material
secret_vault_path_required secrets[%d].vault_path a relative Vault KV path inside the tenant’s mount, e.g. “deployments/codecheck-db” without it the control plane has nothing to resolve at dispatch and the deployment fails closed with no container
secret_write_empty values either {“values”:{“KEY”:“…”}} or {“generate”:{“keys”:[“KEY”]}} a write with neither supplies nothing and would create an empty document that fails closed at dispatch
secrets_version_negative secretsVersion 0 on a new spec; thereafter only the rotation API bumps it secretsVersion is the rotation counter the spec hash covers — a negative value cannot have been produced by a rotation, so the spec did not come from this platform
volume_access_mode_conflict &lt;computed&gt; exactly one read-write consumer per replicated/ephemeral (ReadWriteOnce) volume — mount read-only, or detach the other deployment first two concurrent writers on a single-writer volume corrupt each other; single-writer is the class’s guarantee
volume_binding_unavailable volumes remove the distributed mounts, or enable volume management on this control plane admitting the mount with no controller would store a binding nothing can resolve
volume_class_not_wired &lt;computed&gt; a volume of class “ephemeral” or “replicated” the %q storage class is not yet dispatchable — its backend wiring is the follow-on slice of milestone 28, and binding it now would silently mount node-local storage instead of what the class promises
volume_mount_shape volumes[%d].%s <computed> <computed>
volume_not_found &lt;computed&gt; the typed ID of a volume this tenant owns — list them with GET /api/v1/dvm/volumes the reference does not resolve to a volume owned by this tenant
volume_not_ready &lt;computed&gt; a volume in phase “Ready” mounting a volume that is still materializing (or degraded) dispatches a container against storage that may not exist on the target node yet
volume_primary_mismatch nodeId %q (the volume’s current primary), or omit nodeId and let placement follow the volume a read-write mount off the primary node would write to a replica, which the next replication pass then overwrites — data loss, not a preference
volume_snapshot_not_found snapshots/&#123;snap&#125; the id of a snapshot record this volume actually holds — list them with GET /api/v1/tenants/{tenant}/volumes/{id}/snapshots. That list is normally empty: snapshot creation is refused with volume_snapshot_not_implemented, so the only records that can exist are ones written before that refusal shipped an id handed out by the old endpoint named work that was never started, so a 404 here is usually not a mislaid record but volume_snapshot_not_implemented showing up one call later
volume_snapshot_not_implemented &lt;computed&gt; there is no accepted form of this request: the operation has no implementation behind it, so no body would succeed. What Odysseus does offer, and which need each one meets — (1) to keep a volume’s DATA available when the node holding it is lost, give the volume replicas: POST /api/v1/tenants/{tenant}/volumes/{id}/replicas {“node_id”:“<node>”} on a volume of class “replicated”, which keeps a live copy on another node and promotes it when the primary goes away; (2) to keep a point-in-time copy of a deployment’s CONFIGURATION — its spec, scaling policy, canary state and revision history — use Backups for a quick undo after a risky change (POST /api/v1/backups {“deploymentName”:“<name>”}) or Archives for versioned history you can go further back in (GET /api/v1/archives/deployments/{name}); neither reads or writes a single byte on a volume; (3) to keep a point-in-time copy of a volume’s BYTES, take it from inside the workload that owns them — a scheduled cronjob deployment that mounts the volume and writes its dump off the node, for example to a volume of class “object” — because only that workload knows when its data is consistent nothing in the platform takes a distributed-volume snapshot — no node agent exposes a snapshot operation and no controller consumes a snapshot request — so accepting this would hand back an identifier for work that never starts, never completes and can never be fetched

Alterations — changes the server makes that you did not send

Section titled “Alterations — changes the server makes that you did not send”

A silent alteration is a silent rejection wearing a 200. Every entry below is announced in the response body and in the log, so a change to your workload is never something you have to discover.

Generated from Rule. Hand edits to this table are overwritten on the next build — change the Go doc comment, or the generator.

Code Field What changes
attach_node_id_ignored node_id node_id is ignored: the deployment mounts the volume wherever placement (and the volume’s primary) puts it; pin the deployment’s own nodeId if you must, and admission will reject a pin that fights the volume’s primary
command_repaired command the shell command was split on whitespace and has been rejoined into a single argument. Send the script as ONE array element, e.g. [“sh”,“-c”,“a && b”].
entrypoint_repaired entrypoint the entrypoint was split on whitespace and has been rejoined into a single argument. Send the script as ONE array element.
healthcheck_timeout_clamped healthCheck.timeout the stored probe timeout was below the <computed> floor and was raised to it. You did not send this field; the change was applied to the stored value. Send healthCheck.timeout explicitly to control it.
image_pull_check_inconclusive image could not confirm %q exists on %s before accepting this write (%s). The registry may be unreachable, slow, returning an error, or this tenant may have no credentials configured for it. The write was NOT refused for this reason — a registry outage must never block every deployment write on the platform. If the image is genuinely missing, the create/recreate that follows this write will fail at dispatch instead of being caught here.
name_from_path name the name in the body was ignored: a PUT identifies the deployment by its URL and cannot rename it. Delete and recreate to change a name.
node_auto_assigned nodeId no nodeId was sent and this tenant has exactly one enrolled node, so the deployment was pinned to it. Send nodeId explicitly to control placement.
placement_residency_disabled placement.residency this deployment declares data residency (%s), and geo-awareness is switched OFF on this control plane, so the constraint is STORED BUT NOT ENFORCED — the scheduler may place this workload on a node anywhere. Turn it on by setting scheduler.geo_awareness.enabled: true in the control plane’s config file and restarting it; the regions it may then name are declared under scheduler.geo_awareness.regions in the same file.
placement_residency_unsatisfiable placement.residency no node this tenant can reach advertises a label that satisfies %s. Across the %d node record(s) read, the advertised values are: odysseus.io/country %s, odysseus.io/geo-region %s. The deployment was accepted and stored — it will stay PENDING until a node advertises a matching label, and the scheduler’s refusal will quote both the required value and the node’s own. A node’s labels come from its agent config and are re-advertised on the AGENT’s restart, never on a control-plane restart.
replicas_defaulted replicas replicas was absent or not positive, so it was set to 1. Send replicas explicitly to control it.
secret_refs_preserved secrets secrets[] is read-modify-write, unlike every other array on this endpoint: %s were kept because your body did not mention them. Omitting a ref NEVER removes it. To remove one, send “secrets”: [] to clear the array, then PUT the refs you want to keep.
secret_rotation_shape_changed keys.&lt;computed&gt; you asked for a shape that differs from the material currently stored for this key. The new value will not have the same form as the old one; a consumer that decodes it may reject it. Omit length/charset and bytes/encoding to keep the current shape.
secret_rotation_shape_unknown keys.&lt;computed&gt; the current material for this key does not match any shape the platform mints, so its form could not be preserved. The replacement uses the platform default. State bytes/encoding or length/charset explicitly to control it.
secrets_version_bumped secretsVersion new secret material was written, so secretsVersion was incremented to drift the spec hash and recreate the container — the value would otherwise not reach a running container