@intentius/chant 0.46.0 → 0.49.0
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/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +8 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +12 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Receipt store seam (#1834, epic #1703) — how the `effect()` step and the
|
|
3
|
+
* receipt-reading Ops reach a materialized receipt without knowing which
|
|
4
|
+
* lexicon materializes it.
|
|
5
|
+
*
|
|
6
|
+
* The store is injectable: core defines the {@link ReceiptStore} interface and
|
|
7
|
+
* the activity contracts; the receipt row's lexicon implements the store over
|
|
8
|
+
* its own resource (#1835's aws row over `AWS::SSM::Parameter`) and exports
|
|
9
|
+
* the bound activities (`receiptRead`, `receiptWrite`, `receiptStaleness`)
|
|
10
|
+
* from its `op/activities` module via {@link receiptActivities}. The activity
|
|
11
|
+
* registry then resolves them by name, exactly like `ensureSecret`
|
|
12
|
+
* (#1829/#1830, provided by the k8s lexicon). Tests bind a mock store the
|
|
13
|
+
* same way.
|
|
14
|
+
*
|
|
15
|
+
* Write discipline (epic #1703 decision log, item 3): the `effect()` step is
|
|
16
|
+
* the SOLE writer of a receipt — on success of its nested steps, last. The
|
|
17
|
+
* store interface carries a `write`, but only the effect step's emitted
|
|
18
|
+
* read-compare-run-write (and its local-executor twin) reaches it.
|
|
19
|
+
* `receiptStaleness` — the WatchOp phase (#1834) — is read-only by
|
|
20
|
+
* construction: it never touches `write` and runs nothing.
|
|
21
|
+
*
|
|
22
|
+
* Expectation resolution follows the resolution split (decision 5): a fully
|
|
23
|
+
* static receipt's expectation is stamped at synthesis and rides the step
|
|
24
|
+
* data ({@link receiptCheckInput}); a hash-flavor receipt with reference
|
|
25
|
+
* inputs resolves at run, through the store lexicon's
|
|
26
|
+
* {@link ReceiptActivityOptions.resolveExpectation} hook — never by hashing
|
|
27
|
+
* placeholders.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
EXISTENCE_EXPECTATION,
|
|
32
|
+
isEffectReceipt,
|
|
33
|
+
receiptExpectation,
|
|
34
|
+
referenceInputPaths,
|
|
35
|
+
type EffectReceiptDeclaration,
|
|
36
|
+
type EffectReceiptFlavor,
|
|
37
|
+
} from "../effect-receipt";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The serializable identity + declaration data of an effect receipt — what an
|
|
41
|
+
* `effect()` step and a `receiptStaleness` check carry through codegen. Built
|
|
42
|
+
* from the typed {@link EffectReceiptDeclaration} only ({@link receiptCheckInput});
|
|
43
|
+
* there is no string form. Reference inputs stay in placeholder form.
|
|
44
|
+
*/
|
|
45
|
+
export interface EffectReceiptRef {
|
|
46
|
+
/** The receipt's own name (the export-level identity of the witness). */
|
|
47
|
+
name: string;
|
|
48
|
+
/** The effect this receipt witnesses. */
|
|
49
|
+
effect: string;
|
|
50
|
+
/** How the receipt is compared: mere presence, or a digest of the inputs. */
|
|
51
|
+
flavor: EffectReceiptFlavor;
|
|
52
|
+
/** The effect's inputs as recorded at synthesis (references as placeholders). */
|
|
53
|
+
inputs: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The injectable seam over the materialized receipt (#1835 implements it for
|
|
58
|
+
* SSM; tests implement it with a map). `read` returns the receipt's current
|
|
59
|
+
* stored value, or `undefined` when the receipt is absent. `write` stores the
|
|
60
|
+
* expectation as the new value — called only by the effect step, on success,
|
|
61
|
+
* last.
|
|
62
|
+
*/
|
|
63
|
+
export interface ReceiptStore {
|
|
64
|
+
read(receipt: EffectReceiptRef): Promise<string | undefined>;
|
|
65
|
+
write(receipt: EffectReceiptRef, expectation: string): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One receipt to check: its identity plus, when the receipt is fully static,
|
|
69
|
+
* the expectation stamped at synthesis. */
|
|
70
|
+
export interface ReceiptCheckInput {
|
|
71
|
+
receipt: EffectReceiptRef;
|
|
72
|
+
/** Present when the expectation was computable at synthesis; absent when
|
|
73
|
+
* reference inputs resolve at run (decision 5). */
|
|
74
|
+
expectation?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Snapshot a typed receipt declaration into check-input data: the
|
|
79
|
+
* {@link EffectReceiptRef} plus the synthesis-time expectation when the
|
|
80
|
+
* receipt is fully static. Used by the `effect()` builder and by WatchOp's
|
|
81
|
+
* staleness phase — the single place "static enough to hash now" is decided.
|
|
82
|
+
*/
|
|
83
|
+
export function receiptCheckInput(receipt: EffectReceiptDeclaration): ReceiptCheckInput {
|
|
84
|
+
if (!isEffectReceipt(receipt)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"receiptCheckInput: expected an EffectReceipt declaration — import the exported const; there is no string form",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const ref: EffectReceiptRef = {
|
|
90
|
+
name: receipt.name,
|
|
91
|
+
effect: receipt.effect,
|
|
92
|
+
flavor: receipt.flavor,
|
|
93
|
+
inputs: receipt.inputs as Record<string, unknown>,
|
|
94
|
+
};
|
|
95
|
+
// An existence receipt's expectation is a constant, references or not; a
|
|
96
|
+
// hash receipt is static only when no reference inputs remain.
|
|
97
|
+
const isStatic = receipt.flavor === "existence" || referenceInputPaths(receipt).length === 0;
|
|
98
|
+
return isStatic ? { receipt: ref, expectation: receiptExpectation(receipt) } : { receipt: ref };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Activity contracts ────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Args of the `receiptRead` activity, as the effect step's codegen emits them. */
|
|
104
|
+
export interface ReceiptReadArgs extends ReceiptCheckInput {}
|
|
105
|
+
|
|
106
|
+
/** Result of the `receiptRead` activity. `current` is `null` (not `undefined`)
|
|
107
|
+
* for an absent receipt so the value survives JSON transport. */
|
|
108
|
+
export interface ReceiptReadResult {
|
|
109
|
+
/** The receipt's live stored value, or null when absent. */
|
|
110
|
+
current: string | null;
|
|
111
|
+
/** The resolved expectation the workflow compares and later writes. */
|
|
112
|
+
expectation: string;
|
|
113
|
+
/** Convenience: `current === expectation`. */
|
|
114
|
+
applied: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Args of the `receiptWrite` activity — the receipt and the resolved
|
|
118
|
+
* expectation returned by the preceding `receiptRead`. */
|
|
119
|
+
export interface ReceiptWriteArgs {
|
|
120
|
+
receipt: EffectReceiptRef;
|
|
121
|
+
expectation: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Args of the read-only `receiptStaleness` activity (WatchOp). */
|
|
125
|
+
export interface ReceiptStalenessArgs {
|
|
126
|
+
receipts: ReceiptCheckInput[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One stale receipt, reported as a finding — never acted on. */
|
|
130
|
+
export interface ReceiptStaleFinding {
|
|
131
|
+
/** The receipt's name. */
|
|
132
|
+
receipt: string;
|
|
133
|
+
/** The effect the receipt witnesses. */
|
|
134
|
+
effect: string;
|
|
135
|
+
/** `absent` — no receipt stored; `differs` — stored value is not the expectation. */
|
|
136
|
+
kind: "absent" | "differs";
|
|
137
|
+
/** The resolved expectation. */
|
|
138
|
+
expected: string;
|
|
139
|
+
/** The live stored value (present only for `differs`). */
|
|
140
|
+
current?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Result of the `receiptStaleness` activity. */
|
|
144
|
+
export interface ReceiptStalenessResult {
|
|
145
|
+
stale: boolean;
|
|
146
|
+
findings: ReceiptStaleFinding[];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Options for {@link receiptActivities}. */
|
|
150
|
+
export interface ReceiptActivityOptions {
|
|
151
|
+
/**
|
|
152
|
+
* Resolve a reference-carrying receipt's expectation at run (decision 5) —
|
|
153
|
+
* the store lexicon's hook to deploy-time values, typically wrapping core's
|
|
154
|
+
* `resolveReceiptExpectation`. Without it, a check input that carries no
|
|
155
|
+
* synthesis-time expectation fails loudly rather than hashing placeholders.
|
|
156
|
+
*/
|
|
157
|
+
resolveExpectation?: (receipt: EffectReceiptRef) => Promise<string> | string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The three receipt activities, bound to one store. */
|
|
161
|
+
export interface ReceiptActivities {
|
|
162
|
+
receiptRead: (args: ReceiptReadArgs, signal?: AbortSignal) => Promise<ReceiptReadResult>;
|
|
163
|
+
receiptWrite: (args: ReceiptWriteArgs, signal?: AbortSignal) => Promise<{ written: true; receipt: string }>;
|
|
164
|
+
receiptStaleness: (args: ReceiptStalenessArgs, signal?: AbortSignal) => Promise<ReceiptStalenessResult>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Bind the receipt activities to a store. The store's lexicon calls this once
|
|
169
|
+
* and re-exports the result from its `op/activities` module; the activity
|
|
170
|
+
* registry picks the functions up by name for both executors.
|
|
171
|
+
*/
|
|
172
|
+
export function receiptActivities(store: ReceiptStore, opts?: ReceiptActivityOptions): ReceiptActivities {
|
|
173
|
+
const expectationOf = async (input: ReceiptCheckInput): Promise<string> => {
|
|
174
|
+
if (input.expectation !== undefined) return input.expectation;
|
|
175
|
+
if (input.receipt.flavor === "existence") return EXISTENCE_EXPECTATION;
|
|
176
|
+
if (opts?.resolveExpectation) return await opts.resolveExpectation(input.receipt);
|
|
177
|
+
throw new Error(
|
|
178
|
+
`receipt "${input.receipt.name}": no synthesis-time expectation and no resolveExpectation hook — ` +
|
|
179
|
+
`a hash-flavor receipt with reference inputs resolves at run (#1703 decision 5), ` +
|
|
180
|
+
`and hashing placeholders would be a wrong expectation`,
|
|
181
|
+
);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
async receiptRead(args: ReceiptReadArgs): Promise<ReceiptReadResult> {
|
|
186
|
+
const expectation = await expectationOf(args);
|
|
187
|
+
const current = await store.read(args.receipt);
|
|
188
|
+
return { current: current ?? null, expectation, applied: current === expectation };
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async receiptWrite(args: ReceiptWriteArgs): Promise<{ written: true; receipt: string }> {
|
|
192
|
+
await store.write(args.receipt, args.expectation);
|
|
193
|
+
return { written: true, receipt: args.receipt.name };
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// Read-only: reads and reports, runs nothing, never writes.
|
|
197
|
+
async receiptStaleness(args: ReceiptStalenessArgs): Promise<ReceiptStalenessResult> {
|
|
198
|
+
const findings: ReceiptStaleFinding[] = [];
|
|
199
|
+
for (const input of args.receipts) {
|
|
200
|
+
const expected = await expectationOf(input);
|
|
201
|
+
const current = await store.read(input.receipt);
|
|
202
|
+
if (current === undefined) {
|
|
203
|
+
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "absent", expected });
|
|
204
|
+
} else if (current !== expected) {
|
|
205
|
+
findings.push({ receipt: input.receipt.name, effect: input.receipt.effect, kind: "differs", expected, current });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { stale: findings.length > 0, findings };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/op/types.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* in core without pulling in @temporalio/* as a dependency.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { EffectReceiptRef } from "./receipt-store";
|
|
9
|
+
|
|
8
10
|
export interface OpConfig {
|
|
9
11
|
/** Kebab-case identifier. Used as the workflow function name (camelCase) and output directory name. */
|
|
10
12
|
name: string;
|
|
@@ -33,7 +35,7 @@ export interface PhaseDefinition {
|
|
|
33
35
|
parallel?: boolean;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export type StepDefinition = ActivityStep | GateStep;
|
|
38
|
+
export type StepDefinition = ActivityStep | GateStep | EffectStep;
|
|
37
39
|
|
|
38
40
|
export interface ActivityStep {
|
|
39
41
|
kind: "activity";
|
|
@@ -61,6 +63,36 @@ export interface ActivityStep {
|
|
|
61
63
|
outcomeAttribute?: { name: string; from?: string };
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Read-compare-run-write over an effect receipt (#1834, epic #1703). The
|
|
68
|
+
* runtime reads the live receipt through the receipt store, compares it
|
|
69
|
+
* against the resolved expectation, skips the nested steps on a match, and
|
|
70
|
+
* otherwise runs them — writing the receipt only when every nested step
|
|
71
|
+
* succeeded, last. A nested-step failure leaves the receipt untouched
|
|
72
|
+
* (stale), so the next run re-proposes the effect.
|
|
73
|
+
*
|
|
74
|
+
* Authored via the `effect()` builder, which takes the typed EffectReceipt
|
|
75
|
+
* declaration only — there is no string form.
|
|
76
|
+
*/
|
|
77
|
+
export interface EffectStep {
|
|
78
|
+
kind: "effect";
|
|
79
|
+
/** Receipt identity + declaration data (references in placeholder form). */
|
|
80
|
+
receipt: EffectReceiptRef;
|
|
81
|
+
/**
|
|
82
|
+
* The expectation stamped at synthesis when the receipt is fully static;
|
|
83
|
+
* absent when reference inputs resolve at run (#1703 decision 5).
|
|
84
|
+
*/
|
|
85
|
+
expectation?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Steps run when the live receipt does not match, in authored order. A gate
|
|
88
|
+
* authored here pauses only when the effect will fire. Effect steps do not
|
|
89
|
+
* nest.
|
|
90
|
+
*/
|
|
91
|
+
steps: Array<ActivityStep | GateStep>;
|
|
92
|
+
/** Annotation carried into the generated workflow as a comment. */
|
|
93
|
+
description?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
export interface GateStep {
|
|
65
97
|
kind: "gate";
|
|
66
98
|
/** Signal name. The generated workflow waits for this signal before continuing. */
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated-once materialization engine tests (#1829, epic #1365 decisions 3
|
|
3
|
+
* and 6): read-then-write, present means done, mismatch fails loudly naming
|
|
4
|
+
* key names and metadata keys — and no code path returns, logs, or retains
|
|
5
|
+
* the generated material.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { inspect } from "node:util";
|
|
10
|
+
import {
|
|
11
|
+
SecretMaterial,
|
|
12
|
+
SecretContractMismatchError,
|
|
13
|
+
consumeSecretMaterial,
|
|
14
|
+
defaultSecretMaterialGenerator,
|
|
15
|
+
ensureSecretMaterialization,
|
|
16
|
+
type SecretMaterialGenerator,
|
|
17
|
+
type SecretStoreAdapter,
|
|
18
|
+
type SecretStoreDescription,
|
|
19
|
+
} from "./secret-materialization";
|
|
20
|
+
|
|
21
|
+
/** A recognizable plaintext no output may ever contain. */
|
|
22
|
+
const CANARY = "canary-s3kr3t-material";
|
|
23
|
+
|
|
24
|
+
/** Generator minting a per-key canary value, so leak assertions can grep for it. */
|
|
25
|
+
const canaryGenerator: SecretMaterialGenerator = (key) => SecretMaterial.mint(`${CANARY}:${key}`);
|
|
26
|
+
|
|
27
|
+
interface StoredSecret {
|
|
28
|
+
data: Record<string, string>;
|
|
29
|
+
metadata?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* In-memory fake store. `create` consumes each key's material exactly the way
|
|
34
|
+
* a real adapter writes it, so the tests can check the stored bytes without
|
|
35
|
+
* any material ever traveling back through the engine.
|
|
36
|
+
*/
|
|
37
|
+
function fakeStore(seed?: Record<string, StoredSecret>) {
|
|
38
|
+
const secrets = new Map<string, StoredSecret>(Object.entries(seed ?? {}));
|
|
39
|
+
const adapter: SecretStoreAdapter = {
|
|
40
|
+
exists: vi.fn(async (name: string) => secrets.has(name)),
|
|
41
|
+
describe: vi.fn(async (name: string): Promise<SecretStoreDescription> => {
|
|
42
|
+
const secret = secrets.get(name);
|
|
43
|
+
if (!secret) throw new Error(`fake store: no secret "${name}"`);
|
|
44
|
+
return { keys: Object.keys(secret.data), metadata: secret.metadata };
|
|
45
|
+
}),
|
|
46
|
+
create: vi.fn(async (name: string, keys: readonly string[], generate: SecretMaterialGenerator) => {
|
|
47
|
+
const data: Record<string, string> = {};
|
|
48
|
+
for (const key of keys) {
|
|
49
|
+
data[key] = consumeSecretMaterial(await generate(key));
|
|
50
|
+
}
|
|
51
|
+
secrets.set(name, { data });
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
return { adapter, secrets };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("ensureSecretMaterialization", () => {
|
|
62
|
+
it("absent: mints once through the adapter and reports created", async () => {
|
|
63
|
+
const { adapter, secrets } = fakeStore();
|
|
64
|
+
const outcome = await ensureSecretMaterialization(
|
|
65
|
+
adapter,
|
|
66
|
+
{ name: "master-key", keys: ["MASTER_SECRETS_KEY"] },
|
|
67
|
+
canaryGenerator,
|
|
68
|
+
);
|
|
69
|
+
expect(outcome).toEqual({ outcome: "created", name: "master-key", keys: ["MASTER_SECRETS_KEY"] });
|
|
70
|
+
expect(adapter.create).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(secrets.get("master-key")?.data).toEqual({
|
|
72
|
+
MASTER_SECRETS_KEY: `${CANARY}:MASTER_SECRETS_KEY`,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("present and matching: stops without any write — a rerun is byte-identical and create never fires twice", async () => {
|
|
77
|
+
const { adapter, secrets } = fakeStore();
|
|
78
|
+
const spec = { name: "master-key", keys: ["a", "b"] };
|
|
79
|
+
|
|
80
|
+
await ensureSecretMaterialization(adapter, spec, canaryGenerator);
|
|
81
|
+
const afterFirstRun = JSON.stringify(secrets.get("master-key"));
|
|
82
|
+
|
|
83
|
+
// Second run of the whole ensure — the fountain-ops e2e shape.
|
|
84
|
+
const outcome = await ensureSecretMaterialization(adapter, spec, canaryGenerator);
|
|
85
|
+
expect(outcome.outcome).toBe("present");
|
|
86
|
+
expect(adapter.create).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(JSON.stringify(secrets.get("master-key"))).toBe(afterFirstRun);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("present with a missing declared key: fails loudly naming the key, never minting over the existing value", async () => {
|
|
91
|
+
const { adapter, secrets } = fakeStore({
|
|
92
|
+
"master-key": { data: { a: "pre-existing-value" } },
|
|
93
|
+
});
|
|
94
|
+
await expect(
|
|
95
|
+
ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a", "b"] }, canaryGenerator),
|
|
96
|
+
).rejects.toThrowError(SecretContractMismatchError);
|
|
97
|
+
await expect(
|
|
98
|
+
ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a", "b"] }, canaryGenerator),
|
|
99
|
+
).rejects.toThrowError(/missing declared key\(s\): b/);
|
|
100
|
+
expect(adapter.create).not.toHaveBeenCalled();
|
|
101
|
+
expect(secrets.get("master-key")?.data).toEqual({ a: "pre-existing-value" });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("present with an undeclared extra key: fails naming the extra key", async () => {
|
|
105
|
+
const { adapter } = fakeStore({
|
|
106
|
+
"master-key": { data: { a: "x", rogue: "y" } },
|
|
107
|
+
});
|
|
108
|
+
await expect(
|
|
109
|
+
ensureSecretMaterialization(adapter, { name: "master-key", keys: ["a"] }, canaryGenerator),
|
|
110
|
+
).rejects.toThrowError(/undeclared key\(s\) present: rogue/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("declared metadata mismatch: fails naming the metadata KEY only, never its values", async () => {
|
|
114
|
+
const { adapter } = fakeStore({
|
|
115
|
+
"master-key": {
|
|
116
|
+
data: { a: "x" },
|
|
117
|
+
metadata: { "chant.dev/provenance": "hand-rolled" },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
let thrown: unknown;
|
|
121
|
+
try {
|
|
122
|
+
await ensureSecretMaterialization(
|
|
123
|
+
adapter,
|
|
124
|
+
{
|
|
125
|
+
name: "master-key",
|
|
126
|
+
keys: ["a"],
|
|
127
|
+
metadata: { "chant.dev/provenance": "generated-once", "chant.dev/stack": "fountain" },
|
|
128
|
+
},
|
|
129
|
+
canaryGenerator,
|
|
130
|
+
);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
thrown = e;
|
|
133
|
+
}
|
|
134
|
+
expect(thrown).toBeInstanceOf(SecretContractMismatchError);
|
|
135
|
+
const message = (thrown as Error).message;
|
|
136
|
+
expect(message).toContain("metadata key differs: chant.dev/provenance");
|
|
137
|
+
expect(message).toContain("missing declared metadata key: chant.dev/stack");
|
|
138
|
+
// The KEY is named; neither the declared nor the stored VALUE appears.
|
|
139
|
+
expect(message).not.toContain("hand-rolled");
|
|
140
|
+
expect(message).not.toContain("generated-once");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects an empty key-set — there is nothing to materialize", async () => {
|
|
144
|
+
const { adapter } = fakeStore();
|
|
145
|
+
await expect(
|
|
146
|
+
ensureSecretMaterialization(adapter, { name: "master-key", keys: [] }, canaryGenerator),
|
|
147
|
+
).rejects.toThrowError(/at least one key/);
|
|
148
|
+
expect(adapter.exists).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("never emits the generated material on any log or error surface", async () => {
|
|
152
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
153
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
154
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
155
|
+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
|
156
|
+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
157
|
+
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
158
|
+
|
|
159
|
+
const { adapter } = fakeStore({ mismatched: { data: { wrong: "stored-value" } } });
|
|
160
|
+
await ensureSecretMaterialization(adapter, { name: "fresh", keys: ["k1", "k2"] }, canaryGenerator);
|
|
161
|
+
const failure = await ensureSecretMaterialization(
|
|
162
|
+
adapter,
|
|
163
|
+
{ name: "mismatched", keys: ["right"] },
|
|
164
|
+
canaryGenerator,
|
|
165
|
+
).catch((e: Error) => e);
|
|
166
|
+
|
|
167
|
+
const captured = [logSpy, errorSpy, warnSpy, infoSpy, stdoutSpy, stderrSpy]
|
|
168
|
+
.flatMap((spy) => spy.mock.calls)
|
|
169
|
+
.map((call) => call.map((arg) => inspect(arg)).join(" "))
|
|
170
|
+
.join("\n");
|
|
171
|
+
expect(captured).not.toContain(CANARY);
|
|
172
|
+
expect((failure as Error).message).not.toContain(CANARY);
|
|
173
|
+
expect((failure as Error).message).not.toContain("stored-value");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("SecretMaterial", () => {
|
|
178
|
+
it("holds the plaintext out of reach: enumeration, JSON, string coercion, and inspect all redact", () => {
|
|
179
|
+
const material = SecretMaterial.mint(CANARY);
|
|
180
|
+
expect(Object.keys(material)).toEqual([]);
|
|
181
|
+
expect(JSON.stringify(material)).toBe('"[secret material]"');
|
|
182
|
+
expect(String(material)).toBe("[secret material]");
|
|
183
|
+
expect(inspect(material)).toBe("[secret material]");
|
|
184
|
+
expect(inspect({ nested: material })).not.toContain(CANARY);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("is consumable exactly once — the adapter takes it, nothing re-reads it", () => {
|
|
188
|
+
const material = SecretMaterial.mint(CANARY);
|
|
189
|
+
expect(consumeSecretMaterial(material)).toBe(CANARY);
|
|
190
|
+
expect(() => consumeSecretMaterial(material)).toThrowError(/already consumed/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("default generator mints 32 CSPRNG bytes as base64url", () => {
|
|
194
|
+
const material = defaultSecretMaterialGenerator("any-key");
|
|
195
|
+
expect(material).toBeInstanceOf(SecretMaterial);
|
|
196
|
+
const plaintext = consumeSecretMaterial(material as SecretMaterial);
|
|
197
|
+
expect(plaintext).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
198
|
+
});
|
|
199
|
+
});
|