policies: add receipts + per_diem_rates data-source policies, per_diem transformation policy, extend expense.rego for v1.2.0 (54/54 tests pass)
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 12s
validate / JSON Schema (ajv) (push) Has been cancelled

This commit is contained in:
CTO Agent 2026-10-25 17:07:38 +00:00
parent 27c45b356a
commit c0472691c8
9 changed files with 552 additions and 7 deletions

View file

@ -17,14 +17,20 @@ Default in jedem Package ist `allow := false` — explizites Zulassen ist erford
```
policies/
transformations/
expense.rego
expense.rego — v1.0.0/v1.1.0/v1.2.0 map_to_sink + normalize, with allocation-sum invariant
expense_test.rego
per_diem.rego — expense.compute_per_diem (agent-per-diem-calculator → v1.2.0 output)
per_diem_test.rego
sinks/
expense_db_app.rego
expense_db_app_test.rego
data_sources/
expense_db_app.rego
expense_db_app_test.rego
receipts.rego — ds:receipts write (agent-travel) + read-by-content_hash (agent-sink-*)
receipts_test.rego
per_diem_rates.rego — ds:per-diem-rates read (agent-per-diem-calculator only, country+valid_on required)
per_diem_rates_test.rego
```
## Input-Verträge

View file

@ -0,0 +1,61 @@
# METADATA
# title: Data source policy for ds:per-diem-rates
# description: |
# Only agent-per-diem-calculator may read per-diem rates, and only within
# an active user session. Filters country+valid_on are required; city is
# optional. No write operations through this policy; rate maintenance is a
# separate admin flow.
# authors:
# - CTO Agent <cto@basicstack.de>
# related_resources:
# - ref: docs/architecture/v1.1.md#user-content-62-deterministische-regelwerke-am-beispiel-spesensätze-verpflegungsmehraufwand
# - ref: schemas/ds/per_diem/v1.0.0.json
package proofedmesh.data_sources.per_diem_rates
import rego.v1
default allow := false
allow if {
input.data_source == "ds:per-diem-rates"
input.operation == "read"
input.user_sub != ""
input.session_id != ""
input.agent_id == "agent-per-diem-calculator"
input.resource == "rates"
filters_ok
}
allowed_filters := {"country", "city", "valid_on"}
required_filters := {"country", "valid_on"}
filters_ok if {
provided := {k | some k; _ := input.filters[k]}
count(provided - allowed_filters) == 0
count(required_filters - provided) == 0
}
deny_reason contains msg if {
input.data_source != "ds:per-diem-rates"
msg := sprintf("wrong data_source: %s", [input.data_source])
}
deny_reason contains msg if {
input.operation != "read"
msg := sprintf("operation %s not allowed on ds:per-diem-rates (reads only)", [input.operation])
}
deny_reason contains msg if {
input.user_sub == ""
msg := "missing user_sub (per-diem reads require active user session)"
}
deny_reason contains msg if {
input.agent_id != "agent-per-diem-calculator"
msg := sprintf("agent %s not allowed to read per-diem rates (only agent-per-diem-calculator)", [input.agent_id])
}
deny_reason contains msg if {
not filters_ok
msg := "filters must include country and valid_on; only country, city, valid_on allowed"
}

View file

@ -0,0 +1,70 @@
package proofedmesh.data_sources.per_diem_rates_test
import data.proofedmesh.data_sources.per_diem_rates as pdr
import rego.v1
base := {
"data_source": "ds:per-diem-rates",
"user_sub": "user-12345",
"session_id": "SES-abc",
"operation": "read",
"resource": "rates",
}
test_allow_calculator_country_valid_on if {
pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"filters": {"country": "DE", "valid_on": "2026-10-02"},
})
}
test_allow_calculator_with_city if {
pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"filters": {"country": "DE", "city": "Berlin", "valid_on": "2026-10-02"},
})
}
test_deny_missing_country if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"filters": {"valid_on": "2026-10-02"},
})
}
test_deny_missing_valid_on if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"filters": {"country": "DE"},
})
}
test_deny_other_agent if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-travel",
"filters": {"country": "DE", "valid_on": "2026-10-02"},
})
}
test_deny_write_operation if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"operation": "write",
"filters": {"country": "DE", "valid_on": "2026-10-02"},
})
}
test_deny_no_user_session if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"user_sub": "",
"filters": {"country": "DE", "valid_on": "2026-10-02"},
})
}
test_deny_extra_filter_key if {
not pdr.allow with input as object.union(base, {
"agent_id": "agent-per-diem-calculator",
"filters": {"country": "DE", "valid_on": "2026-10-02", "max_rate": "99"},
})
}

