| 1 | //! Mechanical enforcement of repo-law protected invariants. |
| 2 | //! |
| 3 | //! `.codewhale/constitution.json` invariants were previously advisory prose |
| 4 | //! rendered into the prompt. Entries that carry `paths` globs now also |
| 5 | //! compile into write holds evaluated in the engine's tool gate — the law |
| 6 | //! becomes mechanism, with a receipt naming the invariant. |
| 7 | //! |
| 8 | //! The contract mirrors the project-overlay rule ("overrides may only |
| 9 | //! tighten"): |
| 10 | //! |
| 11 | //! - Law can only ADD holds. There is no allow/widen shape in the schema, so |
| 12 | //! a crafted constitution cannot grant authority. |
| 13 | //! - `ask` force-prompts in approval-gated postures. Full Access never opens |
| 14 | //! tool-approval prompts, so the same law fails closed there. `block` denies |
| 15 | //! outright in every posture. |
| 16 | //! - Any failure (missing file, parse error, bad glob) degrades to fewer or |
| 17 | //! zero rules — never a poisoned gate, never a hold on unprotected paths. |
| 18 | //! - Only the repo-local constitution participates. The user-global |
| 19 | //! constitution stays advisory prose and never reaches this module. |
| 20 | |
| 21 | use std::path::Path; |
| 22 | |
| 23 | use serde_json::Value; |
| 24 | |
| 25 | use crate::project_context::{RepoLawAction, RepoLawRule, load_repo_law_rules}; |
| 26 | use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input}; |
| 27 | |
| 28 | /// Semantic write actions whose inputs name filesystem targets we can hold. |
| 29 | /// Canonical action families are resolved to this policy vocabulary before the |
| 30 | /// check, so removing callable compatibility aliases cannot open a law bypass. |
| 31 | const WRITE_POLICY_ACTIONS: &[&str] = &["write_file", "edit_file", "apply_patch", "fim_edit"]; |
| 32 | |
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 34 | pub(crate) enum RepoLawPlanDecision { |
| 35 | /// Request a policy-forced approval naming the law. The engine converts |
| 36 | /// this to a hard block in non-interactive Full Access. |
| 37 | ForcePrompt(String), |
| 38 | /// Deny the call outright, naming the law. |
| 39 | Block(String), |
| 40 | } |
| 41 | |
| 42 | /// Evaluate the workspace's repo law against a proposed tool call. Returns |
| 43 | /// `None` for tools without write targets, workspaces without enforceable |
| 44 | /// law, and writes outside every protected glob. |
| 45 | pub(crate) fn repo_law_plan_decision( |
| 46 | workspace: &Path, |
| 47 | tool_name: &str, |
| 48 | tool_input: &Value, |
| 49 | ) -> Option<RepoLawPlanDecision> { |
| 50 | let policy_action = |
| 51 | crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); |
| 52 | if !WRITE_POLICY_ACTIONS.contains(&policy_action) { |
| 53 | return None; |
| 54 | } |
| 55 | let targets = write_target_paths(workspace, tool_input); |
| 56 | if targets.is_empty() { |
| 57 | return None; |
| 58 | } |
| 59 | let rules = load_repo_law_rules(workspace); |
| 60 | if rules.is_empty() { |
| 61 | return None; |
| 62 | } |
| 63 | |
| 64 | // Strongest action wins across all (rule, target) matches. |
| 65 | let mut hold: Option<(&RepoLawRule, &str)> = None; |
| 66 | for rule in &rules { |
| 67 | for target in &targets { |
| 68 | if rule.globs.is_match(target) { |
| 69 | let stronger = matches!(rule.action, RepoLawAction::Block) || hold.is_none(); |
| 70 | let already_blocking = hold |
| 71 | .as_ref() |
| 72 | .is_some_and(|(held, _)| matches!(held.action, RepoLawAction::Block)); |
| 73 | if stronger && !already_blocking { |
| 74 | hold = Some((rule, target.as_str())); |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | let (rule, target) = hold?; |
| 80 | let protects = rule.patterns.join(", "); |
| 81 | let reason = format!( |
| 82 | "Repo law holds this write: \"{}\" protects {protects} (matched {target}, .codewhale/constitution.json)", |
| 83 | rule.text |
| 84 | ); |
| 85 | Some(match rule.action { |
| 86 | RepoLawAction::Ask => RepoLawPlanDecision::ForcePrompt(reason), |
| 87 | RepoLawAction::Block => RepoLawPlanDecision::Block(reason), |
| 88 | }) |
| 89 | } |
| 90 | |
| 91 | /// Extract workspace-relative write targets from a tool input. Covers the |
| 92 | /// `path`/`target`/`destination`/`file_path` params, canonical |
| 93 | /// `replace[].path`, legacy `changes[].path`, and |
| 94 | /// every unified-diff / codex-envelope header shape the patch tools accept — |
| 95 | /// old (`--- `) and new (`+++ `) paths, with or without an `a/`/`b/` prefix, |
| 96 | /// tab-timestamp suffixes stripped, and `/dev/null` (deletion) falling back |
| 97 | /// to the counterpart path. Missing any shape the tool honors is a hold |
| 98 | /// bypass, so this deliberately over-collects candidate paths. |
| 99 | fn write_target_paths(workspace: &Path, input: &Value) -> Vec<String> { |
| 100 | let mut targets = Vec::new(); |
| 101 | for key in ["path", "target", "destination", "file_path"] { |
| 102 | if let Some(path) = input.get(key).and_then(Value::as_str) { |
| 103 | push_normalized(&mut targets, workspace, path); |
| 104 | } |
| 105 | } |
| 106 | match normalize_apply_patch_input(input) { |
| 107 | Ok(NormalizedApplyPatchInput::Replacement { entries, .. }) => { |
| 108 | for change in entries { |
| 109 | if let Some(path) = change.get("path").and_then(Value::as_str) { |
| 110 | push_normalized(&mut targets, workspace, path); |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | Ok(NormalizedApplyPatchInput::Patch(patch)) => { |
| 115 | let mut pending_old: Option<String> = None; |
| 116 | for line in patch.lines() { |
| 117 | if let Some(rest) = line.strip_prefix("*** Update File: ") { |
| 118 | push_normalized(&mut targets, workspace, rest.trim()); |
| 119 | } else if let Some(rest) = line.strip_prefix("*** Add File: ") { |
| 120 | push_normalized(&mut targets, workspace, rest.trim()); |
| 121 | } else if let Some(rest) = line.strip_prefix("*** Delete File: ") { |
| 122 | push_normalized(&mut targets, workspace, rest.trim()); |
| 123 | } else if let Some(rest) = line.strip_prefix("--- ") { |
| 124 | // Old path: remember it so a `+++ /dev/null` deletion still |
| 125 | // holds the file being removed. |
| 126 | pending_old = diff_header_path(rest); |
| 127 | if let Some(ref p) = pending_old { |
| 128 | push_normalized(&mut targets, workspace, p); |
| 129 | } |
| 130 | } else if let Some(rest) = line.strip_prefix("+++ ") { |
| 131 | match diff_header_path(rest) { |
| 132 | Some(new_path) => push_normalized(&mut targets, workspace, &new_path), |
| 133 | // `+++ /dev/null` → deletion; the target is the old path. |
| 134 | None => { |
| 135 | if let Some(old) = pending_old.take() { |
| 136 | push_normalized(&mut targets, workspace, &old); |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | Err(_) => {} |
| 144 | } |
| 145 | targets.sort(); |
| 146 | targets.dedup(); |
| 147 | targets |
| 148 | } |
| 149 | |
| 150 | /// Parse a unified-diff header path: strip an optional `a/`/`b/` prefix and a |
| 151 | /// tab-delimited timestamp suffix. Returns `None` for `/dev/null` (absence). |
| 152 | fn diff_header_path(rest: &str) -> Option<String> { |
| 153 | // Headers may carry a "\t<timestamp>" suffix; the path is the first field. |
| 154 | let path = rest.split('\t').next().unwrap_or(rest).trim(); |
| 155 | if path.is_empty() || path == "/dev/null" { |
| 156 | return None; |
| 157 | } |
| 158 | let stripped = path |
| 159 | .strip_prefix("a/") |
| 160 | .or_else(|| path.strip_prefix("b/")) |
| 161 | .unwrap_or(path); |
| 162 | Some(stripped.to_string()) |
| 163 | } |
| 164 | |
| 165 | /// Normalize to a forward-slash, workspace-relative string so globs written |
| 166 | /// as `crates/x/**` match regardless of how the tool spelled the path. Crucially |
| 167 | /// this collapses `.`/`..` path components the same way the write tools' |
| 168 | /// `resolve_path` does, so an interior `crates/./protocol/x` or |
| 169 | /// `x/../crates/protocol/x` cannot spell its way past a glob (a confirmed |
| 170 | /// bypass before this). |
| 171 | fn push_normalized(targets: &mut Vec<String>, workspace: &Path, raw: &str) { |
| 172 | let trimmed = raw.trim().replace('\\', "/"); |
| 173 | if trimmed.is_empty() { |
| 174 | return; |
| 175 | } |
| 176 | // Make workspace-relative when the tool gave an absolute path inside it. |
| 177 | let path = Path::new(&trimmed); |
| 178 | let relative = path.strip_prefix(workspace).unwrap_or(path); |
| 179 | |
| 180 | // Lexically collapse CurDir (`.`) and ParentDir (`..`) components, and |
| 181 | // drop any leading root/empty component. An absolute path outside the |
| 182 | // workspace keeps its tail (e.g. `/etc/passwd` -> `etc/passwd`) so a |
| 183 | // `**/passwd` glob still matches while a workspace-anchored glob does not. |
| 184 | let mut parts: Vec<String> = Vec::new(); |
| 185 | for component in relative.to_string_lossy().split('/') { |
| 186 | match component { |
| 187 | "" | "." => {} |
| 188 | ".." => { |
| 189 | // A `..` that pops above the root escapes the workspace; keep |
| 190 | // an explicit marker so it can never match a workspace-relative |
| 191 | // glob, and the ordinary approval/sandbox gates still govern it. |
| 192 | if parts.pop().is_none() { |
| 193 | parts.push("..".to_string()); |
| 194 | } |
| 195 | } |
| 196 | other => parts.push(other.to_string()), |
| 197 | } |
| 198 | } |
| 199 | let normalized = parts.join("/"); |
| 200 | if !normalized.is_empty() { |
| 201 | targets.push(normalized); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | #[cfg(test)] |
| 206 | mod tests { |
| 207 | use super::*; |
| 208 | use serde_json::json; |
| 209 | use tempfile::TempDir; |
| 210 | |
| 211 | fn write_law(workspace: &Path, body: &str) { |
| 212 | let dir = workspace.join(".codewhale"); |
| 213 | std::fs::create_dir_all(&dir).unwrap(); |
| 214 | std::fs::write(dir.join("constitution.json"), body).unwrap(); |
| 215 | } |
| 216 | |
| 217 | const LAW: &str = r#"{ |
| 218 | "authority": ["AGENTS.md"], |
| 219 | "protected_invariants": [ |
| 220 | "Keep DeepSeek support first-class.", |
| 221 | { "text": "The wire format is frozen", "paths": ["crates/protocol/**"], "action": "block" }, |
| 222 | { "text": "Release notes need human review", "paths": ["CHANGELOG.md"] } |
| 223 | ] |
| 224 | }"#; |
| 225 | |
| 226 | #[test] |
| 227 | fn advisory_only_law_never_holds() { |
| 228 | let tmp = TempDir::new().unwrap(); |
| 229 | write_law( |
| 230 | tmp.path(), |
| 231 | r#"{"protected_invariants": ["Prose only, no paths."]}"#, |
| 232 | ); |
| 233 | assert_eq!( |
| 234 | repo_law_plan_decision( |
| 235 | tmp.path(), |
| 236 | "write_file", |
| 237 | &json!({"path": "src/main.rs", "content": "x"}), |
| 238 | ), |
| 239 | None |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn block_action_denies_protected_write() { |
| 245 | let tmp = TempDir::new().unwrap(); |
| 246 | write_law(tmp.path(), LAW); |
| 247 | let decision = repo_law_plan_decision( |
| 248 | tmp.path(), |
| 249 | "write_file", |
| 250 | &json!({"path": "crates/protocol/wire.rs", "content": "x"}), |
| 251 | ); |
| 252 | let Some(RepoLawPlanDecision::Block(reason)) = decision else { |
| 253 | panic!("expected block, got {decision:?}"); |
| 254 | }; |
| 255 | assert!(reason.contains("The wire format is frozen"), "{reason}"); |
| 256 | assert!(reason.contains("crates/protocol/wire.rs"), "{reason}"); |
| 257 | assert!(reason.contains(".codewhale/constitution.json"), "{reason}"); |
| 258 | } |
| 259 | |
| 260 | #[test] |
| 261 | fn ask_action_force_prompts_and_names_the_law() { |
| 262 | let tmp = TempDir::new().unwrap(); |
| 263 | write_law(tmp.path(), LAW); |
| 264 | let decision = repo_law_plan_decision( |
| 265 | tmp.path(), |
| 266 | "edit_file", |
| 267 | &json!({"path": "CHANGELOG.md", "old": "a", "new": "b"}), |
| 268 | ); |
| 269 | let Some(RepoLawPlanDecision::ForcePrompt(reason)) = decision else { |
| 270 | panic!("expected force prompt, got {decision:?}"); |
| 271 | }; |
| 272 | assert!( |
| 273 | reason.contains("Release notes need human review"), |
| 274 | "{reason}" |
| 275 | ); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn canonical_file_write_and_edit_actions_receive_the_same_holds() { |
| 280 | let tmp = TempDir::new().unwrap(); |
| 281 | write_law(tmp.path(), LAW); |
| 282 | |
| 283 | let blocked = repo_law_plan_decision( |
| 284 | tmp.path(), |
| 285 | "File", |
| 286 | &json!({ |
| 287 | "action": "write", |
| 288 | "path": "crates/protocol/wire.rs", |
| 289 | "content": "x" |
| 290 | }), |
| 291 | ); |
| 292 | assert!(matches!(blocked, Some(RepoLawPlanDecision::Block(_)))); |
| 293 | |
| 294 | let held = repo_law_plan_decision( |
| 295 | tmp.path(), |
| 296 | "File", |
| 297 | &json!({ |
| 298 | "action": "edit", |
| 299 | "path": "CHANGELOG.md", |
| 300 | "search": "before", |
| 301 | "replace": "after" |
| 302 | }), |
| 303 | ); |
| 304 | assert!(matches!(held, Some(RepoLawPlanDecision::ForcePrompt(_)))); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn unprotected_writes_and_non_write_tools_pass() { |
| 309 | let tmp = TempDir::new().unwrap(); |
| 310 | write_law(tmp.path(), LAW); |
| 311 | assert_eq!( |
| 312 | repo_law_plan_decision( |
| 313 | tmp.path(), |
| 314 | "write_file", |
| 315 | &json!({"path": "src/main.rs", "content": "x"}), |
| 316 | ), |
| 317 | None |
| 318 | ); |
| 319 | assert_eq!( |
| 320 | repo_law_plan_decision( |
| 321 | tmp.path(), |
| 322 | "read_file", |
| 323 | &json!({"path": "crates/protocol/wire.rs"}), |
| 324 | ), |
| 325 | None |
| 326 | ); |
| 327 | } |
| 328 | |
| 329 | #[test] |
| 330 | fn apply_patch_targets_are_extracted_from_all_shapes() { |
| 331 | let tmp = TempDir::new().unwrap(); |
| 332 | write_law(tmp.path(), LAW); |
| 333 | // Canonical replace[].path shape. |
| 334 | let decision = repo_law_plan_decision( |
| 335 | tmp.path(), |
| 336 | "apply_patch", |
| 337 | &json!({"replace": [{"path": "crates/protocol/msg.rs"}]}), |
| 338 | ); |
| 339 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 340 | // Legacy changes[].path shape must receive the same hold. |
| 341 | let decision = repo_law_plan_decision( |
| 342 | tmp.path(), |
| 343 | "apply_patch", |
| 344 | &json!({"changes": [{"path": "crates/protocol/msg.rs"}]}), |
| 345 | ); |
| 346 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 347 | // unified diff shape |
| 348 | let decision = repo_law_plan_decision( |
| 349 | tmp.path(), |
| 350 | "apply_patch", |
| 351 | &json!({"patch": "--- a/crates/protocol/msg.rs\n+++ b/crates/protocol/msg.rs\n@@\n"}), |
| 352 | ); |
| 353 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 354 | // codex envelope shape |
| 355 | let decision = repo_law_plan_decision( |
| 356 | tmp.path(), |
| 357 | "apply_patch", |
| 358 | &json!({"patch": "*** Begin Patch\n*** Update File: crates/protocol/msg.rs\n*** End Patch\n"}), |
| 359 | ); |
| 360 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 361 | } |
| 362 | |
| 363 | #[test] |
| 364 | fn block_outranks_ask_when_both_match() { |
| 365 | let tmp = TempDir::new().unwrap(); |
| 366 | write_law( |
| 367 | tmp.path(), |
| 368 | r#"{"protected_invariants": [ |
| 369 | { "text": "ask first", "paths": ["docs/**"] }, |
| 370 | { "text": "never", "paths": ["docs/frozen/**"], "action": "block" } |
| 371 | ]}"#, |
| 372 | ); |
| 373 | let decision = repo_law_plan_decision( |
| 374 | tmp.path(), |
| 375 | "write_file", |
| 376 | &json!({"path": "docs/frozen/spec.md", "content": "x"}), |
| 377 | ); |
| 378 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 379 | } |
| 380 | |
| 381 | #[test] |
| 382 | fn absolute_and_dot_prefixed_paths_normalize_to_workspace_relative() { |
| 383 | let tmp = TempDir::new().unwrap(); |
| 384 | write_law(tmp.path(), LAW); |
| 385 | let absolute = tmp.path().join("crates/protocol/wire.rs"); |
| 386 | let decision = repo_law_plan_decision( |
| 387 | tmp.path(), |
| 388 | "write_file", |
| 389 | &json!({"path": absolute.to_string_lossy(), "content": "x"}), |
| 390 | ); |
| 391 | assert!(matches!(decision, Some(RepoLawPlanDecision::Block(_)))); |
| 392 | let decision = repo_law_plan_decision( |
| 393 | tmp.path(), |
| 394 | "write_file", |
| 395 | &json!({"path": "./CHANGELOG.md", "content": "x"}), |
| 396 | ); |
| 397 | assert!(matches!( |
| 398 | decision, |
| 399 | Some(RepoLawPlanDecision::ForcePrompt(_)) |
| 400 | )); |
| 401 | } |
| 402 | |
| 403 | #[test] |
| 404 | fn malformed_law_and_bad_globs_degrade_to_no_holds() { |
| 405 | let tmp = TempDir::new().unwrap(); |
| 406 | write_law(tmp.path(), "{ not json"); |
| 407 | assert_eq!( |
| 408 | repo_law_plan_decision( |
| 409 | tmp.path(), |
| 410 | "write_file", |
| 411 | &json!({"path": "crates/protocol/wire.rs", "content": "x"}), |
| 412 | ), |
| 413 | None |
| 414 | ); |
| 415 | write_law( |
| 416 | tmp.path(), |
| 417 | r#"{"protected_invariants": [ |
| 418 | { "text": "broken glob", "paths": ["crates/[invalid"] } |
| 419 | ]}"#, |
| 420 | ); |
| 421 | assert_eq!( |
| 422 | repo_law_plan_decision( |
| 423 | tmp.path(), |
| 424 | "write_file", |
| 425 | &json!({"path": "crates/protocol/wire.rs", "content": "x"}), |
| 426 | ), |
| 427 | None |
| 428 | ); |
| 429 | } |
| 430 | |
| 431 | #[test] |
| 432 | fn interior_dot_and_parent_segments_cannot_evade_a_block() { |
| 433 | let tmp = TempDir::new().unwrap(); |
| 434 | write_law(tmp.path(), LAW); |
| 435 | for path in [ |
| 436 | "crates/./protocol/wire.rs", |
| 437 | "crates/../crates/protocol/wire.rs", |
| 438 | "x/../crates/protocol/wire.rs", |
| 439 | "./crates/protocol/wire.rs", |
| 440 | ] { |
| 441 | let decision = repo_law_plan_decision( |
| 442 | tmp.path(), |
| 443 | "write_file", |
| 444 | &json!({ "path": path, "content": "x" }), |
| 445 | ); |
| 446 | assert!( |
| 447 | matches!(decision, Some(RepoLawPlanDecision::Block(_))), |
| 448 | "{path} must be held, got {decision:?}" |
| 449 | ); |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn fim_edit_is_gated_like_other_write_tools() { |
| 455 | let tmp = TempDir::new().unwrap(); |
| 456 | write_law(tmp.path(), LAW); |
| 457 | let decision = repo_law_plan_decision( |
| 458 | tmp.path(), |
| 459 | "fim_edit", |
| 460 | &json!({ "path": "crates/protocol/wire.rs", "prefix": "a", "suffix": "b" }), |
| 461 | ); |
| 462 | assert!( |
| 463 | matches!(decision, Some(RepoLawPlanDecision::Block(_))), |
| 464 | "{decision:?}" |
| 465 | ); |
| 466 | } |
| 467 | |
| 468 | #[test] |
| 469 | fn apply_patch_header_variants_are_all_extracted() { |
| 470 | let tmp = TempDir::new().unwrap(); |
| 471 | write_law(tmp.path(), LAW); |
| 472 | // no a/ or b/ prefix |
| 473 | let d = repo_law_plan_decision( |
| 474 | tmp.path(), |
| 475 | "apply_patch", |
| 476 | &json!({ "patch": "--- crates/protocol/wire.rs\n+++ crates/protocol/wire.rs\n@@\n" }), |
| 477 | ); |
| 478 | assert!( |
| 479 | matches!(d, Some(RepoLawPlanDecision::Block(_))), |
| 480 | "no-prefix: {d:?}" |
| 481 | ); |
| 482 | // deletion: +++ /dev/null, target is the old path |
| 483 | let d = repo_law_plan_decision( |
| 484 | tmp.path(), |
| 485 | "apply_patch", |
| 486 | &json!({ "patch": "--- a/crates/protocol/wire.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n" }), |
| 487 | ); |
| 488 | assert!( |
| 489 | matches!(d, Some(RepoLawPlanDecision::Block(_))), |
| 490 | "deletion: {d:?}" |
| 491 | ); |
| 492 | // tab-timestamp suffix on the header |
| 493 | let d = repo_law_plan_decision( |
| 494 | tmp.path(), |
| 495 | "apply_patch", |
| 496 | &json!({ "patch": "--- a/x\t2026-01-01\n+++ b/crates/protocol/wire.rs\t2026-01-01 10:00:00\n@@\n" }), |
| 497 | ); |
| 498 | assert!( |
| 499 | matches!(d, Some(RepoLawPlanDecision::Block(_))), |
| 500 | "tab-timestamp: {d:?}" |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | #[test] |
| 505 | fn no_law_file_means_no_holds() { |
| 506 | let tmp = TempDir::new().unwrap(); |
| 507 | assert_eq!( |
| 508 | repo_law_plan_decision( |
| 509 | tmp.path(), |
| 510 | "write_file", |
| 511 | &json!({"path": "anything.rs", "content": "x"}), |
| 512 | ), |
| 513 | None |
| 514 | ); |
| 515 | } |
| 516 | } |
| 517 |