@intentius/chant-lexicon-cedar 0.44.8 → 0.44.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -2
- package/dist/agentcore/embed.d.ts +189 -0
- package/dist/agentcore/embed.d.ts.map +1 -0
- package/dist/agentcore/enforcement.d.ts +76 -0
- package/dist/agentcore/enforcement.d.ts.map +1 -0
- package/dist/agentcore/scan.d.ts +46 -0
- package/dist/agentcore/scan.d.ts.map +1 -0
- package/dist/codegen/docs-dogwood.d.ts +21 -0
- package/dist/codegen/docs-dogwood.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/dogwood/cli.d.ts +247 -0
- package/dist/dogwood/cli.d.ts.map +1 -0
- package/dist/dogwood/event-schema.d.ts +161 -0
- package/dist/dogwood/event-schema.d.ts.map +1 -0
- package/dist/dogwood/index.d.ts +39 -0
- package/dist/dogwood/index.d.ts.map +1 -0
- package/dist/dogwood/macros.d.ts +96 -0
- package/dist/dogwood/macros.d.ts.map +1 -0
- package/dist/dogwood/policy.d.ts +120 -0
- package/dist/dogwood/policy.d.ts.map +1 -0
- package/dist/dogwood/replay-activity.d.ts +196 -0
- package/dist/dogwood/replay-activity.d.ts.map +1 -0
- package/dist/dogwood/replay-op.d.ts +165 -0
- package/dist/dogwood/replay-op.d.ts.map +1 -0
- package/dist/dogwood/scan.d.ts +109 -0
- package/dist/dogwood/scan.d.ts.map +1 -0
- package/dist/dogwood/serialize.d.ts +66 -0
- package/dist/dogwood/serialize.d.ts.map +1 -0
- package/dist/dogwood/temporal.d.ts +259 -0
- package/dist/dogwood/temporal.d.ts.map +1 -0
- package/dist/dogwood/trace.d.ts +215 -0
- package/dist/dogwood/trace.d.ts.map +1 -0
- package/dist/dogwood/upstream.d.ts +41 -0
- package/dist/dogwood/upstream.d.ts.map +1 -0
- package/dist/dogwood/window.d.ts +73 -0
- package/dist/dogwood/window.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +15 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
- package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc010.d.ts +25 -0
- package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc011.d.ts +19 -0
- package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc012.d.ts +21 -0
- package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
- package/dist/lint/post-synth/dwdc013.d.ts +33 -0
- package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde010.d.ts +32 -0
- package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
- package/dist/lint/post-synth/dwde011.d.ts +33 -0
- package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
- package/dist/lint/post-synth/dwds010.d.ts +24 -0
- package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +7 -0
- package/dist/okf/rules/DWDC010.md +11 -0
- package/dist/okf/rules/DWDC011.md +11 -0
- package/dist/okf/rules/DWDC012.md +11 -0
- package/dist/okf/rules/DWDC013.md +15 -0
- package/dist/okf/rules/DWDE010.md +11 -0
- package/dist/okf/rules/DWDE011.md +11 -0
- package/dist/okf/rules/DWDS010.md +11 -0
- package/dist/okf/types/Policy.md +1 -0
- package/dist/op/activities/index.d.ts +18 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/policy-text.d.ts +53 -0
- package/dist/policy-text.d.ts.map +1 -0
- package/dist/rules/dogwood-helpers.ts +139 -0
- package/dist/rules/dwdc010.ts +62 -0
- package/dist/rules/dwdc011.ts +61 -0
- package/dist/rules/dwdc012.ts +46 -0
- package/dist/rules/dwdc013.ts +64 -0
- package/dist/rules/dwde010.ts +130 -0
- package/dist/rules/dwde011.ts +108 -0
- package/dist/rules/dwds010.ts +46 -0
- package/dist/serializer.d.ts +10 -18
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-cedar-authoring.md +180 -0
- package/dist/skills/chant-cedar-avp-embedding.md +125 -0
- package/dist/skills/chant-cedar-dogwood.md +327 -0
- package/dist/skills/chant-cedar-meta-policy.md +119 -0
- package/package.json +7 -2
- package/src/agentcore/embed.test.ts +254 -0
- package/src/agentcore/embed.ts +399 -0
- package/src/agentcore/enforcement.test.ts +43 -0
- package/src/agentcore/enforcement.ts +92 -0
- package/src/agentcore/scan.ts +119 -0
- package/src/codegen/docs-dogwood.ts +1119 -0
- package/src/codegen/docs.ts +66 -1
- package/src/codegen/package.ts +3 -2
- package/src/config.test.ts +12 -0
- package/src/config.ts +28 -0
- package/src/dogwood/cli.test.ts +513 -0
- package/src/dogwood/cli.ts +666 -0
- package/src/dogwood/event-schema.test.ts +218 -0
- package/src/dogwood/event-schema.ts +318 -0
- package/src/dogwood/index.ts +271 -0
- package/src/dogwood/macros.test.ts +104 -0
- package/src/dogwood/macros.ts +229 -0
- package/src/dogwood/policy.test.ts +94 -0
- package/src/dogwood/policy.ts +141 -0
- package/src/dogwood/replay-activity.test.ts +481 -0
- package/src/dogwood/replay-activity.ts +506 -0
- package/src/dogwood/replay-op.ts +242 -0
- package/src/dogwood/scan.ts +287 -0
- package/src/dogwood/serialize.test.ts +331 -0
- package/src/dogwood/serialize.ts +246 -0
- package/src/dogwood/temporal.test.ts +272 -0
- package/src/dogwood/temporal.ts +592 -0
- package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
- package/src/dogwood/testdata/default-macros.dw +23 -0
- package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
- package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
- package/src/dogwood/testdata/pinned.dwschema +31 -0
- package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
- package/src/dogwood/testdata/read-after-login.dw +17 -0
- package/src/dogwood/testdata/temporal-policies.dw +53 -0
- package/src/dogwood/trace.test.ts +231 -0
- package/src/dogwood/trace.ts +471 -0
- package/src/dogwood/upstream.ts +41 -0
- package/src/dogwood/window.ts +124 -0
- package/src/index.ts +76 -0
- package/src/lint/audit-catalog.ts +64 -0
- package/src/lint/post-synth/dogwood-helpers.ts +139 -0
- package/src/lint/post-synth/dwd-post-synth.test.ts +374 -0
- package/src/lint/post-synth/dwdc010.ts +62 -0
- package/src/lint/post-synth/dwdc011.ts +61 -0
- package/src/lint/post-synth/dwdc012.ts +46 -0
- package/src/lint/post-synth/dwdc013.ts +64 -0
- package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
- package/src/lint/post-synth/dwde010.ts +130 -0
- package/src/lint/post-synth/dwde011.ts +108 -0
- package/src/lint/post-synth/dwds010.ts +46 -0
- package/src/lint/post-synth/index.ts +14 -0
- package/src/lint/post-synth/post-synth.test.ts +7 -3
- package/src/op/activities/index.ts +27 -0
- package/src/plugin.test.ts +3 -2
- package/src/plugin.ts +30 -0
- package/src/policy-text.ts +128 -0
- package/src/serializer.ts +71 -109
- package/src/skills/chant-cedar-dogwood.md +327 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dogwood dialect's docs pages (#1662, epic #1646).
|
|
3
|
+
*
|
|
4
|
+
* Separate from `./docs.ts` because the dialect is a surface inside this
|
|
5
|
+
* lexicon rather than a lexicon of its own: its pages sit in one sidebar group
|
|
6
|
+
* and get written and reviewed together, while `docs.ts` stays the cedar
|
|
7
|
+
* site's own shape. They are wired in as `extraPages` with `sidebar: false`
|
|
8
|
+
* plus a `sidebarExtra` group, so every page is reachable — Starlight does not
|
|
9
|
+
* auto-discover, and a page no sidebar entry points at exists only for whoever
|
|
10
|
+
* types the URL (#1312).
|
|
11
|
+
*
|
|
12
|
+
* The facts here come from the #1657 upstream verification and from this
|
|
13
|
+
* lexicon's own `src/dogwood/`. Where the two ever disagree the code wins: the
|
|
14
|
+
* builders are what a reader will actually run.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Overview ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const dogwoodOverview = `[Dogwood](https://github.com/dogwood-policy/dogwood) is Cedar with temporal
|
|
20
|
+
operators. A policy can ask what already happened in a session — was there a
|
|
21
|
+
login in the last hour, how much has been transferred in the last fifteen
|
|
22
|
+
minutes, has anything touched a classified document since the session started —
|
|
23
|
+
so approval-before-action, rate limits and budgets become policy instead of
|
|
24
|
+
application code.
|
|
25
|
+
|
|
26
|
+
A \`.dw\` file is a Cedar policy with extra clause forms. Its head is Cedar's,
|
|
27
|
+
byte for byte, and its action schema is an ordinary \`.cedarschema\`.
|
|
28
|
+
|
|
29
|
+
\`\`\`
|
|
30
|
+
@id("read_after_login")
|
|
31
|
+
permit (
|
|
32
|
+
principal,
|
|
33
|
+
action == Drupe::Action::"Read",
|
|
34
|
+
resource
|
|
35
|
+
)
|
|
36
|
+
when temporal {
|
|
37
|
+
formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
|
|
38
|
+
};
|
|
39
|
+
\`\`\`
|
|
40
|
+
|
|
41
|
+
## Pre-release, and what that means here
|
|
42
|
+
|
|
43
|
+
Upstream calls itself a reference interpreter and says, in bold on its own
|
|
44
|
+
README, that it is **not intended for production use**. The gaps it enumerates:
|
|
45
|
+
no event timestamp validation, no event authentication, no trace durability,
|
|
46
|
+
unsandboxed Rhai in providers, no audit logging, no multi-tenancy isolation,
|
|
47
|
+
and an \`http_get\` provider with no SSRF protection.
|
|
48
|
+
|
|
49
|
+
Most of those are a runtime consumer's problem rather than chant's — chant's
|
|
50
|
+
half is authoring, serialization and the walls, and evaluation stays with
|
|
51
|
+
Bedrock AgentCore Policy or whatever engine reads the emitted files. The part
|
|
52
|
+
that *is* chant's problem is that the language surface can move underneath the
|
|
53
|
+
typed builders, which is the next section.
|
|
54
|
+
|
|
55
|
+
## How upstream is governed
|
|
56
|
+
|
|
57
|
+
There is no versioning story, and that is a finding rather than a complaint.
|
|
58
|
+
|
|
59
|
+
| Question | Answer at the pinned revision |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Tags | None |
|
|
62
|
+
| GitHub releases | None |
|
|
63
|
+
| Changelog | None |
|
|
64
|
+
| Crate version | \`1.0.0\`, declared \`publish = ["brazil"]\` — an Amazon-internal registry, not crates.io |
|
|
65
|
+
| Contributions | CONTRIBUTING declares the repo a read-only mirror, not accepting external PRs, not using GitHub issues |
|
|
66
|
+
| Stability statement | Nowhere in README, CONTRIBUTING, SECURITY or the guide |
|
|
67
|
+
|
|
68
|
+
Every content change arrives as one squashed \`Sync from internal source\`
|
|
69
|
+
commit from a publish bot, authored against a repository nobody outside Amazon
|
|
70
|
+
can see. Over the repo's public life the cadence has been roughly one sync
|
|
71
|
+
every three days. A sync is a wholesale tree replacement, so it can retune the
|
|
72
|
+
grammar, rename a JSON field or swap the default macro library in a single
|
|
73
|
+
commit, and the crate will report \`1.0.0\` either way.
|
|
74
|
+
|
|
75
|
+
So a chant version gate cannot key off anything upstream publishes. What
|
|
76
|
+
\`src/dogwood/upstream.ts\` records instead is a git SHA plus the blob hashes of
|
|
77
|
+
seven files — three \`.pest\` grammars, the default macro library, and the three
|
|
78
|
+
\`dogwood-cli/src\` files whose report structs are the JSON contract. The whole
|
|
79
|
+
tree hash moves on docs-only syncs, which makes it too noisy to gate on.
|
|
80
|
+
|
|
81
|
+
Three consequences run through everything else on these pages:
|
|
82
|
+
|
|
83
|
+
- The typed builders target the **parser primitives**, never the named
|
|
84
|
+
aggregates, because the aggregates live in a file a sync can edit and a
|
|
85
|
+
caller can replace. See [Temporal Policies](../dogwood-temporal-policies/).
|
|
86
|
+
- The CLI's **JSON report structs** are the integration surface, never its
|
|
87
|
+
human text, because the human renderer is the likelier thing to get
|
|
88
|
+
cosmetically retuned. See [Validation](../dogwood-validation/).
|
|
89
|
+
- **Nothing in gating CI runs the binary.** Full \`.dw\` validation is a
|
|
90
|
+
CLI-gated check that says out loud when it did not run.
|
|
91
|
+
|
|
92
|
+
## A dialect, not a sibling lexicon
|
|
93
|
+
|
|
94
|
+
k3s beside k3d and forgejo beside github are parallel peers with separate
|
|
95
|
+
upstreams. Dogwood is not a peer: it embeds Cedar, a \`.dw\` file stripped of
|
|
96
|
+
Cedar semantics is meaningless, and the expensive machinery — schema codegen,
|
|
97
|
+
typed entity and action classes, meta-policy lint — is shared verbatim. It
|
|
98
|
+
ships as a surface inside this lexicon, with its checks under the \`DWD\` id
|
|
99
|
+
family declared on the serializer's \`extraRulePrefixes\`.
|
|
100
|
+
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
\`\`\`typescript
|
|
104
|
+
import { TemporalPolicy, TemporalEventSchema, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
105
|
+
|
|
106
|
+
export const events = new TemporalEventSchema({ schema: dogwood.defaultEventSchema() });
|
|
107
|
+
|
|
108
|
+
export const readAfterLogin = new TemporalPolicy({
|
|
109
|
+
annotations: { id: "read_after_login" },
|
|
110
|
+
action: { eq: 'Drupe::Action::"Read"' },
|
|
111
|
+
whenTemporal: [
|
|
112
|
+
dogwood.formerly(
|
|
113
|
+
"1h",
|
|
114
|
+
dogwood.predicate('Drupe::Action::"Login"', "response", {
|
|
115
|
+
"input.user": dogwood.ctx("input.user"),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
## What comes out
|
|
123
|
+
|
|
124
|
+
| File | What reads it |
|
|
125
|
+
|---|---|
|
|
126
|
+
| \`policies.dw\` | \`dogwood validate\` / \`lower\` / \`replay\` |
|
|
127
|
+
| \`events.dwschema\` | \`dogwood --event-schema\` — the service half of the schema |
|
|
128
|
+
| \`macros.dw\` | \`dogwood --macros\` — a macro library, when one is declared non-inline |
|
|
129
|
+
| \`<name>.cedar\`, \`policies.cedar.json\` | The plain-Cedar half of the same policy set, unchanged |
|
|
130
|
+
|
|
131
|
+
A build with no temporal policies emits none of the first three and behaves
|
|
132
|
+
exactly as it did before. A build with both halves emits both from one pass,
|
|
133
|
+
with policy ids derived the same way on each leg.
|
|
134
|
+
|
|
135
|
+
## Deploying it: AgentCore
|
|
136
|
+
|
|
137
|
+
\`AWS::BedrockAgentCore::Policy\` — generated by the
|
|
138
|
+
[aws lexicon](/chant/lexicons/aws/) — is where a temporal policy is actually
|
|
139
|
+
deployed. Its \`Definition\` is a two-arm \`oneOf\`: \`Cedar.Statement\` for plain
|
|
140
|
+
Cedar, \`Policy.Statement\` for anything else. The second arm is what a \`.dw\`
|
|
141
|
+
policy travels in, and it is why the epic picked AgentCore as the target.
|
|
142
|
+
|
|
143
|
+
\`\`\`typescript
|
|
144
|
+
import { agentCoreStagedPolicy } from "@intentius/chant-lexicon-cedar";
|
|
145
|
+
|
|
146
|
+
new BedrockAgentCorePolicy({
|
|
147
|
+
PolicyEngineId: engine.ref(),
|
|
148
|
+
...agentCoreStagedPolicy("writeNeedsApproval", writeNeedsApproval, "log-only"),
|
|
149
|
+
});
|
|
150
|
+
\`\`\`
|
|
151
|
+
|
|
152
|
+
\`agentCorePolicyDefinition(name, policy)\` picks the arm from the policy itself:
|
|
153
|
+
a \`TemporalPolicy\`, or any props carrying a temporal clause, goes to \`Policy\`;
|
|
154
|
+
plain Cedar goes to \`Cedar\`. Nothing in the cedar lexicon imports the aws one —
|
|
155
|
+
the seam is the data shape, the same rule the AVP embedding follows.
|
|
156
|
+
|
|
157
|
+
\`EnforcementMode\` is the staging dial, and a temporal rule is the case that
|
|
158
|
+
needs it most: \`LOG_ONLY\` is evaluated on every request with its decision
|
|
159
|
+
observed rather than returned, so a policy whose behaviour depends on unreplayed
|
|
160
|
+
traffic can be watched before it binds. Promotion is one token, \`"log-only"\` to
|
|
161
|
+
\`"enforce"\`.
|
|
162
|
+
|
|
163
|
+
The resource carries a statement and nothing else, so the event schema has
|
|
164
|
+
nowhere to live in it and is registered with the engine separately. DWDC013
|
|
165
|
+
warns when a build embeds temporal text and emits no \`.dwschema\` beside it,
|
|
166
|
+
because a deployed statement whose event kinds nobody registered matches
|
|
167
|
+
nothing and stops doing its job without failing.
|
|
168
|
+
|
|
169
|
+
The worked example is \`lexicons/cedar/examples/agentcore-policy\`.
|
|
170
|
+
|
|
171
|
+
## What chant does not do
|
|
172
|
+
|
|
173
|
+
chant does not lower. \`dogwood lower\` compiles a \`.dw\` set to plain Cedar with
|
|
174
|
+
the temporal conditions hoisted into \`context.*\` slots; that is upstream's
|
|
175
|
+
semantics to own, and a reimplementation would drift the first time a sync
|
|
176
|
+
changed it. Where the lowered form is wanted, chant shells to the binary.
|
|
177
|
+
|
|
178
|
+
chant does not evaluate at request time. Temporal decisions are made by the
|
|
179
|
+
policy engine in front of the traffic, and chant has no seat there. What chant
|
|
180
|
+
does have is the offline half: \`PolicyReplayOp\` replays a declared set against
|
|
181
|
+
recorded history through upstream's own interpreter and reports where the
|
|
182
|
+
verdicts diverged from what the policy set was supposed to decide.
|
|
183
|
+
|
|
184
|
+
## The pages
|
|
185
|
+
|
|
186
|
+
- [Temporal Policies](../dogwood-temporal-policies/) — the builders, the
|
|
187
|
+
operators, and which of them are macros
|
|
188
|
+
- [Event Schemas](../dogwood-event-schemas/) — the \`.dwschema\` surface and the
|
|
189
|
+
\`callerPrincipal\` pin
|
|
190
|
+
- [Validation](../dogwood-validation/) — which checks always run and which need
|
|
191
|
+
the binary
|
|
192
|
+
- [Replay](../dogwood-replay/) — typed traces, \`PolicyReplayOp\`, and the trap
|
|
193
|
+
that makes half a trace pass silently
|
|
194
|
+
`;
|
|
195
|
+
|
|
196
|
+
// ── Temporal policies ─────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
export const dogwoodTemporalPolicies = `A \`Dogwood::TemporalPolicy\` is a \`Cedar::Policy\` with three extra clause
|
|
199
|
+
forms. Upstream's policy grammar differs from Cedar's in exactly one rule:
|
|
200
|
+
|
|
201
|
+
\`\`\`
|
|
202
|
+
cond = { cond_kw ~ (extension_marker | guardrails_tag? ~ "{" ~ expr ~ "}") }
|
|
203
|
+
\`\`\`
|
|
204
|
+
|
|
205
|
+
| Prop | Emits | What it is |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| \`when\` / \`unless\` | \`when { … }\` | Ordinary Cedar expression strings, same as \`Cedar::Policy\` |
|
|
208
|
+
| \`whenGuardrails\` / \`unlessGuardrails\` | \`when guardrails { … }\` | A Cedar expression with a tag upstream discards when lowering. It marks a clause for a reader; it does not change what the policy means |
|
|
209
|
+
| \`whenTemporal\` / \`unlessTemporal\` | \`when temporal { … }\` | The temporal sub-language, dispatched to a different parser |
|
|
210
|
+
|
|
211
|
+
Clause order in the emitted file is fixed — every \`when\` form, then every
|
|
212
|
+
\`unless\` form — rather than taken from the author. Conditions are a
|
|
213
|
+
conjunction, so order carries no meaning, and fixing it means a policy that
|
|
214
|
+
gains a temporal clause does not reshuffle the clauses already there.
|
|
215
|
+
|
|
216
|
+
## The primitives
|
|
217
|
+
|
|
218
|
+
These seven are the whole temporal keyword set in upstream's
|
|
219
|
+
\`extension/temporal/grammar.pest\`. Everything else you will read about dogwood
|
|
220
|
+
is built out of them.
|
|
221
|
+
|
|
222
|
+
| Builder | Renders | Notes |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| \`formerly(w, φ)\` | \`formerly within 1h φ\` | φ held at some point in the window |
|
|
225
|
+
| \`previous(w, φ)\` | \`previous within 30s φ\` | φ held at the immediately preceding timepoint in the window |
|
|
226
|
+
| \`since(φ, w, ψ)\` | \`φ since within 1h ψ\` | Infix; φ has held continuously since ψ |
|
|
227
|
+
| \`exists(binder, φ)\` | \`exists (total: Long). φ\` | Binds a value for the body to compare |
|
|
228
|
+
| \`tp(binder)\` | \`tp(t)\` | Binds the timepoint under evaluation |
|
|
229
|
+
| \`count(binders, φ)\` | \`count for (t: Timepoint). where φ\` | The aggregation domain is mandatory |
|
|
230
|
+
| \`sum(over, binders, φ)\` | \`sum a for (a: Long), (t: Timepoint). where φ\` | \`over\` names the summed variable |
|
|
231
|
+
|
|
232
|
+
Plus \`and\`, \`not\`, \`compare\`, and \`predicate\` for an event head.
|
|
233
|
+
|
|
234
|
+
Two properties of the operator set are worth stating plainly. All of them are
|
|
235
|
+
**past-only** — there is no future operator, and no way to write one. And
|
|
236
|
+
\`formerly\`, \`previous\` and \`since\` all carry a **mandatory window**: an
|
|
237
|
+
integer and one of \`s\`, \`m\`, \`h\`, \`d\`, and nothing else. The builders take the
|
|
238
|
+
window as an argument, so a windowless operator has nowhere to live; DWDC012
|
|
239
|
+
catches the ones that arrive by other routes.
|
|
240
|
+
|
|
241
|
+
## Four policies
|
|
242
|
+
|
|
243
|
+
Approval before action:
|
|
244
|
+
|
|
245
|
+
\`\`\`typescript
|
|
246
|
+
import { TemporalPolicy, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
247
|
+
|
|
248
|
+
export const readAfterLogin = new TemporalPolicy({
|
|
249
|
+
annotations: { id: "read_after_login" },
|
|
250
|
+
action: { eq: 'Drupe::Action::"Read"' },
|
|
251
|
+
whenTemporal: [
|
|
252
|
+
dogwood.formerly(
|
|
253
|
+
"1h",
|
|
254
|
+
dogwood.predicate('Drupe::Action::"Login"', "response", {
|
|
255
|
+
"input.user": dogwood.ctx("input.user"),
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
\`\`\`
|
|
261
|
+
|
|
262
|
+
A rate limit, from the \`count\` primitive:
|
|
263
|
+
|
|
264
|
+
\`\`\`typescript
|
|
265
|
+
export const rateLimited = new TemporalPolicy({
|
|
266
|
+
annotations: { id: "rate_limited" },
|
|
267
|
+
action: { eq: 'Drupe::Action::"Transfer"' },
|
|
268
|
+
whenTemporal: [
|
|
269
|
+
dogwood.compare(
|
|
270
|
+
dogwood.count(
|
|
271
|
+
[dogwood.typedBinder("t", "Timepoint")],
|
|
272
|
+
dogwood.formerly(
|
|
273
|
+
"15m",
|
|
274
|
+
dogwood.and(dogwood.predicate('Drupe::Action::"Transfer"', "request"), dogwood.tp("t")),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
"<",
|
|
278
|
+
5,
|
|
279
|
+
),
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
\`\`\`
|
|
283
|
+
|
|
284
|
+
A budget, from \`sum\` behind a macro, with \`exists\` naming the total:
|
|
285
|
+
|
|
286
|
+
\`\`\`typescript
|
|
287
|
+
import { TemporalMacroLibrary, TemporalPolicy, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
288
|
+
|
|
289
|
+
const sumFormerly = dogwood.defTemporalMacro(
|
|
290
|
+
"sum_formerly",
|
|
291
|
+
["?a", "?w", "?body"],
|
|
292
|
+
dogwood.sum(
|
|
293
|
+
"?a",
|
|
294
|
+
[dogwood.typedBinder("?a", "Long"), dogwood.typedBinder("$t", "Timepoint")],
|
|
295
|
+
dogwood.formerly(
|
|
296
|
+
dogwood.macroWindow("?w"),
|
|
297
|
+
dogwood.and(dogwood.macroCondition("?body"), dogwood.tp("$t")),
|
|
298
|
+
),
|
|
299
|
+
),
|
|
300
|
+
"Sums the numeric value \`?a\` over occurrences of \`?body\` within window \`?w\`.",
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
export const library = new TemporalMacroLibrary({ macros: [sumFormerly], inline: true });
|
|
304
|
+
|
|
305
|
+
export const transferBudget = new TemporalPolicy({
|
|
306
|
+
annotations: { id: "transfer_sum_over_100" },
|
|
307
|
+
action: { eq: 'Drupe::Action::"Alert"' },
|
|
308
|
+
whenTemporal: [
|
|
309
|
+
dogwood.exists(
|
|
310
|
+
dogwood.typedBinder("total", "Long"),
|
|
311
|
+
dogwood.and(
|
|
312
|
+
dogwood.compare(
|
|
313
|
+
dogwood.call("sum_formerly", [
|
|
314
|
+
dogwood.varRef("a"),
|
|
315
|
+
dogwood.interval("1h"),
|
|
316
|
+
dogwood.predicate('Drupe::Action::"Transfer"', "request", {
|
|
317
|
+
"input.user": dogwood.varRef("_"),
|
|
318
|
+
"input.amount": dogwood.varRef("a"),
|
|
319
|
+
}),
|
|
320
|
+
]),
|
|
321
|
+
"==",
|
|
322
|
+
dogwood.varRef("total"),
|
|
323
|
+
),
|
|
324
|
+
dogwood.compare(dogwood.varRef("total"), ">", 100),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
],
|
|
328
|
+
});
|
|
329
|
+
\`\`\`
|
|
330
|
+
|
|
331
|
+
Sequencing, with a guardrail and a break-glass exemption:
|
|
332
|
+
|
|
333
|
+
\`\`\`typescript
|
|
334
|
+
export const noToolAfterSensitiveRead = new TemporalPolicy({
|
|
335
|
+
effect: "forbid",
|
|
336
|
+
annotations: { id: "no_tool_after_sensitive_read" },
|
|
337
|
+
action: { eq: 'Drupe::Action::"Invoke"' },
|
|
338
|
+
whenGuardrails: ['context.input.tool != "audit"'],
|
|
339
|
+
whenTemporal: [
|
|
340
|
+
dogwood.since(
|
|
341
|
+
dogwood.predicate('Drupe::Action::"Read"', "response", {
|
|
342
|
+
"output.classification": dogwood.varRef("c"),
|
|
343
|
+
}),
|
|
344
|
+
"30m",
|
|
345
|
+
dogwood.predicate('Drupe::Action::"Login"', "request"),
|
|
346
|
+
),
|
|
347
|
+
],
|
|
348
|
+
unless: ['principal in Drupe::Group::"breakglass"'],
|
|
349
|
+
});
|
|
350
|
+
\`\`\`
|
|
351
|
+
|
|
352
|
+
Those four emit this, and the golden test in \`src/dogwood/serialize.test.ts\`
|
|
353
|
+
pins it byte for byte:
|
|
354
|
+
|
|
355
|
+
\`\`\`
|
|
356
|
+
// Sums the numeric value \`?a\` over occurrences of \`?body\` within window \`?w\`.
|
|
357
|
+
def temporal sum_formerly(?a, ?w, ?body) {
|
|
358
|
+
sum ?a for (?a: Long), ($t: Timepoint). where formerly within ?w (?body && tp($t))
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
@id("read_after_login")
|
|
362
|
+
permit (
|
|
363
|
+
principal,
|
|
364
|
+
action == Drupe::Action::"Read",
|
|
365
|
+
resource
|
|
366
|
+
)
|
|
367
|
+
when temporal {
|
|
368
|
+
formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
@id("transfer_sum_over_100")
|
|
372
|
+
permit (
|
|
373
|
+
principal,
|
|
374
|
+
action == Drupe::Action::"Alert",
|
|
375
|
+
resource
|
|
376
|
+
)
|
|
377
|
+
when temporal {
|
|
378
|
+
exists (total: Long). (sum_formerly(a, 1h, Drupe::Action::"Transfer"::request{ input.user: _, input.amount: a })) == total && total > 100
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
@id("no_tool_after_sensitive_read")
|
|
382
|
+
forbid (
|
|
383
|
+
principal,
|
|
384
|
+
action == Drupe::Action::"Invoke",
|
|
385
|
+
resource
|
|
386
|
+
)
|
|
387
|
+
when guardrails { context.input.tool != "audit" }
|
|
388
|
+
when temporal {
|
|
389
|
+
Drupe::Action::"Read"::response{ output.classification: c } since within 30m Drupe::Action::"Login"::request{}
|
|
390
|
+
}
|
|
391
|
+
unless { principal in Drupe::Group::"breakglass" };
|
|
392
|
+
|
|
393
|
+
@id("rate_limited")
|
|
394
|
+
permit (
|
|
395
|
+
principal,
|
|
396
|
+
action == Drupe::Action::"Transfer",
|
|
397
|
+
resource
|
|
398
|
+
)
|
|
399
|
+
when temporal {
|
|
400
|
+
(count for (t: Timepoint). where formerly within 15m (Drupe::Action::"Transfer"::request{} && tp(t))) < 5
|
|
401
|
+
};
|
|
402
|
+
\`\`\`
|
|
403
|
+
|
|
404
|
+
## Primitives versus macros
|
|
405
|
+
|
|
406
|
+
This is the distinction to get right, and the reason the builder list above is
|
|
407
|
+
shorter than most write-ups of dogwood.
|
|
408
|
+
|
|
409
|
+
\`count_within\`, \`sum_within\` and \`count_distinct_within\` are **not**
|
|
410
|
+
operators. They are macros defined in
|
|
411
|
+
\`dogwood-language/configuration/default_macros.dw\`, alongside \`bind\`:
|
|
412
|
+
|
|
413
|
+
\`\`\`
|
|
414
|
+
def temporal count_within(?w, ?s) {
|
|
415
|
+
count for ($t: Timepoint). where (formerly within ?w (?s && tp($t)))
|
|
416
|
+
};
|
|
417
|
+
\`\`\`
|
|
418
|
+
|
|
419
|
+
\`once\` is not even that. It appears in upstream's examples as an ordinary
|
|
420
|
+
user-defined macro and ships in no library at all. (The grammar rule behind
|
|
421
|
+
\`formerly\` is internally named \`once_op\`, which is where the confusion
|
|
422
|
+
starts.) If you want \`once\`, define it — chant will not pretend it exists.
|
|
423
|
+
|
|
424
|
+
A caller who passes \`--macros\` replaces the entire default library, so a
|
|
425
|
+
policy built on \`count_within\` is a policy built on an assumption about the
|
|
426
|
+
far end. chant therefore exposes the four as **calls**:
|
|
427
|
+
|
|
428
|
+
\`\`\`typescript
|
|
429
|
+
dogwood.countWithin("15m", dogwood.predicate('Drupe::Action::"Transfer"', "request"));
|
|
430
|
+
// count_within(15m, Drupe::Action::"Transfer"::request{})
|
|
431
|
+
\`\`\`
|
|
432
|
+
|
|
433
|
+
A call that resolves to nothing at the other end is a missing-macro error,
|
|
434
|
+
which is comprehensible. A first-class builder emitting a name the callee's
|
|
435
|
+
library does not define would be a mystery.
|
|
436
|
+
|
|
437
|
+
The way to stop assuming is to emit the definitions yourself:
|
|
438
|
+
|
|
439
|
+
\`\`\`typescript
|
|
440
|
+
import { TemporalMacroLibrary, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
441
|
+
|
|
442
|
+
export const macros = new TemporalMacroLibrary({ macros: dogwood.defaultMacroLibrary() });
|
|
443
|
+
\`\`\`
|
|
444
|
+
|
|
445
|
+
That writes \`macros.dw\` with upstream's four definitions verbatim. With
|
|
446
|
+
\`inline: true\` they go at the top of \`policies.dw\` instead, and a policy set's
|
|
447
|
+
own \`def\` shadows a same-named library macro — which makes inlining the
|
|
448
|
+
strongest form: the definitions travel with the policies and win over whatever
|
|
449
|
+
\`--macros\` the caller supplies.
|
|
450
|
+
|
|
451
|
+
## Writing macros
|
|
452
|
+
|
|
453
|
+
\`\`\`typescript
|
|
454
|
+
dogwood.defTemporalMacro("once", ["?w", "?s"], dogwood.formerly(dogwood.macroWindow("?w"), dogwood.macroCondition("?s")));
|
|
455
|
+
dogwood.defCedarMacro("is_small", ["?n"], "?n < 100");
|
|
456
|
+
\`\`\`
|
|
457
|
+
|
|
458
|
+
Two sigils, and they are not interchangeable:
|
|
459
|
+
|
|
460
|
+
- \`?p\` splices the call-site argument literally. Build one with
|
|
461
|
+
\`macroWindow("?w")\` in window position, \`macroCondition("?s")\` in condition
|
|
462
|
+
position, \`macroTerm("?a")\` in term position.
|
|
463
|
+
- \`$t\` is a fresh binder the macro introduces, gensym'd at every expansion.
|
|
464
|
+
|
|
465
|
+
Both are legal only inside a macro body; upstream's well-formedness pass
|
|
466
|
+
rejects them anywhere else, so the builders validate them at definition time.
|
|
467
|
+
|
|
468
|
+
A call site supplies a window as a **bare interval** — \`once(1h, …)\`, no
|
|
469
|
+
\`within\` — because the keyword belongs to the operator and stays in the body.
|
|
470
|
+
That is \`dogwood.interval("1h")\`.
|
|
471
|
+
|
|
472
|
+
## Terms
|
|
473
|
+
|
|
474
|
+
Numbers and booleans lift to literals. A bare string does not, and that is
|
|
475
|
+
deliberate: \`"alice"\` is a Cedar string literal and \`alice\` is a binder
|
|
476
|
+
reference, and guessing which one was meant is how a policy silently stops
|
|
477
|
+
matching.
|
|
478
|
+
|
|
479
|
+
| Builder | Renders |
|
|
480
|
+
|---|---|
|
|
481
|
+
| \`str("alice")\` | \`"alice"\` |
|
|
482
|
+
| \`varRef("a")\` | \`a\` |
|
|
483
|
+
| \`ctx("input.user")\` | \`context.input.user\` |
|
|
484
|
+
| \`scopeRef("principal", "dept")\` | \`principal.dept\` |
|
|
485
|
+
| \`entityUid('Drupe::OAuthUser::"alice"')\` | \`Drupe::OAuthUser::"alice"\` |
|
|
486
|
+
| \`decimalOf("1.50")\` | \`decimal("1.50")\` |
|
|
487
|
+
| \`arrayOf(1, 2)\` | \`[1, 2]\` |
|
|
488
|
+
| \`wildcard()\` | \`*\` |
|
|
489
|
+
|
|
490
|
+
## Precedence, and who handles it
|
|
491
|
+
|
|
492
|
+
The renderer parenthesises rather than relying on the reader knowing the
|
|
493
|
+
grammar. \`!\` binds tighter than \`since\` and \`&&\`, so \`!a since within W b\`
|
|
494
|
+
negates only \`a\`; an aggregate's \`where\` body is greedy, so
|
|
495
|
+
\`count for (…). where φ == 3\` would read \`== 3\` as part of φ. Aggregates and
|
|
496
|
+
macro calls in comparison position are wrapped on both sides, and \`exists\`
|
|
497
|
+
binds maximally to the right so it is wrapped inside an \`&&\` chain.
|
|
498
|
+
|
|
499
|
+
## The escape hatch
|
|
500
|
+
|
|
501
|
+
\`dogwood.raw("formerly within 1h …")\` emits temporal source verbatim. It is
|
|
502
|
+
the one builder that can produce something the walls exist to catch, which is
|
|
503
|
+
why the walls read the serialized text rather than the in-memory tree — see
|
|
504
|
+
[Validation](../dogwood-validation/).
|
|
505
|
+
|
|
506
|
+
## Next
|
|
507
|
+
|
|
508
|
+
- [Event Schemas](../dogwood-event-schemas/) — what the \`request\`/\`response\`
|
|
509
|
+
kinds in those predicates come from
|
|
510
|
+
- [Validation](../dogwood-validation/) — what checks a policy set before it
|
|
511
|
+
leaves the build
|
|
512
|
+
`;
|
|
513
|
+
|
|
514
|
+
// ── Event schemas ─────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
export const dogwoodEventSchemas = `A dogwood policy set is checked against two schemas, and only one of them is
|
|
517
|
+
required.
|
|
518
|
+
|
|
519
|
+
| Half | Format | Flag | Required |
|
|
520
|
+
|---|---|---|---|
|
|
521
|
+
| Action schema | Cedar \`.cedarschema\` — entities, actions, each action's \`context\` | \`--policy-schema\` | Yes, for \`validate\`, \`lower\` and \`replay\` |
|
|
522
|
+
| Service schema | \`.dwschema\` event DSL, a \`providers.json\`, a \`.dw\` macro library | \`--event-schema\`, \`--providers\`, \`--macros\` | No |
|
|
523
|
+
|
|
524
|
+
The action schema is the one the rest of this lexicon already generates from —
|
|
525
|
+
see [Schema](../schema/). This page is about the other half.
|
|
526
|
+
|
|
527
|
+
With all three service flags omitted, upstream falls back to
|
|
528
|
+
\`ServiceSchema::defaults()\`: \`request\` (deciding), \`response\` and \`error\`
|
|
529
|
+
kinds, a universal \`pin callerPrincipal = principal\`, a 24h \`max_window\` cap,
|
|
530
|
+
no providers, and the embedded default macro library.
|
|
531
|
+
|
|
532
|
+
## The \`.dwschema\` surface
|
|
533
|
+
|
|
534
|
+
The grammar is 136 lines of pest and purely syntactic: an optional
|
|
535
|
+
\`max_window\` directive, then a sequence of event declarations.
|
|
536
|
+
|
|
537
|
+
\`\`\`
|
|
538
|
+
max_window = 30d
|
|
539
|
+
|
|
540
|
+
decision event <A>::request {
|
|
541
|
+
...inputs(A),
|
|
542
|
+
pin callerPrincipal: principalType(A) = principal,
|
|
543
|
+
callerResource: resourceType(A),
|
|
544
|
+
requestId: String,
|
|
545
|
+
}
|
|
546
|
+
\`\`\`
|
|
547
|
+
|
|
548
|
+
\`A\` is a symbolic action binder, not an action. **The file names no actions at
|
|
549
|
+
all** — it says what shape an event of each kind has, for whichever action it
|
|
550
|
+
is derived against. That is why DWDC010 can check a predicate's event *kind*
|
|
551
|
+
against the emitted schema but not its action: the action half of that check
|
|
552
|
+
lives in the \`.cedarschema\`, and it is the CLI's to make.
|
|
553
|
+
|
|
554
|
+
Event kind names are author-defined. \`request\`, \`response\` and \`error\` are
|
|
555
|
+
conventional, not fixed.
|
|
556
|
+
|
|
557
|
+
| Builder | Emits |
|
|
558
|
+
|---|---|
|
|
559
|
+
| \`spreadInputs()\` / \`spreadOutputs()\` | \`...inputs(A)\` / \`...outputs(A)\` |
|
|
560
|
+
| \`field("requestId", concrete("String"))\` | \`requestId: String\` |
|
|
561
|
+
| \`field("callerResource", resourceType())\` | \`callerResource: resourceType(A)\` |
|
|
562
|
+
| \`field("meta", record([…]))\` | a nested record, addressed as \`meta.member\` |
|
|
563
|
+
| \`pinnedField(name, type, pinPrincipal())\` | \`pin name: … = principal\` |
|
|
564
|
+
| \`pinnedField(name, type, pinContext("input.user"))\` | \`pin name: … = context.input.user\` |
|
|
565
|
+
|
|
566
|
+
A pinned field must be a leaf; upstream requires the \`pin\` prefix and the
|
|
567
|
+
\`= …\` clause together, and the builder enforces both rather than deferring to
|
|
568
|
+
the parser.
|
|
569
|
+
|
|
570
|
+
## The default, and the pin
|
|
571
|
+
|
|
572
|
+
\`\`\`typescript
|
|
573
|
+
import { TemporalEventSchema, dogwood } from "@intentius/chant-lexicon-cedar";
|
|
574
|
+
|
|
575
|
+
export const events = new TemporalEventSchema({ schema: dogwood.defaultEventSchema() });
|
|
576
|
+
\`\`\`
|
|
577
|
+
|
|
578
|
+
That reproduces upstream's \`pinned.dwschema\` — the shape
|
|
579
|
+
\`ServiceSchema::defaults()\` uses — and emits \`events.dwschema\`:
|
|
580
|
+
|
|
581
|
+
\`\`\`
|
|
582
|
+
// The default event-schema shape: request/response/error, each correlated to
|
|
583
|
+
// the deciding request's principal.
|
|
584
|
+
|
|
585
|
+
decision event <A>::request {
|
|
586
|
+
...inputs(A),
|
|
587
|
+
pin callerPrincipal: principalType(A) = principal,
|
|
588
|
+
callerResource: resourceType(A),
|
|
589
|
+
requestId: String,
|
|
590
|
+
sessionId: String,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
event <A>::response {
|
|
594
|
+
...inputs(A),
|
|
595
|
+
...outputs(A),
|
|
596
|
+
pin callerPrincipal: principalType(A) = principal,
|
|
597
|
+
callerResource: resourceType(A),
|
|
598
|
+
requestId: String,
|
|
599
|
+
sessionId: String,
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
event <A>::error {
|
|
603
|
+
...inputs(A),
|
|
604
|
+
pin callerPrincipal: principalType(A) = principal,
|
|
605
|
+
callerResource: resourceType(A),
|
|
606
|
+
requestId: String,
|
|
607
|
+
sessionId: String,
|
|
608
|
+
}
|
|
609
|
+
\`\`\`
|
|
610
|
+
|
|
611
|
+
**The pin is the thing to understand before writing your own schema.**
|
|
612
|
+
\`pin callerPrincipal = principal\` correlates every temporal predicate to the
|
|
613
|
+
deciding request's principal: events logged by other principals are invisible
|
|
614
|
+
to \`formerly\`, \`since\` and every aggregate over them.
|
|
615
|
+
|
|
616
|
+
Supplying *any* event schema opts out of upstream's default wholesale. So a
|
|
617
|
+
schema emitted without a pin does not merely fail to add a correlation — it
|
|
618
|
+
removes one the policy author very likely assumed, and every predicate in the
|
|
619
|
+
set starts matching other principals' events.
|
|
620
|
+
|
|
621
|
+
That is a legitimate design; cross-principal correlation is a reason to write
|
|
622
|
+
your own schema. It is also a decision, so chant makes it a named argument:
|
|
623
|
+
|
|
624
|
+
\`\`\`typescript
|
|
625
|
+
export const events = new TemporalEventSchema({
|
|
626
|
+
schema: dogwood.defaultEventSchema({ pinCallerPrincipal: false }),
|
|
627
|
+
});
|
|
628
|
+
\`\`\`
|
|
629
|
+
|
|
630
|
+
which stamps the reasoning into the emitted file as a comment, and which
|
|
631
|
+
DWDS010 reports as a warning in the build. Neither stops you. Both make the
|
|
632
|
+
choice visible in a diff.
|
|
633
|
+
|
|
634
|
+
## \`max_window\`
|
|
635
|
+
|
|
636
|
+
The directive caps how far back any operator in the set may look. Absent, the
|
|
637
|
+
cap is upstream's 24h default — the same 24h that applies when no schema is
|
|
638
|
+
supplied at all.
|
|
639
|
+
|
|
640
|
+
\`\`\`typescript
|
|
641
|
+
dogwood.defaultEventSchema({ maxWindow: "30d" }); // max_window = 30d
|
|
642
|
+
\`\`\`
|
|
643
|
+
|
|
644
|
+
DWDC011 does the arithmetic in TypeScript and fails the build on a window past
|
|
645
|
+
the cap, with no binary involved. Where several schemas are emitted the
|
|
646
|
+
tightest cap wins, and macro-call intervals count: \`once(48h, …)\` expands
|
|
647
|
+
through \`within ?w\` and looks back exactly as far as \`formerly within 48h\`.
|
|
648
|
+
|
|
649
|
+
## Several schemas
|
|
650
|
+
|
|
651
|
+
One \`.dwschema\` per file, because \`max_window\` is a single directive at the
|
|
652
|
+
top and concatenating two schemas would emit something upstream rejects. A
|
|
653
|
+
build with more than one gives each an explicit filename:
|
|
654
|
+
|
|
655
|
+
\`\`\`typescript
|
|
656
|
+
export const gateway = new TemporalEventSchema({
|
|
657
|
+
schema: dogwood.defaultEventSchema({ maxWindow: "30d" }),
|
|
658
|
+
filename: "gateway.dwschema",
|
|
659
|
+
});
|
|
660
|
+
\`\`\`
|
|
661
|
+
|
|
662
|
+
Two schemas targeting one filename is a serializer warning and only the first
|
|
663
|
+
is written — a silent merge would produce a file that parses as neither.
|
|
664
|
+
|
|
665
|
+
## Providers
|
|
666
|
+
|
|
667
|
+
The third service flag, \`--providers\`, takes a \`providers.json\` whose entries
|
|
668
|
+
carry \`argumentTypes\`, an \`outputType\` and an \`implementation\`. chant has no
|
|
669
|
+
typed builder for it today; the CLI adapter's bundle type accepts provider text
|
|
670
|
+
if you assemble it, and the build's planner does not emit one.
|
|
671
|
+
|
|
672
|
+
One upstream trap worth recording even so: the CLI reads \`--providers\` as raw
|
|
673
|
+
text and **never resolves \`scriptFile\`**. Rhai has to be inlined under
|
|
674
|
+
\`implementation.script\`, or \`replay\` fails per-evaluation with "rhai
|
|
675
|
+
implementation has no script" while \`validate\` and \`lower\` still pass.
|
|
676
|
+
|
|
677
|
+
## Next
|
|
678
|
+
|
|
679
|
+
- [Validation](../dogwood-validation/) — DWDC010, DWDC011 and DWDS010 in full
|
|
680
|
+
- [Replay](../dogwood-replay/) — where the events these schemas describe
|
|
681
|
+
actually come from
|
|
682
|
+
`;
|
|
683
|
+
|
|
684
|
+
// ── Validation ────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
export const dogwoodValidation = `Validation splits by what needs a binary, and the split is the point.
|
|
687
|
+
|
|
688
|
+
Everything answerable in TypeScript runs on every build and gates. Full \`.dw\`
|
|
689
|
+
validation needs upstream's own frontend, which ships as a Rust CLI and nothing
|
|
690
|
+
else — no npm package, no wasm build, no bindings — so it runs when the binary
|
|
691
|
+
is there and says so out loud when it is not.
|
|
692
|
+
|
|
693
|
+
| Check | Severity | Needs the binary |
|
|
694
|
+
|---|---|---|
|
|
695
|
+
| DWDC010 — a temporal predicate names a declared event kind | error | no |
|
|
696
|
+
| DWDC011 — a window fits inside \`max_window\` | error | no |
|
|
697
|
+
| DWDC012 — \`formerly\`/\`previous\`/\`since\` carries its window | error | no |
|
|
698
|
+
| DWDC013 — an embedded AgentCore temporal statement has its event schema emitted | warning | no |
|
|
699
|
+
| DWDS010 — an emitted event schema pins something | warning | no |
|
|
700
|
+
| DWDE010 — the set validates clean under \`dogwood validate\` | error | yes |
|
|
701
|
+
| DWDE011 — the lowered Cedar validates under \`cedar-wasm\` | error | yes |
|
|
702
|
+
|
|
703
|
+
The DWD family is an ordinary set of
|
|
704
|
+
[post-synth checks](/chant/guide/organizational-policy/) under the prefix the
|
|
705
|
+
cedar serializer declares in \`extraRulePrefixes\`. There is no second policy
|
|
706
|
+
engine here; dogwood is a target, the same as Cedar.
|
|
707
|
+
|
|
708
|
+
Every one of them reads the **emitted text**, not the in-memory model, for the
|
|
709
|
+
same reason the CED checks read \`policies.cedar.json\`: \`chant audit\` runs over
|
|
710
|
+
a checked-in artifact chant did not write, and a wall that only fires on
|
|
711
|
+
chant's own output is not a wall. It also keeps the builders and the walls
|
|
712
|
+
independent — DWDC012 catches a windowless \`formerly\` even though the builders
|
|
713
|
+
cannot construct one, because \`raw()\` and a hand-written \`.dw\` both can.
|
|
714
|
+
|
|
715
|
+
## The TypeScript walls
|
|
716
|
+
|
|
717
|
+
**DWDC010** compares every predicate head in the temporal regions of a \`.dw\`
|
|
718
|
+
file against the event kinds the emitted \`.dwschema\` declares. Upstream rejects
|
|
719
|
+
the same thing with code \`extension\`. The check is silent when no \`.dwschema\`
|
|
720
|
+
was emitted: with none supplied, \`ServiceSchema::defaults()\` decides the kinds
|
|
721
|
+
at the far end, and guessing that a project's out-of-band schema matches
|
|
722
|
+
upstream's default would fail builds for a policy set that is fine.
|
|
723
|
+
|
|
724
|
+
**DWDC011** fires with or without an emitted schema, because the cap applies
|
|
725
|
+
either way — 24h by default. See
|
|
726
|
+
[Event Schemas](../dogwood-event-schemas/#max_window).
|
|
727
|
+
|
|
728
|
+
**DWDC012** is the wall behind the typed builders. \`formerly(w, body)\` has
|
|
729
|
+
nowhere to put a missing window, so the builders make it unrepresentable; the
|
|
730
|
+
check is what covers \`raw()\`, hand-written files, and audits of trees chant
|
|
731
|
+
never wrote.
|
|
732
|
+
|
|
733
|
+
**DWDC013** asks the question prior to DWDC010's, and only of statements that
|
|
734
|
+
left the \`.dw\` file behind. A policy embedded in \`Definition.Policy.Statement\`
|
|
735
|
+
travels as one string; the engine at the other end cannot match a temporal
|
|
736
|
+
predicate until it knows what an event is. A build that embeds temporal text
|
|
737
|
+
and emits no \`.dwschema\` has shipped half a policy — the statement deploys, the
|
|
738
|
+
predicates match nothing, and a \`formerly\`-guarded forbid stops denying.
|
|
739
|
+
Warning rather than error, because a project may register the service schema
|
|
740
|
+
through a separate pipeline, and failing that build would be chant asserting a
|
|
741
|
+
fact it cannot check.
|
|
742
|
+
|
|
743
|
+
**DWDS010** is report-only. chant does not know whether cross-principal
|
|
744
|
+
correlation was wanted, only that an unpinned schema should not slip through a
|
|
745
|
+
review unremarked.
|
|
746
|
+
|
|
747
|
+
Scanning is confined to the temporal regions of a file — every
|
|
748
|
+
\`temporal { … }\` body and every \`def temporal\` body — so a Cedar attribute
|
|
749
|
+
named \`since\` is not mistaken for the operator, and \`context.retryWindow == 3\`
|
|
750
|
+
is not mistaken for a window.
|
|
751
|
+
|
|
752
|
+
## The CLI-gated half
|
|
753
|
+
|
|
754
|
+
**DWDE010** runs \`dogwood validate --format json\` over each emitted policy set
|
|
755
|
+
and reports every finding. What it catches that the walls cannot: macro
|
|
756
|
+
expansion, the temporal type checker, and the Cedar body checked against the
|
|
757
|
+
action schema through upstream's own frontend.
|
|
758
|
+
|
|
759
|
+
**DWDE011** takes the \`dogwood lower\` output — plain Cedar with the temporal
|
|
760
|
+
conditions hoisted into \`context.*\` slots, plus an augmented schema declaring
|
|
761
|
+
them — and runs the published \`@cedar-policy/cedar-wasm\` over it. That is a
|
|
762
|
+
different validator from the one vendored inside upstream, which makes a
|
|
763
|
+
finding here meaningful: it is drift between the Cedar upstream pins and the
|
|
764
|
+
Cedar the rest of chant validates against. The #1657 verification put all 86
|
|
765
|
+
upstream example bundles through this exact path and every one validated clean
|
|
766
|
+
in strict mode.
|
|
767
|
+
|
|
768
|
+
### When the binary is absent
|
|
769
|
+
|
|
770
|
+
One \`info\` finding, naming the binary, where chant looked, and the issue. Not
|
|
771
|
+
silence. A check that quietly passes when it could not run is claiming a
|
|
772
|
+
guarantee it never made.
|
|
773
|
+
|
|
774
|
+
## Pointing chant at a binary
|
|
775
|
+
|
|
776
|
+
There is no published build. You build it from the pinned revision:
|
|
777
|
+
|
|
778
|
+
\`\`\`bash
|
|
779
|
+
git clone https://github.com/dogwood-policy/dogwood
|
|
780
|
+
cd dogwood && git checkout 5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c
|
|
781
|
+
cargo build --release
|
|
782
|
+
\`\`\`
|
|
783
|
+
|
|
784
|
+
Resolution order:
|
|
785
|
+
|
|
786
|
+
1. An explicit \`configureDogwoodCli({ binary })\` call — taken as given, since
|
|
787
|
+
its caller knows.
|
|
788
|
+
2. \`$CHANT_DOGWOOD_BINARY\`.
|
|
789
|
+
3. \`cedar.dogwood.binary\` in a \`chant.config.json\`, resolved by walking up
|
|
790
|
+
from the working directory.
|
|
791
|
+
4. \`dogwood\` on \`PATH\`.
|
|
792
|
+
|
|
793
|
+
\`\`\`bash
|
|
794
|
+
export CHANT_DOGWOOD_BINARY=/path/to/dogwood/target/release/dogwood
|
|
795
|
+
\`\`\`
|
|
796
|
+
|
|
797
|
+
\`\`\`json
|
|
798
|
+
{ "cedar": { "dogwood": { "binary": "./vendor/dogwood" } } }
|
|
799
|
+
\`\`\`
|
|
800
|
+
|
|
801
|
+
The config knob reads \`chant.config.json\` only, and that is a real limitation
|
|
802
|
+
rather than an oversight: a post-synth check's \`check()\` is synchronous, while
|
|
803
|
+
chant's config loader is async and, under \`chant build --sandbox\`, evaluates a
|
|
804
|
+
\`chant.config.ts\` in a child process. JSON is data, so reading it executes
|
|
805
|
+
nothing. A project on \`chant.config.ts\` uses the environment variable or the
|
|
806
|
+
programmatic override.
|
|
807
|
+
|
|
808
|
+
A path from the environment or the config that is not executable is resolved
|
|
809
|
+
past rather than returned to fail later, and the advisory names where chant
|
|
810
|
+
looked.
|
|
811
|
+
|
|
812
|
+
## Why exit codes decide nothing
|
|
813
|
+
|
|
814
|
+
Three properties of the CLI shape the adapter, all verified against the pinned
|
|
815
|
+
sources.
|
|
816
|
+
|
|
817
|
+
**Exit 2 is ambiguous.** It covers a rejected policy set *and* clap's own usage
|
|
818
|
+
error for an unknown flag — and upstream's published guide claims exit 1 for
|
|
819
|
+
the latter. Reading a bare non-zero exit as "your policy is bad" would fail a
|
|
820
|
+
build over a flag rename in a sync nobody outside Amazon can review. So the
|
|
821
|
+
adapter branches on the JSON on stdout, in both directions: a \`passed: false\`
|
|
822
|
+
with a zero exit is still a rejection, and the reverse is still a pass.
|
|
823
|
+
|
|
824
|
+
**There are two JSON shapes.** A type-check finding arrives in a report —
|
|
825
|
+
\`passed\`, \`passed_without_warnings\`, \`errors[]\`, \`warnings[]\`. A fatal parse,
|
|
826
|
+
macro or lowering error replaces the whole report with a bare error object
|
|
827
|
+
carrying the same diagnostic fields at the top level plus \`related[]\`. Both
|
|
828
|
+
normalize into one diagnostic type, so nothing downstream has to know which
|
|
829
|
+
arrived.
|
|
830
|
+
|
|
831
|
+
**A run that produced no usable JSON is neither a pass nor a rejection.** It is
|
|
832
|
+
reported at \`warning\` severity as "could not be validated", and the policy set
|
|
833
|
+
is explicitly described as neither accepted nor rejected.
|
|
834
|
+
|
|
835
|
+
Two smaller contract facts the adapter encodes: \`--format json\` writes to
|
|
836
|
+
stdout for success and fatal alike, and \`--emit\` is ignored under
|
|
837
|
+
\`--format json\` (the JSON always carries all three lowered artifacts), so it is
|
|
838
|
+
not passed.
|
|
839
|
+
|
|
840
|
+
Diagnostic labels are **byte offsets** into the \`.dw\` source, and findings
|
|
841
|
+
report them as byte ranges. Converting to line and column would mean
|
|
842
|
+
re-deriving line breaks over a file the adapter does not hold, and a wrong line
|
|
843
|
+
number is worse than an honest offset.
|
|
844
|
+
|
|
845
|
+
## Nothing in gating CI runs it
|
|
846
|
+
|
|
847
|
+
By design, from the epic: upstream instability is priced, not absorbed. The
|
|
848
|
+
CLI-gated checks are for a developer with the binary and for on-demand
|
|
849
|
+
harnesses in the \`forgejo-runtime-e2e\` shape. \`PolicyReplayOp\` shares that
|
|
850
|
+
rule and the same binary discovery — see [Replay](../dogwood-replay/) — with
|
|
851
|
+
one difference: a replay step with no binary **fails**, where a build check
|
|
852
|
+
with no binary reports and moves on. A check that could not run should not
|
|
853
|
+
block a build; a replay that could not run has produced no answer at all.
|
|
854
|
+
|
|
855
|
+
## Next
|
|
856
|
+
|
|
857
|
+
- [Replay](../dogwood-replay/) — the third verb, wrapped as an activity and an
|
|
858
|
+
Op rather than as a build check
|
|
859
|
+
- [Lint Rules](../lint-rules/) — the cedar half of the same check set
|
|
860
|
+
`;
|
|
861
|
+
|
|
862
|
+
// ── Replay ────────────────────────────────────────────────────────
|
|
863
|
+
|
|
864
|
+
export const dogwoodReplay = `\`dogwood replay\` evaluates a policy set against a recorded event trace and
|
|
865
|
+
returns a verdict per decision point. It is how a temporal policy gets tested
|
|
866
|
+
at all.
|
|
867
|
+
|
|
868
|
+
A plain Cedar policy is decidable from its source: given the schema, a build
|
|
869
|
+
can say whether it parses, whether it type-checks, and what it applies to. The
|
|
870
|
+
DWDC and CEDC checks already do that. A temporal policy is not decidable that
|
|
871
|
+
way — whether \`formerly within 1h Login::response{ … }\` fires depends on a
|
|
872
|
+
history nobody has replayed. So the check is a replay against recorded decision
|
|
873
|
+
history, and the answer moves as the history does. That puts it on the observe
|
|
874
|
+
end of the lifecycle dial, beside \`WorkflowAuditOp\`, with a finding mode as the
|
|
875
|
+
reconcile step.
|
|
876
|
+
|
|
877
|
+
Three pieces ship: a typed trace builder, a \`dogwoodReplay\` activity, and the
|
|
878
|
+
\`PolicyReplayOp\` composite that pairs them. The worked example is
|
|
879
|
+
\`lexicons/cedar/examples/policy-replay\`.
|
|
880
|
+
|
|
881
|
+
## The Op
|
|
882
|
+
|
|
883
|
+
\`\`\`typescript
|
|
884
|
+
// ops/policy-replay.op.ts
|
|
885
|
+
import { PolicyReplayOp } from "@intentius/chant-lexicon-cedar";
|
|
886
|
+
import { readAfterLoginExpectations } from "../trace/read-after-login";
|
|
887
|
+
|
|
888
|
+
export const { op } = PolicyReplayOp({
|
|
889
|
+
name: "policy-replay",
|
|
890
|
+
policiesPath: "dist/policies.dw",
|
|
891
|
+
policySchemaPath: "schema.cedarschema",
|
|
892
|
+
eventSchemaPath: "dist/events.dwschema",
|
|
893
|
+
tracePath: "trace/read-after-login.log",
|
|
894
|
+
expect: readAfterLoginExpectations,
|
|
895
|
+
onFinding: "report",
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
export default op;
|
|
899
|
+
\`\`\`
|
|
900
|
+
|
|
901
|
+
\`\`\`bash
|
|
902
|
+
npx chant run policy-replay
|
|
903
|
+
\`\`\`
|
|
904
|
+
|
|
905
|
+
Three phases:
|
|
906
|
+
|
|
907
|
+
| Phase | Step | What it does |
|
|
908
|
+
|---|---|---|
|
|
909
|
+
| Artifacts | \`chantBuild\` | Emits \`policies.dw\`, the \`.cedarschema\` and the \`.dwschema\` the replay reads. Pass \`buildScript: false\` when they are checked in and the phase is dropped rather than run empty |
|
|
910
|
+
| Replay | \`dogwoodReplay\` | Runs \`dogwood replay --format json\` over the bundle and the trace, writes the divergence report |
|
|
911
|
+
| Report | \`dogwoodReplayReport\` | Reads that report and acts on the finding mode |
|
|
912
|
+
|
|
913
|
+
The report file (\`dist/dogwood-replay.json\` by default) is the seam between the
|
|
914
|
+
last two phases, for the same reason \`dist/fly.json\` is the seam between
|
|
915
|
+
\`build:fly\` and \`flyApply\`: Op steps do not hand return values to one another,
|
|
916
|
+
so a phase boundary needs an artifact to be a real boundary.
|
|
917
|
+
|
|
918
|
+
The Replay step carries \`outcomeAttribute: { name: "Divergences", from: "findings" }\`,
|
|
919
|
+
so "show me the replays that found something" is one filter rather than a log
|
|
920
|
+
read. \`onFinding\` takes \`report | issue | pull-request\`; \`report\` prints the
|
|
921
|
+
markdown, and the other two hand back a title and body for whatever opens them
|
|
922
|
+
— the cedar lexicon has no forge client and does not grow one, the same
|
|
923
|
+
division \`workflowSupplyChainAudit\` draws. \`failOnDivergence\` defaults to
|
|
924
|
+
false: an observe-dial Op reports, and a red run is the caller's decision.
|
|
925
|
+
|
|
926
|
+
The composite ships from cedar, not from temporal, because it hands back an Op
|
|
927
|
+
and nothing else. It imports \`@intentius/chant/op\` and carries no dependency on
|
|
928
|
+
the temporal lexicon. A project that wants it scheduled pairs it with a
|
|
929
|
+
\`TemporalSchedule\` of its own — two lines, project-side, rather than a config
|
|
930
|
+
flag that would drag the dependency in for everyone.
|
|
931
|
+
|
|
932
|
+
## Typed traces
|
|
933
|
+
|
|
934
|
+
\`traceEvent()\` builds one line. \`renderTrace()\` renders a list.
|
|
935
|
+
\`traceFixture()\` does both and refuses to hand back a trace that would weaken
|
|
936
|
+
its own replay.
|
|
937
|
+
|
|
938
|
+
\`\`\`typescript
|
|
939
|
+
import { dogwood } from "@intentius/chant-lexicon-cedar";
|
|
940
|
+
|
|
941
|
+
const { entityRef, traceEvent } = dogwood;
|
|
942
|
+
|
|
943
|
+
const ALICE = 'Drupe::OAuthUser::"alice"';
|
|
944
|
+
const GATEWAY = 'Drupe::Gateway::"gw1"';
|
|
945
|
+
|
|
946
|
+
const session = {
|
|
947
|
+
scope: { principal: ALICE, resource: GATEWAY },
|
|
948
|
+
context: { input: { user: "alice" } },
|
|
949
|
+
} as const;
|
|
950
|
+
|
|
951
|
+
const injected = (requestId: string) => ({
|
|
952
|
+
callerPrincipal: entityRef(ALICE),
|
|
953
|
+
callerResource: entityRef(GATEWAY),
|
|
954
|
+
requestId,
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
export const trace = [
|
|
958
|
+
traceEvent({ ...session, timestamp: 0, action: 'Drupe::Action::"Login"', record: injected("u1") }),
|
|
959
|
+
traceEvent({ ...session, timestamp: 0, action: 'Drupe::Action::"Login"', kind: "response", record: injected("u1") }),
|
|
960
|
+
traceEvent({ ...session, timestamp: 10, action: 'Drupe::Action::"Read"', record: injected("u2") }),
|
|
961
|
+
traceEvent({ ...session, timestamp: 7200, action: 'Drupe::Action::"Read"', record: injected("u3") }),
|
|
962
|
+
];
|
|
963
|
+
\`\`\`
|
|
964
|
+
|
|
965
|
+
\`\`\`
|
|
966
|
+
@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")
|
|
967
|
+
\`\`\`
|
|
968
|
+
|
|
969
|
+
Compare the input to the output: \`input\` was written once, under \`context\`,
|
|
970
|
+
and comes out in the \`request_context\` envelope **and** in the logged record.
|
|
971
|
+
\`kind\` defaults to \`request\`. Values render in Cedar surface
|
|
972
|
+
forms: strings quote themselves, \`entityRef()\` renders a uid bare,
|
|
973
|
+
\`decimalValue("1.50")\` keeps a scale a JS number would lose, and a non-integer
|
|
974
|
+
\`number\` throws rather than emitting something the parser reads differently.
|
|
975
|
+
|
|
976
|
+
## The both-bags trap
|
|
977
|
+
|
|
978
|
+
Each line carries two field bags and they are not the same bag.
|
|
979
|
+
|
|
980
|
+
\`\`\`
|
|
981
|
+
@10 … request_context(input: { user: "alice" }) Drupe::Action::"Read"::request(input: { user: "alice" }, callerPrincipal: …)
|
|
982
|
+
└── the Cedar request is built from this └── temporal predicates match against this
|
|
983
|
+
\`\`\`
|
|
984
|
+
|
|
985
|
+
\`formerly within 1h Drupe::Action::"Login"::response{ input.user: context.input.user }\`
|
|
986
|
+
compares the *past* login's \`input.user\`, out of the logged record, against the
|
|
987
|
+
*current* request's \`context.input.user\`, out of the \`request_context\`
|
|
988
|
+
envelope. Fill one bag and not the other and nothing errors: the replay exits 0
|
|
989
|
+
with a verdict that tested half of what it claims.
|
|
990
|
+
|
|
991
|
+
So the default is both bags, and the weaker trace takes an explicit opt-out:
|
|
992
|
+
|
|
993
|
+
| \`bags\` | Where a \`context\` group lands |
|
|
994
|
+
|---|---|
|
|
995
|
+
| \`"both"\` (default) | \`request_context\` and the logged record |
|
|
996
|
+
| \`"record-only"\` | The logged record alone — \`context.*\` is absent from the Cedar request |
|
|
997
|
+
| \`"context-only"\` | The envelope alone — no temporal predicate can match the group |
|
|
998
|
+
|
|
999
|
+
\`record\` is the other half of the input, and it is deliberately separate: the
|
|
1000
|
+
event schema's own injections (\`callerPrincipal\`, \`callerResource\`,
|
|
1001
|
+
\`requestId\`, \`sessionId\`) belong to the logged record and are never part of the
|
|
1002
|
+
Cedar request.
|
|
1003
|
+
|
|
1004
|
+
## The action-naming trap
|
|
1005
|
+
|
|
1006
|
+
Action names must be fully qualified — \`Drupe::Action::"Read"\`, never \`Read\`. A
|
|
1007
|
+
short name leaves every temporal predicate unmatched while Cedar still
|
|
1008
|
+
authorizes. \`traceEvent()\` rejects one at construction, as do \`entityRef()\` and
|
|
1009
|
+
\`traceEntity()\`.
|
|
1010
|
+
|
|
1011
|
+
## Auditing a trace chant did not build
|
|
1012
|
+
|
|
1013
|
+
A trace fetched from somewhere else — an AgentCore session history, a \`.log\`
|
|
1014
|
+
recorded by hand — normalizes into the same \`TraceEvent\` list and takes the
|
|
1015
|
+
same audit:
|
|
1016
|
+
|
|
1017
|
+
\`\`\`typescript
|
|
1018
|
+
const issues = dogwood.auditTrace(events);
|
|
1019
|
+
\`\`\`
|
|
1020
|
+
|
|
1021
|
+
| Kind | What it means |
|
|
1022
|
+
|---|---|
|
|
1023
|
+
| \`single-bag\` | A group is in one bag and not the other, so one side of the check silently misses |
|
|
1024
|
+
| \`no-request-context\` | A deciding event has no envelope at all, so every \`context.*\` test misses |
|
|
1025
|
+
| \`empty-record\` | An event logs no fields, so no temporal predicate can match it |
|
|
1026
|
+
| \`out-of-order\` | A timestamp goes backwards; history accumulates in file order, so a window sees something different |
|
|
1027
|
+
|
|
1028
|
+
Every one of those makes a replay *weaker* rather than making it fail, which is
|
|
1029
|
+
the class a green run hides. \`decisionKinds\` defaults to \`["request"]\` — a
|
|
1030
|
+
history-only event never becomes a Cedar request, so a missing envelope on one
|
|
1031
|
+
is not a weakening and is not reported. The truth is whichever kinds the
|
|
1032
|
+
project's \`.dwschema\` marks \`decision\`, and that file is not visible from the
|
|
1033
|
+
audit.
|
|
1034
|
+
|
|
1035
|
+
\`traceFixture(events)\` runs the same audit and **throws** on any finding,
|
|
1036
|
+
naming the \`allow\` list that would let it through. A fixture that weakens its
|
|
1037
|
+
own replay fails at build time instead of producing a green run that proves
|
|
1038
|
+
nothing.
|
|
1039
|
+
|
|
1040
|
+
## Expectations
|
|
1041
|
+
|
|
1042
|
+
\`\`\`typescript
|
|
1043
|
+
export const expectations = [
|
|
1044
|
+
{ timestamp: 0, verdict: "deny", note: "the login request itself is not permitted" },
|
|
1045
|
+
{ timestamp: 10, verdict: "allow", determiningRules: [0], note: "the login is ten seconds old" },
|
|
1046
|
+
{ timestamp: 7200, verdict: "deny", note: "the login is two hours stale" },
|
|
1047
|
+
];
|
|
1048
|
+
\`\`\`
|
|
1049
|
+
|
|
1050
|
+
Three expectations for four trace lines, and that is the point of writing them
|
|
1051
|
+
against \`timestamp\` rather than \`index\`: \`Login::response\` is history-only
|
|
1052
|
+
under the default event schema, so it contributes to the window and produces no
|
|
1053
|
+
verdict. \`index\` is the position in the *decision* stream, not the trace line
|
|
1054
|
+
number, and it shifts whenever a trace gains a history-only event.
|
|
1055
|
+
|
|
1056
|
+
\`determiningRules\` is the second half of the assertion. A decision that comes
|
|
1057
|
+
out right for the wrong reason — the correct verdict carried by a different
|
|
1058
|
+
rule — is drift the verdict alone cannot show.
|
|
1059
|
+
|
|
1060
|
+
What \`compareVerdicts\` reports: a verdict that differs from the expectation; a
|
|
1061
|
+
verdict that matches but was determined by different rules; a decision point an
|
|
1062
|
+
expectation named that never occurred; a decision point that occurred and
|
|
1063
|
+
nothing expected. Per-evaluation errors are reported even when the expectation
|
|
1064
|
+
matched, and — when no expectations were written at all — an errored evaluation
|
|
1065
|
+
is still a finding, because a provider with no inlined Rhai script would
|
|
1066
|
+
otherwise replay "clean".
|
|
1067
|
+
|
|
1068
|
+
## The trace format
|
|
1069
|
+
|
|
1070
|
+
One event per line. Blank lines are skipped, a leading BOM is stripped, and
|
|
1071
|
+
there is **no comment syntax** — a \`//\` is an ordinary part of a value, so URLs
|
|
1072
|
+
survive and an attribution header would be parsed as an event and rejected with
|
|
1073
|
+
"timepoint must start with \`@\`".
|
|
1074
|
+
|
|
1075
|
+
\`\`\`
|
|
1076
|
+
@<timestamp> [scope(...)] [entities(...)] [request_context(...)] <Ns>::Action::"<Name>"::<kind>(<field>: <value>, ...)
|
|
1077
|
+
\`\`\`
|
|
1078
|
+
|
|
1079
|
+
The timestamp is an \`i64\` after \`@\`. The three envelopes are optional and must
|
|
1080
|
+
appear in that order. Values use Cedar surface forms: entity refs, quoted
|
|
1081
|
+
strings, integers, decimals like \`1.50\`, booleans, arrays, nested records.
|
|
1082
|
+
|
|
1083
|
+
## Reading the run
|
|
1084
|
+
|
|
1085
|
+
Human output is one line per decision point:
|
|
1086
|
+
|
|
1087
|
+
\`\`\`
|
|
1088
|
+
@0 (time point 0): DENY
|
|
1089
|
+
@10 (time point 1): ALLOW [rules: 0]
|
|
1090
|
+
@7200 (time point 2): DENY
|
|
1091
|
+
\`\`\`
|
|
1092
|
+
|
|
1093
|
+
JSON gives \`{verdicts: [{index, timestamp, verdict, determining_rules, errors}]}\`.
|
|
1094
|
+
History-only events produce no line.
|
|
1095
|
+
|
|
1096
|
+
**Replay exits 0 even when every verdict is DENY.** A non-zero exit means the
|
|
1097
|
+
trace or the policy set failed to load, never that a policy denied. The adapter
|
|
1098
|
+
in \`src/dogwood/cli.ts\` reads the JSON and not the exit code, here as
|
|
1099
|
+
everywhere; an unrecognised verdict string is read as a deny rather than
|
|
1100
|
+
dropped, because dropping an entry would shift every later index.
|
|
1101
|
+
|
|
1102
|
+
## It needs the binary, and does not pretend otherwise
|
|
1103
|
+
|
|
1104
|
+
The Replay phase shells to upstream's CLI. There is no npm package and no wasm
|
|
1105
|
+
build — see [Validation](../dogwood-validation/) for how chant finds a binary
|
|
1106
|
+
and how to build one.
|
|
1107
|
+
|
|
1108
|
+
Without one the step **fails** and says where chant looked. It does not degrade
|
|
1109
|
+
to a pass, and an unusable invocation or a fatal (a malformed trace line, an
|
|
1110
|
+
unparseable policy set) throws rather than reporting zero divergences. A replay
|
|
1111
|
+
that did not happen is not a replay that found nothing — which is also why
|
|
1112
|
+
nothing in gating CI executes the binary.
|
|
1113
|
+
|
|
1114
|
+
## Next
|
|
1115
|
+
|
|
1116
|
+
- [Validation](../dogwood-validation/) — the two verbs that run inside a build,
|
|
1117
|
+
and the binary knobs replay shares
|
|
1118
|
+
- [The Dogwood Dialect](../dogwood/) — what pre-release means for all of this
|
|
1119
|
+
`;
|