@intentius/chant-lexicon-cedar 0.44.9 → 0.44.12

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 (70) hide show
  1. package/README.md +48 -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/avp/client.d.ts +85 -8
  9. package/dist/avp/client.d.ts.map +1 -1
  10. package/dist/codegen/docs-dogwood.d.ts +21 -0
  11. package/dist/codegen/docs-dogwood.d.ts.map +1 -0
  12. package/dist/codegen/docs.d.ts.map +1 -1
  13. package/dist/dogwood/cli.d.ts +41 -0
  14. package/dist/dogwood/cli.d.ts.map +1 -1
  15. package/dist/dogwood/index.d.ts +10 -4
  16. package/dist/dogwood/index.d.ts.map +1 -1
  17. package/dist/dogwood/replay-activity.d.ts +196 -0
  18. package/dist/dogwood/replay-activity.d.ts.map +1 -0
  19. package/dist/dogwood/replay-op.d.ts +165 -0
  20. package/dist/dogwood/replay-op.d.ts.map +1 -0
  21. package/dist/dogwood/serialize.d.ts +20 -0
  22. package/dist/dogwood/serialize.d.ts.map +1 -1
  23. package/dist/dogwood/trace.d.ts +215 -0
  24. package/dist/dogwood/trace.d.ts.map +1 -0
  25. package/dist/index.d.ts +8 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/integrity.json +5 -3
  28. package/dist/lint/audit-catalog.d.ts.map +1 -1
  29. package/dist/lint/post-synth/dwdc013.d.ts +33 -0
  30. package/dist/lint/post-synth/dwdc013.d.ts.map +1 -0
  31. package/dist/lint/post-synth/index.d.ts.map +1 -1
  32. package/dist/manifest.json +1 -1
  33. package/dist/okf/index.md +1 -0
  34. package/dist/okf/rules/DWDC013.md +15 -0
  35. package/dist/okf/types/Policy.md +1 -0
  36. package/dist/op/activities/index.d.ts +18 -0
  37. package/dist/op/activities/index.d.ts.map +1 -0
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/rules/dwdc013.ts +64 -0
  40. package/dist/skills/chant-cedar-dogwood.md +327 -0
  41. package/package.json +7 -2
  42. package/src/agentcore/embed.test.ts +254 -0
  43. package/src/agentcore/embed.ts +399 -0
  44. package/src/agentcore/enforcement.test.ts +43 -0
  45. package/src/agentcore/enforcement.ts +92 -0
  46. package/src/agentcore/scan.ts +119 -0
  47. package/src/avp/OWNERSHIP.md +38 -0
  48. package/src/avp/client.test.ts +271 -0
  49. package/src/avp/client.ts +150 -16
  50. package/src/codegen/docs-dogwood.ts +1119 -0
  51. package/src/codegen/docs.ts +66 -1
  52. package/src/dogwood/cli.test.ts +122 -1
  53. package/src/dogwood/cli.ts +122 -1
  54. package/src/dogwood/index.ts +74 -1
  55. package/src/dogwood/replay-activity.test.ts +481 -0
  56. package/src/dogwood/replay-activity.ts +506 -0
  57. package/src/dogwood/replay-op.ts +242 -0
  58. package/src/dogwood/serialize.ts +37 -0
  59. package/src/dogwood/trace.test.ts +231 -0
  60. package/src/dogwood/trace.ts +471 -0
  61. package/src/index.ts +52 -0
  62. package/src/lint/audit-catalog.ts +8 -0
  63. package/src/lint/post-synth/dwd-post-synth.test.ts +119 -1
  64. package/src/lint/post-synth/dwdc013.ts +64 -0
  65. package/src/lint/post-synth/dwde-post-synth.test.ts +1 -1
  66. package/src/lint/post-synth/index.ts +2 -0
  67. package/src/op/activities/index.ts +27 -0
  68. package/src/plugin.test.ts +3 -2
  69. package/src/plugin.ts +30 -0
  70. package/src/skills/chant-cedar-dogwood.md +327 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `PolicyReplayOp` — replay a declared `.dw` set against a recorded event