View file

@ -0,0 +1,79 @@
# METADATA
# title: Data source policy for ds:receipts
# description: |
# Governs who may upload (write) and who may read receipts.
# Receipts are PII. agent-travel uploads during the chat; downstream agents
# may only read by content_hash and only within an active user session.
# authors:
# - CTO Agent <cto@basicstack.de>
# related_resources:
# - ref: docs/architecture/v1.1.md#user-content-47-belege-receipts--upload-hashbindung-lesezugriff-entlang-der-kette
# - ref: schemas/ds/receipts/v1.0.0.json
package proofedmesh.data_sources.receipts
import rego.v1
default allow := false
allow if {
input.data_source == "ds:receipts"
input.user_sub != ""
input.session_id != ""
some rule in agent_scope_rules
rule.agent_id == input.agent_id
rule.operation == input.operation
filters_ok(rule)
}
agent_scope_rules := [
{
"agent_id": "agent-travel",
"operation": "write",
"required_filters": set(),
"allowed_filters": set(),
},
{
"agent_id": "agent-sink-mapping",
"operation": "read",
"required_filters": {"content_hash"},
"allowed_filters": {"content_hash"},
},
{
"agent_id": "agent-sink-posting",
"operation": "read",
"required_filters": {"content_hash"},
"allowed_filters": {"content_hash"},
},
]
filters_ok(rule) if {
rule.operation == "write"
}
filters_ok(rule) if {
rule.operation == "read"
provided := {k | some k; _ := input.filters[k]}
count(provided - rule.allowed_filters) == 0
count(rule.required_filters - provided) == 0
}
deny_reason contains msg if {
input.data_source != "ds:receipts"
msg := sprintf("wrong data_source: %s", [input.data_source])
}
deny_reason contains msg if {
input.user_sub == ""
msg := "missing user_sub (receipts always require an active user session)"
}
deny_reason contains msg if {
not any_rule_matches
msg := sprintf("no scope rule for agent=%s op=%s", [input.agent_id, input.operation])
}
any_rule_matches if {
some rule in agent_scope_rules
rule.agent_id == input.agent_id
rule.operation == input.operation
}

View file

@ -0,0 +1,91 @@
package proofedmesh.data_sources.receipts_test
import data.proofedmesh.data_sources.receipts
import rego.v1
base := {
"data_source": "ds:receipts",
"user_sub": "user-12345",
"session_id": "SES-abc",
}
test_allow_travel_write if {
receipts.allow with input as object.union(base, {
"agent_id": "agent-travel",
"operation": "write",
"filters": {},
})
}
test_allow_mapping_read_by_content_hash if {
receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-mapping",
"operation": "read",
"filters": {"content_hash": "sha256:aa"},
})
}
test_allow_posting_read_by_content_hash if {
receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-posting",
"operation": "read",
"filters": {"content_hash": "sha256:aa"},
})
}
test_deny_read_without_content_hash if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-mapping",
"operation": "read",
"filters": {},
})
}
test_deny_read_with_extra_filter if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-mapping",
"operation": "read",
"filters": {"content_hash": "sha256:aa", "list_all": "yes"},
})
}
test_deny_travel_read if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-travel",
"operation": "read",
"filters": {"content_hash": "sha256:aa"},
})
}
test_deny_posting_write if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-posting",
"operation": "write",
"filters": {},
})
}
test_deny_no_user_session if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-travel",
"operation": "write",
"filters": {},
"user_sub": "",
})
}
test_deny_unknown_agent if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-rogue",
"operation": "read",
"filters": {"content_hash": "sha256:aa"},
})
}
test_deny_list_operation if {
not receipts.allow with input as object.union(base, {
"agent_id": "agent-sink-mapping",
"operation": "list",
"filters": {},
})
}

