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,
}