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 ↔️ Engine interface; delivery plan gitlab-org/gitlab#608189 (closed)). Enforcement modes (gitlab-org/gitlab#607657) ride on these rails in a follow-up; the 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 --workspace passes (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-policy violation/deny/allow interpretation tests · FFI round-trip/error-mapping/debug + the 9-case conformance suite shared with the Go client)
  • cargo clippy --workspace --all-targets is clean
  • cargo fmt --all --check is 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 mise

Evaluate 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
Edited by Artur Fedorov

Merge request reports

Loading
Loading