policies+ci: v1.1.0 allocation support — expense.rego enforces per-item sum invariant, 5 new tests (25/25 pass), CI validates examples per schema version
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 12s
validate / JSON Schema (ajv) (push) Successful in 16s

This commit is contained in:
CTO Agent 2026-10-25 16:19:37 +00:00
parent 81e5f334b5
commit 5d309e16c1
3 changed files with 213 additions and 12 deletions

View file

@ -23,6 +23,7 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends git curl ca-certificates apt-get install -y --no-install-recommends git curl ca-certificates
git --version git --version
node --version
- name: Clone repository (sha256-aware) - name: Clone repository (sha256-aware)
run: | run: |
@ -41,8 +42,8 @@ jobs:
- name: Validate every JSON Schema parses as JSON - name: Validate every JSON Schema parses as JSON
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -eu
find schemas -type f -name '*.json' -print | while read -r f; do find schemas -type f -name 'v*.json' -print | while IFS= read -r f; do
echo "--- parse: $f" echo "--- parse: $f"
node -e "require('./$f')" node -e "require('./$f')"
done done
@ -50,20 +51,24 @@ jobs:
- name: Meta-validate each schema against JSON Schema 2020-12 - name: Meta-validate each schema against JSON Schema 2020-12
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -eu
export NODE_PATH=/tmp/ajv/node_modules export NODE_PATH=/tmp/ajv/node_modules
find schemas -type f -name '*.json' -print | while read -r f; do find schemas -type f -name 'v*.json' -print | while IFS= read -r f; do
echo "--- meta-validate: $f" echo "--- meta-validate: $f"
node /tmp/ajv/node_modules/.bin/ajv compile --spec=draft2020 -c=ajv-formats -s "$f" node /tmp/ajv/node_modules/.bin/ajv compile --spec=draft2020 -c=ajv-formats -s "$f"
done done
- name: Validate example payloads against their schemas (if any) - name: Validate per-version example payloads against their schemas
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -eu set -eu
export NODE_PATH=/tmp/ajv/node_modules export NODE_PATH=/tmp/ajv/node_modules
# Each schema file is <path>/vX.Y.Z.json; example payloads for that version
# live under <path>/examples/vX.Y.Z/*.json. This keeps v1.0.0 examples from
# being validated against v1.1.0 and vice-versa.
find schemas -type f -name 'v*.json' -print | while IFS= read -r schema; do find schemas -type f -name 'v*.json' -print | while IFS= read -r schema; do
dir="$(dirname "$schema")/examples" version="$(basename "$schema" .json)"
dir="$(dirname "$schema")/examples/$version"
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
find "$dir" -type f -name '*.json' -print | while IFS= read -r example; do find "$dir" -type f -name '*.json' -print | while IFS= read -r example; do
echo "--- validate example $example against $schema" echo "--- validate example $example against $schema"

View file

