subjects: 2nd scrum fix wave (token min, chain_tip, tampering, rebuild collision warn)

Second cross-lineage scrum on Steps 5+6 returned 13 distinct findings, 0 convergent.
Three BLOCK-class claims verified as false positives (cache IS written, per-subject
Mutex IS in place, spawn IS safe under writer's lock). Five real fixes shipped:

1. audit_endpoint: legal token min length 16->32 (HMAC-SHA256 best practice, kimi)
2. subject_audit: new chain_tip() returns last hash from full log; audit_endpoint
   now reports chain_root from full chain instead of windowed slice (opus)
3. registry: rebuild loader now warns on sanitize collision (symmetric with
   put_subject's collision guard - opus)
4. audit_endpoint: tampering detection - if manifest expects non-empty chain_root
   but log returns 0 rows, flag chain_verified=false with explicit message (opus)
5. execution_loop::audit_result_state: tightened heuristic - error/denied/not_found
   only classify when no rows/data/results sibling (opus INFO)

Tests: 17 catalogd subject + 6 gateway audit_result_state, all green.
New: audit_result_state_does_not_classify_error_when_data_sibling_present,
     audit_result_state_status_is_authoritative_even_with_data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 04:00:42 -05:00
co-authored by Claude Opus 4.7
parent 15cfd76c04
commit 2a4b316a15
5 changed files with 109 additions and 15 deletions
+30 -7
View File
@@ -69,9 +69,13 @@ impl AuditEndpointState {
legal_token_path.display()
);
None
} else if trimmed.len() < 16 {
} else if trimmed.len() < 32 {
// 2026-05-03 kimi scrum BLOCK at audit_endpoint.rs:59-60:
// 16-byte minimum was too permissive for a legal-tier
// secret. Aligned to the SubjectAuditWriter signing-key
// minimum (32 bytes — HMAC-SHA256 best practice).
tracing::warn!(
"audit endpoint: legal token at {} is {} chars (recommend ≥32) — endpoint will 503",
"audit endpoint: legal token at {} is {} chars (minimum 32) — endpoint will 503",
legal_token_path.display(), trimmed.len()
);
None
@@ -228,19 +232,38 @@ async fn audit_subject(
};
// 3. Verify chain end-to-end (full log, not just the windowed slice;
// chain integrity is global, not per-window).
// chain integrity is global, not per-window). chain_root MUST
// come from the actual log tip — taking it from the windowed
// `rows` would lie when `to` is set or the window catches no
// rows. (2026-05-03 opus scrum WARN audit_endpoint.rs:206.)
let (chain_verified, chain_rows_total, chain_root, chain_err) =
match state.writer.verify_chain(&candidate_id).await {
Ok(n) => {
// Find the latest row's hmac as chain_root for echo.
let root = rows.last()
.map(|r| r.row_hmac.clone())
let tip = state.writer.chain_tip(&candidate_id).await
.unwrap_or_else(|| "GENESIS".into());
(true, n, root, None)
(true, n, tip, None)
}
Err(e) => (false, 0, String::new(), Some(e)),
};
// 4. Tampering detection: if the manifest claims a non-trivial
// chain root but the log is empty (chain_rows_total == 0), the
// log was deleted between writes. Flag it. (2026-05-03 opus
// scrum WARN subject_audit.rs:347 + audit_endpoint coverage.)
let manifest_root = manifest.audit_log_chain_root.clone();
let log_was_tampered = chain_verified
&& chain_rows_total == 0
&& !manifest_root.is_empty()
&& manifest_root != "GENESIS";
let (chain_verified, chain_err) = if log_was_tampered {
(false, Some(format!(
"manifest expects chain root '{}' but the audit log has 0 rows; log was deleted or wiped",
manifest_root,
)))
} else {
(chain_verified, chain_err)
};
let datasets_referenced = manifest.datasets.iter()
.map(|d| DatasetRefEcho {
name: d.name.clone(),