ai_memory/handlers/archive.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers for the archive surface (#650 follow-up per-domain
5//! split). The five archive endpoints listed below were extracted
6//! verbatim from `src/handlers/http.rs` (commit 88d9a96, lines
7//! 7196-7558). Wire compatibility is preserved via the
8//! `pub use archive::*` re-export from `src/handlers/mod.rs`; the
9//! Axum router registrations in `src/lib.rs` are unchanged.
10//!
11//! Routes wired here:
12//!
13//! * `GET /api/v1/archive` → [`list_archive`]
14//! * `POST /api/v1/archive` → [`archive_by_ids`]
15//! * `DELETE /api/v1/archive` → [`purge_archive`]
16//! * `POST /api/v1/archive/{id}/restore` → [`restore_archive`]
17//! * `GET /api/v1/archive/stats` → [`archive_stats`]
18
19use crate::models::field_names;
20use axum::{
21 Json,
22 extract::{Path, Query, State},
23 http::{HeaderMap, StatusCode},
24 response::IntoResponse,
25};
26use serde::Deserialize;
27use serde_json::json;
28
29use crate::db;
30use crate::identity::sentinels;
31use crate::validate;
32
33use super::AppState;
34use super::MAX_BULK_SIZE;
35#[cfg(feature = "sal")]
36use super::StorageBackend;
37#[cfg(feature = "sal")]
38use super::store_err_to_response;
39
40#[derive(Debug, Deserialize)]
41pub struct ArchiveListQuery {
42 pub namespace: Option<String>,
43 #[serde(default = "default_archive_limit")]
44 pub limit: Option<usize>,
45 #[serde(default)]
46 pub offset: Option<usize>,
47}
48
49#[allow(clippy::unnecessary_wraps)]
50fn default_archive_limit() -> Option<usize> {
51 Some(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
52}
53
54pub async fn list_archive(
55 State(app): State<AppState>,
56 headers: axum::http::HeaderMap,
57 Query(q): Query<ArchiveListQuery>,
58) -> impl IntoResponse {
59 // #943 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
60 // only gate. Pre-fix any caller could enumerate every archived
61 // row in the deployment. Mirror the #946 list_agents pattern;
62 // sibling of archive_stats below.
63 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "list_archive") {
64 return resp;
65 }
66 // Ultrareview #350: validate limit range. `usize` already precludes
67 // negative values at the serde layer, but `limit=0` silently
68 // returned an empty page — indistinguishable from "no results".
69 // Require 1..=1000 and reject 0 with a specific error.
70 if matches!(q.limit, Some(0)) {
71 return (
72 StatusCode::BAD_REQUEST,
73 Json(json!({"error": "limit must be >= 1"})),
74 )
75 .into_response();
76 }
77
78 // v0.7.0 ARCH-2 FX-C2-batch5 (2026-05-27): route the postgres branch
79 // through the new `MemoryStore::list_archived` trait method (the
80 // SAL is now the canonical archive-list surface). The legacy
81 // `list_archived_via_store` downcast hatch still exists for
82 // out-of-tree callers but new routes ride the trait surface.
83 // Pre-batch5 the postgres branch dispatched through
84 // `list_archived_via_store`, which downcasted to `PostgresStore`
85 // via the `as_any_for_postgres` hatch.
86 #[cfg(feature = "sal-postgres")]
87 if matches!(app.storage_backend, StorageBackend::Postgres) {
88 let limit = q
89 .limit
90 .unwrap_or(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
91 .clamp(1, crate::storage::LIST_MAX_LIMIT);
92 let offset = q.offset.unwrap_or(0);
93 return match app
94 .store
95 .list_archived(q.namespace.as_deref(), limit, offset)
96 .await
97 {
98 Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
99 Err(e) => store_err_to_response(e),
100 };
101 }
102
103 let limit = q
104 .limit
105 .unwrap_or(crate::storage::ARCHIVE_DEFAULT_PAGE_LIMIT)
106 .clamp(1, crate::storage::LIST_MAX_LIMIT);
107 let offset = q.offset.unwrap_or(0);
108 // PERF-1 (FX-3): wrap rusqlite scan in `db_op`. archived_memories
109 // can carry hundreds of thousands of rows on long-running daemons;
110 // the LIMIT/OFFSET scan can take 50ms+ at the tail and would
111 // otherwise pin a tokio worker.
112 let namespace = q.namespace.clone();
113 let result = super::db_op(app.db.clone(), move |guard| {
114 db::list_archived(&guard.0, namespace.as_deref(), limit, offset)
115 })
116 .await;
117 match result {
118 Ok(items) => Json(json!({"archived": items, "count": items.len()})).into_response(),
119 Err(e) => crate::handlers::errors::handler_error_500(&e),
120 }
121}
122
123pub async fn restore_archive(
124 State(app): State<AppState>,
125 headers: HeaderMap,
126 Path(id): Path<String>,
127) -> impl IntoResponse {
128 if let Err(e) = validate::validate_id(&id) {
129 return (
130 StatusCode::BAD_REQUEST,
131 Json(json!({"error": e.to_string()})),
132 )
133 .into_response();
134 }
135
136 // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
137 // state-change audit. Restoring a row pulls it from the archived
138 // table back into the live working set; this is a privileged admin
139 // operation that must produce a forensic-chain entry BEFORE the
140 // storage write.
141 let header_agent_id = headers
142 .get(crate::HEADER_AGENT_ID)
143 .and_then(|v| v.to_str().ok());
144 let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
145 .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
146 crate::governance::audit::record_decision(
147 &caller,
148 "allow",
149 crate::governance::action_labels::ARCHIVE_RESTORE,
150 "",
151 json!({ "id": &id }),
152 );
153 // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
154 // route through the SAL `archive_restore` trait method. Federation
155 // fanout for restore stays sqlite-only (the `broadcast_restore_quorum`
156 // path uses sqlite-coupled fed-tracker state); postgres-backed
157 // operators relying on multi-node consistency should poll peers.
158 #[cfg(feature = "sal")]
159 if matches!(app.storage_backend, StorageBackend::Postgres) {
160 // QC P1 fix (2026-05-20): use the resolved `caller` (header)
161 // so the SAL #910 visibility filter applies — archive ops
162 // can only touch memories owned by the caller.
163 let ctx = crate::store::CallerContext::for_agent(caller.clone());
164 return match app.store.archive_restore(&ctx, &id).await {
165 Ok(true) => {
166 // #950 SECURITY-medium (Track A QC sweep, 2026-05-20)
167 // — fire subscription dispatch on the postgres restore
168 // path. Re-fetch the restored row to anchor the event
169 // on its namespace; if the lookup fails, skip dispatch
170 // rather than firing with a synthetic namespace.
171 if let Ok(mem) = app.store.get(&ctx, &id).await {
172 let mem_owner = mem
173 .metadata
174 .get("agent_id")
175 .and_then(|v| v.as_str())
176 .map(str::to_string);
177 super::dispatch_event_postgres(
178 &app,
179 "memory_restore",
180 &id,
181 &mem.namespace,
182 mem_owner.as_deref(),
183 None,
184 )
185 .await;
186 }
187 Json(
188 json!({"restored": true, "id": id, (field_names::STORAGE_BACKEND): "postgres"}),
189 )
190 .into_response()
191 }
192 Ok(false) => (
193 StatusCode::NOT_FOUND,
194 Json(json!({"error": crate::errors::msg::NOT_FOUND_IN_ARCHIVE})),
195 )
196 .into_response(),
197 Err(e) => store_err_to_response(e),
198 };
199 }
200
201 // #940 (security-high, 2026-05-20) — caller-vs-row-owner gate.
202 // Pre-#940 the sqlite branch called the owner-blind
203 // `db::restore_archived(&lock.0, &id)` and any authenticated HTTP
204 // caller could restore any other owner's archived rows back into
205 // the live working set. The postgres SAL branch above was already
206 // QC-P1-fixed (2026-05-20) to pass
207 // `CallerContext::for_agent(caller)`; this routes the sqlite
208 // branch through the caller-scoped variant for parity. A
209 // non-owner attempt returns 404 (not 403) so the surface cannot
210 // be used to enumerate other owners' archived ids — mirrors the
211 // #927 `get_memory` posture.
212 // PERF-1 (FX-3): wrap the rusqlite restore in `db_op`. The restore
213 // path does INSERT...SELECT + DELETE on archived_memories, then
214 // re-inserts the row into memories triggering FTS rebuild — all
215 // on the calling thread pre-fix.
216 let id_for_restore = id.clone();
217 let caller_for_restore = caller.clone();
218 let restored = match super::db_op(app.db.clone(), move |guard| {
219 db::restore_archived_for_caller(&guard.0, &id_for_restore, &caller_for_restore)
220 })
221 .await
222 {
223 Ok(v) => v,
224 Err(e) => {
225 return crate::handlers::errors::handler_error_500(&e);
226 }
227 };
228 if !restored {
229 return (
230 StatusCode::NOT_FOUND,
231 Json(json!({"error": crate::errors::msg::NOT_FOUND_IN_ARCHIVE})),
232 )
233 .into_response();
234 }
235
236 // v0.6.2 (S29): broadcast the restore to peers so they move the row
237 // from `archived_memories` → `memories` in lockstep. Without this, a
238 // POST /api/v1/archive/{id}/restore on node-1 leaves node-2..4 with
239 // the row still archived, so node-4 never sees M1 re-enter the active
240 // set (the testbook-v3 S29 assertion). Same posture as
241 // `archive_by_ids`: on a quorum miss we short-circuit with 503 so
242 // operators can retry.
243 if let Some(fed) = app.federation.as_ref() {
244 match crate::federation::broadcast_restore_quorum(fed, &id).await {
245 Ok(tracker) => {
246 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
247 // #869 — typed 503 envelope via the shared helper.
248 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
249 return super::quorum_not_met_response(&payload);
250 }
251 }
252 Err(e) => {
253 // Local commit already landed — sync-daemon catches
254 // stragglers. Same posture as `fanout_or_503`.
255 tracing::warn!("restore fanout error (local committed): {e:?}");
256 }
257 }
258 }
259
260 Json(json!({"restored": true, "id": id})).into_response()
261}
262
263#[derive(Debug, Deserialize)]
264pub struct PurgeQuery {
265 pub older_than_days: Option<i64>,
266}
267
268pub async fn purge_archive(
269 State(app): State<AppState>,
270 headers: HeaderMap,
271 Query(q): Query<PurgeQuery>,
272) -> impl IntoResponse {
273 // #911 (security-medium / SOC2, 2026-05-19) — admin action audit.
274 // `archive_purge` permanently deletes archived memories; SOC2-grade
275 // deployments must prove "who deleted what when". Forensic-chain
276 // entry is emitted BEFORE the destructive write so the audit trail
277 // captures the intent even if the storage layer errors.
278 let header_agent_id = headers
279 .get(crate::HEADER_AGENT_ID)
280 .and_then(|v| v.to_str().ok());
281 let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
282 .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
283
284 // #936 (security-critical, 2026-05-20) — caller-vs-row-owner gate.
285 // Pre-#936 the SAL trait method took NO caller and the handler
286 // resolved the caller only for the audit emit; the destructive
287 // DELETE then ran without any owner constraint. Any authenticated
288 // caller could destroy every owner's archive corpus.
289 //
290 // Default posture: `CallerContext::for_agent(caller)` — the SAL
291 // constrains the DELETE to rows whose `metadata.agent_id`
292 // (or the inbox-target carve-out `metadata.target_agent_id`)
293 // matches the caller. A non-admin call with no matching rows
294 // returns 200 OK with `{purged: 0}` so the surface cannot be
295 // used to enumerate other owners' archived rows.
296 //
297 // Admin/operator path: when the caller appears in the
298 // operator-configured `[admin].agent_ids` allowlist the SAL is
299 // called with `bypass_visibility = true` (the SHIP-cluster
300 // `for_admin` posture) so the DELETE runs cross-tenant — the
301 // legitimate `archive_max_days` operator wipe surface.
302 let is_admin = crate::handlers::admin_role::is_admin_caller_trusted(&app, &caller);
303 crate::governance::audit::record_decision(
304 &caller,
305 "allow",
306 crate::governance::action_labels::ARCHIVE_PURGE,
307 "",
308 json!({
309 (field_names::OLDER_THAN_DAYS): q.older_than_days,
310 (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
311 }),
312 );
313
314 // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
315 // route through the SAL trait. Wire shape preserved: `{purged}`.
316 //
317 // v0.7.0 #1062 (Agent-2 #9) — use `for_admin_checked` (not the
318 // raw `for_admin` literal) so the admin posture is a typed
319 // dependency of the construction call. A future refactor that
320 // moves the construction earlier in the function (or removes
321 // the gate) cannot accidentally hand an admin context to a
322 // non-admin caller — `is_admin` MUST be threaded through.
323 #[cfg(feature = "sal")]
324 if matches!(app.storage_backend, StorageBackend::Postgres) {
325 let ctx = crate::store::CallerContext::for_admin_checked(caller.clone(), is_admin);
326 return match app.store.archive_purge(&ctx, q.older_than_days).await {
327 Ok(n) => Json(json!({
328 "purged": n,
329 (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
330 (field_names::STORAGE_BACKEND): "postgres",
331 }))
332 .into_response(),
333 Err(e) => store_err_to_response(e),
334 };
335 }
336
337 // PERF-1 (FX-3): wrap the rusqlite DELETE in `db_op`. The purge
338 // can touch tens of thousands of archived rows; running it on the
339 // tokio worker pinned the runtime for the whole DELETE.
340 let caller_for_purge = caller.clone();
341 let older_than_days = q.older_than_days;
342 let purge_result = super::db_op(app.db.clone(), move |guard| {
343 if is_admin {
344 db::purge_archive(&guard.0, older_than_days)
345 } else {
346 db::purge_archive_for_caller(&guard.0, &caller_for_purge, older_than_days)
347 }
348 })
349 .await;
350 match purge_result {
351 Ok(n) => Json(json!({
352 "purged": n,
353 (field_names::OWNER_SCOPE): if is_admin { "admin" } else { "caller" },
354 }))
355 .into_response(),
356 Err(e) => crate::handlers::errors::handler_error_500(&e),
357 }
358}
359
360pub async fn archive_stats(
361 State(app): State<AppState>,
362 headers: axum::http::HeaderMap,
363) -> impl IntoResponse {
364 // #943 SECURITY-medium (Track A QC sweep, 2026-05-20) — admin-
365 // only gate. Pre-fix any caller could enumerate archive table
366 // size + per-reason counts + per-namespace stats. Sibling of
367 // list_archive above.
368 if let Err(resp) = crate::handlers::admin_role::require_admin(&app, &headers, "archive_stats") {
369 return resp;
370 }
371 // v0.7.0 Wave-3 Continuation — postgres-backed daemons aggregate
372 // counts directly from the `archived_memories` table.
373 #[cfg(feature = "sal-postgres")]
374 if matches!(app.storage_backend, StorageBackend::Postgres) {
375 return match crate::store::postgres::archive_stats_via_store(&app.store).await {
376 Ok(v) => Json(v).into_response(),
377 Err(e) => store_err_to_response(e),
378 };
379 }
380
381 // PERF-1 (FX-3): wrap the rusqlite aggregate scan in `db_op`.
382 // archive_stats reads multiple GROUP BY queries off
383 // archived_memories and is admin-only — low rate but high tail
384 // latency on a saturated archive table.
385 let result = super::db_op(app.db.clone(), move |guard| db::archive_stats(&guard.0)).await;
386 match result {
387 Ok(archive_stats) => Json(archive_stats).into_response(),
388 Err(e) => crate::handlers::errors::handler_error_500(&e),
389 }
390}
391
392/// Request body for `POST /api/v1/archive` — S29 explicit archive.
393#[derive(Debug, Deserialize)]
394pub struct ArchiveByIdsBody {
395 pub ids: Vec<String>,
396 #[serde(default)]
397 pub reason: Option<String>,
398}
399
400/// POST /api/v1/archive — explicit archive of the given memory ids
401/// (S29). For each id:
402/// 1. Call `db::archive_memory` locally to soft-move the row.
403/// 2. If federation is configured, broadcast via
404/// `broadcast_archive_quorum` so peers land in the same terminal
405/// state (row out of `memories`, row into `archived_memories`).
406///
407/// On a quorum miss for ANY id, short-circuit with 503 via the shared
408/// `fanout_or_503`-style payload. This matches the posture of the
409/// delete + consolidate fanout endpoints.
410///
411/// Response body:
412/// ```json
413/// {"archived": [id1, id2], "missing": [id3], "count": 2}
414/// ```
415/// where `missing` enumerates ids that had no live row locally (common
416/// during retries). The response never includes content/metadata — use
417/// `GET /api/v1/archive` to list archive entries.
418#[allow(clippy::too_many_lines)]
419pub async fn archive_by_ids(
420 State(app): State<AppState>,
421 headers: HeaderMap,
422 Json(body): Json<ArchiveByIdsBody>,
423) -> impl IntoResponse {
424 // Bound the batch the same way bulk_create / sync_push do.
425 if body.ids.len() > MAX_BULK_SIZE {
426 return (
427 StatusCode::BAD_REQUEST,
428 Json(json!({"error": format!("archive limited to {} ids per request", MAX_BULK_SIZE)})),
429 )
430 .into_response();
431 }
432 // Validate all ids up-front so we never start mutating on a bad batch.
433 for id in &body.ids {
434 if let Err(e) = validate::validate_id(id) {
435 return (
436 StatusCode::BAD_REQUEST,
437 Json(json!({"error": format!("invalid id {id}: {e}")})),
438 )
439 .into_response();
440 }
441 }
442 let reason = body.reason.as_deref().unwrap_or("archive").to_string();
443
444 // #913 (security-medium / SOC2, 2026-05-19) — admin/destructive
445 // state-change audit. `archive_by_ids` performs a bulk soft-delete
446 // into the archived_memories table. Emit the forensic-chain entry
447 // BEFORE the storage writes so the audit trail captures the batch
448 // and the caller's identity regardless of partial-success behaviour.
449 let header_agent_id = headers
450 .get(crate::HEADER_AGENT_ID)
451 .and_then(|v| v.to_str().ok());
452 let caller = crate::identity::resolve_http_agent_id(None, header_agent_id)
453 .unwrap_or_else(|_| sentinels::ANONYMOUS_INVALID.to_string());
454 crate::governance::audit::record_decision(
455 &caller,
456 "allow",
457 "archive_by_ids",
458 "",
459 json!({
460 "id_count": body.ids.len(),
461 "reason": &reason,
462 }),
463 );
464 let mut archived: Vec<String> = Vec::new();
465 let mut missing: Vec<String> = Vec::new();
466
467 // v0.7.0 Wave-3 Continuation 3 (Phase 19) — postgres-backed daemons
468 // route through the SAL `archive_by_ids` trait method. The federation
469 // fanout stays sqlite-only; postgres operators relying on multi-node
470 // consistency should poll peers.
471 #[cfg(feature = "sal")]
472 if matches!(app.storage_backend, StorageBackend::Postgres) {
473 // QC P1 fix (2026-05-20): use the resolved `caller` (header)
474 // so the SAL #910 visibility filter applies — archive ops
475 // can only touch memories owned by the caller.
476 let ctx = crate::store::CallerContext::for_agent(caller.clone());
477 // Run per-id so we can split archived vs missing — the trait
478 // method bulk-archives but doesn't tell us which were missing,
479 // so we probe each via the count delta.
480 for id in &body.ids {
481 match app
482 .store
483 .archive_by_ids(&ctx, std::slice::from_ref(id), Some(&reason))
484 .await
485 {
486 Ok(1) => archived.push(id.clone()),
487 Ok(_) => missing.push(id.clone()),
488 Err(e) => return store_err_to_response(e),
489 }
490 }
491 // #950 SECURITY-medium (Track A QC sweep, 2026-05-20) — fire
492 // `memory_archive` per archived row on the postgres path. Each
493 // archive event is anchored on the (now-archived) memory id;
494 // for namespace anchoring we look the row up in archive via
495 // the SAL trait. If a follow-up `archive_get_by_id`-style read
496 // is unavailable on this trait surface, we fall back to an
497 // empty namespace + no owner (best-effort dispatch).
498 for id in &archived {
499 // The row no longer lives in `memories`; reading via
500 // app.store.get returns NotFound after the archive write.
501 // Fire the event with the caller as the agent_id and no
502 // namespace anchor — downstream subscribers match by
503 // event_type + memory_id, not namespace, for archive
504 // events.
505 super::dispatch_event_postgres(
506 &app,
507 crate::governance::OP_MEMORY_ARCHIVE,
508 id,
509 "",
510 Some(&caller),
511 None,
512 )
513 .await;
514 }
515 return (
516 StatusCode::OK,
517 Json(json!({
518 "archived": archived,
519 "missing": missing,
520 "count": archived.len(),
521 "reason": reason,
522 (field_names::STORAGE_BACKEND): "postgres",
523 })),
524 )
525 .into_response();
526 }
527
528 for id in &body.ids {
529 // Local archive. Hold the lock only across this one call per id so
530 // we can release it before a potentially slow network fanout.
531 //
532 // #940 (security-high, 2026-05-20) — caller-vs-row-owner gate.
533 // Pre-#940 the sqlite branch called the owner-blind
534 // `db::archive_memory(&lock.0, id, ...)` and any authenticated
535 // HTTP caller could bulk-archive any other owner's live rows
536 // (cross-tenant denial-of-service primitive). The postgres SAL
537 // branch above was already QC-P1-fixed (2026-05-20) to pass
538 // `CallerContext::for_agent(caller)`; this routes the sqlite
539 // branch through the caller-scoped variant for parity. A
540 // non-owner id surfaces in `missing` (same semantics as a row
541 // that wasn't live locally) so the surface cannot be used to
542 // probe other owners' live ids.
543 let moved = {
544 let lock = app.db.lock().await;
545 match db::archive_memory_for_caller(&lock.0, id, Some(&reason), &caller) {
546 Ok(v) => v,
547 Err(e) => {
548 tracing::error!("archive_by_ids: archive_memory({id}) failed: {e}");
549 return (
550 StatusCode::INTERNAL_SERVER_ERROR,
551 Json(json!({"error": crate::errors::msg::INTERNAL_SERVER_ERROR})),
552 )
553 .into_response();
554 }
555 }
556 };
557 if !moved {
558 // Row wasn't live locally — record as missing but keep going.
559 // Do NOT fan out (peers can't know to archive from a row they
560 // may have under a different state; the originator's local
561 // state is the trigger).
562 missing.push(id.clone());
563 continue;
564 }
565
566 // Fanout. Mirror the shape used by the other
567 // quorum-backed write endpoints (delete, consolidate) — on a
568 // miss, surface the `quorum_not_met` payload with 503 + Retry-After.
569 if let Some(fed) = app.federation.as_ref() {
570 match crate::federation::broadcast_archive_quorum(fed, id).await {
571 Ok(tracker) => {
572 if let Err(err) = crate::federation::finalise_quorum(&tracker) {
573 // #869 — typed 503 envelope via the shared helper.
574 let payload = crate::federation::QuorumNotMetPayload::from_err(&err);
575 return super::quorum_not_met_response(&payload);
576 }
577 }
578 Err(e) => {
579 // Local commit already landed — sync-daemon catches
580 // stragglers. Same posture as `fanout_or_503`.
581 tracing::warn!("archive fanout error (local committed): {e:?}");
582 }
583 }
584 }
585 archived.push(id.clone());
586 }
587
588 (
589 StatusCode::OK,
590 Json(json!({
591 "archived": archived,
592 "missing": missing,
593 "count": archived.len(),
594 "reason": reason,
595 })),
596 )
597 .into_response()
598}