@ -3,28 +3,32 @@
# description: | # description: |
# Governs which agents may perform which transformations on expense # Governs which agents may perform which transformations on expense
# artifacts, and enforces the invariants that must survive mapping. # 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: # authors:
# - CTO Agent <cto@basicstack.de> # - CTO Agent <cto@basicstack.de>
# related_resources: # related_resources:
# - ref: docs/architecture/v1.1.md#user-content-6-berechtigungssteuerung-mit-open-policy-agent # - 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.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.0.0.json
# - ref: schemas/sink/db/expense/v1.1.0.json
package proofedmesh.transformations.expense package proofedmesh.transformations.expense
import rego.v1 import rego.v1
default allow := false default allow := false
# Allow only known agent + operation combinations. # Allow only known agent + operation + schema-pair combinations.
allow if { allow if {
some rule in agent_allowed some rule in agent_allowed
rule.agent_id == input.agent_id rule.agent_id == input.agent_id
rule.operation == input.operation rule.operation == input.operation
schemas_match(rule) schemas_match(rule)
invariants_hold invariants_hold(rule)
} }
# Rule table — the authoritative map of what each agent may do. # Rule table — authoritative map of allowed (agent, operation, input, output).
agent_allowed := [ agent_allowed := [
{ {
"agent_id": "agent-travel", "agent_id": "agent-travel",
@ -38,12 +42,24 @@ agent_allowed := [
"input_schema": {"name": "expense.canonical", "version": "1.0.0"}, "input_schema": {"name": "expense.canonical", "version": "1.0.0"},
"output_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", "agent_id": "agent-sink-mapping",
"operation": "expense.map_to_sink", "operation": "expense.map_to_sink",
"input_schema": {"name": "expense.canonical", "version": "1.0.0"}, "input_schema": {"name": "expense.canonical", "version": "1.0.0"},
"output_schema": {"name": "sink.db.expense", "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"},
},
] ]
schemas_match(rule) if { schemas_match(rule) if {
@ -54,14 +70,18 @@ schemas_match(rule) if {
} }
# Business invariants that no transformation may violate without a new approval. # Business invariants that no transformation may violate without a new approval.
invariants_hold if { # For v1.1.0 sink mappings, we additionally enforce per-item allocation-sum.
invariants_hold(rule) if {
total_amount_preserved total_amount_preserved
currency_preserved currency_preserved
employee_id_preserved employee_id_preserved
allocations_consistent(rule)
} }
# total_amount from the canonical input must appear unchanged in the sink output. # ------------------------------------------------------------------
# Only enforced when both sides carry the fields. # Preservation invariants (apply to all versions)
# ------------------------------------------------------------------
total_amount_preserved if { total_amount_preserved if {
not input.input_payload.total_amount not input.input_payload.total_amount
} }
@ -86,7 +106,44 @@ employee_id_preserved if {
input.input_payload.employee_id == input.output_payload.expense.employee_id 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 payload.
allocations_consistent(rule) if {
not sink_v1_1(rule)
}
allocations_consistent(rule) if {
sink_v1_1(rule)
every item in input.output_payload.expense.items {
item_allocation_sum_matches(item)
}
}
sink_v1_1(rule) if {
rule.output_schema.name == "sink.db.expense"
rule.output_schema.version == "1.1.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. # Human-readable denial reasons for debugging and audit.
# ------------------------------------------------------------------
deny_reason contains msg if { deny_reason contains msg if {
not any_rule_matches not any_rule_matches
msg := sprintf("no allow rule for agent=%s operation=%s", [input.agent_id, input.operation]) msg := sprintf("no allow rule for agent=%s operation=%s", [input.agent_id, input.operation])
@ -110,6 +167,17 @@ deny_reason contains msg if {
msg := "employee_id changed by transformation" 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_v1_1(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 { any_rule_matches if {
some rule in agent_allowed some rule in agent_allowed
rule.agent_id == input.agent_id rule.agent_id == input.agent_id

View file

@ -3,6 +3,10 @@ package proofedmesh.transformations.expense_test
import data.proofedmesh.transformations.expense import data.proofedmesh.transformations.expense
import rego.v1 import rego.v1
# ------------------------------------------------------------------
# v1.0.0 — flat single-project (unchanged behaviour)
# ------------------------------------------------------------------
canonical_payload := { canonical_payload := {
"employee_id": "EMP-12345", "employee_id": "EMP-12345",
"currency": "EUR", "currency": "EUR",
@ -106,3 +110,127 @@ test_deny_posting_agent_cannot_map if {
"output_payload": sink_payload, "output_payload": sink_payload,
} }
} }
# ------------------------------------------------------------------
# v1.1.0 — per-line allocations
# ------------------------------------------------------------------
canonical_v11 := {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "428.50",
}
sink_v11_two_items_split := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "428.50",
"items": [
{
"amount": "328.50",
"allocations": [
{"amount": "197.10"},
{"amount": "131.40"},
],
},
{
"amount": "100.00",
"allocations": [
{"amount": "70.00"},
{"amount": "30.00"},
],
},
],
}}
test_allow_v110_map_to_sink_split if {
expense.allow with input as {
"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"},
"input_payload": canonical_v11,
"output_payload": sink_v11_two_items_split,
}
}
test_deny_v110_allocation_sum_mismatch if {
bad_sink := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "428.50",
"items": [
{
"amount": "328.50",
"allocations": [
{"amount": "197.10"},
{"amount": "131.50"}, # 328.60, nicht 328.50
],
},
{
"amount": "100.00",
"allocations": [
{"amount": "70.00"},
{"amount": "30.00"},
],
},
],
}}
not expense.allow with input as {
"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"},
"input_payload": canonical_v11,
"output_payload": bad_sink,
}
}
test_allow_v110_single_allocation if {
one := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "428.50",
"items": [
{"amount": "328.50", "allocations": [{"amount": "328.50"}]},
{"amount": "100.00", "allocations": [{"amount": "100.00"}]},
],
}}
expense.allow with input as {
"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"},
"input_payload": canonical_v11,
"output_payload": one,
}
}
test_v110_still_enforces_total_currency_employee if {
tampered_total := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "999.99",
"items": [{"amount": "328.50", "allocations": [{"amount": "328.50"}]}],
}}
not expense.allow with input as {
"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"},
"input_payload": canonical_v11,
"output_payload": tampered_total,
}
}
test_allow_v110_normalize if {
# normalize keeps canonical v1.1.0 shape, no sink allocation check applies.
expense.allow with input as {
"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"},
"input_payload": canonical_v11,
"output_payload": {"expense": canonical_v11},
}
}