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.outputconvention),permit/forbid, the(principal, action, resource)scope,when/unlessconditions, and the full expression language. - Temporal expressions — the
when temporal { … }sublanguage: reasoning about event history withformerly,previous,since, windows,exists,tp, and thecount/sumaggregations. - 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 cedaranddef temporalmacros: 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.jsonformat, the Rhai implementation contract (sandbox, host functions, decimal, thenetfeature), output methods, no-implementation providers, and theguardrails { … }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
dogwoodCLI:validate,replay,lower,check-parse, and theschemasubcommands, 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/PolicySchema→LoweredPolicySet→Validator→Authorizer→Event→Response.
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:
- Introduction
- Getting started
- The policy language
- Temporal expressions
- Information providers
- 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
SellSharesonly if there was anApproveSalefor the same stock in the last hour.” — a decision about history. - “Allow
Readonly 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
- Want to run something immediately? → Getting started walks through your first authorization end to end.
- Want the language reference? → The policy language, then Temporal expressions and Information providers.
- Customizing the fixed inputs? → the Advanced topics: The event schema, The provider schema, and Macros.
- Integrating from Rust? → The API and workflow.
See also
- Getting started — your first policy and authorization.
- The policy language — the core syntax.
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 aPolicySchema(your action schema). - Parse and lower a
LoweredPolicySetfrom your policy source, against those two schemas. - (Optionally) validate the policy set with a
Validator. - Build an
Authorizerand feed it events, getting aResponseback.
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.inputrecord (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 validateanddogwood replay.
permitis the effect. (forbidis the other; aforbidalways wins over apermit.)- The parenthesized part is the scope: bare
principalandresourcemean “any”, andaction == Drupe::Action::"Read"restricts this rule to theReadaction. - No
whenclause 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::builderconstructs one. is_authorizedreturnsOption<Response>. You getSome(response)for a decision-kind event (likerequest) andNonefor a history-only event. With the default event schema,requestis a decision kind — so this call returnsSome.- The
Authorizeris&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 validateanddogwood 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
@0Login → Deny. The policy gatesRead, notLogin, so nopermitmatches the login itself. (The login still matters — it is now in the history.)@10Read → Allow. A login foralicehappened 10 seconds ago, inside the 1-hour window, so the temporal condition holds.@7200Read → 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
- The core syntax — scopes, conditions, operators, types: The policy language.
- History-based conditions — the
temporalsublanguage in full: Temporal expressions. - Computed conditions — information providers, called from an ordinary
when { … }: Information providers. - Customizing the fixed inputs — the event schema, providers, and MCP generation: The event schema, The provider schema, and Generating the action schema from an MCP manifest.
- The full Rust API — the end-to-end flow, and how to plug in your own policy or temporal engine: The API and workflow.
See also
- Introduction — the concepts behind this tutorial.
- The policy language — the next step in learning the language.
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 segmentActionto the namespace path, matching Cedar’s rule that all actions live under an implicitActionentity type. So theLoginaction is writtenDrupe::Action::"Login"in policies and traces.entitydeclarations (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;).typedeclarations are Cedar common types — reusable record types (LoginInput,ReadOutput, …) that the actions reference as theirinput/outputrecords. 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 within [...]— for example the corpus schema at case0407hasCallTool in [Action::"Mcp"]andLogin 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 ascontext.input.<field>(e.g.context.input.stock).output?: <Record>— the tool’s result. Optional (usually present only after the action resolves), read ascontext.output.<field>.system: SystemContext— the base context every action carries ({ now: datetime }in the Drupe template), read ascontext.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:
- Zero or more annotations (
@id("…")) - An effect (
permitorforbid) - A parenthesized scope triple
( principal, action, resource ) - Zero or more condition clauses (
when { … }/unless { … }) - 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 validateanddogwood 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
permitrule matches and noforbidrule 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 validateanddogwood 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 action — in 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 Typeis invalid — theisform 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 anistest. Writingis 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 : Useris not supported — useprincipal is User. The error isthe `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 validateanddogwood 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 barewhen { E }, whereEis 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 barewhentoo. 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:
| Operator | Meaning |
|---|---|
< | less than |
<= | less than or equal |
> | greater than |
>= | greater than or equal |
== | equal |
!= | not equal |
in | entity-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!=(andlike, below). Ordered comparison is not meaningful. For example:when { context.input.stock != "BLOCKED" };(runnable as a full rule atexamples/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 atexamples/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:
| Call | Type | Meaning |
|---|---|---|
decimal("…") | decimal | fixed-point decimal literal |
datetime("…") | datetime | ISO-8601 datetime literal |
duration("…") | duration | duration literal |
ip("…") | ipaddr | IP 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()):
| Method | Domain | Meaning |
|---|---|---|
.isEmpty() | set | set is empty |
.isIpv4() | ipaddr | address is IPv4 |
.isIpv6() | ipaddr | address is IPv6 |
.isLoopback() | ipaddr | address is loopback |
.isMulticast() | ipaddr | address is multicast |
.toDate() | datetime | drop the time-of-day |
.toTime() | datetime | time-of-day component |
.toMilliseconds() | duration | duration as milliseconds |
.toSeconds() | duration | duration as seconds |
.toMinutes() | duration | duration as minutes |
.toHours() | duration | duration as hours |
.toDays() | duration | duration as days |
One-argument methods (receiver.method(arg)):
| Method | Domain | Meaning |
|---|---|---|
.contains(x) | set | set contains element x |
.containsAll(s) | set | set ⊇ set s |
.containsAny(s) | set | set ∩ set s is non-empty |
.getTag(k) | entity | read entity tag k |
.hasTag(k) | entity | entity has tag k |
.isInRange(cidr) | ipaddr | address is within CIDR |
.offset(d) | datetime | datetime + duration |
.durationSince(t) | datetime | datetime − datetime |
.lessThan(d) | decimal | decimal < |
.lessThanOrEqual(d) | decimal | decimal <= |
.greaterThan(d) | decimal | decimal > |
.greaterThanOrEqual(d) | decimal | decimal >= |
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:
| Literal | Type | Notes |
|---|---|---|
true / false | Bool | boolean constants |
42, 1000 | Long | 64-bit signed integer; positive at the token level (- is a unary op) |
"…" | String | double-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 adid 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; useprincipal is Type. action is Typein a scope — theisform 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^63as 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 barewhen { … }(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 isguardrails, 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/outputrecords above become event fields. - Temporal expressions — the
temporal { … }sub-language. - Information providers — providers as plain Cedar calls, and the
guardrails { … }sugar clause. - Macros —
def cedar/def temporaldefinitions 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)insideevent <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 asrequest,response,attempt,outcome, oraudit. Kind names are not reserved; you pick them.- The angle brackets and body braces are mandatory.
event A::r { }(no<>) andevent <A>::r(no body) are both parse errors. An empty body is fine:event <A>::ping {}declares a kind with zero fields. decisionprefix 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:
| Selector | Spread as ...sel(A)? | Used as a field type sel(A)? | Yields |
|---|---|---|---|
inputs | yes → an input group | no (error) | the context.input field record |
outputs | yes → an output group | no (error) | the context.output field record |
principalType | no (error) | yes | the action’s appliesTo principal type set |
resourceType | no (error) | yes | the 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 namedinputwhose members are the fields of the action’scontext.inputrecord. So a tool withinput: { user: String }contributes the leafinput.user....outputs(A)mints a group namedoutputfromcontext.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) }producesmeta.input.user, notmeta.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 type — principalType(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:
pinand= <reference>go together.pin foo: Stringwith no=is an error (pinned field \foo` is missing its pin value), andfoo: String = context.foowithoutpinis 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.
pinis a contextual keyword. It only acts as the pin prefix when a field declaration (ident :) follows. So a field can still be namedpin(pin: String), and a field namedpincan 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, anderrorin 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 likewisecallerResource/resource). The alias uses the bare scope reference (= principal/= resource), not= context.principal: as noted below,context.principalis a context field literally namedprincipal, 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-leftsinceidiom already behave this way under pins (a pinned predicate can never match another key’s event), so their meaning is unchanged.previousmeans “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
sincequantifies 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/resourceare 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 bareprincipalis the entity itself (for identity), andprincipal.id/principal.typeproject 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’scontextvariable — a plain record — socontext.principalwould be a field literally namedprincipalin 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 writescallerPrincipal: _(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 writescallerPrincipal: pwherepis 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 of1118_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 addscontext.__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 everywithinclause; omit the directive instead if you want no custom cap. - Absent, the cap defaults to
24h. Omittingmax_window— as every schema shown elsewhere in this guide does — uses the 24h default. - The bound is inclusive. A
withinwindow equal to the cap is allowed; only a window strictly greater than the cap is rejected. So under the default,within 24h(and the identicalwithin 1d) pass, whilewithin 48horwithin 7dare 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 markeddecision(withis_decision_kind(kind)for a single check).- The default event schema marks only
requestas a decision kind.requestruns authorization;responseanderrorare history-only. - A custom schema may mark any kinds —
1110_custom_event_schema_renamed_reservedmakesattemptthe decision kind andoutcomehistory-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 actionA, arequestevent that is a decision point (runs authorization)....inputs(A)— the tool’s arguments, nested under theinputgroup (input.user,input.document, …).pin callerPrincipal: principalType(A) = principal— a top-level leaf holding the request principal’s entity-type set (the fullappliesToset, 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 (nodecision), emitted when the tool call resolves successfully....inputs(A)and...outputs(A)— both the argument (input.*) and result (output.*) groups, so aresponsecarries 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
ServiceSchemacarries). - Macros — the macro library the
ServiceSchemaalso 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 validateanddogwood 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 }:
- namespace —
Drupe::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 correlation —
principalandresourceare the current request’s principal and resource entities (Cedar’s request variables — the same names, and the same meaning, you use in a plainwhen { … }Cedar clause), and pin against the reserved event fieldscallerPrincipal/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-Cedarwhen { principal.dept == … }and a provider argumentprincipal.deptdo. Soprincipalmeans the same thing in all three surfaces; there is nocontext.principalalias (in Cedar, and now here,context.principalwould be a field literally namedprincipalin 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: inP{ a: _, b: _ }the two_s do not forcea == 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:
| Term | Syntax | Notes |
|---|---|---|
| Entity | Drupe::OAuthUser::"alice" | qualified entity reference |
| Integer | 42, -1 | |
| Decimal | decimal("1.5") | payload kept as text; equality-only at eval time (see semantics) |
| String | "hello" | |
| Boolean | true / false | |
| Context field | context.input.foo, context.system.now | dotted path into the current request’s context record |
| Scope entity | principal, resource, principal.dept | the request principal / resource entity (± an attribute) |
| Variable | a bare identifier | a bound-variable name |
| Wildcard | *, or a bare _ | matches anything, binds nothing; each independent |
| Array | [a, b, c] | |
| Aggregate | count … / 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:
| Unit | Meaning | Seconds |
|---|---|---|
s | seconds | 1 |
m | minutes | 60 |
h | hours | 3600 |
d | days | 86400 |
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_window — 24h 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 ?wform (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 like1h.
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 && bparses as(!a) && b— negation binds onlya.!a since within W bparses 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!Xor 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 declaredLong. 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). φ && ψbindsxover bothφandψ. To stop the scope early, parenthesize:(exists (x: T). φ) && ψ. - It is “at least one,” not a count.
existsis satisfied by one or more witnesses; it does not tell you how many. Usecountfor that. - The type is not enumerated.
x’s candidate values come only from the atom that binds it — a predicate field, atp, or an(agg) == xequality. 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
tin theforlist to keep one row per timepoint — this counts occurrences over time. - Omit
tfrom theforlist 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 φ
countyields the number of matching rows.sum vyields the sum of columnvover those rows;vmust be one of thefor-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 isfalse. Decimals are kept as text and are effectively equality-only — adecimal(…)in an ordering comparison resolves but fails the integer conversion and yieldsfalse. - 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 aforlist; field values that are literals,*, orcontext.…/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 pinsx. - Rejected — restrictor under negation:
exists (x: String). !Login{ input.user: x }. The only mention ofxis negated. Likewise!(!Login{ input.user: x }). - Accepted: restricted by a predicate field, by
tp, by an(agg) == nequality, by a literal equality (x == 5), or restricted under aformerlybody.
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==(includingx == *: a wildcard is not a value, so the equality binds nothing) — consumes all its free variables; - a binding equality
x == (aggregate)producesxbut consumes the aggregate’s correlated variables (free in itswherebody, not in itsforlist) — with them unbound, the count/sum would silently de-correlate into a global tally; - a
sinceconsumes 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))wherepis free —pis neither in theforlist nor bound by an enclosing binder. - Rejected —
forvariable 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 —
forvariable only in a since-left:count for (w: String). where (Read{ input.user: w } since … Login{})— the anchorLogin{}restricts nothing aboutw. - Accepted: each
forvariable read from a predicate field (P{ f: x }), atp(t), or an(agg) == xequality 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 bodyholds iffbodyholds at somejin[0, i]withts(i) - ts(j) <= W. Relationally it collects the body’s satisfying rows at every in-window timepoint, so an aggregation over aformerlybody sees one row per satisfying occurrence.previous within W bodychecks onlyj = i - 1: it holds iff the immediately preceding timepoint is in-window andbodyholds there. Ati == 0it isfalse.left since within W rightholds iff there is an anchorjin the window whererightholds andleftholds at every stepkin[j+1, i](through the decision point). Range restriction for the wholesincecomes from the anchorright.!ais boolean¬a; relationally it is an anti-join filter (keep a row iffadoes 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 unifiestwith the current timepoint indexi.count/sumproject thewherebody’s satisfying rows onto thefordomain, deduplicate, then count the rows or sum the named column. Distinctness is the visible choice you make in theforlist. Summation is exact for every total aLongcan hold, including totals reached by way of partial sums that aLongcannot, and does not depend on the order rows are visited. What a total OUTSIDE theLongrange 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, elsefalse; an unresolved operand makes the comparisonfalse. An==with exactly one unbound variable operand binds that variable.
See also
- 02-policy-language.md — the core Cedar-derived policy language, the
when/unlessclauses that host atemporal { … }block, and the action schema (entity/action declarations and thecontextshape) that temporal validation checks against. - 03-event-schema.md — how event kinds and their fields are declared (the
::kindand 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 cedaranddef 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:
- Invocation —
Ns::Fn(args). The function name must be namespace-qualified: it needs at least two::-separated segments (likeStrings::Matches,Content::Risk,Lists::Blocked). A bare single-segment name is not a valid provider function. More segments are allowed (Ns::Sub::Fn). - 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 thekfield. The projection may be empty, in which case the output is compared directly. - Comparison — any Cedar comparison. Against a
bool/long/stringoutput you use the ordinary operators (==,!=,<,<=,>,>=). Adecimaloutput supports==and!=; to order one, use Cedar’s decimal-extension methods (lessThan,lessThanOrEqual,greaterThan,greaterThanOrEqual) with adecimal("…")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 validateanddogwood 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/— theforbidplus a catch-allpermit;dogwood validateanddogwood 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 reference —
context.input.x,principal.id,resource.owner. It begins with one of the rootscontext/principal/resource, followed by one or more.identsegments. At authorize time Dogwood resolves the path against the decision event: acontextpath is looked up on the event’s input (leadingcontextskipped);principal/resourceresolve to the request scope entity, and a trailing.id/.typeprojects that entity’s id or type. A path that does not resolve is null. - String literal —
"…". - Integer literal — an optionally-signed integer (
i64). - Decimal literal —
decimal("0.5")(chiefly a method threshold, e.g.scoreAbove(decimal("0.5"))). - Bool literals —
true/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.jsonfile, 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:
-
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.) -
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 declaredoutputTypeinstead 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 underforbid(the denial doesn’t fire). Pick the sentinel that fails safe for the polarity of the rules reading it. -
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 { … }clause —when guardrails { E }is transparent sugar for a barewhen { E }; the tag carries no semantics and is retained for compatibility with existing policies. You can call a provider from an ordinarywhenjust 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.jsonformat, 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 checkcontext.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 def — cedar 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/andexamples/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/sumexpression, 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 validateanddogwood 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 validateanddogwood 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 validateanddogwood 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?nis compared); - a window in a
withinclause (within ?w), filled by an interval literal like1h; - a whole condition (
once(?w, ?s)where?sis 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 validateanddogwood 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 kind | Callable in |
|---|---|
def cedar | Cedar-expression position (when { ... }, or mid-expression) |
| temporal condition | Temporal condition slot (when temporal { ... }, &&, a formerly body, etc.) |
| temporal aggregation | Aggregation-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 thetemporal { … }leaf with a recent-Login check;dogwood validateanddogwood 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 (
?sinonce) must be given a temporal condition argument. - A window parameter (
within ?w) must be given a bare interval literal such as1h— not awithinclause. The body writesformerly within ?w (...), and the call supplies just1h. - 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
$tname within a single expansion get the same gensym. The binder and its uses stay tied together —count for ($t: ...)and thetp($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?wat which condition?sheld.sum_within(?a, ?w, ?body)— sums the numeric value?aover occurrences of?bodywithin window?w.count_distinct_within(?k, ?w, ?s)— counts the distinct key values?k(aString) for which?sheld within window?w.bind(?n, ?A, ?B)— a “let”-style binder that names an aggregate result?nand 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 sameonce+is_smalllibrary as amacros.dwfile a policy calls viadogwood validate --macros …(anddogwood 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 temporalmacro bodies are written in (formerly,previous,since,count/sum,tp,within, and binders). - Policy language — the Cedar expressions that
def cedarmacro 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 workflow —
ServiceSchemaBuilder::macros_str, the lowering pipeline, and where macro expansion sits (theServiceSchemaa 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:
Authorizeris stateful — each ingestedEventfolds into an accumulated history so temporal operators can see the past.Authorizer::is_authorizedreturnsOption<Response>—Nonefor 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 aLoweredPolicySetis bound to the schema it was lowered against, so validation always uses that one. LoweredPolicySetis moved intoAuthorizer::new(policies)/Authorizer::builder(policies), so do anyas_cedar()/is_self_contained_cedar()inspection before that.LoweredPolicySet::from_str(src, &service, &policy_schema)is an inherent method, notsrc.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:
- 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. - provider declarations — information-provider signatures and implementations (JSON). Optional; defaults to empty. See The provider schema.
- macro library — reusable
def cedar/def temporaldefinitions merged into every policy set at parse time. Optional; defaults toDEFAULT_MACROS, which ships a small standard library of temporal aggregation macros (count_within,sum_within,count_distinct_within,bind). A policy’s owndefof 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.cedarschematext used verbatim. Mirrorscedar_policy::Schema::from_cedarschema_str.from_mcp_manifest(manifest_json)— generate the action schema from an MCPtools/listmanifest layered on the embedded Drupe template (the eager form ofmcp_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 actionAit derives arequestdecision event (input fields,callerPrincipal,callerResource,requestId, andsessionId), aresponsehistory event (input plus output fields and the same reserved leaves), and anerrorhistory 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 owndefof 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 theServiceSchemaalone; 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 returnedParsedPolicySetcarries itsServiceSchema, so phase 2 needs only thePolicySchema.lower(&policy_schema)— phase 2. Derives the event schema against the action schema, lowers to Cedar, augments the schema with hoistedcontext.<id>fields, and injects pins. Produces theLoweredPolicySet. Emitted policy ids and hoisted field names use the defaultpolicy_<index>namespace.lower_with_distincter(&policy_schema, distincter)— likelower, but namespaces this call’s policy ids and hoisted field names underdistincter(ids become<distincter>_<index>). Supply a distinct value per call when combining independently-lowered sets into one CedarPolicySet/ policy store, or when feeding an augmented schema forward. Thedistinctermust be a valid Cedar identifier ([_A-Za-z][_A-Za-z0-9]*) — elseError::InvalidDistincter. It is not derived from a source@id; distinctness is the caller’s decision. Note the plainlowerpath uses the namespacepolicy, 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; returnsErroron syntax / macro / lowering failure. Type errors against the schema are not returned here — runValidator::validatefor those.as_cedar()— borrow the lowered Cedar policies (a realcedar_policy::PolicySet). This is the artifact to hand to thecedar-policycrate 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 hoistedcontext.<id>fields), respectively as an opaquecedar_policy::Schema, as.cedarschematext, 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 viaPolicySchema::from_cedarschema_str.is_self_contained_cedar()—trueiff lowering hoisted no temporal or providercontext.<id>fields, so the exported Cedar artifacts fully reproduce the policy’s semantics in plain Cedar with no extra context. Whenfalse, the policy usestemporal { … }and/orguardrails { … }: a Cedar policy store can still evaluate the exported policies, but each authorization call must be supplied the hoistedcontext.<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 itscontext.<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 asDogwoodRuleRefs, 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 gateAuthorizer::is_authorizedapplies before returningSome/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 thecontext.<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 thePolicySet.validatereads it from there; a schema argument would be redundant (the same one) or wrong (the un-augmented base). - Under the hood,
validateruns 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.dwsource span. It always uses Cedar’s default (strict) mode — there is no mode argument, because the non-strict modes are not meaningful once a hoistedcontext.<id>field must typecheck. ValidationResultcollects 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.actionis the qualified Cedar action id, either bare ("Login") or namespaced ("Drupe::Action::Login"); the builder splits it into(namespace, id).kindis 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
Nonefromprincipal()/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 onobserve. There is noinput-specific accessor:inputis just the"input"group, read like any other. The Cedar request context a policy sees ascontext.<group>.<name>is a separate bag, read viarequest_context_path.from_request(&cedar::Request)— interop bridge for callers coming fromcedar-policy. It lifts a CedarRequestinto arequest-kindEventat timestamp0: principal/resource become the scope, the action becomes the qualified action, andcontext.inputbecomes the input fields. A stateless single-decision authorization is then just a freshAuthorizerfed 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).
timestampdefaults to0; timestamps order events for temporal operators, and the first event of a fresh authorizer can safely stay at0.field(group, name, value)accumulates into the logged record’s nestedgroupobject (inputis one such group, not privileged). It is not read by a policy’scontext.<group>.<name>clause — that reads the separate request-context bag set viarequest_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’preparecannot fail). Consumes theLoweredPolicySet.builder(policies)— for custom backends.is_authorized(&mut self, event)—&mut self(stateful) returningOption<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) yieldsNone: 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 toDeny. Note that for provider errors specifically this is an implementation choice, not a guarantee — an erroring provider is undefined behavior under the provider contract.
- The event is always handed to the temporal engine (
#![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). DefaultCedarPolicyEngine.temporal_engine(engine)— install a custom temporal backend. DefaultInMemoryTemporalEngine.build()— runs each backend’sprepare(the policy engine gets the lowered Cedar policies plus schema; the temporal engine gets the temporal leaves plus schema). It errors if a backend’spreparefails (e.g. a compiling temporal engine rejects a leaf, or a remote policy engine cannot reach its policy store). This is whybuilder()is fallible whilenew()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 implicitDeny(no rule matched), exactly as Cedar’sreason()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);eventscarries 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 anyevaluatethat 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 returningErr(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
PolicyEngineand pass it toAuthorizer::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(preparedoes one-time setup over&[TemporalField],observerecords each event,evaluatecomputes 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.logtrace into its sequence ofEvents. Each non-blank line is one timepoint of the form@<ts> [envelopes] <Action>(<field>: <value>, …)(the optionalscope/entities/request_contextenvelopes are described below). Values use Cedar surface forms (entity refsNs::Type::"id", strings, integers, decimals,true/false, arrays, objects). Feed the events to anAuthorizerin order to replay them. On failure it returnsError::TraceParse("…").replay_log(policies, log)— replay a whole.logtrace throughpoliciesand return the per-timepoint verdict stream: one@<ts> (time point <i>): <bool>line for every decision point (trueforAllow,falseforDeny), newline-joined. This is the corpus-comparison format. It consumes theLoweredPolicySet(it drives a fresh statefulAuthorizer, 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 omittingscope(...)has no principal/resource and fails closed toDeny.)entities(<uid>: { <attr>: … }, …)— the entity attribute store: eachuid(e.g.Drupe::OAuthUser::"alice") maps to its attributes, so a policy can readprincipal.<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 viacontext.<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_jsonis an MCPtools/listpayload (or a JSON array of tool descriptions).mcp_to_cedar_schemausesDRUPE_TEMPLATE;mcp_to_cedar_schema_with_templatetakes a caller-supplied template.- The generator config matches the regression harness:
include_outputs(true),encode_numbers_as_decimal(true), andflatten_namespaces(true)(so action names are unqualified). DRUPE_TEMPLATEsupplies the principals (OAuthUser/IamEntity/UnauthenticatedUser), theGatewayresource, thesystemcontext, 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 thePolicySchema::from_mcp_manifestpath wraps failure asError::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 CedarPolicySet— render it to policy text for a policy store’s create-policy API, or evaluate it with thecedar-policycrate directly.policies.cedar_schema()gives the augmented CedarSchemafor 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 istrue, the exported Cedar reproduces the policy exactly and a policy store can decide standalone. When it isfalse, the policy usestemporal { … }and/orguardrails { … }leaves, so each authorization call must be supplied the hoistedcontext.<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 customPolicyEnginefollows).
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.
- Fatal —
Error. Returned byParsedPolicySet::parse/lower,LoweredPolicySet::from_str, andServiceSchema/PolicySchemaconstruction. This is the fatal prefix: a syntax / macro / lowering / schema failure means there is nothing well-formed to validate or authorize. - Findings —
ValidationResult. Returned byValidator::validate. The policy is well-formed but wrong against the schema (type errors, dialect-check failures). These do not appear inError. - Runtime. During authorization, any evaluation failure is recorded in
Response::diagnostics().errors()and resolved toDenyby 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_authorizedreturnSome(Response)— it observes the event and decides. - A history-only event makes
is_authorizedreturnNone— it observes the event to update temporal state but yields no verdict, and contributes no line inreplay_log. - Statelessness is just a special case: a single stateless decision is a fresh
Authorizerfed onerequest-kind event (for example viaEvent::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 thePolicyEngine.when temporal { … }— hoisted to aTemporalFieldand evaluated by theTemporalEngine(not self-contained Cedar). See Temporal expressions.when guardrails { … }— an information-provider invocation, evaluated fromProviderDeclarations(not self-contained Cedar). See Information providers.
See also
- Introduction — what Dogwood is and why it exists.
- Getting started — installing the crate and a first end-to-end run.
- Policy language — the
permit/forbidsurface syntax,whenclauses, and the action schema. - The event schema — the event-schema DSL and how decision kinds are declared.
- Temporal expressions —
formerly,previous,since, aggregation, and what theTemporalEngineevaluates. - Information providers — calling providers; The provider schema —
ProviderDeclarationsand the Rhai contract. - Macros —
def cedar/def temporaland the macro library merged at lowering time. - Generating the action schema from an MCP manifest — the manifest format behind
from_mcp_manifest.
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
- Notation
- Concrete syntax
- Abstract syntax
- Lowering: Dogwood ⇝ Cedar
- Validation
- Authorization
- 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:
| Judgment | Read as | Section |
|---|---|---|
Γ ⊢ 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, orresource; a literal (string/integer/bool/decimal("…")); or a set of those. This mirrors MFOTL’s argument rule; arbitrary arithmetic /ifis 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 aMethodCallnode (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)bindsm(evaluate(a), b), and methods chain (.m1().m2()=m2(m1(output))). Each method has its ownargumentTypes+outputTypein the provider declaration’savailableMethodsmap, and resolves to afn 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 theLoweredartifact. (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, Record —
L-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:
-
Derivation. The event-schema DSL (§2.6) is bound against the concrete action schema, yielding, per action
Aand 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 markeddecisionbecomes thedecision_kindsused by the authorizer (event_schema/derive.rs). -
Augmentation. The action schema is extended with the hoisted context fields the lowered policies reference: for each temporal leaf, a required
Boolattributecontext.<name>on its action(s); for provider leaves, aprovidersrecord grouping the fields, typed from the declarations. The augmentation merges into an existingprovidersrecord 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 == ywith 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: “variablevis used in a filter … that is not range-restricted by a preceding conjunct …”. - a binding equality
x == vproducesxand demands the value side’s outward free variables — for an aggregate operand,fv(body) ∖ for-list(a correlated aggregate is only per-uwhenuis already bound; withuunbound the count/sum silently de-correlates). Otherwise: “the equality bindingnreadsuinside its aggregate operand …”. - a
sinceproducesRR(anchor)and demandsfv(left) ∖ RR(anchor)(the standardfree(β) ⊆ 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: “variableuis used in the left operand of asince…”.
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:
- The summed variable must be one of this aggregation’s own
forbinders (WF-Agg-BoundVar-InDomain): the sum is computed over the relation projected onto theforcolumns, so a summand outside it would silently sum to 0. Otherwise: “aggregation sumsv, which is not one of itsforbinders …”. - Every free variable of the
where-body must be bound by thefor-list or an enclosing binder (WF-Agg-Domain-Binds-FreeVars). Otherwise: “variableuis used in the aggregation body but is bound by neither …”. - Every
forvariable 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-variablegdoes not occur in the aggregation body …”. - Every
forvariable 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 thefordomain. Occurrence alone is not enough — a variable occurring only under a negation, or only in the left operand of asince, denotes an infinite relation (or one that does not range over the variable), and evaluation would silently degrade to a 0/1 witness count (asumto 0). Otherwise: “aggregationfor-variablegis not range-restricted by any positive atom in thewherebody …”.
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
Longrange 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 noLongwitness at all unless the implementation clamps, so a widening implementation empties the existential outright. The two readings then differ exactly whenBholds of the clamped endpoint: they agree when it does not, soB=n > <maximum>agrees (noLongexceeds the maximum) while most otherBdo not. Note also that emptying the existential is not uniformly restrictive — underforbidit 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 reservesAction); 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’scontextvariable —context.input.<field>…,context.system.<field>…, etc.), on every action the leaf’s scope pins; an unconstrained scope defers to Cedar (TEMP-ContextPath-*). Aprincipal/resourcescope 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 aggregationfor (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 (onlyintis numeric — an aggregate types asint, decimal is not numeric here); an equality requires compatible types; and atp(x)binder must be declaredTimepoint— 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
withinwindow in the leaf (on aformerly,previous, orsince, at any depth including inside an aggregationwherebody) must be ≤ the event schema’smax_windowcap, compared in seconds. A window strictly greater than the cap is rejected, located at the offending operator (TEMP-MaxWindow). The cap is the schema’smax_windowdirective 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/set
— PROV-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):
input→ the event’sinputrecord. Only aValue::Objectis taken; any other shape (or absence) degrades to an empty record — it does not error (A-Context-Input).- for each
(id, b) ∈ β:id → Bool(b)— the hoisted temporal booleans, at top level, i.e.context.<id>. - 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⇓_dyields(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.
validatepresupposes aLowered; 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/evaluateand the hoistedcontext.<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- orfor-bound), every bound variable — existential and aggregationfor— is range-restricted by a positive atom (a negation restricts nothing, at any depth; asincerestricts 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 kind | Callable in |
|---|---|
def cedar | a Cedar-expression position (when { … } / unless { … }, or mid-expression) |
| temporal condition | a temporal condition slot (when temporal { … }, a && operand, a formerly body, …) |
| temporal aggregation | an 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 validateanddogwood replay(the bundle fills the/* … */leaf with a recent-Logincheck).
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 validateanddogwood 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 validateanddogwood 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 validateanddogwood 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 literal — 1h, 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/$tsigils, hygiene, the rejection rules, and the macro library. - The policy language — the Cedar expressions a
def cedarcall expands into and thewhen/unlessclauses that host a call. - Temporal expressions — the temporal sublanguage a
def temporalcall expands into, and thewhen 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 aguardrails { … }clause (which is just sugar for a barewhen). 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 andcontext.providersuse; 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 ofParamTypeentries, one per positional argument. May be empty.outputType— a singleParamTypedescribing 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:
paramType | Cedar type | Notes |
|---|---|---|
string | String | |
integer / long | Long | synonyms |
bool / boolean | Bool | synonyms |
decimal | decimal | Cedar decimal extension |
set | Set<inner> | inner comes from items, defaults to String |
record | { f?: T, … } | each field required-or-optional per required |
| any other (unknown) | String | permissive 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.rhaifile, resolved relative to theproviders.jsonfile’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. AscriptFilereference is left unresolved — the inlinescriptstaysNone.from_json_file(path)reads the file and then, for every Rhai implementation that has ascriptFilebut no inlinescript, 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, case0002_length_threshold):argumentTypes: [{string}],outputTypea record withlength: integer, required. - Decimal output (
Content::Risk, case0003_decimal_score_method):outputTypea record withseverityScore: decimal, required. - Multi-field record output (
Regex::Analyze, case0005_regex_operations): a record withis_match: bool,first_match: string,count: integer, all required. - Two providers in one file (case
0004_two_providers_and_not:Strings::Matches+Lists::Blocked; case0007_boolean_or_parens:Lists::Allowed+Strings::Length) — just add more keys underavailableProviders.
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:
- The invocation’s name (
Ns::Fn) must be present inavailableProviders, or you get “provider is not present in the provider declarations.” - The argument count must equal the declared
argumentTypeslength, or you get “expects N argument(s) but got M.” - Each directly-typed argument’s kind must match the declared
paramType:String→string;Integer→integer/long;Decimal→decimal;Bool→bool/boolean;Set→set. A mismatch reports the actual-vs-declared types. - 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 aninputType— 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
outputTypeinstead 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— doespatternmatchtext? Returnsfalseon 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 (0on 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]/pathonly, 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_getperforms 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 scriptfn evaluate(url) { #{ body: http_get(url) } }, invoked asWebGet(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.providersrecord. - The API and workflow — loading declarations (
from_jsonvsfrom_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: integer → Long, string → String, boolean → Bool, and number with format: decimal → decimal.
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:
- parses the template
.cedarschema, - parses the manifest to a server description,
- seeds a schema generator with the template and the default config,
- layers one Cedar action per MCP tool, deriving
input/outputcontext records from each tool’s JSON schema, - serializes the result back to
.cedarschematext.
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 validateanddogwood 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.outputconvention 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
.cedarschemadeclaring entities, actions, and each action’scontextshape. 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--macroswhen 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
| Example | Description |
|---|---|
| access_not_revoked_since_grant | The “open session” idiom: a negated left operand on since expresses “has |
| alert_exactly_three_transfers | Counting over a *-wildcard field: permit an Alert only if exactly three |
| alert_heartbeat_and_login_rate | A top-level && chain combining a formerly with an exists-guarded count |
| alert_login_and_big_transfer | Nested exists with a value-threshold filter: permit an Alert when the |
| alert_login_current_tp | The canonical tp(t) count-over-timepoints idiom with no temporal wrapper |
| alert_login_in_last_hour | Counting over history: a formerly within 1h body inside the aggregation |
| alert_pending_transfers | Aggregate-vs-aggregate comparison (parenthesized left, bare right): permit an |
| alert_same_principal_login_transfer | An entity-typed exists binder that correlates on the request principal |
| alert_same_user_login_and_transfer | A shared exists variable joins two different predicates through a common |
| alert_some_login | exists is the sole quantifier: it asserts at least one satisfying |
| alert_total_transfer_over_200 | Aggregation example: permit an Alert only when the total transferred amount |
| allow_anything | An unconstrained (bare) scope triple: all three slots (principal, action, |
| approve_has_output_guard | The has attribute-existence guard before reading an optional Bool output |
| call_cedar_macro_as_argument | A Cedar macro call passed as an argument to another macro call: |
| call_cedar_macro_is_small | A def cedar macro called in an ordinary when { … } position: |
| call_cedar_macro_with_temporal_leaf | A Cedar macro conjoined mid-expression with a temporal { … } leaf. Because a |
| call_cedar_macros_composed | Two Cedar macros composed with && in one when { … } clause: |
| call_temporal_aggregation_macro_count | Calling a def temporal aggregation macro. count_formerly produces a |
| call_temporal_condition_macro_once | Calling a def temporal condition macro inside a when temporal { … } |
| call_temporal_condition_macros_composed | Two def temporal condition macros composed with && inside a single |
| cedar_eligible_not_blocked | Two def cedar macros of different argument types composed with && inside one |
| cedar_is_small_threshold | The simplest def cedar macro: is_small(?n) names the < 100 threshold so |
| cedar_macro_plus_temporal_leaf | A def cedar boolean macro (level_ok) conjoined mid-expression with an |
| cedar_semver_gt | The RFC 0061 semver worked example for Cedar macros: a record-building macro |
| cedar_starts_with_f_like | A Cedar macro whose body is a like wildcard pattern: starts_with_f(?s) |
| cedar_within_cap_if_else | A def cedar macro whose body is an if/then/else expression, encoding a |
| cond_is_oauth_in_team | The expression-level counterpart of the is / is-in scope constraint: an |
| deny_overrides_sell_not_amzn | A permit + forbid pair showing deny-overrides semantics: SellShares is |
| forbid_large_except_amzn | Mixing when and unless on a forbid rule: block large SellShares |
| forbid_read_transfers_over_1000 | A forbid rule with a sum over a (value, timepoint) domain and a filtered |
| get_amzn_stock_info | A plain (non-temporal) permit showing that an MCP-manifest input field |
| heartbeat_scope_alias | formerly with scope-alias correlation. Permit an Alert only if a |
| login_attempt_custom_kind | A custom, author-defined event kind. The per-case event schema |
| macro_library_once_is_small | Demonstrates the shareable macro library: the policy calls once (a |
| max_window_raised | Raising the temporal look-back cap. The event schema’s default cap on any |
| permit_read_anyone | The simplest useful rule: permit the Read action for any principal on any |
| principal_is_oauth | Demonstrates the principal is Type entity-type scope constraint: the policy |
| provider_allowed_or_short | Disjunction and parentheses over two information providers: permit Read when |
| provider_digitcount_forbid | A provider gating a forbid rule, alongside a catch-all permit – showing |
| provider_digitcount_operator_ge | The operator-form comparison example (>= on an integer provider output), |
| provider_filter_set_index_decimal | The guardrail flagship shape in one atom: a set argument |
| provider_int_arithmetic_trusted | A provider’s integer output used inside arithmetic, mixed under && with a |
| provider_matches_and_not_blocked | Two information providers combined with the boolean spine (&& and !) inside |
| provider_principal_id_allowlist | Permit Read only when the requesting principal is on the provider’s |
| provider_regex_analyze_fields | Several calls to the same Regex::Analyze provider, each projecting a |
| provider_regex_matches_uppercase | The canonical worked information-provider example: permit Read only when the |
| provider_risk_decimal_method | The decimal-extension-method comparison form. The Content::Risk provider |
| read_after_login | The history-dependent version of the getting-started tour (Step 4 — a decision that |
| read_after_login_success | Response predicate + output-field filter: permit a Read only if the **same |
| read_heartbeat_since_login_30s | since with a short (seconds) window: the anchor must be recent enough, or the |
| read_login_not_logout | The accepted “A but not B” idiom with restrictor-first conjunct ordering. |
| read_prev_compute_open_session | A top-level previous && (open-session since) chain: permit a Read only if the |
| read_prev_login | previous within 1h: permit a Read only if the immediately preceding |
| read_prev_login_success | previous with a response predicate and an output-field filter. |
| read_since_login | Positive-left since within 1h: the left operand must have **held |
| sell_after_2024_datetime | A single datetime literal compared with an ordinary comparison operator |
| sell_after_approval_valid_ticker | Two Dogwood clause forms combined on a single rule: |
| sell_comparison_chain | Several comparison operators chained in a single && conjunction on a Long. |
| sell_datetime_window | Datetime ordered comparison expressing a calendar-year time window: permit |
| sell_like_a_prefix | The like operator matches a string against a wildcard pattern, where * |
| sell_logical_grouping | Logical connectives ||, &&, and ! with parenthesized grouping to override |
| sell_nested_if_threshold | Nested if/then/else used as an operand rather than a top-level |
| sell_nonzero_proceeds_decimal | Decimal supports equality only (== / !=); ordered comparison on |
| sell_not_blocked_string | String inequality (!=) — one of the two operators strings support (== and |
| sell_not_test_tickers_like | The like string-pattern operator used under unless as a denylist idiom: a |
| sell_or_approve_action_in | action in [ ... ]: list-membership matching on the action. This rule permits |
| sell_shares_eq_scope | An == scope constraint pinning the action to a specific entity reference: this |
| sell_shares_temporal_subexpr | A temporal { ... } marker used as a sub-expression inside a larger Cedar |
| sell_small_only | The canonical five-part rule shape: annotation + effect + parenthesized scope |
| sell_small_proceeds_decimal_method | Decimal .lessThan(...) method call — how decimals are ordered. Cedar decimals |
| sell_threshold_by_stock | An if / then / else used as the whole body of a when clause reads like a |
| sell_two_when_small_amzn | Two when clauses stacked on one rule are implicitly conjoined: both must |
| sell_unless_huge | Basic unless { ... } clause: permit SellShares unless the order is |
| sell_when_under_100 | A basic when { ... } condition clause: a when body must evaluate true for |
| sell_when_unless_mix | Mixing when and unless clauses on a single permit rule. Because clauses |
| sell_zero_proceeds_if_has | The if C has attr then ... else false idiom guards an optional output field. |
| simplest_permit | The simplest possible policy: a bare permit for GetStockInfo with no |
| submit_after_approval_injection | A def temporal macro (approved_recently) whose predicate-valued parameter |
| temporal_count_formerly_login | An aggregation-flavoured temporal macro. count_formerly(?w, ?s) counts the |
| temporal_login_then_read | Two def temporal condition macros joined with && inside a single |
| temporal_once_read_recent | A condition-flavoured temporal macro. `def temporal once(?w, ?s) { formerly |
| temporal_sum_formerly_transfer | A temporal sum aggregation macro defined inline. sum_formerly combines a |
| traders_is_in_group_scope | The is Type in Group scope constraint: the principal slot both tests the |
| transfer_prev_nested_conj | previous’s body must be a single atom, so a conjunction has to be |
| write_after_read | The canonical history-dependent policy: permit SellShares only if the **same |
| write_after_read_formerly | The 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:
@0—Grantfordoc1/alice(a history-only event here; noAccesspermit applies, so the decision is a deny).@100—aliceaccessesdoc1, after the grant and with no intervening revoke → allow (the not-revoked-since-grant chain holds).@200—Revokefordoc1/alice(history-only; deny).@300—aliceaccessesdoc1again, but aRevokenow 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 ... Heartbeatconjunct is satisfied at the twoAlerttimepoints (a heartbeat fired within the window), so on its own it would allow. - But the
countconjunct is never satisfied. Its bodyLogin && tp(t)is not wrapped in a past-temporal operator, sotp(t)pins the count to the current timepoint (theAlert), where noLoginfires. The count is therefore0at everyAlert, son > 2is 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:
@0—Logintos1by alice (a history-only event here; noAlertpermit applies, so the decision is a deny).@100— alice raises anAlertfor servers1, with a matchingLogintos1within the window → allow.@200— alice raises anAlertfor servers2, with noLogintos2→ deny.
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— aliceLogin(history-only event; noAlert, so deny).@1— aliceAlertwith a login but no transfer yet → deny.@2— aliceTransfer(history-only event → deny).@3— aliceAlertafter both her login (@0) and transfer (@2) → allow (one user satisfies both sides of the join).@5000— bobLogin;@5001— carolTransfer(both history-only → deny).@5002— carolAlert: a login (bob) and a transfer (carol) both exist in the window, but no single user did both → deny. This is what the shared-ujoin 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 tos1(aLogin, not anAlert; no permit applies) → deny.@100— bob alerts ons1; some user (alice) logged in tos1100s ago → allow. Noteexistsis “at least one,” and the witness need not be the alerting principal — alice’s login satisfies bob’s alert.@200— bob alerts ons2; no login tos2(the server pin fails) → deny.@5000— alice alerts ons1, but the onlys1login 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 thesum … > 200temporal body.schema.cedarschema— Drupe action schema (lifted from corpustemporal_only/0063_sum_threshold; hasTransferwith aLong amountinput andAlert).trace.log— lifted from0063_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 fromdogwood 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:
@0—Loginby alice ons1(history only; noAlertpermit applies) → DENY.@2—Alertby alice,level: 1→level_ok(1)is false → DENY.@10—Alertby alice,level: 2→level_ok(2)true and aLoginons1is within the last hour → ALLOW.@20—Loginby bob (history only) → DENY.@22—Alertby alice,level: 3→level_oktrue and a matching recentLoginons1→ 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
// 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— aliceLoginons1(a history-only event here; noAlertpermit applies, so the decision is a deny).@2,@10— aliceAlertons1, within 1h of her login → allow (count == 1, son > 0).@20— bobLoginons1(history-only again → deny).@22— aliceAlertons1, 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:
@0—Readofdoc1by alice (history-only here; noWritepermit applies, so the decision is a deny).@10— alice writesdoc1, 10s after the read → allow (matching read is within the window and pins bothuseranddocument).@20— alice writesdoc2, 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 writesdoc1: both macros hold → allow.@30— alice writesdoc2: she logged in but never readdoc2(recently_readfails) → deny.@40— bob writesdoc1: 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 requireslevel >= 2.- the temporal leaf — a
Loginfor the same server occurred within the last hour.
The trace exercises every combination:
@0— aLogin(not anAlert; no permit rule applies) → DENY.@10—Alertlevel 3 ons1, with a recent login ons1→ ALLOW (both conjuncts true).@20—Alertlevel 1 ons1→ DENY (macro conjunct false:1 < 2).@30—Alertlevel 5 ons2, no login ons2→ DENY (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 callsGetStockInfo→ deny (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 →unlessexempts AMZN, so the forbid does not fire →DENY(Cedar’s default deny; nopermitapplies).@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 firstTransferresolves 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 (theforbiddoes not fire; 600 is not over 1000).@20/@21— a secondTransferfor 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], theforbidfires because the sum is over 1000).@40— bob reads with noTransfers of his own → default deny (the per-user pininput.user: context.input.usermeans 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 requestsGetStockInfoforAMZN→ allow.@100— bob requestsGetStockInfoforMSFT→ deny.
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:
@0—machine-asends aHeartbeatfors1(history-only event; noAlertpermit applies, so the decision is a deny).@100—machine-araises anAlertfors1→ allow (its own heartbeat, same server, same principal/resource, is within the window).@200—machine-braises anAlertfors1→ deny: the server matches, but the scope-alias pins oncallerPrincipalfail (the only heartbeat came frommachine-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 aLogin(a history-only event here; noReadpermit 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:
@0—ApproveSalefor AMZN by alice (history-only; noSellSharespermit 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< 100threshold fails).@5000— bob sells MSFT with no prior approval → deny (oncefinds no matchingApproveSalein 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’sLogin(a history-only occurrence here; the policy gatesRead, notLogin, so nopermitmatches → deny).@10— alice’sRead→ allow (rule 0 matches; bareprincipal/resourcemean “any”).@20— bob’sRead→ allow (“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:
@0—readmeis on the allowlist → ALLOW (left branch).@10—hiis 2 chars, solength < 4→ ALLOW (right branch).@20—longfilenameis neither allowlisted nor short → DENY.@30—manifestis 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 <= 3 ⇔ count <= 2):
| trusted | document | digits | verdict |
|---|---|---|---|
| true | ab | 0 | ALLOW |
| true | a1b2 | 2 | ALLOW |
| true | a1b2c3 | 3 | DENY |
| false | ab | 0 | DENY |
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 (hasReadwithReadInput = { document: String }), lifted fromprovider_only/corpus/0004_two_providers_and_not.providers.json— declaresStrings::MatchesandLists::Blocked. Each Rhai body is inlined in thescriptfield (rather than referenced viascriptFile) because thedogwoodCLI reads--providersas text withProviderDeclarations::from_json, which does not resolve externalscriptFilereferences at replay time.matches.rhai— theStrings::Matchesbody kept as a readable standalone source (lifted from the corpus).blocked.rhai— theLists::Blockedbody kept as a readable standalone source (a tiny hard-coded denylist:"evil","badword").trace.log— threeReadevents; see verdicts below.expected.out— captured from the realdogwood replayrun.
Verdicts (from dogwood replay)
@0—document: "hello"→ ALLOW: matches^[a-z]+$and is not on the blocklist.@10—document: "evil"→ DENY: matches the pattern but is blocked; the!rejects it.@20—document: "Hello"→ DENY: the uppercaseHfails 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:
| tp | principal | allowed | verdict |
|---|---|---|---|
| 0 | alice | true | ALLOW |
| 1 | mallory | false | DENY |
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:
- starts with an uppercase letter —
^[A-Z]→.is_match == true - has at least three digit characters —
[0-9]→.count >= 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:
| doc | starts A–Z | ≥3 digits | first run == “42” | verdict |
|---|---|---|---|---|
Abc42x999 | yes | yes (5) | yes | ALLOW |
abc42x999 | no | yes | yes | DENY |
A42 | yes | no (2) | yes | DENY |
A99942 | yes | yes | no (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 DrupeReadaction).providers.json— declaresStrings::Matches(string, string) -> { matched: Bool }.matches.rhai— the provider implementation (fn evaluate(text, pattern)).trace.log— threeReadrequests:ABC,abc,AB12.expected.out— the replay verdict stream.
The trace shows:
@0—Readof"ABC"(all uppercase) → ALLOW.@10—Readof"abc"(lowercase) → DENY.@20—Readof"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:
@0—document: "safe"(score 0.10, below 0.5) -> ALLOW.@10—document: "spam"(score 0.80) -> DENY.@20—document: "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:
@0—Loginrequest by alice. The policy gatesRead, notLogin, so nopermitmatches → deny (the login request still lands in the history).@5—Loginresponse (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 readsdoc1, 100s after aLogin::responsewithoutput.result: truefor alice → ALLOW (a matching successful login is within the window).@300— bob readsdoc2, after aLogin::responsewithoutput.result: falsefor bob → DENY (the login failed, so theoutput.result: truefilter 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 (thesinceanchor).@10,@20,@30— alice sends Heartbeats, keeping the streak alive.@32— alice Reads. The onlyLoginis now 32s old, past the 30s window, so the anchor no longer counts → deny.@50— another Heartbeat, but still no freshLogin.@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 (aLoginevent; noReadpermit 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:
@0—Loginby alice (a history-only event here; noReadpermit applies, so the decision is a deny).@100— alice reads doc1, immediately after the login → allow (the directly preceding event was a matchingLoginwithin the window).@200— alice reads doc2, but the immediately preceding event was aRead, not aLogin→ deny (previousfails 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— aReadat timepoint 0: no predecessor exists, so deny (the no-verdict-at-tp0 behavior).@7— aReadwhose immediately preceding event (@5) is alice’s successfulLogin::response(output.result: true) → allow.@31— aReadwhose predecessor (@30) is bob’sLogin::responsewithoutput.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— twoLogins by alice (history-only; the scope isRead, so no permit applies → deny).@12,@20— aliceReads within an hour of her logins. The anchor (Login) is in the window, but the left operand (Login) does not hold at theReaddecision step, so thesinceis false → deny.@30— bobReads 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 matchingApproveSalefor the same stock must precede thisSellShareswithin the last hour (formerly within 1h, with the stock pinned viainput.stock: context.input.stock); and - a
when guardrails { … }provider check — the ticker must match an uppercase regex, decided by theStrings::Matchesinformation provider. Theguardrailstag is transparent sugar for a barewhen;Strings::Matchesis 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 (hasSellSharesandApproveSale), lifted from thewrite_after_readexample.providers.json— declaresStrings::Matches. The Rhai body is inlined in thescriptfield (rather than referenced viascriptFile) because thedogwoodCLI reads--providersas text withProviderDeclarations::from_json, which does not resolve externalscriptFilereferences at replay time.matches.rhai— the same provider body kept as a readable standalone source (lifted fromprovider_only/corpus/0001_regex_matches_uppercase).trace.log— five events; see verdicts below.expected.out— captured from the realdogwood replayrun.
Verdicts (from dogwood replay)
@0— aliceApproveSaleAMZN → DENY (history-only; noSellSharespermit applies).@100— aliceSellSharesAMZN → ALLOW: temporal passes (AMZN approval at@0is within 1h) and guardrails passes (AMZNmatches^[A-Z]+$).@200— bobApproveSalegoog → DENY (history-only).@300— bobSellSharesgoog → DENY: temporal passes (goog approval at@200) but guardrails fails —googis lowercase. Isolates the guardrails clause.@5000— carolSellSharesMSFT → DENY: guardrails passes (MSFTis 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 sellsAMZN(starts withA) → allow.@100— bob sellsAAPL(starts withA) → allow.@200— carol sellsMSFT(does not start withA) → 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 > 5is 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 <= 50holds).@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 → thethenbranch (5 <= 10) → allow.@100— alice sells 50 AMZN → thethenbranch (50 <= 10) → deny.@200— bob sells 500 MSFT → theelsebranch (500 <= 1000) → allow.@300— bob sells 5000 MSFT → theelsebranch (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 thepermit … when temporal { approved_recently(1h, …) }rule.schema.cedarschema– Cedar action schema (Approve + Submit underDrupe), lifted from macros corpus0041_field_injection_omitted_field.trace.log– five events exercising both outcomes.expected.out– captured verbatim fromdogwood replay.
Trace outcomes
| tp | event | verdict | why |
|---|---|---|---|
| 0 | alice Approve (status “approved”) | DENY | only Submit is permitted |
| 1 | alice Submit | ALLOW | alice was formerly Approved within 1h with status “approved” |
| 2 | bob Submit | DENY | bob was never Approved |
| 3 | carol Approve (status “pending”) | DENY | only Submit is permitted |
| 4 | carol Submit | DENY | carol’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 ons1(a history-only event; noAlertpermit applies, so the decision is a deny).@100— alice raises anAlertons1, 100s after her login → allow (a matching login is within the 1h window and the count is positive).@200— bob raises anAlertons2with 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— aliceLogin(history-only; not aWrite, so deny).@10— aliceReaddoc1 (history-only; not aWrite, so deny).@20— aliceWritedoc1 → allow (both macros hold: logged in within 1h and read doc1 within 1h).@30— aliceWritedoc2 → deny (logged in, but never read doc2, sorecently_readfails).@40— bobWritedoc1 → 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— aliceReadsdoc1; theonce(...)condition matches at the current timepoint → allow.@100— aliceWritesdoc1, 100s after her read → allow (a matching read for the same user + document is within the window).@200— bobWritesdoc2with 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:
@0—Transferof 40 by alice (history-only; running sum = 40).@2—Alert: cumulative Transfer sum within 1h is 40, not> 100→ DENY.@4—Transferof 80 by bob (running sum = 40 + 80 = 120).@6—Alert: cumulative Transfer sum is 120, which is> 100→ ALLOW.
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:
@0—ApproveSalefor AMZN by alice (a history-only event here; noSellSharespermit 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; aReadis not aWritepermit, so the decision is a deny).@5— the read completes successfully (a history-onlyresponseevent; 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 (theinput.documentpin fails).@30— bob writes doc1, which he never read -> deny (theinput.userpin 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