Skip to main content

ainl_improvement_proposals/
lib.rs

1//! Improvement proposal **ledger** (Phase 4 — `SELF_LEARNING_INTEGRATION_MAP.md` §8).
2//!
3//! Persists [`ProposalEnvelope`] + proposed AINL text, verifies `proposed_hash` against
4//! `sha256(proposed_ainl_text)`, and runs a caller-supplied strict validator before marking a row
5//! **accepted**. Hosts wire `validator` to `mcp_ainl_ainl_validate` / compile / or `ainl-runtime`
6//! as appropriate; this crate stays free of `openfang_*` and `ainl-runtime` (no dependency cycles).
7
8mod ledger;
9
10pub use ledger::{
11    AdoptGraphPayload, AdoptResult, ImprovementProposalId, ImprovementProposalListItem,
12    ImprovementProposalRow, ProposalLedger, ProposalLedgerError,
13};
14
15use ainl_contracts::ProposalEnvelope;
16use sha2::{Digest, Sha256};
17
18pub mod proposal_kind {
19    pub const PATTERN_PROMOTE: &str = "pattern_promote";
20    pub const PROCEDURE_MINT: &str = "procedure_mint";
21    pub const PROCEDURE_PATCH: &str = "procedure_patch";
22    pub const PROCEDURE_PROMOTE: &str = "procedure_promote";
23    pub const PROCEDURE_DEPRECATE: &str = "procedure_deprecate";
24    pub const GRAPH_PATCH_FROM_PROCEDURE: &str = "graph_patch_from_procedure";
25}
26
27#[must_use]
28pub fn is_known_proposal_kind(kind: &str) -> bool {
29    matches!(
30        kind,
31        proposal_kind::PATTERN_PROMOTE
32            | proposal_kind::PROCEDURE_MINT
33            | proposal_kind::PROCEDURE_PATCH
34            | proposal_kind::PROCEDURE_PROMOTE
35            | proposal_kind::PROCEDURE_DEPRECATE
36            | proposal_kind::GRAPH_PATCH_FROM_PROCEDURE
37    )
38}
39
40/// Lowercase hex SHA-256 of UTF-8 bytes (for comparing to `ProposalEnvelope::proposed_hash`).
41#[must_use]
42pub fn sha256_hex_lower(s: &str) -> String {
43    let d = Sha256::digest(s.as_bytes());
44    hex::encode(d)
45}
46
47/// `true` when the proposal text matches the envelope’s `proposed_hash` (both compared lowercase).
48#[must_use]
49pub fn proposed_hash_matches(envelope: &ProposalEnvelope, proposed_ainl_text: &str) -> bool {
50    let h = sha256_hex_lower(proposed_ainl_text);
51    h == envelope.proposed_hash.trim().to_ascii_lowercase()
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::{ProposalLedger, ProposalLedgerError};
58    use ainl_contracts::{
59        ContextFreshness, ImpactDecision, ProposalEnvelope, LEARNER_SCHEMA_VERSION,
60    };
61    use uuid::Uuid;
62
63    fn sample_envelope(ainl: &str) -> ProposalEnvelope {
64        let ph = sha256_hex_lower(ainl);
65        ProposalEnvelope {
66            schema_version: LEARNER_SCHEMA_VERSION,
67            original_hash: "a".repeat(64),
68            proposed_hash: ph,
69            kind: "pattern_promote".into(),
70            rationale: "test".into(),
71            freshness_at_proposal: ContextFreshness::Fresh,
72            impact_decision: ImpactDecision::AllowExecute,
73        }
74    }
75
76    #[test]
77    fn procedure_proposal_kinds_are_known() {
78        assert!(is_known_proposal_kind(proposal_kind::PROCEDURE_MINT));
79        assert!(is_known_proposal_kind(proposal_kind::PROCEDURE_PATCH));
80        assert!(!is_known_proposal_kind("unknown"));
81    }
82
83    #[test]
84    fn submit_rejects_hash_mismatch() {
85        let tmp = tempfile::tempdir().unwrap();
86        let db = tmp.path().join("prop.db");
87        let p = ProposalLedger::open(&db).unwrap();
88        let env = sample_envelope("graph\nt1\n");
89        let err = p
90            .submit("a1", &env, "wrong\n")
91            .expect_err("expected mismatch");
92        assert!(matches!(err, ProposalLedgerError::HashMismatch));
93    }
94
95    #[test]
96    fn accept_and_reject_paths() {
97        let tmp = tempfile::tempdir().unwrap();
98        let db = tmp.path().join("prop2.db");
99        let p = ProposalLedger::open(&db).unwrap();
100        let text = "graph\nt1\n";
101        let env = sample_envelope(text);
102        let id = p.submit("a1", &env, text).expect("insert");
103
104        let r_ok = p
105            .validate_and_record(id, |s: &str| if s == text { Ok(()) } else { Err("bad") })
106            .expect("validate");
107        assert!(r_ok.accepted, "{r_ok:?}");
108        let row = p.get(id).unwrap().expect("row");
109        assert!(row.accepted);
110        assert!(row.validation_error.is_none());
111
112        let id2 = p
113            .submit("a1", &sample_envelope("other\n"), "other\n")
114            .expect("insert2");
115        let r_no = p
116            .validate_and_record(id2, |_s: &str| Err::<(), _>("no"))
117            .expect("valid");
118        assert!(!r_no.accepted);
119        assert_eq!(r_no.error.as_deref(), Some("no"));
120        let row2 = p.get(id2).unwrap().expect("row2");
121        assert!(!row2.accepted);
122        assert!(row2.adopted_at.is_none());
123        assert!(p.get(Uuid::new_v4()).unwrap().is_none());
124    }
125
126    #[test]
127    fn mark_adopted_after_validation() {
128        let tmp = tempfile::tempdir().unwrap();
129        let db = tmp.path().join("prop3.db");
130        let p = ProposalLedger::open(&db).unwrap();
131        let text = "graph\nt1\n";
132        let env = sample_envelope(text);
133        let id = p.submit("a1", &env, text).expect("insert");
134        p.validate_and_record(id, |s: &str| if s == text { Ok(()) } else { Err("bad") })
135            .expect("validate");
136        p.mark_adopted(id, "node-1").expect("adopted");
137        let r = p.get(id).unwrap().expect("row");
138        assert!(r.adopted_at.is_some());
139        assert_eq!(r.adopted_graph_node_id.as_deref(), Some("node-1"));
140    }
141}