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

The Dogwood Guide

Complete documentation for the Dogwood policy language — its syntax, its temporal expressions and information providers, its schemas, and the Rust API for evaluating policies.

The guide is organized so the core language comes first, read through the lens of a fixed setup: the event schema, the information providers, and the macro library are all taken as given. The Advanced topics section then covers how each of those fixed inputs is built.

A. Introduction to the language

The core language, assuming the event schema, providers, and macros are given.

  • Introduction — what Dogwood is, the problem it solves, and the concepts you need. Read this first if you are new.
  • Getting started — your first schema, policy, and authorization, end to end, with runnable code.
  • The policy language — the core (Cedar-derived) syntax: the action schema (entity/action declarations and the context.input / context.output convention), permit/forbid, the (principal, action, resource) scope, when/unless conditions, and the full expression language.
  • Temporal expressions — the when temporal { … } sublanguage: reasoning about event history with formerly, previous, since, windows, exists, tp, and the count / sum aggregations.
  • Information providers — consulting values computed on demand: calling a provider as a plain Cedar call inside an ordinary when { … } clause, and how its output composes with a condition.
  • Calling macros — invoking def cedar and def temporal macros: where a call may appear and what shape its arguments take.

B. Advanced topics

Deep dives on the three fixed inputs the core language takes as given, plus MCP schema generation.

  • The event schema — the event-schema DSL (.dwschema): the four selectors, spreads, named fields, nested records, pins, decision kinds, and the default request/response schema.
  • The provider schema — declaring providers: the providers.json format, the Rhai implementation contract (sandbox, host functions, decimal, the net feature), output methods, no-implementation providers, and the guardrails { … } sugar.
  • Macros — defining macros: def cedar / def temporal, the two parameter sigils, hygiene, every rejection rule, and the macro library.
  • Generating the action schema from an MCP manifest — a Dogwood action schema is an MCP tool manifest; the manifest format, the JSON→Cedar type mapping, and the Drupe template.

C. Running Dogwood

  • The command line — the dogwood CLI: validate, replay, lower, check-parse, and the schema subcommands, driven over plain files. The quickest way to check a policy or watch a temporal policy decide across a trace, with no Rust.
  • The API and workflow — the Rust API reference and end-to-end workflow: ServiceSchema/PolicySchemaLoweredPolicySetValidatorAuthorizerEventResponse.

D. Reference

  • Formal specification — the precise reference: the grammars in BNF, the abstract syntax, and the lowering / validation / authorization rules, each cross-referenced to its source of record.

Runnable examples

Every policy-level example in this guide is a complete, runnable bundle under this crate’s examples/ directory — a policy.dw, its schema.cedarschema, and (for history-dependent examples) a trace.log plus the expected verdict stream, along with any providers.json / macros.dw / event schema the example needs. A test harness checks every bundle on each build (validating each policy, and replaying traces against the expected verdict stream), so a guide example that stops parsing, validating, or replaying as written is a build failure. To run one yourself, see The command line.

To embed the engine rather than drive it over files — building events programmatically and feeding them one at a time to a stateful Authorizer — use the Rust API, walked through end to end in The API and workflow.

Reading order

If you read straight through, this order builds naturally:

  1. Introduction
  2. Getting started
  3. The policy language
  4. Temporal expressions
  5. Information providers
  6. Calling macros

Then reach into the Advanced topics as you need them: the event schema, the provider schema, macros, and MCP schema generation. Integrate from Rust with The API and workflow, and consult the Formal specification as the reference.

Introduction to Dogwood

This page explains what Dogwood is, the problem it solves, and the concepts the rest of the guide assumes. It introduces no syntax, only the model.

What Dogwood is

Dogwood is a policy language for authorization decisions — deciding whether a given actor may perform a given action on a given resource. If you have seen Cedar, Dogwood will feel familiar: you write permit and forbid rules over a principal, an action, and a resource, and an authorizer answers Allow or Deny.

A Dogwood decision is not confined to what is known at a single point in time. Two capabilities extend it:

  • Accumulated history. Temporal expressions let a rule reason about the stream of past events — “permit this transfer only if the same user was approved within the last hour”, “deny reads after a logout until the next login”. The authorizer remembers what it has seen.
  • On-demand computed values. Information providers let a rule consult a value produced on demand by a small sandboxed script — “permit only if this document id matches an allowed pattern”, “deny if a risk score is elevated”.

Everything else — the schema, the operators, the tooling — exists to support writing, validating, and evaluating those rules safely.

The problem it solves

Ordinary point-in-time authorization answers “is this request allowed, in isolation?” That is not enough when the safe answer depends on what happened before or on a value computed during authorization. Consider the following guardrails around an AI agent’s tool calls:

  • “Allow SellShares only if there was an ApproveSale for the same stock in the last hour.” — a decision about history.
  • “Allow Read only if the document id looks like a public identifier.” — a decision about a computed property of the request.
  • “Deny everything for a user after they log out, until they log in again.” — again, history.

You could enforce this logic manually in application code, but it would be scattered, duplicated, and hard to reason about. Dogwood lets you express it declaratively, in one place, as policy — and it handles the history-tracking and value-computation for you.

The five concepts

There are five core concepts that underpin everything in Dogwood (and this documentation).

1. Policies

A policy is a permit or forbid rule. It has a scope (which principal, action, and resource it applies to) and optional conditions (when / unless clauses). The decision is default-deny: a request is allowed only if some permit matches and no forbid overrides it. This is the Cedar model, and it is the subject of The Policy Language.

2. Events

The authorizer evaluates events: a timestamped occurrence of an action, carrying named input fields. Each event has a kind, conventionally request for the invocation and response for the result, that determines its role in authorization. For authorization decisions, the event also includes a principal and a resource.

The kind matters: some kinds are decision points (they ask for a verdict — conventionally request), and others are history-only (they record something that happened — conventionally response). A history-only event updates what the temporal expressions can see but produces no verdict. This is why Dogwood’s authorizer is stateful: you feed it events one at a time, and each is remembered.

3. Schemas

A schema tells Dogwood about your world. Dogwood composes three schemas plus a macro library into one:

  • the action schema — your principals, resources, and actions (a Cedar .cedarschema);
  • the event schema — which event kinds each action produces and what fields they carry (a small Dogwood DSL, with a sensible default);
  • the provider declarations — the signatures and implementations of any information providers (JSON; empty by default);
  • the macro library — reusable named fragments of Cedar or temporal conditions (Dogwood source; a built-in standard library by default).

You write the action schema to describe your application, and it is the schema this introduction and the core language chapters assume — it is covered as part of The policy language. The other three have sensible defaults, so a plain policy needs only the action schema; the core chapters take them as given. When you do need to customize them, the Advanced topics cover each in depth: The event schema, The provider schema, and Macros.

4. Reaching beyond the current request

Inside a policy’s conditions, two constructs unlock Dogwood’s extra reach:

  • when temporal { … } — a condition over event history. See Temporal expressions.
  • an information-provider call in an ordinary when { … } — a condition that consults a value computed on demand. A provider is invoked as a plain Cedar call (Provider::Name(args)…); no special clause is required. See Information providers.

Both are ordinary conditions from the policy’s point of view; they evaluate against history or a computed value instead of only the current request. (You will also see macros — a way to name and reuse fragments of either; calling them is covered in Calling macros, and defining them in Macros.)

5. The authorizer

The authorizer is what you build from your policies and then feed events to. For each decision-kind event it returns a Response: the decision (Allow / Deny) and diagnostics (which rules determined it, and any evaluation errors). Evaluation is fail-closed at defined points — if evaluating the event history or building the request context cannot complete, the decision is Deny with the reason in diagnostics rather than risking a wrong Allow. Information providers are the exception: the language leaves an erroring provider undefined, and while this reference interpreter does deny, a policy must not rely on that (see Information providers). Driving the authorizer from Rust is the subject of The API and workflow.

How it fits together

        schema(s)                policy source (.dw)
            │                          │
            └───────────┬──────────────┘
                        ▼
                LoweredPolicySet      ← parsed + lowered (to Cedar, under the hood)
                        │
                        ▼
                   Authorizer         ← stateful; holds accumulated history
                        │
   events ────────────►│
   (one at a time)     ▼
                    Response          ← Allow / Deny + diagnostics
                  (per decision-kind event)

Under the hood, Dogwood lowers policies to Cedar and evaluates with the Cedar engine — the temporal and provider clauses are compiled into extra context that Cedar then sees. You do not need to know this to use Dogwood, but it is why the API mirrors Cedar’s.

Where to go next

See also

Getting Started

This page walks you through your first Dogwood authorization end to end — a schema, a policy, and the Rust code that decides a request. It starts with the simplest possible policy and then adds a decision that depends on history. The two policies here are the permit_read_anyone and read_after_login example bundles, checked on every build, so they do validate and replay as shown.

If you have not read the introduction, skim it first — it explains the five concepts (policies, events, schemas, temporal expressions and information providers, and the authorizer) this tutorial puts into practice.

The shape of the workflow

Every use of Dogwood runs the same pipeline:

  • Build the two schema halves — a ServiceSchema (macros, providers, and the event-schema DSL — the fixed, service-provided inputs) and a PolicySchema (your action schema).
  • Parse and lower a LoweredPolicySet from your policy source, against those two schemas.
  • (Optionally) validate the policy set with a Validator.
  • Build an Authorizer and feed it events, getting a Response back.

The tutorial below writes the schema and policy first, then runs that whole pipeline in Step 3.

Step 1 — a schema

The schema declares the entity types and actions in your world. It is a standard Cedar .cedarschema. Here is a minimal one for an agent that can Login and Read, each taking a user input field:

namespace Drupe {
  type LoginInput = { user: String };
  type ReadInput = { user: String };
  entity Gateway;
  entity OAuthUser = { id: String } tags String;
  action "Login" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: LoginInput }
  };
  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: ReadInput }
  };
}

Two things to notice, both Dogwood conventions covered in The policy language:

  • Each action’s parameters live under a context.input record (context: { input: ReadInput }). This is where a policy reads the request’s fields from (context.input.user).
  • We only wrote the action schema. Dogwood’s other two schemas — the event schema and provider declarations — have sensible defaults, so a simple policy needs nothing more.

Step 2 — a policy

A policy is a permit or forbid rule. The simplest useful one: permit Read for anyone, on any resource.

permit (
    principal,
    action == Drupe::Action::"Read",
    resource
);

Runnable: examples/permit_read_anyone/dogwood validate and dogwood replay.

  • permit is the effect. (forbid is the other; a forbid always wins over a permit.)
  • The parenthesized part is the scope: bare principal and resource mean “any”, and action == Drupe::Action::"Read" restricts this rule to the Read action.
  • No when clause means no extra condition — this rule applies whenever its scope matches.

The full policy syntax — scope constraints, when/unless conditions, the whole expression language — is The policy language.

Step 3 — decide, from Rust

Here is the whole pipeline in Rust. Build the schema, parse the policy, validate, then authorize one event:

#![allow(unused)]
fn main() {
use dogwood_language::{
    Authorizer, Decision, Event, LoweredPolicySet, PolicySchema, ServiceSchema, Validator, Value,
};

// (SCHEMA and POLICY are the strings from steps 1 and 2.)
// The service half takes its defaults (no macros/providers/event schema here);
// the policy half is your action schema.
let service = ServiceSchema::defaults();
let policy_schema = PolicySchema::from_cedarschema_str(SCHEMA)?;
let policies = LoweredPolicySet::from_str(POLICY, &service, &policy_schema)?;

// Optional but recommended: type-check the policy against the schema.
// The validator takes no schema — the one the policy set was lowered
// against already travels on the `LoweredPolicySet`.
let report = Validator::new().validate(&policies);
assert!(report.validation_passed());

// Build the authorizer and ask it about one Read request.
let mut authorizer = Authorizer::new(policies);
let event = Event::builder("Drupe::Action::Read", "request")
    .principal("Drupe::OAuthUser::\"alice\"")
    .resource("Drupe::Gateway::\"gw1\"")
    .field("input", "user", Value::String("alice".to_string()))
    .build();

if let Some(response) = authorizer.is_authorized(&event) {
    assert_eq!(response.decision(), Decision::Allow);
}
}

Three details of Dogwood’s model are worth noting here:

  • You authorize an Event, not a bare request. An event is a timestamped occurrence of an action with a kind (here "request") plus the principal/resource and input fields. Event::builder constructs one.
  • is_authorized returns Option<Response>. You get Some(response) for a decision-kind event (like request) and None for a history-only event. With the default event schema, request is a decision kind — so this call returns Some.
  • The Authorizer is &mut. It is stateful: it remembers every event you feed it. That does not matter for this pure-Cedar policy, but the next step depends on it.

The full API is The API and workflow.

Step 4 — a decision that depends on history

The next requirement depends on the past. Change it to: permit Read only if the same user logged in within the last hour. That is a statement about the past, so it uses a when temporal { … } clause:

permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
};

Runnable: examples/read_after_login/dogwood validate and dogwood replay.

Read the temporal clause as: “there was formerly, within the last 1 hour, a successful Login whose input.user equals this request’s context.input.user.” The formerly within 1h … operator scans the event history; the { input.user: context.input.user } part correlates the past login’s user with the current request’s user. This is the subject of Temporal expressions.

Because the decision now depends on history, we feed the authorizer a stream of events and watch the verdicts change over time:

#![allow(unused)]
fn main() {
let mut authorizer = Authorizer::new(policies);

let events = vec![
    // A Login at t=0.
    Event::builder("Drupe::Action::Login", "request")
        .timestamp(0)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
    // A Read at t=10 — 10s after the login, well within the 1h window.
    Event::builder("Drupe::Action::Read", "request")
        .timestamp(10)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
    // A Read at t=7200 — two hours later; the login has expired.
    Event::builder("Drupe::Action::Read", "request")
        .timestamp(7200)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
];

for event in &events {
    if let Some(response) = authorizer.is_authorized(event) {
        println!("@{:<5} {:?}", event.timestamp(), response.decision());
    }
}
}

This prints:

@0     Deny
@10    Allow
@7200  Deny
  • @0 Login → Deny. The policy gates Read, not Login, so no permit matches the login itself. (The login still matters — it is now in the history.)
  • @10 Read → Allow. A login for alice happened 10 seconds ago, inside the 1-hour window, so the temporal condition holds.
  • @7200 Read → Deny. The only login was 7200 seconds (2 hours) ago, outside the window, so the condition no longer holds.

Same policy, same code — the verdict changes because the history changed.

Run it yourself

You have two ways to run Dogwood, and this guide uses both.

The command line. The dogwood CLI drives the whole pipeline over plain files — no Rust needed. Save the policy above to policy.dw and its schema to schema.cedarschema, then validate and replay a trace:

dogwood validate policy.dw --policy-schema schema.cedarschema
dogwood replay   policy.dw --policy-schema schema.cedarschema --trace trace.log

Every policy-level example in this guide is a complete, runnable bundle under this crate’s examples/ directory — a policy.dw, its schema.cedarschema, and (where the example is history-dependent) a trace.log and the expected verdict stream. A test harness checks every bundle on each build, so the examples cannot drift from the language. The two policies above are the permit_read_anyone and read_after_login bundles. See The command line for the full CLI.

The library. To embed the engine instead of driving it over files — building events programmatically, feeding them one at a time, and reading each Response — use the Rust API, walked through end to end in The API and workflow. The CLI cannot drive the builder API.

Where to go next

See also

The Policy Language

This page documents the core Dogwood policy language — the Cedar-derived surface syntax you write in a .dw file. It covers the anatomy of a policy rule (permit/forbid), the (principal, action, resource) scope and every constraint form it accepts, when/unless condition clauses, and the complete condition-expression language: every operator, literal, type, built-in method, set/record, and entity reference.

Every policy example on this page is a runnable bundle under examples/, checked on every build.

Dogwood’s core language is an intentional re-implementation of upstream Cedar (the grammar is a faithful translation of cedar-policy-core v4.11.0), so its syntax follows Cedar’s. What Dogwood adds on top is the temporal { … } marker clause — which hands off to a dedicated sub-language — plus a thin guardrails { … } clause that is sugar for a bare when (it invokes information providers, which are ordinary Cedar calls). This page only names these forms and tells you where they attach; their contents are documented separately (see the See also list).

How to read this page: the grammar is deliberately permissive, and the real semantics are enforced by the parser afterward. That means a number of things parse but are then rejected with an error. Wherever that matters, this page tells you the real, current behavior rather than what the grammar alone might suggest.

Before the syntax, one thing a policy always assumes: an action schema that declares the entities and actions a policy scopes over, and the context shape a policy reads from. That comes first.


The action schema

Every policy authorizes against an action schema: the declaration of your world — the entity types (principals, resources) and the actions (tools, operations) a policy may name — plus the context each action carries. It is a standard Cedar schema (.cedarschema). Dogwood parses it with Cedar’s own parser and adds no new schema syntax — it is Cedar .cedarschema verbatim. What Dogwood adds is a convention about how you lay out the context record, covered below.

(The action schema is the one schema you always write. Dogwood composes two more schemas — the event schema and the provider declarations — plus a macro library, and all three have defaults, so the core language takes them as given; see The event schema, The provider schema, and Macros when you need to customize them. You can also generate an action schema from an MCP tool manifest — see Generating the action schema from an MCP manifest.)

A real action schema

Here is a trimmed but complete schema from the tested corpus (case 1113). It declares two tools, Login and Read:

namespace Drupe {
  type LoginInput  = { user: String };
  type LoginOutput = { };
  type ReadInput   = { document: String, user: String };
  type ReadOutput  = { };
  type SystemContext = { now: datetime };

  entity Gateway;
  entity OAuthUser = { id: String };

  action "Login" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };
}

The pieces:

  • namespace Drupe { … } is the namespace your actions live under. Dogwood’s derivation appends the literal segment Action to the namespace path, matching Cedar’s rule that all actions live under an implicit Action entity type. So the Login action is written Drupe::Action::"Login" in policies and traces.
  • entity declarations (OAuthUser, Gateway) are the principal and resource types. They may carry attributes (= { id: String }) and, in the fuller template, tags (e.g. entity OAuthUser = { id: String } tags String;).
  • type declarations are Cedar common types — reusable record types (LoginInput, ReadOutput, …) that the actions reference as their input / output records. Common types may be cross-namespace (Shared::Addr) or chained (type Outer = Inner;); Dogwood resolves both.
  • action "<Id>" appliesTo { principal, resource, context } declares one action per tool or operation. Actions may also sit in a group hierarchy with in [...] — for example the corpus schema at case 0407 has CallTool in [Action::"Mcp"] and Login in [Action::"CallTool"], which is exactly the shape the MCP generator produces (see Generating the action schema from an MCP manifest).

The context.input / context.output convention

appliesTo.context is an ordinary Cedar record, but Dogwood expects a specific layout so a policy — and the event stream — can find a tool’s arguments and results by a stable path:

  • input: <Record> — the tool’s arguments. A policy reads them as context.input.<field> (e.g. context.input.stock).
  • output?: <Record> — the tool’s result. Optional (usually present only after the action resolves), read as context.output.<field>.
  • system: SystemContext — the base context every action carries ({ now: datetime } in the Drupe template), read as context.system.now.

This is only a convention: nothing in Cedar forces it. A policy references these fields the same way it references any Cedar record — for instance context.input.stock == "AMZN".

The input / output grouping also avoids a name collision. Because inputs nest under an input group and outputs under an output group, an action whose input and output both declare a field named x produces two distinct leaves, input.x and output.x, with possibly different types — something a flat context could not represent.

Rule of thumb: put a tool’s arguments under context.input and its result under context.output. (This is also what the event schema’s spread selectors read when deriving event fields; see The event schema.)


Policy anatomy

A .dw file is a sequence of top-level items — macro definitions and policy rules — freely interleaved, each terminated by a semicolon (;). Comments are line-comments introduced by // and run to the end of the line; there are no block comments. By convention every policy in the corpus opens with a // doc comment describing its intent.

Here is the simplest possible policy — a single permit with no extra conditions:

// The simplest possible Dogwood policy: a single permit with
// no further condition.
permit ( principal, action == Drupe::Action::"GetStockInfo", resource );

Runnable: examples/simplest_permit/dogwood validate.

Every policy rule has the same five-part shape, in this order:

  1. Zero or more annotations (@id("…"))
  2. An effect (permit or forbid)
  3. A parenthesized scope triple ( principal, action, resource )
  4. Zero or more condition clauses (when { … } / unless { … })
  5. A terminating semicolon
@id("sell_small_only")                                  // (1) annotation
permit (                                                // (2) effect
    principal,                                          // (3) scope
    action == Drupe::Action::"SellShares",
    resource
)
when { context.input.shares <= 50 };                    // (4) condition, (5) terminator

Runnable: examples/sell_small_only/dogwood validate and dogwood replay.

The sections below take each part in turn.

Effect: permit and forbid

The effect is the first keyword of the rule and must be exactly permit or forbid. Anything else is rejected with policy effect must be permitorforbid, found {other}``.

Dogwood evaluates a request under deny-overrides with default-deny semantics:

  • A request is allowed if and only if at least one permit rule matches and no forbid rule matches.
  • If no rule matches at all, the default decision is deny.

Because forbid always wins, source order does not matter — a forbid “carves a hole” out of whatever the permit rules allow, no matter where it appears in the file. This pair permits selling shares generally, but forbids selling AMZN:

@id("permit-sell-shares")
permit ( principal, action == Drupe::Action::"SellShares", resource );

@id("forbid-sell-amzn")
forbid ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.stock == "AMZN" };

Runnable: examples/deny_overrides_sell_not_amzn/dogwood validate and dogwood replay.

Annotations: @key("value")

An annotation attaches metadata to a rule. It is written @ followed by an identifier key, optionally followed by a parenthesized string value:

@id("sell_small_only")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.shares <= 50 };

The value string is optional, so both @id("x") and a bare @reviewed are legal. A rule may carry any number of annotations. Annotations are purely for diagnostics and reporting (the @id key names a rule so tools can refer to it) — they never change whether a rule matches.

Scope and conditions

The scope triple and the condition clauses are where all the matching logic lives; they each get their own full section below (The scope triple and Condition clauses). The terminator is the ; that ends every top-level item.


The scope triple

Every policy’s scope is a parenthesized list of up to three variables: principal, action, resource. These are the three fixed dimensions of an authorization request — who is acting, what they are doing, and what they are acting on. The scope is the fast, coarse filter: a rule can only possibly apply to requests whose principal, action, and resource all satisfy the scope constraints. A trailing comma after the last variable is allowed.

The variables may appear in any order, and any subset may be omitted — an omitted variable is treated as unconstrained (matches everything). The following are all valid:

permit ( principal, action, resource );          // all three, standard order
permit ( resource, action, principal );          // any order
permit ( principal, action );                    // resource omitted → any resource
permit ( );                                     // all omitted → matches everything

Each variable may appear at most once — duplicates are rejected with duplicate \principal` in scope. If all three are present and a fourth element appears, the error is this policy has an extra element in the scope: `{other}`. If a variable name is unrecognized before all three are seen, the error is unexpected scope variable `{other}`; expected `principal`, `action`, or `resource`. Each of the three slots may be left **unconstrained** or given exactly one constraint. The available constraint forms differ slightly between principal/resource(which behave identically) andaction` (which is more restrictive).

Unconstrained (bare) — matches everything

A bare variable name with no operator imposes no constraint on that dimension. A rule with all three bare matches every request:

@id("allow_anything")
permit (
    principal,
    action,
    resource
);

Runnable: examples/allow_anything/dogwood validate.

== — equality to a specific entity

== EntityRef requires the dimension to be exactly that entity. On principal and resource the right-hand side must be an entity reference (Ns::Type::"id") or a template slot; on action it must be an action reference (no slots allowed):

permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
);

Runnable: examples/sell_shares_eq_scope/dogwood validate.

If the operand is not a valid entity reference you get expected an entity reference (`Ns::Type::"id"`) or a template slot (`?principal`) (for principal/resource) or action scope expects an action reference (Ns::Action::“id”) (for action).

in — membership in a group or hierarchy

in tests membership in an entity hierarchy (a group, a parent entity, etc.). On principal and resource the right-hand side is a single entity reference or slot. On action — and only on actionin may take a list of action references, meaning “any of these actions”:

permit (
    principal,
    action in [Drupe::Action::"SellShares", Drupe::Action::"ApproveSale"],
    resource
);

Runnable: examples/sell_or_approve_action_in/dogwood validate.

A single action reference is also accepted after action in (i.e. the list brackets are optional for one element).

is Type and is Type in Group — entity-type test

is Type matches only when the dimension’s entity is of the named entity type; is Type in Group additionally requires membership in a group. This applies to principal and resource:

permit (
    principal is Drupe::OAuthUser,
    action == Drupe::Action::"GetStockInfo",
    resource
);

Runnable: examples/principal_is_oauth/dogwood validate.

To combine both, write the type test first and the group after in:

permit (
    principal is Drupe::OAuthUser in Drupe::Team::"traders",
    action == Drupe::Action::"GetStockInfo",
    resource
);

Runnable: examples/traders_is_in_group_scope/dogwood validate.

Two restrictions to know:

  • action is Type is invalid — the is form is not allowed on the action slot. You will get `action is Type` is not valid in the action scope.
  • Only is Type in … may follow an is test. Writing is Type == … is rejected with `is Type {op} …` is not valid; only `is Type in …` is allowed.

What the scope rejects

The scope grammar is permissive but the parser only accepts == and in (plus the is/is-in forms above). Everything else is a parse-then-error:

  • The legacy colon form principal : User is not supported — use principal is User. The error is the `principal : Type` scope form is not supported; use `principal is Type`.
  • Writing = gets a targeted hint: `=` is not a valid operator in this scope; did you mean `==`?.
  • Any other operator in a scope gives scope only allows `==` or `in`, found `{other}`.

Condition clauses

The scope is a coarse filter; condition clauses express the fine-grained logic. A rule may carry any number of when and unless clauses, in any combination and order. They are implicitly conjoined: the rule fires if and only if every when body evaluates true and no unless body evaluates true. Put another way, unless { B } is exactly sugar for when { !B }.

Each clause is a keyword (when or unless) followed by a braced Cedar expression { … }, optionally preceded by the temporal marker or the guardrails tag (temporal { … } / guardrails { … }, covered at the end of this section). A condition keyword other than when/unless is rejected with condition keyword must be `when` or `unless`, found `{other}`.

when { … }

A when clause must hold for the rule to fire. Here we only permit selling under 100 shares:

permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    context.input.shares < 100
};

Runnable: examples/sell_when_under_100/dogwood validate.

unless { … }

An unless clause blocks the rule when its body holds. Here we permit selling unless the order is enormous:

permit ( principal, action == Drupe::Action::"SellShares", resource )
unless {
    context.input.shares > 10000
};

Runnable: examples/sell_unless_huge/dogwood validate.

Multiple clauses on one rule

Because clauses are conjoined, you can stack them for readability instead of writing one giant &&. Two when clauses both must hold:

permit( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.shares < 100 }
when { context.input.stock == "AMZN" };

Runnable: examples/sell_two_when_small_amzn/dogwood validate.

You can freely mix when and unless on the same rule:

permit ( principal, action == Drupe::Action::"SellShares", resource )
when   { context.input.shares <= 1000 }
unless { context.input.stock == "BLOCKED" };

Runnable: examples/sell_when_unless_mix/dogwood validate.

The same applies to forbid rules — this forbids large sells except for AMZN:

forbid ( principal, action == Drupe::Action::"SellShares", resource )
when   { context.input.shares > 100 }
unless { context.input.stock == "AMZN" };

Runnable: examples/forbid_large_except_amzn/dogwood validate and dogwood replay.

The temporal { … } marker and the guardrails { … } clause

Dogwood extends Cedar with two clause forms beyond a bare when { … }:

  • temporal { … } — a genuine marker into a dedicated sub-language for temporal (history-aware) expressions. See Temporal expressions.
  • guardrails { … }not a sub-language: guardrails { E } is transparent sugar for a bare when { E }, where E is ordinary Cedar. An information provider is invoked as a plain Cedar call (Provider::Name(args)…), recognized and hoisted at lowering — it needs no marker, and works in a bare when too. The tag is retained only for surface compatibility. See Information providers.

The temporal marker can appear in two places. First, as an entire clause body (a full permit combining both a when temporal and a when guardrails clause is runnable at examples/sell_after_approval_valid_ticker/):

when temporal {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.approver }
}
when guardrails {
    Strings::Matches(context.input.request_id, "^REQ-[0-9]+$").matched == true
};

Second, a temporal marker is also a primary expression, so it may appear inside an ordinary Cedar expression (a full permit of this shape is runnable at examples/sell_shares_temporal_subexpr/):

when { context.input.shares > 5 && temporal { /* … */ } }

Both unless temporal { … } and unless guardrails { … } are equally valid. The temporal marker’s braced contents are out of scope for this page — see the linked docs. All this page records is that these forms exist and where they attach.


The condition expression language

Everything inside a when { … } / unless { … } body (and inside a def cedar macro body) is a Cedar expression. The expression grammar is a strict precedence tower — from loosest to tightest binding: if/then/else||&& → relational (<, has, like, is, …) → +/-* → unary !/- → member access → primary. All binary operator chains associate to the left. The subsections below walk the tower from the top.

if / then / else

if C then A else B is an expression (not a statement), so it produces a value and can appear anywhere a value is expected. Both branches must produce the same type.

At the top level of a when, it reads like a conditional rule (runnable as a full rule at examples/sell_threshold_by_stock/):

when {
    if context.input.stock == "AMZN"
    then context.input.shares <= 10
    else context.input.shares <= 1000
};

Because it is an expression, you can nest it and use it as an operand — here to pick a per-stock threshold (runnable as a full rule at examples/sell_nested_if_threshold/):

when {
    context.input.shares <=
        (if context.input.stock == "AMZN" then 10
         else if context.input.stock == "MSFT" then 50
         else 1000)
};

A common idiom pairs if with has (see has) to guard an optional field, falling back to false when the field is absent (runnable as a full rule at examples/sell_zero_proceeds_if_has/):

when {
    if context has output
    then context.output.proceeds == decimal("0.0")
    else false
};

Logical operators: ||, &&, !

|| (or) and && (and) are the boolean connectives; ! is boolean negation (a unary prefix, covered under arithmetic and unary operators). && binds tighter than ||, so parenthesize when you want the other grouping (runnable as a full rule at examples/sell_logical_grouping/):

when {
    (context.input.shares < 100 || context.input.stock == "AMZN")
    && !(context.input.stock == "BLOCKED")
};

Comparison and relational operators

The relational level covers ordinary comparison operators plus the keyword operators has, like, and is. The comparison operator set is:

OperatorMeaning
<less than
<=less than or equal
>greater than
>=greater than or equal
==equal
!=not equal
inentity-hierarchy membership

Comparisons chain and fold left, so you can write several in a single && conjunction (runnable as a full rule at examples/sell_comparison_chain/):

when {
    context.input.shares >= 1
    && context.input.shares <= 1000
    && context.input.shares != 777
};

Note there is no = operator — writing = is rejected with `=` is not a valid operator in this scope; did you mean `==`?.

in is not only a scope keyword; it is also an expression operator that tests entity-hierarchy membership, e.g. principalGroup in someParent.

Which operators apply to which type matters, and is checked by the validator downstream (not by the parser). The rules of thumb:

  • Long (integer) supports the full ordered set: <, <=, >, >=, ==, !=.

  • String supports == and != (and like, below). Ordered comparison is not meaningful. For example: when { context.input.stock != "BLOCKED" }; (runnable as a full rule at examples/sell_not_blocked_string/).

  • Bool is compared with == true / == false.

  • Decimal supports equality only (== / !=). Ordered comparison on decimals does not type-check — use the decimal methods (.lessThan, etc.) instead (runnable as a full rule at examples/sell_nonzero_proceeds_decimal/):

    when { context has output && context.output.proceeds != decimal("0.0") };
    
  • Datetime supports the full ordered set, so you can express time windows directly (runnable as a full rule at examples/sell_datetime_window/):

    when {
        context.system.now >= datetime("2025-01-01T00:00:00Z")
        && context.system.now <  datetime("2026-01-01T00:00:00Z")
    };
    

Arithmetic and unary operators

Dogwood supports integer addition and subtraction (+, -) and multiplication (*):

  • + and - are the additive operators.
  • * is the only multiplicative operator that is accepted. Division (/) and modulo (%) parse but are rejected with `{other}` is not a supported operator.

There are two unary prefixes:

  • ! is boolean negation (UnaryOp::Not).
  • - is arithmetic negation (UnaryOp::Neg).

A prefix may be a run of the same symbol (!!x, --x), but you cannot mix them — !-x and -!x do not parse (neither ! nor - can begin the value that the other prefix would apply to). The ! prefix is what you saw above in !(context.input.stock == "BLOCKED").

One subtlety about negative integer literals: -N folds directly into a negative Long value. The one special case is 9223372036854775808 (2^63): a bare 2^63 overflows i64 and is rejected, but -9223372036854775808 is stored exactly as i64::MIN, so the most-negative integer is representable via negation.

Datetime literals compare with the ordinary comparison operators (there is no +/- arithmetic on datetimes at the operator level — use the .offset / .durationSince methods for that, see method calls) — runnable as a full rule at examples/sell_after_2024_datetime/:

when { context.system.now > datetime("2024-01-01T00:00:00Z") };

Member access: attributes, indexing, and calls

After a primary expression you can chain member accessors. There are three forms.

Attribute access e.attr reads a field. Chained attribute access is the common form in Dogwood conditions:

context.input.shares
context.output.approved
context.system.now

context is the request context; its input / output / system layout follows the convention above, with the exact shape of context.input determined by the rule’s action scope.

Index access e["key"] reads a field by string key and is equivalent to attribute access. Cedar requires the key to be a string literal — a dynamic index is rejected with index access requires a string-literal key, e.g. record[“field”]``:

context.output.categories["VIOLENCE"]

Call syntax e(args) is only meaningful on a bare name (an extension function or macro call) or after a .method (method call). A call applied to anything else is rejected with unexpected call: only extension functions and methods can be called.

has — attribute existence

e has attr tests whether an optional attribute is present, returning a boolean. It is the guard you use before reading a field that might not exist. The right-hand side may be a dotted path (has a.b.c), a string-literal name (has "attr"), or (per Cedar RFC 62) the reserved word if used as an attribute name (has if.x). This guard-then-read pattern is runnable as a full rule at examples/approve_has_output_guard/:

when {
    context has output && context.output.approved == true
};

Because && short-circuits, the has guard on the left protects the attribute read on the right. The if … then … else false variant of this pattern was shown under if/then/else.

like — string pattern matching

s like "pattern" matches a string against a wildcard pattern. The right-hand side must be a string literal. Inside the pattern, * matches any number of characters, \* matches a literal star, and the usual escapes (including \u{HEX}) are supported (runnable as a full rule at examples/sell_like_a_prefix/):

when { context.input.stock like "A*" };

A common denylist idiom uses like under unless to reject a family of values (runnable as a full rule at examples/sell_not_test_tickers_like/):

unless { context.input.stock like "TEST_*" };

is — entity-type test in a condition

e is Type tests whether an entity value has the given entity type, and the optional is Type in group additionally checks hierarchy membership. This is the expression-level counterpart of the is scope constraint (runnable as a full rule at examples/cond_is_oauth_in_team/):

principal is Drupe::OAuthUser in Drupe::Team::"traders"

The first operand after is is read as an entity type (not a value); the operand after in is a value expression.

Extension functions: decimal, datetime, duration, ip

A bare name immediately followed by (args) is an extension-function call. Dogwood recognizes four built-in constructors, each taking exactly one string argument, used to build the non-primitive literal types:

CallTypeMeaning
decimal("…")decimalfixed-point decimal literal
datetime("…")datetimeISO-8601 datetime literal
duration("…")durationduration literal
ip("…")ipaddrIP address / CIDR literal

Examples:

context.output.proceeds == decimal("0.0")
context.system.now > datetime("2024-01-01T00:00:00Z")
duration("1h30m")
ip("10.0.0.0/24")

Any other name-with-args (not one of these four) is treated as a macro call and is resolved during macro expansion (see Macros); an unresolved call is an error.

Method calls

A .method(args) after a receiver expression is a method call. Methods come in two arities.

Zero-argument methods (receiver.method()):

MethodDomainMeaning
.isEmpty()setset is empty
.isIpv4()ipaddraddress is IPv4
.isIpv6()ipaddraddress is IPv6
.isLoopback()ipaddraddress is loopback
.isMulticast()ipaddraddress is multicast
.toDate()datetimedrop the time-of-day
.toTime()datetimetime-of-day component
.toMilliseconds()durationduration as milliseconds
.toSeconds()durationduration as seconds
.toMinutes()durationduration as minutes
.toHours()durationduration as hours
.toDays()durationduration as days

One-argument methods (receiver.method(arg)):

MethodDomainMeaning
.contains(x)setset contains element x
.containsAll(s)setset ⊇ set s
.containsAny(s)setset ∩ set s is non-empty
.getTag(k)entityread entity tag k
.hasTag(k)entityentity has tag k
.isInRange(cidr)ipaddraddress is within CIDR
.offset(d)datetimedatetime + duration
.durationSince(t)datetimedatetime − datetime
.lessThan(d)decimaldecimal <
.lessThanOrEqual(d)decimaldecimal <=
.greaterThan(d)decimaldecimal >
.greaterThanOrEqual(d)decimaldecimal >=

Note the decimal comparison methods — since </<=/>/>= do not type-check on decimals, these methods are how you order decimals (a full rule using .lessThan on a decimal output field is runnable at examples/sell_small_proceeds_decimal_method/):

context.output.severityScore.lessThan(decimal("0.5"))
context.input.tags.contains("approved")
ip("192.168.1.5").isInRange(ip("192.168.0.0/16"))

Calling a method with the wrong number of arguments is rejected (`{m}` takes no arguments… / `{m}` takes exactly one argument…), and an unrecognized method name gives unknown method `{other}`.

Primary expressions: literals, variables, grouping, sets, records

At the base of the tower, a primary is one of: a dialect marker, a literal, a template slot, an entity reference, a variable name, a parenthesized expression, a set literal, or a record literal.

Variable names. A bare name in value position must resolve to one of the four request variables: principal, action, resource, or context. Any other bare identifier is an error (`{other}` is not a valid variable).

Parenthesized expressions group to override precedence, as shown earlier: (context.input.shares < 100 || context.input.stock == "AMZN").

Set literals are square-bracketed, comma-separated expression lists (a trailing comma is allowed). They are what an action in […] scope list uses, and they are also ordinary values you can test with the set methods:

["VIOLENCE", "HATE"]

Record literals are brace-delimited key: value pairs. Keys must be string literals or bare identifiers; as a special case the reserved word if is allowed as a key:

{ label: "review", count: 3, if: true }

Literals and types

The atomic literal forms are:

LiteralTypeNotes
true / falseBoolboolean constants
42, 1000Long64-bit signed integer; positive at the token level (- is a unary op)
"…"Stringdouble-quoted

Beyond these three primitive literals, the non-primitive types — decimal, datetime, duration, and IP address — are constructed with the extension functions (decimal("…"), datetime("…"), duration("…"), ip("…")) covered above. Sets and records are built with [ … ] and { … }, and entity references with the Ns::Type::"id" form covered next.

Integer range. An integer literal is parsed as a 64-bit value. The only literal that does not fit a signed i64 is 9223372036854775808 (2^63): a bare 2^63 is out of range and rejected (integer literal `…` is out of range), but -9223372036854775808 is exactly i64::MIN.

String escapes. Strings (and like patterns) support the escapes \n \t \r \0 \\ \" \' \* plus braced unicode \u{HEX}:

"line1\nline2"
"a literal quote: \""
"a\u{2764}b"

Entity references: Ns::Type::"id"

An entity reference names a specific entity by type and id, using one or more ::- separated name segments followed by ::"id". This is how you refer to actions, users, groups, and any other entity:

Drupe::Action::"SellShares"     // an action
Drupe::OAuthUser                // just an entity type (no id) — used with `is`
Drupe::Team::"traders"          // a group entity

Entity ids are ordinary strings and may contain escaped or special characters, e.g. Drupe::Grant_Input_role::"o'admin".

Note that the Type::{ … } entity-initializer syntax (with a record body) parses but is rejected: entity initializer syntax `Type::{ … }` is not supported. Use the Type::"id" form.

Template slots: ?principal and ?resource

Dogwood supports Cedar template slots, ?principal and ?resource, which act as placeholders in principal/resource scope operands (they are not allowed in an action scope):

permit ( principal == ?principal, action, resource in ?resource );

A ?name that is not ?principal or ?resource is a macro parameter reference, which is only legal inside a macro body (see Macros); using one anywhere else is rejected during macro expansion.


What parses but is rejected

Because the grammar is deliberately permissive and the parser enforces the real rules afterward, several constructs look syntactically plausible but are always rejected. Do not use these:

  • = anywhere an operator is expected — rejected with a did you mean ==? hint. There is no assignment operator and no single-= comparison.
  • / and % — division and modulo parse but are rejected (`{other}` is not a supported operator). Only +, -, and * are supported.
  • The colon scope form principal : Type — rejected; use principal is Type.
  • action is Type in a scope — the is form is not valid on the action slot.
  • Type::{ … } entity-initializer syntax — parses but is not supported.
  • Ordered comparison on decimals (<, <=, >, >=) does not type-check — use the decimal comparison methods. Equality (== / !=) does work.
  • Integer 2^63 as a bare literal — out of range (only reachable via negation).

The temporal hand-off and the guardrails sugar

To recap the boundary of this page: the temporal marker hands off to a separate sub-language, while guardrails does not.

  • temporal { … } — history-aware conditions in a dedicated grammar. Appears either as a whole clause body (when temporal { … }) or as a primary inside a larger expression. Its braced contents are documented in Temporal expressions.
  • guardrails { … } — sugar for a bare when { … } (the tag carries no semantics); its body is ordinary Cedar. Information providers such as content-safety checks are invoked as plain Cedar calls (Provider::Name(args)…) — the surface tag keyword is guardrails, the concept is “provider” everywhere else. See Information providers.

Similarly, macro definitions (def cedar name(?p) { … } and def temporal …) let you name and reuse expressions. A def cedar body is an ordinary core expression (everything on this page applies), while a def temporal body is a temporal expression. Macro authoring — definition syntax, parameters, expansion, and the default macro standard library — is covered in Macros.


See also

  • Introduction — what Dogwood is and how the pieces fit together.
  • Getting started — write and evaluate your first policy.
  • The event schema — the event-kind DSL, and how the context.input/output records above become event fields.
  • Temporal expressions — the temporal { … } sub-language.
  • Information providers — providers as plain Cedar calls, and the guardrails { … } sugar clause.
  • Macrosdef cedar / def temporal definitions and expansion.
  • API and workflow — parsing, compiling, and evaluating policies from Rust.

The Event Schema

This is an Advanced-topics deep dive on Dogwood’s event-schema DSL. The core guide takes the event schema as given — the default schema that ships out of the box, which declares three event kinds: request, response, and error. The examples in this guide use only request and response. This page is for authors who need to customize the event model: which event kinds exist, what fields each kind carries, and which kinds are decision points that trigger an authorization decision. Everything here is optional; omit an event schema entirely and Dogwood uses the default described at the end.

The event schema DSL (.dwschema)

The event schema is a generic template that describes how to derive event signatures from any action schema. It is a sequence of event declarations; each one names a kind of event (request, response, …) for a symbolic action <A> and lists the fields that kind carries. Parsing the event schema never looks at your action schema — the two are bound together in a later derivation pass. That is what lets a single event schema (like the default) serve every application.

The shape of a declaration

Every declaration has the form:

[decision] event <A>::kind {
    field,
    field,
    ...
}
  • <A> — the binder. A symbolic name meaning “any action”. Every selector inside the body must reference this exact binder. Writing ...inputs(B) inside event <A>::… is a parse error: selector argument \B` does not name the declared action binder `A``.
  • kind — the event kind. An author-chosen name such as request, response, attempt, outcome, or audit. Kind names are not reserved; you pick them.
  • The angle brackets and body braces are mandatory. event A::r { } (no <>) and event <A>::r (no body) are both parse errors. An empty body is fine: event <A>::ping {} declares a kind with zero fields.
  • decision prefix marks the kind as a decision point (see Decision kinds).

Comments use // to end of line; a trailing comma after the last field is allowed.

The four selectors

Selectors are how a declaration reaches into the bound action. There are exactly four, and each has a fixed role:

SelectorSpread as ...sel(A)?Used as a field type sel(A)?Yields
inputsyes → an input groupno (error)the context.input field record
outputsyes → an output groupno (error)the context.output field record
principalTypeno (error)yesthe action’s appliesTo principal type set
resourceTypeno (error)yesthe action’s appliesTo resource type set

inputs and outputs yield a record of fields, so they are meant to be spread. principalType and resourceType yield entity types, so they are meant to be used as a field type. Using one the wrong way is a derive error with a message that tells you which form to use instead — e.g. spreading principalType reports “cannot be spread (it yields entity types, not a field record); use it as a field type instead.”

Spread selectors: ...inputs(A) and ...outputs(A)

A spread splices every field the selector yields into the event, under a group named for the selector:

  • ...inputs(A) mints a group named input whose members are the fields of the action’s context.input record. So a tool with input: { user: String } contributes the leaf input.user.
  • ...outputs(A) mints a group named output from context.output.

Two rules keep spreads unambiguous:

  • The group name (input / output) must be unique in the declaration. It may not collide with a named field of the same name, nor may you spread the same selector twice.
  • A spread always mints its own group, even nested inside a named record. Writing meta: { ...inputs(A) } produces meta.input.user, not meta.user.

Named (injected) fields: name: type

A named field injects one field with an explicit type. The type can take three forms:

1. A selector used as a typeprincipalType(A) or resourceType(A). This yields the action’s declared appliesTo entity-type set, kept whole (no collapsing or unioning). For a two-principal action, both types are retained. (inputs/outputs may not be used as a type — spread them instead.)

2. A concrete Cedar type — an ordinary, possibly-qualified type name such as String, Long, or Drupe::OAuthUser.

3. A nested record{ … }, a record type whose members are themselves field specs, addressed as name.member. This is how you opt a field into hierarchy. Members are derived recursively, so a spread inside a record is legal (meta: { ...inputs(A) }) and records nest arbitrarily deep.

Field names must be unique at every level, not just the top — a duplicate name inside a nested record is also an error.

Nested records and deep hierarchy

Records can nest as deeply as you like, and field identity is by dotted path:

__drupe: { sessionid: String }          // leaf __drupe.sessionid
a: { b: { c: String } }                      // leaf a.b.c; a and a.b are groups
meta: { }                                    // a group with no leaves

Flat fields, depth-2, and depth-3 fields can coexist on one event, and a flat reserved field (like requestId) stays flat while a sibling nests. The derived list of a Login::request event against the Login/Read action schema from The policy language is, for example, ["input.user", "callerPrincipal", "callerResource", "requestId", "sessionId"].

Pins: pin name: type = <request-reference>

A pin forces a correlation. On every predicate that mentions the event, the pinned field is implicitly conjoined to a request reference — the current request’s principal / resource scope entity (± an attribute) or a context.<path> field — whether or not the policy author wrote it. This is how a schema author guarantees an invariant that individual policies cannot forget.

pin callerPrincipal: principalType(A) = principal

The rules:

  • pin and = <reference> go together. pin foo: String with no = is an error (pinned field \foo` is missing its pin value), and foo: String = context.foowithoutpin is an error (field `foo` has a `= context.<…>` value but is not marked `pin`). The reference is either a scope entity (principal, resource, principal.dept) or a context field (context.`).
  • A pin may sit only on a leaf. Pinning a whole record group is an error; pin a leaf inside the group instead.
  • pin is a contextual keyword. It only acts as the pin prefix when a field declaration (ident :) follows. So a field can still be named pin (pin: String), and a field named pin can itself be pinned (pin pin: String = context.pin).

At derivation time each pinned leaf records its full dotted path plus the context path. The engine’s pin-injection pass then runs after macro expansion and before validation: for every predicate naming an event with pins, it appends the correlation. The pin is appended unconditionally — even if the policy already wrote that field. A hand-written copy can only narrow a pin, never relax it: if it agrees with the pin the appended correlation is a redundant no-op, and if it disagrees the two constraints on the same field make the predicate unsatisfiable. Because injection happens before validation, the pinned field is validated like any authored field, and because it is never skipped, a pin cannot be bypassed — not by omitting the field, and not by writing it with a weaker value (a wildcard, a different context.<path>, or a fresh variable).

Universal symmetric pins: key-local semantics and the partition guarantee

A pin becomes more than a per-predicate correlation when it is universal and symmetric:

  • Universal — the same pin is declared on every event kind the schema derives (request, response, and error in the default layout, plus any custom kinds). If a kind is left out the pin is not universal, and the partition guarantee does not apply.
  • Symmetric — the pin’s value is the field’s own path on the current request (pin session_id: String = context.session_id), or one of the reserved scope-alias pairs (pin callerPrincipal: principalType(A) = principal, and likewise callerResource/resource). The alias uses the bare scope reference (= principal / = resource), not = context.principal: as noted below, context.principal is a context field literally named principal, not the scope entity, so it would not be the reserved alias and would not qualify. Symmetry is what makes an event’s own field value the key under which it is both stored and looked up.

When at least one universal symmetric pin exists, Dogwood switches every temporal leaf to key-local semantics: the leaf is evaluated as if the trace contained only the events agreeing with the current request on every universally-pinned field (the request’s “slice”). Concretely:

  • formerly, aggregations, and the negated-left since idiom already behave this way under pins (a pinned predicate can never match another key’s event), so their meaning is unchanged.
  • previous means “this key’s previous event.” Another key’s interleaved event no longer displaces it — and no longer satisfies it. The window still measures from the decision point, exactly.
  • The positive left of since quantifies over this key’s positions only. Another key’s interleaved event cannot break the “held at every step” continuity; the key’s own non-matching events still do.

This is implemented by a lowering-time rewrite of the leaf formulas (the authored form is what validation checks and error messages point at; the rewritten form is what engines evaluate, so any conforming engine agrees with the in-memory interpreter). The default event schema carries such a pin, on callerPrincipal, so key-local semantics apply unless you replace it: every temporal leaf is keyed on the requesting principal, and events from other principals are invisible to it. With no universal symmetric pin — an event schema that declares none, or declares one that is partial or asymmetric — nothing changes: leaves keep the global-trace semantics described in Temporal expressions.

A pin that is not universal silently keeps global semantics — with no error. A pin declared on only some event kinds (e.g. on request but not response), or one that is not symmetric, is a valid schema: it still acts as an ordinary per-predicate correlation, but it does not activate key-local semantics and the partition guarantee does not apply. Nothing warns you — the schema builds and evaluates, just without isolation. To buy the guarantee you must declare the same symmetric pin on every derived event kind; otherwise assume global-trace semantics.

The payoff is the partition guarantee: with a universal symmetric pin on field f, a verdict for a request with key f = v depends only on events whose f equals v. Events may therefore be stored, evaluated, and retained per key — a per-session or per-principal database is equivalent to a global one — and no policy, present or future, can write a temporal expression that escapes the key. One consequence to weigh before adopting a universal pin: it applies to every predicate, so cross-key policies (“more than N logins by any user”) become inexpressible under it — that is the isolation being bought.

How request references resolve in policies

Both a pin’s right-hand side and a policy’s request references resolve against the current decision request, mirroring Cedar’s four request variables:

  • principal / resource are the request scope’s principal / resource entities — regardless of what the schema happens to name its injected principal field. A trailing attribute (principal.dept) reads that entity’s attribute from the request’s entity store; a bare principal is the entity itself (for identity), and principal.id / principal.type project the uid.
  • context.<path> resolves to a field of the request context record at that dotted path (context.input.user, context.__drupe_sessionid, context.__drupe.session.id). This is Cedar’s context variable — a plain record — so context.principal would be a field literally named principal in that record, not the scope entity.

This distinction matters: an injected field can be renamed actor and still be correlated against the scope principal with actor: principal, while a declared context field like __drupe_sessionid is reached by its own name (context.__drupe_sessionid).

Worked examples from the corpus

These are real, passing event.dwschema files from the tested corpus. Each case named below is a directory under dogwood-language/tests/passing/temporal_only/corpus/.

Author-defined kinds and a renamed principal field (1110_custom_event_schema_renamed_reserved). Nothing forces the kinds to be request/response; here they are attempt (a decision) and outcome (history-only), and the injected principal is renamed actor:

decision event <A>::attempt {
    ...inputs(A),
    actor: principalType(A),
}
event <A>::outcome {
    ...inputs(A),
    ...outputs(A),
    actor: principalType(A),
}

The policy then correlates actor: principal.

A renamed flat reserved field (1111_custom_injected_sessionid). This replaces the default’s sessionId with __drupe_sessionid, keeping the other three reserved leaves:

decision event <A>::request {
    ...inputs(A),
    callerPrincipal:      principalType(A),
    callerResource:       resourceType(A),
    requestId:            String,
    __drupe_sessionid:  String,
}
event <A>::response {
    ...inputs(A),
    ...outputs(A),
    callerPrincipal:      principalType(A),
    callerResource:       resourceType(A),
    requestId:            String,
    __drupe_sessionid:  String,
}

A policy can then reach the field by name: …::request{ input.user: context.input.user, __drupe_sessionid: context.__drupe_sessionid }.

Input/output field-name collision (1112_input_output_field_name_collision). This uses the default schema (no custom event.dwschema) and relies on the input / output grouping to keep two same-named fields distinct as input.x and output.x.

Nested reserved field (1113_nested_reserved_field_correlation). __drupe: { sessionid: String } derives the leaf __drupe.sessionid; the policy correlates __drupe.sessionid: context.__drupe.sessionid.

Depth-3 nested fields (1114_deep_nested_field_correlation / 1116_injection_onto_deep_path). __drupe: { session: { id: String, region: String } } derives __drupe.session.id. 1114_deep_nested_field_correlation correlates it directly; 1116_injection_onto_deep_path injects the same correlation through a def temporal same_session(?w, ?s) macro (see Macros).

Two deep siblings (1115_multiple_deep_path_constraints). Both __drupe.session.id and __drupe.session.region are carried under the one group and constrained together.

Top-level pin (1117_pin_principal_correlation). The pin forces every predicate for this event to share the request’s principal, even though no policy writes callerPrincipal. It is declared on both kinds, which is what makes it universal:

decision event <A>::request {
    ...inputs(A),
    pin callerPrincipal: principalType(A) = principal,
    callerResource:  resourceType(A),
    requestId:       String,
}
event <A>::response {
    ...inputs(A),
    ...outputs(A),
    pin callerPrincipal: principalType(A) = principal,
    callerResource:  resourceType(A),
    requestId:       String,
}

Nested-leaf pin (1118_pin_nested_session_id). A pin can sit on a leaf inside a record group; here it conjoins __drupe.session_id: context.__drupe.session_id onto every predicate:

decision event <A>::request {
    ...inputs(A),
    callerPrincipal: principalType(A),
    callerResource:  resourceType(A),
    requestId:       String,
    __drupe: { pin session_id: String = context.__drupe.session_id },
}
event <A>::response {
    ...inputs(A),
    ...outputs(A),
    callerPrincipal: principalType(A),
    callerResource:  resourceType(A),
    requestId:       String,
    __drupe: { pin session_id: String = context.__drupe.session_id },
}

A pin cannot be bypassed by a hand-written field (1119_pin_not_bypassed_by_wildcard, 1120_pin_agrees_with_author_written_field, 1121_pin_not_bypassed_by_fresh_binder, 1122_pin_disagree_denies_despite_author_literal). These all have the policy write the pinned field, proving the append is unconditional. Three are bypass attempts that fail:

  • 1119_pin_not_bypassed_by_wildcard — wildcard. The policy writes callerPrincipal: _ (accept a Login by any principal). The pin is still injected, so the wildcard adds nothing and the cross-principal Login is still excluded.
  • 1121_pin_not_bypassed_by_fresh_binder — fresh variable. The policy writes callerPrincipal: p where p is used nowhere else — a variable that binds but never constrains, so on its own it too accepts any principal. The pin still forces the correlation.
  • 1122_pin_disagree_denies_despite_author_literal — disagreeing concrete value. Using the nested-session schema of 1118_pin_nested_session_id, the policy hard-codes __drupe.session_id: "sess-1" (the historical Login’s session). That literal alone would permit every trace; the pin adds context.__drupe.session_id, so when the current request’s session differs the two constraints on the one field disagree, the predicate is unsatisfiable, and the Read is denied. This is the “disagree → unsatisfiable” outcome end to end.

The fourth, 1120_pin_agrees_with_author_written_field — agreeing value, writes the pin’s exact value (callerPrincipal: principal); the appended pin is then a redundant no-op and the verdicts match 1117_pin_principal_correlation. Together they cover both documented outcomes of a hand-written pinned field: a weaker one (wildcard, variable, or a disagreeing value) can only narrow the pin, never relax it, and an agreeing one is a no-op.

How derived fields appear on an ingested event

To make the derivation concrete, here is an event from 1113_nested_reserved_field_correlation’s trace (against the Login/Read action schema from The policy language) — the fields the event schema derived, filled in with real values:

@0  scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1")
    request_context(…)
    Drupe::Action::"Login"::request(
      input: { user: "alice" },
      callerPrincipal: Drupe::OAuthUser::"alice",
      callerResource:  Drupe::Gateway::"gw1",
      requestId: "u1",
      __drupe: { sessionid: "sess-1" })

input.user came from the ...inputs(A) spread; callerPrincipal, callerResource, requestId and the nested __drupe.sessionid came from the injected fields. The request_context(…) bag is elided here because it is not derived from the event schema — it is the request’s own context record, which every context.<path> reference reads, whether in a Cedar condition or in a temporal correlation like this case’s. A real trace line spells it out.


Capping the look-back window: max_window

A temporal policy looks back over event history with a within <interval> window (see Temporal expressions). The event schema can put a ceiling on how far back any policy may look with an optional max_window directive at the top of the file:

max_window = 24h

decision event <A>::request {
    ...inputs(A),
    callerPrincipal: principalType(A),
    callerResource:  resourceType(A),
    requestId:       String,
}

The rules:

  • It goes first, at most once. The directive precedes all event declarations; placing it after one is a parse error.
  • The interval is a positive <amount><unit> using the same four units as temporal windows (s, m, h, d) — e.g. 24h, 30m, 7d. A zero window (max_window = 0h) is a parse error, since it would forbid every within clause; omit the directive instead if you want no custom cap.
  • Absent, the cap defaults to 24h. Omitting max_window — as every schema shown elsewhere in this guide does — uses the 24h default.
  • The bound is inclusive. A within window equal to the cap is allowed; only a window strictly greater than the cap is rejected. So under the default, within 24h (and the identical within 1d) pass, while within 48h or within 7d are rejected.

The validator enforces the cap: any temporal within window in a policy that exceeds max_window is a validation error naming both the offending window and the cap, whether the window sits on a top-level formerly/previous/since or is nested inside an aggregation. Raise the cap here when a policy genuinely needs a longer history (max_window = 30d), or lower it to tighten what policies may do (max_window = 1h).


Decision kinds: when authorization runs

The decision prefix determines when Dogwood authorizes.

Dogwood ingests a stream of events. Ingesting an event of a decision kind runs authorization and produces a verdict; ingesting any other (history-only) kind updates the engine’s state but yields no verdict. This is the mechanism behind temporal policies: a response records that a tool call completed (history), and a later request for another tool can ask about it (decision).

  • LoweredPolicySet::decision_kinds() exposes the set of kinds marked decision (with is_decision_kind(kind) for a single check).
  • The default event schema marks only request as a decision kind. request runs authorization; response and error are history-only.
  • A custom schema may mark any kinds — 1110_custom_event_schema_renamed_reserved makes attempt the decision kind and outcome history-only.

The default event schema (request / response / error)

If you never supply an event schema, Dogwood uses DEFAULT_EVENT_SCHEMA. Here it is in full:

decision event <A>::request {
    ...inputs(A),
    pin callerPrincipal: principalType(A) = principal,
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::response {
    ...inputs(A),
    ...outputs(A),
    pin callerPrincipal: principalType(A) = principal,
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::error {
    ...inputs(A),
    pin callerPrincipal: principalType(A) = principal,
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

Reading it part by part:

  • decision event <A>::request — for every action A, a request event that is a decision point (runs authorization).
    • ...inputs(A) — the tool’s arguments, nested under the input group (input.user, input.document, …).
    • pin callerPrincipal: principalType(A) = principal — a top-level leaf holding the request principal’s entity-type set (the full appliesTo set, kept whole), pinned to the request’s scope principal.
    • callerResource: resourceType(A) — the resource entity-type set.
    • requestId: String — the event’s unique id, a flat leaf.
    • sessionId: String — the session this request belongs to.
  • event <A>::response — a history-only event (no decision), emitted when the tool call resolves successfully.
    • ...inputs(A) and ...outputs(A) — both the argument (input.*) and result (output.*) groups, so a response carries the outcome.
    • the same reserved leaves (callerPrincipal, callerResource, requestId, sessionId).
  • event <A>::error — a history-only event emitted when the gateway/target returns an error.
    • ...inputs(A) — the attempted call’s arguments, so a policy can correlate on what was attempted.
    • the same reserved leaves (no ...outputs(A) since the call failed).

So against the Login/Read schema, Login::request derives input.user, callerPrincipal, callerResource, requestId, sessionId; request does not splice outputs, while response splices both. The callerPrincipal pin is declared on every kind and is symmetric, so the default carries a universal symmetric pin: temporal leaves run under key-local semantics, keyed on the requesting principal. To get global-trace semantics instead, supply an event schema without that pin.


See also

  • The policy language — the action schema these events derive from.
  • Temporal expressions — how decision-kind and history-kind events power past-looking policies (the consumer of decision kinds).
  • The API and workflow — assembling ServiceSchema + PolicySchema.
  • The provider schema — how information providers are declared (another input the ServiceSchema carries).
  • Macros — the macro library the ServiceSchema also carries.

Temporal Expressions

This page is the reference and tutorial for Dogwood’s temporal sublanguage — the code you write inside a when temporal { … } (or unless temporal { … }) block. Ordinary Cedar authorization decides this request from this request’s attributes. Temporal expressions extend that decision to the event history: they let a policy say “allow this only if such-and-such happened (or did not happen) recently.” This document motivates why that matters, then builds the sublanguage up operator by operator — the three past operators (formerly, previous, since) and their mandatory windows, conjunction and negation, the exists and tp binders, aggregations (count, sum), predicates and field patterns, field-injection refinement, and finally the legality rules that decide which expressions are accepted. It closes with the precise evaluation semantics. If you are new to Dogwood policies overall, read 02-policy-language.md first; this page assumes you know what permit/forbid, when, and unless mean. Every policy example below is backed by a runnable bundle under examples/ that the dogwood CLI validates (and, where a trace.log is present, replays) on each build.

Why temporal? Authorization over event history

Cedar answers a question about a single moment: given this principal, this action, this resource, and this request context, permit or forbid? That is enough for “can Alice read document X” but not for questions whose answer depends on what came before:

  • “Allow a write only if the same user read the same document within the last hour.”
  • “Deny any action after a logout until the next login.”
  • “Flag a transfer if the user has made more than two logins in the last hour.”
  • “Require that a heartbeat was seen recently before trusting a session.”

Each of these is a statement about a trace of events over time, not a single request. Dogwood’s temporal sublanguage is a bounded, past-only fragment of Metric First-Order Temporal Logic (MFOTL) for expressing authorization-over-history rules.

A worked scenario: write-after-read. Suppose an agent may only write a document it has recently read. In Dogwood you attach a temporal marker to the rule and, inside it, assert that a matching Read event occurred in the recent past:

permit(principal, action == Drupe::Action::"Write", resource)
when temporal {
    formerly within 1h Drupe::Action::"Read"::response{
        input.user: context.input.user,
        input.document: context.input.document
    }
};

Runnable: examples/write_after_read_formerly/dogwood validate and dogwood replay.

Read this as: “permit the write only if, at some point in the last hour, this same user successfully read this same document.” The formerly within 1h … part is the temporal claim; the predicate Drupe::Action::"Read"::response{ … } describes the past event to look for; and input.user: context.input.user pins the past event’s user to the current request’s user. (This is corpus case 0004_write_after_read.)

The temporal marker keyword

Dogwood adds one extension sub-language that plugs into a policy’s when/unless clause via a marker keyword: temporal, which this page is about. (There is a second clause tag, guardrails, but it is not a sub-language — guardrails { E } is sugar for a bare when { E }, and information providers are invoked as ordinary Cedar calls; see 05-information-providers.md.) Internally the codebase sometimes calls the temporal extension a “dialect,” but in a .dw file you always write the surface keyword temporal, and that is the term this documentation uses throughout.

What a temporal block is, mechanically

The body between the temporal { … } braces is parsed as a single condition and evaluated at the request’s decision timepoint against all events up to and including that moment. The whole block yields a boolean: true means the temporal condition held, and the enclosing when/unless uses that boolean exactly as it would any other clause. when temporal { φ } contributes to permitting only when φ holds; unless temporal { φ } blocks when φ holds — which is the idiomatic way to express absence, as we will see with unless temporal { formerly … }.

The building block: predicates

Before the temporal operators, you need the thing they operate on — a predicate, which describes a past event to match. Every temporal example is built from predicates, so we cover them first.

Predicate shape

A predicate names a fully-qualified action, an event kind, and a set of field patterns:

Namespace::…::Action::"ActionId"::kind{ field: pattern, … }

Decomposing Drupe::Action::"Login"::request{ input.user: context.input.user }:

  • namespaceDrupe::Action
  • action — the quoted id "Login"
  • kind — the trailing ::request
  • args — the field patterns input.user: context.input.user

The ::kind suffix is mandatory; a predicate is not well-formed without it. The quoted action id acts as an anchor so the parser can tell the namespace :: segments (before the quote) apart from the kind segment (after the quote).

Event kinds are author-defined, not a fixed set. request and response are merely the conventional kinds — request for the invocation, response for the result — and a response predicate typically reads output.* fields (a formerly-gated read-after-successful-login permit built on this is runnable as examples/read_after_login_success/):

Drupe::Action::"Login"::response{ input.user: context.input.user, output.result: true }

There is no separate “response” AST form; a response is a predicate whose kind segment is response. Nothing stops a schema from naming other kinds; corpus case 1110_custom_event_schema_renamed_reserved uses a custom attempt kind (runnable as examples/login_attempt_custom_kind/):

formerly within 1h Drupe::Action::"Login"::attempt{ input.user: context.input.user, actor: principal }

Field patterns

Each named argument is field_path : term. The field name is a dotted path into the event’s record — a bare field (user), or a path into a nested group (input.user, output.result). This mirrors how you reference the current request on the right-hand side (context.input.user): an event’s spliced input/output values nest under the input and output groups, which stay distinct.

The term on the right of the colon is the pattern the field must match. The forms you will use:

  • Value binding — a bare variable name captures the field’s value into a variable for later use: input.user: u, input.amount: a. The variable is bound by the first predicate that mentions it and equality-checked by later ones.

  • Pinned correlation — a context.* reference forces the past event’s field to equal the current request’s field: input.user: context.input.user. It is what expresses “the same user” and “the same document”.

  • Scope correlationprincipal and resource are the current request’s principal and resource entities (Cedar’s request variables — the same names, and the same meaning, you use in a plain when { … } Cedar clause), and pin against the reserved event fields callerPrincipal / callerResource:

    formerly within 1h Drupe::Action::"Heartbeat"::request{
        input.server: context.input.server,
        callerPrincipal: principal,
        callerResource: resource
    }
    

    (Corpus case 0036_plain_heartbeat.) A trailing attribute reads that entity’s attribute — principal.dept, resource.owner — resolved against the current request’s entity attributes, exactly as a pure-Cedar when { principal.dept == … } and a provider argument principal.dept do. So principal means the same thing in all three surfaces; there is no context.principal alias (in Cedar, and now here, context.principal would be a field literally named principal in the context record, not the scope entity).

  • Literal values — a string (input.server: "s1"), a boolean (output.result: true), or a decimal (output.score: decimal("0.5")).

  • Wildcards_ or * matches anything and binds nothing: input.user: _, input.amount: *. Each wildcard is independent: in P{ a: _, b: _ } the two _s do not force a == b. Use a shared variable name if you want that.

Terms in general

Beyond field patterns, terms appear on both sides of comparisons and as macro arguments. The full term vocabulary:

TermSyntaxNotes
EntityDrupe::OAuthUser::"alice"qualified entity reference
Integer42, -1
Decimaldecimal("1.5")payload kept as text; equality-only at eval time (see semantics)
String"hello"
Booleantrue / false
Context fieldcontext.input.foo, context.system.nowdotted path into the current request’s context record
Scope entityprincipal, resource, principal.deptthe request principal / resource entity (± an attribute)
Variablea bare identifiera bound-variable name
Wildcard*, or a bare _matches anything, binds nothing; each independent
Array[a, b, c]
Aggregatecount … / sum …comparison-operand-only (see aggregations)

The past operators and their windows

There are exactly three temporal operators, and all of them look only into the past: formerly, previous, and since. There are no future operators and no unbounded operator — every temporal operator carries a mandatory within <interval> window that bounds how far back it looks.

Intervals and time units

A window is written within <amount><unit>. There are exactly four time units:

UnitMeaningSeconds
sseconds1
mminutes60
hhours3600
ddays86400

There is no week, month, or year unit. The amount is an integer. A window boundary is a closed (inclusive) interval: a witness exactly W seconds back is in the window; one second further is out (see Evaluation semantics).

Windows are capped. How far back a window may look is bounded by the event schema’s max_window24h by default, adjustable with a max_window = <interval> directive at the top of the event schema (see The event schema). The validator rejects any within window that exceeds the cap. So within 7d or within 30d require a schema that raised the cap accordingly; under the default they are validation errors. The bound is inclusive, so within 24h sits exactly at the default cap and is allowed.

The within ?w form (a ?-sigil in place of a literal) is legal only inside a macro body, where the window is a parameter resolved at the call site; see Macros. Outside a macro body you always write a literal like 1h.

formerly — happened at least once, recently

Syntax: formerly within <interval> <atom>

formerly is the existential past operator: it holds at the decision timepoint if its body held at some timepoint within the window. Think “did this ever happen in the last hour?” The write-after-read policy from the introduction uses it (corpus 0004_write_after_read; runnable as examples/write_after_read/, which adapts it to a SellShares/ApproveSale permit):

when temporal {
    formerly within 1h Drupe::Action::"Read"::response{
        input.user: context.input.user,
        input.document: context.input.document
    }
};

The body of formerly (and of previous) is an atom: a parenthesized condition, a tp(...), a macro call, a predicate (optionally refined), or a comparison. A bare && chain is not an atom, so to put a conjunction under formerly you must parenthesize it: formerly within 1h (A && B).

A session-correlated example using the scope entities (corpus 0036_plain_heartbeat; the same pattern is runnable as an Alert permit in examples/heartbeat_scope_alias/):

when temporal {
    formerly within 1h Drupe::Action::"Heartbeat"::request{
        input.server: context.input.server,
        callerPrincipal: principal,
        callerResource: resource
    }
};

previous — the immediately preceding event

Syntax: previous within <interval> <atom>

previous is stricter than formerly: it looks only at the immediately preceding timepoint (i - 1), not the whole window. It holds when the event directly before the decision point is both within the window and satisfies the body. At the first timepoint it is false (there is no previous event). The window still applies, so previous within 1h succeeds only when the preceding event was at most an hour ago.

Corpus 0243_kernel_previous_within (runnable as a Read-after-login permit in examples/read_prev_login/):

when temporal {
    previous within 1h Drupe::Action::"Login"::request{ input.user: context.input.user }
};

With a response predicate and an output-field filter (corpus 0183_previous_at_tp0_no_verdict; runnable as examples/read_prev_login_success/):

when temporal {
    previous within 1h Drupe::Action::"Login"::response{ input.user: context.input.user, output.result: true }
};

Because previous’s body is an atom, a conjunction must again be parenthesized (corpus 0268_previous_containing_nested):

when temporal {
    previous within 2h (
        Drupe::Action::"Login"::request{ input.user: context.input.user }
        && Drupe::Action::"Login"::request{ input.server: "s1" }
    )
};

since — held continuously since an anchor

Syntax: <left> since within <interval> <right>

since is infix: unlike formerly and previous, which come before a single body, it sits between its two operands. Each operand is a single item, as with those operators, so a conjunction on either side must be parenthesized. It expresses “left has held continuously ever since right happened.” Formally it holds at the decision point when there is an anchor timepoint j in the window where right held, and left held at every step from j+1 through the decision point. This is MFOTL’s left S right.

A positive-left example — a login has held continuously since a login (corpus 0034_since_explicit; runnable as examples/read_since_login/):

when temporal {
    Drupe::Action::"Login"::request{ input.user: context.input.user }
    since within 1h
    Drupe::Action::"Login"::request{ input.user: context.input.user }
};

Negated left — the “open session” idiom. There is no dedicated “hasn’t happened since” operator; you write it with a negated left operand, !left since …. Because negation binds tighter than since (see Precedence), !A since within W B negates only A. This expresses “no A has happened since B” — e.g. “the user has not been revoked since they were granted” (corpus 0156_without_since_access_control; runnable as examples/access_not_revoked_since_grant/):

when temporal {
    !Drupe::Action::"Revoke"::request{ input.user: context.input.user, input.resource: context.input.resource }
    since within 1h
    Drupe::Action::"Grant"::request{ input.user: context.input.user, input.resource: context.input.resource }
};

A since with a shorter window unit (corpus 0184_since_window_anchor_too_old; runnable as examples/read_heartbeat_since_login_30s/):

when temporal {
    Drupe::Action::"Heartbeat"::request{ input.user: context.input.user }
    since within 30s
    Drupe::Action::"Login"::request{ input.user: context.input.user }
};

Conjunction, negation, and precedence

The temporal sublanguage has exactly one boolean connective: conjunction, &&. There is no || (disjunction). If you need “A or B,” write two separate policy rules — that is how disjunction across authorization outcomes is expressed. Three common patterns follow from just &&, !, and the binders:

  • “A but not B” → a && !b
  • “X has not held since an anchor” → !X since …
  • binding a computed value → exists (n: T). ((A) == n && B) (see Binders)

Negation !

Negation is written with a leading !. !a is boolean negation of a. In a relational context (inside exists or an aggregation where body) it acts as an anti-join filter: it keeps a row only when a does not hold under that row’s bindings. Multiple !s stack, and an even count cancels (double negation).

Precedence: ! > since > &&

From tightest to loosest binding: negation, then since, then conjunction. Consequences:

  • !a && b parses as (!a) && b — negation binds only a.
  • !a since within W b parses as (!a) since within W b — negation binds only the since-left.
  • To widen a negation’s scope, parenthesize: !(a && b).

&& is left-associative and is the loosest operator, so a top-level chain like A && B && C groups as ((A && B) && C). A top-level conjunction combining a formerly with an exists-guarded count (corpus 0059_count_threshold; runnable as an Alert permit in examples/alert_heartbeat_and_login_rate/):

when temporal {
    formerly within 1h Drupe::Action::"Heartbeat"::request{ input.server: context.input.server }
    && exists (n: Long). (
        (count for (t: Timepoint). where (
            Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
        )) == n && n > 2
    )
};

A top-level previous && (open-session) chain (corpus 0462_previous_and_without_since_top_level; runnable as a Read permit in examples/read_prev_compute_open_session/):

when temporal {
    previous within 1h Drupe::Action::"Compute"::request{ input.user: context.input.user }
    && (!Drupe::Action::"Logout"::request{ input.user: context.input.user }
        since within 24h
        Drupe::Action::"Login"::request{ input.user: context.input.user })
};

Order matters in a && chain — not for logical truth, but for what is accepted. A conjunct that only filters (like !X or an ordering comparison) must come after a conjunct that binds its variables. See Writing temporal expressions that are accepted.

Binders: exists and tp

Predicates capture field values into variables. To quantify over those values — “there is some user such that…” — or to reason across distinct timepoints, you use the two binders.

exists — the sole quantifier

Syntax: exists (x: T). φ

exists introduces a single typed variable x and asserts that its body φ has at least one satisfying assignment. It is the only binding form in the language. A few important rules:

  • The type annotation is mandatory on the binder. Types are Timepoint, or a qualified concrete/entity type (Long, String, Drupe::OAuthUser). The annotation is authoritative: validation seeds the binder’s declared type into the type environment and then checks every use of the variable against it, rather than inferring the type from the first use. A use that contradicts the declaration is a type error — exists (x: Long). x == "s" is rejected because the string literal is inconsistent with the declared Long. The annotation is only consulted at validation time; at evaluation time only the binder name matters (candidate values still come from the binding atom, so there is no enumeration of the type).
  • The scope is greedy to the right. The body is a full condition, so exists (x: T). φ && ψ binds x over both φ and ψ. To stop the scope early, parenthesize: (exists (x: T). φ) && ψ.
  • It is “at least one,” not a count. exists is satisfied by one or more witnesses; it does not tell you how many. Use count for that.
  • The type is not enumerated. x’s candidate values come only from the atom that binds it — a predicate field, a tp, or an (agg) == x equality. There is no iteration over “all Longs.”

Simplest form — some user logged in (corpus 1140_exists_login_no_agg):

exists (u: String). formerly within 1h Drupe::Action::"Login"::request{ input.user: u, input.server: context.input.server }

Correlation — the same user both logged in and transferred, by sharing u across two formerlys (corpus 1142_exists_correlation; runnable as an Alert permit in examples/alert_same_user_login_and_transfer/):

exists (u: String). (
    formerly within 1h Drupe::Action::"Login"::request{ input.user: u }
    && formerly within 1h Drupe::Action::"Transfer"::request{ input.user: u }
)

Nested existentials with a value filter — a user who logged in and made a transfer over 100 (corpus 1143_nested_exists_threshold; runnable as an Alert permit in examples/alert_login_and_big_transfer/):

exists (u: String). (
    formerly within 1h Drupe::Action::"Login"::request{ input.user: u }
    && exists (a: Long). (
        formerly within 1h Drupe::Action::"Transfer"::request{ input.user: u, input.amount: a }
        && a > 100
    )
)

Two independent existentials (distinct variables, no correlation — different users may satisfy each side) look like exists (u: …). ( … ) && exists (v: …). ( … ) — contrast that with the shared-u binding above. And an entity-typed binder can correlate on the same principal:

exists (pr: Drupe::OAuthUser). (
    formerly within 1h Drupe::Action::"Login"::request{ callerPrincipal: pr }
    && formerly within 1h Drupe::Action::"Deny"::request{ callerPrincipal: pr }
)

tp — the timepoint binder

Syntax: tp(t)

tp(t) binds t to the timepoint currently being evaluated. It appears inside an aggregation’s where body, conjoined with a predicate, so the aggregation can range over distinct timepoints. Whether t is listed in the aggregation’s for domain determines distinctness:

  • Include t in the for list to keep one row per timepoint — this counts occurrences over time.
  • Omit t from the for list to deduplicate equal values across time.

The count-over-timepoints idiom — how many logins to this server occurred (corpus 0178_agg_no_temporal_counts_current_tp; runnable as an Alert permit in examples/alert_login_current_tp/):

when temporal {
    exists (n: Long). (
        (count for (t: Timepoint). where (
            Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
        )) == n && n > 0
    )
};

Aggregations: count and sum

Aggregations turn a set of matching past events into a number you can compare against a threshold. There are exactly two aggregate keywords: count and sum. There is no min, max, or avg.

Form

count for (g1: T1), …, (gn: Tn). where φ
sum   v for (g1: T1), …, (gn: Tn). where φ
  • count yields the number of matching rows.
  • sum v yields the sum of column v over those rows; v must be one of the for-declared variables (written as a bare name, no type).

The for <binders>. clause names the aggregation domain: the satisfying assignments of where φ are collected, projected onto the for variables, deduplicated, then aggregated. Each for element is a declaration site, so it carries a mandatory type annotation, and the trailing . terminates the list. An aggregate is a numeric term (it yields a Long).

Two rules that shape how you write aggregations

1. Aggregates may appear only as an immediate comparison operand. An aggregate is syntactically a term, but it is legal only directly on one side of a comparison — never as a predicate-argument value, never nested inside an array or another term. P{ f: count … } and [count …] == x are both rejected.

2. Parenthesize an aggregate on the left of a comparison. The where body is a greedy full condition, so (count …) == n needs parentheses around the aggregate or the where body will swallow the == n. On the right of a comparison no parentheses are needed, because there is nothing to the right for the greedy body to eat: 0 < count for (t: Timepoint). where φ parses fine. The parentheses carry no semantics; they only fence the greedy body.

count examples

Exact count — exactly two logins (corpus 0062_count_exact):

when temporal {
    (count for (t: Timepoint). where (
        Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
    )) == 2
};

Count over history using a temporal body (corpus 0179_agg_with_once_counts_history; runnable as an Alert permit in examples/alert_login_in_last_hour/):

when temporal {
    exists (n: Long). (
        (count for (t: Timepoint). where (
            formerly within 1h (Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t))
        )) == n && n > 0
    )
};

Aggregate-vs-aggregate comparison — note the left operand is parenthesized, the right is not (corpus 1144_agg_vs_agg):

when temporal {
    (count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::response{ requestId: _ } && tp(t))
    ))
    < count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::request{ requestId: _ } && tp(t))
    )
};

Count over a *-wildcard field — exactly three transfers, regardless of amount (corpus 1117_count_for_tp; runnable as an Alert permit in examples/alert_exactly_three_transfers/):

when temporal {
    (count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::request{ input.amount: * } && tp(t))
    )) == 3
};

sum examples

Simple sum of a bound value column — total transferred exceeds 200 (corpus 0063_sum_threshold; runnable as an Alert permit in examples/alert_total_transfer_over_200/):

when temporal {
    exists (total: Long). (
        (sum a for (a: Long). where Drupe::Action::"Transfer"::request{ input.amount: a }) == total
        && total > 200
    )
};

Sum over a (value, timepoint) domain with a filtered temporal body (corpus 0299_sum_resolved_filter; runnable as a forbid Read rule in examples/forbid_read_transfers_over_1000/):

when temporal {
    exists (total: Long). (
        (sum a for (a: Long), (t: Timepoint). where (
            formerly within 1h (
                Drupe::Action::"Transfer"::response{ input.user: context.input.user, output.amount: a }
                && a > 0 && tp(t)
            )
        )) == total
        && total > 1000
    )
};

The two-binder domain for (a: Long), (t: Timepoint). is what keeps equal amounts made at different timepoints from being deduplicated — a is the summed value, t distinguishes the timepoints. A range filter on the bound value (corpus 0301_sum_resolved_range_filter) works the same way, adding a > 100 && a < 500 inside the temporal body.

Comparisons

Comparisons filter or bind. The operators are <, <=, >, >=, ==, and !=:

term cmp_op term

Both operands are terms, and either may be an aggregate (subject to the comparison-operand-only rule above). Semantics:

  • == and != use domain equality on the resolved values.
  • Ordering comparisons (<, <=, >, >=) require both sides to resolve to integers; otherwise the comparison is false. Decimals are kept as text and are effectively equality-only — a decimal(…) in an ordering comparison resolves but fails the integer conversion and yields false.
  • If either operand fails to resolve (an unbound variable, a wildcard), the comparison is false.

== doubles as a binder. In a relational context, an a == x (or x == a) with exactly one unbound variable operand binds that variable to the other side’s value. This is the mechanism behind exists (n: Long). ((agg) == n && …) — the == n binds n to the aggregate’s value so the following n > 2 can filter it. An == with no unbound operand, or any ordering comparison, is a plain filter.

Field-injection refinement

A predicate (or a macro condition-sigil) may carry trailing { … } blocks that inject extra named arguments onto it:

P::kind{ a: 1 }{ b: 2 }   // equivalent to P::kind{ a: 1, b: 2 }
?s{ status: "approved" }  // refine a macro's predicate-valued argument

With zero blocks, the predicate is unchanged. With one or more blocks, the injected arguments are concatenated and merged onto the base predicate. The base must resolve to a single predicate — refining a conjunction, a formerly, or a comparison is a static error. Refinement is resolved at macro expansion time and never reaches the evaluator.

Refinement exists for the macro path: a macro whose parameter is a predicate can have extra fields forced onto whatever predicate the caller passes. Corpus case 1116_injection_onto_deep_path refines a predicate-valued parameter ?s with a deep session-id field to force a same-session correlation:

def temporal same_session(?w, ?s) {
    formerly within ?w (?s{ __drupe.session.id: context.__drupe.session.id })
};

Macros (def temporal)

Macros let you name and parameterize a temporal pattern: you define one outside a temporal block with def temporal name(...) { <body> } and call it inside one. This section covers the call site — for defining a macro (the ?p / $t sigils, hygiene, and the rejection rules), see Macros.

A macro call looks like an ordinary call, name(arg, …). Each argument may be a bare interval literal, a condition, or a term. A window argument at the call site is a bare interval literal (1h, 30m) with no within keyword — the within keyword stays with the temporal operator in the macro body. The call site for the same_session macro above (corpus 1116_injection_onto_deep_path):

when temporal {
    same_session(
        1h,
        Drupe::Action::"Login"::request{ input.user: context.input.user }
    )
};

The first argument 1h fills the within ?w window; the second (a predicate condition) fills ?s. A runnable, validate-passing macro that exercises the same ?s{…} refinement-in-body path (the same_session example above uses a deep context path the validator rejects) is examples/submit_after_approval_injection/.

Macros are a fully specified part of the language; reach for them when you have a reusable temporal pattern. See Calling macros for call syntax across both sublanguages, and Macros for the general macro system (defining def temporal, the sigils, and hygiene).

Writing temporal expressions that are accepted

A temporal expression is accepted only when it is well-defined as a runtime monitor. That is stricter than “it parses.” The rejections all trace back to one requirement: every variable must be bound (by an exists or an aggregation for list) and range-restricted — pinned to a finite set of candidate values by a positive atom — before anything tries to filter it or count over it. Below are the rules, framed as what you must do to be accepted.

Close the condition: bind every variable

A temporal condition must be closed: every variable you write must be bound by an exists (x: T). binder or an aggregation for list. A free variable is rejected at parse time — a temporal leaf is evaluated as a single boolean at the decision point, with no implicit “for some value” reading, so a free variable would silently turn the guard into one that never fires (or correlates the wrong things).

  • Rejected — free variable in a filter: formerly … Transfer{ input.amount: a } && a > 100. Wrap it: exists (a: Long). (formerly … Transfer{ input.amount: a } && a > 100).
  • Rejected — even a single-use free variable: formerly … Login{ input.user: u }. If you mean “any value,” write the wildcard: formerly … Login{ input.user: * }.
  • Accepted: every variable exists-bound or in a for list; field values that are literals, *, or context.… / principal.… / resource.… references (those are not variables).

Range-restrict every exists variable

An exists (x: T). φ is accepted only if x is range-restricted by a positive atom somewhere in φ: a predicate field (P{ f: x }), a tp(x), or an equality ((term/agg) == x or x == (term/agg)). A restrictor under a negation does not count — at any negation depth (a doubly-negated atom still restricts nothing: negation is evaluated as an opaque filter that produces no bindings).

  • Rejected — no restrictor, only ordering filters: exists (x: Long). (0 < x && x < 2). Ordering comparisons filter an infinite domain; nothing pins x.
  • Rejected — restrictor under negation: exists (x: String). !Login{ input.user: x }. The only mention of x is negated. Likewise !(!Login{ input.user: x }).
  • Accepted: restricted by a predicate field, by tp, by an (agg) == n equality, by a literal equality (x == 5), or restricted under a formerly body.

Order conjuncts so producers come before consumers

Within a && chain, every conjunct may produce bindings (a predicate field, tp, a binding equality) and may consume bindings — variables that must already be bound when it evaluates. A consumer is accepted only when the variables it consumes are restricted by a preceding conjunct — in the same chain, or inherited from an enclosing chain (parenthesized sub-chains and nested exists/formerly bodies see everything already established at their position; an enclosing binder alone establishes nothing, and a shadowing binder cuts inherited restrictions of its name). The consumers:

  • a pure filter — an ordering comparison, a guarded negation , or a non-binding == (including x == *: a wildcard is not a value, so the equality binds nothing) — consumes all its free variables;
  • a binding equality x == (aggregate) produces x but consumes the aggregate’s correlated variables (free in its where body, not in its for list) — with them unbound, the count/sum would silently de-correlate into a global tally;
  • a since consumes the left operand’s variables not restricted by its anchor (the left is checked per step and can bind nothing itself).

❌ Rejected — filter before its restrictor:

exists (a: Long). (a > 100 && formerly … Transfer{ input.amount: a })

✓ Fixed — restrictor first:

exists (a: Long). (formerly … Transfer{ input.amount: a } && a > 100)

❌ Rejected — correlated count before its restrictor:

exists (u: String). exists (n: Long). (
    (count for (t: Timepoint). where (formerly … (Login{ input.user: u } && tp(t))))
    == n && n >= 2 && formerly … Login{ input.user: u }
)

✓ Fixed — move the restrictor before the equality:

exists (u: String). (
    formerly … Login{ input.user: u }
    && exists (n: Long). (
        (count for (t: Timepoint). where (formerly … (Login{ input.user: u } && tp(t))))
        == n && n >= 2
    )
)

❌ Rejected — since-left variable restricted only later:

exists (u). ((Read{ input.user: u } since … Login{}) && formerly … Transfer{ input.user: u })

✓ Fixed — put the restrictor first, or restrict u in the anchor:

exists (u). (formerly … Transfer{ input.user: u } && (Read{ input.user: u } since … Login{}))

✓ Accepted — guarded negation after a restrictor (runnable as a Read permit in examples/read_login_not_logout/):

Login{ input.user: context.input.user } && !Logout{ input.user: context.input.user }

✓ Accepted — standard aggregate shape:

exists (n: Long). ((agg) == n && n > 0)

Binding equalities against a ground value (x == 5, x == context.input.limit) are pure producers, so their order never matters.

Bind — and range-restrict — every aggregation for variable

Every free variable of an aggregation body must be bound — either by the for list or by an enclosing binder — and for sum v, the summed variable v must itself be in the for domain. A body variable bound nowhere is a static error, because the projection onto the for columns would leave it dangling. Conversely, every for variable must occur in the body and be range-restricted by a positive atom of it, exactly like an exists binder: an occurrence under a negation, or only in the left operand of a since (only the anchor restricts), pins nothing — the domain would be infinite, and the count or sum would silently collapse.

  • Rejected — unbound body variable: sum a for (a: Long), (t: Timepoint). where (W{user: p, amount: a} && tp(t)) where p is free — p is neither in the for list nor bound by an enclosing binder.
  • Rejected — for variable only under a negation: count for (x: String). where (!(formerly … Login{ input.user: x })) — “the users who did not log in” is an infinite set.
  • Rejected — for variable only in a since-left: count for (w: String). where (Read{ input.user: w } since … Login{}) — the anchor Login{} restricts nothing about w.
  • Accepted: each for variable read from a predicate field (P{ f: x }), a tp(t), or an (agg) == x equality in positive position — a guarded negation after such a restrictor is fine (P{ f: q } && !exists … { f: q }).

Keep aggregates as comparison operands only

As covered above: an aggregate is legal only as the immediate operand of a comparison, never as a predicate argument or nested in another term.

Every monitoring scope must depend on the timepoint (tp-dependence)

A schema-aware check rejects any degenerate monitoring scope — one whose body does not vary with the current timepoint, and therefore “monitors nothing.” Only predicate matches and tp(_) vary between timepoints that share the same request context; literals, context.* references, and entities are timepoint-independent, and an aggregate is always timepoint-dependent. Scopes that are individually checked include each top-level && conjunct, a formerly/previous body, each side of a since, an exists body, and an aggregation’s where body. In practice this means every scope must contain at least one real predicate (or tp); a conjunct made only of literals and context references is rejected.

Schema-level checks

Beyond the structural rules, the schema-aware validation pass also requires: every entity type and enum eid you reference is declared; every context.input.<field> path resolves in the scoped action’s input record; and comparison operands and predicate arguments type-check against the schema. Ordering comparisons need numeric operands on both sides; == needs compatible types. (Predicate event-kind and field-name validation is owned by a separate event-schema checker.) See The policy language for the action schema (entity/action declarations, the context shape) and The event schema for the event-kind and field definitions.

Legality in one sentence

A temporal expression is legal exactly when: it parses under the grammar (only &&, !, since, the three temporal operators, exists, tp, comparisons, count/sum, predicates, refinements, and macro calls); it is closed (every variable bound by an exists or a for list); every aggregation’s for domain binds every free body variable, includes any summed variable, and each for variable occurs in — and is range-restricted by a positive atom of — the body; every exists binder is range-restricted by a positive atom; every aggregate appears only as an immediate comparison operand; every consumer (a pure filter, a binding equality’s aggregate operand, a since-left) is preceded — in its chain or an enclosing one — by a restrictor of the variables it consumes; and, at the schema stage, every entity/enum reference and context.input.X path resolves, every monitoring scope is timepoint-dependent, and all operands type-check.

Evaluation semantics

This section states precisely how a temporal condition is evaluated. Any conforming temporal engine must produce these verdicts.

Decision timepoint and history. A condition is evaluated at a single decision timepoint i against the trace history 0..=i — everything up to and including i. The language is past-only: nothing at any j > i is ever read. The request’s own fields seed the initial bindings (nested groups flattened to dotted keys), plus the scope aliases @principal and @resource.

Key-local semantics under universal pins. The semantics below are stated over the whole trace. When the event schema declares a universal symmetric pin (a field pinned on every event kind to its own request-side path — see The event schema), every temporal condition is instead evaluated over the slice of the trace agreeing with the current request on the pinned field(s): previous means “this key’s previous event,” and the -side of since ranges over this key’s positions only. For formerly, aggregations, and the negated-left since idiom the two readings coincide: a pinned predicate cannot match another key’s event, and a body containing no such predicate is guarded so that a foreign position cannot witness it either. The slice reading is what makes per-key storage and evaluation verdict-preserving. The default event schema declares such a pin, on callerPrincipal, so the key-local reading is the one that applies unless you replace it. Without a universal symmetric pin — a schema that declares none, or one that is partial or asymmetric — the global reading below applies verbatim.

Windows are closed (inclusive). A window of W is the set of past timepoints j with 0 <= ts(i) - ts(j) <= W. A witness exactly W seconds back is in; one second further is out. The lower bound >= 0 is what makes the language past-only.

Operator semantics, “holds at timepoint i”:

  • formerly within W body holds iff body holds at some j in [0, i] with ts(i) - ts(j) <= W. Relationally it collects the body’s satisfying rows at every in-window timepoint, so an aggregation over a formerly body sees one row per satisfying occurrence.
  • previous within W body checks only j = i - 1: it holds iff the immediately preceding timepoint is in-window and body holds there. At i == 0 it is false.
  • left since within W right holds iff there is an anchor j in the window where right holds and left holds at every step k in [j+1, i] (through the decision point). Range restriction for the whole since comes from the anchor right.
  • !a is boolean ¬a; relationally it is an anti-join filter (keep a row iff a does not hold under its bindings).
  • && is evaluated left-to-right, and a binding-producing conjunct on the left extends the environment before the right is evaluated; bindings accumulate. Relationally it is a join on shared columns — this is why the same variable in two predicates correlates them.
  • exists (x: T). φ holds iff φ’s relation is non-empty (≥ 1), not a count; x’s candidate values come only from the atom that binds it, with no enumeration of the type.
  • tp(t) binds or unifies t with the current timepoint index i.
  • count / sum project the where body’s satisfying rows onto the for domain, deduplicate, then count the rows or sum the named column. Distinctness is the visible choice you make in the for list. Summation is exact for every total a Long can hold, including totals reached by way of partial sums that a Long cannot, and does not depend on the order rows are visited. What a total OUTSIDE the Long range means is implementation-defined — a conforming implementation may clamp, widen, or report an error, and the shapes that can observe the choice are enumerated in §5.4 Temporal acceptance. This implementation clamps rather than raising, so a pathological trace cannot overflow into a panic. Only the binder name is used at evaluation time; the type annotation is not consulted.
  • Comparisons== and != are domain equality; ordering requires both sides to be integers, else false; an unresolved operand makes the comparison false. An == with exactly one unbound variable operand binds that variable.

See also

  • 02-policy-language.md — the core Cedar-derived policy language, the when/unless clauses that host a temporal { … } block, and the action schema (entity/action declarations and the context shape) that temporal validation checks against.
  • 03-event-schema.md — how event kinds and their fields are declared (the ::kind and field names a predicate matches).
  • 05-information-providers.md — information providers (external computed facts), invoked as plain Cedar calls in an ordinary when { … }.
  • 09-calling-macros.md — calling macros (both def cedar and def temporal) at the sites shown here.
  • 06-macros.md — the general macro system, including defining def temporal.
  • 00-introduction.md and 01-getting-started.md — orientation and setup.
  • 07-api-and-workflow.md — lowering policies and running the monitor.

Information Providers

This page covers using Dogwood’s information providers: values computed at authorize time by a small piece of sandboxed code, then folded back into a policy as if they had always been part of the request context. It explains how you call a provider from an ordinary when { … } clause, the arguments a provider can take, how its output composes with the rest of a condition, and how that output reaches you as context.providers.<id>. Corpus cases named below are directories under dogwood-language/tests/passing/provider_only/corpus/; each complete policy shown below is also a runnable bundle under examples/.

This page takes the providers themselves as given. Declaring one — the providers.json format, the Rhai implementation contract (the sandbox, host functions, decimal support, the off-by-default net feature), and the advanced features (output methods, no-implementation providers, and the guardrails { … } sugar) — is the subject of the Advanced-topics page The provider schema.


What an information provider is, and why

Cedar policies decide on the request they are given. Sometimes the fact you want to authorize on is not in the request — it has to be computed: does this document match a regex? Is this string on a denylist? What is the risk score of this content according to some classifier? An information provider lets you write that computation once, declare its shape, and then reference its result inside a policy as though it were an ordinary context attribute.

A provider is lowering-time sugar over Cedar. When you write a provider invocation in a policy, Dogwood does not invent a new runtime evaluator. Instead, at lowering time it hoists the invocation out of the policy and rewrites it to a reference into context.providers.<id>. The Cedar that the engine ultimately evaluates contains no provider call at all — just a plain attribute access and comparison. Then, at authorize time, Dogwood runs the provider’s declared implementation, and binds its output record into context.providers.<id> before handing the request to Cedar.

Because providers are hoisted at lowering time, the hoisted field must be typed and declared in the schema. A rule that calls a provider may use any action scope — action == Ns::Action::"X", action in [list], action in Group, or a bare unconstrained action — because the hoisted field is grafted onto every action’s context and the provider is evaluated for every decision event (see The provider contract below).


Calling a provider

You call a provider directly inside an ordinary when { … } clause, exactly where you would write any other Cedar condition. There is no special marker: any namespace-qualified name (Ns::Fn) is read as a provider invocation. You invoke it, reach into its output, and compare — all as part of a normal Cedar expression:

permit ( principal, action == Doc::Action::"read", resource )
when {
    Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
};

That reads as: call the Strings::Matches provider with the document and a regex, take the matched field of its output, and require it to be true.

Because the call sits in ordinary Cedar, only the call itself is special — the projection (.matched) and comparison (== true) are plain Cedar. That means a provider’s output composes with the full Cedar expression language: arithmetic, if/then/else, any method, &&/||/!, comparisons against other context fields — anything Cedar allows.

The shape of a call

A provider call has three parts, in order:

  1. InvocationNs::Fn(args). The function name must be namespace-qualified: it needs at least two ::-separated segments (like Strings::Matches, Content::Risk, Lists::Blocked). A bare single-segment name is not a valid provider function. More segments are allowed (Ns::Sub::Fn).
  2. Projection — zero or more accessors that reach into the provider’s output record: .field (field access) or ["key"] (index access). Because Cedar has no positional list indexing, an index must be a string key: record["k"] reads the k field. The projection may be empty, in which case the output is compared directly.
  3. Comparison — any Cedar comparison. Against a bool/long/string output you use the ordinary operators (==, !=, <, <=, >, >=). A decimal output supports == and !=; to order one, use Cedar’s decimal-extension methods (lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual) with a decimal("…") literal.

A complete worked example

Here is a provider from the policy author’s side. It uses the Strings::Matches provider (corpus case 0001_regex_matches_uppercase), which asks whether a document matches a regular expression. The policy (policy_1.dw):

permit ( principal, action == Doc::Action::"read", resource )
when {
    Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
};

Runnable: examples/provider_regex_matches_uppercase/dogwood validate and dogwood replay.

Behind it, Strings::Matches is declared in a providers.json (with argument types [string, string], an output record { matched: bool }, and a small Rhai script) — the declaration side is covered in The provider schema.

What happens: at lowering time, Strings::Matches(context.input.document, "^[A-Z]+$") is hoisted to a context.providers.<id> reference, and the policy Cedar becomes context.providers.<id>.matched == true. At authorize time, Dogwood runs the provider’s evaluate("...the document...", "^[A-Z]+$"), gets back the record { matched: … }, binds it into context.providers.<id>, and Cedar evaluates .matched == true. So "ABC" gives true (permit), "abc" gives false, and "AB12" gives false.

Combining providers with the rest of a condition

Because a provider call is just part of an ordinary Cedar condition, it combines with &&, ||, !, parentheses, other provider calls, and plain Cedar terms — the full expression language (see The policy language).

Two providers combined with && and ! (the Strings::Matches and Lists::Blocked providers of case 0004_two_providers_and_not) — a fragment; the full rule is the provider_matches_and_not_blocked bundle:

when {
    Strings::Matches(context.input.document, "^[a-z]+$").matched == true
    && !(Lists::Blocked(context.input.document).blocked == true)
};

Disjunction and parentheses (the Lists::Allowed and Strings::Length providers of case 0007_boolean_or_parens) — a fragment; the full rule is the provider_allowed_or_short bundle:

when {
    (Lists::Allowed(context.input.document).allowed == true
     || Strings::Length(context.input.document).length < 4)
};

Several calls to the same provider, each with plain-Cedar comparisons — case 0005_regex_operations (a fragment; the full rule is the provider_regex_analyze_fields bundle):

when {
    Regex::Analyze(context.input.document, "^[A-Z]").is_match == true
    && Regex::Analyze(context.input.document, "[0-9]").count >= 3
    && Regex::Analyze(context.input.document, "[0-9]+").first_match == "42"
};

Because the output is plain Cedar, it can feed ordinary Cedar expressions — for instance, an integer output used in arithmetic alongside an ordinary context field — case 0010_unwrapped_mixed_with_cedar (a fragment; the full rule is the provider_int_arithmetic_trusted bundle):

when {
    context.input.trusted == true
    && Strings::DigitCount(context.input.document).count + 1 <= 3
};

The comparison, in two forms

The comparison against a provider’s output comes in two flavors, depending on the output’s type.

Operator form uses one of <=, >=, ==, !=, <, >. Verified examples across the corpus include .matched == true (case 0001_regex_matches_uppercase), .length < 5 (case 0002_length_threshold), and .count >= 2 (case 0008_greater_than_family) — this fragment’s full rule is the provider_digitcount_operator_ge bundle:

when {
    Strings::DigitCount(context.input.document).count >= 2
};

Method form uses Cedar’s decimal comparison methods — lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual — to compare a decimal output against a decimal("…") literal (the Content::Risk provider of case 0003_decimal_score_method) — a fragment; the full rule is the provider_risk_decimal_method bundle:

when {
    Content::Risk(context.input.document).severityScore.lessThan(decimal("0.5"))
};

Use the method form to order a decimal output; == and != work on a decimal directly, and the operators cover bool / long / string outputs.

Projection: reaching into the output record

The projection is the path between the invocation and the comparison. Field access (.field) and index access (["key"]) can be chained. Because Cedar has no positional list indexing, an index must be a string key: record["k"] reads the k field.

The Content::Filter provider of case 0006_set_arg_index_projection chains an index accessor and a field accessor, then compares with the decimal method form (a fragment; the full rule is the provider_filter_set_index_decimal bundle):

when {
    Content::Filter(context.input.document, ["VIOLENCE", "HATE"])["VIOLENCE"].severityScore.lessThan(decimal("0.5"))
};

Here ["VIOLENCE"] selects the VIOLENCE sub-record from the output, .severityScore reads its field, and .lessThan(decimal("0.5")) compares.

Providers work under permit and forbid

A provider call is independent of the rule’s effect: it works the same under permit and forbid. Here the Strings::DigitCount provider of case 0008_greater_than_family gates a forbid (with a catch-all permit alongside):

forbid ( principal, action == Doc::Action::"post", resource )
when {
    Strings::DigitCount(context.input.document).count >= 2
};

Runnable: examples/provider_digitcount_forbid/ — the forbid plus a catch-all permit; dogwood validate and dogwood replay.

A caution on names

A call is read as a provider invocation because of its shape — any namespace-qualified name — not because it is declared. Declaredness is a separate check, made at lowering: a namespace-qualified call that matches no declared provider (and no macro and no Cedar built-in) is a hard error, “unresolved call to Ns::Fn reached lowering — it is not a declared information provider, not a declared macro, and not a Cedar built-in”. So a mistyped provider name is caught, not silently ignored.


Provider arguments

A provider invocation passes arguments positionally. Because a provider is resolved before Cedar runs (it helps build the context Cedar evaluates against), an argument must be a value Dogwood can read off the request event directly. The argument kinds are:

  • Attribute-path referencecontext.input.x, principal.id, resource.owner. It begins with one of the roots context / principal / resource, followed by one or more .ident segments. At authorize time Dogwood resolves the path against the decision event: a context path is looked up on the event’s input (leading context skipped); principal / resource resolve to the request scope entity, and a trailing .id / .type projects that entity’s id or type. A path that does not resolve is null.
  • String literal"…".
  • Integer literal — an optionally-signed integer (i64).
  • Decimal literaldecimal("0.5") (chiefly a method threshold, e.g. scoreAbove(decimal("0.5"))).
  • Bool literalstrue / false.
  • Set[ arg, … ], possibly empty, and possibly nesting other args (a set of strings, ints, bools, or nested sets/paths).

Arbitrary Cedar (arithmetic, if/then/else) is not a provider argument — only the value forms above. This mirrors the temporal-logic argument restriction.

Field-and-string arguments — case 0001_regex_matches_uppercase:

Strings::Matches(context.input.document, "^[A-Z]+$")

A principal-rooted argument — case 0015_principal_id_arg (a fragment; the full rule is the provider_principal_id_allowlist bundle):

Access::Allowed(principal.id)

A set argument — cases 0006_set_arg_index_projection / 0009_unwrapped_no_marker:

Content::Filter(context.input.document, ["VIOLENCE", "HATE"])

At authorize time, each argument is resolved to a runtime value (event fields and scope entities as above, literals as-is, sets recursively), and the values are passed positionally to the provider’s implementation, matched to its declared argument order.

Declaring a provider. The signature you invoke against — the argument types, the output record, and the implementation that computes it — is declared in a providers.json file, described in full in The provider schema. This page assumes those declarations already exist and focuses on calling them from a policy.


How a provider binds to context.providers.<id>

Putting the pieces together, here is what happens to a provider invocation from the caller’s point of view.

Lowering time. Every provider invocation is hoisted: the call leaf is replaced with a reference into context.providers.<id>. The surrounding projection and comparison were already ordinary Cedar, so they lower natively — an index ["k"] becomes .k, and the comparison stays as whatever Cedar op you wrote. (The generated field names and the Cedar-schema augmentation this entails are covered in The provider schema.)

Authorize time. For each decision event Dogwood builds the Cedar request context. It passes context.input through from the event, evaluates every declared provider field (resolving each argument, then running the provider), and collects the outputs into a single context.providers object keyed by id. So context.providers.<id> holds that provider’s evaluated output record, and Cedar evaluates the (already-lowered) comparison against it.

So you write the surface form Ns::Fn(args).field <cmp> literal, and the engine evaluates context.providers.<id>.field <cmp> literal against the bound output. For case 0001_regex_matches_uppercase, the engine runs the provider, binds { matched: … }, and Cedar evaluates .matched == true — giving "ABC" → true, "abc" → false, "AB12" → false. For case 0006_set_arg_index_projection, document="violent" scores VIOLENCE at 0.90 so .lessThan(0.5) is false (deny), document="safe" scores 0.10 so it is true (permit), and document="hateful" scores VIOLENCE at 0.10 (only HATE is 0.90) so it is also true (permit).


The provider contract

Provider execution is unconditional. A provider invocation belongs to a rule, but its evaluation is not gated by that rule — not by the rule’s action clause, its principal/resource constraints, its when/unless conditions, or whether the rule could fire at all. For every decision event, every provider invocation in the policy set is evaluated and its output bound into context.providers; Cedar alone then decides which policies fire, using its ordinary scope and condition semantics. (Deciding “could this rule match this event?” before running its provider would mean re-implementing Cedar’s scope semantics inside the provider machinery, so Dogwood does not.)

Three consequences for provider authors:

  1. Providers must be pure. A provider may run for events its rule has nothing to do with, and implementations are free to skip, cache, reorder, or repeat evaluations whose results cannot affect the verdicts. A provider must be a deterministic function of its arguments with no observable effects. (Replay — dogwood replay — and checking one engine against another also assume this.)

  2. Any argument may be absent. On an event whose context or scope entities do not carry the fields a provider reads (a different action’s input shape, a resource type without the attribute), the argument arrives as Null — in a Rhai script, the unit value (). Scripts must be defensive: detect unit arguments and return a sentinel that conforms to the declared outputType instead of erroring:

    fn evaluate(text) {
        if type_of(text) == "()" {
            return #{ length: -1 };
        }
        #{ length: text.len() }
    }
    

    Choose the sentinel deliberately, and mind the polarity trap: a sentinel that makes a guard false is the restrictive direction under permit (the rule doesn’t fire) but the permissive direction under forbid (the denial doesn’t fire). Pick the sentinel that fails safe for the polarity of the rules reading it.

  3. An erroring provider is undefined behavior. If a provider evaluation errors (a script that throws, an external resolver that fails), the decision outcome carries no guarantees. This reference interpreter fails closed: it denies the request and reports the error in the response’s diagnostics. That is an implementation choice, not a contract — other implementations, or future versions of this one, may avoid the error entirely (and reach a different verdict) or handle it differently. A policy set whose safety depends on an erroring provider denying is incorrect on every implementation, including this one. Defensive scripts (point 2) are the only defense.

The same contract applies to external providers supplied through a ProviderResolver: pure, tolerant of Null arguments, never relying on error behavior.


How it composes with the rest of a policy

Providers slot into the same rule structure as everything else. A rule scopes a principal, action, and resource in the usual Cedar way (see The policy language), and the provider call lives in an ordinary when { … } clause, freely combined with plain Cedar conditions and with other providers.

The companion feature is temporal expressions, written with when temporal { … } — see Temporal expressions. Where a provider computes a value for the current request, a temporal expression reasons about the history of requests. Both are lowered into ordinary Cedar the engine can evaluate.


Advanced features

Two provider features exist but most policies do not need them, and both are documented on the declaration-side page, The provider schema:

  • The guardrails { … } clausewhen guardrails { E } is transparent sugar for a bare when { E }; the tag carries no semantics and is retained for compatibility with existing policies. You can call a provider from an ordinary when just as well.
  • Output methods and no-implementation providers — post-processing a provider’s output with a declared method (Provider::Fn(args).method(…)), and declaring a provider with no implementation so its value is supplied by your own code.

See The provider schema for both.


See also

  • The provider schema — declaring a provider: the providers.json format, the Rhai implementation contract, and the advanced features above.
  • The policy language — rule structure and the Cedar condition language a provider call lives in.
  • Temporal expressions — the companion feature, reasoning about request history.
  • Calling macros — the other way to reuse logic across policies.
  • The API and workflow — wiring provider declarations into a ServiceSchema (ServiceSchemaBuilder::providers) and the authorize-time flow that evaluates providers.

Macros

This page is the Advanced-topics deep dive on defining Dogwood macros: lowering-time templates that let you name and reuse a fragment of policy logic. It explains the two kinds of macro (def cedar and def temporal), the two parameter sigils (?p value parameters and $t fresh binders), how calls are checked for arity and kind, how hygiene keeps reused macros from capturing each other’s variables, every rule that will get a macro rejected, and how to ship a reusable macro library alongside a schema.

If you just want to call a macro that already exists — where a call may appear and what shape its arguments take — see Calling macros; this page is what you write to define one.

What a macro is (and is not)

A Dogwood macro is a lowering-time template, not a runtime function. There is no call stack, no recursion, and nothing that survives into the lowered monitor. You declare a macro once at the top of a .dw file, and everywhere you call it expansion splices the macro body in — substituting the call arguments — before the policy is lowered. By the time the temporal evaluator or the Cedar backend sees your policy, there are no macro definitions and no calls left; the definitions have been consumed and every call has been replaced by its expanded body.

Because macros are templates, they give you two things:

  • Naming. is_small(context.input.shares) names the check context.input.shares < 100, and the threshold lives in exactly one place.
  • Reuse without duplication. A temporal pattern like “did this happen in the last hour” can be written once and called from many policies. Dogwood’s hygiene rules (see Hygiene) make that reuse safe even when the pattern introduces its own bound variables.

What macros are not: they are not first-class values, they cannot be passed around at runtime, they cannot call other macros from inside their own body (see No macro-in-macro), and they are not scoped blocks — a macro is always declared at the top level of a file, never inside a policy.

Declaring a macro: the two kinds

A macro definition looks like a policy rule that starts with def, names a kind, gives the macro a name and a parameter list, wraps a body in braces, and ends with a mandatory semicolon:

def cedar    <name>(?p, ?q, ...) { <cedar expression> } ;
def temporal <name>(?w, ?s, ...) { <temporal condition or aggregation> } ;

The kind keyword after defcedar or temporal — chooses the sub-language the body is parsed in, and that in turn decides where the macro may be called. Definitions may be interleaved with policies in any order; all definitions are collected first, then calls in the policies are expanded.

The trailing ; is required, exactly as it is on a policy rule. The parameter list is optional: a zero-argument macro is just name().

def cedar — a pure Cedar expression

A def cedar macro’s body is an ordinary Cedar expression. Wherever you would write that expression by hand — inside a when { ... } clause, or as part of a larger expression — you can instead call the macro.

One example names a threshold:

def cedar is_small(?n) { ?n < 100 };

and is called as an expression:

permit(principal, action, resource)
when { is_small(context.input.shares) };

Runnable: examples/cedar_is_small_threshold/dogwood validate (the macro library is supplied with --macros).

Cedar macros compose with ordinary Cedar operators. Here two of them are joined with &&, and each takes an argument of a different type:

def cedar is_eligible(?shares, ?stock) { ?shares < 100 || ?stock == "FOO" };
def cedar is_not_blocked(?stock) { !(?stock == "BLOCKED") };

permit(principal, action, resource)
when {
    is_eligible(context.input.shares, context.input.stock)
    && is_not_blocked(context.input.stock)
};

Runnable: examples/cedar_eligible_not_blocked/dogwood validate.

A Cedar macro body can be any Cedar expression, including like patterns and if/then/else:

def cedar starts_with_f(?s) { ?s like "F*" };

def cedar within_cap(?stock, ?shares) {
    if ?stock == "FOO" then ?shares <= 10 else ?shares <= 1000
};

Runnable: examples/cedar_starts_with_f_like/ and examples/cedar_within_cap_if_else/ — each wraps the macro in a full rule; dogwood validate.

A Cedar macro can even build a record and be passed as an argument to another Cedar macro. This is the RFC 0061 semver worked example — two macros, where semver constructs a { major, minor, patch } record that semverGT compares:

def cedar semver(?major, ?minor, ?patch) {
    { major: ?major, minor: ?minor, patch: ?patch }
};
def cedar semverGT(?a, ?b) {
    if ?a.major == ?b.major
    then (if ?a.minor == ?b.minor then ?a.patch > ?b.patch else ?a.minor > ?b.minor)
    else ?a.major > ?b.major
};

permit(principal, action, resource)
when { semverGT(semver(2, 1, 1), semver(2, 1, 0)) };

Runnable: examples/cedar_semver_gt/dogwood validate.

This works because call arguments are expanded first, and the result is then spliced into the outer macro’s body — nesting one macro call as an argument to another is fine. (Nesting a call inside a macro’s declared body is not; see No macro-in-macro.)

def temporal — a temporal condition or aggregation

A def temporal macro’s body is written in the temporal sub-language (the same one you write inside a when temporal { ... } clause; see Temporal expressions). Depending on what the body matches, a def temporal macro comes in one of two flavours, chosen automatically by the parser:

  • a temporal condition — for example a formerly within ... ... predicate, which is callable wherever a temporal condition is expected; or
  • a temporal aggregation — a count/sum expression, which is callable only as an operand of a comparison (the value side of an aggregation), never as a standalone condition.

The distinction matters at call sites, because Dogwood enforces which flavour may appear where (see Kind checking).

A condition-flavoured temporal macro is frequently a wrapper. once takes a window ?w and a whole condition ?s, and wraps them in formerly within (once only puts a name on formerly within and adds no capability of its own; it is shown here for the shape a temporal macro takes, and the next example puts a macro to fuller use):

def temporal once(?w, ?s) { formerly within ?w ?s };

permit(principal, action in [Drupe::Action::"Read", Drupe::Action::"Write"], resource)
when temporal {
    once(1h, Drupe::Action::"Read"::request{
        input.user: context.input.user,
        input.document: context.input.document
    })
};

Runnable: examples/temporal_once_read_recent/dogwood validate and dogwood replay.

Condition macros compose the same way Cedar ones do. Two of them joined with && inside a single temporal block:

def temporal recently_logged_in(?u) {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: ?u }
};
def temporal recently_read(?u, ?d) {
    formerly within 1h Drupe::Action::"Read"::response{
        input.user: ?u, input.document: ?d
    }
};

permit(principal, action == Drupe::Action::"Write", resource)
when temporal {
    recently_logged_in(context.input.user)
    && recently_read(context.input.user, context.input.document)
};

Runnable: examples/temporal_login_then_read/dogwood validate and dogwood replay.

An aggregation-flavoured temporal macro produces a count or sum. It is spliced into a comparison, never called on its own. count_formerly counts the timepoints in a window at which a predicate held:

def temporal count_formerly(?w, ?s) {
    count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
};

permit(principal, action == Drupe::Action::"Alert", resource)
when temporal {
    exists (n: Long). (
        (count_formerly(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        })) == n
        && n > 0
    )
};

Runnable: examples/temporal_count_formerly_login/dogwood validate and dogwood replay.

An aggregate value is always compared inside an exists binder — that is what introduces the n the count is compared against (exists is the temporal sublanguage’s only binder; see Temporal expressions). The macro call fills the aggregate slot.

The $t in that body is a fresh binder the macro introduces itself, not a parameter. That is the subject of the next section.

Parameters: two sigils, two jobs

Dogwood macros use two sigils, and they do different things. The distinction matters when writing temporal macros.

?p — value / expression parameters

A ?p parameter is declared in the parameter list and receives an argument at each call site. Expansion splices the call argument literally into every occurrence of ?p in the body. This is ordinary template substitution.

?p parameters can stand for several different kinds of thing depending on where they appear in the body:

  • an ordinary term/expression (is_small(?n) where ?n is compared);
  • a window in a within clause (within ?w), filled by an interval literal like 1h;
  • a whole condition (once(?w, ?s) where ?s is an entire temporal condition); or
  • a binder position — see below.

In all of these, the rule is the same: ?p is declared once in the parameter list, and each call supplies exactly one argument for it.

The parameter list always stores names without the ? internally, but you always write the ? — both in the declaration and at every use inside the body. There is no whitespace allowed between the ? and the name.

$t — fresh binders (introduced inline, never declared)

A $t binder is spelled with a dollar sign and is not declared in the parameter list. It receives no call-site argument. Instead, it is a placeholder for a fresh bound variable that the macro introduces for its own internal use — typically the timepoint variable a count/sum iterates over.

Look again at count_formerly:

def temporal count_formerly(?w, ?s) {
    count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
};

?w and ?s are parameters (declared, filled by the caller). $t is a fresh binder: the macro needs a timepoint variable to count over, so it names one $t. The caller never passes a $t argument — it is entirely internal machinery. At expansion, each $t is renamed to a unique concrete name (see Hygiene).

The $ sigil is distinct from ! (negation) so it can never be confused with an operator; $ is used nowhere else in the surface syntax. A $t may appear anywhere a regular identifier or binder can go: a term position, a binder list, a tp(...) argument, or a sum’s bound variable. It may not stand for a whole condition (see Rejection rules).

Binder-position parameters (?p used as a binder)

There is one subtle case that ties the two sigils together: a ?p parameter can be used in a binder position — for example as the bound variable of a sum, or inside a for (?a: Long). When a ?p is used that way, the argument the caller passes for it must be a single bare identifier, because that identifier is going to become a bound variable name. Passing anything else (a literal, a compound expression) is a hard error.

In sum_formerly, ?a is used both as the sum bound variable and inside for (?a: Long), so ?a is a binder-position parameter, while ?w is a window and ?body is a condition, and $t is the macro’s own fresh timepoint binder:

def temporal sum_formerly(?a, ?w, ?body) {
    sum ?a for (?a: Long), ($t: Timepoint). where (formerly within ?w (?body && tp($t)))
};

permit(principal, action == Drupe::Action::"Alert", resource)
when temporal {
    exists (total: Long). (
        (sum_formerly(a, 1h, Drupe::Action::"Transfer"::request{
            input.user: _, input.amount: a
        })) == total
        && total > 100
    )
};

Runnable: examples/temporal_sum_formerly_transfer/dogwood validate and dogwood replay.

As with count_formerly, the aggregate is compared inside an exists (total: Long). (…) binder, which introduces total.

The caller passes the bare identifier a for ?a; that identifier fills the binder slot. If you called it with a non-identifier such as a literal, the call is rejected: “parameter ?a is used in a binder position, so the call-site argument must be a single identifier”.

There is a difference between ?a (a binder-position parameter the caller names) and $t (a binder the macro names for itself). Use ?p when the caller should choose the variable; use $t when the variable is purely internal and should be hygienically fresh.

Calling macros

A call is written name(arg, arg, ...). Where it may appear depends on the macro’s kind, and every call is checked in three ways: the call site’s kind must match the macro’s kind, the number of arguments must match the number of parameters, and each argument’s shape must match how the corresponding parameter is used in the body.

Where each kind is callable

Macro body kindCallable in
def cedarCedar-expression position (when { ... }, or mid-expression)
temporal conditionTemporal condition slot (when temporal { ... }, &&, a formerly body, etc.)
temporal aggregationAggregation-value position (an operand of a comparison)

A Cedar macro can even be conjoined with a temporal block mid-expression — the Cedar macro is expanded before the surrounding expression is lowered:

def cedar level_ok(?n) { ?n >= 2 };

permit(principal, action, resource)
when { level_ok(context.input.level) && temporal { /* ... */ } };

Runnable: examples/cedar_macro_plus_temporal_leaf/ — the bundle fills the temporal { … } leaf with a recent-Login check; dogwood validate and dogwood replay.

Arity checking

The number of call arguments must exactly equal the number of declared parameters. A macro declared foo(?a, ?b) called as foo(principal) fails with “macro foo expects 2 argument(s), got 1”. This is checked on every call path — Cedar, temporal-condition, and aggregation.

Kind checking

A call slot demands a specific macro kind, and a mismatch is a hard error, never a silent coercion. The messages are specific about what went wrong:

  • Calling a temporal macro in a Cedar-expression position: “macro <name> is a temporal macro and cannot be called in a cedar expression position”.
  • Calling a Cedar macro in a temporal condition position: “macro <name> is a cedar macro and cannot be called in a temporal condition position”.
  • Calling an aggregation macro as if it were a condition: rejected — an aggregation macro must appear as a comparison operand, not as a standalone condition.
  • Calling a condition macro in an aggregation-value position: rejected — a condition macro cannot be used where an aggregation value is expected.

Argument-shape checking

Even with the right count and kind, each argument’s shape must match how the parameter is used inside the body:

  • A whole-condition parameter (?s in once) must be given a temporal condition argument.
  • A window parameter (within ?w) must be given a bare interval literal such as 1h — not a within clause. The body writes formerly within ?w (...), and the call supplies just 1h.
  • A term-position parameter must be given a term (an expression), not a condition or an interval.
  • A binder-position parameter must be given a single bare identifier, as described above.

Passing an argument whose shape does not match the parameter’s use is a hard error explaining the expected shape.

Hygiene: $t is fresh per call site

Reusing a macro that introduces its own bound variable would break if the macro’s variable could collide with a variable the caller already has in scope. Consider sum_formerly again: its body binds $t, and the caller of sum_formerly might also have a variable named t in scope. If the macro’s $t and the caller’s t were the same name after expansion, they would capture each other and the policy would mean something the author never wrote.

Dogwood prevents this with hygiene: at expansion, every distinct $t name in a macro body is renamed to a fresh, unique concrete name of the form <name>$<offset>, where <offset> is the byte position of the call site. So a $t becomes something like t$412, which cannot collide with a user’s t.

Two properties make this both safe and predictable:

  • All occurrences of one $t name within a single expansion get the same gensym. The binder and its uses stay tied together — count for ($t: ...) and the tp($t) inside it become the same fresh name, so the count still iterates over the variable it binds.
  • Different call sites get different gensyms. Because the fresh name is derived from the call site’s byte offset, calling the same macro in two different policies produces two different fresh names. That is exactly what makes reusing a macro across policies safe.

Concretely, if a caller passes its own identifier t as a bound-variable argument and the macro also uses $t internally, the macro’s $t expands to t$<offset>. A source identifier cannot contain $, so the fresh name is not expressible in the surface syntax and cannot collide with the caller’s t. And because the gensym is a deterministic function of the source position, the same source always produces the same name, keeping builds reproducible.

What gets rejected

The following are all hard errors, caught during the macro-expansion pass.

Reserved names

A macro name may not shadow a Cedar built-in or a temporal keyword. The reserved set is:

decimal, datetime, duration, ip,        // Cedar built-in unary calls
let, in, where, for, sum, count, tp,    // temporal keywords / operators
formerly, previous, since,
true, false                             // literals

let is reserved although the language has no let form. Naming a macro count, for example, fails with “macro name count is reserved (built-in or keyword); pick a different name”.

Duplicate definitions

Two defs with the same name in the merged set are rejected: “duplicate macro definition <name>”. (This does not apply to a library macro and a policy macro of the same name — see Precedence. In that case the library one is silently dropped rather than treated as a duplicate.)

Undeclared parameters in a body

Every ?p reference inside a body must name a declared parameter. A def cedar body that references ?x when ?x was never declared fails with “in def cedar body: ?x does not name a declared parameter”. The same check applies to temporal bodies in every position a ?p can appear (term, window, binder, and whole-condition positions).

$t binder names are the exception: they are not checked against the parameter list (they are never declared there), and are tied together by name at expansion.

No macro-in-macro

A macro body may not contain a call to another macro. A def cedar body that calls another macro fails with “in def cedar body: macro-in-macro is not supported”; the temporal equivalent is “in def temporal body: macro-in-macro is not supported”.

This is distinct from nesting a call as a call-site argument, which is allowed: semverGT(semver(2, 1, 1), semver(2, 1, 0)) works because the arguments are expanded first and then spliced in. The restriction is only on a macro’s own declared body containing a call.

No temporal block inside a Cedar macro body

A def cedar body must be a pure Cedar expression; it may not contain a temporal { ... } block. Attempting it fails with a message telling you to “lift the block to the call site instead”. The reason is that Cedar substitution does not descend into the temporal sub-language, so a ?p inside one would never be substituted, and even a self-contained block would be re-evaluated per call site.

An information-provider invocation (Provider::Name(args)…) is a namespaced Cedar call, not a block, so it is subject to the macro-in-macro rule above: a provider call inside a def cedar body fails with “macro-in-macro is not supported” — lift it to the call site. (There is no guardrails { … } to reject in a macro body: guardrails is a clause tag, not an expression form. See Information providers.)

Stray sigils outside a macro body

A ?p or $t that survives expansion outside any macro body is rejected, because there is nothing to substitute it. Writing ?something in a policy that is not inside a macro body fails with “stray macro parameter reference ?something outside a macro body”; a top-level $t in a for-binder fails with “stray macro binder reference $t”. (?principal and ?resource in Cedar templates are a different feature — template slots — and are handled before the macro pass.)

A whole-condition $t has no meaning

A $t cannot stand for an entire condition — a bare binder name is not a condition. This is rejected both by the grammar (only ?p, never $t, may fill a whole-condition slot) and, if it somehow reaches expansion, with “macro binder $<name> used as a whole condition has no meaning”.

Unknown macro call

A call whose name is neither a Cedar built-in nor a declared macro is rejected. In Cedar position: “unknown function or macro <name> (not a Cedar built-in and not a declared macro)”. In temporal position: “unknown macro <name> (no declared def temporal)”. (One exception exists that is not a macro: a call whose name is a declared information provider is left intact for the Cedar backend — see Information providers.)

The macro library

Macros declared at the top of a .dw file are visible only to the policies in that file. To share macros across many policy files, Dogwood lets you attach a macro library to a schema. Reusable def cedar / def temporal definitions in the library are merged into every policy set lowered against that schema.

DEFAULT_MACROS: the built-in standard library

If you build a schema without specifying a library, Dogwood uses a built-in default, DEFAULT_MACROS. It ships a small standard library of temporal aggregation macros, so every policy can call them without redeclaring them:

  • count_within(?w, ?s) — counts the timepoints within window ?w at which condition ?s held.
  • sum_within(?a, ?w, ?body) — sums the numeric value ?a over occurrences of ?body within window ?w.
  • count_distinct_within(?k, ?w, ?s) — counts the distinct key values ?k (a String) for which ?s held within window ?w.
  • bind(?n, ?A, ?B) — a “let”-style binder that names an aggregate result ?n and uses it in the predicate ?B, so an aggregate can be compared or thresholded via ?A == ?n.

The file backing DEFAULT_MACROS is embedded into the crate at build time via include_str!, so these macros are available to every caller regardless of working directory — there is no runtime file lookup. A policy’s own def of the same name still takes precedence over a default, so a policy can always override or shadow a standard-library macro with its own definition.

Using the standard-library macros

All four are def temporal macros, so they are called inside a when temporal { … } block. Because they expand to an aggregate term, the usual idiom is to bind the aggregate’s value with exists (n: Long). (… == n && …) and then threshold n — or let bind write that scaffold for you. The examples below assume an action schema with Login, Transfer, and Alert actions; each is a runnable corpus case (tests/passing/macros/corpus/stdlib_default_*).

count_within — permit an Alert only when more than two Logins to the same server occurred within the last hour:

permit ( principal, action == Drupe::Action::"Alert", resource )
when temporal {
    exists (n: Long). (
        count_within(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        }) == n
        && n > 2
    )
};

sum_within — permit an Alert when the total amount transferred by any user exceeds 200 within the last hour. The first argument (a) names the value column to sum; it is bound from the predicate’s input.amount: a:

permit ( principal, action == Drupe::Action::"Alert", resource )
when temporal {
    exists (total: Long). (
        sum_within(a, 1h, Drupe::Action::"Transfer"::request{
            input.user: _, input.amount: a
        }) == total
        && total > 200
    )
};

count_distinct_within — permit an Alert when more than two distinct users logged in to the server within the last hour. The key u is a for-binder with no timepoint, so repeated values across timepoints deduplicate (contrast count_within, which counts events):

permit ( principal, action == Drupe::Action::"Alert", resource )
when temporal {
    exists (d: Long). (
        count_distinct_within(u, 1h, Drupe::Action::"Login"::request{
            input.user: u, input.server: context.input.server
        }) == d
        && d > 2
    )
};

bind — writes the exists (n: Long). (agg == n && pred) scaffold for you. It composes with the aggregate macros, so the count_within example above can be written without the hand-rolled exists:

permit ( principal, action == Drupe::Action::"Alert", resource )
when temporal {
    bind(
        n,
        count_within(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        }),
        n > 2
    )
};

bind’s value argument is any term-position aggregate, so it accepts a raw hand-written count for (t: Timepoint). where (…) just as readily as a count_within(…) call.

Supplying your own library with macros_str

The macro library is part of the ServiceSchema — the fixed, service-provided half of a schema (macros, providers, event schema). Provide the library source to the ServiceSchemaBuilder via macros_str (see API and workflow for the full builder). The library source is ordinary Dogwood source containing def definitions:

#![allow(unused)]
fn main() {
let service = ServiceSchema::builder()
    .macros_str(
        "def temporal once(?w, ?s) { formerly within ?w ?s };\n\
         def cedar    is_small(?n) { ?n < 100 };",
    )
    .build()?;
}

Every policy set parsed against this service schema can then call once and is_small without redeclaring them (macro expansion runs in the parse phase, against the ServiceSchema alone — no action schema needed). Supplying macros_str replaces DEFAULT_MACROS entirely, so a custom library does not automatically include the standard-library macros; if you want them alongside your own, copy their definitions into your library source. If you omit macros_str, DEFAULT_MACROS (the standard library above) is used.

Runnable: examples/macro_library_once_is_small/ — the same once + is_small library as a macros.dw file a policy calls via dogwood validate --macros … (and dogwood replay).

Only the library’s definitions are used — if the library source happens to contain policies too, they are ignored. An empty or whitespace-only library (for example, one supplied via macros_str that contains only comments) adds nothing.

Precedence: a policy’s def wins

If a policy file declares a macro with the same name as one in the library, the policy’s own definition takes precedence and the library’s same-named definition is dropped. This is a merge rule, not a duplicate error: the library can neither shadow a policy’s macro nor cause a duplicate-definition failure against one. So a policy author can always override a library macro locally by defining their own version of it.

See also

  • Temporal expressions — the sub-language that def temporal macro bodies are written in (formerly, previous, since, count/sum, tp, within, and binders).
  • Policy language — the Cedar expressions that def cedar macro bodies expand into.
  • Calling macros — the core counterpart to this page: where a call may appear and what shape its arguments take.
  • Information providers — why a Cedar macro body may not contain a provider call, and how provider calls differ from macro calls.
  • The API and workflowServiceSchemaBuilder::macros_str, the lowering pipeline, and where macro expansion sits (the ServiceSchema a macro library attaches to).

The API and Workflow

This page is the Rust API reference for the dogwood_language crate and the end-to-end tutorial for using it. It walks through the authorization workflow — build the two schema halves, parse and lower a LoweredPolicySet, type-check it with a Validator, then drive Events through a stateful Authorizer to get a Response — with a complete, runnable-looking example. It then documents every public type grouped by role, explains the engine seam that lets you replace the policy or temporal engine with your own, and covers trace replay, MCP schema generation, and Cedar export. If you have never called the crate before, read the tutorial first; if you already know the shape and want a specific signature, jump to the grouped reference.

The API mirrors the cedar-policy crate. Wherever a concept has a Cedar counterpart the name matches: Validator, Authorizer, Response, Decision, Diagnostics, ValidationResult. The schema and policy-set types differ because a Dogwood policy set is bound to its (augmented) schema at lower time and Dogwood’s schema splits into two halves: Cedar’s single Schema becomes a ServiceSchema + PolicySchema, and Cedar’s PolicySet becomes a LoweredPolicySet (with a ParsedPolicySet for the schema-free parse phase). Two signatures then depart from Cedar’s: LoweredPolicySet::from_str takes the schemas, and Validator::new() takes none (details in the Validator section below). Two further divergences are semantic — things Cedar cannot express — and both live on the authorizer:

  1. Authorizer is stateful — each ingested Event folds into an accumulated history so temporal operators can see the past.
  2. Authorizer::is_authorized returns Option<Response>None for a history-only event whose kind is not a decision kind.

Nearly all of the public API is re-exported at the crate root, so you reach it through dogwood_language::<T>; the internal pub mod modules exist only to give rustdoc a home (and a few AST types live under dogwood_language::temporal_ast).


Your first authorization

The goal of this section is to get you from a schema and a policy to a verdict, and to understand why each step exists. The complete program is shown inline below. (If you only need to check or replay policies over files rather than embed the engine, you do not need this API — see The command line.)

The scenario is a two-action agent schema (Login, Read) with a policy that permits Read only if the same user logged in within the last hour. That “within the last hour” clause is temporal, which is what makes this stateful and impossible to express in plain Cedar.

The pipeline, one stage at a time

The sequence is always the same:

ServiceSchema + PolicySchema  ->  LoweredPolicySet  ->  Validator  ->  Authorizer  ->  Event  ->  is_authorized  ->  Response

Each stage produces the input for the next, and one ownership handoff matters: a LoweredPolicySet is moved into the Authorizer, so inspect and reuse it before that. The two schema halves are only borrowed while lowering and are not needed again (the validator takes none), so they stay available throughout.

Stage 1 — Schemas. A Dogwood schema has two halves. The ServiceSchema holds the fixed, service-provided inputs — the event schema and provider declarations (both optional, both defaulted) plus the macro library; the PolicySchema holds the Cedar action schema. This policy uses only a temporal leaf, so the service defaults are exactly right (ServiceSchema::defaults()), and PolicySchema::from_cedarschema_str is the one-call shortcut for the action schema.

#![allow(unused)]
fn main() {
use dogwood_language::{
    Authorizer, Decision, Event, LoweredPolicySet, PolicySchema, ServiceSchema, Validator, Value,
};

// A minimal agent schema: a `Login` action and a `Read` action, each with a
// `user` input field.
const SCHEMA: &str = r#"
namespace Drupe {
  type LoginInput = { user: String };
  type ReadInput = { user: String };
  entity Gateway;
  entity OAuthUser = { id: String } tags String;
  action "Login" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: LoginInput }
  };
  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: ReadInput }
  };
}
"#;

let service = ServiceSchema::defaults();                    // or ServiceSchema::builder()…build()
let policy_schema = PolicySchema::from_cedarschema_str(SCHEMA)?;
}

Stage 2 — LoweredPolicySet. Unlike Cedar’s PolicySet::from_str, Dogwood’s takes the two schema halves. Lowering the temporal { … } clause derives event signatures from the action schema and augments it — it hoists a synthesized context.<id> field that the lowered Cedar policies reference. Because those extra schema arguments do not fit std::str::FromStr, this is an inherent method named from_str: call LoweredPolicySet::from_str(src, &service, &policy_schema), not src.parse(). (When the action schema arrives later than the source, split this into ParsedPolicySet::parse(src, &service) then .lower(&policy_schema) — see The parse/lower split.)

#![allow(unused)]
fn main() {
// Permit `Read` only if the same user logged in within the last hour. The
// `temporal { … }` clause is what makes the authorizer stateful.
const POLICY: &str = r#"
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
};
"#;

let policies = LoweredPolicySet::from_str(POLICY, &service, &policy_schema)?; // inherent fn, not .parse()

// The policy hoisted a temporal leaf, so it is NOT self-contained Cedar:
// reproducing it needs Dogwood's monitor, not just the exported Cedar.
// (A pure-Cedar policy would report `true` here and could be shipped to
// external store as-is via `as_cedar()` / `cedar_schema()`.)
println!("self-contained Cedar = {}", policies.is_self_contained_cedar());
}

LoweredPolicySet::from_str returns Error on syntax, macro, or lowering failure. It does not report type errors against the schema — those are validation findings, which is the next stage. Inspect the LoweredPolicySet (with is_self_contained_cedar, as_cedar, cedar_schema) before stage 4, because the authorizer consumes it.

Stage 3 — Validator. Validator::new().validate(&policies). It runs Cedar’s own validator over the lowered policies (against the augmented schema) plus each Dogwood dialect’s own checks, and rebases every finding to the originating .dw source span. Unlike Cedar’s Validator::new(schema), Dogwood’s takes no schema: the schema a policy set was lowered against — augmented with its hoisted context.<id> fields — already travels on the LoweredPolicySet, and that is what validation runs against. (A Dogwood LoweredPolicySet is schema-bound at lower time, so unlike Cedar you cannot reuse one validator across policy sets built from different schemas.)

#![allow(unused)]
fn main() {
let report = Validator::new().validate(&policies);
assert!(report.validation_passed());
}

Stage 4 — Authorizer. Authorizer::new consumes the LoweredPolicySet and is infallible with the built-in backends. The authorizer is stateful: each is_authorized call folds the event into history so the temporal leaf can see prior events.

#![allow(unused)]
fn main() {
let mut authorizer = Authorizer::new(policies); // consumes policies
}

Stage 5 — Event. An Event is Dogwood’s generalization of a Cedar Request. It carries a first-class kind (here "request"), an optional principal/resource scope, and input fields exposed to the policy as context.input.<name>.

Stage 6 & 7 — is_authorized and Response. is_authorized takes &mut self (stateful) and returns Option<Response>. Every event here is a request (a decision kind), so each yields Some(response). Read the verdict from response.decision() and the explanation from response.diagnostics().

#![allow(unused)]
fn main() {
let events = vec![
    Event::builder("Drupe::Action::Login", "request")
        .timestamp(0)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
    Event::builder("Drupe::Action::Read", "request")
        .timestamp(10)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
    Event::builder("Drupe::Action::Read", "request")
        .timestamp(7200)
        .principal("Drupe::OAuthUser::\"alice\"")
        .resource("Drupe::Gateway::\"gw1\"")
        .field("input", "user", Value::String("alice".to_string()))
        .build(),
];

let mut decisions = Vec::new();
for event in &events {
    // `is_authorized` returns None for a non-decision (history-only) event;
    // here every event is a `request`, so each yields a Response.
    if let Some(response) = authorizer.is_authorized(event) {
        println!("@{:<5} {:?}", event.timestamp(), response.decision());
        decisions.push(response.decision());
    }
}

//   @0    Login → Deny  (the policy gates Read, not Login)
//   @10   Read  → Allow (login within the 1h window)
//   @7200 Read  → Deny  (login expired: 7200s > 3600s)
assert_eq!(decisions, vec![Decision::Deny, Decision::Allow, Decision::Deny]);
}

What just happened

Three events went in and the verdict stream came out as [Deny, Allow, Deny]. The first Read is allowed because a Login for the same user landed within the 3600-second window; the second Read is denied because that login has expired (7200 > 3600). This is the two Cedar-inexpressible facts made concrete: the authorizer remembered the earlier login (statefulness), and — because every event was a decision-kind request — each call returned Some(Response) rather than None.

Ownership gotchas

  • Both schema halves are only borrowed by LoweredPolicySet::from_str(&service, &policy_schema), and the validator takes none, so they stay usable afterward — but a LoweredPolicySet is bound to the schema it was lowered against, so validation always uses that one.
  • LoweredPolicySet is moved into Authorizer::new(policies) / Authorizer::builder(policies), so do any as_cedar() / is_self_contained_cedar() inspection before that.
  • LoweredPolicySet::from_str(src, &service, &policy_schema) is an inherent method, not src.parse().

A version of this tour that exercises every schema option at once — MCP-generated action schema, explicit event schema, a macro library, and provider declarations — is summarized in MCP schema generation and Information provider declarations.


API reference

Everything below is reachable as dogwood_language::<T>. The reference groups types by the role they play in the pipeline.

ServiceSchema and PolicySchema

Cedar has one schema; Dogwood needs three parts plus a macro library, grouped into two halves by where each comes from.

The ServiceSchema — the fixed, service-provided inputs, none of which need an action schema:

  1. event schema — the Dogwood event-schema DSL describing how event kinds and their fields derive from the action schema. Optional; defaults to DEFAULT_EVENT_SCHEMA (the request/response convention). Held parsed but unbound — derivation against the action schema is deferred to lowering. See The event schema.
  2. provider declarations — information-provider signatures and implementations (JSON). Optional; defaults to empty. See The provider schema.
  3. macro library — reusable def cedar / def temporal definitions merged into every policy set at parse time. Optional; defaults to DEFAULT_MACROS, which ships a small standard library of temporal aggregation macros (count_within, sum_within, count_distinct_within, bind). A policy’s own def of the same name takes precedence over a library macro. See Macros.
#![allow(unused)]
fn main() {
impl ServiceSchema {
    pub fn builder() -> ServiceSchemaBuilder;
    pub fn defaults() -> ServiceSchema;   // all three defaulted; == builder().build()
}

impl ServiceSchemaBuilder {
    pub fn event_schema_str(mut self, src: &str) -> Self;   // omit for DEFAULT_EVENT_SCHEMA
    pub fn providers(mut self, declarations: ProviderDeclarations) -> Self; // omit for empty
    pub fn macros_str(mut self, src: &str) -> Self;         // omit for DEFAULT_MACROS
    pub fn build(self) -> Result<ServiceSchema, Error>;
}
}

build() parses the event-schema DSL (it does not derive it — that needs the action schema and happens at lowering). An empty or whitespace-only event_schema_str is explicitly rejected. A blank event schema parses to zero declarations, which yields a schema with no decision kinds, so is_authorized would return None for every event — a subtle footgun. If you want the default behavior, omit event_schema_str entirely rather than passing an empty string. (The default path always derives a decision request event, so this never trips there.)

The PolicySchema — the action schema (a Cedar .cedarschema: entity types + actions), which typically arrives later and varies per deployment. Supply Cedar text directly, or generate it from an MCP tool manifest (resolved eagerly at construction, so MCP-generation failures surface here):

#![allow(unused)]
fn main() {
impl PolicySchema {
    pub fn from_cedarschema_str(action_schema_src: &str) -> Result<PolicySchema, Error>;
    pub fn from_mcp_manifest(manifest_json: &str) -> Result<PolicySchema, Error>;
    pub fn from_mcp_manifest_with_template(manifest_json: &str, template: &str) -> Result<PolicySchema, Error>;
}
}
  • from_cedarschema_str(src) — Cedar .cedarschema text used verbatim. Mirrors cedar_policy::Schema::from_cedarschema_str.
  • from_mcp_manifest(manifest_json) — generate the action schema from an MCP tools/list manifest layered on the embedded Drupe template (the eager form of mcp_to_cedar_schema).
  • from_mcp_manifest_with_template(manifest_json, template) — as above but against a caller-supplied Cedar template stub instead of Drupe.

Constants:

  • DEFAULT_EVENT_SCHEMA: &str — the built-in request/response convention. For every action A it derives a request decision event (input fields, callerPrincipal, callerResource, requestId, and sessionId), a response history event (input plus output fields and the same reserved leaves), and an error history event (input fields and reserved leaves, no outputs).
  • DEFAULT_MACROS: &str — the built-in macro library. It ships a small standard library of temporal aggregation macros (count_within, sum_within, count_distinct_within, bind), embedded at build time so they are available regardless of working directory. A policy’s own def of the same name takes precedence.

The parse/lower split

Lowering is split into two phases along the input that motivates it — the action schema:

#![allow(unused)]
fn main() {
impl ParsedPolicySet {
    pub fn parse(source: &str, service_schema: &ServiceSchema) -> Result<ParsedPolicySet, Error>;
    pub fn lower(&self, policy_schema: &PolicySchema) -> Result<LoweredPolicySet, Error>;
    pub fn lower_with_distincter(&self, policy_schema: &PolicySchema, distincter: &str) -> Result<LoweredPolicySet, Error>;
}
}
  • parse(source, &service)phase 1, schema-free. Parses and macro-expands against the ServiceSchema alone; catches syntax and macro-resolution errors but not schema-dependent ones (unknown attributes, type errors) — those need the action schema. Useful to reject a broken policy before the action schema is available. The returned ParsedPolicySet carries its ServiceSchema, so phase 2 needs only the PolicySchema.
  • lower(&policy_schema)phase 2. Derives the event schema against the action schema, lowers to Cedar, augments the schema with hoisted context.<id> fields, and injects pins. Produces the LoweredPolicySet. Emitted policy ids and hoisted field names use the default policy_<index> namespace.
  • lower_with_distincter(&policy_schema, distincter) — like lower, but namespaces this call’s policy ids and hoisted field names under distincter (ids become <distincter>_<index>). Supply a distinct value per call when combining independently-lowered sets into one Cedar PolicySet / policy store, or when feeding an augmented schema forward. The distincter must be a valid Cedar identifier ([_A-Za-z][_A-Za-z0-9]*) — else Error::InvalidDistincter. It is not derived from a source @id; distinctness is the caller’s decision. Note the plain lower path uses the namespace policy, so it is not automatically distinct from a "policy" distincter.

LoweredPolicySet::from_str(src, &service, &policy_schema) (below) is the fused form of parse then lower for when the action schema is already in hand.

LoweredPolicySet and the Cedar export accessors

LoweredPolicySet is opaque. It is produced by ParsedPolicySet::lower (or the fused from_str shortcut) and consumed by Validator::validate and Authorizer::new / Authorizer::builder. Beyond lowering, its accessors are the bridge to plain Cedar and external policy stores.

#![allow(unused)]
fn main() {
impl LoweredPolicySet {
    pub fn from_str(source: &str, service_schema: &ServiceSchema, policy_schema: &PolicySchema) -> Result<LoweredPolicySet, Error>;
    pub fn as_cedar(&self) -> &cedar_policy::PolicySet;
    pub fn cedar_schema(&self) -> &cedar_policy::Schema;
    pub fn cedar_schema_str(&self) -> Result<String, Error>;
    pub fn cedar_schema_json(&self) -> Result<String, Error>;
    pub fn is_self_contained_cedar(&self) -> bool;
    pub fn temporal_fields(&self) -> impl Iterator<Item = &TemporalField>;
    pub fn provider_fields(&self) -> impl Iterator<Item = &ProviderField>;
    pub fn rules(&self) -> impl Iterator<Item = DogwoodRuleRef> + '_;
    pub fn rule_ref(&self, cedar_policy_id: &str) -> Option<DogwoodRuleRef>;
    pub fn decision_kinds(&self) -> impl Iterator<Item = &str>;
    pub fn is_decision_kind(&self, kind: &str) -> bool;
}
}
  • from_str(source, service, policy_schema) — fused parse + lower. Inherent method; returns Error on syntax / macro / lowering failure. Type errors against the schema are not returned here — run Validator::validate for those.
  • as_cedar() — borrow the lowered Cedar policies (a real cedar_policy::PolicySet). This is the artifact to hand to the cedar-policy crate directly, or (rendered to policy text) to a policy store’s create-policy API.
  • cedar_schema() / cedar_schema_str() / cedar_schema_json() — the augmented Cedar schema (action schema plus hoisted context.<id> fields), respectively as an opaque cedar_policy::Schema, as .cedarschema text, and as the Cedar JSON schema form (what a Cedar policy store’s schema ingest accepts). The text form round-trips: it can seed a later lowering via PolicySchema::from_cedarschema_str.
  • is_self_contained_cedar()true iff lowering hoisted no temporal or provider context.<id> fields, so the exported Cedar artifacts fully reproduce the policy’s semantics in plain Cedar with no extra context. When false, the policy uses temporal { … } and/or guardrails { … }: a Cedar policy store can still evaluate the exported policies, but each authorization call must be supplied the hoisted context.<id> values. Computing those values is Dogwood’s temporal-monitor / provider-evaluation job — so in that case Dogwood produces the enriched context and the policy store performs the final Cedar decision.
  • temporal_fields() / provider_fields() — the hoisted leaves (each with its context.<id> field name, action, and what to evaluate). A consumer reimplementing the decision loop computes each leaf’s value and binds it into the request context.
  • rules() / rule_ref(id) — the Dogwood rules as DogwoodRuleRefs, and the reverse map from a Cedar policy id (as named in a decision) back to its originating rule.
  • decision_kinds() / is_decision_kind(kind) — which event kinds are decision points (authorization runs and yields a verdict); any other kind is history-only. This is the gate Authorizer::is_authorized applies before returning Some/None.

Validator, ValidationResult

Validation is the second of Dogwood’s two error channels (see Errors): the policy already parsed and lowered, and this stage checks whether it is correct against the schema.

#![allow(unused)]
fn main() {
impl Validator {
    pub fn new() -> Self;   // takes no schema — see below
    pub fn validate(&self, policies: &LoweredPolicySet) -> ValidationResult;
}

impl ValidationResult {
    pub fn validation_passed(&self) -> bool;                  // no errors (warnings ignored)
    pub fn validation_passed_without_warnings(&self) -> bool; // no errors AND no warnings
    pub fn validation_errors(&self) -> impl Iterator<Item = &ValidationError>;
    pub fn validation_warnings(&self) -> impl Iterator<Item = &ValidationWarning>;
}
}
  • No schema argument (unlike cedar_policy::Validator::new(schema)). Cedar’s validator owns a schema so one validator can be reused across many policy sets — a Cedar schema is policy-independent. Dogwood’s is not: lowering augments the schema with the context.<id> fields hoisted from a policy set’s own temporal / provider clauses, so the effective validation schema is a function of the policies and already travels on the PolicySet. validate reads it from there; a schema argument would be redundant (the same one) or wrong (the un-augmented base).
  • Under the hood, validate runs Cedar’s own validator on the lowered policies (against that augmented schema) plus each Dogwood dialect’s checks (temporal, provider) and the event-schema names check, rebasing every finding to the originating .dw source span. It always uses Cedar’s default (strict) mode — there is no mode argument, because the non-strict modes are not meaningful once a hoisted context.<id> field must typecheck.
  • ValidationResult collects every finding rather than stopping at the first. Warnings never make validation fail.

Both finding types are #[derive(Error, Diagnostic, Debug)] (miette) and are self-rendering: each embeds its .dw source alongside the span, so miette::Report::new(err) prints the underlined snippet with no with_source_code — the same way the fatal Error from LoweredPolicySet::from_str renders, and the same way Cedar’s own errors do. There are no accessor methods on the finding enums; render via their Display / miette Diagnostic impls or match the variants directly.

#![allow(unused)]
fn main() {
pub enum ValidationError {
    Cedar { message, span, label, help, src, source },              // Cedar's validator rejected the lowered policy
    Extension { code: &'static str, message, span, label, help, src, source }, // dialect finding; code is "temporal" / "provider" / …
}

pub enum ValidationWarning {
    Cedar(cedar_policy::ValidationWarning),                         // Cedar's own warning verbatim
    Extension { code, message, span, label, help, src, source },    // dialect warning (no dialect emits one today)
}
// `src` is the embedded `.dw` source (Arc<str>) — this is what makes a finding
// self-render, so `miette::Report::new(err)` needs no `with_source_code`.
}

Event, EventBuilder, Value

An Event is Dogwood’s generalization of cedar_policy::Request. Its distinguishing feature is a first-class kind string: a request-kind event is a decision point (it authorizes), while a response-kind event is history-only. Which kinds decide is data (the event schema’s decision flags, queryable via Schema::decision_kinds()), not a hardcoded convention.

#![allow(unused)]
fn main() {
impl Event {
    pub fn builder(action: &str, kind: &str) -> EventBuilder;
    pub fn kind(&self) -> &str;
    pub fn action(&self) -> &str;             // unqualified id, e.g. "Login"
    pub fn namespace(&self) -> &[String];     // e.g. ["Drupe", "Action"]; empty if bare
    pub fn timestamp(&self) -> i64;
    pub fn principal(&self) -> Option<String>; // e.g. Drupe::OAuthUser::"alice"; None if history-only
    pub fn resource(&self) -> Option<String>;  // e.g. Drupe::Gateway::"gw1"; None otherwise
    pub fn field(&self, group: &str, name: &str) -> Option<&Value>;      // one logged field, e.g. field("input", "user")
    pub fn fields(&self, group: &str) -> impl Iterator<Item = (&str, &Value)>; // all fields of a logged group
    pub fn from_request(request: &cedar_policy::Request) -> Result<Event, Error>;
}
}
  • builder(action, kind) — start building. action is the qualified Cedar action id, either bare ("Login") or namespaced ("Drupe::Action::Login"); the builder splits it into (namespace, id). kind is the event-kind string ("request", "response", …).
  • The request scope is optional: only an event that wraps a request carries a principal/resource, so history-only events return None from principal() / resource().
  • field(group, name) reads one field of the logged temporal record (field("input", "user")); fields(group) yields all (name, value) pairs of a group — what an engine that persists history outside the process would record on observe. There is no input-specific accessor: input is just the "input" group, read like any other. The Cedar request context a policy sees as context.<group>.<name> is a separate bag, read via request_context_path.
  • from_request(&cedar::Request) — interop bridge for callers coming from cedar-policy. It lifts a Cedar Request into a request-kind Event at timestamp 0: principal/resource become the scope, the action becomes the qualified action, and context.input becomes the input fields. A stateless single-decision authorization is then just a fresh Authorizer fed this one event.
#![allow(unused)]
fn main() {
impl EventBuilder {
    pub fn timestamp(mut self, ts: i64) -> Self;              // default 0
    pub fn principal(mut self, uid: &str) -> Self;            // e.g. User::"alice"; makes the event request-wrapping
    pub fn resource(mut self, uid: &str) -> Self;             // e.g. Photo::"vacation.jpg"; makes it request-wrapping
    pub fn field(mut self, group: &str, name: &str, value: Value) -> Self;  // logged temporal record, e.g. field("input", "user", v)
    pub fn request_context(mut self, group: &str, name: &str, value: Value) -> Self; // Cedar request context (context.<group>.<name>)
    pub fn build(self) -> Event;
}
}
  • Setting a principal or resource marks the event as wrapping an authorization request (it gains a principal/resource scope).
  • timestamp defaults to 0; timestamps order events for temporal operators, and the first event of a fresh authorizer can safely stay at 0.
  • field(group, name, value) accumulates into the logged record’s nested group object (input is one such group, not privileged). It is not read by a policy’s context.<group>.<name> clause — that reads the separate request-context bag set via request_context(group, name, value). A field a policy reads and a temporal predicate correlates on must be supplied to both.
  • Entity uid strings are parsed as Type::"id" (possibly namespaced); a string that is not of that shape yields no scope value.

Value is the runtime value type for event fields:

#![allow(unused)]
fn main() {
pub enum Value {
    Null,
    Bool(bool),
    Int(i64),
    Decimal(String),          // canonical text; equality canonicalizes (1.5 == 1.50)
    String(String),
    Entity { ty: String, id: String },
    Array(Vec<Value>),
    Object(BTreeMap<String, Value>),
}

impl Value {
    pub fn dom_eq(&self, other: &Value) -> bool; // structural eq with decimal canonicalization
    pub fn as_int(&self) -> Option<i64>;         // integer view; None if not an Int
}
}

The constructors you will use most are Value::String(s.to_string()) and Value::Int(n), as in the tour. Decimal is canonical text; Entity / Array / Object build the structured shapes.

Authorizer, AuthorizerBuilder, Response, Diagnostics, Decision

The Authorizer is the stateful monitor. It is built from a LoweredPolicySet and assembled from two swappable backends (see The engine seam): a PolicyEngine (decision; default CedarPolicyEngine) and a TemporalEngine (temporal-leaf evaluation; default InMemoryTemporalEngine).

#![allow(unused)]
fn main() {
impl Authorizer {
    pub fn new(policies: LoweredPolicySet) -> Self;                  // built-in backends; infallible
    pub fn builder(policies: LoweredPolicySet) -> AuthorizerBuilder; // to substitute backends
    pub fn is_authorized(&mut self, event: &Event) -> Option<Response>;
}
}
  • new(policies) — built-in backends, infallible (the defaults’ prepare cannot fail). Consumes the LoweredPolicySet.
  • builder(policies) — for custom backends.
  • is_authorized(&mut self, event)&mut self (stateful) returning Option<Response>:
    • The event is always handed to the temporal engine (observe) so it sees the history.
    • A decision-kind event additionally triggers temporal evaluation, request construction, and the policy engine’s decision, yielding Some(Response).
    • A history-only event (a kind that is not a decision kind, e.g. response) yields None: it updates temporal state but produces no verdict.
    • Evaluation failures do not abort: a failure (a provider that errors, the temporal engine erroring, a decision event missing a principal/resource) is recorded in Response::diagnostics().errors(), and this reference implementation resolves it to Deny. Note that for provider errors specifically this is an implementation choice, not a guarantee — an erroring provider is undefined behavior under the provider contract.
#![allow(unused)]
fn main() {
impl AuthorizerBuilder {
    pub fn policy_engine(mut self, engine: impl PolicyEngine + 'static) -> Self;
    pub fn temporal_engine(mut self, engine: impl TemporalEngine + 'static) -> Self;
    pub fn build(self) -> Result<Authorizer, Error>;
}
}
  • policy_engine(engine) — install the decision backend (e.g. a remote Cedar-based engine). Default CedarPolicyEngine.
  • temporal_engine(engine) — install a custom temporal backend. Default InMemoryTemporalEngine.
  • build() — runs each backend’s prepare (the policy engine gets the lowered Cedar policies plus schema; the temporal engine gets the temporal leaves plus schema). It errors if a backend’s prepare fails (e.g. a compiling temporal engine rejects a leaf, or a remote policy engine cannot reach its policy store). This is why builder() is fallible while new() is not.
#![allow(unused)]
fn main() {
impl Response {
    pub fn decision(&self) -> Decision;    // Allow or Deny
    pub fn diagnostics(&self) -> &Diagnostics;
    pub fn allowed(&self) -> bool;         // convenience: decision == Allow
}

impl Diagnostics {
    pub fn reason(&self) -> impl Iterator<Item = &DogwoodRuleRef>; // determining rules; empty for implicit Deny
    pub fn errors(&self) -> impl Iterator<Item = &str>;           // evaluation errors (degrade, not throw)
}
}
  • reason() — the Dogwood rules that drove the decision. Empty for an implicit Deny (no rule matched), exactly as Cedar’s reason() is.
  • errors() — problems encountered while evaluating (missing attribute, provider that could not run, decision event with no principal/resource). Evaluation degrades rather than aborting, so these are reported here rather than thrown.

DogwoodRuleRef is the enriched analog of Cedar’s PolicyId in reason(). Where Cedar names the lowered policy id, Dogwood maps it back to the originating .dw rule:

#![allow(unused)]
fn main() {
pub struct DogwoodRuleRef {
    pub rule_index: usize,       // 0-based index of the rule in the source policy set
    pub cedar_policy_id: String, // synthesized Cedar policy id, e.g. "policy0"
}
}

Decision is pub use cedar_policy::Decision; — re-exported so consumers need no direct cedar-policy dependency. Its values are Decision::Allow and Decision::Deny, and it is the type returned by Response::decision().


The engine seam

Why does the seam exist? A Dogwood Authorizer is really two decisions glued together: what boolean does each temporal / provider leaf evaluate to right now (the temporal seam), and given those booleans folded into the context, does the Cedar policy allow the request (the policy seam). Each of those has a built-in default, and each is a trait you can implement to replace it. That is how you point Dogwood at a remote policy engine for the final Cedar decision, or at an alternative temporal engine of your own for the history. You install a custom backend through the builder:

#![allow(unused)]
fn main() {
let mut authorizer = Authorizer::builder(policies)
    .policy_engine(my_remote_engine)     // decision seam
    .temporal_engine(my_temporal_engine) // temporal seam
    .build()?;                           // fallible: runs each backend's prepare()
}

You can swap either or both; anything you do not specify falls back to the default.

The policy-decision seam (Cedar authorization-service shaped)

This trait is shaped like a Cedar authorization service’s IsAuthorized (minus the token- and batch-authorization variants Dogwood does not use), so you can implement PolicyEngine by calling a remote Cedar-based service instead of evaluating Cedar locally.

#![allow(unused)]
fn main() {
pub struct AuthorizationRequest<'a> {
    pub request: &'a cedar_policy::Request,   // <principal, action, resource, context>
    pub entities: &'a cedar_policy::Entities, // Dogwood supplies an empty set; field exists so a
                                              // remote engine can forward its managed entities
}

pub struct AuthorizationDecision {
    pub decision: Decision,                   // Allow or Deny
    pub determining_policy_ids: Vec<String>,  // ids of determining policies (empty for implicit Deny)
    pub errors: Vec<String>,                  // evaluation errors (folded into diagnostics; Deny, not throw)
}

pub trait PolicyEngine: Send {
    fn prepare(&mut self, policies: &cedar_policy::PolicySet, schema: &cedar_policy::Schema) -> Result<(), Error>;
    fn is_authorized(&self, request: AuthorizationRequest<'_>) -> AuthorizationDecision;
}
}

prepare is called once at authorizer-build time (analogous to creating and populating a policy store); is_authorized decides one request. Implementations must not panic — surface problems through AuthorizationDecision::errors with a fail-closed Deny.

The built-in default is CedarPolicyEngine (implements Default, with CedarPolicyEngine::new()). Its prepare keeps the policy set; its is_authorized runs cedar_policy::Authorizer::new().is_authorized(request, policies, entities) and maps the decision, determining ids, and errors across. If it was never prepared it returns a Deny with an error string.

The temporal-evaluation seam

This trait computes the boolean value of each hoisted temporal { … } leaf for the current decision point.

#![allow(unused)]
fn main() {
pub type ExtensionId = String;                          // the context.<id> slot a leaf's boolean binds into (e.g. "__temporal_0")
pub type TemporalBindings = BTreeMap<ExtensionId, bool>; // per-leaf booleans for one decision point

pub trait TemporalEngine: Send {
    fn prepare(&mut self, leaves: &[TemporalField], schema: &cedar_policy::Schema, events: &[EventSignature]) -> Result<(), Error>;
    fn observe(&mut self, event: &Event);
    fn evaluate(&mut self) -> Result<TemporalBindings, String>;
}
}

The lifecycle has three points:

  • prepare(leaves, schema, events)once, at authorizer-build time. A custom backend does its one-time setup here (an engine that precomputes queries against a store, say, would build them now); events carries the declared signature of every event kind the policy set can see (each field’s dotted path and type), which such a backend needs to emit type-correct comparisons.
  • observe(event) — for every ingested event (decision or history-only), in timestamp order, before any evaluate that includes it. A backend persists the event (in memory, or in an external store).
  • evaluate() — at each decision point, after that point’s event has been observed. It returns each leaf’s boolean keyed by id. The in-memory backend re-runs the interpreter; a custom backend evaluates however it prepared. A failure (an external store being unreachable, say) fails the decision closed by returning Err(String).

The built-in default is InMemoryTemporalEngine (implements Default, with InMemoryTemporalEngine::new()): it keeps the event history in memory and re-runs the in-process temporal interpreter over the trace so far. prepare stores the leaves (no compilation), observe appends to an in-memory log, and evaluate runs the interpreter at the last timepoint.

The leaves handed to prepare are TemporalFields:

#![allow(unused)]
fn main() {
pub struct TemporalField {
    pub id: ExtensionId,            // the context.<id> slot its boolean binds into
    pub action: ActionScope,        // the action scope its rule pins, as written
    pub target_actions: Vec<ActionRef>, // concrete actions `action` resolves to (a group to its
                                    // members, Unconstrained to all) — what the leaf is checked against
    pub principal: ScopeConstraint, // entity-type axis the rule's `principal` scope admits
    pub resource: ScopeConstraint,  // likewise for `resource`
    pub condition: Temporal,        // the parsed temporal condition
}
}

Supporting types a custom engine will name:

#![allow(unused)]
fn main() {
pub struct ActionRef {
    pub namespace: Option<String>, // None = top-level (unnamespaced) action
    pub id: String,
}

pub enum ActionScope {
    Concrete(ActionRef),           // action == Ns::Action::"X"
    List(Vec<ActionRef>),          // action in [ … ]
    Unconstrained,                 // bare `action` scope, may fire on any action
}
impl ActionScope {
    pub fn concrete(&self) -> Option<&ActionRef>;   // the single == action, else None
    pub fn actions_to_check(&self) -> &[ActionRef]; // one concrete / each listed; Unconstrained => empty slice
}

pub enum ScopeConstraint {
    Any,                 // bare `principal` / `resource`: every type the action permits
    IsType(String),      // `is Ns::T`
    Uid { .. },          // `== Ns::T::"x"` or `in Ns::T::"x"`
}

pub struct Temporal {
    pub condition: Condition, // parsed temporal condition; the Condition AST is public via `dogwood_language::temporal_ast`
    pub span: Span,           // span of the block body in the .dw source
}
impl Temporal {
    pub fn parse(body: &str, body_span: Span) -> Result<Temporal, String>;
}
}

A custom engine reads TemporalField::condition to decide what to evaluate; the in-memory engine interprets it directly. Temporal is re-exported so a custom engine can name the type, and its inner condition AST is public via dogwood_language::temporal_ast to walk.

The practical recipe for swapping either backend:

  • Swap the decision backend (any Cedar-based store): implement PolicyEngine and pass it to Authorizer::builder(policies).policy_engine(...). PolicySet::as_cedar() / cedar_schema() give you the Cedar policies and schema to load into the store.
  • Swap the temporal backend (a custom evaluation strategy): implement TemporalEngine (prepare does one-time setup over &[TemporalField], observe records each event, evaluate computes the leaves at the decision point) and pass it to .temporal_engine(...).

Trace replay

parse_trace and replay_log are conveniences over the core Authorizer loop for driving a recorded .log event trace — useful for regression testing and for reproducing a sequence of events without hand-building each Event.

#![allow(unused)]
fn main() {
pub fn parse_trace(log: &str) -> Result<Vec<Event>, Error>;
pub fn replay_log(policies: LoweredPolicySet, log: &str) -> Result<String, Error>;
}
  • parse_trace(log) — parse a .log trace into its sequence of Events. Each non-blank line is one timepoint of the form @<ts> [envelopes] <Action>(<field>: <value>, …) (the optional scope / entities / request_context envelopes are described below). Values use Cedar surface forms (entity refs Ns::Type::"id", strings, integers, decimals, true/false, arrays, objects). Feed the events to an Authorizer in order to replay them. On failure it returns Error::TraceParse("…").
  • replay_log(policies, log) — replay a whole .log trace through policies and return the per-timepoint verdict stream: one @<ts> (time point <i>): <bool> line for every decision point (true for Allow, false for Deny), newline-joined. This is the corpus-comparison format. It consumes the LoweredPolicySet (it drives a fresh stateful Authorizer, so temporal leaves see prior events as history). History-only events (non-decision kinds) contribute no line.
@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::request(input: { user: "alice" })
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Read"::request(input: { user: "alice" })
@7200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Read"::request(input: { user: "alice" })

Each .log line is: the @<timestamp>, then up to three optional request-supplement envelopes in a fixed order, then the event itself — a quoted, fully-qualified action id (Drupe::Action::"Read") with an explicit ::<kind> segment (::request) and its trailing (<field>: …) group. That trailing group is the logged record (the temporal-history fields a predicate correlates on); the envelopes carry the request-only supplements, each parsed positionally when present:

  • scope(principal: …, resource: …) — the request’s principal and resource entities. (A decision-kind event omitting scope(...) has no principal/resource and fails closed to Deny.)
  • entities(<uid>: { <attr>: … }, …) — the entity attribute store: each uid (e.g. Drupe::OAuthUser::"alice") maps to its attributes, so a policy can read principal.<attr> / resource.<attr> or a provider can resolve one. A uid "id" containing " or \ is escaped as in Cedar ("a\"b").
  • request_context(<group>: { … }, …) — the request-only context the Cedar request is built from (input, system, …), distinct from the trailing logged group. A field a policy reads via context.<group>.<name> must appear here; a field a temporal predicate correlates on must appear in the trailing logged group. A field both need (input) is supplied in both.

The envelopes appear in that order (scope, then entities, then request_context) before the event group, and any may be omitted. Parsing is strict: an unterminated envelope, or a duplicate key within any group (a repeated uid in entities(...), a repeated group in scope/request_context, a repeated field in the logged group or a nested record), is a TraceParse error rather than a silent last-wins — so a hand-written trace cannot mis-decide on an accidental duplicate.

Running replay_log on the trace above (which uses only scope(...)) with the tour’s policy produces one line per request-kind timepoint:

@0 (time point 0): false
@10 (time point 1): true
@7200 (time point 2): false

— the same [Deny, Allow, Deny] shape you saw in the tutorial (false/true = Deny/Allow; note replay_log emits a line for every decision point, not only allows).


MCP schema generation

Because a Dogwood action schema is an MCP tool manifest, you can generate the Cedar .cedarschema text directly from a Model Context Protocol tools/list payload rather than writing it by hand. The PolicySchema::from_mcp_manifest path is the usual entry point, but the underlying functions are also public:

#![allow(unused)]
fn main() {
pub const DRUPE_TEMPLATE: &str; // embedded Drupe .cedarschema template stub
pub fn mcp_to_cedar_schema(manifest_json: &str) -> Result<String, String>;
pub fn mcp_to_cedar_schema_with_template(manifest_json: &str, template: &str) -> Result<String, String>;
}
  • manifest_json is an MCP tools/list payload (or a JSON array of tool descriptions).
  • mcp_to_cedar_schema uses DRUPE_TEMPLATE; mcp_to_cedar_schema_with_template takes a caller-supplied template.
  • The generator config matches the regression harness: include_outputs(true), encode_numbers_as_decimal(true), and flatten_namespaces(true) (so action names are unqualified).
  • DRUPE_TEMPLATE supplies the principals (OAuthUser / IamEntity / UnauthenticatedUser), the Gateway resource, the system context, and the base MCP action hierarchy. The generator layers one Cedar action per MCP tool (with input/output context records) on top of the template.
  • Note the return type: these return Result<String, String> (a plain error string), whereas the PolicySchema::from_mcp_manifest path wraps failure as Error::McpSchema.

For example, a manifest declaring SellShares / ApproveSale / GetStockInfo tools (each with stock / shares inputs) passed to PolicySchema::from_mcp_manifest(MCP_MANIFEST) layers them onto the Drupe template. See Generating the action schema from an MCP manifest for the manifest format, the JSON→Cedar type mapping, and the Drupe template in depth.


Cedar export

To hand Dogwood’s output to plain Cedar or to an external policy store, you use the LoweredPolicySet accessors together with the dogwood_language::cedar interop module.

The dogwood_language::cedar submodule re-exports exactly the cedar_policy types that appear in Dogwood’s public API — Entities, EntityUid, PolicySet, Request, Schema. This lets a consumer name them (to implement a PolicyEngine, to read PolicySet::as_cedar() / PolicySet::cedar_schema(), or to bridge a cedar::Request via Event::from_request) without taking a direct, version-matched cedar-policy dependency of their own.

The export workflow is:

  • policies.as_cedar() gives the lowered Cedar PolicySet — render it to policy text for a policy store’s create-policy API, or evaluate it with the cedar-policy crate directly.
  • policies.cedar_schema() gives the augmented Cedar Schema for a policy store’s schema ingest or Cedar’s validator.
  • policies.is_self_contained_cedar() tells you whether that export is the whole story. When it is true, the exported Cedar reproduces the policy exactly and a policy store can decide standalone. When it is false, the policy uses temporal { … } and/or guardrails { … } leaves, so each authorization call must be supplied the hoisted context.<id> values — and computing those is Dogwood’s job. In that split, Dogwood produces the enriched context and the policy store performs the final Cedar decision (the pattern a custom PolicyEngine follows).

A brief note on terminology you will meet at the boundary: the policy-level clause keyword an author writes for an information provider is guardrails { … }, and for a temporal expression it is temporal { … }. Internally the hoisted provider artifact is called a ProviderField, but the surface keyword is always guardrails. See Information providers.


Errors

Dogwood has a two-channel-plus-runtime error model, and knowing which channel a problem lands in tells you where to look for it.

  1. Fatal — Error. Returned by ParsedPolicySet::parse / lower, LoweredPolicySet::from_str, and ServiceSchema/PolicySchema construction. This is the fatal prefix: a syntax / macro / lowering / schema failure means there is nothing well-formed to validate or authorize.
  2. Findings — ValidationResult. Returned by Validator::validate. The policy is well-formed but wrong against the schema (type errors, dialect-check failures). These do not appear in Error.
  3. Runtime. During authorization, any evaluation failure is recorded in Response::diagnostics().errors() and resolved to Deny by this implementation — never thrown. (Provider errors specifically are undefined behavior under the provider contract; the deny is a tendency, not a guarantee.)

The construction error itself:

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum Error {
    Parse(ParseErrors),                       // one or more syntax errors (self-rendering)
    Macro(MacroError),                        // def cedar / def temporal expansion failed
    Cedarify(CedarifyError),                  // lowering Dogwood -> Cedar failed
    PolicySet(Box<cedar_policy::PolicySetError>),   // Cedar could not assemble the policy set
    CedarSchema(Box<cedar_policy::CedarSchemaError>), // the augmented Cedar schema was invalid
    McpSchema(String),                        // MCP -> action schema generation failed
    TraceParse(String),                       // a `.log` trace failed to parse
    RequestMissingAction,                     // a Cedar Request had no action
    ContextRead(String),                      // reading a Request's context failed
    EventSchema(String),                      // event-schema DSL failed to parse / derive
    Leaf { id: String, message: String },     // a hoisted extension leaf failed to prepare
    InvalidDistincter(String),                // lower_with_distincter got a non-identifier
    SchemaSerialize(String),                  // rendering the augmented Cedar schema to text failed
    PartitioningUnsupported,                  // a pin-partitioning request the built-ins cannot honor
    // #[non_exhaustive]: more kinds may be added without a breaking change.
}
}

Error is a #[derive(miette::Diagnostic)]: the spanned variants (Parse, Macro, Cedarify) embed their .dw source and self-render, and PolicySet / CedarSchema forward Cedar’s own diagnostic. Parse aggregates multiple ParseErrors via ParseErrors (.iter() yields them all). Note again that type errors against the schema are not in Error — run Validator::validate to see those as ValidationResult findings.


Event kinds, decisions, and failure handling

Two behaviors thread through the whole API and deserve to be stated plainly.

Event kind is data, not convention. The event schema marks certain kinds as decision (queryable on a lowered set via LoweredPolicySet::decision_kinds() / is_decision_kind(kind)). With the default event schema, request is a decision kind and response is history-only. From that single fact everything else follows:

  • A decision-kind event makes is_authorized return Some(Response) — it observes the event and decides.
  • A history-only event makes is_authorized return None — it observes the event to update temporal state but yields no verdict, and contributes no line in replay_log.
  • Statelessness is just a special case: a single stateless decision is a fresh Authorizer fed one request-kind event (for example via Event::from_request).

Authorization never aborts, and this implementation resolves evaluation failures to Deny. Any evaluation failure — a provider that errors, the temporal engine erroring, a decision event missing a principal/resource, a policy engine that was never prepared — yields a Deny with the reason recorded in Response::diagnostics().errors(). (For provider errors this deny is a description of this implementation, not a semantic guarantee: an erroring provider is undefined behavior under the provider contract, so a policy set must never rely on deny-on-error.) The temporal seam’s evaluate returning Err and the policy seam’s errors vector both funnel into this same place. So a caller that wants to distinguish “denied by policy” from “denied because something broke” should always inspect diagnostics().errors(), not just decision().

Finally, the three policy-clause forms map cleanly onto the seams, which is a useful mental model when reading a policy:

  • when { … } — a plain Cedar condition, decided by the PolicyEngine.
  • when temporal { … } — hoisted to a TemporalField and evaluated by the TemporalEngine (not self-contained Cedar). See Temporal expressions.
  • when guardrails { … } — an information-provider invocation, evaluated from ProviderDeclarations (not self-contained Cedar). See Information providers.

See also

A formal specification of Dogwood

This chapter is the precise, semi-formal reference for the Dogwood language: its concrete syntax (BNF), its abstract syntax, and the three judgments that give it meaning — lowering (Dogwood → Cedar), validation (well-formedness and typing), and authorization (the operational semantics of a decision).

Where the rest of the guide (from Getting started through The API and workflow) explains how to use Dogwood, this chapter states what it is, at the level of detail a second implementation or a proof would need. Every rule is annotated with its source of record — the file and function; the code is authoritative, and this document tracks it.

Contents

  1. Notation
  2. Concrete syntax
  3. Abstract syntax
  4. Lowering: Dogwood ⇝ Cedar
  5. Validation
  6. Authorization
  7. Meta-properties

1. Notation

Dogwood is defined by translation into Cedar plus a stateful monitor. A source policy set is first lowered to a Cedar PolicySet together with an augmented Cedar schema; validation and authorization are then largely defined in terms of that Cedar artifact, with the temporal sub-language and the information providers supplying values that Cedar itself cannot compute. Three judgments capture this:

JudgmentRead asSection
Γ ⊢ p ⇝ πpolicy p lowers to Cedar policy π (recording hoisted leaves)§4
Σ ⊢ L ✓lowered artifact L is valid under schema Σ (no findings)§5
⟨H, e⟩ ⇓ ⟨H′, r⟩ingesting event e in history H yields history H′ and result r§6

Inference rules are written in the usual style — premises above the line, conclusion below, name at the right:

        premise₁      premise₂
       ───────────────────────── (Rule-Name)
              conclusion

A rule with no premises is an axiom. A side-condition is written in the premise position in prose. We write ⟦e⟧ for “the Cedar translation of e”, fv(e) for the free (unbound) variables of e, and · for sequence concatenation. Metavariables: p policies, e core expressions, φ temporal conditions, t terms, A actions, k event kinds, H histories, β temporal bindings, Σ a (composite) schema.

Two channels of failure are distinguished throughout, and the distinction is load-bearing (see §5.1):

  • a fatal error (Error) aborts lowering — there is nothing well-formed to validate or authorize;
  • a finding (ValidationError / ValidationWarning) is a validation result over an artifact that did lower.

2. Concrete syntax

The grammar is given in EBNF: { x } is zero-or-more, [ x ] is optional, x | y is alternation, "lit" is a terminal. It is transcribed from the three pest grammars of record:

  • core policy — src/parser/grammar.pest
  • temporal — src/extension/temporal/grammar.pest
  • event schema — src/event_schema/grammar.pest

(There is no provider grammar: an information-provider invocation is ordinary Cedar recognized at lowering — see §2.5.)

Whitespace and // line comments are insignificant between tokens (each grammar declares them implicit). Identifiers are [A-Za-z_][A-Za-z0-9_]*.

2.1 Policy sets, macros, rules

A .dw source is a sequence of macro definitions and policy rules, in any order.

policies    ::= { def_decl | policy }

def_decl    ::= "def" ("cedar" | "temporal") ident "(" [ params ] ")"
                "{" block "}" ";"
params      ::= param { "," param }
param       ::= "?" ident

policy      ::= { annotation } effect "(" scope ")" { cond } ";"
annotation  ::= "@" ident [ "(" string ")" ]
effect      ::= ident                       (* semantically: "permit" | "forbid" *)

scope       ::= [ variable_def { "," variable_def } [ "," ] ]
variable_def::= ident [ ":" name ] [ "is" add ] [ rel_op expr ]

Notes. effect is any identifier at the grammar level; a later pass requires it to be permit or forbid (this yields a better diagnostic than a parse failure). The ":" form in variable_def (principal : User) is legacy and is accepted only so the semantic pass can emit a “use is” hint. block is the raw text between a marker’s braces, with balanced inner braces honored — it is dispatched to a sub-language parser.

2.2 Condition clauses

cond      ::= cond_kw ( extension_marker | guardrails_tag? "{" expr "}" )
cond_kw   ::= ident                          (* semantically: "when" | "unless" *)

extension_marker ::= dialect_tag "{" block "}"
dialect_tag      ::= "temporal"
guardrails_tag   ::= "guardrails"

A rule carries any number of when / unless clauses in any combination; they are implicitly conjoined (the rule fires iff every when holds and no unless holds — made precise in §4).

temporal is a genuine sub-language: the tagged form when temporal { … } captures the braced body as raw block text dispatched to the temporal parser, and — because extension_marker is also a primary (§2.3) — a temporal marker may appear inline, so when { context.x > 5 && temporal { … } } is legal (the tagged form alone cannot express this).

guardrails, by contrast, is not a sub-language: guardrails { E } is transparent sugar for a bare { E } clause, where E is a full Cedar expression parsed identically to an untagged when { … }. An information-provider invocation inside it (Ns::Fn(args)…) is recognized and hoisted at lowering time exactly as in a bare when { … } (§4.4), so the tag adds nothing semantically and the parser discards it. It is retained only for backward compatibility of the surface syntax. (“provider” is the term used everywhere else; guardrails is the surface keyword.) There is no closed provider grammar.

2.3 Core expressions (Cedar-derived)

The core expression grammar is a pest transliteration of Cedar’s own (cedar-policy-core v4.x); precedence is encoded by the rule layering, exactly as in Cedar’s CST.

expr    ::= if_expr | or
if_expr ::= "if" expr "then" expr "else" expr
or      ::= and { "||" and }
and     ::= rel { "&&" rel }
rel     ::= add [ has_tail | like_tail | is_tail | { rel_op add } ]
has_tail::= "has" ( "if" { mem_access } | add )
like_tail ::= "like" add
is_tail ::= "is" add [ "in" add ]
rel_op  ::= "<=" | ">=" | "!=" | "==" | "<" | ">" | "in" | "="
add     ::= mult { ("+" | "-") mult }
mult    ::= unary { ("*" | "/" | "%") unary }
unary   ::= [ "!"+ | "-"+ ] member
member  ::= primary { mem_access }
mem_access ::= "." ident | "(" [ expr { "," expr } ] ")" | "[" expr "]"

primary ::= extension_marker | literal | slot | ref | name
          | "(" expr ")" | list | record
literal ::= "true" | "false" | number | string
slot    ::= "?principal" | "?resource" | "?" ident
name    ::= ident { "::" ident } | ident
ref     ::= name "::" ( string | "{" [ ref_init { "," ref_init } ] "}" )
list    ::= "[" [ expr { "," expr } ] "]"
record  ::= "{" [ rec_init { "," rec_init } ] "}"

= is captured by rel_op deliberately, so the semantic layer can emit a “did you mean ==?” hint rather than a raw parse error. A run of ! or - may not be mixed (!-x is rejected), matching Cedar. / and % parse but are rejected downstream (Cedar has no division). Surface operators !=, >, >= are retained here and desugared during lowering (§4.3).

2.4 Temporal sub-language

The body of a temporal { … } block. Past-only; within is mandatory on every temporal operator.

condition   ::= conjunct_or_since { "&&" conjunct_or_since }
conjunct_or_since ::= neg_conjunct [ "since" within atom ]
neg_conjunct::= { "!" } conjunct
conjunct    ::= comparison | parenthesized | temporal_op
              | exists_op | tp_op | call | refinable

temporal_op ::= "formerly" within atom | "previous" within atom
exists_op   ::= "exists" typed_binder "." condition
tp_op       ::= "tp" "(" binder_slot ")"
parenthesized ::= "(" condition ")"
atom        ::= "(" condition ")" | tp_op | call | refinable | comparison

refinable   ::= ( predicate | param_ref ) { field_block }
field_block ::= "{" [ named_args ] "}"
predicate   ::= qualified_action "::" event_kind "{" [ named_args ] "}"
qualified_action ::= ident { "::" ident } "::" string
event_kind  ::= ident
named_args  ::= named_arg { "," named_arg }
named_arg   ::= field_path ":" term
field_path  ::= ident { "." ident }

comparison  ::= term cmp_op term
cmp_op      ::= "<=" | ">=" | "==" | "<" | ">"

agg_expr    ::= sum_expr | count_expr | call
sum_expr    ::= "sum" binder_slot for_binders "where" condition
count_expr  ::= "count" for_binders "where" condition
for_binders ::= "for" typed_binder { "," typed_binder } "."
typed_binder::= "(" binder_slot ":" type_expr ")"
type_expr   ::= ident { "::" ident }

within      ::= "within" ( param_ref | integer time_unit )
time_unit   ::= "s" | "m" | "h" | "d"

call        ::= ident "(" [ call_arg { "," call_arg } ] ")"
call_arg    ::= interval_lit | condition | term
interval_lit::= integer time_unit

term        ::= entity | decimal_lit | paren_agg | agg_expr | integer
              | string | "true" | "false" | array | context_field
              | scope_field | wildcard | param_ref | binder_ref | ident
entity      ::= ident { "::" ident } "::" string
decimal_lit ::= "decimal" "(" string ")"
context_field ::= "context" ("." ident)+           (* the context record  *)
scope_field ::= ("principal" | "resource") ("." ident)*  (* scope entity ± attr *)
wildcard    ::= "*"
binder_slot ::= param_ref | binder_ref | ident
param_ref   ::= "?" ident        (* macro value parameter    *)
binder_ref  ::= "$" ident        (* macro fresh binder       *)

Precedence and scope. && is loosest; ! binds tighter than both since and && (so !a since W b negates only a, and !a && b negates only a). exists is a binding form with maximal right scope: exists (x: T). φ && ψ binds x over φ && ψ; parenthesize to stop it early. An aggregate is a term syntactically; the rule that an aggregate may appear only as a comparison operand is a validation rule (§5.4), not a grammar rule. param_ref (?p) and binder_ref ($t) are legal only inside a macro body; the well-formedness pass rejects them elsewhere.

2.5 Information-provider invocations (no dedicated grammar)

An information-provider invocation has no dedicated syntax: it is an ordinary Cedar expression (§2.3) whose head is a namespace-qualified call, optionally followed by output methods and a field/index projection, then used in any Cedar position (comparison, arithmetic, boolean combination). All of the following are provider invocations, recognized structurally — a namespaced Ns::Fn(…) call — and hoisted at lowering (§4.4):

Strings::Matches(context.input.doc, "^[A-Z]+$").matched == true
Content::Filter(context.input.doc, ["VIOLENCE"])["VIOLENCE"].severityScore.lessThan(decimal("0.5"))
BedrockGuardrails::ContentFilter(context.input.doc).maxConfidenceScore() < 50
Access::Allowed(principal.id).allowed == true
Strings::DigitCount(context.input.doc).count + 1 <= 3

Because the invocation is plain Cedar, it composes with the full expression grammar — arithmetic on outputs, if/then/else, mixing provider and non-provider terms — with no restriction beyond what Cedar itself allows. This holds identically whether the invocation sits in a bare when { … } or a guardrails { … } clause (the latter is transparent sugar for the former, see §2.2).

Two surface shapes are relevant to the parser:

  • Arguments. A provider argument is resolved before Cedar runs (it helps build the context Cedar evaluates against), so it must be a value the resolver can read off the request event: an attribute path rooted at context, principal, or resource; a literal (string / integer / bool / decimal("…")); or a set of those. This mirrors MFOTL’s argument rule; arbitrary arithmetic / if is not a provider argument. A non-conforming argument is a lowering error (§4.4).

  • Methods. A method whose name is not a Cedar built-in (isEmpty, contains, lessThan, …) is deferred by the parser as a MethodCall node (Cedar built-ins are still desugared eagerly). At lowering, if the chain’s base is a provider invocation the method is a declared output method; otherwise it is a genuine unknown-method error. A method post-processes the output: Fn(a).m(b) binds m(evaluate(a), b), and methods chain (.m1().m2() = m2(m1(output))). Each method has its own argumentTypes + outputType in the provider declaration’s availableMethods map, and resolves to a fn name(input, args…) in the provider’s Rhai script. The method chain is eager (evaluated in Rhai at authorize time); a field/index projection after the last method stays native Cedar over the hoisted value, and a projection may not precede a method in the same chain.

2.6 Event-schema DSL

A schema-independent description of how event signatures are derived from an action schema.

schema      ::= [ max_window ] { event_decl }
max_window  ::= "max_window" "=" interval
interval    ::= integer time_unit                  (* time_unit ∈ {s,m,h,d} *)
event_decl  ::= [ "decision" ] "event" "<" binder ">" "::" event_kind
                "{" [ fields ] "}"
fields      ::= field { "," field } [ "," ]
field       ::= spread | named_field
spread      ::= "..." selector "(" binder_ref ")"
named_field ::= [ "pin" ] ident ":" type_expr [ "=" pin_ref ]
selector    ::= "inputs" | "outputs" | "principalType" | "resourceType"
type_expr   ::= selector_call | record_type | concrete_type
selector_call ::= selector "(" binder_ref ")"
record_type ::= "{" [ fields ] "}"
concrete_type ::= ident { "::" ident }
pin_ref     ::= pin_scope | pin_context
pin_scope   ::= ("principal" | "resource") ("." ident)*
pin_context ::= "context" ("." ident)+

decision marks the event kind as a decision kind (ingesting one runs authorization). <A> is the action binder; ...inputs(A) splices the action’s context.input fields (likewise outputs); principalType(A) / resourceType(A) yield the action’s appliesTo entity type set. pin marks a field whose value is forced to a request-side value on every predicate for the event (a correlation); pin and the = <reference> clause must appear together, where the reference is a scope entity (principal / resource, ± an attribute tail) or a context field (context.<path>), and a pinned field must be a leaf. pin, decision, event are contextual keywords. This grammar is purely syntactic — binding the selectors to a concrete schema is the derivation pass, which runs inside lowering (§4.5).

The optional leading max_window = <interval> directive caps how far back any policy’s temporal within window may look; it must precede the event declarations, appear at most once, and be a positive interval (a zero window is a parse error). Absent, derivation supplies a 24h default. The cap is enforced by the temporal dialect (§5.5, TEMP-MaxWindow).


3. Abstract syntax

Parsing produces the surface AST. The core spine and operators are Dogwood’s own (src/ast.rs); leaf values (literals, entity refs, patterns, entity types) reuse Cedar’s cedar_policy_core::ast types verbatim.

PolicySet  ::= (defs: MacroDef*,  policies: Policy*)
Policy     ::= (annots: Annotation*, effect: Effect, scope: Scope, conds: Cond*)
Effect     ::= Permit | Forbid
Cond       ::= (kw: When | Unless, body: Expr)
Scope      ::= (principal: PrincipalConstraint,          (* Cedar constraints,   *)
                action:    ActionConstraint,             (* built by the parser, *)
                resource:  ResourceConstraint)           (* carrying .dw Locs     *)

Expr       ::= Lit ℓ | Var v | Slot s | Extension X
             | UnaryApp(UnOp, Expr) | BinaryApp(BinOp, Expr, Expr)
             | GetAttr(Expr, name) | HasAttr(Expr, name⁺) | Like(Expr, pat)
             | Is(Expr, ety, Expr?) | IfThenElse(Expr, Expr, Expr)
             | Set(Expr*) | Record((name × Expr)*)
             | Call(name, Expr*) | MethodCall(Expr, name, Expr*)  (* both residual; see below *)
             | ParamRef(name)                                     (* transient; see below *)
X          ::= Temporal φ

UnOp = Not | Neg | IsEmpty plus the extension constructors (decimal | datetime | duration | ip) and zero-argument extension methods (isIpv4 | … | toDays). BinOp = the core relational/boolean/arithmetic operators (including the surface-only NotEq | Greater | GreaterEq), the set/entity/tag operators, and the one-argument extension methods (DecimalLessThan | … | isInRange | offset | durationSince). Extension now carries only Temporal — an information provider is not an extension leaf but a residual Call/MethodCall recognized at lowering (below). Call, MethodCall, and ParamRef are residual: after macro expansion a Call survives only if it is a namespace-qualified (provider) invocation, a MethodCall survives only as a non-Cedar-builtin method (a provider output method), and ParamRef never survives — lowering hoists the provider forms (§4.4) and rejects everything else.

Temporal abstract syntax (src/extension/temporal/ast.rs):

φ  (Condition)  ::= And(φ, φ) | Not(φ)
                  | Formerly(W, φ) | Previous(W, φ) | Since(φ, W, φ)
                  | Predicate P | Comparison(⋈, t, t)
                  | Exists((x:T), φ) | Tp(x)
                  | Call c | SigilRef(σ, name) | Refine(φ, NamedArg*)  (* transient *)
P  (Predicate)  ::= (ns: name*, action: string, kind: string, args: NamedArg*)
NamedArg        ::= (name: field_path, value: t)
t  (Term)       ::= Entity(ty,id) | Int n | Decimal s | Str s | Bool b
                  | ContextField(seg*) | Var x | Wildcard | Array(t*)
                  | Agg a | ParamRef name | BinderRef name              (* last two transient *)
a  (AggExpr)    ::= Sum(x, (x:T)*, φ) | Count((x:T)*, φ) | Call c
⋈  (CmpOp)      ::= ≤ | < | ≥ | > | =
W  (WithinSpec) ::= Concrete(n, unit) | ParamRef name                  (* ParamRef transient *)
T  (Type)       ::= Timepoint | Named(name*)

Since’s negative form (“left has not held since”) is Not around the left operand — there is no dedicated flag. Tp(x) binds x to the current timepoint. Binder type annotations are authoritative: validation seeds each declared type into the type environment and checks every use of the variable against it (see §5.5), rather than inferring the type from a use site. The transient nodes (Call, SigilRef, Refine, Term::ParamRef, Term::BinderRef, BinderSlot::{ParamRef,BinderRef}, WithinSpec::ParamRef, AggExprKind::Call) appear only inside an unexpanded macro body and are removed by macro expansion; a valid post-expansion tree contains none of them.

Provider data types (src/extension/provider/ast.rs): there is no provider expression AST — an invocation lives in the core Expr tree as a Call (with an optional MethodCall chain). The module holds only the data types the invocation is lifted into at lowering: Invocation = (function: name*, args: Arg*), a MethodCall = (name, args: Arg*) for each output method, and Arg ::= Field(seg*) | String | Integer | Decimal | Bool | Set(Arg*), where a Field path is rooted at context / principal / resource.


4. Lowering: Dogwood ⇝ Cedar

Lowering is a translation semantics: a Dogwood policy set becomes a Cedar PolicySet (one static policy per rule) plus an augmented Cedar schema and a list of hoisted leaves. It is the crate-private parse/lower pipeline (src/api.rs), split into two phases along the one input that motivates the split — the action schema (see Chapter 07):

  • parse(source, ServiceSchema) — pure syntax: parse, merge the macro library, macro-expand. No action schema. (api.rs, ParsedPolicySet::parse, rule L-Phase-Parse.)
  • lower(parsed, PolicySchema, distincter?) — derive the event schema against the action schema, translate each rule, augment the schema, inject pins, and assemble the Lowered artifact. (api.rs, lower, rule L-Phase-Lower.)

The translation environment is Γ = (𝓀, ι, D) where 𝓀 is the current rule’s rule key (§4.1), ι a per-rule field ordinal, and D the provider declarations. We write Γ ⊢ e ⇝ ⟦e⟧ ⊣ Γ′ for “e translates to the Cedar expression ⟦e⟧, threading state Γ → Γ′” (the state carries the accumulating hoisted-leaf lists and the ordinal counter).

4.1 Rules, policy ids, and hoisting

Each Dogwood rule becomes exactly one Cedar static policy.

   effect ↦ ε      scope = (P, A, R)      Γ₀ = Γ[𝓀 := key(δ, i), ι := 0]
   Γ₀ ⊢ conds ⇝ γ ⊣ Γ′        id = key(δ, i)      annots ↦ ᾱ
  ───────────────────────────────────────────────────────────────────── (L-EmitPolicy)
   Γ ⊢ (rule i, effect, scope, conds, annots)
        ⇝  StaticPolicy(PolicyID id, ᾱ, ε, P, A, R, γ)  ⊣ Γ′

(cedarify/mod.rs emit_policy.) The scope constraints P, A, R pass through unchanged — the parser already built them as loc-bearing Cedar constraints (L-ScopePassthrough, cedarify/mod.rs emit_policy). Effect maps directly, Permit ↦ Permit, Forbid ↦ Forbid (L-Effect). Annotations map to Cedar annotations; a key that fails to parse as a Cedar id is silently dropped, and @id is not special-cased — it is an ordinary annotation, never the policy’s identity (L-Annotations, cedarify/mod.rs emit_policy). A rule containing an unfilled template slot (?principal/?resource) fails: StaticPolicy::try_from errors “policy is not static”.

Rule key. The i-th rule’s key — which is simultaneously the emitted Cedar PolicyID, the policy-store key, the decision token returned in a response, and the prefix on that rule’s hoisted field names — is

key(δ, i)  =  δ "_" i      if a distincter δ is supplied
           =  "policy_" i   otherwise

(cedarify/mod.rs rule_key, rule L-RuleKey.) It is deliberately not derived from @id; distinctness is the caller’s decision, so that independently-lowered sets can be combined without colliding ids (see Chapter 07 on lower_with_distincter). Two rules that mint the same id are a fatal error (“duplicate policy id”). The field ordinal ι resets to 0 at each rule boundary and is shared by both hoisting schemes below.

4.2 Clause folding

A rule’s condition γ is the conjunction of its clauses, when verbatim and unless negated:

  ───────────────────────────── (L-Fold-Empty)
   Γ ⊢ [] ⇝ true ⊣ Γ
   Γ ⊢ e ⇝ ⟦e⟧ ⊣ Γ′
  ────────────────────────────── (L-Fold-When)
   Γ ⊢ (when e) ⇝ ⟦e⟧ ⊣ Γ′
   Γ ⊢ e ⇝ ⟦e⟧ ⊣ Γ′
  ─────────────────────────────────── (L-Fold-Unless)
   Γ ⊢ (unless e) ⇝ !⟦e⟧ ⊣ Γ′
   Γ ⊢ c ⇝ γ_c ⊣ Γ₁       Γ₁ ⊢ rest ⇝ γ_r ⊣ Γ₂       rest ≠ []
  ──────────────────────────────────────────────────────────────── (L-Fold-Cons)
   Γ ⊢ (c :: rest) ⇝ (γ_c && γ_r) ⊣ Γ₂

(cedarify/mod.rs emit_policy.) Clauses fold left-associatively in source order: [c₁, c₂, c₃] becomes And(And(γ₁, γ₂), γ₃). A bare rule (no clauses) has condition literal true — note this is Some(true), an explicit condition, not an absent one. The synthesized Not (for unless) and And (for the conjunction) nodes carry the whole-rule source location, since they correspond to no surface token; every other node carries its own span (L-NodeLoc).

4.3 Expression translation

Non-extension expressions translate by a straightforward syntax-directed map, each node stamped with its .dw span. Leaves: Lit ℓ ⇝ ℓ, Var v ⇝ v, Slot s ⇝ s (L-Expr-Leaves). Structural nodes recurse and rebuild (GetAttr, HasAttr, Like, Is, IfThenElse, Set, RecordL-Expr-Structural). Operators desugar via Cedar’s own ExprBuilder — the same primitive Cedar’s text parser uses — so the output is identical to Cedar-parsed text. The surface-only operators desugar to negations:

   NotEq(l, r)     ⇝  !(⟦l⟧ == ⟦r⟧)
   Greater(l, r)   ⇝  !(⟦l⟧ <= ⟦r⟧)
   GreaterEq(l, r) ⇝  !(⟦l⟧ <  ⟦r⟧)

(to_ast.rs lower_binary, L-Binary-Table.) Extension constructors and methods become ExtensionFunctionApps keyed by the unqualified name: unary decimal | datetime | duration | ip | isIpv4 | … | toDays (L-Unary-Table, to_ast.rs lower_unary) and binary isInRange | offset | durationSince | lessThan | lessThanOrEqual | greaterThan | greaterThanOrEqual. A Call that is not a namespace-qualified (provider) invocation, and any ParamRef, are fatal at this point (L-Expr-Call) — a well-formed post-expansion tree has neither except a provider Call/MethodCall, which the provider rules below hoist.

4.4 Information-provider leaves

A temporal marker and a provider invocation are the two forms that are not a pure syntactic map: each is replaced by a context.<id> reference and its content recorded as a hoisted leaf, evaluated by the monitor at authorization time. The .dw span of the replacement is the original node’s span, so a type error on a hoisted field points back at the source it came from.

   name = 𝓀 "__temporal_" ι      Γ′ = Γ[ι := ι+1, bool_fields ⊕ ⟨scope, name, φ⟩]
  ─────────────────────────────────────────────────────────────────────────────── (L-Hoist-Temporal)
   Γ ⊢ Temporal φ  ⇝  context.name  ⊣ Γ′

(to_ast.rs lower_expr.) The recorded ContextField carries the rule’s scoped action (Concrete / List / Unconstrained, classified off the Cedar action constraint — L-ScopeAction-Classification, cedarify/mod.rs scope_action), the field name, and the temporal condition. Semantically the field is a pre-evaluated Bool.

A provider invocation is a Call whose name is namespace-qualified (L-Expr-Call-Provider), or a MethodCall chain whose base peels down to such a Call (L-Expr-Method-Chain, to_ast.rs:peel_provider_chain — a MethodCall whose base is not a provider is the fatal unknown-method error). In both cases lowering collects the base Invocation, its eager output-method chain (in source order), and any trailing field/index projection after the last method. The rule may use any action scope (==, in [list], in Group, or a bare action); the hoisted field is typed from the declarations, declared on every action’s context by schema augmentation, and evaluated for every decision event (execution is unconditional — see the provider contract in Information providers). It hoists to a two-level reference context.providers.<id>:

   name = 𝓀 "_p_" ι      τ = cedarType(D, invocation, methods)      Γ′ = Γ[ι := ι+1, provider_fields ⊕ …]
  ──────────────────────────────────────────────────────────────────────────────────────────────────── (L-Hoist-Provider)
   Γ ⊢ Invocation·methods  ⇝  context.providers.name  ⊣ Γ′

(to_ast.rs lower_provider_invocation.) τ is the pipeline’s tail type: the last method’s outputType if the chain is non-empty, else the invocation’s outputType, looked up from the declarations D and defaulting to String when undeclared (the undeclared case — and an undeclared/misused method — is caught by validation, §5.6). The invocation’s arguments are lifted to Args (attribute paths rooted at context/principal/resource, literals, sets; a non-conforming argument is fatal). The method chain is eager (evaluated in Rhai at authorize time and bound into the field); the trailing projection (.field / ["key"]) lowers to a chain of native GetAttr over the hoisted value (a string index r["k"] is exactly r.k), and the surrounding comparison is ordinary Cedar (L-Provider-Projection). A projection may not precede a method in the same chain (fatal).

Because the field name is prefixed by the rule key 𝓀 and suffixed by the per-rule ordinal ι, hoisted names are deterministic and unique across lowering calls with distinct distincters — the property that lets independently-lowered sets share one Cedar PolicySet / policy store.

4.5 Schema derivation and augmentation

Two schema transformations happen inside lower:

  1. Derivation. The event-schema DSL (§2.6) is bound against the concrete action schema, yielding, per action A and declaration, a derived event (ns(A), A, kind, decision?, fields, pins). This is the sole place the symbolic selectors (inputs/outputs/principalType/resourceType) meet a real schema. The set of kinds marked decision becomes the decision_kinds used by the authorizer (event_schema/derive.rs).

  2. Augmentation. The action schema is extended with the hoisted context fields the lowered policies reference: for each temporal leaf, a required Bool attribute context.<name> on its action(s); for provider leaves, a providers record grouping the fields, typed from the declarations. The augmentation merges into an existing providers record rather than replacing it, so feeding an augmented schema forward (incremental lowering) preserves earlier providers (cedarify/schema_augment.rs).

Finally the temporal leaves’ conditions have schema pins injected — a pinned event field f is conjoined f: context.<pin-path> onto every matching predicate, realizing the “same X” correlation (event_schema/pin.rs).

After pin injection, leaves are relativized when the schema declares at least one universal symmetric pin — a pin present (identically) on every derived event kind whose context path is the field’s own path, or a reserved scope-alias pair (callerPrincipal/principal, callerResource/resource — the bare scope reference, not context.principal) (L-Relativize, event_schema/relativize.rs). Let μ be the disjunction, over every derived event kind, of a predicate carrying exactly the pinned correlations (“an event of any kind agreeing with the current request on every universally-pinned field”). The rewrite guards each temporal-scope body that does not already contain a positive predicate conjunct with μ ∧ ·; replaces previous[0,W] φ by a timepoint-encoded “most recent μ-position satisfies φ, within W of the decision point” (an anti-join over tp-bound positions); and replaces a since whose left is not a μ-confined negation by a count-equality encoding of “every μ-position in the anchor range satisfies the left”. The rewritten formula’s verdict over the global trace equals the original formula’s verdict over the sub-trace agreeing with the request on the pinned fields — the partition guarantee: storage and evaluation may be sharded by the pinned key without changing any verdict. Every synthesized binder is fresh (__pin_*) and range-restricted by a positive atom before any filter uses it. Validation (§5) runs on the pre-rewrite leaves, so findings point at authored structure; engines receive the rewritten leaves (Lowered.temporal_rewritten, surfaced by temporal_fields() / temporal_leaves()). With no universal symmetric pin the rewrite is the identity. The default event schema is not such a case: it pins callerPrincipal on every derived kind, so under the default the rewrite is active.

The result is the Lowered artifact (L-Lowered-Artifact, api.rs, struct Lowered): the Cedar policies, the augmented_schema (+ its source text), the hoisted temporal and provider leaf lists, the parallel rule_ids / rule_spans (where rule_ids[k] is rule k’s PolicyID), the derived event_schema, and decision_kinds.


5. Validation

Validation is the judgment Σ ⊢ L ✓: the lowered artifact L, with its augmented schema Σ, produces no error findings. It is Validator::new().validate(&L) (src/validate.rs, src/validator.rs). The validator holds no schema of its own — the augmented schema travels on L — which is why, unlike Cedar’s Validator::new(schema), Dogwood’s takes none.

5.1 The two-channel error model

   parse(src, S) = Ok(𝓅)     lower(𝓅, S′) = Ok(L)
  ────────────────────────────────────────────────── (V-TwoChannel)
   L is a proof object; validate(L) yields only findings, never a fatal Error

(validate.rs.) A fatal Error — a parse failure, a macro-expansion failure, a lowering failure, or an augmented-schema failure — aborts before a Lowered exists, so the validator never sees it. The existence of L is the proof that those four phases succeeded. Everything validate produces is a finding (ValidationError / ValidationWarning); it never returns a fatal error and never a syntax/lowering error. Σ ⊢ L ✓ holds iff the error channel is empty.

5.2 Order of checks

   validate_cedar_side(L, Σ) = (E₁, W₁)
   TemporalDialect.run(L.temporal, ctx) = (E₂, W₂)
   ProviderDialect.run(L.providers, ctx) = (E₃, W₃)
  ─────────────────────────────────────────────────────────── (V-Order)
   validate(L) = ValidationResult(E₁·E₂·E₃, W₁·W₂·W₃)

(validate.rs validate_impl.) Checks run in a fixed order — Cedar side, then temporal, then provider — accumulating into two channels with no short-circuit across checks. ctx = (Σ = L.augmented_schema, event_schema = L.event_schema, dw_src). Σ ⊢ L ✓ ⟺ E₁·E₂·E₃ = [].

5.3 The Cedar side

   L.policies = []                                  L.policies ≠ []
  ──────────────────── (V-Cedar-Empty)      ────────────────────────────────────────── (V-Cedar-Validate)
   Cedar side = ([], [])                     Cedar side = CedarValidator(Σ).validate(
                                                            L.policies, Strict)

(validate.rs validate_impl.) Cedar’s own validator runs in strict mode over the augmented schema. Its errors become ValidationError::Cedar findings and its warnings pass through verbatim (V-Cedar-Warnings). Crucially, this pass is where several things are deliberately delegated rather than re-checked elsewhere: provider output/projection typing, provider field-path arguments, and context typing under an unconstrained action scope. Each finding is located in .dw: prefer the Cedar diagnostic’s own label span (the lowered AST carries .dw locations), else fall back to the originating rule’s whole span via rule_ids → rule_spans, else a 1-byte span at the start (V-Cedar-Span).

5.4 Temporal acceptance (well-formedness)

Before a temporal condition is ever validated against a schema, it must pass the acceptance checker (src/extension/temporal/check.rs), run at parse time via check_condition(φ, ∅). This enforces MFOTL-style safe-range monitorability — the conditions under which the condition denotes a finite, computable relation over the event history. These are the rules most likely to reject a plausible-looking policy, so they are given in full. All are conservative (“reject if unsure”) and are suspended in the presence of a macro sigil (re-checked post-expansion — WF-Sigil-Punt).

We write x ∈ RR(φ) for “x is range-restricted by a positive atom of φ”, where RR collects restrictors from: predicate fields P{f: x}, tp(x), a binding equality (non-var, non-wildcard term) == x or the mirror (x == * restricts nothing — a wildcard resolves to no value), the bodies of formerly/previous, and the right operand of since — descending through && and nested exists (dropping the inner binder’s own fact). A negation contributes nothing, at any depth: RR does not descend into — the ¬-rule discards every restriction fact rather than flipping a parity, so a doubly-negated atom is not a positive restrictor (evaluation treats a negation as an opaque boolean filter whose rows carry no bindings).

Existential range restriction.

   x ∈ RR(φ)          Γ, x ⊢ φ ✓wf
  ──────────────────────────────────── (WF-Exists)
   Γ ⊢ exists (x : T). φ  ✓wf

(check.rs check_exists_safe.) An exists-bound variable must be range-restricted by a positive atom inside its body. Otherwise: “existential variable x is not range-restricted by any positive atom in the exists body …”. (Sigil slot or a sigil anywhere in the body ⇒ accept, deferring to post-expansion.)

Ordered conjunction (the demands rule). Every conjunct is a conditional restriction fact demands(c) → RR(c): it produces the variables in RR(c) and consumes the variables in demands(c) — bindings that must already be in the environment when it evaluates. The standard formulation discharges such facts order-independently; this evaluator binds left-to-right, so the chain must be a valid discharge order. Flatten a &&-chain into [c₁ … cₙ] in source order and walk left to right with an accumulator ρ:

   for each i:  demands(ci) ⊆ ρ          ρ ⇐ ρ ∪ RR(ci)
  ──────────────────────────────────────────────────────── (WF-Demands)
   c₁ && … && cₙ   ✓wf

(check.rs check_demands/check_chain.) The demanding conjuncts (WF-Demands-Classification):

  • a pure filter — an ordering comparison (<, <=, >, >=), an equality that binds nothing (x == y with both bare vars, both sides ground, or a * wildcard side — a wildcard is never a resolvable value), or a guarded negation (WF-Neg-Guarded) — demands all its free variables and produces none. Otherwise: “variable v is used in a filter … that is not range-restricted by a preceding conjunct …”.
  • a binding equality x == v produces x and demands the value side’s outward free variables — for an aggregate operand, fv(body) ∖ for-list (a correlated aggregate is only per-u when u is already bound; with u unbound the count/sum silently de-correlates). Otherwise: “the equality binding n reads u inside its aggregate operand …”.
  • a since produces RR(anchor) and demands fv(left) ∖ RR(anchor) (the standard free(β) ⊆ free(γ) side condition, relaxed to “or restricted earlier”: the left is a per-step condition evaluated under the anchor’s bindings and binds nothing itself). Otherwise: “variable u is used in the left operand of a since …”.

Demands propagate through non-chain wrappers (formerly/previous bodies, an exists body minus its binder). Nested && chains are checked self-contained, seeded with ρ at their position (WF-Demands-Seed): the evaluator threads every binding produced by preceding conjuncts into nested structure, so an enclosing-chain restrictor genuinely discharges a nested demand — and grouping with parentheses never changes acceptance. An enclosing binder is still never a seed (a binder restricts nothing by itself), and a shadowing binder removes its name from the inherited seed. So exists (a: Long). (a > 100 && P{f: a}) is rejected — the restrictor P{f: a} must precede the filter — while exists (u). (P{f: u} && exists (n). ((count … u …) == n && n >= 2)) is accepted: the nested chain inherits u from the enclosing one.

Leaf closedness. A temporal leaf must be closed: every variable bound by an exists binder or an aggregation for list.

   fv(φ) = ∅
  ──────────────────── (WF-Closed)
   φ   ✓wf as a leaf

(check.rs check_leaf_closed; run by Temporal::parse and again post-expansion.) A leaf is evaluated boolean-ly at the decision point with no implicit existential closure, so a free variable’s bindings would not thread across conjuncts — the accepted formula would silently evaluate as an always-false (or mis-correlated) guard. Violation: “variable x is free in this temporal condition …”. Here fv is the standard free-variable set (an aggregate binds its for list over its where body; exists binds its binder; tp(t) outside any binder leaves t free). No sigil punt is needed: an unresolved macro call contributes no visible variables, and macro hygiene guarantees expansion can never capture a call-site variable, so a variable free at parse time is necessarily still free after expansion.

Monitoring scope must monitor (tp-dependence). Every scope that establishes a timepoint must actually vary with the current timepoint (is_tp_dep):

   ─────────────────────────── (WF-TpDep-Conjunct)   each top-level when/unless conjunct is tp-dependent
   ─────────────────────────── (WF-TpDep-Op-Body)    the body of formerly/previous is tp-dependent
   ─────────────────────────── (WF-TpDep-Since)       both sides of since are tp-dependent
   ─────────────────────────── (WF-TpDep-Exists-Body) the body of exists is tp-dependent
   ─────────────────────────── (WF-TpDep-Agg-Body)    the where-body of an aggregate is tp-dependent

(check.rs, temporal/validate.rs check_tp_dependence.) A Predicate, a Tp, or an Agg is tp-dependent by construction; a Var is tp-dependent iff bound in the current scope; literals, context fields, and wildcards are not. Each violated scope emits a targeted message (e.g. “this when/unless conjunct does not vary with the current timepoint; it monitors nothing”). Binder harvesting is asymmetric: a variable bound only in a negated position or only inside an aggregation for-domain does not bind for the surrounding scope (TEMP-TpDep-BinderHarvest).

Aggregation binding. For sum v for (g₁:T₁) … (gₙ:Tₙ). where ψ (and count, which has no v):

   v ∈ {ḡ}       fv(ψ) ⊆ (Γ ∪ {ḡ})       ḡ ⊆ fv(ψ)       ∀g ∈ ḡ:  g ∈ RR(ψ)
  ─────────────────────────────────────────────────────────────────────────── (WF-Agg-Domain)
   sum v for ḡ. where ψ   ✓wf

(check.rs check_aggregation.) Four obligations, checked in order:

  1. The summed variable must be one of this aggregation’s own for binders (WF-Agg-BoundVar-InDomain): the sum is computed over the relation projected onto the for columns, so a summand outside it would silently sum to 0. Otherwise: “aggregation sums v, which is not one of its for binders …”.
  2. Every free variable of the where-body must be bound by the for-list or an enclosing binder (WF-Agg-Domain-Binds-FreeVars). Otherwise: “variable u is used in the aggregation body but is bound by neither …”.
  3. Every for variable must occur in the body (WF-Agg-Domain-Occurs; the standard safe-range rule presupposes ḡ ⊆ fv(ψ)): an unused group key would group over an unbounded domain. Otherwise: for-variable g does not occur in the aggregation body …”.
  4. Every for variable must be range-restricted by a positive atom of the body (WF-Agg-Domain-RR) — the aggregation analogue of WF-Exists, and what the monitorable fragment demands: the body must denote a finite relation over the for domain. Occurrence alone is not enough — a variable occurring only under a negation, or only in the left operand of a since, denotes an infinite relation (or one that does not range over the variable), and evaluation would silently degrade to a 0/1 witness count (a sum to 0). Otherwise: “aggregation for-variable g is not range-restricted by any positive atom in the where body …”.

Aggregate value. The rest of this section states acceptance obligations; the paragraphs under this heading state semantics instead. Nothing here can make a condition ill-formed, so §7’s meta-properties about what passes this section are unaffected. They sit with the other aggregation rules because that is where a reader looks for what sum means. (src/interpreter/eval.rs eval_agg_expr, sum_column.)

count yields the number of rows in the projected relation. sum yields the total of its summand column over that relation, skipping any row whose summand is not a Long — which is observable, because count over the same relation still counts that row. Validation rejects the common ways a summand could be non-Long — chiefly a declared type that disagrees with whatever range-restricts it — but it does not rule the case out: a comparison is only checked where both sides have a type, and an optional attribute can be declared Long and still be absent. The skip is therefore observable in a validated policy, not only in an unvalidated one. Both aggregates are Long.

Because an aggregate ranges over a set, its value does not depend on the order rows are visited, and it is exact for every total that a Long can hold — including totals reached by way of partial sums that a Long cannot hold.

An aggregate value that is not representable as a Long is implementation-defined. The language does not say what such an aggregate compares as: a conforming implementation may clamp it to the Long range, evaluate the comparison at a wider precision, report an error, or otherwise yield an unspecified value. A policy whose verdict depends on which of those an implementation chose is not portable, and two implementations may disagree on it without either being wrong, so such a policy is not a sound basis for comparing them.

Which policies those are depends on how the aggregate is used. Taking clamping and wider precision as the two readings — an erroring implementation differs from both for every comparison, interior thresholds included, so it is not covered by what follows:

  • Compared directly, only a comparison against an endpoint of the Long range can distinguish the two; a threshold strictly inside the range cannot, whichever way the total left it. At the maximum, ==, !=, > and <= distinguish them while >= and < do not; at the minimum, ==, !=, < and >= distinguish them while <= and > do not.
  • Bound to a variable, as exists (n: Long). ((A) == n && B) does, the reach is wider. An out-of-range total has no Long witness at all unless the implementation clamps, so a widening implementation empties the existential outright. The two readings then differ exactly when B holds of the clamped endpoint: they agree when it does not, so B = n > <maximum> agrees (no Long exceeds the maximum) while most other B do not. Note also that emptying the existential is not uniformly restrictive — under forbid it stops the rule firing, so the divergence surfaces as a permit.

If a policy must not depend on any of this, keep window totals inside the Long range — which for the aggregate to be meaningful is normally already true.

Aggregate position. An aggregate may appear only as the immediate operand of a comparison:

  ─────────────────────────────────────────────────────── (WF-Agg-Operand)
   an Agg is well-placed iff it is a direct operand of a Comparison;
   an Agg as a predicate-arg value, an array element, or otherwise nested ⟹ reject

(check.rs check_operand.) Violation: “an aggregate (sum/count) may appear only as the immediate operand of a comparison …”. This makes the aggregate-binding idiom exists (n: T). ((A) == n && B) well-formed: (A) == n binds n (restrictor), then B’s use of n is an already-restricted filter.

5.5 Temporal dialect validation (against the schema)

Given an accepted condition, the temporal dialect checks it against the derived event schema and the augmented Cedar schema, per leaf, in order: event-schema names → entity types → context paths → tp-dependence → types (temporal/validate.rs, TEMP-Order). Authority is divided: event/field name resolution is owned solely by the event-schema checker (§5.7); the dialect additionally checks:

  • Entity types — every Entity(ty, id) term must name a declared entity type; an action ref (…::Action::"X") is skipped (Cedar reserves Action); an enum entity’s id must be a permitted eid (TEMP-EntityType-*).
  • Context paths — a context.<path> must resolve against the scoped action’s full declared context record (Cedar’s context variable — context.input.<field>…, context.system.<field>…, etc.), on every action the leaf’s scope pins; an unconstrained scope defers to Cedar (TEMP-ContextPath-*). A principal / resource scope term is separate (Cedar’s request scope entities, not a context field): the bare root types to the scoped action’s principal / resource entity type, and an attribute tail (principal.dept) is accepted and resolved at eval time against the request’s entity store (untyped here, as on the provider surface).
  • Types (best-effort) — from a monomorphic environment seeded by the concrete scoped action’s input fields and every binder’s declared annotation (exists (x: T) and each aggregation for (v: T); the declared type is authoritative, not reverse-inferred from a use site). Against that environment: a predicate arg must match its declared field type; a use of a bound variable must be consistent with its declared type; an ordering comparison requires both operands numeric (only int is numeric — an aggregate types as int, decimal is not numeric here); an equality requires compatible types; and a tp(x) binder must be declared Timepoint — any other declaration conflates a timepoint index with a data value, making the condition permanently false (a validated dead guard). A check fires only when both types are known (TEMP-Type-*).
  • Max window — every within window in the leaf (on a formerly, previous, or since, at any depth including inside an aggregation where body) must be the event schema’s max_window cap, compared in seconds. A window strictly greater than the cap is rejected, located at the offending operator (TEMP-MaxWindow). The cap is the schema’s max_window directive or the 24h default (§2.6); post-expansion every window is concrete, so macro-supplied windows are checked too.

5.6 Provider dialect

For each hoisted provider leaf (provider/validate.rs):

   field.declaration = Some(decl)     |decl.argument_types| = |invocation.args|     ∀ (arg,param): accepts(param, arg)
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────────── (PROV-Ok)
   provider leaf ✓

An undeclared provider is caught only here (lowering defaulted its output type permissively) — “provider k is not present in the provider declarations” (PROV-DeclaredProvider). Argument count must match (PROV-ArgCount); a directly-typed literal/set argument must match the declared paramType (string/integer|long/bool|boolean/decimal/setPROV-ArgType); a field-path argument (context/principal/resource) is deferred to Cedar. Each output method in a chain is likewise checked here (PROV-Method-*): it must be declared in availableMethods, must not shadow a Cedar extension-method name, must be given the declared argument count/types, and — when it declares an inputType — must be fed a compatible value by the preceding pipeline stage (the running type starts at the invocation’s outputType and advances by each method’s outputType). Provider output/projection typing is not checked here — it was lowered to native Cedar and is checked by the Cedar side (§5.3, PROV-Output-Deferred).

5.7 Event-schema name resolution

The authority on predicate names (event_schema/validate.rs), applied to every Predicate reachable in the (post-expansion) condition tree:

   schema.get(P.ns, P.action, P.kind) = Some(ev)     ∀ arg ∈ P.args:  ev.lookup(arg.path) = Leaf
  ─────────────────────────────────────────────────────────────────────────────────────────────── (EVENT-Predicate)
   Predicate P  ✓names

(event_schema/validate.rs validate_condition.) A predicate must name a declared derived event (EVENT-Predicate-DeclaredEvent: else “predicate does not name a declared event …”), and every mentioned field path must resolve to a declared leaf (EVENT-Field-Leaf). Omitting a field is legal (omission = wildcard); only a mentioned name is checked. A mentioned path resolving to a field group rather than a leaf (EVENT-Field-Group), or to nothing (EVENT-Field-Absent), is an error.


6. Authorization

Authorization is stateful and event-driven. The judgment is

   ⟨H, e⟩ ⇓ ⟨H′, r⟩            r ∈ { None } ∪ { Some(ρ) : ρ a Response }

where H is the temporal engine’s observed history and H′ = H · e (the history always grows). A Response ρ = (decision ∈ {Allow, Deny}, reason: DogwoodRuleRef*, errors: String*). This is Authorizer::is_authorized (authorize/mod.rs, api.rs). The None result — “this event decided nothing” — is the essential difference from Cedar.

6.1 Observe, then gate on decision kind

Every event is observed first, unconditionally; then its kind decides whether a verdict is produced.

  ─────────────────────────── (A-Observe)     H′ := H · e   for every ingested e (observe runs first)
   H′ = H · e        e.kind ∉ decision_kinds
  ─────────────────────────────────────────────── (A-Ingest-History)
   ⟨H, e⟩ ⇓ ⟨H′, None⟩
   H′ = H · e        e.kind ∈ decision_kinds        ⟨H′, e⟩ ⇓_d ρ
  ────────────────────────────────────────────────────────────────── (A-Ingest-Decide)
   ⟨H, e⟩ ⇓ ⟨H′, Some(ρ)⟩

(api.rs, ingest.) observe(e) runs for both decision and history-only events (A-Observe), so at a later decision point the temporal leaves see the full prefix history including interleaved history-only events (A-Stateful-Invariant). ⇓_d is the decision judgment of §6.2. A stateless single decision is a fresh authorizer fed one decision-kind event.

6.2 The decision pipeline (fail-closed)

⇓_d runs four steps — evaluate temporal leaves, build context, build request, decide — each of the first three a fail-closed short-circuit. There are exactly three top-level fail-closed points (and build_request decomposes into four sub-causes). A fail-closed Response is always (Deny, reason = [], errors = [msg…]).

   temporal.evaluate(H) = β        β, providers, e ⊢ ctx ⇓ Ok(κ)        e, κ ⊢ req ⇓ Ok(q)
   decide(q) = ρ₀        ρ = ρ₀ with pre-errors prepended
  ────────────────────────────────────────────────────────────────────────────────────────── (A-Decide-Ok)
   ⟨H, e⟩ ⇓_d ρ
   temporal.evaluate(H) = Err(m)
  ───────────────────────────────────────────────────────── (FailClosed-Temporal)
   ⟨H, e⟩ ⇓_d (Deny, [], ["temporal evaluation: " · m])
   β, providers, e ⊢ ctx ⇓ Err(m)
  ─────────────────────────────────────── (FailClosed-Context)
   ⟨H, e⟩ ⇓_d (Deny, [], [m])
   e, κ ⊢ req ⇓ Err(m)
  ─────────────────────────────────────── (FailClosed-Request)
   ⟨H, e⟩ ⇓_d (Deny, [], [m])

(api.rs, decide_at.) The rationale for FailClosed-Context is explicit in the code: authorizing against a partial context could let a permit fire whose guard can no longer be checked — a Deny silently flipping to Allow. Scope of this rule: it describes THIS reference implementation. For provider-originated errors (the dominant cause of a context-build failure) the language-level semantics is undefined behavior — an erroring provider carries no cross-implementation guarantee, and a policy set must not rely on deny-on-error (see the provider contract). The FailClosed-Request family below is NOT so scoped: a decision event with a missing or malformed principal/resource must deny in every implementation. build_request fails closed when the event’s labeled scope has no principal or no resource (FailClosed-Request-NoPrincipal/NoResource), when the principal/action/resource UID is malformed (FailClosed-Request-MalformedUID — a fabricated fallback would silently authorize against the wrong entity), or when Cedar Request::new rejects the request.

6.3 Context assembly

The Cedar context κ is an ordered record with three groups of keys (api.rs, build_context, A-Context-Keys):

  1. input → the event’s input record. Only a Value::Object is taken; any other shape (or absence) degrades to an empty record — it does not error (A-Context-Input).
  2. for each (id, b) ∈ β: id → Bool(b) — the hoisted temporal booleans, at top level, i.e. context.<id>.
  3. if any provider leaves exist: providers → { id → v } for every provider invocation in the policy set (A-Context-Providers) — i.e. context.providers.<id>, unconditional. There is no applicability filter of any kind: provider execution is not gated by the rule’s action clause, scope constraints, or conditions (see the provider contract).

Value mapping (value_to_expr, A-Context-ValueMap) is the obvious one, with two lossy cases worth flagging: Null and a record-construction failure both collapse to the empty string literal. An entity value inside the context uses a sentinel (Drupe::Gateway::"unknown") on a malformed UID rather than failing closed — the deliberate counterpart to the fail-closed treatment of the request’s principal/action/resource (A-Context-EntityValue-Sentinel).

6.4 Provider evaluation

Each provider’s value is computed resolver-first, Rhai-fallback (api.rs, eval_provider):

   resolver present     resolver.resolve(name, args) = Some(res)
  ──────────────────────────────────────────────────────────────── (A-Provider-Resolver)
   eval_provider = res        (res = Ok(v) ⇒ v ; res = Err(m) ⇒ FailClosed-Context)
   resolver absent ∨ resolver declines (None)     field.declaration = Some(decl)
  ─────────────────────────────────────────────────────────────────────────────── (A-Provider-Rhai)
   eval_provider = evaluate(invocation, decl, args)

A caller-supplied ProviderResolver gets first refusal on every invocation; if it declines, the declared sandboxed Rhai implementation runs. An invocation with no declaration and no resolver value is an error (⇒ FailClosed-Context): “provider k was not declared … so it has no implementation to evaluate”. Arguments are resolved against the event: a context.<path> argument reads the event’s field at that path (falling back to a bare last-segment lookup, then Null); literals and sets map directly (A-Provider-ArgResolve).

6.5 The decision core

Once context and request are built, the decision is delegated to the pluggable PolicyEngine (default: local Cedar; alternatively, a remote Cedar-based engine) over the entity store E assembled by build_entities: the request’s scope entities (made present bare), any caller-supplied attributed entities from the event’s entity store — attributes and direct memberOf parents, validated against the augmented schema — and the schema’s action-hierarchy entities (so action in [Group] membership resolves). Cross-event state lives in the temporal history; per-event entity data flows through E:

   policy.is_authorized(q, E) = (d, ids, errs)
   reason = [ DogwoodRuleRef(k, id) : id ∈ ids,  rule_ids[k] = id ]
  ──────────────────────────────────────────────────────────────────── (A-Decide-Core)
   decide(q) = (d, reason, errs)

(api.rs, decide.) The engine returns a decision, the determining policy ids, and any errors. Each determining id is mapped back to its Dogwood rule by rule_ids (rule k’s PolicyID); an id not found there is dropped (A-Decide-RuleMap). An implicit Deny (no rule matched) has empty reason, exactly as Cedar’s does — which means a fail-closed Deny and an implicit Deny are indistinguishable in the reason channel and differ only in errors.


7. Meta-properties

These follow from the rules above; they are the properties a caller may rely on.

  • Fail-closed. No evaluation failure produces Allow. Every error path in ⇓_d yields (Deny, [], errors) (FailClosed-*), and the context is never partial when the policy engine runs — an incomplete context denies. (§6.2.)
  • Lowering totality of the syntactic map. Every non-extension surface form has exactly one translation (§4.3); the only non-structural step is hoisting, which is deterministic given the rule key and ordinal. (§4.4.)
  • Validation soundness of the proof object. validate presupposes a Lowered; parse/macro/lower/schema failures are a separate fatal channel and can never appear as findings. Σ ⊢ L ✓ concerns type/dialect findings only. (§5.1.)
  • Determinism of identity. A rule’s PolicyID and its hoisted field names are a pure function of the rule key key(δ, i) and the per-rule ordinal — so two lowerings with distinct distinguishers never collide, and the same input with the same distincter is byte-identical. (§4.1, §4.4.)
  • Statefulness is confined to the temporal engine. The policy engine is invoked per-request over an empty entity set; all history-dependence flows through observe/evaluate and the hoisted context.<id> booleans. History-only events matter precisely because they mutate that state. (§6.1, §6.5.)
  • Acceptance ⇒ monitorability. A temporal condition that passes §5.4 denotes a finite, computable relation over the history (safe-range): the leaf is closed (every variable exists- or for-bound), every bound variable — existential and aggregation for — is range-restricted by a positive atom (a negation restricts nothing, at any depth; a since restricts only through its anchor), filters follow their restrictors, and every monitoring scope varies with the timepoint.

Calling Macros

Macros let you name and reuse a fragment of policy logic — a Cedar sub-expression or a temporal pattern — so a threshold or a “happened recently” check lives in one place and reads well at every use. This page covers calling a macro: where a call may appear and what shape its arguments take. It assumes the macros already exist (declared in your policy file or supplied by a macro library). Defining macros — the def cedar / def temporal syntax, the ?p / $t sigils, hygiene, the rejection rules, and the macro library — is the subject of Macros.

The complete policies shown on this page are runnable example bundles under examples/.

A macro call looks like an ordinary function call:

name(arg, arg, …)

A zero-argument macro is just name(). What differs from an ordinary call is where a call is allowed and what each argument may be — both determined by the macro’s kind.

Where each kind is callable

A macro has a kind, fixed by its definition, and each kind may be called only in a matching position:

Macro kindCallable in
def cedara Cedar-expression position (when { … } / unless { … }, or mid-expression)
temporal conditiona temporal condition slot (when temporal { … }, a && operand, a formerly body, …)
temporal aggregationan aggregation-value position (an operand of a comparison)

A mismatch is a hard error, never a silent coercion — calling a temporal macro in a Cedar position (or vice versa) is rejected with a message naming the macro and the two kinds. The full set of checks (arity, kind, and argument shape) is documented in Macros.

Calling a Cedar macro

A def cedar macro is called wherever you would write the expression it names. Given is_small and is_eligible / is_not_blocked in scope, they slot into an ordinary when exactly like plain Cedar, and compose with && / || / !:

permit(principal, action, resource)
when { is_small(context.input.shares) };

Runnable: examples/call_cedar_macro_is_small/dogwood validate.

permit(principal, action, resource)
when {
    is_eligible(context.input.shares, context.input.stock)
    && is_not_blocked(context.input.stock)
};

Runnable: examples/call_cedar_macros_composed/dogwood validate.

Because a Cedar macro expands before the surrounding expression is lowered, one may be conjoined with a temporal { … } block mid-expression:

permit(principal, action, resource)
when { level_ok(context.input.level) && temporal { /* … */ } };

Runnable: examples/call_cedar_macro_with_temporal_leaf/dogwood validate and dogwood replay (the bundle fills the /* … */ leaf with a recent-Login check).

A macro call may also appear as an argument to another macro call — the arguments are expanded first, then spliced in — so a record-building macro can feed a comparing one:

permit(principal, action, resource)
when { semverGT(semver(2, 1, 1), semver(2, 1, 0)) };

Runnable: examples/call_cedar_macro_as_argument/dogwood validate.

(Nesting a call inside a macro’s declared body is a different thing and is not allowed; see Macros.)

Calling a temporal macro

A def temporal macro is called inside a when temporal { … } (or unless temporal { … }) block. A condition-flavoured macro is called wherever a temporal condition is expected. Here once wraps a window and a predicate:

permit(principal, action == Drupe::Action::"Write", resource)
when temporal {
    once(1h, Drupe::Action::"Read"::response{
        input.user: context.input.user,
        input.document: context.input.document
    })
};

Runnable: examples/call_temporal_condition_macro_once/dogwood validate and dogwood replay.

Condition macros compose with && just like the built-in operators:

permit(principal, action == Drupe::Action::"Write", resource)
when temporal {
    recently_logged_in(context.input.user)
    && recently_read(context.input.user, context.input.document)
};

Runnable: examples/call_temporal_condition_macros_composed/dogwood validate and dogwood replay.

An aggregation-flavoured macro produces a count or sum, so it is spliced into a comparison — always inside an exists binder, which introduces the variable the aggregate is compared against — never called on its own:

permit(principal, action == Drupe::Action::"Alert", resource)
when temporal {
    exists (n: Long). (
        (count_formerly(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        })) == n
        && n > 0
    )
};

Runnable: examples/call_temporal_aggregation_macro_count/dogwood validate and dogwood replay.

Argument-nesting works for temporal macros too: because an aggregation macro expands to a value (a count/sum term), a call to one may be passed as the argument to another temporal macro whose parameter sits in a comparison-operand position — the argument is expanded first, then spliced into that operand slot. Given count_within (an aggregation macro) and bind (which names the exists … == … binding scaffold):

def temporal count_within(?w, ?s) {
    count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
};
def temporal bind(?n, ?A, ?B) { exists (?n: Long). (?A == ?n && ?B) };

permit(principal, action == Drupe::Action::"Alert", resource)
when temporal {
    bind(n, count_within(1h, Drupe::Action::"Login"::request{
        input.user: _, input.server: context.input.server
    }), n > 2)
};

The nested count_within(...) fills bind’s ?A parameter, which the body uses as the left side of ?A == ?n — a comparison operand, exactly where an aggregate is legal. This is verdict-equivalent to writing the exists … == n form by hand. (A condition-flavoured macro call cannot be nested this way — it does not produce a comparison operand — and is rejected with a shape mismatch.)

Window arguments are bare interval literals

When a macro takes a window parameter (the within ?w slot in its body), the call-site argument is a bare interval literal1h, 30m, 24h — with no within keyword. The within keyword stays with the temporal operator in the macro’s body; the call supplies only the interval. In the once call above, 1h fills the window and the predicate fills the condition parameter.

Binder arguments are bare identifiers

When a macro parameter is used in a binder position (for example the bound variable of a sum), the call-site argument for it must be a single bare identifier — that identifier becomes the bound-variable name. Passing a literal or a compound expression there is a hard error. This is the one case where an argument must be a plain name rather than a value; see Macros for why.

What is checked at the call site

Every call is validated three ways — the details (and their exact error messages) live in Macros:

  • Kind — the call position must match the macro’s kind (the table above).
  • Arity — the number of arguments must equal the number of declared parameters.
  • Argument shape — each argument must match how its parameter is used in the body: a whole-condition parameter takes a temporal condition, a window parameter takes a bare interval literal, a term parameter takes an expression, and a binder-position parameter takes a bare identifier.

Calling a name that is neither a declared macro nor a Cedar built-in is rejected as an unknown call.

See also

  • Macros — defining macros (def cedar / def temporal), the ?p / $t sigils, hygiene, the rejection rules, and the macro library.
  • The policy language — the Cedar expressions a def cedar call expands into and the when / unless clauses that host a call.
  • Temporal expressions — the temporal sublanguage a def temporal call expands into, and the when temporal { … } block a temporal call lives in.
  • Information providers — the other reusable-logic feature; providers are ordinary Cedar calls, not macros.

The Provider Schema

This page is the advanced deep dive on defining an information provider — the providers.json declaration format and the Rhai implementation contract a service author writes to make a provider available to policies. Calling a provider from a policy (the invocation syntax, arguments, projection, and comparison) is covered in Information providers; this page is the other half: what you write so that such a call resolves. The corpus cases named below are directories under dogwood-language/tests/passing/provider_only/corpus/.

A note on naming: an information provider is invoked as an ordinary Cedar call (Provider::Name(args)…), optionally inside a guardrails { … } clause (which is just sugar for a bare when). Throughout the docs we call these things providers when we mean the declared functions and speak of guardrails clauses when we mean the sugar. The internal name “provider” is what the declarations and context.providers use; there is no dedicated provider grammar.


The providers.json declaration format

Providers are declared in a JSON document — a provider declarations file, conventionally providers.json. It is optional and defaults to empty. Making providers a file — rather than baked-in Rust — is what lets a deployment add a provider by editing configuration, with no code change. The top level is a single object, availableProviders, whose keys are the fully-qualified provider names (Ns::Fn) — the same names you invoke in policies — and whose values describe each provider’s argument types (argumentTypes), output type (outputType), and implementation (implementation).

The declaration of a single provider

Each provider declaration has three parts:

  • argumentTypes — an ordered list of ParamType entries, one per positional argument. May be empty.
  • outputType — a single ParamType describing the record (or scalar) the provider returns. This field is required.
  • implementation — optional. If omitted, the declaration is interface-only (it describes the shape but cannot be evaluated). If present, it currently must be a Rhai implementation.

paramType variants and their Cedar types

A ParamType always has a paramType string, and — depending on its kind — a fields map (for records), an items type (for sets), and a required list (for records). The paramType values and how they map to Cedar types:

paramTypeCedar typeNotes
stringString
integer / longLongsynonyms
bool / booleanBoolsynonyms
decimaldecimalCedar decimal extension
setSet<inner>inner comes from items, defaults to String
record{ f?: T, … }each field required-or-optional per required
any other (unknown)Stringpermissive fallback

For a record, each field is rendered as name: T if name is listed in required, otherwise name?: T (optional). Records and sets nest.

Choosing the implementation: inline script vs scriptFile

The implementation object is tagged by kind. The only kind today is "rhai", and it can carry the script two ways:

  • script — the Rhai source inline as a JSON string.
  • scriptFile — a path to an external .rhai file, resolved relative to the providers.json file’s own directory.

You use exactly one of these. The inline form (the same Http::Fetch provider used in The net feature and http_get below) looks like:

{
  "availableProviders": {
    "Http::Fetch": {
      "argumentTypes": [
        { "paramType": "string" },
        { "paramType": "string" }
      ],
      "outputType": {
        "paramType": "record",
        "fields": { "body": { "paramType": "string" } },
        "required": ["body"]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(base, key) { if regex_is_match(\"^[a-z0-9_-]+(\\\\.[a-z0-9_-]+)*$\", key) { #{ body: http_get(base + \"/lookup/\" + key) } } else { #{ body: \"\" } } }"
      }
    }
  }
}

The scriptFile form appears in the Content::Filter declaration below (which points "scriptFile": "filter.rhai" at an external script), and in the Strings::Matches provider whose declaration references an external matches.rhai.

from_json vs from_json_file

How you load providers.json determines whether a scriptFile reference gets resolved. (These are API entry points; see The API and workflow for the full loading surface.)

  • from_json(text) parses JSON text only. A scriptFile reference is left unresolved — the inline script stays None.
  • from_json_file(path) reads the file and then, for every Rhai implementation that has a scriptFile but no inline script, reads that file (relative to the declarations file’s directory) and folds its contents into the script. After loading this way, the resolved script is available regardless of which form was used.

This matters at authorize time: if a provider declared with scriptFile was loaded via from_json instead of from_json_file, evaluating it fails with an error telling you the scriptFile reference was never resolved and to load with from_json_file. Rule of thumb: when your declarations reference external .rhai files, load with from_json_file.

More declaration shapes

The corpus exercises the full range of output types. A few representative shapes:

  • Integer output (Strings::Length, case 0002_length_threshold): argumentTypes: [{string}], outputType a record with length: integer, required.
  • Decimal output (Content::Risk, case 0003_decimal_score_method): outputType a record with severityScore: decimal, required.
  • Multi-field record output (Regex::Analyze, case 0005_regex_operations): a record with is_match: bool, first_match: string, count: integer, all required.
  • Two providers in one file (case 0004_two_providers_and_not: Strings::Matches + Lists::Blocked; case 0007_boolean_or_parens: Lists::Allowed + Strings::Length) — just add more keys under availableProviders.

The most elaborate is Content::Filter (cases 0006_set_arg_index_projection / 0009_unwrapped_no_marker), which takes a string and a set of strings, and returns a nested record whose keys are content categories:

{
  "availableProviders": {
    "Content::Filter": {
      "argumentTypes": [
        { "paramType": "string" },
        { "paramType": "set", "items": { "paramType": "string" } }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "VIOLENCE": {
            "paramType": "record",
            "fields": { "severityScore": { "paramType": "decimal" } },
            "required": ["severityScore"]
          },
          "HATE": {
            "paramType": "record",
            "fields": { "severityScore": { "paramType": "decimal" } },
            "required": ["severityScore"]
          }
        },
        "required": ["VIOLENCE", "HATE"]
      },
      "implementation": { "kind": "rhai", "scriptFile": "filter.rhai" }
    }
  }
}

How invocations are validated against declarations

When Dogwood validates a policy against its declarations, it checks each provider invocation:

  1. The invocation’s name (Ns::Fn) must be present in availableProviders, or you get “provider is not present in the provider declarations.”
  2. The argument count must equal the declared argumentTypes length, or you get “expects N argument(s) but got M.”
  3. Each directly-typed argument’s kind must match the declared paramType: Stringstring; Integerinteger/long; Decimaldecimal; Boolbool/boolean; Setset. A mismatch reports the actual-vs-declared types.
  4. Each output method in a chain is checked: it must be declared in the provider’s availableMethods, must not shadow a Cedar extension-method name, must be given the declared argument count/types, and — when it declares an inputType — must be fed a compatible value by the preceding pipeline stage.

Field-path arguments (context.input.x, principal.id, …) are not type-checked here — they are deferred to Cedar/temporal schema validation, since their type comes from the schema. Likewise, the output projection and comparison are not re-checked in this pass: they were lowered to native Cedar, so Cedar’s own schema validator checks them against the synthesized context.providers type.


The Rhai implementation contract

A provider’s implementation is a script in Rhai, evaluated in a tightly sandboxed engine. The contract is simple.

The evaluate function

The script must define a function fn evaluate(arg0, arg1, …) { … } whose parameters correspond positionally to the declared argumentTypes. It returns a value matching the outputType — typically a Rhai object map (#{ … }), which Dogwood converts into a Cedar record.

The simplest possible script — length.rhai (case 0002_length_threshold). Note that even the simplest script carries a unit-argument guard; that guard is part of what “simplest” means here, not an optional refinement:

fn evaluate(text) {
    // Defensive per the provider contract: a provider may be evaluated
    // for ANY decision event, so any argument may be absent (unit).
    // Return a conforming sentinel instead of erroring (errors are UB).
    if type_of(text) == "()" {
        return #{ length: -1 };
    }

    #{ length: text.len() }
}

Defensive scripts

Provider execution is unconditional (see the provider contract): your script runs for every decision event, including events of actions whose context has none of the fields your arguments read. On such events the argument arrives as the Rhai unit value (). Every script must therefore:

  • detect absent arguments — type_of(x) == "()" — and
  • return a sentinel conforming to the declared outputType instead of letting an operation on () throw. An erroring provider is undefined behavior for the decision outcome.

Choose the sentinel deliberately and mind the polarity: a sentinel that makes a guard false is the restrictive direction under permit but the permissive direction under forbid. Every corpus script carries this guard; the examples below elide it for brevity with a comment marking where it belongs.

Note that parameter order follows the declared argumentTypes, not any host function’s order. In matches.rhai the parameters are (text, pattern) — matching the declared arguments (arg0 is the context.input.document field, arg1 is the literal pattern) — even though the host function regex_is_match takes its arguments as (pattern, text):

fn evaluate(text, pattern) {
    // … unit-argument guard elided — see "Defensive scripts" above …
    #{ matched: regex_is_match(pattern, text) }
}

The sandbox

Providers run in the request path and may be re-evaluated on replay, so they must be deterministic and side-effect-free. Dogwood enforces this with a locked-down, shared, immutable engine (built once and reused). The key constraints:

  • The engine is built from a raw Rhai engine, and only the pure subset of Rhai’s standard library is registered — core operations, logic, basic math, arrays, maps, bit fields, and more-string functions.
  • The time package is deliberately excluded, so clock-reading functions like timestamp() are not reachable. This keeps the engine deterministic.
  • Operation and call-depth caps guard against runaway loops and deep recursion (one million operations; 64 call levels).
  • Module loading is disabled — a script cannot pull in other code or files.
  • There is no ambient file, network, or process access. A bare Rhai engine cannot do I/O at all; the only capabilities a script has are the host functions Dogwood explicitly registers.

Pure facilities — arithmetic, strings, arrays, maps, for iteration, len(), and so on — are all available.

Host functions

Three regex host functions are always registered (they are pure — no I/O):

  • regex_is_match(pattern, text) -> bool — does pattern match text? Returns false on an invalid pattern.
  • regex_find(pattern, text) -> string — the first match, or "" if none (or if the pattern is invalid).
  • regex_count(pattern, text) -> i64 — the number of non-overlapping matches (0 on an invalid pattern).

All three appear together in analyze.rhai (case 0005_regex_operations):

fn evaluate(text, pattern) {
    // … unit-argument guard elided — see "Defensive scripts" above …
    #{
        is_match:    regex_is_match(pattern, text),
        first_match: regex_find(pattern, text),
        count:       regex_count(pattern, text),
    }
}

A provider does not have to call any host function at all — pure Rhai is often enough. The denylist in blocked.rhai (case 0004_two_providers_and_not) uses only a built-in contains:

fn evaluate(text) {
    // … unit-argument guard elided — see "Defensive scripts" above …
    let denylist = ["evil", "badword"];
    #{ blocked: denylist.contains(text) }
}

Decimal support

The engine is built with Rhai’s decimal feature, so a script can call parse_decimal("0.10") to produce a decimal, which Dogwood converts to a Cedar decimal. This is what lets a provider return a severityScore that the policy then compares with .lessThan(decimal("0.5")).

The risk-score provider — risk.rhai (case 0003_decimal_score_method):

fn evaluate(text) {
    // … unit-argument guard elided — see "Defensive scripts" above …
    let score = if text == "safe" {
        parse_decimal("0.10")
    } else if text == "spam" {
        parse_decimal("0.80")
    } else {
        parse_decimal("0.50")
    };
    #{ severityScore: score }
}

Scripts can also build nested records dynamically. filter.rhai (cases 0006_set_arg_index_projection / 0009_unwrapped_no_marker) loops over the requested categories and builds a record keyed by category name:

fn score_for(text, category) {
    if text == "violent" && category == "VIOLENCE" {
        parse_decimal("0.90")
    } else if text == "hateful" && category == "HATE" {
        parse_decimal("0.90")
    } else {
        parse_decimal("0.10")
    }
}

fn evaluate(text, categories) {
    // … unit-argument guard elided — see "Defensive scripts" above …
    let out = #{};
    for category in categories {
        out[category] = #{ severityScore: score_for(text, category) };
    }
    out
}

The net feature and http_get

Everything above keeps providers deterministic. Network access breaks that — so it is gated behind an off-by-default net feature. When Dogwood is built with net, one additional host function is registered:

  • http_get(url) -> string — a blocking HTTP GET that returns the response body on a 2xx, else "". It is deliberately minimal: http://host[:port]/path only, with no TLS, no redirects, no chunked decoding, short (5-second) timeouts, and a 1 MiB response cap, on a plain blocking socket.

This is the one thing that makes the engine non-deterministic, which is exactly why it is opt-in. In spirit it is like OPA/Rego’s http.send. Use it with care: the same policy can reach different decisions if the network response changes.

Security — SSRF: never build the URL from an untrusted event field. http_get performs no host validation: it connects to whatever host the URL names, including internal/link-local addresses (169.254.169.254, 127.0.0.1, RFC-1918). The naive script fn evaluate(url) { #{ body: http_get(url) } }, invoked as WebGet(context.input.url), hands the whole URL to the request — so a caller can steer the fetch to any endpoint the Dogwood process can reach (a server-side request forgery). The safe pattern: keep the base URL a fixed, deployer-owned literal, and let a request field fill only a validated, non-authority path segment.

The net example follows that pattern. It declares Http::Fetch(base, key) -> { body: String } whose script validates the request-supplied key against a strict allowlist before interpolating it into a fixed path, and only then fetches:

fn evaluate(base, key) {
    // Dot-separated allowlisted tokens: ordinary keys like `page.html` are fine,
    // but `/`, `@`, `:`, whitespace, CRLF — and `..` — are rejected, so `key`
    // cannot change the authority or traverse out of the `/lookup/` segment.
    if regex_is_match("^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$", key) {
        #{ body: http_get(base + "/lookup/" + key) }
    } else {
        #{ body: "" }        // reject: fail safe, never fetch
    }
}

The policy passes a literal base URL (deployer-owned) and only a validated key from the request — never a URL:

when {
    Http::Fetch("http://example.com", context.input.key).body != "BLOCKED"
};

Its test starts a loopback mock server (the base literal is the mock’s runtime origin): the key allowed returns body "OK" (permit), the key blocked returns "BLOCKED" (deny), and a companion test proves that authority-hijacking keys (allowed@attacker.com, 127.0.0.1:9999, allowed/../secret, CRLF payloads) fail the allowlist and never reach the network.

Value conversion at the boundary

When Dogwood calls evaluate, it converts each resolved argument value into a Rhai value, and converts the returned value back to a Dogwood value. The mapping is straightforward: null ↔ unit, bool ↔ bool, integers ↔ int, decimals ↔ decimal (Cedar decimal text semantics), strings ↔ string, arrays ↔ arrays, and object maps ↔ records (recursively). A returned value that is none of these — a function pointer, say — produces the error “script returned an unsupported value type.” In practice, return an object map (#{ … }) whose fields match your declared outputType.

If a provider has no implementation, evaluating it errors (“has no implementation; cannot evaluate it at authorize time”). Compile errors in the script surface as “script compile error,” and errors thrown while calling evaluate surface as “script error calling evaluate.”


How a provider binds to context.providers.<id>

Putting the pieces together, here is the full life cycle of a provider invocation.

At lowering time, every provider invocation is hoisted. A generated field name (p_0, p_1, …) is assigned per invocation, and the call leaf is replaced with context.providers.<field>. The surrounding projection and comparison were already ordinary Cedar, so they lower natively — an index ["k"] becomes .k, and the comparison stays as whatever Cedar op you wrote.

Dogwood also augments the Cedar schema. EVERY action’s context record gains a required providers record (matching the unconditional evaluation below — the declared schema and the runtime context always agree), and each hoisted field is typed from its provider’s declared outputType. (The base schema.cedarschema files in the corpus are the un-augmented schemas; the synthetic context.providers record is added during compilation. The base action schema is described in The policy language and the event schema in The event schema.)

At authorize time, for each decision event Dogwood builds the Cedar request context. It passes context.input through from the event, evaluates every declared provider field (resolving each argument, then running the Rhai evaluate), and collects the outputs into a single context.providers object keyed by field id. So context.providers.p_N holds that provider’s evaluated output record, and Cedar evaluates the (already-lowered) comparison against it.

The net effect: you write the surface form Ns::Fn(args).field <cmp> literal, and the engine evaluates context.providers.<id>.field <cmp> literal against the bound output. For case 0001_regex_matches_uppercase, the engine runs matches.rhai, binds { matched: … }, and Cedar evaluates .matched == true — giving "ABC" → true, "abc" → false, "AB12" → false. For case 0006_set_arg_index_projection, document="violent" scores VIOLENCE at 0.90 so .lessThan(0.5) is false (deny), document="safe" scores 0.10 so it is true (permit), and document="hateful" scores VIOLENCE at 0.10 (only HATE is 0.90) so it is also true (permit).


Advanced and undocumented features

Everything above is the supported way to use providers: call them from an ordinary when { … } clause, backed by a Rhai implementation. The two features below exist and work, but most policies do not need them — treat them as advanced.

The guardrails { … } clause

Besides calling a provider inside an ordinary when, Dogwood also accepts a when guardrails { … } clause:

permit ( principal, action == Doc::Action::"read", resource )
when guardrails {
    Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
    && !(Lists::Blocked(context.input.document).blocked == true)
};

guardrails { E } is transparent sugar for a bare when { E }: its body is a full Cedar expression, parsed and lowered identically to an ordinary when clause. The tag adds nothing — it is retained only for surface compatibility with existing policies. Because the body is plain Cedar, everything an ordinary when can do works here too: arithmetic on a provider’s output, mixing provider calls with plain context conditions, if/then/else, and so on.

This guide leads with the ordinary when form because it is simpler to teach and there is no reason to prefer the tag. The two forms are exactly equivalent; use whichever reads better (the tag can document intent — “this clause gates on a provider” — but carries no semantics).

Methods on a provider’s output

Beyond projecting into a provider’s output record (.field / ["key"]), you can call a method on it: Provider::Fn(args).method(margs). A method post-processes the provider’s output — the value used in the comparison is method(output, margs…) — and methods chain left to right (.m1().m2() = m2(m1(output))), each producing the input to the next.

A method is declared on the provider, alongside its output type, in an availableMethods map — each method with its own argumentTypes and outputType (exactly mirroring the base invocation):

"BedrockGuardrails::ContentFilter": {
  "argumentTypes": [ { "paramType": "string" } ],
  "outputType": { "paramType": "record", "fields": { "severityScore": { "paramType": "decimal" } } },
  "availableMethods": {
    "maxConfidenceScore": { "argumentTypes": [], "outputType": { "paramType": "decimal" } },
    "scoreAbove":         { "argumentTypes": [ { "paramType": "decimal" } ], "outputType": { "paramType": "bool" } }
  },
  "implementation": { "kind": "rhai", "scriptFile": "content_filter.rhai" }
}

and implemented as a fn <name>(input, args…) in the provider’s single Rhai script — its first parameter is the previous stage’s value (the base output for the first method), the rest are the method’s own arguments:

// content_filter.rhai
fn evaluate(text) { /* … returns the base record … */ }
fn maxConfidenceScore(output)          { /* reduce over output → decimal */ }
fn scoreAbove(output, threshold)       { output.maxConfidenceScore() > threshold }

A zero-argument method is the common accessor case (maxConfidenceScore()); a method with arguments (scoreAbove(decimal("0.72"))) is the generalization. In a policy:

permit ( principal, action == Drupe::Action::"InvokeAgent", resource )
when guardrails {
    BedrockGuardrails::ContentFilter(context.input.prompt).maxConfidenceScore() < 50
};

Notes and constraints:

  • Parens distinguish a method from a field. .maxConfidenceScore() (with parens) is a method call; .severityScore (no parens) is a field projection. A method’s name may not shadow a Cedar extension method (lessThan, contains, isEmpty, …) — those are reserved for the comparison / native forms.
  • Method arguments use the same forms as invocation arguments — attribute paths rooted at context / principal / resource, string / integer / decimal / bool literals, and sets — and are resolved against the request exactly like the base arguments.
  • Eager vs. Cedar. A method runs in Rhai at authorize time (it is not native Cedar), so the value bound into context.providers.<id> is the pipeline’s result after the last method; any field projection after the last method (e.g. .classify()["VIOLENCE"].score) is then plain Cedar over that value. A projection may not appear before a method in the same chain.
  • Failure is fail-closed. If a method’s Rhai body errors, the decision denies with the error in the response diagnostics, like any provider failure.

Providers with no implementation — plugging in your own code

A provider declaration’s implementation field is optional. If you omit it, the declaration is interface-only: it still names the provider, declares its argumentTypes and outputType, and type-checks and lowers exactly like any other provider — but Dogwood’s built-in Rhai evaluator has nothing to run.

{
  "availableProviders": {
    "Risk::Score": {
      "argumentTypes": [ { "paramType": "string" } ],
      "outputType": {
        "paramType": "record",
        "fields": { "severityScore": { "paramType": "decimal" } },
        "required": ["severityScore"]
      }
    }
  }
}

This is the hook for supplying the value from your own code instead of a Rhai script. The provider’s output is nothing more than a record bound into context.providers.<id> at authorize time; a declaration with no implementation declares the contract for that record and leaves the production of it to you. If you evaluate such a provider through the built-in path, you get a clear error — provider ... has no implementation; cannot evaluate it at authorize time — which is the signal that this provider is meant to be satisfied by a caller-supplied computation rather than the sandboxed Rhai engine.

Use this when the value comes from somewhere the sandbox deliberately cannot reach — a service call, a model, a database lookup — and you want to keep that computation in your own (unsandboxed, non-deterministic-allowed) code while still declaring the provider’s shape so policies can be written and validated against it.


See also

  • Information providers — calling providers from a policy: the invocation syntax, arguments, projection, and comparison.
  • The policy language — the base action schema and the Cedar condition language a provider call lives in.
  • The event schema — how the base event schema is written before Dogwood augments the context with the synthetic context.providers record.
  • The API and workflow — loading declarations (from_json vs from_json_file) and the authorize-time flow that evaluates providers.
  • Macros — the other way to reuse logic across policies.

Generating the Action Schema from an MCP Manifest

This is the Advanced-topics page on schema authoring via MCP: a Dogwood action schema is, conceptually, a Model Context Protocol (MCP) tool manifest, and Dogwood can generate the Cedar .cedarschema for you from one. The hand-written counterpart of what this generation produces — declaring the entity and action types and laying out the context.input / context.output records yourself — is covered in The policy language. The Rust API for MCP generation (the from_mcp_manifest / mcp_to_cedar_schema constructors) lives in The API and workflow; this page covers the manifest format, the JSON→Cedar type mapping, and the Drupe template.

So you often will not hand-write a .cedarschema. Because the frontend works in Cedar, Dogwood converts the manifest — a list of tools with their input and output JSON schemas — into a Cedar .cedarschema for you, layering the tools on top of the Drupe template.

From the command line, dogwood schema mcp --manifest tools.json generates the schema (write it out with -o schema.cedarschema); see The command line. The Rust equivalents are the from_mcp_manifest / mcp_to_cedar_schema constructors in The API and workflow.

The policy example on this page is a runnable bundle under examples/.

The manifest

A manifest is a JSON array of MCP tool descriptions (or an MCP tools/list payload). Each tool has a name, description, and inputSchema / outputSchema:

{
  "name": "SellShares",
  "description": "Sell `shares` shares of `stock`. Returns the proceeds in USD.",
  "inputSchema":  { "type": "object",
    "properties": { "stock": {"type":"string"}, "shares": {"type":"integer"} },
    "required": ["stock", "shares"] },
  "outputSchema": { "type": "object",
    "properties": { "proceeds": {"type":"number","format":"decimal"} },
    "required": ["proceeds"] }
}

A tool’s inputSchema.properties becomes the Cedar context.input record and outputSchema becomes context.output. JSON types map to Cedar types as follows: integerLong, stringString, booleanBool, and number with format: decimaldecimal.

How generation works

mcp_to_cedar_schema(manifest_json) produces the .cedarschema string using the embedded Drupe template; mcp_to_cedar_schema_with_template(manifest_json, template) uses a template you supply. Internally the generator:

  1. parses the template .cedarschema,
  2. parses the manifest to a server description,
  3. seeds a schema generator with the template and the default config,
  4. layers one Cedar action per MCP tool, deriving input/output context records from each tool’s JSON schema,
  5. serializes the result back to .cedarschema text.

The default config include_outputs(true) (emit context.output from each tool’s outputSchema), encode_numbers_as_decimal(true) (JSON number → Cedar decimal), and flatten_namespaces(true) (so a tool SellShares becomes Drupe::Action::"SellShares" rather than a deeply qualified name).

The Drupe template

The template supplies the principals, resource, base context, and base action hierarchy that tool actions are layered onto. Its key parts:

namespace Drupe {
  @mcp_principal("User")                entity OAuthUser { id: String } tags String;
  @mcp_principal("IamEntity")           entity IamEntity { id: String };
  @mcp_principal("UnauthenticatedUser") entity UnauthenticatedUser;
  @mcp_resource("Gateway")              entity Gateway;
  @mcp_context("system")                type SystemContext = { now: datetime };

  // guardrail leaf types
  type ContentFilterFinding = { severityScore: decimal };
  type PromptAttackFinding  = { severityScore: decimal };
  type SensitiveInfoFinding = { confidenceScore: decimal };

  action Mcp  appliesTo { principal: [/* 3 */], resource: [Gateway], context: { system: SystemContext } };
  action Http appliesTo { /* … */ };
  @mcp_action("CallTools")
  action CallTool    in [Mcp]      appliesTo { /* … */ };
  action UnknownTool in [CallTool] appliesTo { /* … */ };
  action InvokeAgent in [Http]     appliesTo { /* …, */ input?: {} };
  action InvokeLLM   in [Http]     appliesTo { /* …, */ input?: {} };
}

The @mcp_principal / @mcp_resource / @mcp_context / @mcp_action annotations tell the generator which entities, types, and actions play each role. Generated tool actions are placed in [Action::"CallTool"] — matching the committed corpus schema at case 0407, where Login, Read, and Transfer are all in [Action::"CallTool"].

What you get

The generated .cedarschema combines the template (principals like OAuthUser, base actions) with one action per tool (SellShares, GetStockInfo, …). Because the tool’s stock argument lands at context.input.stock, a plain Cedar policy validates against it:

permit(principal, action == Drupe::Action::"GetStockInfo", resource)
when { context.input.stock == "AMZN" };

Runnable: examples/get_amzn_stock_info/dogwood validate and dogwood replay.

Feeding the generated schema through PolicySchema::from_cedarschema_str and pairing it with a default ServiceSchema (so request is a decision kind) lets the policy above lower and validate cleanly.

This is also the basis of Dogwood’s MCP-manifest workflow: point Dogwood at an MCP server’s tools/list, and it produces the action schema you would otherwise write by hand.


See also

  • The policy language — the hand-written counterpart: entity/action declarations and the context.input / context.output convention this generation produces.
  • The API and workflow — the Rust constructors (from_mcp_manifest / mcp_to_cedar_schema) that drive MCP generation.
  • The event schema — how event kinds and their fields derive from the action schema.

The command line

The dogwood CLI runs the whole Dogwood pipeline — parse, macro expansion, lowering, type-checking, and stateful replay — over plain files. It is the quickest way to check a policy or watch a temporal policy decide across an event trace, and it needs no Rust: everything is a file in, a verdict (or a diagnostic) out.

Every policy-level example in this guide is a complete, runnable bundle in this crate’s examples/ directory, and a test harness runs each one through this exact CLI on every build. So the commands below are not illustrative — they are what checks the documentation.

The two schema halves

A Dogwood schema comes in two parts, and the CLI mirrors that split:

  • The action schema — a Cedar .cedarschema declaring entities, actions, and each action’s context shape. Required wherever a schema is needed; passed with --policy-schema.
  • The service schema — the event schema, information-provider declarations, and macro library. All optional; each defaults sensibly. Supplied with --event-schema, --providers, and --macros when a policy needs a non-default one.

See The policy language for the action schema, and The event schema / The provider schema / Macros for the service-side pieces.

Commands

Every command takes the policy set as a positional argument (a .dw file, or - to read stdin) and --format human|json (human is the default; JSON is the stable machine shape). Exit codes are uniform: 0 success, 1 a usage or I/O error (a missing file, a bad flag), 2 rejected input (a policy, schema, or trace that does not check out). That 0 vs 2 split lets a CI job tell “the tool broke” from “the policy was rejected”.

validate

dogwood validate policy.dw --policy-schema schema.cedarschema \
    [--event-schema events.dwschema] [--providers providers.json] [--macros macros.dw]

Parses, lowers against the schema, and type-checks in one shot — the two error classes (syntax/macro/lowering failures, and schema-aware type errors) are both covered, so a clean validate means the policy is fully accepted. On success it prints OK: validation passed … and exits 0; on rejection it prints each finding as an underlined source snippet pointing at the offending .dw span and exits 2.

$ dogwood validate examples/write_after_read/policy.dw \
    --policy-schema examples/write_after_read/schema.cedarschema
OK: validation passed with no errors or warnings.

Add --format json for findings as structured data (each with a message and a byte-offset labels span) instead of rendered snippets — the form a CI job or an editor integration wants.

--providers and scriptFile: inline your Rhai for CLI use. A providers.json implementation carries its Rhai two ways: inline script (source as a JSON string) or scriptFile (a path to an external .rhai, resolved relative to the declarations file). The CLI reads --providers as text, so a scriptFile reference is never resolved: validate and lower still work (they don’t evaluate providers), but replay fails at every provider evaluation with “rhai implementation has no script.” When a bundle will be driven through the CLI, put the Rhai inline under implementation.script — every runnable bundle in examples/ does exactly this. (Library callers are unaffected: load with ProviderDeclarations::from_json_file, which folds scriptFile contents in. An implementation-less, interface-only declaration validates and lowers through the CLI too, but replay cannot evaluate it — its value comes from your code via a ProviderResolver.)

replay — watch a temporal policy decide

Validation proves a policy is legal; replay shows what it does. It feeds a whole event trace through a stateful authorizer, so temporal operators see the accumulated history, and prints one verdict per decision point:

dogwood replay policy.dw --policy-schema schema.cedarschema --trace trace.log
$ dogwood replay examples/write_after_read/policy.dw \
    --policy-schema examples/write_after_read/schema.cedarschema \
    --trace examples/write_after_read/trace.log
@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@5000 (time point 2): DENY

Each line is @<timestamp> (time point <index>): ALLOW|DENY, optionally followed by [rules: …] — the indices of the .dw rules that determined the decision. History-only events (a non-decision event kind) update the history but produce no line. This is the way to catch a policy that validates but does not mean what you intended — a mis-pinned “same user” correlation, or a window that is too narrow. --format json emits a structured verdict stream.

A trace is one event per line. The events in examples/write_after_read/trace.log look like:

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")

— a timestamp, optional request envelopes (scope(...), and here request_context(...) — the context the Cedar request is built from), and the fully-qualified action with its explicit ::request (or other) kind and its logged input record. See The event schema for the event model.

lower — see the generated Cedar

dogwood lower policy.dw --policy-schema schema.cedarschema --emit both

Lowers the .dw to Cedar and emits the artifacts: the Cedar policies, the augmented action schema (with the hoisted context.* fields Dogwood adds for temporal leaves and providers), or — with --emit cedar-json — the schema as Cedar JSON (suitable for any Cedar-based policy store). --emit is one of cedar-policies, cedar-schema, cedar-json, or both (the default). When the lowered Cedar is not self-contained — because temporal or provider fields were hoisted and need Dogwood at authorize time — lower says so on stderr.

check-parse — syntax only

dogwood check-parse policy.dw

Parses and macro-expands, reporting only syntax and macro errors — no schema required. Useful as a fast first pass, or when you do not yet have the action schema. It reports each policy’s temporal-leaf and provider-call counts, and flags any provider a policy calls that the service schema does not declare.

schema — check a schema part on its own

dogwood schema action    schema.cedarschema
dogwood schema event     events.dwschema
dogwood schema providers providers.json

Each checks one schema artifact in isolation — that it parses and is well-formed — so you can tell “my schema is broken” from “my policy is broken” before running validate. dogwood schema mcp --manifest tools.json generates a Cedar action schema from an MCP tool manifest (see MCP schema generation).

Pipelines and stdin

- reads stdin or writes stdout, so commands compose. To lower a policy and hand the Cedar straight to Cedar’s own tooling:

dogwood lower policy.dw --policy-schema schema.cedarschema --emit cedar-policies | cedar validate ...

When to reach for the library instead

The CLI covers checking and replaying policies over files. When you need to embed the engine — build events programmatically, feed them one at a time, read each Response, or replace the policy or temporal engine with your own — use the Rust API instead. See The API and workflow.

Examples

ExampleDescription
access_not_revoked_since_grantThe “open session” idiom: a negated left operand on since expresses “has
alert_exactly_three_transfersCounting over a *-wildcard field: permit an Alert only if exactly three
alert_heartbeat_and_login_rateA top-level && chain combining a formerly with an exists-guarded count
alert_login_and_big_transferNested exists with a value-threshold filter: permit an Alert when the
alert_login_current_tpThe canonical tp(t) count-over-timepoints idiom with no temporal wrapper
alert_login_in_last_hourCounting over history: a formerly within 1h body inside the aggregation
alert_pending_transfersAggregate-vs-aggregate comparison (parenthesized left, bare right): permit an
alert_same_principal_login_transferAn entity-typed exists binder that correlates on the request principal
alert_same_user_login_and_transferA shared exists variable joins two different predicates through a common
alert_some_loginexists is the sole quantifier: it asserts at least one satisfying
alert_total_transfer_over_200Aggregation example: permit an Alert only when the total transferred amount
allow_anythingAn unconstrained (bare) scope triple: all three slots (principal, action,
approve_has_output_guardThe has attribute-existence guard before reading an optional Bool output
call_cedar_macro_as_argumentA Cedar macro call passed as an argument to another macro call:
call_cedar_macro_is_smallA def cedar macro called in an ordinary when { … } position:
call_cedar_macro_with_temporal_leafA Cedar macro conjoined mid-expression with a temporal { … } leaf. Because a
call_cedar_macros_composedTwo Cedar macros composed with && in one when { … } clause:
call_temporal_aggregation_macro_countCalling a def temporal aggregation macro. count_formerly produces a
call_temporal_condition_macro_onceCalling a def temporal condition macro inside a when temporal { … }
call_temporal_condition_macros_composedTwo def temporal condition macros composed with && inside a single
cedar_eligible_not_blockedTwo def cedar macros of different argument types composed with && inside one
cedar_is_small_thresholdThe simplest def cedar macro: is_small(?n) names the < 100 threshold so
cedar_macro_plus_temporal_leafA def cedar boolean macro (level_ok) conjoined mid-expression with an
cedar_semver_gtThe RFC 0061 semver worked example for Cedar macros: a record-building macro
cedar_starts_with_f_likeA Cedar macro whose body is a like wildcard pattern: starts_with_f(?s)
cedar_within_cap_if_elseA def cedar macro whose body is an if/then/else expression, encoding a
cond_is_oauth_in_teamThe expression-level counterpart of the is / is-in scope constraint: an
deny_overrides_sell_not_amznA permit + forbid pair showing deny-overrides semantics: SellShares is
forbid_large_except_amznMixing when and unless on a forbid rule: block large SellShares
forbid_read_transfers_over_1000A forbid rule with a sum over a (value, timepoint) domain and a filtered
get_amzn_stock_infoA plain (non-temporal) permit showing that an MCP-manifest input field
heartbeat_scope_aliasformerly with scope-alias correlation. Permit an Alert only if a
login_attempt_custom_kindA custom, author-defined event kind. The per-case event schema
macro_library_once_is_smallDemonstrates the shareable macro library: the policy calls once (a
max_window_raisedRaising the temporal look-back cap. The event schema’s default cap on any
permit_read_anyoneThe simplest useful rule: permit the Read action for any principal on any
principal_is_oauthDemonstrates the principal is Type entity-type scope constraint: the policy
provider_allowed_or_shortDisjunction and parentheses over two information providers: permit Read when
provider_digitcount_forbidA provider gating a forbid rule, alongside a catch-all permit – showing
provider_digitcount_operator_geThe operator-form comparison example (>= on an integer provider output),
provider_filter_set_index_decimalThe guardrail flagship shape in one atom: a set argument
provider_int_arithmetic_trustedA provider’s integer output used inside arithmetic, mixed under && with a
provider_matches_and_not_blockedTwo information providers combined with the boolean spine (&& and !) inside
provider_principal_id_allowlistPermit Read only when the requesting principal is on the provider’s
provider_regex_analyze_fieldsSeveral calls to the same Regex::Analyze provider, each projecting a
provider_regex_matches_uppercaseThe canonical worked information-provider example: permit Read only when the
provider_risk_decimal_methodThe decimal-extension-method comparison form. The Content::Risk provider
read_after_loginThe history-dependent version of the getting-started tour (Step 4 — a decision that
read_after_login_successResponse predicate + output-field filter: permit a Read only if the **same
read_heartbeat_since_login_30ssince with a short (seconds) window: the anchor must be recent enough, or the
read_login_not_logoutThe accepted “A but not B” idiom with restrictor-first conjunct ordering.
read_prev_compute_open_sessionA top-level previous && (open-session since) chain: permit a Read only if the
read_prev_loginprevious within 1h: permit a Read only if the immediately preceding
read_prev_login_successprevious with a response predicate and an output-field filter.
read_since_loginPositive-left since within 1h: the left operand must have **held
sell_after_2024_datetimeA single datetime literal compared with an ordinary comparison operator
sell_after_approval_valid_tickerTwo Dogwood clause forms combined on a single rule:
sell_comparison_chainSeveral comparison operators chained in a single && conjunction on a Long.
sell_datetime_windowDatetime ordered comparison expressing a calendar-year time window: permit
sell_like_a_prefixThe like operator matches a string against a wildcard pattern, where *
sell_logical_groupingLogical connectives ||, &&, and ! with parenthesized grouping to override
sell_nested_if_thresholdNested if/then/else used as an operand rather than a top-level
sell_nonzero_proceeds_decimalDecimal supports equality only (== / !=); ordered comparison on
sell_not_blocked_stringString inequality (!=) — one of the two operators strings support (== and
sell_not_test_tickers_likeThe like string-pattern operator used under unless as a denylist idiom: a
sell_or_approve_action_inaction in [ ... ]: list-membership matching on the action. This rule permits
sell_shares_eq_scopeAn == scope constraint pinning the action to a specific entity reference: this
sell_shares_temporal_subexprA temporal { ... } marker used as a sub-expression inside a larger Cedar
sell_small_onlyThe canonical five-part rule shape: annotation + effect + parenthesized scope
sell_small_proceeds_decimal_methodDecimal .lessThan(...) method call — how decimals are ordered. Cedar decimals
sell_threshold_by_stockAn if / then / else used as the whole body of a when clause reads like a
sell_two_when_small_amznTwo when clauses stacked on one rule are implicitly conjoined: both must
sell_unless_hugeBasic unless { ... } clause: permit SellShares unless the order is
sell_when_under_100A basic when { ... } condition clause: a when body must evaluate true for
sell_when_unless_mixMixing when and unless clauses on a single permit rule. Because clauses
sell_zero_proceeds_if_hasThe if C has attr then ... else false idiom guards an optional output field.
simplest_permitThe simplest possible policy: a bare permit for GetStockInfo with no
submit_after_approval_injectionA def temporal macro (approved_recently) whose predicate-valued parameter
temporal_count_formerly_loginAn aggregation-flavoured temporal macro. count_formerly(?w, ?s) counts the
temporal_login_then_readTwo def temporal condition macros joined with && inside a single
temporal_once_read_recentA condition-flavoured temporal macro. `def temporal once(?w, ?s) { formerly
temporal_sum_formerly_transferA temporal sum aggregation macro defined inline. sum_formerly combines a
traders_is_in_group_scopeThe is Type in Group scope constraint: the principal slot both tests the
transfer_prev_nested_conjprevious’s body must be a single atom, so a conjunction has to be
write_after_readThe canonical history-dependent policy: permit SellShares only if the **same
write_after_read_formerlyThe flagship history-dependent policy in the guide’s literal wording: permit a

access_not_revoked_since_grant

The “open session” idiom: a negated left operand on since expresses “has not happened since.” Because ! binds tighter than since, !A since within W B negates only A, giving “no A has happened since B.” Here: permit an Access only if the user has not been Revoked on this resource since they were Granted it within the last hour (!Revoke since within 1h Grant, with both input.user and input.resource pinned).

The trace shows both outcomes:

  • @0Grant for doc1/alice (a history-only event here; no Access permit applies, so the decision is a deny).
  • @100alice accesses doc1, after the grant and with no intervening revoke → allow (the not-revoked-since-grant chain holds).
  • @200Revoke for doc1/alice (history-only; deny).
  • @300alice accesses doc1 again, but a Revoke now sits between the grant and this access → deny (the negated-left chain is broken).

Referenced by guide/04-temporal-expressions.md.

Policy

// The "open session" idiom: a negated LEFT operand expresses "has not happened
// since". Because `!` binds tighter than `since`, `!A since within W B`
// negates only A. Permit an Access only if the user has NOT been revoked on
// this resource since they were granted it within the last hour.
@id("access_not_revoked_since_grant")
permit (
    principal,
    action == Drupe::Action::"Access",
    resource
)
when temporal {
    !Drupe::Action::"Revoke"::request{ input.user: context.input.user, input.resource: context.input.resource }
    since within 1h
    Drupe::Action::"Grant"::request{ input.user: context.input.user, input.resource: context.input.resource }
};

Schema

namespace Drupe {
  type AccessInput = {
    resource: String,
    user: String
  };

  type AccessOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GrantInput = {
    resource: String,
    user: String
  };

  type GrantOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type RevokeInput = {
    resource: String,
    user: String
  };

  type RevokeOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Access" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AccessInput,
      output?: AccessOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Grant" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GrantInput,
      output?: GrantOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Revoke" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: RevokeInput,
      output?: RevokeOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { resource: "doc1", user: "alice" }) Drupe::Action::"Grant"::request(input: { resource: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { resource: "doc1", user: "alice" }) Drupe::Action::"Access"::request(input: { resource: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { resource: "doc1", user: "alice" }) Drupe::Action::"Revoke"::request(input: { resource: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@300 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { resource: "doc1", user: "alice" }) Drupe::Action::"Access"::request(input: { resource: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY
@300 (time point 3): DENY

alert_exactly_three_transfers

Counting over a *-wildcard field: permit an Alert only if exactly three Transfers — regardless of amount — occurred within the last hour. The input.amount: * wildcard matches any transfer and binds nothing; tp(t) keeps the rows one-per-timepoint so count tallies distinct past timepoints.

The trace demonstrates both verdicts, including the window boundary:

  • @200 — only two Transfers so far (w1@0, w2@100) → deny (count = 2).
  • @400 — three Transfers in window (w1, w2, w3@300) → allow (count = 3).
  • @600 — a fourth Transfer (w4@500) pushes the count to 4 → deny.
  • @3700 — one hour later, w1@0 has aged out of the 1h window but w2@100, w3@300, and w4@500 remain → count back to 3 → allow (the window-boundary demo).

Referenced by guide/04-temporal-expressions.md.

Policy

// Counting over a `*`-wildcard field (matches anything, binds nothing).
// Permit an Alert only if EXACTLY three Transfers, regardless of amount,
// occurred within the last hour.
@id("alert_exactly_three_transfers")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    (count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::request{ input.amount: * } && tp(t))
    )) == 3
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 5, user: "alice" }) Drupe::Action::"Transfer"::request(input: { amount: 5, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "w1")
@100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 42, user: "bob" }) Drupe::Action::"Transfer"::request(input: { amount: 42, user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "w2")
@200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "a1")
@300 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 9, user: "carol" }) Drupe::Action::"Transfer"::request(input: { amount: 9, user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "w3")
@400 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "a2")
@500 scope(principal: Drupe::OAuthUser::"dave", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 1, user: "dave" }) Drupe::Action::"Transfer"::request(input: { amount: 1, user: "dave" }, callerPrincipal: Drupe::OAuthUser::"dave", callerResource: Drupe::Gateway::"gw1", requestId: "w4")
@600 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s2" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s2" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "a3")
@3700 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "a4")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): DENY
@200 (time point 2): DENY
@300 (time point 3): DENY
@400 (time point 4): ALLOW  [rules: 0]
@500 (time point 5): DENY
@600 (time point 6): DENY
@3700 (time point 7): ALLOW  [rules: 0]

Event Schema

// Unpinned event schema.
//
// Same structure as the pinned default but WITHOUT pins. Temporal
// predicates use global-trace semantics: a policy can match events
// from any principal unless it explicitly constrains callerPrincipal.
//
// Use this to see the behavioral difference vs the pinned default:
//   dogwood replay --event-schema configuration/event-schemas/unpinned.dwschema ...

decision event <A>::request {
    ...inputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::response {
    ...inputs(A),
    ...outputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::error {
    ...inputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

alert_heartbeat_and_login_rate

A top-level && chain combining a formerly with an exists-guarded count (the login-rate threshold). Permit an Alert only if a Heartbeat for this server fired within the last hour and more than two Logins to this server occurred.

when temporal {
    formerly within 1h Drupe::Action::"Heartbeat"::request{ input.server: context.input.server }
    && exists (n: Long). (
        (count for (t: Timepoint). where (
            Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
        )) == n && n > 2
    )
};

Schema is lifted from the temporal_only corpus case 0059_count_threshold (it declares Heartbeat/Login/Alert with a server input); the trace is lifted from that case’s trace_1.log. The default event schema (request/response) is used.

What the trace shows

The trace fires a Heartbeat for s1, three Logins (alice, bob, carol) to s1, and two Alerts. Every timepoint replays to DENY:

  • The formerly ... Heartbeat conjunct is satisfied at the two Alert timepoints (a heartbeat fired within the window), so on its own it would allow.
  • But the count conjunct is never satisfied. Its body Login && tp(t) is not wrapped in a past-temporal operator, so tp(t) pins the count to the current timepoint (the Alert), where no Login fires. The count is therefore 0 at every Alert, so n > 2 is false.
  • Because the two conjuncts are joined by &&, the rule denies everywhere.

This is the intended semantics of the guide fragment as written: a bare count ... where (P && tp(t)) counts occurrences at the verdict timepoint, not across history. To count historical logins you would wrap the body in a formerly within 1h (...) (compare corpus 0179_agg_with_once_counts_history).

Referenced by guide/04-temporal-expressions.md.

Policy

// A top-level && chain combining a `formerly` with an exists-guarded count.
// Permit an Alert only if a Heartbeat for this server fired within the last
// hour AND more than two Logins to this server occurred.
@id("alert_heartbeat_and_login_rate")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Heartbeat"::request{ input.server: context.input.server }
    && exists (n: Long). (
        (count for (t: Timepoint). where (
            Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
        )) == n && n > 2
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Heartbeat"::response(input: { server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@2 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@3 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@4 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "bob" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@5 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@10 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@11 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@20 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "carol" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@21 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@30 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u6")
@31 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u6")

Expected Output

@0 (time point 0): DENY
@2 (time point 1): DENY
@4 (time point 2): DENY
@10 (time point 3): DENY
@20 (time point 4): DENY
@30 (time point 5): DENY

alert_login_and_big_transfer

Nested exists with a value-threshold filter: permit an Alert when the same user both logged in AND made a transfer over 100 within the last hour.

The outer exists (u: String) binds the user; the inner exists (a: Long) is scoped by that same u and combines a predicate-field restrictor (input.amount: a) with a comparison filter (a > 100). Pinning the inner transfer to the outer user’s u prevents another user’s large transfer from satisfying the threshold.

Referenced by guide/04-temporal-expressions.md.

Policy

// Nested existentials: the inner `exists (a: Long)` is scoped by the outer
// user `u`, combining a predicate-field restrictor with a comparison filter
// (restrictor before filter). Permit an Alert if the same user logged in AND
// made a transfer over 100 within the last hour.
@id("alert_login_and_big_transfer")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (u: String). (
        formerly within 1h Drupe::Action::"Login"::request{ input.user: u }
        && exists (a: Long). (
            formerly within 1h Drupe::Action::"Transfer"::request{ input.user: u, input.amount: a }
            && a > 100
        )
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

alert_login_current_tp

The canonical tp(t) count-over-timepoints idiom with no temporal wrapper on the aggregation body. The count for (t: Timepoint) ranges over distinct timepoints, and because there is no formerly/since/previous wrapper on the Login::request predicate, the count sees only same-timepoint events. The policy permits an Alert iff at least one Login to this same server (input.server: context.input.server) occurred at the current timepoint.

No trace is authored for this example (validate-only).

Referenced by guide/04-temporal-expressions.md.

Policy

// The `tp(t)` binder binds `t` to each visited timepoint, letting the
// aggregation range over distinct timepoints. With NO temporal wrapper on the
// body, the count sees only same-timepoint events. Permit an Alert if at least
// one Login to this server occurred at the current timepoint.
@id("alert_login_current_tp")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (n: Long). (
        (count for (t: Timepoint). where (
            Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t)
        )) == n && n > 0
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

alert_login_in_last_hour

Counting over history: a formerly within 1h body inside the aggregation makes the count range over the whole window, not just the current timepoint. Permit an Alert if at least one Login to this server (input.server: context.input.server) occurred in the last hour. The exists (n: Long). ((count …) == n && n > 0) idiom just asserts the historical count is positive.

The trace shows both a fire and a non-fire:

  • @0Login to s1 by alice (a history-only event here; no Alert permit applies, so the decision is a deny).
  • @100 — alice raises an Alert for server s1, with a matching Login to s1 within the window → allow.
  • @200 — alice raises an Alert for server s2, with no Login to s2deny.

Referenced by guide/04-temporal-expressions.md.

Policy

// Counting over HISTORY: a `formerly within 1h` body inside the aggregation
// makes the count range over the whole window, not just the current
// timepoint. Permit an Alert if at least one Login to this server occurred in
// the last hour.
@id("alert_login_in_last_hour")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (n: Long). (
        (count for (t: Timepoint). where (
            formerly within 1h (Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server } && tp(t))
        )) == n && n > 0
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s2" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s2" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

alert_pending_transfers

Aggregate-vs-aggregate comparison (parenthesized left, bare right): permit an Alert when there are more Transfer requests than responses in the last hour — i.e. some transfers are still pending.

The left count for (t: Timepoint). where (…) aggregate is parenthesized so its greedy where body does not swallow the < operator; the right aggregate is rightmost so it needs no parens.

World: drupe. Schema lifted from the temporal_only corpus case ea_0014_agg_vs_agg (has the Transfer and Alert actions). Default event schema; no trace.

Referenced by guide/04-temporal-expressions.

Policy

// An aggregate on BOTH sides of a comparison. The LEFT aggregate is
// parenthesized so its greedy `where` body does not swallow the `<` operator;
// the RIGHT is rightmost so it needs no parens. Permit an Alert if there are
// more Transfer requests than responses in the last hour (some pending).
@id("alert_pending_transfers")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    (count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::response{ requestId: _ } && tp(t))
    ))
    < count for (t: Timepoint). where (
        formerly within 1h (Drupe::Action::"Transfer"::request{ requestId: _ } && tp(t))
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

alert_same_principal_login_transfer

An entity-typed exists binder that correlates on the request principal via the reserved callerPrincipal field. The policy permits an Alert only if the same principal both logged in (Login) and made a transfer (Transfer) within the last hour — the join on the bound pr: Drupe::OAuthUser variable is the whole point.

The guide illustrates the pattern with Login + Deny, but no shipped schema declares a Deny action, so this example is adapted to Login + Transfer (both present in the lifted ea_0012_exists_correlation schema). The shape of the correlation — callerPrincipal: pr on both sides — is identical.

Schema lifted from the temporal_only corpus case ea_0012_exists_correlation (declares Login, Transfer, Alert, and the OAuthUser entity). Uses the default event schema.

Referenced by guide/04-temporal-expressions.

Policy

// An entity-typed `exists` binder correlates on the request principal via the
// reserved callerPrincipal field. Permit an Alert if the SAME principal both
// logged in and transferred within the last hour. (The guide illustrates this
// with Login+Deny; adapted here to Login+Transfer since no shipped schema
// declares a Deny action.)
@id("alert_same_principal_login_and_transfer")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (pr: Drupe::OAuthUser). (
        formerly within 1h Drupe::Action::"Login"::request{ callerPrincipal: pr }
        && formerly within 1h Drupe::Action::"Transfer"::request{ callerPrincipal: pr }
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

alert_same_user_login_and_transfer

A shared exists variable joins two different predicates through a common value: permit an Alert only if the same user both logged in and made a transfer within the last hour. Binding u across both formerlys is the whole point — a broken join that accepted “some login AND some (other-user) transfer” would wrongly permit.

The trace shows both outcomes and the discriminating case:

  • @0 — alice Login (history-only event; no Alert, so deny).
  • @1 — alice Alert with a login but no transfer yet → deny.
  • @2 — alice Transfer (history-only event → deny).
  • @3 — alice Alert after both her login (@0) and transfer (@2) → allow (one user satisfies both sides of the join).
  • @5000 — bob Login; @5001 — carol Transfer (both history-only → deny).
  • @5002 — carol Alert: a login (bob) and a transfer (carol) both exist in the window, but no single user did bothdeny. This is what the shared-u join buys you; a broken join would wrongly allow here.

Referenced by guide/04-temporal-expressions.md.

Policy

// A shared `exists` variable JOINS two different predicates: the SAME user
// both logged in and transferred. A broken join that accepted "some login AND
// some (other-user) transfer" would wrongly permit. Permit an Alert if one
// user did both within the last hour.
@id("alert_same_user_login_and_transfer")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (u: String). (
        formerly within 1h Drupe::Action::"Login"::request{ input.user: u }
        && formerly within 1h Drupe::Action::"Transfer"::request{ input.user: u }
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@2 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 50, user: "alice" }) Drupe::Action::"Transfer"::request(input: { amount: 50, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@3 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@5000 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "bob" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@5001 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 50, user: "carol" }) Drupe::Action::"Transfer"::request(input: { amount: 50, user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u6")
@5002 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u7")

Expected Output

@0 (time point 0): DENY
@1 (time point 1): DENY
@2 (time point 2): DENY
@3 (time point 3): ALLOW  [rules: 0]
@5000 (time point 4): DENY
@5001 (time point 5): DENY
@5002 (time point 6): DENY

alert_some_login

exists is the sole quantifier: it asserts at least one satisfying assignment, with candidate values coming only from the atom that binds the variable. This policy permits an Alert if some user logged in to this Alert’s server within the last hour. The bound u is range-restricted by the Login predicate’s input.user field, and input.server: context.input.server pins the login’s server to the server named in the current Alert request.

The trace shows all the interesting cases (within 1h = 3600s, inclusive):

  • @0 — alice logs in to s1 (a Login, not an Alert; no permit applies) → deny.
  • @100 — bob alerts on s1; some user (alice) logged in to s1 100s ago → allow. Note exists is “at least one,” and the witness need not be the alerting principal — alice’s login satisfies bob’s alert.
  • @200 — bob alerts on s2; no login to s2 (the server pin fails) → deny.
  • @5000 — alice alerts on s1, but the only s1 login was 5000s ago, outside the 1h window → deny.

Referenced by guide/04-temporal-expressions.

Policy

// `exists` is the sole quantifier: it asserts at least one satisfying
// assignment, with candidate values coming only from the atom that binds the
// variable. Permit an Alert if SOME user logged in to this Alert's server
// within the last hour (the bound `u` is range-restricted by the Login field).
@id("alert_some_login")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (u: String). formerly within 1h Drupe::Action::"Login"::request{ input.user: u, input.server: context.input.server }
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s2" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s2" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@5000 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY
@5000 (time point 3): DENY

Event Schema

// Unpinned event schema.
//
// Same structure as the pinned default but WITHOUT pins. Temporal
// predicates use global-trace semantics: a policy can match events
// from any principal unless it explicitly constrains callerPrincipal.
//
// Use this to see the behavioral difference vs the pinned default:
//   dogwood replay --event-schema configuration/event-schemas/unpinned.dwschema ...

decision event <A>::request {
    ...inputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::response {
    ...inputs(A),
    ...outputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

event <A>::error {
    ...inputs(A),
    callerPrincipal:   principalType(A),
    callerResource:    resourceType(A),
    requestId:         String,
    sessionId:         String,
}

alert_total_transfer_over_200

Aggregation example: permit an Alert only when the total transferred amount exceeds a threshold. sum a for (a: Long). where …Transfer::request{ input.amount: a } sums the amount column over the deduplicated matching rows, binds it to total via == total, and the permit fires only if total > 200.

The sum body is a bare predicate — it has no formerly/temporal wrapper, so it scans only the current timepoint. Because the permit’s scope requires the current action to be Alert (never a Transfer), the summed relation at every Alert decision point is empty, so total is 0 and total > 200 is never satisfied. Every verdict in the trace is therefore a deny — matching the all-false oracle for corpus 0063_sum_threshold. (To make this fire you would wrap the body in a temporal operator so the sum ranges over past transfers, as in the 0299_sum_resolved_filter / 0301_sum_resolved_range_filter variants.)

Files:

  • policy.dw — the permit with the sum … > 200 temporal body.
  • schema.cedarschema — Drupe action schema (lifted from corpus temporal_only/0063_sum_threshold; has Transfer with a Long amount input and Alert).
  • trace.log — lifted from 0063_sum_threshold/trace_1.log: transfers of 100, 200, and 50 by three users interleaved with three Alerts.
  • expected.out — the real per-timepoint verdict stream from dogwood replay.

Reproduce (run from this directory):

dogwood validate policy.dw --policy-schema schema.cedarschema
dogwood replay   policy.dw --policy-schema schema.cedarschema --trace trace.log

Referenced by guide/04-temporal-expressions.md.

Policy

// `sum v` sums column v over the deduplicated matching rows; v must be a
// for-declared variable. Permit an Alert only if the total transferred amount
// exceeds 200.
@id("alert_total_transfer_over_200")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (total: Long). (
        (sum a for (a: Long). where Drupe::Action::"Transfer"::request{ input.amount: a }) == total
        && total > 200
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 100, user: "alice" }) Drupe::Action::"Transfer"::request(input: { amount: 100, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { amount: 100, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@2 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@3 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@10 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 200, user: "bob" }) Drupe::Action::"Transfer"::request(input: { amount: 200, user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@11 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { amount: 200, user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@12 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@13 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@20 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 50, user: "carol" }) Drupe::Action::"Transfer"::request(input: { amount: 50, user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@21 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { amount: 50, user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@22 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u6")
@23 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u6")

Expected Output

@0 (time point 0): DENY
@2 (time point 1): DENY
@10 (time point 2): DENY
@12 (time point 3): DENY
@20 (time point 4): DENY
@22 (time point 5): DENY

allow_anything

An unconstrained (bare) scope triple: all three slots (principal, action, resource) carry no constraint, so the rule matches every request.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// A rule with all three scope slots bare matches every request.
@id("allow_anything")
permit (
    principal,
    action,
    resource
);

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

approve_has_output_guard

The has attribute-existence guard before reading an optional Bool output field. context has output tests whether the optional output attribute is present, and because && short-circuits, that guard on the left protects the context.output.approved == true read on the right.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `has` tests optional-attribute presence; && short-circuits so the guard on
// the left protects the attribute read on the right.
@id("approve_when_approved")
permit ( principal, action == Drupe::Action::"ApproveSale", resource )
when {
    context has output && context.output.approved == true
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

call_cedar_macro_as_argument

A Cedar macro call passed as an argument to another macro call: semverGT(semver(2, 1, 1), semver(2, 1, 0)). The inner semver(...) calls expand into record literals first, then splice into the outer semverGT(...) call, which expands into the nested if/comparison. The result is a constant-true guard (2.1.1 > 2.1.0), so the permit fires for every Drupe::Action::"GetStockInfo" request.

The two macros (semver, semverGT) are lifted verbatim from macros corpus 0036_semver_rfc0061 (Cedar RFC-0061, translated to Dogwood) and live in macros.dw, passed via --macros.

Validate (run from this directory):

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw

Referenced by guide/09-calling-macros.md.

Policy

// A macro call passed as an ARGUMENT to another macro call: the inner
// semver(...) records are expanded first, then spliced into semverGT.
// (RFC-0061 semantic-versioning macros, translated to Dogwood.)
@id("gate_on_semver_compare")
permit (principal, action == Drupe::Action::"GetStockInfo", resource)
when {
    semverGT(semver(2, 1, 1), semver(2, 1, 0))
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Macros

// RFC-0061 semantic-versioning macros (lifted verbatim from macros corpus
// 0036_semver_rfc0061/policy_1.dw): a record-building macro `semver` and a
// comparing macro `semverGT`. `semver(...)` may be passed as an argument to
// `semverGT(...)` — the inner calls expand first, then splice in.
def cedar semver(?major, ?minor, ?patch) {
    { major: ?major, minor: ?minor, patch: ?patch }
};

def cedar semverGT(?lhs, ?rhs) {
    if ?lhs.major == ?rhs.major then
        if ?lhs.minor == ?rhs.minor then
            ?lhs.patch > ?rhs.patch
        else
            ?lhs.minor > ?rhs.minor
    else
        ?lhs.major > ?rhs.major
};

call_cedar_macro_is_small

A def cedar macro called in an ordinary when { … } position: is_small(context.input.shares) slots into the clause exactly like plain Cedar. The macro is defined in the macro library (macros.dw, supplied via --macros), not redeclared in policy.dw, and it guards a SellShares permit against the reusable Drupe schema.

Validate with:

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw

Referenced by guide/09-calling-macros.md.

Policy

// Calling a `def cedar` macro in an ordinary `when` position.
// `is_small` is defined in the macro library (macros.dw) and slots
// into the clause exactly like plain Cedar.
@id("sell_small")
permit (principal, action == Drupe::Action::"SellShares", resource)
when { is_small(context.input.shares) };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Macros

// Shareable macro library, attached to a schema via --macros. This `def`
// definition is merged into every policy set lowered against the schema, so
// policies can call `is_small` without redeclaring it.
def cedar is_small(?n) { ?n < 100 };

call_cedar_macro_with_temporal_leaf

A Cedar macro conjoined mid-expression with a temporal { … } leaf. Because a def cedar macro (level_ok(?n) { ?n >= 2 }, in macros.dw) expands before the surrounding expression is lowered, level_ok(context.input.level) can be &&-joined with a temporal { … } block in one when. The guide leaves the temporal body as /* … */; here it is promoted to a recent-Login check (formerly within 1h, pinned to the same input.server).

The trace shows both outcomes:

  • @0Login by alice on s1 (history only; no Alert permit applies) → DENY.
  • @2Alert by alice, level: 1level_ok(1) is false → DENY.
  • @10Alert by alice, level: 2level_ok(2) true and a Login on s1 is within the last hour → ALLOW.
  • @20Login by bob (history only) → DENY.
  • @22Alert by alice, level: 3level_ok true and a matching recent Login on s1ALLOW.

Run from this directory:

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw
dogwood replay  policy.dw --policy-schema schema.cedarschema --macros macros.dw --trace trace.log

Referenced by guide/09-calling-macros.md.

Policy

// A `def cedar` macro conjoined mid-expression with a temporal { … }
// leaf: the cedar macro expands BEFORE the expression is lowered, so
// level_ok(...) && temporal { … } is well-formed. The guide shows the
// temporal body as `/* … */`; promoted here to a recent-Login check.
@id("alert_when_level_ok_and_logged_in")
permit (principal, action == Drupe::Action::"Alert", resource)
when {
    level_ok(context.input.level)
    && temporal {
        formerly within 1h Drupe::Action::"Login"::request{ input.user: _, input.server: context.input.server }
    }
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@2 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@3 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@11 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@20 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "bob" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@21 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@22 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@23 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@2 (time point 1): DENY
@10 (time point 2): ALLOW  [rules: 0]
@20 (time point 3): DENY
@22 (time point 4): ALLOW  [rules: 0]

Macros

// A `def cedar` boolean macro: true when its numeric argument is at least 2.
// It expands BEFORE the surrounding expression is lowered, so its call can be
// conjoined mid-expression with a `temporal { … }` leaf.
def cedar level_ok(?n) {
    ?n >= 2
};

call_cedar_macros_composed

Two Cedar macros composed with && in one when { … } clause: is_eligible(context.input.shares, context.input.stock) && is_not_blocked(context.input.stock). Both macros are defined in the macro library (macros.dw) and loaded via --macros; each is called with a different argument shape (a Long and a String), showing that ?p literal-splicing carries call-site types through.

Validate with:

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw

Referenced by guide/09-calling-macros.md.

Policy

// Two `def cedar` macros composed with `&&` in one `when` clause.
// Both are defined in the macro library (macros.dw); each is called
// with a different argument shape (Long, String).
@id("sell_eligible_unblocked")
permit (principal, action == Drupe::Action::"SellShares", resource)
when {
    is_eligible(context.input.shares, context.input.stock)
    && is_not_blocked(context.input.stock)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Macros

// Macro library: two reusable `def cedar` fragments composed by policy.dw.
//
// `is_eligible` takes a Long (?shares) and a String (?stock); `is_not_blocked`
// takes a String (?stock). Each is called with a different argument shape,
// proving ?p literal-splicing carries call-site types through.
def cedar is_eligible(?shares, ?stock) {
    ?shares < 100 || ?stock == "FOO"
};

def cedar is_not_blocked(?stock) {
    !(?stock == "BLOCKED")
};

call_temporal_aggregation_macro_count

Calling a def temporal aggregation macro. count_formerly produces a count, so it is spliced into a comparison and wrapped in an exists binder that introduces the variable it is compared against — never called on its own:

exists (n: Long). (count_formerly(1h, Login) == n && n > 0)

The macro is defined in the attached library macros.dw (supplied via --macros) and desugars to count for ($t: Timepoint). where (formerly within ?w (?s && tp($t))). The policy permits Alert once at least one matching Login is in the last hour.

The trace shows both verdicts:

  • @0 — alice Login on s1 (a history-only event here; no Alert permit applies, so the decision is a deny).
  • @2, @10 — alice Alert on s1, within 1h of her login → allow (count == 1, so n > 0).
  • @20 — bob Login on s1 (history-only again → deny).
  • @22 — alice Alert on s1, still within the window → allow.

Run from this directory:

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw
dogwood replay   policy.dw --policy-schema schema.cedarschema --macros macros.dw --trace trace.log

Referenced by guide/09-calling-macros.md.

Policy

// Calling a `def temporal` AGGREGATION macro: it produces a count, so
// it is spliced into a comparison inside an `exists` binder (which
// introduces the variable it is compared against) — never called on
// its own. Permits Alert once at least one Login is in the window.
@id("alert_after_a_login")
permit (principal, action == Drupe::Action::"Alert", resource)
when temporal {
    exists (n: Long). (
        (count_formerly(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        })) == n
        && n > 0
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@2 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@3 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@11 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@20 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "bob" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@21 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@22 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@23 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@2 (time point 1): ALLOW  [rules: 0]
@10 (time point 2): ALLOW  [rules: 0]
@20 (time point 3): DENY
@22 (time point 4): ALLOW  [rules: 0]

Macros

// Macro library supplied via --macros. `count_formerly(?w, ?s)` (?w a bare
// interval, e.g. `1h`) desugars to
//   count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
// so a call spliced into a comparison inside an `exists` counts how many
// times the predicate ?s held anywhere within the window ?w.
def temporal count_formerly(?w, ?s) {
    count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
};

call_temporal_condition_macro_once

Calling a def temporal condition macro inside a when temporal { … } block. macros.dw defines once(?w, ?s) as a thin wrapper over formerly within ?w ?s; the policy calls it with a bare interval literal window arg (1h, no within keyword) and a Read request pattern as the predicate arg — pinning input.user and input.document to the current request’s context.

Net effect: permit a Write only if the same user recently Read the same document (within 1h). The trace shows both outcomes:

  • @0Read of doc1 by alice (history-only here; no Write permit applies, so the decision is a deny).
  • @10 — alice writes doc1, 10s after the read → allow (matching read is within the window and pins both user and document).
  • @20 — alice writes doc2, which she never read → deny.

Referenced by guide/09-calling-macros.md.

Policy

// Calling a `def temporal` CONDITION macro inside when temporal { … }.
// `once` wraps a window + a predicate; the window arg is a BARE
// interval literal (1h, no `within` keyword) and the predicate arg is
// a Read response pattern.
@id("write_after_recent_read")
permit (principal, action == Drupe::Action::"Write", resource)
when temporal {
    once(1h, Drupe::Action::"Read"::response{
        input.user: context.input.user,
        input.document: context.input.document
    })
};

Schema

namespace Drupe {
  type ApproveInput = {
    approver: String,
    request_id: String
  };

  type ApproveOutput = {
    result: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type LogoutInput = {
    server: String,
    user: String
  };

  type LogoutOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {
    result: Bool
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SubmitInput = {
    request_id: String,
    user: String
  };

  type SubmitOutput = {
    result: Bool
  };

  type SystemContext = {
    now: datetime
  };

  type WriteInput = {
    document: String,
    user: String
  };

  type WriteOutput = {
    result: Bool
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Approve" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveInput,
      output?: ApproveOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Logout" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LogoutInput,
      output?: LogoutOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "Submit" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SubmitInput,
      output?: SubmitOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Write" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: WriteInput,
      output?: WriteOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Read"::response(input: { document: "doc1", user: "alice" }, output: { result: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY

Macros

def temporal once(?w, ?s) { formerly within ?w ?s };

call_temporal_condition_macros_composed

Two def temporal condition macros composed with && inside a single when temporal { … } block. recently_logged_in(?u) and recently_read(?u, ?d) each name a “happened within the last hour” check (formerly within 1h); the policy permits a Write only when both hold — the same user logged in and read the same document being written. Condition macros compose with && exactly like the built-in temporal operators.

The macros live in macros.dw and are supplied with --macros.

The trace walks alice through login (@0) then read of doc1 (@10), so:

  • @20 — alice writes doc1: both macros hold → allow.
  • @30 — alice writes doc2: she logged in but never read doc2 (recently_read fails) → deny.
  • @40 — bob writes doc1: he never logged in or read → deny.

Referenced by guide/09-calling-macros.md.

Policy

// Two `def temporal` condition macros composed with `&&` inside one
// when temporal { … } block — condition macros compose like the
// built-in temporal operators.
@id("write_after_login_and_read")
permit (principal, action == Drupe::Action::"Write", resource)
when temporal {
    recently_logged_in(context.input.user)
    && recently_read(context.input.user, context.input.document)
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type WriteInput = {
    document: String,
    user: String
  };

  type WriteOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Write" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: WriteInput,
      output?: WriteOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@11 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Read"::response(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@40 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "bob" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@20 (time point 2): ALLOW  [rules: 0]
@30 (time point 3): DENY
@40 (time point 4): DENY

Macros

// Two temporal condition macros, each a "happened recently" check over the
// accumulated event history. They are supplied as a macro library (`--macros`)
// and called from policy.dw.
def temporal recently_logged_in(?u) {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: ?u }
};

def temporal recently_read(?u, ?d) {
    formerly within 1h Drupe::Action::"Read"::response{ input.user: ?u, input.document: ?d }
};

cedar_eligible_not_blocked

Two def cedar macros of different argument types composed with && inside one when { … } clause. is_eligible(?shares, ?stock) takes a Long and a String; is_not_blocked(?stock) takes a String. Composing them proves that ?p literal-splicing carries the call-site types (context.input.shares: Long, context.input.stock: String) through macro expansion into the lowered Cedar.

Both macros are defined inline in policy.dw (no macros.dw). The schema is the Drupe SellShares schema (SellSharesInput.shares: Long, .stock: String); the default request/response event schema is used.

Validate with:

dogwood validate policy.dw --policy-schema schema.cedarschema

Referenced by guide/06-macros.md.

Policy

// Two Cedar macros composed with `&&`, each taking an argument of a
// different type (Long, String), proving ?p literal-splicing carries
// call-site types through.
def cedar is_eligible(?shares, ?stock) { ?shares < 100 || ?stock == "FOO" };
def cedar is_not_blocked(?stock) { !(?stock == "BLOCKED") };

@id("sell_eligible")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when {
    is_eligible(context.input.shares, context.input.stock)
    && is_not_blocked(context.input.stock)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

cedar_is_small_threshold

The simplest def cedar macro: is_small(?n) names the < 100 threshold so the bound lives in exactly one place, then calls it in a when { … } clause of a SellShares permit. The macro is defined inline in policy.dw (no separate macros.dw), and the default event schema is used.

Validate with:

dogwood validate policy.dw --policy-schema schema.cedarschema

Referenced by guide/06-macros.md.

Policy

// Name a threshold: `is_small(?n)` abstracts the `< 100` comparison so the
// bound lives in exactly one place. Called as an ordinary Cedar expression.
def cedar is_small(?n) { ?n < 100 };

@id("sell_small")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when {
    is_small(context.input.shares)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

cedar_macro_plus_temporal_leaf

A def cedar boolean macro (level_ok) conjoined mid-expression with an inline temporal { … } leaf. The Cedar macro is expanded before the surrounding expression is lowered (which hoists the temporal leaf). The guide shows a temporal { /* ... */ } placeholder; here it is promoted to a concrete recent-login leaf: formerly within 1h ...Login..., pinned to the same server via input.server: context.input.server.

The policy permits Alert only when both conjuncts hold:

  • level_ok(context.input.level) — the Cedar macro requires level >= 2.
  • the temporal leaf — a Login for the same server occurred within the last hour.

The trace exercises every combination:

  • @0 — a Login (not an Alert; no permit rule applies) → DENY.
  • @10Alert level 3 on s1, with a recent login on s1ALLOW (both conjuncts true).
  • @20Alert level 1 on s1DENY (macro conjunct false: 1 < 2).
  • @30Alert level 5 on s2, no login on s2DENY (temporal conjunct false: server mismatch).

Referenced by guide/06-macros.md.

Policy

// A Cedar macro conjoined with a temporal block mid-expression: the Cedar
// macro `level_ok` is expanded BEFORE the surrounding expression is lowered
// (which hoists the temporal leaf). Guide shows a `temporal { /* ... */ }`
// placeholder; promoted here to a concrete recent-login leaf.
def cedar level_ok(?n) { ?n >= 2 };

@id("alert_level_and_recent_login")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when {
    level_ok(context.input.level)
    && temporal {
        formerly within 1h Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        }
    }
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 5, server: "s2" }) Drupe::Action::"Alert"::request(input: { level: 5, server: "s2" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY
@30 (time point 3): DENY

cedar_semver_gt

The RFC 0061 semver worked example for Cedar macros: a record-building macro (semver, which constructs a { major, minor, patch } record) passed as an argument to a comparator macro (semverGT). Nesting one macro call as an argument to another is allowed — the compiler expands call arguments first, then splices the result into the outer macro’s body.

Both macros are declared inline in policy.dw. The rule uses constant arguments and a bare action scope, so it validates against the Drupe schema (copied from write_after_read) with no event history.

Referenced by guide/06-macros.md.

Policy

// RFC 0061 worked example: `semver` builds a { major, minor, patch }
// record that `semverGT` compares. Nesting one macro call as an argument
// to another is allowed (call arguments are expanded first).
def cedar semver(?major, ?minor, ?patch) {
    { major: ?major, minor: ?minor, patch: ?patch }
};
def cedar semverGT(?a, ?b) {
    if ?a.major == ?b.major
    then (if ?a.minor == ?b.minor then ?a.patch > ?b.patch else ?a.minor > ?b.minor)
    else ?a.major > ?b.major
};

@id("semver_gt_constant")
permit (principal, action, resource)
when { semverGT(semver(2, 1, 1), semver(2, 1, 0)) };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

cedar_starts_with_f_like

A Cedar macro whose body is a like wildcard pattern: starts_with_f(?s) wraps ?s like "F*". The macro is defined inline and used from a permit rule that allows GetStockInfo only when context.input.stock starts with the letter F.

This shows that a def cedar macro body can be any Cedar expression, including a like pattern, not just a comparison.

Validate:

dogwood validate policy.dw --policy-schema schema.cedarschema

Referenced by guide/06-macros.md.

Policy

// A Cedar macro body can be any Cedar expression, including a `like`
// pattern. `starts_with_f(?s)` wraps `?s like "F*"`.
def cedar starts_with_f(?s) { ?s like "F*" };

@id("stock_starts_with_f")
permit(
    principal,
    action == Drupe::Action::"GetStockInfo",
    resource
)
when {
    starts_with_f(context.input.stock)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

cedar_within_cap_if_else

A def cedar macro whose body is an if/then/else expression, encoding a per-stock share cap: within_cap(?stock, ?shares) returns ?shares <= 10 for stock "FOO" and ?shares <= 1000 otherwise. Two params of different types (String and Long) are spliced into one Cedar expression.

The macro is promoted here to a full permit rule on Drupe::Action::"SellShares", reading context.input.stock and context.input.shares (the SellSharesInput type from the Drupe schema).

Referenced by guide/06-macros.md.

Policy

// `within_cap(?stock, ?shares)` encapsulates a per-stock cap inside an
// if/then/else expression; two params of different types are spliced.
def cedar within_cap(?stock, ?shares) {
    if ?stock == "FOO" then ?shares <= 10 else ?shares <= 1000
};

@id("sell_within_cap")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when {
    within_cap(context.input.stock, context.input.shares)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

cond_is_oauth_in_team

The expression-level counterpart of the is / is-in scope constraint: an entity-type + hierarchy membership test written inside a when { ... } body rather than in the policy scope. The condition principal is Drupe::OAuthUser in Drupe::Team::"traders" succeeds only when the principal is an OAuthUser that also belongs to the traders team.

This reuses the bespoke schema (schema.cedarschema) from traders_is_in_group_scope, which adds entity Team; and entity OAuthUser in [Team] = { id: String } tags String; so the membership target Drupe::Team::"traders" type-checks — the stock GetStockInfo schema has no Team type.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// The expression-level counterpart of the `is`/`is-in` scope constraint:
// an entity-type + hierarchy membership test inside a when body.
@id("cond_is_oauth_in_team")
permit ( principal, action == Drupe::Action::"GetStockInfo", resource )
when { principal is Drupe::OAuthUser in Drupe::Team::"traders" };

Schema

namespace Drupe {
  type GetStockInfoInput = { stock: String };
  type GetStockInfoOutput = { info: String };
  type SystemContext = { now: datetime };

  entity Gateway;
  entity Team;
  entity OAuthUser in [Team] = { id: String } tags String;

  action "GetStockInfo" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };
}

deny_overrides_sell_not_amzn

A permit + forbid pair showing deny-overrides semantics: SellShares is permitted in general, but a forbid carves out AMZN. Because forbid always wins, source order does not matter — the forbid “carves a hole” out of whatever the permit allows.

The trace shows both outcomes:

  • @0 — alice sells MSFT → allow (the permit matches; no forbid applies).
  • @100 — alice sells AMZN → deny (the forbid matches and overrides the permit).
  • @200 — bob calls GetStockInfodeny (no permit matches, so the default-deny applies).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Deny-overrides semantics: permit selling shares generally, but a forbid
// carves out AMZN no matter the source order.
@id("permit-sell-shares")
permit ( principal, action == Drupe::Action::"SellShares", resource );

@id("forbid-sell-amzn")
forbid ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.stock == "AMZN" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { stock: "AMZN" }) Drupe::Action::"GetStockInfo"::request(input: { stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): DENY  [rules: 1]
@200 (time point 2): DENY

forbid_large_except_amzn

Mixing when and unless on a forbid rule: block large SellShares (context.input.shares > 100), but carve out an exemption for AMZN (unless { context.input.stock == "AMZN" }).

Because this bundle has only a forbid rule and no permit, every request is denied — there is nothing that can produce an allow. What the trace shows is why each request is denied:

  • @0 — alice sells 500 MSFT → the forbid fires (large, not AMZN) → DENY [rules: 0] (actively blocked by the rule).
  • @100 — alice sells 500 AMZN → unless exempts AMZN, so the forbid does not fire → DENY (Cedar’s default deny; no permit applies).
  • @200 — bob sells 50 MSFT → when { shares > 100 } is false, so the forbid does not fire → DENY (below the threshold; again default deny).

The [rules: …] annotation distinguishes an actively forbidden request from one that simply falls through to the default deny.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Forbid large sells except for AMZN (when + unless on a forbid rule).
@id("forbid_large_except_amzn")
forbid ( principal, action == Drupe::Action::"SellShares", resource )
when   { context.input.shares > 100 }
unless { context.input.stock == "AMZN" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 500, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 500, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 500, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 500, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 50, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 50, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY  [rules: 0]
@100 (time point 1): DENY
@200 (time point 2): DENY

forbid_read_transfers_over_1000

A forbid rule with a sum over a (value, timepoint) domain and a filtered temporal body. Forbid a Read if the same user’s resolved positive Transfers in the last hour total more than 1000.

The two-binder domain for (a: Long), (t: Timepoint). is what keeps equal amounts made at different timepoints from being deduplicated — a is the summed value and t distinguishes the timepoints, so the sum is per-occurrence.

Because the policy set contains only this forbid rule, no request can ever be allowed — the interesting contrast is the threshold, i.e. whether the forbid fires (an explicit deny, shown as [rules: 0]) or not (a default deny).

The trace shows both sides of the threshold:

  • @0/@1 — alice’s first Transfer resolves to 600 (a history-only event; no matching rule applies, so the decision is a default deny).
  • @10 — alice reads with only 600 transferred in the last hour → default deny (the forbid does not fire; 600 is not over 1000).
  • @20/@21 — a second Transfer for alice resolves to 600 (running total 1200).
  • @30 — alice reads again, now with 600 + 600 = 1200 transferred in the last hour → explicit deny ([rules: 0], the forbid fires because the sum is over 1000).
  • @40 — bob reads with no Transfers of his own → default deny (the per-user pin input.user: context.input.user means alice’s transfers do not count for bob).

Referenced by guide/04-temporal-expressions.md.

Policy

// A FORBID rule with a `sum` over a (value, timepoint) domain and a filtered
// temporal body. The two-binder domain `for (a: Long), (t: Timepoint).` keeps
// equal amounts made at different timepoints from being deduplicated. Forbid a
// Read if the same user's resolved positive Transfers in the last hour total
// more than 1000.
@id("forbid_read_when_transfers_over_1000")
forbid (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    exists (total: Long). (
        (sum a for (a: Long), (t: Timepoint). where (
            formerly within 1h (
                Drupe::Action::"Transfer"::response{ input.user: context.input.user, output.amount: a }
                && a > 0 && tp(t)
            )
        )) == total
        && total > 1000
    )
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    user: String
  };

  type TransferOutput = {
    amount: Long
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Transfer"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { user: "alice" }, output: { amount: 600 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Transfer"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@21 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { user: "alice" }, output: { amount: 600 }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@40 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "bob" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@20 (time point 2): DENY
@30 (time point 3): DENY  [rules: 0]
@40 (time point 4): DENY

get_amzn_stock_info

A plain (non-temporal) permit showing that an MCP-manifest input field validates at context.input.stock. The tool’s stock argument comes from the MCP manifest’s inputSchema, so in the generated Drupe schema it lands at context.input.stock — meaning an ordinary Cedar when clause type-checks against it. The rule allows GetStockInfo only when the requested stock is AMZN; everything else is denied.

The trace shows both cases:

  • @0 — alice requests GetStockInfo for AMZNallow.
  • @100 — bob requests GetStockInfo for MSFTdeny.

Referenced by guide/11-mcp-schema-generation.md.

Policy

// Permit reading stock info only for AMZN. The tool's `stock` argument comes
// from the MCP manifest's inputSchema, so in the generated Drupe schema it
// lands at `context.input.stock` — meaning a plain Cedar `when` clause validates
// against it. This is the policy from guide chapter 11 ("What you get"),
// promoted to a complete rule with an @id.
@id("get_amzn_stock_info")
permit (
    principal,
    action == Drupe::Action::"GetStockInfo",
    resource
)
when { context.input.stock == "AMZN" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { stock: "AMZN" }) Drupe::Action::"GetStockInfo"::request(input: { stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { stock: "MSFT" }) Drupe::Action::"GetStockInfo"::request(input: { stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u2")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): DENY

heartbeat_scope_alias

formerly with scope-alias correlation. Permit an Alert only if a Heartbeat for the same server and the same request scope fired within the last hour (formerly within 1h).

The scope aliases context.principal / context.resource resolve to the current request’s scope entities and are pinned against the reserved event fields callerPrincipal / callerResource, so a heartbeat only counts if it came from the same principal on the same resource — not just any heartbeat for that server.

The trace shows both outcomes:

  • @0machine-a sends a Heartbeat for s1 (history-only event; no Alert permit applies, so the decision is a deny).
  • @100machine-a raises an Alert for s1allow (its own heartbeat, same server, same principal/resource, is within the window).
  • @200machine-b raises an Alert for s1deny: the server matches, but the scope-alias pins on callerPrincipal fail (the only heartbeat came from machine-a).

Referenced by guide/04-temporal-expressions.md.

Policy

// Scope correlation: `principal`/`resource` are the request's scope entities
// (first-class temporal term roots, matching Cedar) and pin against the
// reserved event fields callerPrincipal / callerResource. Permit an Alert
// only if a Heartbeat for the same server, principal, and resource fired within
// the last hour.
@id("alert_recent_heartbeat")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Heartbeat"::request{
        input.server: context.input.server,
        callerPrincipal: principal,
        callerResource: resource
    }
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"machine-a", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1" }, callerPrincipal: Drupe::OAuthUser::"machine-a", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"machine-a", resource: Drupe::Gateway::"gw1") request_context(input: { level: 3, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 3, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"machine-a", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"machine-b", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"machine-b", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

login_attempt_custom_kind

A custom, author-defined event kind. The per-case event schema (event.dwschema) names attempt as the decision kind and outcome as history (instead of the conventional request/response), and renames the injected principal field to actor (instead of callerPrincipal). The policy permits a Read only if the same actor formerly attempted a Login within the last hour (formerly within 1h, correlating actor: context.principal and input.user: context.input.user).

The event schema is passed with --event-schema event.dwschema; without it the CLI would default to request/response and reject the ::attempt kind.

The trace shows all three cases:

  • @0 — alice attempts a Login (a history-only event here; no Read permit applies to a login, so the decision is a deny).
  • @10 — alice reads, 10s after her login → allow (a matching login by the same actor is within the window).
  • @20 — bob reads with no prior login of his own → deny.

Referenced by guide/04-temporal-expressions.md.

Policy

// Event kinds are author-defined. This event schema names `attempt` (the
// decision kind) and `outcome` (history) instead of request/response, and
// renames the injected principal field to `actor`. Permit a Read only if the
// same actor formerly attempted a Login within the last hour.
@id("read_after_login_attempt")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::attempt{
        input.user: context.input.user,
        actor: principal
    }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {
    result: Bool
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }, actor: Drupe::OAuthUser::"alice") Drupe::Action::"Login"::attempt(input: { server: "s1", user: "alice" }, actor: Drupe::OAuthUser::"alice")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }, actor: Drupe::OAuthUser::"alice") Drupe::Action::"Read"::attempt(input: { document: "doc1", user: "alice" }, actor: Drupe::OAuthUser::"alice")
@20 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }, actor: Drupe::OAuthUser::"bob") Drupe::Action::"Read"::attempt(input: { document: "doc1", user: "alice" }, actor: Drupe::OAuthUser::"bob")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY

Event Schema

// A custom event schema exercising the per-case override: the event kinds
// are `attempt` (the decision kind) and `outcome` (history), NOT the
// conventional request/response, and the injected principal field is
// named `actor` rather than `callerPrincipal`. This probes that (a) the
// harness honors a per-case event.dwschema, (b) author-defined kinds work
// end to end, and (c) a renamed injected reserved field is usable.
decision event <A>::attempt {
    ...inputs(A),
    actor: principalType(A),
}

event <A>::outcome {
    ...inputs(A),
    ...outputs(A),
    actor: principalType(A),
}

macro_library_once_is_small

Demonstrates the shareable macro library: the policy calls once (a def temporal macro) and is_small (a def cedar macro) that are defined in an external macros.dw and supplied via --macros. The policy.dw does not redeclare either macro — they are merged in from the library at lowering time.

permit SellShares only when the sale amount is_small(context.input.shares) (the Cedar macro, < 100) and the same stock had an ApproveSale once within the last hour (the temporal macro, formerly within 1h, pinned to context.input.stock).

Run it (from this directory):

dogwood validate policy.dw --policy-schema schema.cedarschema --macros macros.dw
dogwood replay   policy.dw --policy-schema schema.cedarschema --macros macros.dw --trace trace.log

The trace shows every case:

  • @0ApproveSale for AMZN by alice (history-only; no SellShares permit applies, so the decision is a deny).
  • @100 — alice sells 5 AMZN, 100s after the approval → allow (is_small(5) holds and a matching approval is within the window).
  • @200 — alice sells 500 AMZN → deny (is_small(500) is false: the Cedar macro’s < 100 threshold fails).
  • @5000 — bob sells MSFT with no prior approval → deny (once finds no matching ApproveSale in the window).

This is the only chapter example that needs --macros.

Referenced by guide/06-macros.md.

Policy

// This policy calls `is_small` (a Cedar macro) and `once` (a temporal
// condition macro) that are DEFINED in the attached macro LIBRARY
// (macros.dw, supplied via --macros), not redeclared here.
@id("sell_small_recent_approval")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when {
    is_small(context.input.shares)
    && temporal {
        once(1h, Drupe::Action::"ApproveSale"::request{
            input.stock: context.input.stock
        })
    }
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 500, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 500, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@5000 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY
@5000 (time point 3): DENY

Macros

// Shareable macro library, attached to a schema via --macros. These
// `def` definitions are merged into every policy set lowered against the
// schema, so policies can call `once` and `is_small` without redeclaring
// them.
def temporal once(?w, ?s) { formerly within ?w ?s };
def cedar    is_small(?n) { ?n < 100 };

max_window_raised

Raising the temporal look-back cap. The event schema’s default cap on any within window is 24h; a max_window = <interval> directive at the top of the event schema changes it. Here the cap is raised to 30d, which is what lets the policy’s formerly within 7d validate — under the default 24h cap a 7-day window is a max_window validation error.

The event schema is passed with --event-schema event.dwschema; the max_window = 30d line must precede the event declarations.

dogwood validate policy.dw \
  --policy-schema schema.cedarschema \
  --event-schema event.dwschema

See The event schema § Capping the look-back window and Temporal expressions § Intervals and time units.

Policy

// A multi-day look-back: permit a Read only if the same user successfully
// logged in within the last 7 days. A `formerly within 7d` window exceeds the
// 24h default cap, so this policy validates ONLY because the event schema
// raises the cap to 30d (see event.dwschema). Under the default event schema
// this would be a `max_window` validation error.
@id("read_after_recent_login_7d")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 7d Drupe::Action::"Login"::response{
        input.user: context.input.user
    }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {
    result: Bool
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Event Schema

// This event schema raises the temporal look-back cap from the 24h default to
// 30 days with a `max_window` directive. Without it, the policy's
// `formerly within 7d` would be a validation error (7d > the 24h default);
// with the cap raised to 30d, a 7d window is well within bounds.
//
// The directive must come first, before any event declaration. The event
// kinds are the conventional request/response convention (the default
// shape), spelled out here only because supplying any `--event-schema` opts
// out of the built-in default.
max_window = 30d

decision event <A>::request {
    ...inputs(A),
    callerPrincipal: principalType(A),
    callerResource:  resourceType(A),
    requestId:       String,
}

event <A>::response {
    ...inputs(A),
    ...outputs(A),
    callerPrincipal: principalType(A),
    callerResource:  resourceType(A),
    requestId:       String,
}

permit_read_anyone

The simplest useful rule: permit the Read action for any principal on any resource, with no when clause (Step 2 of the getting-started tour). A pure-Cedar rule with no history dependence, checked against the tour’s own minimal Login/Read Drupe schema (schema.cedarschema).

The trace shows both outcomes:

  • @0 — alice’s Login (a history-only occurrence here; the policy gates Read, not Login, so no permit matches → deny).
  • @10 — alice’s Readallow (rule 0 matches; bare principal/resource mean “any”).
  • @20 — bob’s Readallow (“anyone” really means any principal).

Referenced by guide/01-getting-started.md.

Policy

// Permit Read for anyone, on any resource. No `when` clause means no extra
// condition: this rule applies whenever its scope matches (the Read action).
// A pure-Cedar rule with no history dependence.
@id("permit_read_anyone")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
);

Schema

namespace Drupe {
  type LoginInput = { user: String };
  type ReadInput = { user: String };
  entity Gateway;
  entity OAuthUser = { id: String } tags String;
  action "Login" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: LoginInput }
  };
  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: ReadInput }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Login"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Read"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { user: "bob" }) Drupe::Action::"Read"::request(input: { user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): ALLOW  [rules: 0]

principal_is_oauth

Demonstrates the principal is Type entity-type scope constraint: the policy only applies when the principal is an Drupe::OAuthUser entity (and the action is GetStockInfo).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `is Type` scope test: principal must be an OAuthUser entity.
@id("oauth_get_stock")
permit (
    principal is Drupe::OAuthUser,
    action == Drupe::Action::"GetStockInfo",
    resource
);

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

provider_allowed_or_short

Disjunction and parentheses over two information providers: permit Read when the document is EITHER explicitly allowlisted (Lists::Allowed) OR shorter than 4 characters (Strings::Length) — (A || B) — showing the || operator and parentheses in a when body across two different providers.

The trace exercises both branches and both verdicts:

  • @0readme is on the allowlist → ALLOW (left branch).
  • @10hi is 2 chars, so length < 4ALLOW (right branch).
  • @20longfilename is neither allowlisted nor short → DENY.
  • @30manifest is on the allowlist → ALLOW (left branch).

The two Rhai providers are declared in providers.json. Because the dogwood CLI parses declarations with ProviderDeclarations::from_json (which does not resolve scriptFile), the script bodies are inlined into providers.json via the script field. allowed.rhai and length.rhai are kept alongside as the readable source of those inlined bodies.

Referenced by guide/05-information-providers.md.

Policy

// Permit Read when the document is EITHER explicitly allowlisted OR short
// enough (A || B) -- showing the || operator and parentheses in a when body,
// over two different providers.
@id("read_allowed_or_short")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    (Lists::Allowed(context.input.document).allowed == true
     || Strings::Length(context.input.document).length < 4)
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "readme" }) Drupe::Action::"Read"::request(input: { document: "readme" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "hi" }) Drupe::Action::"Read"::request(input: { document: "hi" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "longfilename" }) Drupe::Action::"Read"::request(input: { document: "longfilename" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "manifest" }) Drupe::Action::"Read"::request(input: { document: "manifest" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY
@30 (time point 3): ALLOW  [rules: 0]

Provider Declarations

{
  "availableProviders": {
    "Lists::Allowed": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "allowed": {
            "paramType": "bool"
          }
        },
        "required": [
          "allowed"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ allowed: false };\n    }\n\n    let allow = text == \"readme\" || text == \"manifest\";\n    #{ allowed: allow }\n}\n"
      }
    },
    "Strings::Length": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "length": {
            "paramType": "integer"
          }
        },
        "required": [
          "length"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ length: -1 };\n    }\n\n    #{ length: text.len() }\n}\n"
      }
    }
  }
}

provider_digitcount_forbid

A provider gating a forbid rule, alongside a catch-all permit – showing that a provider call is orthogonal to the rule effect. The same Strings::DigitCount(context.input.document).count >= 2 atom that permits Read in provider_digitcount_operator_ge here forbids it, so the verdicts are the exact inverse:

@id("forbid_digits")
forbid (...) when { Strings::DigitCount(context.input.document).count >= 2 };
@id("permit_read")
permit (...);   // catch-all: allow Read by default; the forbid overrides it

Strings::DigitCount(text) -> { count: Long } counts [0-9] matches via the regex_count host function (see digits.rhai). The provider script is inlined into providers.json because the CLI reads that file as text and does not resolve scriptFile references; digits.rhai is kept as the readable source.

The trace over four documents (Cedar’s forbid overrides the catch-all permit):

  • @0"abc" (0 digits) → ALLOW
  • @10"a1b" (1 digit) → ALLOW
  • @20"a1b2" (2 digits) → DENY
  • @30"12345" (5 digits) → DENY

Referenced by guide/05-information-providers.md.

Policy

// Forbid Read when the document contains 2 or more digits, as counted by the
// Strings::DigitCount provider. Shows that a provider call is orthogonal to the
// rule effect -- it works the same under forbid as under permit -- with a
// catch-all permit alongside.
@id("forbid_digits")
forbid (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Strings::DigitCount(context.input.document).count >= 2
};

@id("permit_read")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
);

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "abc" }) Drupe::Action::"Read"::request(input: { document: "abc" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b" }) Drupe::Action::"Read"::request(input: { document: "a1b" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b2" }) Drupe::Action::"Read"::request(input: { document: "a1b2" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "12345" }) Drupe::Action::"Read"::request(input: { document: "12345" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): ALLOW  [rules: 1]
@10 (time point 1): ALLOW  [rules: 1]
@20 (time point 2): DENY  [rules: 0]
@30 (time point 3): DENY  [rules: 0]

Provider Declarations

{
  "availableProviders": {
    "Strings::DigitCount": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "count": {
            "paramType": "integer"
          }
        },
        "required": [
          "count"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// Strings::DigitCount(text) -> { count: Long }.\n// Counts the digit characters in the document via the regex_count host\n// function (non-overlapping matches of [0-9]).\n// (Kept in sync with digits.rhai; inlined here because the CLI reads\n// providers.json as text and does not resolve scriptFile references.)\nfn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ count: -1 };\n    }\n\n    #{ count: regex_count(\"[0-9]\", text) }\n}\n"
      }
    }
  }
}

provider_digitcount_operator_ge

The operator-form comparison example (>= on an integer provider output), promoted from a guardrail fragment to a standalone permit. Permit Read only when the document contains two or more digits, as counted by the Strings::DigitCount provider:

Strings::DigitCount(context.input.document).count >= 2

Strings::DigitCount(text) -> { count: Long } counts [0-9] matches via the regex_count host function (see digits.rhai). The provider script is inlined into providers.json because the CLI reads that file as text and does not resolve scriptFile references; digits.rhai is kept as the readable source.

The trace over four documents:

  • @0"abc" (0 digits) → DENY
  • @10"a1b" (1 digit) → DENY
  • @20"a1b2" (2 digits) → ALLOW
  • @30"12345" (5 digits) → ALLOW

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the document contains 2 or more digits, as counted by
// the Strings::DigitCount provider. Demonstrates the OPERATOR comparison form
// (>=) on an integer provider output.
@id("read_digits_gte_two")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Strings::DigitCount(context.input.document).count >= 2
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "abc" }) Drupe::Action::"Read"::request(input: { document: "abc" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b" }) Drupe::Action::"Read"::request(input: { document: "a1b" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b2" }) Drupe::Action::"Read"::request(input: { document: "a1b2" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "12345" }) Drupe::Action::"Read"::request(input: { document: "12345" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@20 (time point 2): ALLOW  [rules: 0]
@30 (time point 3): ALLOW  [rules: 0]

Provider Declarations

{
  "availableProviders": {
    "Strings::DigitCount": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "count": {
            "paramType": "integer"
          }
        },
        "required": [
          "count"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// Strings::DigitCount(text) -> { count: Long }.\n// Counts the digit characters in the document via the regex_count host\n// function (non-overlapping matches of [0-9]).\n// (Kept in sync with digits.rhai; inlined here because the CLI reads\n// providers.json as text and does not resolve scriptFile references.)\nfn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ count: -1 };\n    }\n\n    #{ count: regex_count(\"[0-9]\", text) }\n}\n"
      }
    }
  }
}

provider_filter_set_index_decimal

The guardrail flagship shape in one atom: a set argument (["VIOLENCE", "HATE"]), an index-then-field projection (["VIOLENCE"].severityScore), and a decimal extension-method comparison (.lessThan(decimal("0.5"))), all via the Content::Filter information provider. Permit Read only when the document’s VIOLENCE severity is below 0.5.

The Content::Filter(string, set<string>) provider returns a per-category record { VIOLENCE: { severityScore: decimal }, HATE: { severityScore: decimal } }, so the policy can index into ["VIOLENCE"] and compare .severityScore. The Rhai implementation is inlined into providers.json (the CLI does not resolve a scriptFile path); filter.rhai is kept alongside for reference.

The trace shows all three cases:

  • @0"violent" scores VIOLENCE 0.90 (>= 0.5) -> DENY.
  • @10"safe" scores VIOLENCE 0.10 (< 0.5) -> ALLOW.
  • @20"hateful" scores VIOLENCE 0.10 (only HATE is high) -> ALLOW.

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the document's VIOLENCE severity is below 0.5, via the
// Content::Filter provider. Shows a SET argument (["VIOLENCE", "HATE"]), an
// INDEX projection (["VIOLENCE"]) chained with a field access (.severityScore),
// and a decimal extension-method comparison.
@id("read_low_violence")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Content::Filter(context.input.document, ["VIOLENCE", "HATE"])["VIOLENCE"].severityScore.lessThan(decimal("0.5"))
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "violent" }) Drupe::Action::"Read"::request(input: { document: "violent" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "safe" }) Drupe::Action::"Read"::request(input: { document: "safe" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "hateful" }) Drupe::Action::"Read"::request(input: { document: "hateful" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): ALLOW  [rules: 0]

Provider Declarations

{
  "availableProviders": {
    "Content::Filter": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "set",
          "items": {
            "paramType": "string"
          }
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "VIOLENCE": {
            "paramType": "record",
            "fields": {
              "severityScore": {
                "paramType": "decimal"
              }
            },
            "required": [
              "severityScore"
            ]
          },
          "HATE": {
            "paramType": "record",
            "fields": {
              "severityScore": {
                "paramType": "decimal"
              }
            },
            "required": [
              "severityScore"
            ]
          }
        },
        "required": [
          "VIOLENCE",
          "HATE"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// `Content::Filter(text, categories) -> { <CATEGORY>: { severityScore } }`.\n//\n// Takes a document and a SET of category names (the second declared\n// argument, `paramType: set`, arrives as a Rhai array). Returns a record\n// keyed by category, each with a decimal `severityScore` \u2014 so the policy\n// can project `[\"VIOLENCE\"].severityScore` and compare it. Scores here are\n// a fixed lookup per keyword; a real provider would call a classifier.\nfn score_for(text, category) {\n    if text == \"violent\" && category == \"VIOLENCE\" {\n        parse_decimal(\"0.90\")\n    } else if text == \"hateful\" && category == \"HATE\" {\n        parse_decimal(\"0.90\")\n    } else {\n        parse_decimal(\"0.10\")\n    }\n}\n\nfn evaluate(text, categories) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(categories) == \"()\" {\n        return #{ VIOLENCE: #{ severityScore: parse_decimal(\"-1.0\") }, HATE: #{ severityScore: parse_decimal(\"-1.0\") } };\n    }\n\n    let out = #{};\n    for category in categories {\n        out[category] = #{ severityScore: score_for(text, category) };\n    }\n    out\n}\n"
      }
    }
  }
}

provider_int_arithmetic_trusted

A provider’s integer output used inside arithmetic, mixed under && with a plain Cedar condition on context.input — something only the unwrapped form allows (the closed guardrails { … } grammar cannot express it).

Strings::DigitCount(context.input.document).count counts [0-9] characters in the document, and the guard is “trusted and at most two digits” (count + 1 <= 3count <= 2):

trusteddocumentdigitsverdict
trueab0ALLOW
truea1b22ALLOW
truea1b2c33DENY
falseab0DENY

The Strings::DigitCount provider is declared in providers.json. Because the dogwood CLI parses providers.json without resolving external scriptFile references, the Rhai implementation (originally digits.rhai) is inlined into the script field of the declaration. digits.rhai is kept alongside for readability.

Schema, provider declaration, and Rhai script are lifted from corpus case 0010_unwrapped_mixed_with_cedar; the schema’s ReadInput carries an extra trusted: Bool field so the plain Cedar condition has something to read.

Reproduce:

dogwood validate policy.dw --policy-schema schema.cedarschema --providers providers.json
dogwood replay  policy.dw --policy-schema schema.cedarschema --providers providers.json --trace trace.log

Run with the bundle directory as the working directory.

Referenced by guide/05-information-providers.md.

Policy

// Permit Read when the request is flagged trusted AND the document has at most
// two digits (expressed as digitCount + 1 <= 3). Shows a provider's integer
// output used inside ARITHMETIC, mixed under && with a plain Cedar condition on
// context.input -- something only the unwrapped form allows.
@id("read_trusted_and_few_digits")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    context.input.trusted == true
    && Strings::DigitCount(context.input.document).count + 1 <= 3
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String,
    trusted: Bool
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "ab", trusted: true }) Drupe::Action::"Read"::request(input: { document: "ab", trusted: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b2", trusted: true }) Drupe::Action::"Read"::request(input: { document: "a1b2", trusted: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "a1b2c3", trusted: true }) Drupe::Action::"Read"::request(input: { document: "a1b2c3", trusted: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "ab", trusted: false }) Drupe::Action::"Read"::request(input: { document: "ab", trusted: false }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY
@30 (time point 3): DENY

Provider Declarations

{
  "availableProviders": {
    "Strings::DigitCount": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "count": {
            "paramType": "integer"
          }
        },
        "required": [
          "count"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// `Strings::DigitCount(text) -> { count: Long }`.\n//\n// Counts digit characters via the `regex_count` host function. In the\n// policy the integer output is used inside arithmetic (`count + 1 <= 3`),\n// which only the unwrapped form allows.\nfn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ count: -1 };\n    }\n\n    #{ count: regex_count(\"[0-9]\", text) }\n}\n"
      }
    }
  }
}

provider_matches_and_not_blocked

Two information providers combined with the boolean spine (&& and !) inside one ordinary when { ... } clause: permit Read only when the document matches an allowed-name pattern (Strings::Matches against ^[a-z]+$) and is not on the blocklist (!(Lists::Blocked(...).blocked == true)).

A when { ... } body is a boolean expression over multiple provider atoms, not just one; each provider is a plain namespaced call recognized and hoisted at lowering.

Files

  • policy.dw — the permit rule combining both providers with && and !.
  • schema.cedarschema — the minimal Drupe action schema (has Read with ReadInput = { document: String }), lifted from provider_only/corpus/0004_two_providers_and_not.
  • providers.json — declares Strings::Matches and Lists::Blocked. Each Rhai body is inlined in the script field (rather than referenced via scriptFile) because the dogwood CLI reads --providers as text with ProviderDeclarations::from_json, which does not resolve external scriptFile references at replay time.
  • matches.rhai — the Strings::Matches body kept as a readable standalone source (lifted from the corpus).
  • blocked.rhai — the Lists::Blocked body kept as a readable standalone source (a tiny hard-coded denylist: "evil", "badword").
  • trace.log — three Read events; see verdicts below.
  • expected.out — captured from the real dogwood replay run.

Verdicts (from dogwood replay)

  • @0document: "hello"ALLOW: matches ^[a-z]+$ and is not on the blocklist.
  • @10document: "evil"DENY: matches the pattern but is blocked; the ! rejects it.
  • @20document: "Hello"DENY: the uppercase H fails the lowercase-only pattern.

Reproduce

Run from this directory (so relative provider paths resolve):

dogwood validate policy.dw --policy-schema schema.cedarschema --providers providers.json
dogwood replay   policy.dw --policy-schema schema.cedarschema --providers providers.json --trace trace.log

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the document matches an allowed-name pattern AND is
// not on the blocklist. Two information providers combined with the boolean
// spine (&& and !) inside one ordinary when { ... } clause.
@id("read_allowed_and_not_blocked")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Strings::Matches(context.input.document, "^[a-z]+$").matched == true
    && !(Lists::Blocked(context.input.document).blocked == true)
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "hello" }) Drupe::Action::"Read"::request(input: { document: "hello" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "evil" }) Drupe::Action::"Read"::request(input: { document: "evil" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "Hello" }) Drupe::Action::"Read"::request(input: { document: "Hello" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): DENY
@20 (time point 2): DENY

Provider Declarations

{
  "availableProviders": {
    "Strings::Matches": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "matched": {
            "paramType": "bool"
          }
        },
        "required": [
          "matched"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text, pattern) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(pattern) == \"()\" {\n        return #{ matched: false };\n    }\n\n    #{ matched: regex_is_match(pattern, text) }\n}\n"
      }
    },
    "Lists::Blocked": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "blocked": {
            "paramType": "bool"
          }
        },
        "required": [
          "blocked"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ blocked: false };\n    }\n\n    let denylist = [\"evil\", \"badword\"];\n    #{ blocked: denylist.contains(text) }\n}\n"
      }
    }
  }
}

provider_principal_id_allowlist

Permit Read only when the requesting principal is on the provider’s allowlist. The distinctive shape is the provider argument: Access::Allowed is passed principal.id — an attribute path rooted at principal, not context. Provider arguments are resolved pre-Cedar against the request event; principal / resource roots resolve to the request scope entity, and a trailing .id projects that entity’s id.

allowed.rhai stands in for a membership lookup with a one-name allowlist (alice). Because the CLI loads providers.json via from_json (text only, scriptFile is not resolved), the script is inlined into providers.json as implementation.script; allowed.rhai is kept alongside for reference.

The trace has two decision points:

tpprincipalallowedverdict
0alicetrueALLOW
1malloryfalseDENY

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the requesting principal is on the provider's
// allowlist. The distinctive shape is the ARGUMENT: the provider is passed
// principal.id -- an attribute path rooted at principal, not context. Provider
// arguments are resolved pre-Cedar against the request event, and principal /
// resource roots resolve to the request scope entity (trailing .id projects
// the entity's id).
@id("read_principal_allowed")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Access::Allowed(principal.id).allowed == true
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(document: "hello") Drupe::Action::"Read"::request(callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", document: "hello", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"mallory", resource: Drupe::Gateway::"gw1") request_context(document: "hello") Drupe::Action::"Read"::request(callerPrincipal: Drupe::OAuthUser::"mallory", callerResource: Drupe::Gateway::"gw1", document: "hello", requestId: "u2")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): DENY

Provider Declarations

{
  "availableProviders": {
    "Access::Allowed": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "allowed": {
            "paramType": "bool"
          }
        },
        "required": [
          "allowed"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// Access::Allowed(principalId) -> { allowed: bool }. A tiny hardcoded\n// allowlist stands in for a real membership lookup: alice is allowed,\n// everyone else is not. Mirrors allowed.rhai (inlined because the CLI loads\n// providers.json via from_json, which does not resolve scriptFile).\nfn evaluate(principal_id) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(principal_id) == \"()\" {\n        return #{ allowed: false };\n    }\n\n    let allowed = principal_id == \"alice\";\n    #{ allowed: allowed }\n}\n"
      }
    }
  }
}

provider_regex_analyze_fields

Several calls to the same Regex::Analyze provider, each projecting a different output field and comparing it, combined with && as plain Cedar (the unwrapped provider form — no guardrails { … } block). Permit Read only when the document:

  1. starts with an uppercase letter — ^[A-Z].is_match == true
  2. has at least three digit characters — [0-9].count >= 3
  3. whose first run of digits is exactly 42[0-9]+.first_match == "42"

Regex::Analyze(string, string) -> { is_match: bool, first_match: string, count: integer } is declared in providers.json. Its Rhai script is inlined into providers.json (as the "script" field) rather than referenced via scriptFile, because the dogwood CLI parses the declarations text directly and does not resolve external scriptFile references.

Trace

Each denial isolates one failing condition:

docstarts A–Z≥3 digitsfirst run == “42”verdict
Abc42x999yesyes (5)yesALLOW
abc42x999noyesyesDENY
A42yesno (2)yesDENY
A99942yesyesno (99942)DENY

Run from this directory so the schema/providers paths resolve:

dogwood validate policy.dw --policy-schema schema.cedarschema --providers providers.json
dogwood replay   policy.dw --policy-schema schema.cedarschema --providers providers.json --trace trace.log

Lifted from corpus case 0005_regex_operations.

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the document starts with an uppercase letter, has at
// least three digit characters, and its first run of digits is exactly "42".
// Three calls to one Regex::Analyze provider, each projecting a different
// output field, combined with && as plain Cedar.
@id("read_regex_analyze")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Regex::Analyze(context.input.document, "^[A-Z]").is_match == true
    && Regex::Analyze(context.input.document, "[0-9]").count >= 3
    && Regex::Analyze(context.input.document, "[0-9]+").first_match == "42"
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "Abc42x999" }) Drupe::Action::"Read"::request(input: { document: "Abc42x999" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "abc42x999" }) Drupe::Action::"Read"::request(input: { document: "abc42x999" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "A42" }) Drupe::Action::"Read"::request(input: { document: "A42" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "A99942" }) Drupe::Action::"Read"::request(input: { document: "A99942" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): DENY
@20 (time point 2): DENY
@30 (time point 3): DENY

Provider Declarations

{
  "availableProviders": {
    "Regex::Analyze": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "is_match": {
            "paramType": "bool"
          },
          "first_match": {
            "paramType": "string"
          },
          "count": {
            "paramType": "integer"
          }
        },
        "required": [
          "is_match",
          "first_match",
          "count"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// `Regex::Analyze(text, pattern) -> { is_match, first_match, count }`.\n//\n// One provider that exercises ALL THREE regex host functions Dogwood\n// exposes to provider scripts:\n//   * regex_is_match(pattern, text) -> bool   \u2014 does the pattern match?\n//   * regex_find(pattern, text)     -> string \u2014 the first match (\"\" if none)\n//   * regex_count(pattern, text)    -> i64     \u2014 number of matches\n//\n// The output record surfaces each result so the policy can project and\n// compare them independently.\nfn evaluate(text, pattern) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(pattern) == \"()\" {\n        return #{ is_match: false, first_match: \"\", count: -1 };\n    }\n\n    #{\n        is_match:    regex_is_match(pattern, text),\n        first_match: regex_find(pattern, text),\n        count:       regex_count(pattern, text),\n    }\n}\n"
      }
    }
  }
}

provider_regex_matches_uppercase

The canonical worked information-provider example: permit Read only when the requested document is all-uppercase letters (matches ^[A-Z]+$), as decided by the Strings::Matches information provider (a regex matcher implemented in matches.rhai). The provider call sits in an ordinary when { ... }; the projection (.matched) and comparison (== true) are plain Cedar.

Files:

  • policy.dw — the policy.
  • schema.cedarschema — the base Cedar schema (minimal Drupe Read action).
  • providers.json — declares Strings::Matches(string, string) -> { matched: Bool }.
  • matches.rhai — the provider implementation (fn evaluate(text, pattern)).
  • trace.log — three Read requests: ABC, abc, AB12.
  • expected.out — the replay verdict stream.

The trace shows:

  • @0Read of "ABC" (all uppercase) → ALLOW.
  • @10Read of "abc" (lowercase) → DENY.
  • @20Read of "AB12" (digits) → DENY.

Running it

dogwood validate policy.dw --policy-schema schema.cedarschema --providers providers.json
dogwood replay   policy.dw --policy-schema schema.cedarschema --providers providers.json --trace trace.log

Note on providers.json: the CLI’s --providers flag parses the declarations with from_json, which does not resolve a scriptFile reference. So for the CLI path the Rhai body is inlined under implementation.script; the equivalent matches.rhai file is kept alongside for reference (and validation works either way).

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the requested document is all-uppercase letters,
// as decided by the Strings::Matches information provider (a regex matcher,
// implemented in matches.rhai). The provider call sits in an ordinary
// when { ... }; the projection (.matched) and comparison (== true) are
// plain Cedar.
@id("read_uppercase_only")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Strings::Matches(context.input.document, "^[A-Z]+$").matched == true
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "ABC" }) Drupe::Action::"Read"::request(input: { document: "ABC" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "abc" }) Drupe::Action::"Read"::request(input: { document: "abc" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "AB12" }) Drupe::Action::"Read"::request(input: { document: "AB12" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): DENY
@20 (time point 2): DENY

Provider Declarations

{
  "availableProviders": {
    "Strings::Matches": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "matched": {
            "paramType": "bool"
          }
        },
        "required": [
          "matched"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "// Information-provider implementation for `Strings::Matches`.\n//\n// A provider script defines `fn evaluate(arg0, arg1, \u2026)` whose parameters\n// correspond positionally to the declared `argumentTypes` in\n// providers.json \u2014 here `(text, pattern)`. It returns a record whose\n// shape matches the declared `outputType` (`{ matched: Bool }`).\n//\n// The engine runs this in a locked-down Rhai engine: no file/network/\n// process access, only the host functions Dogwood registers. Here we use\n// `regex_is_match(pattern, text) -> bool`.\nfn evaluate(text, pattern) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(pattern) == \"()\" {\n        return #{ matched: false };\n    }\n\n    #{ matched: regex_is_match(pattern, text) }\n}\n"
      }
    }
  }
}

provider_risk_decimal_method

The decimal-extension-method comparison form. The Content::Risk provider returns a record whose severityScore field is a Cedar decimal, so the policy compares it with the decimal extension method .lessThan(decimal("0.5")) rather than the bare < operator (Cedar decimals are not comparable with <).

providers.json declares Content::Risk(string) -> { severityScore: decimal }. The Rhai script is inlined into providers.json (as script) rather than referenced via scriptFile, because the dogwood CLI reads the providers file as raw text and does not resolve scriptFile paths. The equivalent standalone risk.rhai is included for readability. It returns a fixed score per keyword: "safe" -> 0.10, "spam" -> 0.80, anything else -> 0.50.

The trace shows all three cases:

  • @0document: "safe" (score 0.10, below 0.5) -> ALLOW.
  • @10document: "spam" (score 0.80) -> DENY.
  • @20document: "other" (score 0.50, not less than 0.5) -> DENY.

Referenced by guide/05-information-providers.md.

Policy

// Permit Read only when the content-risk score is below 0.5, as decided by the
// Content::Risk provider. The output is a Cedar decimal, so the comparison uses
// the decimal extension method .lessThan(decimal("...")) rather than <.
@id("read_low_risk")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when {
    Content::Risk(context.input.document).severityScore.lessThan(decimal("0.5"))
};

Schema

namespace Drupe {
  type ReadInput = {
    document: String
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  } tags String;

  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ReadInput
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "safe" }) Drupe::Action::"Read"::request(input: { document: "safe" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "spam" }) Drupe::Action::"Read"::request(input: { document: "spam" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "other" }) Drupe::Action::"Read"::request(input: { document: "other" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@10 (time point 1): DENY
@20 (time point 2): DENY

Provider Declarations

{
  "availableProviders": {
    "Content::Risk": {
      "argumentTypes": [
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "severityScore": {
            "paramType": "decimal"
          }
        },
        "required": [
          "severityScore"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" {\n        return #{ severityScore: parse_decimal(\"-1.0\") };\n    }\n\n    let score = if text == \"safe\" {\n        parse_decimal(\"0.10\")\n    } else if text == \"spam\" {\n        parse_decimal(\"0.80\")\n    } else {\n        parse_decimal(\"0.50\")\n    };\n    #{ severityScore: score }\n}\n"
      }
    }
  }
}

read_after_login

The history-dependent version of the getting-started tour (Step 4 — a decision that depends on history, which a single Cedar request cannot see): permit Read only if the same user successfully logged in within the last hour. The when temporal { … } clause reads the accumulated event history, and the { input.user: context.input.user } pin correlates the past Login response’s user with the current Read request’s user.

The trace shows both an allow and a deny:

  • @0Login request by alice. The policy gates Read, not Login, so no permit matches → deny (the login request still lands in the history).
  • @5Login response (history-only; records that the login succeeded).
  • @10 — alice reads, 10s after the login → allow (a matching login response is inside the 1h window).
  • @7200 — alice reads again, two hours later; the only login has expired (7200s > 3600s) → deny.

Referenced by guide/01-getting-started.md.

Policy

// Permit Read only if the same user successfully logged in within the last
// hour. The `when temporal { … }` clause reads the accumulated event history;
// the `{ input.user: context.input.user }` pin correlates the past login's
// user with the current request's user. This is what makes the authorizer
// stateful.
@id("read_after_login")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
};

Schema

namespace Drupe {
  type LoginInput = { user: String };
  type ReadInput = { user: String };
  entity Gateway;
  entity OAuthUser = { id: String } tags String;
  action "Login" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: LoginInput }
  };
  action "Read" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: { input: ReadInput }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Login"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@5 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Login"::response(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Read"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@7200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Read"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@7200 (time point 2): DENY

read_after_login_success

Response predicate + output-field filter: permit a Read only if the same user had a Login that succeeded (output.result: true) within the last hour. A response predicate reads output.* fields and matches the result event, not the request — so it can gate on the login’s outcome.

The trace shows both verdicts:

  • @100 — alice reads doc1, 100s after a Login::response with output.result: true for alice → ALLOW (a matching successful login is within the window).
  • @300 — bob reads doc2, after a Login::response with output.result: false for bob → DENY (the login failed, so the output.result: true filter rejects it).

Referenced by guide/04-temporal-expressions.md.

Policy

// A response predicate reads output.* fields. Permit a Read only if the
// same user's Login SUCCEEDED (output.result: true) within the last hour --
// a response predicate matches the result event, not the request event.
@id("read_after_login_success")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::response{
        input.user: context.input.user,
        output.result: true
    }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, output: { result: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "bob" }, output: { result: false }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@300 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "bob" }) Drupe::Action::"Read"::request(input: { document: "doc2", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@100 (time point 0): ALLOW  [rules: 0]
@300 (time point 1): DENY

read_heartbeat_since_login_30s

since with a short (seconds) window: the anchor must be recent enough, or the streak does not count. Permit a Read only if a Heartbeat by the same user has held continuously since a Login anchor within the last 30 seconds (since within 30s, with the user pinned via input.user: context.input.user).

The trace (lifted from corpus 0184_since_window_anchor_too_old) shows the anchor aging out of the 30s window:

  • @0 — alice logs in (the since anchor).
  • @10, @20, @30 — alice sends Heartbeats, keeping the streak alive.
  • @32 — alice Reads. The only Login is now 32s old, past the 30s window, so the anchor no longer counts → deny.
  • @50 — another Heartbeat, but still no fresh Login.
  • @52 — alice Reads again; the anchor is 52s old → deny.

Every timepoint denies. Non-Read events (Login, Heartbeat) deny because the permit only applies to action == Read; the Read events deny because the Login anchor has aged past the 30s window. This all-deny outcome is the point the guide illustrates: a short since window makes the anchor’s freshness the deciding factor.

Referenced by guide/04-temporal-expressions.md.

Policy

// `since` with a short (seconds) window: the anchor must be within 30s. Permit
// a Read only if a Heartbeat by the same user has held continuously since a
// Login anchor within the last 30 seconds.
@id("read_heartbeat_since_login_30s")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    Drupe::Action::"Heartbeat"::request{ input.user: context.input.user }
    since within 30s
    Drupe::Action::"Login"::request{ input.user: context.input.user }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String,
    user: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@32 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")
@50 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Heartbeat"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u6")
@52 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u7")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@20 (time point 2): DENY
@30 (time point 3): DENY
@32 (time point 4): DENY
@50 (time point 5): DENY
@52 (time point 6): DENY

read_login_not_logout

The accepted “A but not B” idiom with restrictor-first conjunct ordering. A guarded negation (!B) restricts nothing, so it is legal only when a preceding conjunct in the same && chain already range-restricts its free variables. Here the restrictor comes first:

formerly within 1h Login{ input.user: context.input.user }   // restrictor
&& !Logout{ input.user: context.input.user }                 // guarded negation, after

Permit a Read only if the same user logged in within the last hour and has not since logged out. (Reversing the two conjuncts — negation before its restrictor — is the rejected form.)

The !Logout conjunct is an anti-join at the decision timepoint: the decision event is always a Read, so what drives the verdict in this trace is the formerly within 1h Login restrictor.

The trace shows both an allow and a deny:

  • @0 — alice logs in (a Login event; no Read permit applies) -> deny.
  • @100 — alice reads 100s after login (login still inside the 1h window) -> allow.
  • @4000 — alice reads 4000s after login (login now outside the 1h window) -> deny.
  • @4100 — bob reads with no prior login -> deny.

Referenced by guide/04-temporal-expressions.md (line 525, the accepted restrictor-first example).

Policy

// The accepted "A but not B" idiom with restrictor-first conjunct ordering: a
// guarded negation must come AFTER a conjunct that range-restricts its
// variables. Permit a Read only if the same user successfully logged in within
// the last hour AND has not since successfully logged out.
@id("read_login_not_logout")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
    && !Drupe::Action::"Logout"::response{ input.user: context.input.user }
};

Schema

namespace Drupe {
  type ComputeInput = {
    user: String
  };

  type ComputeOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type LogoutInput = {
    user: String
  };

  type LogoutOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Compute" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ComputeInput,
      output?: ComputeOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Logout" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LogoutInput,
      output?: LogoutOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", server: "s1" }) Drupe::Action::"Login"::request(input: { user: "alice", server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@5 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", server: "s1" }) Drupe::Action::"Login"::response(input: { user: "alice", server: "s1" }, output: { result: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", document: "doc1" }) Drupe::Action::"Read"::request(input: { user: "alice", document: "doc1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@4000 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice", document: "doc4" }) Drupe::Action::"Read"::request(input: { user: "alice", document: "doc4" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@4100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { user: "bob", document: "doc2" }) Drupe::Action::"Read"::request(input: { user: "bob", document: "doc2" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@4000 (time point 2): DENY
@4100 (time point 3): DENY

read_prev_compute_open_session

A top-level previous && (open-session since) chain: permit a Read only if the same user computed at the immediately preceding timepoint (previous within 1h ... Compute, user pinned via input.user: context.input.user) AND that user has an open session — no Logout since their Login within 24h (!Logout since within 24h Login).

The since-clause is parenthesized so that && (the loosest-binding operator) groups the two conjuncts, rather than the since swallowing the previous conjunct. This demonstrates combining previous with a negated-left since in one top-level temporal chain.

Referenced by guide/04-temporal-expressions.md.

Policy

// A top-level `previous && (open-session)` chain. Permit a Read only if the
// same user computed at the immediately preceding timepoint AND has an open
// session -- no Logout since their Login within 24h. The since-clause is
// parenthesized so && (loosest) groups the two conjuncts, not the since.
@id("read_prev_compute_open_session")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    previous within 1h Drupe::Action::"Compute"::request{ input.user: context.input.user }
    && (!Drupe::Action::"Logout"::request{ input.user: context.input.user }
        since within 24h
        Drupe::Action::"Login"::request{ input.user: context.input.user })
};

Schema

namespace Drupe {
  type ComputeInput = {
    user: String
  };

  type ComputeOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type LogoutInput = {
    user: String
  };

  type LogoutOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Compute" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ComputeInput,
      output?: ComputeOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Logout" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LogoutInput,
      output?: LogoutOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

read_prev_login

previous within 1h: permit a Read only if the immediately preceding timepoint (i - 1) was a matching Login by the same user, and that event was within the last hour. Unlike formerly, previous looks only at the single event directly before the decision point, not the whole window. At the first timepoint previous is always false.

The trace shows both outcomes:

  • @0Login by alice (a history-only event here; no Read permit applies, so the decision is a deny).
  • @100 — alice reads doc1, immediately after the login → allow (the directly preceding event was a matching Login within the window).
  • @200 — alice reads doc2, but the immediately preceding event was a Read, not a Logindeny (previous fails even though a login exists earlier in history).

Referenced by guide/04-temporal-expressions.md.

Policy

// `previous` looks ONLY at the immediately preceding timepoint (i-1), not the
// whole window. Permit a Read only if the same user logged in at the event
// directly before this one, and that event was within the last hour. At the
// first timepoint `previous` is always false.
@id("read_after_prev_login")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    previous within 1h Drupe::Action::"Login"::request{ input.user: context.input.user }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Login"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

read_prev_login_success

previous with a response predicate and an output-field filter. Permit a Read only if the immediately preceding event (within 1h) was the same user’s successful Login — i.e. a Login::response whose output.result is true. Because previous looks only at timepoint i - 1, at timepoint 0 there is no predecessor, so the rule can never fire there.

The trace shows all three behaviors:

  • @0 — a Read at timepoint 0: no predecessor exists, so deny (the no-verdict-at-tp0 behavior).
  • @7 — a Read whose immediately preceding event (@5) is alice’s successful Login::response (output.result: true) → allow.
  • @31 — a Read whose predecessor (@30) is bob’s Login::response with output.result: false; the output-field filter rejects the failed login → deny.

Referenced by guide/04-temporal-expressions.md.

Policy

// `previous` with a response predicate and an output-field filter. Permit a
// Read only if the immediately preceding event (within 1h) was the same
// user's SUCCESSFUL Login (output.result: true). At timepoint 0 there is no
// predecessor, so the rule never fires there.
@id("read_after_prev_login_success")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    previous within 1h Drupe::Action::"Login"::response{ input.user: context.input.user, output.result: true }
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc0", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc0", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r0")
@5 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, output: { result: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "l1")
@7 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "r1")
@30 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") Drupe::Action::"Login"::response(input: { server: "s1", user: "bob" }, output: { result: false }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "l2")
@31 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "bob" }) Drupe::Action::"Read"::request(input: { document: "doc2", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "r2")

Expected Output

@0 (time point 0): DENY
@7 (time point 1): ALLOW  [rules: 0]
@31 (time point 2): DENY

read_since_login

Positive-left since within 1h: the left operand must have held continuously since the anchor. Permit a Read only if a Login by the same user has held continuously since a Login by that user within the last hour (left since within W right, the classic MFOTL left S right).

This is corpus case 0034_since_explicit, lifted verbatim.

What the trace shows

By the since semantics, left since within W right holds at the decision timepoint i only if the left operand (Login) holds at every step from just after the anchor through i itself. But the rule’s scope is action == Read, so every applicable decision point is a Read event — and a Read event is never a Login::request. The left operand therefore fails at the decision step, so the condition can never hold. This policy is structurally always-DENY — matching corpus 0034, whose reference outputs are all false. An ALLOW is not achievable for a faithful positive-left Login since Login guarding a Read; interleaving extra logins between the reads (verified against the CLI) does not change this.

The trace exercises the natural cases, all of which deny:

  • @0, @10 — two Logins by alice (history-only; the scope is Read, so no permit applies → deny).
  • @12, @20 — alice Reads within an hour of her logins. The anchor (Login) is in the window, but the left operand (Login) does not hold at the Read decision step, so the since is false → deny.
  • @30 — bob Reads with no prior login of his own → deny.

This is the intended contrast with the “open session” idiom (!left since …, corpus 0418/0547), where a negated left holds at the read step and the policy can allow.

Referenced by guide/04-temporal-expressions.md.

Policy

// `since` is a suffix on a conjunct: `left since within W right` holds when an
// anchor `right` occurred in the window and `left` held continuously from just
// after it through now. Permit a Read only if a Login by the same user has
// held continuously since a Login by that user within the last hour.
@id("read_since_login")
permit (
    principal,
    action == Drupe::Action::"Read",
    resource
)
when temporal {
    Drupe::Action::"Login"::request{ input.user: context.input.user }
    since within 1h
    Drupe::Action::"Login"::request{ input.user: context.input.user }
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@12 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@30 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "bob" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@12 (time point 2): DENY
@20 (time point 3): DENY
@30 (time point 4): DENY

sell_after_2024_datetime

A single datetime literal compared with an ordinary comparison operator (>) — no datetime operator arithmetic. Permits SellShares only when the request’s context.system.now is later than datetime("2024-01-01T00:00:00Z").

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// A single datetime literal compared with an ordinary operator.
@id("sell_after_2024")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context.system.now > datetime("2024-01-01T00:00:00Z") };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_after_approval_valid_ticker

Two Dogwood clause forms combined on a single rule:

  • a when temporal { … } marker — a matching ApproveSale for the same stock must precede this SellShares within the last hour (formerly within 1h, with the stock pinned via input.stock: context.input.stock); and
  • a when guardrails { … } provider check — the ticker must match an uppercase regex, decided by the Strings::Matches information provider. The guardrails tag is transparent sugar for a bare when; Strings::Matches is a plain provider call recognized and hoisted at lowering.

Both clauses must hold for the rule to permit.

Files

  • policy.dw — the combined-clause permit rule.
  • schema.cedarschema — the reusable Drupe action schema (has SellShares and ApproveSale), lifted from the write_after_read example.
  • providers.json — declares Strings::Matches. The Rhai body is inlined in the script field (rather than referenced via scriptFile) because the dogwood CLI reads --providers as text with ProviderDeclarations::from_json, which does not resolve external scriptFile references at replay time.
  • matches.rhai — the same provider body kept as a readable standalone source (lifted from provider_only/corpus/0001_regex_matches_uppercase).
  • trace.log — five events; see verdicts below.
  • expected.out — captured from the real dogwood replay run.

Verdicts (from dogwood replay)

  • @0 — alice ApproveSale AMZN → DENY (history-only; no SellShares permit applies).
  • @100 — alice SellShares AMZN → ALLOW: temporal passes (AMZN approval at @0 is within 1h) and guardrails passes (AMZN matches ^[A-Z]+$).
  • @200 — bob ApproveSale goog → DENY (history-only).
  • @300 — bob SellShares goog → DENY: temporal passes (goog approval at @200) but guardrails failsgoog is lowercase. Isolates the guardrails clause.
  • @5000 — carol SellShares MSFT → DENY: guardrails passes (MSFT is uppercase) but temporal fails — no prior approval. Isolates the temporal clause.

Reproduce

Run from this directory (so relative provider paths resolve):

dogwood validate policy.dw --policy-schema schema.cedarschema --providers providers.json
dogwood replay   policy.dw --policy-schema schema.cedarschema --providers providers.json --trace trace.log

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Combined clauses on one rule: a `temporal` marker clause (a matching
// ApproveSale for the same stock must precede this sell within the last hour)
// AND a `guardrails` provider check (the ticker matches an uppercase regex).
// The guardrails tag is sugar for a bare when; Strings::Matches is a provider.
@id("sell_after_approval_valid_ticker")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when temporal {
    formerly within 1h Drupe::Action::"ApproveSale"::response{ input.stock: context.input.stock }
}
when guardrails {
    Strings::Matches(context.input.stock, "^[A-Z]+$").matched == true
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@50 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::response(input: { shares: 5, stock: "AMZN" }, output: { approved: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"ApproveSale"::request(input: { shares: 3, stock: "goog" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@250 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"ApproveSale"::response(input: { shares: 3, stock: "goog" }, output: { approved: true }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@300 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"SellShares"::request(input: { shares: 3, stock: "goog" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@5000 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 2, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 2, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY
@300 (time point 3): DENY
@5000 (time point 4): DENY

Provider Declarations

{
  "availableProviders": {
    "Strings::Matches": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "matched": {
            "paramType": "bool"
          }
        },
        "required": [
          "matched"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text, pattern) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(pattern) == \"()\" {\n        return #{ matched: false };\n    }\n\n    #{ matched: regex_is_match(pattern, text) }\n}\n"
      }
    }
  }
}

sell_comparison_chain

Several comparison operators chained in a single && conjunction on a Long. Comparisons chain and fold left, so a range check plus an inequality (shares >= 1 && shares <= 1000 && shares != 777) all live in one when block over context.input.shares (a Long, which supports the full ordered set of comparison operators).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Chained comparisons folded into one && conjunction (range + inequality).
@id("sell_range_not_777")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    context.input.shares >= 1
    && context.input.shares <= 1000
    && context.input.shares != 777
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_datetime_window

Datetime ordered comparison expressing a calendar-year time window: permit SellShares only when context.system.now falls within calendar year 2025 (>= 2025-01-01T00:00:00Z and < 2026-01-01T00:00:00Z). Because datetime supports the full ordered set, the window is expressed directly with >= and < — no temporal block is needed.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Datetime supports the full ordered set, so a time window is direct.
@id("sell_in_2025_window")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    context.system.now >= datetime("2025-01-01T00:00:00Z")
    && context.system.now <  datetime("2026-01-01T00:00:00Z")
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_like_a_prefix

The like operator matches a string against a wildcard pattern, where * matches any number of characters. This policy permits SellShares only when context.input.stock starts with the letter A (like "A*").

The trace shows both outcomes:

  • @0 — alice sells AMZN (starts with A) → allow.
  • @100 — bob sells AAPL (starts with A) → allow.
  • @200 — carol sells MSFT (does not start with A) → deny.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `like` matches a string against a wildcard pattern: only stocks starting A.
@id("sell_a_prefixed")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.stock like "A*" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "AAPL" }) Drupe::Action::"SellShares"::request(input: { shares: 3, stock: "AAPL" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 7, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 7, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

sell_logical_grouping

Logical connectives ||, &&, and ! with parenthesized grouping to override precedence. && binds tighter than ||, so the small-or-AMZN test is wrapped in parentheses to keep it as a single disjunction before it is ANDed with the “not BLOCKED” guard: permit SellShares when the request is for fewer than 100 shares OR the stock is AMZN, AND the stock is not BLOCKED.

World: drupe (schema shared with the other Drupe examples).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Logical connectives with explicit grouping: && binds tighter than ||, so
// parenthesize to get the intended grouping.
@id("sell_small_or_amzn_not_blocked")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    (context.input.shares < 100 || context.input.stock == "AMZN")
    && !(context.input.stock == "BLOCKED")
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_nested_if_threshold

Nested if/then/else used as an operand rather than a top-level conditional: the chained conditional computes a per-stock share cap (AMZN → 10, MSFT → 50, everything else → 1000), and the <= comparison checks the request’s shares against it. Permits SellShares only when the requested share count stays under that stock’s cap.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// if/then/else nested as an operand to pick a per-stock share cap.
@id("sell_nested_if_threshold")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    context.input.shares <=
        (if context.input.stock == "AMZN" then 10
         else if context.input.stock == "MSFT" then 50
         else 1000)
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_nonzero_proceeds_decimal

Decimal supports equality only (== / !=); ordered comparison on decimals does not type-check. This policy permits SellShares only when the sale’s proceeds is not zero, using !=. The optional output attribute is guarded first with context has output before its field is read.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Decimal supports equality only (== / !=); guard the optional output first.
@id("sell_nonzero_proceeds")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context has output && context.output.proceeds != decimal("0.0") };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_not_blocked_string

String inequality (!=) — one of the two operators strings support (== and !=). This policy permits SellShares for any stock except the sentinel value "BLOCKED": when { context.input.stock != "BLOCKED" }.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// String supports == and != only: sell any stock except BLOCKED.
@id("sell_not_blocked_string")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.stock != "BLOCKED" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_not_test_tickers_like

The like string-pattern operator used under unless as a denylist idiom: a permit for SellShares is retracted whenever the requested ticker matches the TEST_* family, rejecting a whole value family in one condition.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Denylist idiom: reject a family of test tickers with `like` under `unless`.
@id("sell_not_test_tickers")
permit ( principal, action == Drupe::Action::"SellShares", resource )
unless { context.input.stock like "TEST_*" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_or_approve_action_in

action in [ ... ]: list-membership matching on the action. This rule permits a request whose action is either Drupe::Action::"SellShares" or Drupe::Action::"ApproveSale" — a compact alternative to writing two separate action == ... rules.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `action in [ ... ]`: match any of the listed actions.
@id("sell_or_approve")
permit (
    principal,
    action in [Drupe::Action::"SellShares", Drupe::Action::"ApproveSale"],
    resource
);

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_shares_eq_scope

An == scope constraint pinning the action to a specific entity reference: this rule can only apply to Drupe::Action::"SellShares". The == EntityRef form on the action dimension requires an action reference (no template slots allowed).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `== EntityRef` action scope: this rule can only apply to SellShares.
@id("sell_shares_eq_scope")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
);

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_shares_temporal_subexpr

A temporal { ... } marker used as a sub-expression inside a larger Cedar && condition, rather than as the whole when body. The rule permits SellShares when the share count clears a threshold and a temporal marker holds:

when { context.input.shares > 5 && temporal { formerly within 1h Drupe::Action::"SellShares"::request{} } }

formerly looks at [0, i] inclusive, and the bare SellShares::request{} body has no correlation pins, so the current SellShares event always self-matches the temporal half. That makes the Cedar context.input.shares > 5 conjunct the deciding factor here — which is exactly the point: the temporal marker composes as an ordinary primary expression under &&.

The trace shows both outcomes:

  • @0 — alice sells 10 shares (shares > 5, temporal holds) -> allow.
  • @100 — alice sells 3 shares (shares > 5 is false) -> deny.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// The `temporal { ... }` marker is also a primary expression, so it can
// appear inside an ordinary Cedar expression (here && with a share threshold).
@id("sell_shares_and_prior_sell")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.shares > 5 && temporal { formerly within 1h Drupe::Action::"SellShares"::request{} } };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 10, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 10, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 3, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): DENY

sell_small_only

The canonical five-part rule shape: annotation + effect + parenthesized scope triple + a single when clause + terminating semicolon. It permits SellShares only for small orders — 50 shares or fewer (context.input.shares <= 50).

The trace exercises both outcomes:

  • @0 — alice sells exactly 50 shares → allow (50 <= 50 holds).
  • @100 — bob sells 500 shares → deny (threshold exceeded).
  • @200 — carol sells 10 shares → allow.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// The canonical five-part rule shape: annotation + effect + scope + one
// when clause. Permits selling only small orders (50 shares or fewer).
@id("sell_small_only")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when { context.input.shares <= 50 };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 50, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 50, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 500, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 500, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 10, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 10, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): DENY
@200 (time point 2): ALLOW  [rules: 0]

sell_small_proceeds_decimal_method

Decimal .lessThan(...) method call — how decimals are ordered. Cedar decimals are not comparable with < / <= (those do not type-check on decimal); you must use the decimal comparison methods (lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual).

Here the policy permits SellShares only when the (optional) output’s proceeds is below decimal("0.5"). Because output is an optional context attribute, the condition guards it with context has output before projecting context.output.proceeds.

Adapted to SellSharesOutput.proceeds (a decimal) because the Drupe schema has no severityScore action field to compare against.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Decimals are ordered with the decimal comparison methods, not < / <=
// (which do not type-check on decimals). Guard the optional output first.
@id("sell_small_proceeds")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when { context has output && context.output.proceeds.lessThan(decimal("0.5")) };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_threshold_by_stock

An if / then / else used as the whole body of a when clause reads like a conditional rule: a stricter per-share cap for AMZN (<= 10) than for every other stock (<= 1000). Because if/then/else is an expression, both branches must produce the same type (here, Bool).

The trace exercises both branches, each in its allow and deny form:

  • @0 — alice sells 5 AMZN → the then branch (5 <= 10) → allow.
  • @100 — alice sells 50 AMZN → the then branch (50 <= 10) → deny.
  • @200 — bob sells 500 MSFT → the else branch (500 <= 1000) → allow.
  • @300 — bob sells 5000 MSFT → the else branch (5000 <= 1000) → deny.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// if/then/else at the top of a when reads like a conditional rule: a stricter
// share cap for AMZN than for everything else.
@id("sell_threshold_by_stock")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    if context.input.stock == "AMZN"
    then context.input.shares <= 10
    else context.input.shares <= 1000
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 50, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 50, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 500, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 500, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@300 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5000, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 5000, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): DENY
@200 (time point 2): ALLOW  [rules: 0]
@300 (time point 3): DENY

sell_two_when_small_amzn

Two when clauses stacked on one rule are implicitly conjoined: both must hold. Here SellShares is permitted only when the order is small (shares < 100) and the stock is "AMZN" — equivalent to a single when { context.input.shares < 100 && context.input.stock == "AMZN" }, but split for readability.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Two `when` clauses are conjoined: both must hold (small AND AMZN).
@id("sell_small_amzn")
permit( principal, action == Drupe::Action::"SellShares", resource )
when { context.input.shares < 100 }
when { context.input.stock == "AMZN" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_unless_huge

Basic unless { ... } clause: permit SellShares unless the order is enormous (more than 10,000 shares). An unless clause blocks the rule when its body holds, so it is exactly sugar for when { !B }.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// An `unless` clause blocks the rule when its body holds: permit selling
// unless the order is enormous.
@id("sell_unless_huge")
permit ( principal, action == Drupe::Action::"SellShares", resource )
unless {
    context.input.shares > 10000
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_when_under_100

A basic when { ... } condition clause: a when body must evaluate true for the rule to fire. Here the rule permits SellShares only when the order is a strict share threshold under 100 (context.input.shares < 100).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// A `when` clause must hold for the rule to fire: only sells under 100 shares.
@id("sell_under_100")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    context.input.shares < 100
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_when_unless_mix

Mixing when and unless clauses on a single permit rule. Because clauses are conjoined, this permit fires only when both hold: the sale is capped (context.input.shares <= 1000) and the stock is not blocked (unless { context.input.stock == "BLOCKED" }).

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Freely mix when and unless on one rule.
@id("sell_capped_not_blocked")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when   { context.input.shares <= 1000 }
unless { context.input.stock == "BLOCKED" };

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

sell_zero_proceeds_if_has

The if C has attr then ... else false idiom guards an optional output field. Here SellShares has an optional output record (output?: SellSharesOutput), so if context has output then context.output.proceeds == decimal("0.0") else false reads proceeds only when the output is present and falls back to false when it is absent — avoiding an error on the missing optional field.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// The `if C has attr then ... else false` idiom guards an optional field,
// falling back to false when the output record is absent.
@id("approve_zero_proceeds")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when {
    if context has output
    then context.output.proceeds == decimal("0.0")
    else false
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

simplest_permit

The simplest possible policy: a bare permit for GetStockInfo with no conditions. It has a scope triple (principal, action == Drupe::Action::"GetStockInfo", resource) and no when / unless clauses, so every GetStockInfo request is allowed.

Validate:

dogwood validate policy.dw --policy-schema schema.cedarschema

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// The simplest possible policy: a single permit with no further condition.
@id("get_stock_info")
permit ( principal, action == Drupe::Action::"GetStockInfo", resource );

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

submit_after_approval_injection

A def temporal macro (approved_recently) whose predicate-valued parameter ?s is refined inside the macro body with an injected field (?s{ input.status: "approved" }). The refinement forces the input.status: "approved" filter onto whatever event the caller passes; the call site never writes input.status – it supplies only Drupe::Action::"Approve"::request{ input.user: context.input.user }. The window ?w is filled at the call site with a bare interval literal (1h, no within keyword). The policy permits a Submit only if the same user was formerly Approved within 1h with status “approved”.

This is the load-bearing ?s{…} refinement-in-macro-body path.

Files

  • policy.dw – the macro definition and the permit … when temporal { approved_recently(1h, …) } rule.
  • schema.cedarschema – Cedar action schema (Approve + Submit under Drupe), lifted from macros corpus 0041_field_injection_omitted_field.
  • trace.log – five events exercising both outcomes.
  • expected.out – captured verbatim from dogwood replay.

Trace outcomes

tpeventverdictwhy
0alice Approve (status “approved”)DENYonly Submit is permitted
1alice SubmitALLOWalice was formerly Approved within 1h with status “approved”
2bob SubmitDENYbob was never Approved
3carol Approve (status “pending”)DENYonly Submit is permitted
4carol SubmitDENYcarol’s prior Approve was status “pending” – excluded by the injected input.status: "approved" filter

Reproduce

Run from this directory (so relative paths resolve):

dogwood validate policy.dw --policy-schema schema.cedarschema
dogwood replay   policy.dw --policy-schema schema.cedarschema --trace trace.log

Note on the guide’s same_session macro

The guide’s literal same_session example (guide/04-temporal-expressions.md:482) injects a deep context path (context.__drupe.session.id) and does not pass validate – the validator rejects that deep path (only the corpus’s replay-only test accepts it). This bundle substitutes the semantically equivalent, validating field-injection macro from corpus 0041_field_injection_omitted_field to teach the same ?s{…} refinement-in-macro-body point.

Referenced by guide/04-temporal-expressions.

Policy

// A `def temporal` macro whose predicate-valued parameter `?s` is REFINED in
// the macro body with an injected field (`?s{ input.status: "approved" }`),
// forcing that filter onto whatever event the caller passes -- the caller
// never writes input.status. The call site fills `?w` with a bare interval
// literal (no `within`). Permit a Submit only if the same user was formerly
// Approved (within 1h) WITH status "approved".
def temporal approved_recently(?w, ?s) {
    formerly within ?w (?s{ input.status: "approved" })
};

@id("submit_after_approval")
permit (
    principal,
    action == Drupe::Action::"Submit",
    resource
)
when temporal {
    approved_recently(
        1h,
        Drupe::Action::"Approve"::request{ input.user: context.input.user }
    )
};

Schema

namespace Drupe {
  type ApproveInput = {
    status: String,
    user: String
  };

  type ApproveOutput = {  };

  type SubmitInput = {
    user: String
  };

  type SubmitOutput = {  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity OAuthUser = {
    id: String
  };

  action "Approve" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: ApproveInput,
      output?: ApproveOutput,
      system: SystemContext
    }
  };

  action "Submit" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: SubmitInput,
      output?: SubmitOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { status: "approved", user: "alice" }) Drupe::Action::"Approve"::request(input: { status: "approved", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { user: "alice" }) Drupe::Action::"Submit"::request(input: { user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { user: "bob" }) Drupe::Action::"Submit"::request(input: { user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { status: "pending", user: "carol" }) Drupe::Action::"Approve"::request(input: { status: "pending", user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@40 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { user: "carol" }) Drupe::Action::"Submit"::request(input: { user: "carol" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY
@30 (time point 3): DENY
@40 (time point 4): DENY

temporal_count_formerly_login

An aggregation-flavoured temporal macro. count_formerly(?w, ?s) counts the timepoints within a window ?w at which predicate ?s held. It desugars to

count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))

where $t is a fresh binder the macro introduces itself — hygienically renamed per call site, so the macro is safe to reuse across policies. The macro is spliced into a comparison inside exists, never called on its own: the login_count_positive rule permits an Alert only when the same user has had at least one Login on the same server within the last hour (count == n and n > 0).

The trace shows both outcomes:

  • @0 — alice logs in on s1 (a history-only event; no Alert permit applies, so the decision is a deny).
  • @100 — alice raises an Alert on s1, 100s after her login → allow (a matching login is within the 1h window and the count is positive).
  • @200 — bob raises an Alert on s2 with no prior login → deny.

Referenced by guide/06-macros.md.

Policy

// An aggregation-flavoured temporal macro: `count_formerly(?w, ?s)` counts
// the timepoints in a window at which a predicate held. It is spliced into a
// comparison inside `exists`, never called on its own. Note `$t` is a fresh
// binder the macro introduces itself, hygienically renamed per call site.
def temporal count_formerly(?w, ?s) {
    count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
};

@id("login_count_positive")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (n: Long). (
        (count_formerly(1h, Drupe::Action::"Login"::request{
            input.user: _, input.server: context.input.server
        })) == n
        && n > 0
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s2" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s2" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

temporal_login_then_read

Two def temporal condition macros joined with && inside a single when temporal { … } block. Permit Write only when the same user both recently logged in (recently_logged_in) and recently read the same document (recently_read) — each a formerly within 1h wrapper.

The trace shows both outcomes:

  • @0 — alice Login (history-only; not a Write, so deny).
  • @10 — alice Read doc1 (history-only; not a Write, so deny).
  • @20 — alice Write doc1 → allow (both macros hold: logged in within 1h and read doc1 within 1h).
  • @30 — alice Write doc2 → deny (logged in, but never read doc2, so recently_read fails).
  • @40 — bob Write doc1 → deny (bob never logged in and never read, so both macros fail).

Referenced by guide/06-macros.md.

Policy

// Two condition macros composed with `&&` inside one temporal block.
def temporal recently_logged_in(?u) {
    formerly within 1h Drupe::Action::"Login"::response{ input.user: ?u }
};
def temporal recently_read(?u, ?d) {
    formerly within 1h Drupe::Action::"Read"::response{
        input.user: ?u, input.document: ?d
    }
};

@id("login_then_read")
permit (
    principal,
    action == Drupe::Action::"Write",
    resource
)
when temporal {
    recently_logged_in(context.input.user)
    && recently_read(context.input.user, context.input.document)
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type WriteInput = {
    document: String,
    user: String
  };

  type WriteOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Write" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: WriteInput,
      output?: WriteOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::request(input: { server: "s1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@5 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { server: "s1", user: "alice" }) Drupe::Action::"Login"::response(input: { server: "s1", user: "alice" }, output: {}, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@15 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::response(input: { document: "doc1", user: "alice" }, output: {}, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@40 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "bob" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): DENY
@20 (time point 2): ALLOW  [rules: 0]
@30 (time point 3): DENY
@40 (time point 4): DENY

temporal_once_read_recent

A condition-flavoured temporal macro. def temporal once(?w, ?s) { formerly within ?w ?s } wraps a window ?w and a whole condition ?s in formerly within, so it is callable wherever a temporal condition is expected. The policy permits when the same user recently (within 1h) issued a Read for the same document, pinning both fields via input.user: context.input.user and input.document: context.input.document.

The macro is defined inline in policy.dw (no separate macros.dw), and the schema (schema.cedarschema, which carries a Read action) is copied from tests/passing/macros/corpus/0020_condition_macro_bare.

The trace shows both outcomes:

  • @0 — alice Reads doc1; the once(...) condition matches at the current timepoint → allow.
  • @100 — alice Writes doc1, 100s after her read → allow (a matching read for the same user + document is within the window).
  • @200 — bob Writes doc2 with no prior read → deny.

Referenced by guide/06-macros.md.

Policy

// A condition-flavoured temporal macro. `once(?w, ?s)` wraps a window and a
// whole condition in `formerly within`. Callable wherever a temporal
// condition is expected.
def temporal once(?w, ?s) { formerly within ?w ?s };

@id("read_recently_via_once")
permit (principal, action in [Drupe::Action::"Read", Drupe::Action::"Write"], resource)
when temporal {
    once(1h, Drupe::Action::"Read"::request{
        input.user: context.input.user,
        input.document: context.input.document
    })
};

Schema

namespace Drupe {
  type ApproveInput = {
    approver: String,
    request_id: String
  };

  type ApproveOutput = {
    result: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type LogoutInput = {
    server: String,
    user: String
  };

  type LogoutOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {
    result: Bool
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SubmitInput = {
    request_id: String,
    user: String
  };

  type SubmitOutput = {
    result: Bool
  };

  type SystemContext = {
    now: datetime
  };

  type WriteInput = {
    document: String,
    user: String
  };

  type WriteOutput = {
    result: Bool
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Approve" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveInput,
      output?: ApproveOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Logout" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LogoutInput,
      output?: LogoutOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "Submit" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SubmitInput,
      output?: SubmitOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Write" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: WriteInput,
      output?: WriteOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "bob" }) Drupe::Action::"Write"::request(input: { document: "doc2", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): ALLOW  [rules: 0]
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY

temporal_sum_formerly_transfer

A temporal sum aggregation macro defined inline. sum_formerly combines a binder-position parameter ?a (the sum’s bound variable, passed by the caller as the bare identifier a) with a macro-introduced hygienic binder $t. It desugars to:

sum ?a for (?a: Long), ($t: Timepoint). where (formerly within ?w (?body && tp($t)))

The policy sums every Transfer’s input.amount seen within the last hour and permits Alert only when that running total exceeds 100, comparing the aggregate inside an exists (total: Long) binder.

The trace shows both outcomes:

  • @0Transfer of 40 by alice (history-only; running sum = 40).
  • @2Alert: cumulative Transfer sum within 1h is 40, not > 100DENY.
  • @4Transfer of 80 by bob (running sum = 40 + 80 = 120).
  • @6Alert: cumulative Transfer sum is 120, which is > 100ALLOW.

Note: the guide uses total > 100; the corpus source policy (tests/passing/macros/corpus/0010_sum_formerly_exact) uses total == 100. This example follows the guide (> 100), so expected.out was captured from a fresh dogwood replay of this policy.

Referenced by guide/06-macros.md.

Policy

// `sum_formerly` uses a binder-position parameter `?a` (the sum's bound
// variable, passed as the bare identifier `a`) plus a macro-introduced fresh
// binder `$t`. The aggregate is compared inside an `exists (total: Long)`.
def temporal sum_formerly(?a, ?w, ?body) {
    sum ?a for (?a: Long), ($t: Timepoint). where (formerly within ?w (?body && tp($t)))
};

@id("transfer_sum_over_100")
permit (
    principal,
    action == Drupe::Action::"Alert",
    resource
)
when temporal {
    exists (total: Long). (
        (sum_formerly(a, 1h, Drupe::Action::"Transfer"::request{
            input.user: _, input.amount: a
        })) == total
        && total > 100
    )
};

Schema

namespace Drupe {
  type AlertInput = {
    level: Long,
    server: String
  };

  type AlertOutput = {  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type HeartbeatInput = {
    server: String
  };

  type HeartbeatOutput = {  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Alert" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: AlertInput,
      output?: AlertOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Heartbeat" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: HeartbeatInput,
      output?: HeartbeatOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 40, user: "alice" }) Drupe::Action::"Transfer"::request(input: { amount: 40, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@1 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { amount: 40, user: "alice" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@2 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 1, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "a1")
@3 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 1, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "a1")
@4 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { amount: 80, user: "bob" }) Drupe::Action::"Transfer"::request(input: { amount: 80, user: "bob" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@5 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Transfer"::response(input: { amount: 80, user: "bob" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@6 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") request_context(input: { level: 2, server: "s1" }) Drupe::Action::"Alert"::request(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "a2")
@7 scope(principal: Drupe::OAuthUser::"s1", resource: Drupe::Gateway::"gw1") Drupe::Action::"Alert"::response(input: { level: 2, server: "s1" }, callerPrincipal: Drupe::OAuthUser::"s1", callerResource: Drupe::Gateway::"gw1", requestId: "a2")

Expected Output

@0 (time point 0): DENY
@2 (time point 1): DENY
@4 (time point 2): DENY
@6 (time point 3): ALLOW  [rules: 0]

traders_is_in_group_scope

The is Type in Group scope constraint: the principal slot both tests the entity type (is Drupe::OAuthUser) AND requires hierarchy membership (in Drupe::Team::"traders"). The policy permits GetStockInfo only for principals that are OAuthUsers belonging to the traders team.

This uses a bespoke schema (schema.cedarschema) that adds entity Team; and entity OAuthUser in [Team] = { id: String } tags String; so the membership target Drupe::Team::"traders" type-checks — the stock GetStockInfo schema has no Team type.

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// `is Type in Group` scope: principal must be an OAuthUser AND a member of
// the traders team.
@id("traders_only_scope")
permit (
    principal is Drupe::OAuthUser in Drupe::Team::"traders",
    action == Drupe::Action::"GetStockInfo",
    resource
);

Schema

namespace Drupe {
  type GetStockInfoInput = { stock: String };
  type GetStockInfoOutput = { info: String };
  type SystemContext = { now: datetime };

  entity Gateway;
  entity Team;
  entity OAuthUser in [Team] = { id: String } tags String;

  action "GetStockInfo" appliesTo {
    principal: [OAuthUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };
}

transfer_prev_nested_conj

previous’s body must be a single atom, so a conjunction has to be parenthesized. This policy permits an Drupe::Action::"Transfer" only when the immediately preceding event (within 2h) was a Login by the same user (input.user: context.input.user) and to server "s1" (input.server: "s1") — both conditions grouped inside the parentheses that form previous’s single-atom body.

Schema lifted from the temporal_only corpus case 0268_previous_containing_nested (Login with user+server input, plus Transfer). Default event schema.

Referenced by guide/04-temporal-expressions.

Policy

// `previous`'s body is an atom, so a conjunction must be parenthesized. Permit
// a Transfer only if the immediately preceding event (within 2h) was a Login
// by the same user AND to server "s1".
@id("transfer_after_prev_login_s1")
permit (
    principal,
    action == Drupe::Action::"Transfer",
    resource
)
when temporal {
    previous within 2h (
        Drupe::Action::"Login"::request{ input.user: context.input.user }
        && Drupe::Action::"Login"::request{ input.server: "s1" }
    )
};

Schema

namespace Drupe {
  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  type TransferInput = {
    amount: Long,
    user: String
  };

  type TransferOutput = {  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Transfer" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: TransferInput,
      output?: TransferOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

write_after_read

The canonical history-dependent policy: permit SellShares only if the same user had an ApproveSale for the same stock within the last hour (formerly within 1h, with the stock pinned via input.stock: context.input.stock).

The trace shows all three cases:

  • @0ApproveSale for AMZN by alice (a history-only event here; no SellShares permit applies, so the decision is a deny).
  • @100 — alice sells AMZN, 100s after the approval → allow (a matching approval is within the window).
  • @5000 — bob sells MSFT with no prior approval → deny.

Referenced by guide/04-temporal-expressions.md.

Policy

// Permit selling shares of a stock only if the same user had that sale
// approved within the last hour. The `when temporal { … }` block is the
// temporal marker; its body reads the accumulated event history.
//
// `input.stock: context.input.stock` PINS the approval to the same stock as
// the current request — without the pin this would match an approval for any
// stock.
@id("sell_after_approval")
permit (
    principal,
    action == Drupe::Action::"SellShares",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"ApproveSale"::response{
        input.stock: context.input.stock
    }
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@50 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::response(input: { shares: 5, stock: "AMZN" }, output: { approved: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@5000 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@5000 (time point 2): DENY

write_after_read_formerly

The flagship history-dependent policy in the guide’s literal wording: permit a Write only if the same user successfully read the same document within the last hour. formerly within 1h is the existential past operator; the field pins input.user: context.input.user and input.document: context.input.document correlate the past Read response to the current request.

This bundle preserves the guide’s literal Read/Write text. (The sibling bundle examples/write_after_read adapts the same concept to the designated SellShares/ApproveSale schema.) The schema here is lifted from temporal corpus case 0004_write_after_read, which carries the Read/Write actions.

The trace shows both outcomes:

  • @0 — alice reads doc1 (a decision event; a Read is not a Write permit, so the decision is a deny).
  • @5 — the read completes successfully (a history-only response event; no verdict is produced, but it records the success for later lookups).
  • @10 — alice writes doc1, 10s after her read -> allow (matching read response in the window).
  • @20 — alice writes doc2, which she never read -> deny (the input.document pin fails).
  • @30 — bob writes doc1, which he never read -> deny (the input.user pin fails).
  • @3700 — alice writes doc1 again, but 3700s > 1h after her read -> deny (the window has expired).

Referenced by guide/04-temporal-expressions.md.

Policy

// Permit a Write only if the same user successfully read the same document
// within the last hour (the canonical write-after-read policy). `formerly
// within 1h` is the existential past operator; `input.user`/`input.document`
// pin the past Read to the CURRENT request via context.* correlation.
@id("write_after_read")
permit (
    principal,
    action == Drupe::Action::"Write",
    resource
)
when temporal {
    formerly within 1h Drupe::Action::"Read"::response{
        input.user: context.input.user,
        input.document: context.input.document
    }
};

Schema

namespace Drupe {
  type ApproveInput = {
    approver: String,
    request_id: String
  };

  type ApproveOutput = {
    result: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type LoginInput = {
    server: String,
    user: String
  };

  type LoginOutput = {
    result: Bool
  };

  type LogoutInput = {
    server: String,
    user: String
  };

  type LogoutOutput = {
    result: Bool
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type ReadInput = {
    document: String,
    user: String
  };

  type ReadOutput = {
    result: Bool
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SubmitInput = {
    request_id: String,
    user: String
  };

  type SubmitOutput = {
    result: Bool
  };

  type SystemContext = {
    now: datetime
  };

  type WriteInput = {
    document: String,
    user: String
  };

  type WriteOutput = {
    result: Bool
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "Approve" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveInput,
      output?: ApproveOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Login" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LoginInput,
      output?: LoginOutput,
      system: SystemContext
    }
  };

  action "Logout" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: LogoutInput,
      output?: LogoutOutput,
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Read" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ReadInput,
      output?: ReadOutput,
      system: SystemContext
    }
  };

  action "Submit" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SubmitInput,
      output?: SubmitOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "Write" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: WriteInput,
      output?: WriteOutput,
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@5 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Read"::response(input: { document: "doc1", user: "alice" }, output: { result: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@10 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@20 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc2", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc2", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@30 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "bob" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "bob" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@3700 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { document: "doc1", user: "alice" }) Drupe::Action::"Write"::request(input: { document: "doc1", user: "alice" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@10 (time point 1): ALLOW  [rules: 0]
@20 (time point 2): DENY
@30 (time point 3): DENY
@3700 (time point 4): DENY