3
+ * trace and report divergence (#1661, epic #1646).
4
+ *
5
+ * Shape follows `WorkflowAuditOp`: an observe-dial Op whose last phase is a
6
+ * finding mode (`report | issue | pull-request`). What it observes is not a
7
+ * moving upstream but a moving *history* — a policy set that was correct
8
+ * against last week's traffic can decide differently against this week's, and
9
+ * the deterministic build cannot see that.
10
+ *
11
+ * ## Packaging: the composite ships from cedar, not from temporal
12
+ *
13
+ * `WorkflowAuditOp` and `PipelineAuditOp` live in the temporal lexicon because
14
+ * they hand back a `TemporalSchedule` alongside the Op, and that resource is
15
+ * temporal's. This composite hands back an Op and nothing else, so it has no
16
+ * reason to reach across — it imports `@intentius/chant/op` only, exactly as
17
+ * the fly lexicon's `flyDeploy` composite does. cedar therefore keeps zero
18
+ * runtime dependency on `@intentius/chant-lexicon-temporal`.
19
+ *
20
+ * The scheduled form is a two-line project-side pairing rather than a config
21
+ * flag, and that is the deliberate cost of the decision:
22
+ *
23
+ * ```ts
24
+ * import { TemporalSchedule } from "@intentius/chant-lexicon-temporal";
25
+ *
26
+ * export const { op } = PolicyReplayOp({ name: "policy-replay", … });
27
+ * export const schedule = new TemporalSchedule({
28
+ * scheduleId: "policy-replay-schedule",
29
+ * spec: { cronExpressions: ["0 6 * * *"] },
30
+ * action: { workflowType: "policyReplayWorkflow", taskQueue: "policy-replay" },
31
+ * });
32
+ * ```
33
+ *
34
+ * A project that wants that already installs the temporal lexicon; a project
35
+ * that only wants `chant run policy-replay` on the local executor should not
36
+ * have to. `examples/policy-replay` is the worked recipe for both.
37
+ *
38
+ * ## Phases
39
+ *
40
+ * ```
41
+ * Artifacts chantBuild — emit policies.dw, the .cedarschema and the
42
+ * .dwschema the replay reads (skippable when they are checked in)
43
+ * Replay dogwoodReplay — run `dogwood replay --format json` over the
44
+ * bundle and the trace, write the divergence report
45
+ * Report dogwoodReplayReport — read that report and act on the mode
46
+ * ```
47
+ *
48
+ * The report file is the seam between the last two phases, the same way
49
+ * `dist/fly.json` is the seam between `build:fly` and `flyApply`: Op steps do
50
+ * not pass return values to one another, so a phase boundary needs an artifact
51
+ * to be a real boundary rather than a cosmetic one.
52
+ */
53
+
54
+ import { Op, activity, build, phase, type ActivityStep, type OpResource } from "@intentius/chant/op";
55
+ import type { PolicyReplayMode, ReplayExpectation } from "./replay-activity";
56
+
57
+ /** Where the Replay phase writes its report by default. */
58
+ export const DEFAULT_REPLAY_REPORT_PATH = "dist/dogwood-replay.json";
59
+
60
+ /** Options for {@link dogwoodReplayStep}. Mirrors `DogwoodReplayArgs`. */
61
+ export interface DogwoodReplayStepOpts {
62
+ policies?: string;
63
+ policiesPath?: string;
64
+ policySchema?: string;
65
+ policySchemaPath?: string;
66
+ eventSchema?: string;
67
+ eventSchemaPath?: string;
68
+ macros?: string;
69
+ macrosPath?: string;
70
+ providers?: string;
71
+ providersPath?: string;
72
+ trace?: string;
73
+ tracePath?: string;
74
+ expect?: readonly ReplayExpectation[];
75
+ mode?: PolicyReplayMode;
76
+ binary?: string;
77
+ cwd?: string;
78
+ reportPath?: string;
79
+ profile?: ActivityStep["profile"];
80
+ }
81
+
82
+ /**
83
+ * Replay a policy bundle against a trace — the typed twin of `flyApplyStep`.
84
+ *
85
+ * Resolves to the `dogwoodReplay` activity by name, which `loadActivities`
86
+ * binds when the project lists the `cedar` lexicon. Defaults to the
87
+ * `policyCheck` profile.
88
+ */
89
+ export const dogwoodReplayStep = (opts: DogwoodReplayStepOpts): ActivityStep => {
90
+ const { profile, ...args } = opts;
91
+ return activity("dogwoodReplay", args as Record<string, unknown>, profile ?? "policyCheck");
92
+ };
93
+
94
+ /** Options for {@link dogwoodReplayReportStep}. */
95
+ export interface DogwoodReplayReportStepOpts {
96
+ reportPath?: string;
97
+ mode?: PolicyReplayMode;
98
+ title?: string;
99
+ cwd?: string;
100
+ failOnDivergence?: boolean;
101
+ profile?: ActivityStep["profile"];
102
+ }
103
+
104
+ /** Read a written replay report and act on its finding mode. */
105
+ export const dogwoodReplayReportStep = (opts: DogwoodReplayReportStepOpts = {}): ActivityStep => {
106
+ const { profile, ...args } = opts;
107
+ return activity("dogwoodReplayReport", args as Record<string, unknown>, profile ?? "fastIdempotent");
108
+ };
109
+
110
+ /** Options for {@link PolicyReplayOp}. */
111
+ export interface PolicyReplayOpConfig {
112
+ /** Op name (kebab-case) — the `chant run <name>` target and the task queue base. */
113
+ name: string;
114
+ overview?: string;
115
+ /** Defaults to {@link name}. */
116
+ taskQueue?: string;
117
+
118
+ /**
119
+ * Directory every relative path below resolves against, and the one the
120
+ * build script runs in. Default `.`.
121
+ */
122
+ path?: string;
123
+ /**
124
+ * npm script that emits the `.dw` bundle. Default `build`. Pass `false` when
125
+ * the artifacts are checked in — the Artifacts phase is then omitted rather
126
+ * than run as a no-op, so the Op's phase list says what it really does.
127
+ */
128
+ buildScript?: string | false;
129
+
130
+ /** Emitted `.dw` policy set. Default `dist/policies.dw`. */
131
+ policiesPath?: string;
132
+ /** Emitted Cedar action schema. Required by the CLI; default `dist/app.cedarschema`. */
133
+ policySchemaPath?: string;
134
+ /** Emitted `.dwschema`, when the project declares one. */
135
+ eventSchemaPath?: string;
136
+ /** Emitted macro library, when the project ships one out of line. */
137
+ macrosPath?: string;
138
+ /** `providers.json`, with the Rhai inlined under `implementation.script`. */
139
+ providersPath?: string;
140
+
141
+ /** The recorded trace to replay. Required — there is nothing to replay without one. */
142
+ tracePath: string;
143
+
144
+ /** What each decision point must decide. Empty replays and reports verdicts. */
145
+ expect?: readonly ReplayExpectation[];
146
+
147
+ /** What to produce on divergence. Default `report`. */
148
+ onFinding?: PolicyReplayMode;
149
+ /** Title for the issue or pull request the finding mode calls for. */
150
+ title?: string;
151
+ /**
152
+ * Fail the Op when the replay diverged. Default false — an observe-dial Op
153
+ * reports by default, and a red run is a decision the caller makes.
154
+ */
155
+ failOnDivergence?: boolean;
156
+
157
+ /** Where the Replay phase writes its report. Default {@link DEFAULT_REPLAY_REPORT_PATH}. */
158
+ reportPath?: string;
159
+ /** Explicit `dogwood` binary path, for a runner that knows where it built one. */
160
+ binary?: string;
161
+ }
162
+
163
+ /** What {@link PolicyReplayOp} hands back. */
164
+ export interface PolicyReplayOpResources {
165
+ /** The Op — discovered by `chant run <name>`, emitted by `chant build`. */
166
+ op: InstanceType<typeof OpResource>;
167
+ }
168
+
169
+ /**
170
+ * Assemble the replay Op: emit the artifacts, replay them, act on the finding.
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * export const { op } = PolicyReplayOp({
175
+ * name: "policy-replay",
176
+ * tracePath: "trace/read-after-login.log",
177
+ * expect: [
178
+ * { timestamp: 10, verdict: "allow", determiningRules: [0] },
179
+ * { timestamp: 7200, verdict: "deny", note: "the login is two hours stale" },
180
+ * ],
181
+ * onFinding: "issue",
182
+ * });
183
+ * ```
184
+ */
185
+ export function PolicyReplayOp(config: PolicyReplayOpConfig): PolicyReplayOpResources {
186
+ const path = config.path ?? ".";
187
+ const mode = config.onFinding ?? "report";
188
+ const reportPath = config.reportPath ?? DEFAULT_REPLAY_REPORT_PATH;
189
+
190
+ const phases = [];
191
+
192
+ if (config.buildScript !== false) {
193
+ phases.push(phase("Artifacts", [build(path, { script: config.buildScript ?? "build" })]));
194
+ }
195
+
196
+ phases.push(
197
+ phase("Replay", [
198
+ {
199
+ ...dogwoodReplayStep({
200
+ cwd: path,
201
+ policiesPath: config.policiesPath ?? "dist/policies.dw",
202
+ policySchemaPath: config.policySchemaPath ?? "dist/app.cedarschema",
203
+ ...(config.eventSchemaPath ? { eventSchemaPath: config.eventSchemaPath } : {}),
204
+ ...(config.macrosPath ? { macrosPath: config.macrosPath } : {}),
205
+ ...(config.providersPath ? { providersPath: config.providersPath } : {}),
206
+ tracePath: config.tracePath,
207
+ ...(config.expect ? { expect: config.expect } : {}),
208
+ mode,
209
+ ...(config.binary ? { binary: config.binary } : {}),
210
+ reportPath,
211
+ }),
212
+ // The divergence count as a workflow search attribute, so "show me the
213
+ // replays that found something" is one filter rather than a log read.
214
+ outcomeAttribute: { name: "Divergences", from: "findings" },
215
+ },
216
+ ]),
217
+ phase("Report", [
218
+ dogwoodReplayReportStep({
219
+ cwd: path,
220
+ reportPath,
221
+ mode,
222
+ ...(config.title ? { title: config.title } : {}),
223
+ ...(config.failOnDivergence ? { failOnDivergence: true } : {}),
224
+ }),
225
+ ]),
226
+ );
227
+
228
+ return {
229
+ op: Op({
230
+ name: config.name,
231
+ overview:
232
+ config.overview ??
233
+ "Replay the declared dogwood policy set against a recorded event trace and report divergence",
234
+ taskQueue: config.taskQueue ?? config.name,
235
+ searchAttributes: {
236
+ Audit: "true",
237
+ Surface: "cedar-dogwood",
238
+ },
239
+ phases,
240
+ }),
241
+ };
242
+ }
@@ -207,3 +207,40 @@ export function serializeDogwood(entities: Map<string, Declarable>): DogwoodSeri
207
207
 
208
208
  return { files, warnings };
209
209
  }
