feat(govern): trigger-keyed batch evaluation with mock policy lookup
What
Adds trigger-keyed batch policy evaluation: the caller supplies an event trigger and a context document, and the engine reads the evaluation scope from the context (organization.id), resolves the applicable policies through a new PolicyLookup seam (canned in-memory policies now; the Policy Store REST client in !139 (closed)), and evaluates them as a batch — one decision per policy. The evaluation surface is trigger-keyed — policies enter only through the PolicyLookup seam; the one deliberate exception (review round 5, @mcavoj) is the restored debug_evaluate raw-Rego hatch for debugging a single policy without the Policy Store.
Why
GLAZ's govern path was keyed on policy_rego — the caller had to pre-select the Rego. In the target architecture a single call resolves "which policies apply to this trigger" itself, so integrators — in particular the CD deployment-gate flow — can test the target shape from this branch before the Policy Store exists. The PolicyLookup trait is the permanent seam: the canned lookup and the REST client are the same type, so activation changes no callers.
Part of gitlab-org/gitlab#607650 (Store data_json size cap is split to !141 (merged) per review. Round-5 review applied: original message names evolved (EvaluateGovernPolicyRequest now carries trigger), the lookup seam takes a LookupKey { trigger, organization_id }, StoredPolicy follows the store schema (trigger, organization_id, scope_rego), and the mock is a static filtered list. Round-7 review applied: ids are integers end-to-end (StoredPolicy.id/organization_id and LookupKey.organization_id are u64, PolicyDecision.policy_id is uint64 on the wire; the engine parses the context's organizations/<n> resource path fail-closed), the module's lookup is swappable (Box<dyn PolicyLookup> + with_lookup), the test-only insert is gone, the input document must be a JSON object (was a misleading scope error), the decision construction and input size check are deduped, and the no-op import rego.v1 lines are dropped (regorus 0.11 defaults to Rego v1 — verified by the full suite passing without them). Round-8 review applied: the incomplete-batch signal lives on the response message, not the FFI status — EvaluateGovernPolicyResponse.undecided_policy_ids enumerates the decisions whose in-band error is non-empty, populated in glaz-govern where the error is recorded, visible to every host (FFI, gem, gRPC) and additive under proto versioning (the interim GLAZ_OK_WITH_ERRORS status was reworked per @mcavoj's ABI-range/err_buf analysis; GLAZ_OK is again the only success status). The normative rule now lives in the proto and the C header: a decision with a non-empty error is undecided, and undecided is never allow. The ABI stays at v2 per @bauerdominic, the ABI doc describes the current contract instead of narrating v1, and the canned-lookup migration story lives in one place (with_canned_policies) instead of five. Parse-once optimization is a follow-up: #16 (closed).
Testing
-
cargo test --workspacepasses (207 tests, incl. the gating stamp and Go-FFI port from main: 4 lookup · 20 batch-path incl. every whole-call error and in-band sibling isolation · ~27 per-policyviolation/deny/allowinterpretation tests · FFI round-trip/error-mapping/debug + the 9-case conformance suite shared with the Go client) -
cargo clippy --workspace --all-targetsis clean -
cargo fmt --all --checkis clean
How to reproduce / test locally
git fetch origin af/607650-trigger-based-evaluate
git checkout af/607650-trigger-based-evaluate
cargo test -p glaz-govern -p glaz-module # mise exec -- cargo ... if you use miseEvaluate against the canned policies from Rust — registered under trigger "deployment_requested", organization 1; the context must carry organization.id as an organizations/<n> resource path, which the engine parses down to the numeric id:
use glaz_govern::{GovernPolicyEngine, PolicyStoreLookup};
use glaz_proto::glaz::govern::v1::EvaluateGovernPolicyRequest;
let response = GovernPolicyEngine::new().evaluate_policies(
&PolicyStoreLookup::with_canned_policies(),
&EvaluateGovernPolicyRequest {
trigger: "deployment_requested".to_string(),
input_json: r#"{"organization":{"id":"organizations/1"},"environment":"production","context":{"approved":false,"scan_completed":true}}"#.to_string(),
data_json: String::new(),
},
).unwrap();context (plus "organization":{"id":"organizations/1"}) |
policy 1 (deny unapproved production) |
policy 2 (deny missing scan) |
|---|---|---|
"environment":"production","context":{"approved":false,"scan_completed":true} |
matched, 1 block action |
not matched |
"environment":"production","context":{"approved":true,"scan_completed":true} |
not matched | not matched |
"environment":"staging","context":{"scan_completed":false} |
not matched | matched, 1 block action |
any context, trigger "merge_request.merge" |
empty decisions (success, nothing to enforce) |
— |
context without organization.id, or with one that is not an organizations/<n> path |
whole-call error (invalid scope) |
— |
FFI boundary (what the Ruby gem will call): encode the same request with prost::Message::encode_to_vec and pass the bytes to glaz_module::GovernPolicyCheckEngine::new().evaluate_policies(&bytes). Custom policy sources: implement PolicyLookup and install it with GovernPolicyCheckEngine::with_lookup(...) (or GovernPolicyEngine::evaluate_policies(&lookup, ...) natively).
Author checklist
- Title follows Conventional Commits and breaking changes are flagged (reshapes the pre-release surface per review — no external consumers; the Ruby binding does not exist yet)
- Docs / comments updated where behaviour changed
- No unrelated changes swept in