refund_budget_settled_plus_current
EXPERIMENTAL. This bundle uses arithmetic in temporal comparison operands, gated behind the off-by-default Cargo feature
experimental-arithmetic. A default build rejects this policy; the test harnesses check this bundle only when built with that feature.
This bundle exercises + between a sum and a context field (a budget
combining unlike sources).
Permit a Refund only when the settled refund value in the window
(Refund::response amounts), plus the amount being requested right now,
stays at or under 100.
permit (principal, action == Drupe::Action::"Refund", resource)
when temporal {
(sum a for (a: Long), (t: Timepoint). where (
formerly within 24h (Drupe::Action::"Refund"::response{ output.amount: a } && tp(t))
)) + context.input.amount <= 100
};
When a sum matches the same event kind the policy decides, the + term is
not just unnecessary but wrong: temporal windows are closed and reflexive, so
such a sum already includes the in-flight request’s amount, and adding
context.input.amount double-counts it. Here the sum matches response
events, the request being decided is a request, and nothing else puts its
amount into the total — the + term is genuinely load-bearing. Counting
responses is also the more accurate accounting: a denied refund does not
consume budget.
Replay
| Decision point | This request | Settled + this request | Verdict |
|---|---|---|---|
@0 | 60 | 0 + 60 = 60 | allow |
@10 | 50 | 60 + 50 = 110 | deny |
@20 | 30 | 60 + 30 = 90 | allow |
@30 | 15 | 90 + 15 = 105 | deny |
@40 | 10 | 90 + 10 = 100 | allow |
The denied @10 request never settles, so its 50 does not count at @20 —
contrast a request-kind quota, where a denied attempt stays in the trace and
keeps counting. @40 sits exactly at the budget (closed bound).
Files
policy.dw: the permit with the settled-sum-plus-current guard.schema.cedarschema:Drupeaction schema withRefundonly (RefundOutputcarries the settledamount).trace.log: five refund requests by alice; the allowed ones settle with a matchingresponse.expected.out: the per-decision-point verdict stream.
Reproduce (run from this directory):
dogwood validate policy.dw --policy-schema schema.cedarschema
dogwood replay policy.dw --policy-schema schema.cedarschema --trace trace.log
World: drupe. Default event schema; no providers.
Referenced by guide/04-temporal-expressions.md (arithmetic in comparison operands).
Policy
// A refund budget counting settled refunds plus the current request: permit
// a Refund only when the amounts already paid out (response events) in the
// window, plus the amount being requested right now, stay at or under 100.
//
// The `+ context.input.amount` term is the arithmetic this bundle exists to
// exercise. A sum over the SAME request kind the policy decides would not
// need it: windows are closed and reflexive, so such a sum already includes
// the in-flight amount, and adding the term would double-count. Here the
// sum matches `Refund::response` events and the request being decided is a
// `request`, so nothing else puts its amount into the total. Counting
// responses is also the more accurate accounting — a denied or failed
// refund does not consume budget.
@id("refund_budget_settled_plus_current")
permit (
principal,
action == Drupe::Action::"Refund",
resource
)
when temporal {
(sum a for (a: Long), (t: Timepoint). where (
formerly within 24h (Drupe::Action::"Refund"::response{ output.amount: a } && tp(t))
)) + context.input.amount <= 100
};
Schema
namespace Drupe {
type RefundInput = {
user: String,
amount: Long
};
type RefundOutput = {
amount: Long
};
type SystemContext = {
now: datetime
};
entity Gateway;
entity OAuthUser = {
id: String
} tags String;
action "Refund" appliesTo {
principal: [OAuthUser],
resource: [Gateway],
context: {
input: RefundInput,
output?: RefundOutput,
system: SystemContext
}
};
}
Trace
@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", amount: 60 }) Drupe::Action::"Refund"::request(input: { user: "alice", amount: 60 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Refund"::response(input: { user: "alice", amount: 60 }, output: { amount: 60 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", amount: 50 }) Drupe::Action::"Refund"::request(input: { user: "alice", amount: 50 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", amount: 30 }) Drupe::Action::"Refund"::request(input: { user: "alice", amount: 30 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r3")
@21 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Refund"::response(input: { user: "alice", amount: 30 }, output: { amount: 30 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", amount: 15 }) Drupe::Action::"Refund"::request(input: { user: "alice", amount: 15 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r4")
@40 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", amount: 10 }) Drupe::Action::"Refund"::request(input: { user: "alice", amount: 10 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r5")
Expected Output
@0 (time point 0): ALLOW [rules: 0]
@10 (time point 1): DENY
@20 (time point 2): ALLOW [rules: 0]
@30 (time point 3): DENY
@40 (time point 4): ALLOW [rules: 0]