210
+
211
+ /** One declared temporal policy, resolved: chant name, policy id, walked props. */
212
+ export interface DogwoodPolicyRecord {
213
+ /** The chant entity name — the export name on the `*.ts` file. */
214
+ name: string;
215
+ /** The policy id, as it appears in `@id(…)`. */
216
+ id: string;
217
+ /** Props with references resolved, ready for `renderTemporalPolicyText`. */
218
+ props: Record<string, unknown>;
219
+ }
220
+
221
+ /**
222
+ * Every `Dogwood::TemporalPolicy` in a build, with references walked and ids
223
+ * resolved — the temporal counterpart of `cedarPolicyRecords` (#1660).
224
+ *
225
+ * `serializeDogwood` above renders whole *files*; the AgentCore embedding needs
226
+ * the policies one at a time, because `AWS::BedrockAgentCore::Policy` carries a
227
+ * single statement and `EnforcementMode` is set per policy. Both paths walk
228
+ * references through the same visitor, so a scope that names another declared
229
+ * entity resolves identically whichever one rendered it.
230
+ */
231
+ export function dogwoodPolicyRecords(entities: Map<string, Declarable>): DogwoodPolicyRecord[] {
232
+ const entityNames = new Map<Declarable, string>();
233
+ for (const [name, entity] of entities) {
234
+ entityNames.set(entity, name);
235
+ }
236
+
237
+ const records: DogwoodPolicyRecord[] = [];
238
+ for (const [name, entity] of entities) {
239
+ if (isPropertyDeclarable(entity)) continue;
240
+ if (entity.entityType !== DOGWOOD_POLICY_TYPE) continue;
241
+
242
+ const props = walkValue(getProps(entity), entityNames, dogwoodVisitor) as Record<string, unknown>;
243
+ records.push({ name, id: resolvePolicyId(name, props), props });
244
+ }
245
+ return records;
246
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Trace construction (#1661).
3
+ *
4
+ * The line grammar is #1657 §6, read from `interpreter/log_parse.rs` at the
5
+ * pinned SHA. The tests that matter most are not the rendering ones — they are
6
+ * the two traps, because both produce a *green* replay rather than a failing
7
+ * one: a field in only one bag, and an action name that is not fully
8
+ * qualified.
9
+ */
10
+ import { describe, expect, test } from "vitest";
11
+ import {
12
+ auditTrace,
13
+ decimalValue,
14
+ entityRef,
15
+ rawValue,
16
+ renderTrace,
17
+ renderTraceLine,
18
+ renderTraceValue,
19
+ traceEntity,
20
+ traceEvent,
21
+ traceFixture,
22
+ } from "./trace";
23
+
24
+ const ALICE = 'Drupe::OAuthUser::"alice"';
25
+ const GATEWAY = 'Drupe::Gateway::"gw1"';
26
+
27
+ function login(over: Partial<Parameters<typeof traceEvent>[0]> = {}) {
28
+ return traceEvent({
29
+ timestamp: 0,
30
+ action: 'Drupe::Action::"Login"',
31
+ scope: { principal: ALICE, resource: GATEWAY },
32
+ context: { input: { user: "alice" } },
33
+ record: { callerPrincipal: entityRef(ALICE), requestId: "u1" },
34
+ ...over,
35
+ });
36
+ }
37
+
38
+ // ── The line grammar ───────────────────────────────────────────────
39
+
40
+ describe("renderTraceLine", () => {
41
+ test("renders the envelopes in the one order the parser accepts", () => {
42
+ const line = renderTraceLine(
43
+ traceEvent({
44
+ timestamp: 0,
45
+ action: 'Drupe::Action::"Login"',
46
+ scope: { principal: ALICE, resource: GATEWAY },
47
+ entities: [traceEntity(ALICE, { id: "alice" }, ['Drupe::Gateway::"gw1"'])],
48
+ context: { input: { user: "alice" } },
49
+ record: { requestId: "u1" },
50
+ }),
51
+ );
52
+
53
+ expect(line).toBe(
54
+ `@0 scope(principal: ${ALICE}, resource: ${GATEWAY}) ` +
55
+ `entities(${ALICE}: { id: "alice" } in [${GATEWAY}]) ` +
56
+ `request_context(input: { user: "alice" }) ` +
57
+ `Drupe::Action::"Login"::request(input: { user: "alice" }, requestId: "u1")`,
58
+ );
59
+ });
60
+
61
+ test("every envelope is optional", () => {
62
+ const line = renderTraceLine({
63
+ timestamp: 42,
64
+ action: 'Drupe::Action::"Read"',
65
+ kind: "response",
66
+ record: { requestId: "u9" },
67
+ });
68
+ expect(line).toBe(`@42 Drupe::Action::"Read"::response(requestId: "u9")`);
69
+ });
70
+
71
+ test("a whole trace is one event per line, newline-terminated", () => {
72
+ const text = renderTrace([login(), login({ timestamp: 10, kind: "response" })]);
73
+ expect(text.split("\n")).toHaveLength(3);
74
+ expect(text.endsWith("\n")).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe("values", () => {
79
+ test("Cedar surface forms, by JS type", () => {
80
+ expect(renderTraceValue("a")).toBe('"a"');
81
+ expect(renderTraceValue(7)).toBe("7");
82
+ expect(renderTraceValue(true)).toBe("true");
83
+ expect(renderTraceValue([1, "a"])).toBe('[1, "a"]');
84
+ expect(renderTraceValue({ a: 1 })).toBe("{ a: 1 }");
85
+ });
86
+
87
+ test("an entity reference renders unquoted", () => {
88
+ expect(renderTraceValue(entityRef(ALICE))).toBe(ALICE);
89
+ });
90
+
91
+ test("a decimal keeps its scale, which a JS number cannot", () => {
92
+ expect(renderTraceValue(decimalValue("1.50"))).toBe("1.50");
93
+ // The failure mode this exists to prevent: 1.50 as a number is 1.5.
94
+ expect(() => renderTraceValue(1.5)).toThrow(/decimalValue/);
95
+ });
96
+
97
+ test("raw text passes through, for shapes this module has no type for", () => {
98
+ expect(renderTraceValue(rawValue("ip(\"10.0.0.1\")"))).toBe('ip("10.0.0.1")');
99
+ });
100
+
101
+ test("a value containing // survives, because the format has no comments", () => {
102
+ expect(renderTraceValue("https://example.test/a")).toBe('"https://example.test/a"');
103
+ });
104
+
105
+ test("quotes and backslashes are escaped, not dropped", () => {
106
+ expect(renderTraceValue('a "b" \\ c')).toBe('"a \\"b\\" \\\\ c"');
107
+ });
108
+ });
109
+
110
+ // ── Trap 1: the action must be fully qualified ─────────────────────
111
+
112
+ describe("fully-qualified actions", () => {
113
+ test("a short action name is rejected at construction", () => {
114
+ // The trap: a bare name leaves every temporal predicate unmatched while
115
+ // Cedar still authorizes, so the replay is green and tested nothing.
116
+ expect(() => traceEvent({ timestamp: 0, action: "Transfer", record: {} })).toThrow(
117
+ /fully qualified/,
118
+ );
119
+ expect(() => traceEvent({ timestamp: 0, action: '"Transfer"', record: {} })).toThrow(
120
+ /fully qualified/,
121
+ );
122
+ });
123
+
124
+ test("so is a short entity reference, in scope or in value position", () => {
125
+ expect(() => entityRef("alice")).toThrow(/fully qualified/);
126
+ expect(() =>
127
+ traceEvent({
128
+ timestamp: 0,
129
+ action: 'Drupe::Action::"Read"',
130
+ scope: { principal: "alice", resource: GATEWAY },
131
+ record: {},
132
+ }),
133
+ ).toThrow(/fully qualified/);
134
+ });
135
+ });
136
+
137
+ // ── Trap 2: both bags ──────────────────────────────────────────────
138
+
139
+ describe("both-bag population", () => {
140
+ test("a context group is written to the envelope AND the logged record", () => {
141
+ const event = login();
142
+ expect(event.requestContext).toEqual({ input: { user: "alice" } });
143
+ expect(event.record).toMatchObject({ input: { user: "alice" } });
144
+ });
145
+
146
+ test("record-only fields stay out of the Cedar request", () => {
147
+ const event = login();
148
+ expect(event.requestContext).not.toHaveProperty("requestId");
149
+ expect(event.record).toHaveProperty("requestId");
150
+ });
151
+
152
+ test("the weaker forms exist, but have to be typed out", () => {
153
+ const recordOnly = login({ bags: "record-only" });
154
+ expect(recordOnly.requestContext).toBeUndefined();
155
+ expect(recordOnly.record).toHaveProperty("input");
156
+
157
+ const contextOnly = login({ bags: "context-only" });
158
+ expect(contextOnly.requestContext).toHaveProperty("input");
159
+ expect(contextOnly.record).not.toHaveProperty("input");
160
+ });
161
+ });
162
+
163
+ describe("auditTrace", () => {
164
+ test("a trace built the default way has nothing to report", () => {
165
+ expect(auditTrace([login(), login({ timestamp: 10 })])).toEqual([]);
166
+ });
167
+
168
+ test("a record-only decision event is a single-bag finding", () => {
169
+ const issues = auditTrace([login({ bags: "record-only" })]);
170
+ expect(issues.map((i) => i.kind)).toContain("no-request-context");
171
+ });
172
+
173
+ test("a context-only decision event is a single-bag finding", () => {
174
+ const issues = auditTrace([login({ bags: "context-only" })]);
175
+ expect(issues).toHaveLength(1);
176
+ expect(issues[0].kind).toBe("single-bag");
177
+ expect(issues[0].message).toMatch(/temporal predicates over input\.\* silently miss/);
178
+ });
179
+
180
+ test("a history-only event needs no request context — it never becomes one", () => {
181
+ // `response` does not decide under the default event schema, so an absent
182
+ // envelope there is not a weakening. Only the deciding kinds are held to it.
183
+ const historyOnly = traceEvent({
184
+ timestamp: 0,
185
+ action: 'Drupe::Action::"Login"',
186
+ kind: "response",
187
+ bags: "record-only",
188
+ context: { input: { user: "alice" } },
189
+ record: { requestId: "u1" },
190
+ });
191
+ expect(auditTrace([historyOnly])).toEqual([]);
192
+ // …but say `response` decides, and it is held to the same bar.
193
+ expect(auditTrace([historyOnly], { decisionKinds: ["response"] })).not.toEqual([]);
194
+ });
195
+
196
+ test("an out-of-order timestamp is reported — history accumulates in file order", () => {
197
+ const issues = auditTrace([login({ timestamp: 10 }), login({ timestamp: 0 })]);
198
+ expect(issues.map((i) => i.kind)).toEqual(["out-of-order"]);
199
+ });
200
+
201
+ test("an event that logs nothing can match no predicate", () => {
202
+ const empty = traceEvent({
203
+ timestamp: 0,
204
+ action: 'Drupe::Action::"Read"',
205
+ context: { input: { user: "alice" } },
206
+ bags: "context-only",
207
+ });
208
+ expect(auditTrace([empty]).map((i) => i.kind).sort()).toEqual(["empty-record", "single-bag"]);
209
+ });
210
+ });
211
+
212
+ describe("traceFixture", () => {
213
+ test("hands back the text when the trace is honest", () => {
214
+ const fixture = traceFixture([login(), login({ timestamp: 10 })]);
215
+ expect(fixture.issues).toEqual([]);
216
+ expect(fixture.text.split("\n").filter(Boolean)).toHaveLength(2);
217
+ });
218
+
219
+ test("refuses a fixture that would weaken its own replay", () => {
220
+ expect(() => traceFixture([login({ bags: "context-only" })])).toThrow(/weaken its own replay/);
221
+ });
222
+
223
+ test("…unless the weakening is named, which is the point of naming it", () => {
224
+ const fixture = traceFixture([login({ bags: "context-only" })], { allow: ["single-bag"] });
225
+ expect(fixture.issues.map((i) => i.kind)).toEqual(["single-bag"]);
226
+ });
227
+
228
+ test("the error names the allow-list entry that would silence it", () => {
229
+ expect(() => traceFixture([login({ bags: "context-only" })])).toThrow(/allow: \["single-bag"\]/);
230
+ });
231
+ });