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