ProofedMesh/policies/transformations/expense.rego
CTO Agent c0472691c8
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 12s
validate / JSON Schema (ajv) (push) Has been cancelled
policies: add receipts + per_diem_rates data-source policies, per_diem transformation policy, extend expense.rego for v1.2.0 (54/54 tests pass)
2026-10-25 17:07:38 +00:00

198 lines
6 KiB
Rego

# METADATA
# title: Transformation policies for expense workflows
# description: |
# Governs which agents may perform which transformations on expense
# artifacts, and enforces the invariants that must survive mapping.
# Supports expense.canonical v1.0.0 (flat single-project) and v1.1.0
# (per-line allocations with percentage or absolute mode).
# authors:
# - CTO Agent <cto@basicstack.de>
# related_resources:
# - ref: docs/architecture/v1.1.md#user-content-6-berechtigungssteuerung-mit-open-policy-agent
# - ref: schemas/expense/canonical/v1.0.0.json
# - ref: schemas/expense/canonical/v1.1.0.json
# - ref: schemas/sink/db/expense/v1.0.0.json
# - ref: schemas/sink/db/expense/v1.1.0.json
package proofedmesh.transformations.expense
import rego.v1
default allow := false
# Allow only known agent + operation + schema-pair combinations.
allow if {
some rule in agent_allowed
rule.agent_id == input.agent_id
rule.operation == input.operation
schemas_match(rule)
invariants_hold(rule)
}
# Rule table — authoritative map of allowed (agent, operation, input, output).
agent_allowed := [
{
"agent_id": "agent-travel",
"operation": "expense.extract_receipts",
"input_schema": {"name": "receipt.raw", "version": "1.0.0"},
"output_schema": {"name": "expense.canonical", "version": "1.0.0"},
},
{
"agent_id": "agent-travel",
"operation": "expense.normalize",
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
"output_schema": {"name": "expense.canonical", "version": "1.0.0"},
},
{
"agent_id": "agent-travel",
"operation": "expense.normalize",
"input_schema": {"name": "expense.canonical", "version": "1.1.0"},
"output_schema": {"name": "expense.canonical", "version": "1.1.0"},
},
{
"agent_id": "agent-sink-mapping",
"operation": "expense.map_to_sink",
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
"output_schema": {"name": "sink.db.expense", "version": "1.0.0"},
},
{
"agent_id": "agent-sink-mapping",
"operation": "expense.map_to_sink",
"input_schema": {"name": "expense.canonical", "version": "1.1.0"},
"output_schema": {"name": "sink.db.expense", "version": "1.1.0"},
},
{
"agent_id": "agent-travel",
"operation": "expense.normalize",
"input_schema": {"name": "expense.canonical", "version": "1.2.0"},
"output_schema": {"name": "expense.canonical", "version": "1.2.0"},
},
{
"agent_id": "agent-sink-mapping",
"operation": "expense.map_to_sink",
"input_schema": {"name": "expense.canonical", "version": "1.2.0"},
"output_schema": {"name": "sink.db.expense", "version": "1.2.0"},
},
]
schemas_match(rule) if {
input.input_schema.name == rule.input_schema.name
input.input_schema.version == rule.input_schema.version
input.output_schema.name == rule.output_schema.name
input.output_schema.version == rule.output_schema.version
}
# Business invariants that no transformation may violate without a new approval.
# For v1.1.0 sink mappings, we additionally enforce per-item allocation-sum.
invariants_hold(rule) if {
total_amount_preserved
currency_preserved
employee_id_preserved
allocations_consistent(rule)
}
# ------------------------------------------------------------------
# Preservation invariants (apply to all versions)
# ------------------------------------------------------------------
total_amount_preserved if {
not input.input_payload.total_amount
}
total_amount_preserved if {
input.input_payload.total_amount == input.output_payload.expense.total_amount
}
currency_preserved if {
not input.input_payload.currency
}
currency_preserved if {
input.input_payload.currency == input.output_payload.expense.currency
}
employee_id_preserved if {
not input.input_payload.employee_id
}
employee_id_preserved if {
input.input_payload.employee_id == input.output_payload.expense.employee_id
}
# ------------------------------------------------------------------
# Allocation invariants (v1.1.0 sink mappings only)
# ------------------------------------------------------------------
# Vacuously true unless the output is a sink.db.expense v1.1.0 or v1.2.0 payload.
allocations_consistent(rule) if {
not sink_with_allocations(rule)
}
allocations_consistent(rule) if {
sink_with_allocations(rule)
every item in input.output_payload.expense.items {
item_allocation_sum_matches(item)
}
}
sink_with_allocations(rule) if {
rule.output_schema.name == "sink.db.expense"
rule.output_schema.version in {"1.1.0", "1.2.0"}
}
# Monetary amounts are decimal strings like "428.50". We parse to cents (integer-valued
# float) and compare for exact equality. Rego's to_number yields a float; multiplying
# by 100 and rounding gives whole cents, dodging IEEE-754 accumulation error at this scale.
to_cents(s) := n if {
n := round(to_number(s) * 100)
}
item_allocation_sum_matches(item) if {
expected := to_cents(item.amount)
actual := sum([to_cents(a.amount) | some a in item.allocations])
expected == actual
}
# ------------------------------------------------------------------
# Human-readable denial reasons for debugging and audit.
# ------------------------------------------------------------------
deny_reason contains msg if {
not any_rule_matches
msg := sprintf("no allow rule for agent=%s operation=%s", [input.agent_id, input.operation])
}
deny_reason contains msg if {
any_rule_matches
not total_amount_preserved
msg := "total_amount changed by transformation"
}
deny_reason contains msg if {
any_rule_matches
not currency_preserved
msg := "currency changed by transformation"
}
deny_reason contains msg if {
any_rule_matches
not employee_id_preserved
msg := "employee_id changed by transformation"
}
deny_reason contains msg if {
some rule in agent_allowed
rule.agent_id == input.agent_id
rule.operation == input.operation
schemas_match(rule)
sink_with_allocations(rule)
some i, item in input.output_payload.expense.items
not item_allocation_sum_matches(item)
msg := sprintf("item %d: sum(allocations.amount) != item.amount", [i])
}
any_rule_matches if {
some rule in agent_allowed
rule.agent_id == input.agent_id
rule.operation == input.operation
schemas_match(rule)
}