@openwop/openwop-conformance 2.40.2 → 2.41.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.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.40.2",
5
- "corpusTag": "v2.40.2",
4
+ "version": "2.41.0",
5
+ "corpusTag": "v2.41.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "c92bde1fb2ac949edfb50519651ac3755cfc88b8a2a8785eab0e15bcb6a8dbb9",
13
- "api/v2/asyncapi.yaml": "87d5b9de8e3fa920a0ddecce7f51018f8f573b5ef14f954973a0776d3b9d9a0f",
14
- "api/v2/openapi.yaml": "532467b699c179c355349c8bb483a801156a68b003d02aa9d7cf576f867c3097",
12
+ "api/seams-v2.yaml": "1309f1f087272e74b836b436fa08160a1009feb9c341b6d359332e875ee7475f",
13
+ "api/v2/asyncapi.yaml": "b34dcda44b69de1d9d41ede8c2c69cc0fdbb6cc3533588057dd3a71acc42da5a",
14
+ "api/v2/openapi.yaml": "12ccdda2cf32ec21392fe84366607e0c69c4e07ae960a9fda3fd407a5cd7be78",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,13 +204,13 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "a16e3a91b9121d7b6d58fd9f65a348f92a07636417406f56bc3173571e9d55ea",
207
+ "spec/v1/core-standard-manifest.json": "a8008e40cb7e5e68ccd4fe6761e563354d34b5ab278722fab75bc7ed4bc40cae",
208
208
  "spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "3decfec271c30f796ba76586ca06ab31e6ea3b8bcae174c3af0b8bb1c19709b5",
213
+ "spec/v1/gaps.json": "392a3d7f4b869a67d5861fcd47bc7eca83b6805b95b6520a91d9beb91df51c52",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -240,7 +240,7 @@
240
240
  "spec/v2/core/security-defaults.md": "500471a8db7af9b776ac40b4a9a278c9f3d45e1c1dead880114e7fadc7bee37d",
241
241
  "spec/v2/core/tool-catalog.md": "35f1a3fd509db0fc0490ddf400686fe94dc4b20c98064d5ebe776da516153e60",
242
242
  "spec/v2/core/versioning.md": "0aded71d090c8358c3ce17763c120cfc1cc42531a5eaa1b8a2a7016ed9594069",
243
- "spec/v2/core/webhooks.md": "95880896e37e5b7096693a75203cf0ed70cefa09959230e17b45aa9193078eff",
243
+ "spec/v2/core/webhooks.md": "2a44b100c42dd841fe2b8f87179a981be6c0dd26608ce1b14e077318c4ac8201",
244
244
  "spec/v2/core/workflow-chain-packs.md": "ee45d3fede3bb6f0cc6abcf0d6c929c7cd213a0fbe862b738a998f929c4d1102",
245
245
  "spec/v2/corrections.json": "cc74ffde74384b0f261a4f625871d922e8661d654d647a8534e21036595cb280",
246
246
  "spec/v2/corrections.schema.json": "4ac595b9a6d7f66d53de03fe0edbe0dc58de46426932387bfdc0ceb3cf949a3e",
@@ -294,9 +294,9 @@
294
294
  "spec/v2/path-manifest.json": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
295
295
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
296
296
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
297
- "spec/v2/release.json": "f04bc2406dfc3af40a495d8750349409f053bb56f009359ebc62a8877251c90f",
297
+ "spec/v2/release.json": "6849e210dc510d008596f51718f01f8deb32c6f5f9c3114f6e2707e778f38dcc",
298
298
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
299
- "spec/v2/surface-baseline.json": "26f2a03c650413ac9c0d0b5facee47ee0eaebeb01d377c808fe6e737bb08687c"
299
+ "spec/v2/surface-baseline.json": "18c79a71b4b65cf8f9f8bf345d3828627bb17673b7279de276c1120d540a4746"
300
300
  },
301
- "corpusCommit": "70c93c0fcd5c595893c1cac7cf1020d5c5835ec2"
301
+ "corpusCommit": "d0da968bd70f57f4844b58f1b2544029dc576efb"
302
302
  }
@@ -354,6 +354,26 @@ export function scrubEvidence<T>(value: T, secrets: readonly string[]): ScrubRes
354
354
  * are excluded, and a caller may name values that MUST stay visible (`except`)
355
355
  * regardless of which variable carried them.
