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"}- · pkg/api/validation_response.goRejections
Section titled “Rejections”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.<computed> |
<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 |
<computed> |
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 |
<computed>, 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 |
<computed> |
<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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
{{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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed>, 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 |
<computed>, 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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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 |
<computed> |
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/{snap} |
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 |
<computed> |
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.