Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

sell_after_approval_valid_ticker

Two Dogwood clause forms combined on a single rule:

  • a when temporal { … } marker — a matching ApproveSale for the same stock must precede this SellShares within the last hour (formerly within 1h, with the stock pinned via input.stock: context.input.stock); and
  • a when guardrails { … } provider check — the ticker must match an uppercase regex, decided by the Strings::Matches information provider. The guardrails tag is transparent sugar for a bare when; Strings::Matches is a plain provider call recognized and hoisted at lowering.

Both clauses must hold for the rule to permit.

Files

  • policy.dw — the combined-clause permit rule.
  • schema.cedarschema — the reusable Drupe action schema (has SellShares and ApproveSale), lifted from the write_after_read example.
  • providers.json — declares Strings::Matches. The Rhai body is inlined in the script field (rather than referenced via scriptFile) because the dogwood CLI reads --providers as text with ProviderDeclarations::from_json, which does not resolve external scriptFile references at replay time.
  • matches.rhai — the same provider body kept as a readable standalone source (lifted from provider_only/corpus/0001_regex_matches_uppercase).
  • trace.log — five events; see verdicts below.
  • expected.out — captured from the real dogwood replay run.

Verdicts (from dogwood replay)

  • @0 — alice ApproveSale AMZN → DENY (history-only; no SellShares permit applies).
  • @100 — alice SellShares AMZN → ALLOW: temporal passes (AMZN approval at @0 is within 1h) and guardrails passes (AMZN matches ^[A-Z]+$).
  • @200 — bob ApproveSale goog → DENY (history-only).
  • @300 — bob SellShares goog → DENY: temporal passes (goog approval at @200) but guardrails failsgoog is lowercase. Isolates the guardrails clause.
  • @5000 — carol SellShares MSFT → DENY: guardrails passes (MSFT is uppercase) but temporal fails — no prior approval. Isolates the temporal clause.

Reproduce

Run from this directory (so relative provider paths resolve):

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

Referenced by guide/02-policy-language.md — The Policy Language.

Policy

// Combined clauses on one rule: a `temporal` marker clause (a matching
// ApproveSale for the same stock must precede this sell within the last hour)
// AND a `guardrails` provider check (the ticker matches an uppercase regex).
// The guardrails tag is sugar for a bare when; Strings::Matches is a provider.
@id("sell_after_approval_valid_ticker")
permit ( principal, action == Drupe::Action::"SellShares", resource )
when temporal {
    formerly within 1h Drupe::Action::"ApproveSale"::response{ input.stock: context.input.stock }
}
when guardrails {
    Strings::Matches(context.input.stock, "^[A-Z]+$").matched == true
};

Schema

namespace Drupe {
  type ApproveSaleInput = {
    shares: Long,
    stock: String
  };

  type ApproveSaleOutput = {
    approved: Bool
  };

  type ContentFilterFinding = {
    severityScore: decimal
  };

  type GetStockInfoInput = {
    stock: String
  };

  type GetStockInfoOutput = {
    info: String
  };

  type PromptAttackFinding = {
    severityScore: decimal
  };

  type SellSharesInput = {
    shares: Long,
    stock: String
  };

  type SellSharesOutput = {
    proceeds: decimal
  };

  type SensitiveInfoFinding = {
    confidenceScore: decimal
  };

  type SystemContext = {
    now: datetime
  };

  entity Gateway;

  entity IamEntity = {
    id: String
  };

  entity OAuthUser = {
    id: String
  } tags String;

  entity UnauthenticatedUser;

  action "ApproveSale" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: ApproveSaleInput,
      output?: ApproveSaleOutput,
      system: SystemContext
    }
  };

  action "CallTool" in [Action::"Mcp"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "GetStockInfo" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: GetStockInfoInput,
      output?: GetStockInfoOutput,
      system: SystemContext
    }
  };

  action "Http" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "InvokeAgent" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "InvokeLLM" in [Action::"Http"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input?: {      },
      system: SystemContext
    }
  };

  action "Mcp" appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };

  action "SellShares" in [Action::"CallTool"] appliesTo {
    principal: [IamEntity, OAuthUser, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      input: SellSharesInput,
      output?: SellSharesOutput,
      system: SystemContext
    }
  };

  action "UnknownTool" in [Action::"CallTool"] appliesTo {
    principal: [OAuthUser, IamEntity, UnauthenticatedUser],
    resource: [Gateway],
    context: {
      system: SystemContext
    }
  };
}

Trace

@0 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@50 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"ApproveSale"::response(input: { shares: 5, stock: "AMZN" }, output: { approved: true }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u1")
@100 scope(principal: Drupe::OAuthUser::"alice", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 5, stock: "AMZN" }) Drupe::Action::"SellShares"::request(input: { shares: 5, stock: "AMZN" }, callerPrincipal: Drupe::OAuthUser::"alice", callerResource: Drupe::Gateway::"gw1", requestId: "u2")
@200 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"ApproveSale"::request(input: { shares: 3, stock: "goog" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@250 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"ApproveSale"::response(input: { shares: 3, stock: "goog" }, output: { approved: true }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u3")
@300 scope(principal: Drupe::OAuthUser::"bob", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 3, stock: "goog" }) Drupe::Action::"SellShares"::request(input: { shares: 3, stock: "goog" }, callerPrincipal: Drupe::OAuthUser::"bob", callerResource: Drupe::Gateway::"gw1", requestId: "u4")
@5000 scope(principal: Drupe::OAuthUser::"carol", resource: Drupe::Gateway::"gw1") request_context(input: { shares: 2, stock: "MSFT" }) Drupe::Action::"SellShares"::request(input: { shares: 2, stock: "MSFT" }, callerPrincipal: Drupe::OAuthUser::"carol", callerResource: Drupe::Gateway::"gw1", requestId: "u5")

Expected Output

@0 (time point 0): DENY
@100 (time point 1): ALLOW  [rules: 0]
@200 (time point 2): DENY
@300 (time point 3): DENY
@5000 (time point 4): DENY

Provider Declarations

{
  "availableProviders": {
    "Strings::Matches": {
      "argumentTypes": [
        {
          "paramType": "string"
        },
        {
          "paramType": "string"
        }
      ],
      "outputType": {
        "paramType": "record",
        "fields": {
          "matched": {
            "paramType": "bool"
          }
        },
        "required": [
          "matched"
        ]
      },
      "implementation": {
        "kind": "rhai",
        "script": "fn evaluate(text, pattern) {\n    // Defensive per the provider contract: a provider may be evaluated\n    // for ANY decision event, so any argument may be absent (unit).\n    // Return a conforming sentinel instead of erroring (errors are UB).\n    if type_of(text) == \"()\" || type_of(pattern) == \"()\" {\n        return #{ matched: false };\n    }\n\n    #{ matched: regex_is_match(pattern, text) }\n}\n"
      }
    }
  }
}