356
356
  */
357
+ /**
358
+ * Whether an environment VALUE can be a credential at all (suite 2.40.3).
359
+ *
360
+ * The name filter above matches settings ABOUT secrets as well as secrets:
361
+ * openwop-app's `OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60` (RFC 0201's
362
+ * rotation overlap) was swept in, and `scrubEvidence` then rewrote every "60"
363
+ * in the bundle — including the one inside `discovery.sha256`, which stopped
364
+ * matching `^[0-9a-f]{64}$` and failed the host's own deploy certify. A short
365
+ * value occurs by chance inside digests, counts and timestamps, so scrubbing it
366
+ * corrupts the evidence without protecting anything: no credential is a bare
367
+ * integer or under 8 characters (NIST SP 800-63B's floor for a memorized
368
+ * secret). The floor applies only to values GUESSED from variable names; a
369
+ * credential the emitter hands over explicitly (`extra`) is always scrubbed,
370
+ * whatever its shape.
371
+ */
372
+ export function credentialShaped(v: string): boolean {
373
+ const t = v.trim();
374
+ return t.length >= 8 && !/^\d+$/.test(t);
375
+ }
376
+
357
377
  export function evidenceSecretsFromEnv(
358
378
  env: NodeJS.ProcessEnv,
359
379
  extra: readonly (string | undefined)[] = [],
@@ -365,7 +385,9 @@ export function evidenceSecretsFromEnv(
365
385
  if (!k.startsWith('OPENWOP_')) continue;
366
386
  if (!/(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/.test(k)) continue;
367
387
  if (/_ID$/.test(k)) continue; // a key's identifier is published, not secret
368
- if (v !== undefined && v.trim() !== '' && !keep.has(v)) out.add(v);
388
+ if (v === undefined || v.trim() === '' || keep.has(v)) continue;
389
+ if (!credentialShaped(v)) continue; // a setting that names a secret, not a secret
390
+ out.add(v);
369
391
  }
370
392
  for (const s of extra) if (s !== undefined && s.trim() !== '' && !keep.has(s)) out.add(s);
371
393
  out.add(CONFORMANCE_SECRET_CANARY);
@@ -0,0 +1,198 @@
1
+ /**
2
+ * RFC 0111 — the transcript-window checker, kept pure so its sabotage cases are
3
+ * self-tests (`context-budget.test.ts`) rather than claims.
4
+ *
5
+ * WHY THIS FILE EXISTS (the 2026-09-26 correction on record, COMPATIBILITY.md).
6
+ * Until suite 2.41.0 `context-budget-transcript-bound` could not fail in the ways
7
+ * RFC 0111 names:
8
+ * (b) "the harness independently … re-computes their token sum" — impossible:
9
+ * events are content-free, so the suite had nothing to count. The test
10
+ * checked that each id existed, and nothing else.
11
+ * (c) "the recent tail" — the test checked only that ids were unique.
12
+ * and no check that the budget was ever under PRESSURE, so a host advertising
13
+ * a budget of 10^9 passed.
14
+ * The seam now also returns `entries[] { eventId, rendered }` — the exact text
15
+ * the host fed the model for each event that iteration — and for
16
+ * `tokenCounter: "chars"` the suite recounts it itself.
17
+ *
18
+ * Definitions this checker applies (host-sample-test-seams.md §14):
19
+ * - `chars` is Unicode code points (`[...s].length`), not UTF-16 units or bytes.
20
+ * - An event is ELIGIBLE for an iteration's transcript when it shares
21
+ * `(type, nodeId)` with an event the host fed verbatim that iteration. The
22
+ * RFC's "no older event included while a newer eligible one is dropped"
23
+ * never defined "eligible"; keying on the pair (not the type alone) keeps a
24
+ * host that feeds only one node's events from being convicted for another
25
+ * node's.
26
+ * - PRESSURE: the budget did something — an eligible event older than the
27
+ * oldest fed one exists, or the window carries a summarized range.
28
+ */
29
+
30
+ export interface WindowEntry {
31
+ readonly eventId: string;
32
+ readonly rendered: string;
33
+ }
34
+ export interface SummarizedRange {
35
+ readonly summaryRef: string;
36
+ readonly replacedTurns: readonly string[];
37
+ }
38
+ export interface TranscriptWindow {
39
+ readonly tokenCounter: string;
40
+ readonly tokenCount: number;
41
+ readonly eventIds: readonly string[];
42
+ readonly summarizedRanges: readonly SummarizedRange[];
43
+ readonly entries?: readonly WindowEntry[];
44
+ }
45
+ export interface LogEvent {
46
+ readonly eventId: string;
47
+ readonly type: string;
48
+ readonly sequence: number;
49
+ readonly nodeId?: string;
50
+ readonly payload: Record<string, unknown>;
51
+ }
52
+ export interface BudgetCap {
53
+ readonly transcriptTokenBudget: number;
54
+ readonly tokenCounter: string;
55
+ }
56
+ export interface WindowVerdict {
57
+ /** Every broken rule, as a sentence naming the iteration. Empty = consistent. */
58
+ readonly violations: string[];
59
+ /** Whether this iteration shows the budget acting (see file docblock). */
60
+ readonly pressure: boolean;
61
+ /** Rules that could not be evaluated (no event log). */
62
+ readonly unmeasured: string[];
63
+ }
64
+
65
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
66
+ const stringArrayOf = (v: unknown): string[] | undefined => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : undefined);
67
+
68
+ /** Unicode code points — the `chars` unit. */
69
+ export function charCount(s: string): number {
70
+ return [...s].length;
71
+ }
72
+
73
+ /** Parse a seam response; undefined when the required shape is wrong (a malformed optional member is also undefined — it cannot be half-trusted). */
74
+ export function parseTranscriptWindow(v: unknown): TranscriptWindow | undefined {
75
+ if (!isRecord(v)) return undefined;
76
+ const tokenCounter = v['tokenCounter'];
77
+ const tokenCount = v['tokenCount'];
78
+ const eventIds = stringArrayOf(v['eventIds']);
79
+ if (typeof tokenCounter !== 'string' || typeof tokenCount !== 'number' || eventIds === undefined) return undefined;
80
+ const summarizedRanges: SummarizedRange[] = [];
81
+ const rawRanges = v['summarizedRanges'];
82
+ if (rawRanges !== undefined) {
83
+ if (!Array.isArray(rawRanges)) return undefined;
84
+ for (const r of rawRanges) {
85
+ if (!isRecord(r) || typeof r['summaryRef'] !== 'string') return undefined;
86
+ const replacedTurns = stringArrayOf(r['replacedTurns']);
87
+ if (replacedTurns === undefined) return undefined;
88
+ summarizedRanges.push({ summaryRef: r['summaryRef'], replacedTurns });
89
+ }
90
+ }
91
+ const rawEntries = v['entries'];
92
+ if (rawEntries === undefined) return { tokenCounter, tokenCount, eventIds, summarizedRanges };
93
+ if (!Array.isArray(rawEntries)) return undefined;
94
+ const entries: WindowEntry[] = [];
95
+ for (const e of rawEntries) {
96
+ if (!isRecord(e) || typeof e['eventId'] !== 'string' || typeof e['rendered'] !== 'string') return undefined;
97
+ entries.push({ eventId: e['eventId'], rendered: e['rendered'] });
98
+ }
99
+ return { tokenCounter, tokenCount, eventIds, summarizedRanges, entries };
100
+ }
101
+
102
+ const eligibilityKey = (e: LogEvent): string => `${e.type}\u0000${e.nodeId ?? ''}`;
103
+
104
+ /**
105
+ * Check one iteration's window against the advertised cap and (when available)
106
+ * the run's event log. `log` null ⇒ the log-dependent rules are reported in
107
+ * `unmeasured`, never silently passed.
108
+ */
109
+ export function checkTranscriptWindow(iteration: number, w: TranscriptWindow, cap: BudgetCap, log: readonly LogEvent[] | null): WindowVerdict {
110
+ const violations: string[] = [];
111
+ const unmeasured: string[] = [];
112
+ const at = `iteration ${iteration}`;
113
+
114
+ if (w.tokenCounter !== cap.tokenCounter) violations.push(`${at}: seam tokenCounter "${w.tokenCounter}" is not the advertised "${cap.tokenCounter}"`);
115
+ if (w.tokenCount > cap.transcriptTokenBudget) violations.push(`${at}: tokenCount ${w.tokenCount} exceeds transcriptTokenBudget ${cap.transcriptTokenBudget}`);
116
+ if (new Set(w.eventIds).size !== w.eventIds.length) violations.push(`${at}: eventIds repeats an entry`);
117
+
118
+ // (b) — the recount. Only `chars` is recountable by a black-box harness.
119
+ if (cap.tokenCounter === 'chars') {
120
+ if (w.entries === undefined) {
121
+ violations.push(`${at}: tokenCounter is "chars" but the seam returned no entries[] — the recount (RFC 0111 (b)) cannot run, and for "chars" entries are REQUIRED`);
122
+ } else {
123
+ const sum = w.entries.reduce((n, e) => n + charCount(e.rendered), 0);
124
+ if (sum !== w.tokenCount) violations.push(`${at}: entries[].rendered sum to ${sum} chars but tokenCount is ${w.tokenCount}`);
125
+ }
126
+ }
127
+
128
+ if (log === null) {
129
+ unmeasured.push('real-event, entries↔eventIds, summary-range, recent-tail and pressure rules (no event log)');
130
+ return { violations, pressure: false, unmeasured };
131
+ }
132
+
133
+ const byId = new Map(log.map((e) => [e.eventId, e]));
134
+ const summarizedByRef = new Map<string, LogEvent>();
135
+ for (const e of log) {
136
+ if (e.type !== 'context.summarized') continue;
137
+ const ref = e.payload['summaryRef'];
138
+ if (typeof ref === 'string') summarizedByRef.set(ref, e);
139
+ }
140
+
141
+ for (const id of w.eventIds) if (!byId.has(id)) violations.push(`${at}: eventId "${id}" is not an event of this run`);
142
+ for (const r of w.summarizedRanges) if (!summarizedByRef.has(r.summaryRef)) violations.push(`${at}: summarizedRanges summaryRef "${r.summaryRef}" has no matching context.summarized event`);
143
+
144
+ if (w.entries !== undefined) {
145
+ const verbatim: string[] = [];
146
+ for (const e of w.entries) {
147
+ const ev = byId.get(e.eventId);
148
+ if (ev === undefined) { violations.push(`${at}: entries[] eventId "${e.eventId}" is not an event of this run`); continue; }
149
+ if (ev.type === 'context.summarized') {
150
+ const ref = ev.payload['summaryRef'];
151
+ if (!w.summarizedRanges.some((r) => r.summaryRef === ref)) violations.push(`${at}: summary entry "${e.eventId}" is not listed in summarizedRanges`);
152
+ } else {
153
+ verbatim.push(e.eventId);
154
+ }
155
+ }
156
+ if (verbatim.join('\u0000') !== w.eventIds.join('\u0000')) violations.push(`${at}: the verbatim entries[] (${verbatim.length}) are not eventIds (${w.eventIds.length}) in the same order`);
157
+ }
158
+
159
+ // (c) — the recent tail.
160
+ const fed = w.eventIds.map((id) => byId.get(id)).filter((e): e is LogEvent => e !== undefined);
161
+ if (fed.length === 0) return { violations, pressure: w.summarizedRanges.length > 0, unmeasured };
162
+ for (let i = 1; i < fed.length; i += 1) {
163
+ if (fed[i].sequence <= fed[i - 1].sequence) { violations.push(`${at}: eventIds are not in event-log order`); break; }
164
+ }
165
+ const decided = log.find((e) => e.type === 'runOrchestrator.decided' && e.payload['iteration'] === iteration);
166
+ const cutoff = decided?.sequence ?? Number.POSITIVE_INFINITY;
167
+ for (const e of fed) if (e.sequence >= cutoff) violations.push(`${at}: eventId "${e.eventId}" (seq ${e.sequence}) is at or after the iteration's own decision (seq ${cutoff})`);
168
+
169
+ const keys = new Set(fed.map(eligibilityKey));
170
+ const fedIds = new Set(fed.map((e) => e.eventId));
171
+ const replaced = new Set(w.summarizedRanges.flatMap((r) => r.replacedTurns));
172
+ const minFed = Math.min(...fed.map((e) => e.sequence));
173
+ const maxBound = Number.isFinite(cutoff) ? cutoff : Math.max(...fed.map((e) => e.sequence)) + 1;
174
+ let pressure = w.summarizedRanges.length > 0;
175
+ for (const e of log) {
176
+ if (!keys.has(eligibilityKey(e)) || fedIds.has(e.eventId)) continue;
177
+ if (e.sequence > minFed && e.sequence < maxBound && !replaced.has(e.eventId)) {
178
+ violations.push(`${at}: eligible event "${e.eventId}" (${e.type}, seq ${e.sequence}) was dropped while an older one (seq ${minFed}) was fed — not a recent tail`);
179
+ }
180
+ if (e.sequence < minFed) pressure = true;
181
+ }
182
+ return { violations, pressure, unmeasured };
183
+ }
184
+
185
+ /** The summary texts a window fed, in entry order — the model-facing proof of replay reuse. */
186
+ export function summaryTexts(w: TranscriptWindow, log: readonly LogEvent[]): string[] {
187
+ if (w.entries === undefined) return [];
188
+ const summaryIds = new Set(log.filter((e) => e.type === 'context.summarized').map((e) => e.eventId));
189
+ return w.entries.filter((e) => summaryIds.has(e.eventId)).map((e) => e.rendered);
190
+ }
191
+
192
+ /** v1 reads `summarization.supported: true`; v2 has no `supported` seat — presence of the record is the claim (capabilities.md §2). */
193
+ export function summarizationDeclared(contextBudget: unknown, major: 1 | 2): boolean {
194
+ if (!isRecord(contextBudget)) return false;
195
+ const s = contextBudget['summarization'];
196
+ if (!isRecord(s)) return false;
197
+ return major === 2 ? true : s['supported'] === true;
198
+ }
@@ -74,9 +74,17 @@
74
74
  */
75
75
  export const HARNESS_DOUBLE_MODULES: readonly string[] = [
76
76
  'a2a-fake-peer',
77
+ // The two receiver modules whose only job is a server the HOST calls (2.40.4).
78
+ // They were missing, so `v2-a2a-push-delivery`'s correct declaration read as a
79
+ // callback it "does not make" and Conformance Soak went red on every run from
80
+ // #1546 (2026-09-24) on, while eight webhook/effect scenarios that DO take a
81
+ // host callback declared nothing. `webhook-receiver` stays off the list: it is
82
+ // mostly verification helpers, and importing it proves nothing.
83
+ 'effect-receiver',
77
84
  'mcp-fake-server',
78
85
  'oidc-issuer',
79
86
  'otel-collector',
87
+ 'scoped-receiver',
80
88
  ];
81
89
 
82
90
  /**
@@ -141,17 +141,40 @@ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never
141
141
 
142
142
  it('evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary', () => {
143
143
  const env = {
144
- OPENWOP_API_KEY: 'k1',
145
- OPENWOP_MESSAGING_BRIDGE_TOKEN: 't1',
146
- OPENWOP_DB_PASSWORD: 'p1',
147
- OPENWOP_CLIENT_SECRET: 's1',
144
+ OPENWOP_API_KEY: 'k1-api-key-value',
145
+ OPENWOP_MESSAGING_BRIDGE_TOKEN: 't1-bridge-token',
146
+ OPENWOP_DB_PASSWORD: 'p1-db-password',
147
+ OPENWOP_CLIENT_SECRET: 's1-client-secret',
148
148
  OPENWOP_BASE_URL: 'https://example.invalid', // not a secret
149
149
  OPENWOP_REQUIRE_BEHAVIOR: 'true',
150
150
  PATH: '/usr/bin',
151
151
  SOME_OTHER_TOKEN: 'not-ours',
152
152
  } as NodeJS.ProcessEnv;
153
153
  const secrets = evidenceSecretsFromEnv(env, [API_KEY, undefined, '']);
154
- expect(secrets.sort(), req('openwop.it.certification-bundle-redaction.evidencesecretsfromenv-selects-openwop-key-token-secret-password-variables-the-h', 'RFC 0148 §C', 'evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary')).toEqual([API_KEY, 'k1', 'p1', 's1', 't1', CONFORMANCE_SECRET_CANARY].sort());
154
+ expect(secrets.sort(), req('openwop.it.certification-bundle-redaction.evidencesecretsfromenv-selects-openwop-key-token-secret-password-variables-the-h', 'RFC 0148 §C', 'evidenceSecretsFromEnv selects OPENWOP_* key/token/secret/password variables, the handed credential, and ALWAYS the canary')).toEqual([API_KEY, 'k1-api-key-value', 'p1-db-password', 's1-client-secret', 't1-bridge-token', CONFORMANCE_SECRET_CANARY].sort());
155
+ });
156
+
157
+ it('evidenceSecretsFromEnv never treats a setting ABOUT a secret as one: a bare integer or a value under 8 characters is not swept from the environment', () => {
158
+ // Suite 2.40.2: openwop-app set OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60,
159
+ // the name matched SECRET, and scrubEvidence rewrote the "60" inside
160
+ // discovery.sha256 — the bundle failed ^[0-9a-f]{64}$ and main could not deploy.
161
+ const digest = '4b8d99c002a061a0255e8224ee683162007febadd251b2d171b47c0849695315';
162
+ const env = {
163
+ OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S: '60',
164
+ OPENWOP_TOKEN_TTL_SECONDS_KEY: '86400000',
165
+ OPENWOP_SHORT_KEY: 'abc1234',
166
+ OPENWOP_REAL_SECRET: 'whsec_c2VjcmV0LXZhbHVl',
167
+ } as NodeJS.ProcessEnv;
168
+ const secrets = evidenceSecretsFromEnv(env, ['k9']);
169
+ const R = 'openwop.it.certification-bundle-redaction.setting-about-a-secret-not-swept';
170
+ expect(secrets, req(R, 'RFC 0148 §C', 'a bare-integer setting whose NAME mentions a secret (a rotation overlap, a TTL) MUST NOT be swept as a credential')).not.toContain('60');
171
+ expect(secrets).not.toContain('86400000');
172
+ expect(secrets, req(R, 'RFC 0148 §C', 'a value under 8 characters is not credential-shaped and is not swept from the environment')).not.toContain('abc1234');
173
+ expect(secrets, req(R, 'RFC 0148 §C', 'a credential-shaped value is still swept')).toContain('whsec_c2VjcmV0LXZhbHVl');
174
+ expect(secrets, req(R, 'RFC 0148 §C', 'a credential the emitter hands over explicitly is scrubbed whatever its shape')).toContain('k9');
175
+ const { value } = scrubEvidence({ discovery: { sha256: digest } }, secrets);
176
+ expect(value.discovery.sha256, req(R, 'RFC 0148 §C', 'scrubbing MUST NOT corrupt a digest the bundle carries')).toBe(digest);
177
+ expect(value.discovery.sha256).toMatch(/^[0-9a-f]{64}$/);
155
178
  });
156
179
 
157
180
  it('evidenceSecretsFromEnv never scrubs a key IDENTIFIER: *_ID variables are excluded and `except` values stay visible whichever variable carried them', () => {
@@ -162,14 +185,14 @@ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never
162
185
  OPENWOP_BUNDLE_SIGNING_KEY: '-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----',
163
186
  OPENWOP_BUNDLE_SIGNING_KEY_ID: 'myndhyve-bundle-2026-09',
164
187
  OPENWOP_BUNDLE_VERIFIER_KEY_ID: 'steward-verifier-1',
165
- OPENWOP_API_KEY: 'k1',
188
+ OPENWOP_API_KEY: 'k1-api-key-value',
166
189
  OPENWOP_ODDLY_NAMED_KEY: 'published-key-id-in-a-secret-shaped-variable',
167
190
  } as NodeJS.ProcessEnv;
168
191
  const secrets = evidenceSecretsFromEnv(env, [], ['published-key-id-in-a-secret-shaped-variable']);
169
192
  expect(secrets, req('openwop.it.certification-bundle-redaction.key-identifier-never-scrubbed', 'RFC 0168 §E.2', 'the keyId a host publishes in discovery.document.signingKeys[] MUST survive evidence scrubbing — a *_ID variable is an identifier, not a secret')).not.toContain('myndhyve-bundle-2026-09');
170
193
  expect(secrets).not.toContain('steward-verifier-1');
171
194
  expect(secrets, req('openwop.it.certification-bundle-redaction.key-identifier-never-scrubbed', 'RFC 0168 §E.2', 'a value the emitter names as `except` (the keyId it is about to publish) is never scrubbed, whichever variable carried it')).not.toContain('published-key-id-in-a-secret-shaped-variable');
172
- expect(secrets).toContain('k1');
195
+ expect(secrets).toContain('k1-api-key-value');
173
196
  expect(secrets.some((s) => s.includes('BEGIN PRIVATE KEY'))).toBe(true);
174
197
  expect(secrets).toContain(CONFORMANCE_SECRET_CANARY);
175
198
  });