From ed8cf49540fd8bab6f22e5a95f6071f3d6c850e4 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Sat, 19 Sep 2026 12:07:59 -0700 Subject: [PATCH] M3a task 01: copy a strict.rs of its own The strict.rs in files/ is the merged one and walks task 02's new wire fixtures, so task 01's gate could not pass; the first run stopped on it. Task 01 now copies area A's version, checked to give 3 and 5 passed at task 01's end state. Co-Authored-By: Claude Opus 5 (1M context) --- docs/plans/M3a/01-proto-audit-types.md | 5 +- docs/plans/M3a/README.md | 8 +- .../files/crates/proto/tests/strict.rs-task01 | 128 ++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 docs/plans/M3a/files/crates/proto/tests/strict.rs-task01 diff --git a/docs/plans/M3a/01-proto-audit-types.md b/docs/plans/M3a/01-proto-audit-types.md index 80f542b..cedc5ce 100644 --- a/docs/plans/M3a/01-proto-audit-types.md +++ b/docs/plans/M3a/01-proto-audit-types.md @@ -12,7 +12,8 @@ nothing else in the workspace changes. ## Files -- Copy: `crates/proto/tests/records.rs`, `crates/proto/tests/strict.rs`, +- Copy: `crates/proto/tests/records.rs`, `crates/proto/tests/strict.rs` (from + `files/crates/proto/tests/strict.rs-task01`; `files/.../strict.rs` is task 02's), `crates/proto/tests/fixtures/records/audit.jsonl` - Modify: `crates/proto/src/audit.rs`, `crates/proto/src/lib.rs`, `docs/implementer-log.md` @@ -92,7 +93,7 @@ In `lib.rs` the audit export becomes ## Steps - [ ] **1. Copy.** `git switch -c m3a`, then - `cp docs/plans/M3a/files/crates/proto/tests/records.rs docs/plans/M3a/files/crates/proto/tests/strict.rs crates/proto/tests/` + `cp docs/plans/M3a/files/crates/proto/tests/records.rs crates/proto/tests/ && cp docs/plans/M3a/files/crates/proto/tests/strict.rs-task01 crates/proto/tests/strict.rs` and `cp docs/plans/M3a/files/crates/proto/tests/fixtures/records/audit.jsonl crates/proto/tests/fixtures/records/` - [ ] **2. See the tests fail.** `cargo test -p proto --test records`. Expected: it does not compile (`AuditEvent` is not found). diff --git a/docs/plans/M3a/README.md b/docs/plans/M3a/README.md index 709b2dd..877e219 100644 --- a/docs/plans/M3a/README.md +++ b/docs/plans/M3a/README.md @@ -39,7 +39,7 @@ tip T17) and what that check exposed. The detail is in `checks-a.md` to `checks- | # | File | Delivers | Tests that define it | Check | What the check exposed | |---|---|---|---|---|---| -| 01 | `01-proto-audit-types.md` | `AuditRecord`, `AuditEvent`, `DecisionRecord` and friends | `proto/tests/records.rs`, `strict.rs`, `fixtures/records/audit.jsonl` | reference (the types) | `DecisionRecord`'s unit variants accepted unknown fields (serde ignores `deny_unknown_fields` there); found by `strict.rs` | +| 01 | `01-proto-audit-types.md` | `AuditRecord`, `AuditEvent`, `DecisionRecord` and friends | `proto/tests/records.rs`, `strict.rs`, `fixtures/records/audit.jsonl` | reference (the types) | `DecisionRecord`'s unit variants accepted unknown fields (serde ignores `deny_unknown_fields` there); found by `strict.rs`. After hand-over: the merged `strict.rs` named task 02's fixtures, so task 01's gate could not pass (fixed with `strict.rs-task01`) | | 02 | `02-proto-admin-wire.md` | the admin messages, `u64` approval ids, `approval_pending`, `tool_denied` | `wire.rs`, `turn_wire.rs`, `admin_wire.rs`, `strict.rs`, 17 wire fixtures | reference (the types) | the same serde hole, found independently; serde's array form for structs (left open in `decisions.md`) | | 03 | `03-proto-chain-verifier.md` | `ChainVerifier`, `ChainReport` | `proto/tests/chain.rs`, 30 fixture logs with real hashes | reference, generated fixtures, 8 mutants | four spec gaps: which `seq` the lists report, the resumed verifier inside a failed region, a `Recovery` describing nothing, the report fields a writer needs | | 04 | `04-brokerd-config.md` | `brokerd.toml` into `Config` | `brokerd/tests/config.rs`, 6 fixtures | minimal reference | socket paths when only `home` is set; stale "Used by" column in `dependencies.md` | @@ -64,7 +64,9 @@ tip T17) and what that check exposed. The detail is in `checks-a.md` to `checks- `files/` holds everything the tasks copy in, byte-identical to the reference tree (`~/src/boxmaker-ref`, branch `m3a-ref`, unpushed). `files/Makefile-task21` is task 21's -`Makefile`; `files/Makefile` is task 22's, with the end-to-end line. +`Makefile`; `files/Makefile` is task 22's, with the end-to-end line. Likewise +`files/crates/proto/tests/strict.rs-task01` is task 01's `strict.rs`; `files/.../strict.rs` is task +02's, which also walks task 02's new wire fixtures. **State of the checks at hand-over.** In the reference tree `make gate` passes in full, the end-to-end line included, so every given test has passed against some implementation. Area E's @@ -96,7 +98,7 @@ enough. Most spec defects were found by writing test tables and exit lists, not 1. `git log --oneline master..m3a`: twenty-two commits with the trailer. 2. Copied files unchanged: - `for f in $(cd docs/plans/M3a/files && find . -type f ! -name Makefile-task21); do cmp "docs/plans/M3a/files/$f" "$f"; done` + `for f in $(cd docs/plans/M3a/files && find . -type f ! -name Makefile-task21 ! -name strict.rs-task01); do cmp "docs/plans/M3a/files/$f" "$f"; done` 3. `git diff master..m3a --stat -- docs/design.md docs/specs docs/plans docs/runbook.md AGENTS.md CLAUDE.md deny.toml` is empty. 4. `make gate`, `make audit`, `make verify-device`, `tools/check-m3a-device.sh`. diff --git a/docs/plans/M3a/files/crates/proto/tests/strict.rs-task01 b/docs/plans/M3a/files/crates/proto/tests/strict.rs-task01 new file mode 100644 index 0000000..6509ea2 --- /dev/null +++ b/docs/plans/M3a/files/crates/proto/tests/strict.rs-task01 @@ -0,0 +1,128 @@ +//! Every JSON object in every fixture must reject an unknown key. Do not edit. +//! +//! The other test files check unknown fields in a few hand-picked places. This one checks all of +//! them: it walks each fixture, adds one unknown key to one object at a time, at every depth, and +//! requires that the result no longer decodes. + +use proto::{AuditRecord, Envelope, Grant, LogRecord}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +/// Every copy of `value` that has exactly one extra key in exactly one object. +fn with_one_unknown_key(value: &Value) -> Vec { + let mut out = Vec::new(); + match value { + Value::Object(map) => { + let mut extended = map.clone(); + extended.insert("zz_unknown".to_string(), Value::Bool(true)); + out.push(Value::Object(extended)); + for (key, child) in map { + for changed in with_one_unknown_key(child) { + let mut copy = map.clone(); + copy.insert(key.clone(), changed); + out.push(Value::Object(copy)); + } + } + } + Value::Array(items) => { + for (i, child) in items.iter().enumerate() { + for changed in with_one_unknown_key(child) { + let mut copy = items.clone(); + copy[i] = changed; + out.push(Value::Array(copy)); + } + } + } + _ => {} + } + out +} + +/// Returns how many variations were tried, so callers can check the walk reached nested objects. +fn check(what: &str, text: &str) -> usize { + let value: Value = serde_json::from_str(text).unwrap_or_else(|e| panic!("{what}: {e}")); + assert!( + serde_json::from_value::(value.clone()).is_ok(), + "{what}: fixture must decode" + ); + let variations = with_one_unknown_key(&value); + for changed in &variations { + assert!( + serde_json::from_value::(changed.clone()).is_err(), + "{what}: accepted an unknown key: {changed}" + ); + } + variations.len() +} + +fn fixture(path: &str) -> String { + let full = format!("{}/tests/fixtures/{path}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("{full}: {e}")) +} + +#[test] +fn envelopes_reject_unknown_keys_at_every_depth() { + for name in [ + "tool_request.json", + "tool_response_pending.json", + "tool_response_result.json", + "tool_response_failed.json", + "tool_response_denied.json", + "error.json", + "turn.json", + "turn_event_tool_result.json", + "turn_event_content.json", + "turn_event_retrying.json", + "turn_done.json", + "error_session_full.json", + ] { + // Envelope, msg and body: three objects; turn_done also has a usage object. + let want = if name == "turn_done.json" { 4 } else { 3 }; + assert_eq!( + check::(name, &fixture(&format!("wire/{name}"))), + want, + "{name}" + ); + } +} + +#[test] +fn audit_records_reject_unknown_keys_at_every_depth() { + for (i, line) in fixture("records/audit.jsonl").lines().enumerate() { + // The record and its event: two objects. Decision and approval events also hold an + // outcome object. + let want = if line.contains("\"outcome\"") { 3 } else { 2 }; + assert_eq!( + check::(&format!("audit.jsonl:{}", i + 1), line), + want + ); + } +} + +#[test] +fn log_records_reject_unknown_keys_at_every_depth() { + let mut tried = 0; + for (i, line) in fixture("records/session.jsonl").lines().enumerate() { + tried += check::(&format!("session.jsonl:{}", i + 1), line); + } + // Seven records, plus the one tool call inside the first assistant record. + assert_eq!(tried, 8); +} + +#[test] +fn usage_log_records_reject_unknown_keys_at_every_depth() { + let mut tried = 0; + for (i, line) in fixture("records/session_usage.jsonl").lines().enumerate() { + tried += check::(&format!("session_usage.jsonl:{}", i + 1), line); + } + // Seven records, plus the one tool call inside the first assistant record. + assert_eq!(tried, 8); +} + +#[test] +fn grants_reject_unknown_keys_at_every_depth() { + let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap(); + let text = serde_json::to_string(&grant).unwrap(); + // The grant and its constraints: two objects. + assert_eq!(check::("full.toml as JSON", &text), 2); +}