# 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 # 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) }