subject manifests Steps 1-4 — fix scrum-flagged BLOCKs and WARNs
2026-05-03 cross-lineage scrum on the subjects_steps_1_to_4 wave
returned 14 distinct findings, 0 convergent. opus verdict was HOLD
with 3 BLOCKs around the audit-chain integrity. All real. Fixed:
──────────────────────────────────────────────────────────────────
BLOCK 1 — opus subject_audit.rs:172 + execution_loop.rs:391
Concurrency race: append_line is read-modify-write; the gateway
hook used tokio::spawn fan-out → two concurrent appends to the
same subject both read the same prev_hash, both compute their
HMAC from the same prev, second write silently overwrites first
→ row lost AND chain broken.
Fix:
- SubjectAuditWriter gains per-subject Mutex map. append() acquires
the subject's lock for the duration of the read-modify-write.
Different subjects still parallelize.
- Gateway hook switches from tokio::spawn to inline await. Per-row
cost is ~1ms (one object_store put); inline is correct AND cheap.
- New regression test: 50 concurrent appends to the same subject,
asserts all 50 land with intact chain.
BLOCK 2 — opus subject_audit.rs:108
Non-deterministic canonicalization: serde_json serializes struct
fields in declaration order. Schema evolution (adding/reordering
fields) silently changes the bytes verify_chain hashes → chain
breaks even when nothing was actually tampered with.
Fix:
- New canonical_json() free fn — recursive value rewrite to sort
object keys alphabetically (BTreeMap projection), arrays preserve
order, scalars pass through. Stable across struct evolution.
- Both append() and verify_chain() now compute HMAC over canonical
bytes, not declaration-order bytes.
- New regression tests: alphabetical-key + array-order-preserved.
WARN — opus execution_loop:401
Audit row's `result` was hardcoded to "success" for every Ok(result)
including payloads like {"error":"not found"}. Misleads compliance.
Fix:
- New audit_result_state() free fn that inspects the payload
top-level for error/denied/not_found/status signals (per spec
§3.2 enum). Defaults to "success" only when no error signal.
- 4 new tests covering each enum case + falsy-signals defense.
WARN — opus registry.rs:735
Storage-key collision: sanitize_view_name(id) is the disk key,
but the in-memory HashMap was keyed by raw candidate_id. Two
distinct ids that sanitize to the same key (e.g. "CAND/1" and
"CAND_1") would collide on disk while appearing distinct in
memory; second put silently overwrites first; rebuild loads only
one.
Fix:
- put_subject() / get_subject() / delete_subject() / rebuild()
all key the in-memory HashMap by sanitize_view_name(id), matching
the storage key shape.
- Collision guard: put_subject() refuses (with clear error) when
the sanitized key matches an EXISTING subject with a DIFFERENT
raw candidate_id.
- New regression test: put("CAND/1") then put("CAND_1") errors
+ first subject survives.
WARN — opus backfill_subjects.rs:189
trim_start_matches strips REPEATED prefixes; the spec wanted
one-shot semantics. Edge case unlikely in practice but real.
Fix:
- Switched to strip_prefix(&prefix).unwrap_or(&cid). One-shot.
INFO — opus subject_audit.rs:131
Per-byte format!("{:02x}", b) allocates each iteration. Hot path
on every append.
Fix:
- Replaced with const HEX lookup table + push() into preallocated
String. Same output bytes, no per-byte allocation.
──────────────────────────────────────────────────────────────────
Test summary post-fix:
catalogd subject_audit: 11/11 PASS (added 4 new — concurrency
race regression, parallel-different-subjects,
canonical-key sort, canonical-array order)
catalogd registry subject: 6/6 PASS (added 1 new — collision guard)
gateway execution_loop subject: 10/10 PASS (added 4 new —
audit_result_state enum coverage)
All 27 subject-related tests green. cargo build --release clean.
The convergent-zero scrum result was misleading on its face — opus
caught real BLOCKs that kimi/qwen missed. Per
feedback_cross_lineage_review.md: opus is the load-bearing reviewer;
single-opus BLOCKs warrant manual verification, which here confirmed
all three were correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -375,10 +375,20 @@ impl ExecutionLoop {
|
||||
if hits.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Fire one audit row per hit. Spawn so we don't add latency to
|
||||
// the tool path even if the writer is slow.
|
||||
// Determine result-state from the payload shape, NOT from the
|
||||
// transport status. A tool can return Ok(Value) where the body
|
||||
// itself is `{"error":"..."}` — hardcoding "success" here would
|
||||
// mislead compliance review (opus WARN at execution_loop:401).
|
||||
let result_state = audit_result_state(result);
|
||||
|
||||
// Sequential await — NOT tokio::spawn. The previous spawn-per-row
|
||||
// shape fanned out concurrent writes that raced inside the
|
||||
// per-subject append. The audit writer now serializes per-subject
|
||||
// internally, but spawning would still flood those locks under
|
||||
// burst load with no observable latency win. Inline await is
|
||||
// simpler AND correct. (2026-05-03 opus scrum BLOCKs on
|
||||
// execution_loop:391 + subject_audit:172.)
|
||||
for (cid, fields) in hits {
|
||||
let audit = audit.clone();
|
||||
let row = shared::types::SubjectAuditRow {
|
||||
schema: "subject_audit.v1".into(),
|
||||
ts: chrono::Utc::now(),
|
||||
@@ -390,15 +400,13 @@ impl ExecutionLoop {
|
||||
trace_id: String::new(), // TODO: thread X-Lakehouse-Trace-Id through
|
||||
},
|
||||
fields_accessed: fields,
|
||||
result: "success".into(),
|
||||
result: result_state.clone(),
|
||||
prev_chain_hash: String::new(),
|
||||
row_hmac: String::new(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = audit.append(row).await {
|
||||
tracing::warn!("subject audit write failed for {cid}: {e}");
|
||||
}
|
||||
});
|
||||
if let Err(e) = audit.append(row).await {
|
||||
tracing::warn!("subject audit write failed for {cid}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,6 +1053,53 @@ async fn append_outcomes_row_at(
|
||||
|
||||
/// PORT FROM orchestrator.ts:306-311. Cap `rows` at 20 entries and
|
||||
/// annotate the truncation so the executor sees it on the next turn
|
||||
/// Inspect a tool result JSON payload and return the appropriate
|
||||
/// audit `result` field per spec §3.2 enumeration:
|
||||
/// "success" | "denied" | "not_found" | "error".
|
||||
///
|
||||
/// Heuristic on the top-level object:
|
||||
/// - body has `"error"` key (truthy) → "error"
|
||||
/// - body has `"denied"` key (truthy) → "denied"
|
||||
/// - body has `"not_found"` key (truthy) → "not_found"
|
||||
/// - body has `"status":"denied"`/etc → that status
|
||||
/// - otherwise → "success"
|
||||
///
|
||||
/// Conservative — only flags explicit signals, defaults to success.
|
||||
/// Keeps audit log honest about ambiguous payloads (an error inside
|
||||
/// rows[*] doesn't downgrade the whole call to "error" — that's a
|
||||
/// row-level concern, not a result-state concern).
|
||||
pub(crate) fn audit_result_state(v: &serde_json::Value) -> String {
|
||||
if let Some(obj) = v.as_object() {
|
||||
if obj.get("error").map(is_truthy).unwrap_or(false) {
|
||||
return "error".into();
|
||||
}
|
||||
if obj.get("denied").map(is_truthy).unwrap_or(false) {
|
||||
return "denied".into();
|
||||
}
|
||||
if obj.get("not_found").map(is_truthy).unwrap_or(false) {
|
||||
return "not_found".into();
|
||||
}
|
||||
if let Some(s) = obj.get("status").and_then(|v| v.as_str()) {
|
||||
match s {
|
||||
"denied" | "not_found" | "error" => return s.into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"success".into()
|
||||
}
|
||||
|
||||
fn is_truthy(v: &serde_json::Value) -> bool {
|
||||
match v {
|
||||
serde_json::Value::Null => false,
|
||||
serde_json::Value::Bool(b) => *b,
|
||||
serde_json::Value::String(s) => !s.is_empty(),
|
||||
serde_json::Value::Array(a) => !a.is_empty(),
|
||||
serde_json::Value::Object(o) => !o.is_empty(),
|
||||
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a tool-result JSON value recursively. For each object that
|
||||
/// contains a candidate_id (string) or worker_id (int/string), append
|
||||
/// (canonical_candidate_id, fields_in_object) to `out`. Conventions
|
||||
@@ -2050,4 +2105,29 @@ mod tests {
|
||||
collect_subject_hits(&v, &mut hits);
|
||||
assert_eq!(hits.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_result_state_default_is_success() {
|
||||
let v = serde_json::json!({"rows": [{"candidate_id": "X"}]});
|
||||
assert_eq!(audit_result_state(&v), "success");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_result_state_detects_error_payload() {
|
||||
let v = serde_json::json!({"error": "not found"});
|
||||
assert_eq!(audit_result_state(&v), "error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_result_state_detects_status_field() {
|
||||
let v = serde_json::json!({"status": "denied", "rows": []});
|
||||
assert_eq!(audit_result_state(&v), "denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_result_state_ignores_falsy_signals() {
|
||||
// Empty/null/false should not trigger error state.
|
||||
let v = serde_json::json!({"error": null, "denied": false, "rows": [{"candidate_id":"X"}]});
|
||||
assert_eq!(audit_result_state(&v), "success");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user