Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 pointThis requestSettled + this requestVerdict
@0600 + 60 = 60allow
@105060 + 50 = 110deny
@203060 + 30 = 90allow
@301590 + 15 = 105deny
@401090 + 10 = 100allow

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: Drupe action schema with Refund only (RefundOutput carries the settled amount).
  • trace.log: five refund requests by alice; the allowed ones settle with a matching response.
  • 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]