{"record":{"id":"323da549e362a74a","repo":"astrid-runtime/astrid","slug":"unexpected-admin-response-shape-other","errorCode":null,"errorMessage":"unexpected admin response shape: {other:?}","messagePattern":"unexpected admin response shape: (.+?)","errorType":"http","errorClass":"GatewayError::Internal","httpStatus":500,"severity":"error","filePath":"crates/astrid-gateway/src/routes/principals.rs","lineNumber":608,"sourceCode":"// Both helpers consume their argument logically (wrap and discard);\n// clippy::needless_pass_by_value fires because they only `Display` /\n// `Debug` the value. Taking by value keeps `map_err(daemon_internal)`\n// usable as a one-line closure replacement throughout the routes —\n// the by-reference shape would force every call site to write\n// `map_err(|e| daemon_internal(&e))`.\n// The admin-client request path still surfaces `anyhow::Error` (it is not part\n// of this change's typed-error migration — a follow-up); every failure here maps\n// to 500. The bus-direct kernel-request path uses the typed\n// [`daemon_kernel_error`](crate::routes::daemon_kernel_error) instead, which can\n// distinguish a 504 timeout.\n#[allow(clippy::needless_pass_by_value)]\npub(crate) fn daemon_internal(e: anyhow::Error) -> GatewayError {\n    GatewayError::Internal(anyhow::anyhow!(\"daemon request: {e}\"))\n}\n\n#[allow(clippy::needless_pass_by_value)]\npub(crate) fn unexpected(other: AdminResponseBody) -> GatewayError {\n    GatewayError::Internal(anyhow::anyhow!(\n        \"unexpected admin response shape: {other:?}\"\n    ))\n}\n\n/// Read the request body as JSON, capping at 64 `KiB` to bound any\n/// pathological inbound on the otherwise-unauthenticated edge.\npub(crate) async fn read_json_body<T: serde::de::DeserializeOwned>(\n    req: Request<axum::body::Body>,\n) -> GatewayResult<T> {\n    let bytes = axum::body::to_bytes(req.into_body(), 64 * 1024)\n        .await\n        .map_err(|e| GatewayError::BadRequest(format!(\"body read: {e}\")))?;\n    Ok(serde_json::from_slice(&bytes)?)\n}\n","sourceCodeStart":590,"sourceCodeEnd":623,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-gateway/src/routes/principals.rs#L590-L623","documentation":"The `unexpected` helper builds a 500 `GatewayError::Internal` when the daemon returns an `AdminResponseBody` that does not match the variant the caller expected. It is invoked from handlers like `grant_caps`, `revoke_caps`, `write_env_inner`, `list_groups`, `create_group`, and `modify_group` in the catch-all arm of their response matches. It indicates the gateway and daemon disagree on the admin response protocol.","triggerScenarios":"A capability/group/env admin handler matches on the daemon's `AdminResponseBody` and falls through to `Self::unexpected(other)` because the daemon returned a different variant (e.g. `Error`, or a newer/older protocol variant) instead of the expected success payload.","commonSituations":"Gateway and daemon built from different versions after a partial deploy; the daemon rejected the request and returned an error variant the handler didn't expect; a newly added `AdminResponseBody` variant not yet handled in the gateway's match.","solutions":["Read the `{other:?}` debug payload in the 500 response/logs to see which variant actually arrived.","Align gateway and daemon to the same version (redeploy both together).","Add a match arm for the observed variant in the calling handler, mapping `AdminResponseBody::Error` to a client-appropriate GatewayError instead of 500.","Check for a daemon-side error log corresponding to the failed admin request."],"exampleFix":"// before\nmatch resp {\n    AdminResponseBody::Caps(c) => Ok(Json(c)),\n    other => Err(unexpected(other)),\n}\n// after\nmatch resp {\n    AdminResponseBody::Caps(c) => Ok(Json(c)),\n    AdminResponseBody::Error(msg) => Err(GatewayError::Forbidden { reason: msg }),\n    other => Err(unexpected(other)),\n}","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"fn as_caps(resp: &AdminResponseBody) -> Option<&Caps> {\n    match resp { AdminResponseBody::Caps(c) => Some(c), _ => None }\n}","tryCatchPattern":"try {\n    ...\n} catch (e) {\n    if (e.status === 500 && /unexpected admin response shape/.test(e.message)) {\n        // protocol mismatch: refresh both gateway+daemon, log full body\n    } else { throw e; }\n}","preventionTips":["Deploy gateway and daemon together so `AdminResponseBody` variants stay in sync.","Handle `AdminResponseBody::Error` explicitly in every admin handler match.","Add exhaustive-match lint/CI checks on response enums.","Log the full debug payload of any catch-all arm before returning 500."],"tags":["gateway","daemon","response-shape","internal-error"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}