View file

@ -60,6 +60,18 @@ agent_allowed := [
"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 {
@ -110,21 +122,21 @@ employee_id_preserved if {
# Allocation invariants (v1.1.0 sink mappings only)
# ------------------------------------------------------------------
# Vacuously true unless the output is a sink.db.expense v1.1.0 payload.
# Vacuously true unless the output is a sink.db.expense v1.1.0 or v1.2.0 payload.
allocations_consistent(rule) if {
not sink_v1_1(rule)
not sink_with_allocations(rule)
}
allocations_consistent(rule) if {
sink_v1_1(rule)
sink_with_allocations(rule)
every item in input.output_payload.expense.items {
item_allocation_sum_matches(item)
}
}
sink_v1_1(rule) if {
sink_with_allocations(rule) if {
rule.output_schema.name == "sink.db.expense"
rule.output_schema.version == "1.1.0"
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
@ -172,7 +184,7 @@ deny_reason contains msg if {
rule.agent_id == input.agent_id
rule.operation == input.operation
schemas_match(rule)
sink_v1_1(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])

View file

@ -234,3 +234,65 @@ test_allow_v110_normalize if {
"output_payload": {"expense": canonical_v11},
}
}
# ------------------------------------------------------------------
# v1.2.0 — allocations + receipts + per-diem items (map_to_sink)
# ------------------------------------------------------------------
canonical_v12 := {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "484.50",
}
sink_v12_mixed := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "484.50",
"items": [
{"amount": "328.50", "allocations": [{"amount": "328.50"}]},
{"amount": "14.00", "allocations": [{"amount": "14.00"}]},
{"amount": "28.00", "allocations": [{"amount": "28.00"}]},
{"amount": "14.00", "allocations": [{"amount": "14.00"}]},
{"amount": "100.00", "allocations": [{"amount": "100.00"}]},
],
}}
test_allow_v120_map_to_sink_mixed_items if {
expense.allow with input as {
"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"},
"input_payload": canonical_v12,
"output_payload": sink_v12_mixed,
}
}
test_deny_v120_allocation_sum_mismatch if {
bad := {"expense": {
"employee_id": "EMP-12345",
"currency": "EUR",
"total_amount": "484.50",
"items": [{"amount": "328.50", "allocations": [{"amount": "328.51"}]}],
}}
not expense.allow with input as {
"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"},
"input_payload": canonical_v12,
"output_payload": bad,
}
}
test_allow_v120_normalize if {
expense.allow with input as {
"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"},
"input_payload": canonical_v12,
"output_payload": {"expense": canonical_v12},
}
}

View file

