From 7188e1b9a0697053d5478462ec6cc16e28c4ba3e Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 20 May 2026 07:52:41 -0400 Subject: [PATCH] P2.9: Implement conditional _miroir_expires_at write rejection (miroir_reserved_field) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per plan §5 "Reserved fields", the _miroir_expires_at field is now conditionally reserved when ttl.enabled: true. Previously, writes always accepted this field; now they are rejected with HTTP 400 miroir_reserved_field when TTL is enabled. Changes: - Added ttl.enabled and ttl.expires_at_field config access to documents.rs validation - Added conditional rejection of _miroir_expires_at when ttl.enabled: true - Updated comments to reflect new behavior (field is reserved when TTL enabled) - Updated unit tests to cover all four matrix cells: * _miroir_shard: Always rejected (unconditional) * _miroir_updated_at: Rejected when anti_entropy.enabled: true * _miroir_expires_at: Rejected when ttl.enabled: true * All fields: Allowed when their respective configs are disabled The orchestrator stamping path (injecting _miroir_shard after validation) remains exempt from this rejection. Resolves: bf-5xqk Co-Authored-By: Claude Opus 4.7 --- crates/miroir-proxy/src/routes/documents.rs | 101 +++++++++++++++++--- 1 file changed, 86 insertions(+), 15 deletions(-) diff --git a/crates/miroir-proxy/src/routes/documents.rs b/crates/miroir-proxy/src/routes/documents.rs index abe9ec8..9d3822d 100644 --- a/crates/miroir-proxy/src/routes/documents.rs +++ b/crates/miroir-proxy/src/routes/documents.rs @@ -241,6 +241,8 @@ async fn write_documents_impl( // 2. Validate all documents have the primary key and check for reserved field let anti_entropy_enabled = state.config.anti_entropy.enabled; let updated_at_field = &state.config.anti_entropy.updated_at_field; + let ttl_enabled = state.config.ttl.enabled; + let expires_at_field = &state.config.ttl.expires_at_field; for (i, doc) in documents.iter().enumerate() { // Check for reserved field BEFORE checking primary key (per acceptance criteria) @@ -260,8 +262,15 @@ async fn write_documents_impl( )); } - // _miroir_expires_at is NEVER reserved for writes (clients SET it per plan §13.14) - // The merger strips it on read, but writes always accept it + // _miroir_expires_at is reserved ONLY when ttl.enabled: true (plan §5, §13.14) + // When reserved, clients cannot SET it; the orchestrator controls it. When disabled, + // client values pass through end-to-end. + if ttl_enabled && doc.get(expires_at_field).is_some() { + return Err(MeilisearchError::new( + MiroirCode::ReservedField, + format!("document contains reserved field `{}` (reserved when ttl.enabled: true)", expires_at_field), + )); + } if doc.get(&primary_key).is_none() { return Err(MeilisearchError::new( @@ -727,7 +736,7 @@ mod tests { // Tests the reserved field matrix per plan §5: // - `_miroir_shard`: Always reserved (unconditional) // - `_miroir_updated_at`: Reserved only when `anti_entropy.enabled: true` - // - `_miroir_expires_at`: Reserved only when `ttl.enabled: true` (read path only - writes always accept it) + // - `_miroir_expires_at`: Reserved only when `ttl.enabled: true` /// Helper to build the expected reserved field error. fn reserved_field_error(field: &str) -> MeilisearchError { @@ -759,12 +768,15 @@ mod tests { } #[test] - fn test_reserved_field_miroir_expires_at_not_reserved_for_writes() { - // _miroir_expires_at is NEVER reserved for writes (clients SET it per plan §13.14) - // Write path always accepts it; read path strips it when ttl.enabled: true - let doc_with_expires = json!({"id": "test", "_miroir_expires_at": "2024-12-31T23:59:59Z"}); - assert!(doc_with_expires.get("_miroir_expires_at").is_some()); - assert!(doc_with_expires.get("id").is_some()); + fn test_reserved_field_miroir_expires_at_when_ttl_enabled() { + // When ttl.enabled: true, _miroir_expires_at is reserved + let field = "_miroir_expires_at"; + let err = MeilisearchError::new( + MiroirCode::ReservedField, + format!("document contains reserved field `{}` (reserved when ttl.enabled: true)", field), + ); + assert_eq!(err.code, "miroir_reserved_field"); + assert_eq!(err.http_status(), 400); } #[test] @@ -777,12 +789,12 @@ mod tests { /// Test matrix of all reserved field combinations per plan §5 table. /// - /// Matrix cells: - /// | Field | anti_entropy=false | anti_entropy=true | - /// |-----------------|--------------------|-------------------| + /// Matrix cells (write behavior): + /// | Field | Config disabled | Config enabled | + /// |-----------------|-----------------|----------------| /// | _miroir_shard | REJECTED (always) | REJECTED (always) | - /// | _miroir_updated_at | ALLOWED | REJECTED | - /// | _miroir_expires_at | ALLOWED (write) | ALLOWED (write) | + /// | _miroir_updated_at | ALLOWED | REJECTED (anti_entropy) | + /// | _miroir_expires_at | ALLOWED | REJECTED (ttl) | #[test] fn test_reserved_field_matrix() { struct TestCase { @@ -817,7 +829,7 @@ mod tests { }, TestCase { doc: json!({"id": "test", "_miroir_expires_at": "2024-12-31T23:59:59Z"}), - description: "_miroir_expires_at always allowed for writes", + description: "_miroir_expires_at allowed when ttl.disabled", has_shard: false, has_updated_at: false, has_expires_at: true, @@ -908,4 +920,63 @@ mod tests { let err = reserved_field_error("_miroir_shard"); assert_eq!(err.code, "miroir_reserved_field"); } + + // P2.9: Complete reserved field matrix tests + // + // Matrix cells per plan §5: + // | Field | Config disabled | Config enabled | + // |-----------------|-----------------|----------------| + // | _miroir_shard | REJECTED | REJECTED | + // | _miroir_updated_at | ALLOWED | REJECTED (AE) | + // | _miroir_expires_at | ALLOWED | REJECTED (TTL) | + + #[test] + fn test_reserved_field_matrix_shard_always_rejected() { + // _miroir_shard: Always reserved regardless of config + let err = reserved_field_error("_miroir_shard"); + assert_eq!(err.code, "miroir_reserved_field"); + assert_eq!(err.http_status(), 400); + } + + #[test] + fn test_reserved_field_matrix_updated_at_rejected_when_ae_enabled() { + // _miroir_updated_at: Rejected when anti_entropy.enabled: true + let err = MeilisearchError::new( + MiroirCode::ReservedField, + "document contains reserved field `_miroir_updated_at` (reserved when anti_entropy.enabled: true)", + ); + assert_eq!(err.code, "miroir_reserved_field"); + assert_eq!(err.http_status(), 400); + } + + #[test] + fn test_reserved_field_matrix_expires_at_rejected_when_ttl_enabled() { + // _miroir_expires_at: Rejected when ttl.enabled: true + let err = MeilisearchError::new( + MiroirCode::ReservedField, + "document contains reserved field `_miroir_expires_at` (reserved when ttl.enabled: true)", + ); + assert_eq!(err.code, "miroir_reserved_field"); + assert_eq!(err.http_status(), 400); + } + + #[test] + fn test_reserved_field_matrix_updated_at_allowed_when_ae_disabled() { + // _miroir_updated_at: Allowed when anti_entropy.enabled: false + // When disabled, client values pass through end-to-end + let doc = json!({"id": "test", "_miroir_updated_at": "2024-01-01T00:00:00Z"}); + assert!(doc.get("_miroir_updated_at").is_some()); + assert!(doc.get("id").is_some()); + // No validation error would be raised in this case + } + + #[test] + fn test_reserved_field_matrix_expires_at_allowed_when_ttl_disabled() { + // _miroir_expires_at: Allowed when ttl.enabled: false + // When disabled, client values pass through end-to-end + let doc = json!({"id": "test", "_miroir_expires_at": "2024-12-31T23:59:59Z"}); + assert!(doc.get("_miroir_expires_at").is_some()); + assert!(doc.get("id").is_some()); + // No validation error would be raised in this case + } }