// test_immutability_gate.el — verifies that PATCH bodies referencing // any of the immutable fields are rejected by body_contains_key + the // gate logic (the per-field check ladder in patch_internal_state_handler). // This mirrors the production check ladder in registry/handlers.el. fn body_contains_key(body: String, key: String) -> Bool { return str_contains(body, "\"" + key + "\":") } // patch_validate — returns "" if the body is acceptable for PATCH, // otherwise the field name that violates immutability. This is the same // ladder as patch_internal_state_handler in handlers.el. fn patch_validate(body: String) -> String { if body_contains_key(body, "pre_reasoning") { return "pre_reasoning" } if body_contains_key(body, "pre_logged_at") { return "pre_logged_at" } if body_contains_key(body, "cgi_id") { return "cgi_id" } if body_contains_key(body, "event_id") { return "event_id" } if body_contains_key(body, "trigger") { return "trigger" } if body_contains_key(body, "domain") { return "domain" } if body_contains_key(body, "logged_at") { return "logged_at" } if body_contains_key(body, "id") { return "id" } return "" } fn assert_eq_str(name: String, got: String, want: String) -> Bool { if str_eq(got, want) { println("PASS " + name) return true } println("FAIL " + name + " want=" + want + " got=" + got) return false } fn main() -> Void { // Allowed: only post-reasoning + gap + compression let ok1: String = "{\"post_reasoning\":\"x\",\"gap_summary\":\"y\"}" assert_eq_str("allow/post-only", patch_validate(ok1), "") let ok2: String = "{\"post_reasoning\":\"x\",\"gap_direction\":\"softened\",\"compression_ratio\":0.5,\"tags\":\"new\"}" assert_eq_str("allow/all-mutable", patch_validate(ok2), "") // Empty body is acceptable (no-op PATCH; idempotent). assert_eq_str("allow/empty", patch_validate("{}"), "") // Each immutable field must be rejected. assert_eq_str("reject/pre_reasoning", patch_validate("{\"pre_reasoning\":\"new\"}"), "pre_reasoning") assert_eq_str("reject/pre_logged_at", patch_validate("{\"pre_logged_at\":1234}"), "pre_logged_at") assert_eq_str("reject/cgi_id", patch_validate("{\"cgi_id\":\"other\"}"), "cgi_id") assert_eq_str("reject/event_id", patch_validate("{\"event_id\":\"other\"}"), "event_id") assert_eq_str("reject/trigger", patch_validate("{\"trigger\":\"new\"}"), "trigger") assert_eq_str("reject/domain", patch_validate("{\"domain\":\"other\"}"), "domain") assert_eq_str("reject/logged_at", patch_validate("{\"logged_at\":1234}"), "logged_at") assert_eq_str("reject/id", patch_validate("{\"id\":\"other\"}"), "id") // Mixed: rejection on first immutable hit; ladder order matters. assert_eq_str("reject/pre-takes-precedence", patch_validate("{\"post_reasoning\":\"ok\",\"pre_reasoning\":\"bad\"}"), "pre_reasoning") }