@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.
Files changed (149) hide show
  1. package/README.md +106 -2
  2. package/dist/agentcore/embed.d.ts +189 -0
  3. package/dist/agentcore/embed.d.ts.map +1 -0
  4. package/dist/agentcore/enforcement.d.ts +76 -0
  5. package/dist/agentcore/enforcement.d.ts.map +1 -0
  6. package/dist/agentcore/scan.d.ts +46 -0
  7. package/dist/agentcore/scan.d.ts.map +1 -0
  8. package/dist/codegen/docs-dogwood.d.ts +21 -0
  9. package/dist/codegen/docs-dogwood.d.ts.map +1 -0
  10. package/dist/codegen/docs.d.ts.map +1 -1
  11. package/dist/codegen/package.d.ts.map +1 -1
  12. package/dist/config.d.ts +25 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/dogwood/cli.d.ts +247 -0
  15. package/dist/dogwood/cli.d.ts.map +1 -0
  16. package/dist/dogwood/event-schema.d.ts +161 -0
  17. package/dist/dogwood/event-schema.d.ts.map +1 -0
  18. package/dist/dogwood/index.d.ts +39 -0
  19. package/dist/dogwood/index.d.ts.map +1 -0
  20. package/dist/dogwood/macros.d.ts +96 -0
  21. package/dist/dogwood/macros.d.ts.map +1 -0
  22. package/dist/dogwood/policy.d.ts +120 -0
  23. package/dist/dogwood/policy.d.ts.map +1 -0
  24. package/dist/dogwood/replay-activity.d.ts +196 -0
  25. package/dist/dogwood/replay-activity.d.ts.map +1 -0
  26. package/dist/dogwood/replay-op.d.ts +165 -0
  27. package/dist/dogwood/replay-op.d.ts.map +1 -0
  28. package/dist/dogwood/scan.d.ts +109 -0
  29. package/dist/dogwood/scan.d.ts.map +1 -0
  30. package/dist/dogwood/serialize.d.ts +66 -0
  31. package/dist/dogwood/serialize.d.ts.map +1 -0
  32. package/dist/dogwood/temporal.d.ts +259 -0
  33. package/dist/dogwood/temporal.d.ts.map +1 -0
  34. package/dist/dogwood/trace.d.ts +215 -0
  35. package/dist/dogwood/trace.d.ts.map +1 -0
  36. package/dist/dogwood/upstream.d.ts +41 -0
  37. package/dist/dogwood/upstream.d.ts.map +1 -0
  38. package/dist/dogwood/window.d.ts +73 -0
  39. package/dist/dogwood/window.d.ts.map +1 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/integrity.json +15 -3
  43. package/dist/lint/audit-catalog.d.ts.map +1 -1
  44. package/dist/lint/post-synth/dogwood-helpers.d.ts +63 -0
  45. package/dist/lint/post-synth/dogwood-helpers.d.ts.map +1 -0
  46. package/dist/lint/post-synth/dwdc010.d.ts +25 -0
  47. package/dist/lint/post-synth/dwdc010.d.ts.map +1 -0
  48. package/dist/lint/post-synth/dwdc011.d.ts +19 -0
  49. package/dist/lint/post-synth/dwdc011.d.ts.map +1 -0
  50. package/dist/lint/post-synth/dwdc012.d.ts +21 -0
  51. package/dist/lint/post-synth/dwdc012.d.ts.map +1 -0
  52. package/dist/lint/post-synth/dwdc013.d.ts +33 -0
  53. package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
  54. package/dist/lint/post-synth/dwde010.d.ts +32 -0
  55. package/dist/lint/post-synth/dwde010.d.ts.map +1 -0
  56. package/dist/lint/post-synth/dwde011.d.ts +33 -0
  57. package/dist/lint/post-synth/dwde011.d.ts.map +1 -0
  58. package/dist/lint/post-synth/dwds010.d.ts +24 -0
  59. package/dist/lint/post-synth/dwds010.d.ts.map +1 -0
  60. package/dist/lint/post-synth/index.d.ts.map +1 -1
  61. package/dist/manifest.json +1 -1
  62. package/dist/okf/index.md +7 -0
  63. package/dist/okf/rules/DWDC010.md +11 -0
  64. package/dist/okf/rules/DWDC011.md +11 -0
  65. package/dist/okf/rules/DWDC012.md +11 -0
  66. package/dist/okf/rules/DWDC013.md +15 -0
  67. package/dist/okf/rules/DWDE010.md +11 -0
  68. package/dist/okf/rules/DWDE011.md +11 -0
  69. package/dist/okf/rules/DWDS010.md +11 -0
  70. package/dist/okf/types/Policy.md +1 -0
  71. package/dist/op/activities/index.d.ts +18 -0
  72. package/dist/op/activities/index.d.ts.map +1 -0
  73. package/dist/plugin.d.ts.map +1 -1
  74. package/dist/policy-text.d.ts +53 -0
  75. package/dist/policy-text.d.ts.map +1 -0
  76. package/dist/rules/dogwood-helpers.ts +139 -0
  77. package/dist/rules/dwdc010.ts +62 -0
  78. package/dist/rules/dwdc011.ts +61 -0
  79. package/dist/rules/dwdc012.ts +46 -0
  80. package/dist/rules/dwdc013.ts +64 -0
  81. package/dist/rules/dwde010.ts +130 -0
  82. package/dist/rules/dwde011.ts +108 -0
  83. package/dist/rules/dwds010.ts +46 -0
  84. package/dist/serializer.d.ts +10 -18
  85. package/dist/serializer.d.ts.map +1 -1
  86. package/dist/skills/chant-cedar-authoring.md +180 -0
  87. package/dist/skills/chant-cedar-avp-embedding.md +125 -0
  88. package/dist/skills/chant-cedar-dogwood.md +327 -0
  89. package/dist/skills/chant-cedar-meta-policy.md +119 -0
  90. package/package.json +7 -2
  91. package/src/agentcore/embed.test.ts +254 -0
  92. package/src/agentcore/embed.ts +399 -0
  93. package/src/agentcore/enforcement.test.ts +43 -0
  94. package/src/agentcore/enforcement.ts +92 -0
  95. package/src/agentcore/scan.ts +119 -0
  96. package/src/codegen/docs-dogwood.ts +1119 -0
  97. package/src/codegen/docs.ts +66 -1
  98. package/src/codegen/package.ts +3 -2
  99. package/src/config.test.ts +12 -0
  100. package/src/config.ts +28 -0
  101. package/src/dogwood/cli.test.ts +513 -0
  102. package/src/dogwood/cli.ts +666 -0
  103. package/src/dogwood/event-schema.test.ts +218 -0
  104. package/src/dogwood/event-schema.ts +318 -0
  105. package/src/dogwood/index.ts +271 -0
  106. package/src/dogwood/macros.test.ts +104 -0
  107. package/src/dogwood/macros.ts +229 -0
  108. package/src/dogwood/policy.test.ts +94 -0
  109. package/src/dogwood/policy.ts +141 -0
  110. package/src/dogwood/replay-activity.test.ts +481 -0
  111. package/src/dogwood/replay-activity.ts +506 -0
  112. package/src/dogwood/replay-op.ts +242 -0
  113. package/src/dogwood/scan.ts +287 -0
  114. package/src/dogwood/serialize.test.ts +331 -0
  115. package/src/dogwood/serialize.ts +246 -0
  116. package/src/dogwood/temporal.test.ts +272 -0
  117. package/src/dogwood/temporal.ts +592 -0
  118. package/src/dogwood/testdata/custom-kinds.dwschema +17 -0
  119. package/src/dogwood/testdata/default-macros.dw +23 -0
  120. package/src/dogwood/testdata/lowered-read-after-login.json +13 -0
  121. package/src/dogwood/testdata/max-window-raised.dwschema +25 -0
  122. package/src/dogwood/testdata/pinned.dwschema +31 -0
  123. package/src/dogwood/testdata/read-after-login.cedarschema +20 -0
  124. package/src/dogwood/testdata/read-after-login.dw +17 -0
  125. package/src/dogwood/testdata/temporal-policies.dw +53 -0
  126. package/src/dogwood/trace.test.ts +231 -0
  127. package/src/dogwood/trace.ts +471 -0
  128. package/src/dogwood/upstream.ts +41 -0
  129. package/src/dogwood/window.ts +124 -0
  130. package/src/index.ts +76 -0
  131. package/src/lint/audit-catalog.ts +64 -0
  132. package/src/lint/post-synth/dogwood-helpers.ts +139 -0
  133. package/src/lint/post-synth/dwd-post-synth.test.ts +374 -0
  134. package/src/lint/post-synth/dwdc010.ts +62 -0
  135. package/src/lint/post-synth/dwdc011.ts +61 -0
  136. package/src/lint/post-synth/dwdc012.ts +46 -0
  137. package/src/lint/post-synth/dwdc013.ts +64 -0
  138. package/src/lint/post-synth/dwde-post-synth.test.ts +368 -0
  139. package/src/lint/post-synth/dwde010.ts +130 -0
  140. package/src/lint/post-synth/dwde011.ts +108 -0
  141. package/src/lint/post-synth/dwds010.ts +46 -0
  142. package/src/lint/post-synth/index.ts +14 -0
  143. package/src/lint/post-synth/post-synth.test.ts +7 -3
  144. package/src/op/activities/index.ts +27 -0
  145. package/src/plugin.test.ts +3 -2
  146. package/src/plugin.ts +30 -0
  147. package/src/policy-text.ts +128 -0
  148. package/src/serializer.ts +71 -109
  149. 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
+ `;