@ -0,0 +1,83 @@
# METADATA
# title: Transformation policy for per-diem compute
# description: |
# Only agent-per-diem-calculator may run expense.compute_per_diem.
# Output must consist exclusively of items whose type is PER_DIEM_*,
# each with a computed_from.transform_attestation_ref back-reference
# and a positive amount. Enforces separation between LLM agents (free-form)
# and deterministic compute agents (strict shape, no free-form fields).
# authors:
# - CTO Agent <cto@basicstack.de>
# related_resources:
# - ref: docs/architecture/v1.1.md#user-content-62-deterministische-regelwerke-am-beispiel-spesensätze-verpflegungsmehraufwand
# - ref: schemas/expense/canonical/v1.2.0.json
package proofedmesh.transformations.per_diem
import rego.v1
default allow := false
allow if {
input.agent_id == "agent-per-diem-calculator"
input.operation == "expense.compute_per_diem"
input.output_schema.name == "expense.canonical"
input.output_schema.version == "1.2.0"
all_items_are_per_diem
all_items_have_computed_from
all_amounts_positive
}
per_diem_types := {"PER_DIEM_FULL", "PER_DIEM_PARTIAL", "PER_DIEM_ARRIVAL", "PER_DIEM_DEPARTURE"}
all_items_are_per_diem if {
every item in input.output_payload.expense_items {
item.type in per_diem_types
}
}
all_items_have_computed_from if {
every item in input.output_payload.expense_items {
item.computed_from.transform_attestation_ref != ""
}
}
to_cents(s) := n if {
n := round(to_number(s) * 100)
}
all_amounts_positive if {
every item in input.output_payload.expense_items {
to_cents(item.amount) > 0
}
}
deny_reason contains msg if {
input.agent_id != "agent-per-diem-calculator"
msg := sprintf("agent %s not allowed to compute per_diem", [input.agent_id])
}
deny_reason contains msg if {
input.operation != "expense.compute_per_diem"
msg := sprintf("operation %s not allowed", [input.operation])
}
deny_reason contains msg if {
input.agent_id == "agent-per-diem-calculator"
input.operation == "expense.compute_per_diem"
not all_items_are_per_diem
msg := "compute output must contain only PER_DIEM_* typed items"
}
deny_reason contains msg if {
input.agent_id == "agent-per-diem-calculator"
input.operation == "expense.compute_per_diem"
not all_items_have_computed_from
msg := "every computed item must carry computed_from.transform_attestation_ref"
}
deny_reason contains msg if {
input.agent_id == "agent-per-diem-calculator"
input.operation == "expense.compute_per_diem"
not all_amounts_positive
msg := "all computed per-diem amounts must be strictly positive"
}

View file

@ -0,0 +1,81 @@
package proofedmesh.transformations.per_diem_test
import data.proofedmesh.transformations.per_diem as pdc
import rego.v1
good_output := {"expense_items": [
{
"type": "PER_DIEM_ARRIVAL",
"amount": "14.00",
"incurred_on": "2026-10-01",
"computed_from": {"transform_attestation_ref": "sha256:aa", "rate_id": "PDR-42"},
},
{
"type": "PER_DIEM_FULL",
"amount": "28.00",
"incurred_on": "2026-10-02",
"computed_from": {"transform_attestation_ref": "sha256:aa", "rate_id": "PDR-42"},
},
]}
base := {
"agent_id": "agent-per-diem-calculator",
"operation": "expense.compute_per_diem",
"output_schema": {"name": "expense.canonical", "version": "1.2.0"},
"output_payload": good_output,
}
test_allow_normal_compute if {
pdc.allow with input as base
}
test_deny_wrong_agent if {
not pdc.allow with input as object.union(base, {"agent_id": "agent-travel"})
}
test_deny_wrong_operation if {
not pdc.allow with input as object.union(base, {"operation": "expense.normalize"})
}
test_deny_wrong_output_schema_version if {
not pdc.allow with input as object.union(base, {"output_schema": {"name": "expense.canonical", "version": "1.1.0"}})
}
test_deny_non_per_diem_item if {
bad := {"expense_items": [{
"type": "HOTEL",
"amount": "100.00",
"incurred_on": "2026-10-01",
"computed_from": {"transform_attestation_ref": "sha256:aa"},
}]}
not pdc.allow with input as object.union(base, {"output_payload": bad})
}
test_deny_missing_computed_from if {
bad := {"expense_items": [{
"type": "PER_DIEM_FULL",
"amount": "28.00",
"incurred_on": "2026-10-02",
}]}
not pdc.allow with input as object.union(base, {"output_payload": bad})
}
test_deny_zero_amount if {
bad := {"expense_items": [{
"type": "PER_DIEM_FULL",
"amount": "0.00",
"incurred_on": "2026-10-02",
"computed_from": {"transform_attestation_ref": "sha256:aa"},
}]}
not pdc.allow with input as object.union(base, {"output_payload": bad})
}
test_deny_negative_amount if {
bad := {"expense_items": [{
"type": "PER_DIEM_FULL",
"amount": "-5.00",
"incurred_on": "2026-10-02",
"computed_from": {"transform_attestation_ref": "sha256:aa"},
}]}
not pdc.allow with input as object.union(base, {"output_payload": bad})
}