Compile calendar rules to Rego
What does this MR do and why?
This MR adds a calendar emitter to Gitlab::PolicyStore::RuleTranspiler. A calendar rule compiles to a single violation rule with its freeze windows inlined directly into the rule body, in the same package governance shape the existing environment emitter already produces. It also tightens rule_transpiler_spec.rb's catalogue example, which previously pinned calendar as the one type with no emitter, to assert every type in Rules::ALL has one.
The emitted program compares timestamps as plain strings, since whether the time.parse_rfc3339_ns builtin is available under Rego's restricted builtins allowlist is not yet settled. That means every authored timestamp has to be normalized to a fixed-width UTC form before it's emitted: an offset such as 2026-12-24T00:00:00+01:00 would otherwise sort as if it were an hour later than the UTC instant it actually names. Three authored forms are refused outright rather than normalized, because each would compile and then silently name the wrong instant: one with no UTC offset (read as local time, so it means a different instant on different hosts), a year that isn't exactly four digits (breaks the ordering the comparison depends on), and a non-zero fractional second (dropping it silently moves the boundary by up to a second).
That normalization covers only the authored half of the comparison. The other half, input.evaluated_at, arrives already formed at evaluation time, and neither the transpiler nor the emitted program can verify its shape, so whoever calls the Policy Engine owns that contract.
How to set up and validate locally
Requires an Ultimate licence. Every block below runs in gdk rails console, continuing the same
session throughout.
- Turn the experiment on and confirm it took effect, because until it is active every step
below returns
Policy Store experiment is not active for this organizationand no rule reaches the transpiler
Feature.enable(:security_policies_v2)
ApplicationSetting.current.update!(policy_store_experiment_enabled: true)
organization = Organizations::Organization.first
current_user = User.admins.first
organization.policy_store_experiment_active?
# => true- Define the helper the rest of the steps call. It reads the message rather than the payload
when a create fails, since a rejected policy has no payload, and it returns the stored
rule so the next step can look at the whole entry.
current_userhas to be an organization owner or an admin, becauseCreateServicechecks:create_govern_policyagainst it
def create_rule(organization, current_user, rules, name:)
result = Security::SecurityOrchestrationPolicies::PolicyStore::CreateService.new(
organization: organization,
current_user: current_user,
params: { name: name, trigger_type: "deployment_requested", rules: rules }
).execute
return puts(result.message) unless result.success?
result.payload[:policy].rules.first
end- Create a policy carrying a freeze window. Verify the stored entry has three keys, the
typeandvalueyou authored plus aregothe store compiled, because that third key is what this MR adds and nothing authored it
window = {
"name" => "eoq-freeze",
"tiers" => ["production"],
"starts_at" => "2026-12-24T00:00:00Z",
"ends_at" => "2027-01-02T00:00:00Z"
}
stored = create_rule(organization, current_user, [{ "type" => "calendar", "value" => { "windows" => [window] } }],
name: "End of quarter freeze")
stored.keys
# => ["type", "value", "rego"]- Read the compiled program. Verify it matches the block below, because the windows are policy configuration and nothing in a deployment's input carries them
puts stored["rego"]package governance
# rule 0: calendar
violation contains {"msg": msg, "details": {"rule_index": 0, "window": freeze_window.name}} if {
freeze_window := [
{"name": "eoq-freeze", "tiers": ["production"], "starts_at": "2026-12-24T00:00:00Z", "ends_at": "2027-01-02T00:00:00Z"},
][_]
input.environment.tier in freeze_window.tiers
input.evaluated_at >= freeze_window.starts_at
input.evaluated_at < freeze_window.ends_at
msg := sprintf("deployment blocked by freeze window %s (%s to %s)", [freeze_window.name, freeze_window.starts_at, freeze_window.ends_at])
}- Author the same window to the millisecond. Verify it fails with
rule 0: calendar window "eoq-freeze" ends_at carries sub-second precision the emitted comparison cannot represent: "2027-01-02T00:00:00.500Z", because keeping the fraction would break the ordering, since.sorts beforeZ, and dropping it would move the boundary by up to a second without saying so
to_the_millisecond = window.merge("ends_at" => "2027-01-02T00:00:00.500Z")
create_rule(organization, current_user, [{ "type" => "calendar", "value" => { "windows" => [to_the_millisecond] } }],
name: "Millisecond freeze")- Author a window with an offset instead of
Z. Verify the emitted program'sstarts_atreads"2026-09-01T10:00:00Z", because the transpiler normalises an authored offset to UTC before emitting it, which is the reason the refusals above exist at all
offset_window = {
"name" => "cutover-freeze",
"tiers" => ["production"],
"starts_at" => "2026-09-01T12:00:00+02:00",
"ends_at" => "2026-09-02T00:00:00Z"
}
stored_offset = create_rule(organization, current_user, [{ "type" => "calendar", "value" => { "windows" => [offset_window] } }],
name: "Cutover freeze")
puts stored_offset["rego"] if stored_offsetExpected result: before this MR, a calendar rule failed every call with
rule 0: unsupported rule type "calendar". After it, a calendar rule compiles the same way an
environment rule already does, an authored offset normalises to UTC in the emitted program, and the
three misleading forms above are refused rather than silently compiled.
Names are unique within an organization, so re-running any step needs a fresh name:. The helper
prints the error, so a repeat shows as a message rather than an exception.
References
- Related to https://gitlab.com/gitlab-org/gitlab/-/work_items/607656
- Depends on !249000 (merged) (merged)
- The reference Rego programs the output contract follows: https://gitlab.com/gitlab-org/gitlab/-/work_items/607649#note_3653227579
- The deployment context the emitted programs read, and the source of the
evaluated_atcontract: https://gitlab.com/gitlab-org/gitlab/-/work_items/607786 - Epic: https://gitlab.com/groups/gitlab-org/-/epics/22937