@openwop/openwop-conformance 1.106.0 → 1.123.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.
Files changed (154) hide show
  1. package/README.md +3 -3
  2. package/coverage.md +6 -0
  3. package/dist/cli.js +89 -45
  4. package/dist/lib/certification-bundle-verify.js +274 -0
  5. package/dist/lib/profiles.js +102 -19
  6. package/dist/lib/requirement-ledger.js +182 -0
  7. package/dist/lib/requirement-registry.js +110 -0
  8. package/dist/lib/scenario-disposition.js +249 -0
  9. package/dist/lib/soft-skip.js +82 -0
  10. package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
  11. package/fixtures.md +1 -1
  12. package/package.json +1 -1
  13. package/schemas/CORPUS-STAMP.json +2 -2
  14. package/schemas/README.md +1 -0
  15. package/schemas/capabilities.schema.json +2 -2
  16. package/schemas/compensation-policy.schema.json +64 -0
  17. package/schemas/run-snapshot.schema.json +5 -0
  18. package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
  19. package/schemas/workflow-definition.schema.json +4 -0
  20. package/src/cli.ts +99 -45
  21. package/src/lib/a2a-fake-peer.ts +290 -110
  22. package/src/lib/certification-bundle-verify.ts +356 -0
  23. package/src/lib/llm-cache-key-recipe.ts +6 -5
  24. package/src/lib/mcp-fake-server.ts +235 -72
  25. package/src/lib/profiles.ts +132 -19
  26. package/src/lib/requirement-ledger.ts +98 -1
  27. package/src/lib/requirement-registry.ts +47 -4
  28. package/src/lib/scenario-disposition.ts +291 -0
  29. package/src/lib/soft-skip.ts +89 -0
  30. package/src/lib/workflow-chain-expansion.ts +189 -0
  31. package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
  32. package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
  33. package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
  34. package/src/scenarios/a2a-peer-authority.test.ts +99 -0
  35. package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
  36. package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
  37. package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
  38. package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
  39. package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
  40. package/src/scenarios/approval-gate-flow.test.ts +5 -4
  41. package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
  42. package/src/scenarios/auth-scim-profile.test.ts +5 -4
  43. package/src/scenarios/authorization-fail-closed.test.ts +4 -3
  44. package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
  45. package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
  46. package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
  47. package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
  48. package/src/scenarios/commitment-fired.test.ts +3 -2
  49. package/src/scenarios/compensation-behavior.test.ts +63 -0
  50. package/src/scenarios/compensation-profile.test.ts +144 -1
  51. package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
  52. package/src/scenarios/conformance-execution-witness.test.ts +104 -0
  53. package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
  54. package/src/scenarios/credential-payload-redaction.test.ts +4 -3
  55. package/src/scenarios/credentials-capability-shape.test.ts +4 -3
  56. package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
  57. package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
  58. package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
  59. package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
  60. package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
  61. package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
  62. package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
  63. package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
  64. package/src/scenarios/distillation-shape.test.ts +2 -1
  65. package/src/scenarios/distillation-stable-archive.test.ts +4 -3
  66. package/src/scenarios/distillation-token-budget.test.ts +4 -3
  67. package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
  68. package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
  69. package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
  70. package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
  71. package/src/scenarios/feedback-record-and-list.test.ts +4 -3
  72. package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
  73. package/src/scenarios/fixtures-valid.test.ts +16 -18
  74. package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
  75. package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
  76. package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
  77. package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
  78. package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
  79. package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
  80. package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
  81. package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
  82. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
  83. package/src/scenarios/mcp-stateless-request.test.ts +77 -0
  84. package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
  85. package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
  86. package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
  87. package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
  88. package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
  89. package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
  90. package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
  91. package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
  92. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
  93. package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
  94. package/src/scenarios/memory-injection-budget.test.ts +4 -3
  95. package/src/scenarios/metric-emission.test.ts +4 -3
  96. package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
  97. package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
  98. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
  99. package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
  100. package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
  101. package/src/scenarios/multi-region-idempotency.test.ts +4 -3
  102. package/src/scenarios/normative-example-extraction.test.ts +242 -0
  103. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
  104. package/src/scenarios/oauth-capability-shape.test.ts +4 -3
  105. package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
  106. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
  107. package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
  108. package/src/scenarios/otel-emission-grpc.test.ts +4 -3
  109. package/src/scenarios/otel-emission.test.ts +4 -3
  110. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
  111. package/src/scenarios/otel-trace-propagation.test.ts +4 -3
  112. package/src/scenarios/pack-registry-isolation.test.ts +4 -3
  113. package/src/scenarios/pack-registry-publish.test.ts +65 -3
  114. package/src/scenarios/pack-registry.test.ts +22 -0
  115. package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
  116. package/src/scenarios/profileDerivation.test.ts +2 -1
  117. package/src/scenarios/rate-limit-envelope.test.ts +2 -1
  118. package/src/scenarios/registry-public.test.ts +6 -5
  119. package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
  120. package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
  121. package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
  122. package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
  123. package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
  124. package/src/scenarios/requirement-ledger.test.ts +29 -6
  125. package/src/scenarios/runner-ledger.test.ts +279 -0
  126. package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
  127. package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
  128. package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
  129. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
  130. package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
  131. package/src/scenarios/stream-text-fixture.test.ts +5 -4
  132. package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
  133. package/src/scenarios/subrun-approval-gate.test.ts +4 -3
  134. package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
  135. package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
  136. package/src/scenarios/test-seam-unauthenticated.test.ts +61 -11
  137. package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
  138. package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
  139. package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
  140. package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
  141. package/src/scenarios/tool-hooks-shape.test.ts +2 -1
  142. package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
  143. package/src/scenarios/version-fold.test.ts +5 -4
  144. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
  145. package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
  146. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
  147. package/src/scenarios/wasm-pack-load.test.ts +4 -3
  148. package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
  149. package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
  150. package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
  151. package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
  152. package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
  153. package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
  154. package/src/setup.ts +95 -1
@@ -814,6 +814,10 @@
814
814
  "type": "integer",
815
815
  "minimum": 1,
816
816
  "default": 5
817
+ },
818
+ "compensation": {
819
+ "$ref": "compensation-policy.schema.json",
820
+ "description": "RFC 0151 §B — the workflow-level compensation policy (WHEN an unwind starts and HOW it runs). OPTIONAL and capability-gated: a host that advertises `capabilities.compensation` MUST validate it at registration; a host that does not MUST refuse the workflow with `capability_required` rather than accept a policy it will never honour. Node-level `nodes[].compensation` declarations remain valid without it — they describe the inverse action; this key requests the unwind."
817
821
  }
818
822
  }
819
823
  }
package/src/cli.ts CHANGED
@@ -33,10 +33,14 @@ import { tmpdir } from 'node:os';
33
33
  import Ajv2020 from 'ajv/dist/2020.js';
34
34
  import addFormats from 'ajv-formats';
35
35
  import { SCHEMAS_DIR } from './lib/paths.js';
36
+ import { readLedgerFile } from './lib/requirement-ledger.js';
37
+ import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
38
+ import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
36
39
  import {
37
40
  deriveProfiles,
38
41
  isCoreStandard,
39
42
  agentPlatformStatus,
43
+ DEPRECATED_PROFILE_ALIASES,
40
44
  type DiscoveryPayload,
41
45
  } from './lib/profiles.js';
42
46
 
@@ -284,9 +288,14 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
284
288
  const conformanceRoot = resolvePath(here, '..');
285
289
  const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
286
290
  const reportFile = join(reportDir, 'vitest-report.json');
291
+ // RFC 0148 §A ledger sink (S6): every scenario file records its disposition
292
+ // (and assertion count) here; the runner reads it after the run so bundle v2
293
+ // rows come from what scenarios RECORDED, not from per-file pass/fail/skip.
294
+ const ledgerFile = join(reportDir, 'requirement-ledger.jsonl');
287
295
  const env: NodeJS.ProcessEnv = { ...process.env };
288
296
  env.OPENWOP_BASE_URL = baseUrl;
289
297
  env.OPENWOP_API_KEY = apiKey;
298
+ env.OPENWOP_LEDGER_PATH = ledgerFile;
290
299
  if (args.impl) env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
291
300
  if (args.implVersion) env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
292
301
 
@@ -307,6 +316,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
307
316
  }
308
317
 
309
318
  let report: VitestJsonReport;
319
+ const ledgerEntries = readLedgerFile(ledgerFile);
310
320
  try {
311
321
  report = JSON.parse(readFileSync(reportFile, 'utf8')) as VitestJsonReport;
312
322
  } catch (err) {
@@ -382,6 +392,21 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
382
392
  // a large `blocked` count and therefore does NOT certify. That is not a defect
383
393
  // in the emitter. It is the true state of the evidence, which v1 was unable to
384
394
  // represent and therefore reported as a clean skip.
395
+ // (d1) RFC 0148 acceptance item 2 — requirement-level dispositions from the
396
+ // ledger, and rejection of unclassified returns for a claimed profile.
397
+ const derived = deriveRequirementDispositions(states, ledgerEntries, claimedProfiles, document as Record<string, unknown>);
398
+ const notHeld = new Set(derived.verdicts.filter((v) => v.runtimeDerived && !v.held).map((v) => v.profile));
399
+ const rejectedProfiles = derived.verdicts.filter((v) => v.unclassified.length > 0);
400
+ if (rejectedProfiles.length > 0) {
401
+ process.stderr.write(
402
+ 'openwop-conformance --certify: REJECTING certification — a claimed profile has UNCLASSIFIED floor requirements\n' +
403
+ ' (no disposition recorded, or an executed-pass with assertionCount 0 — a witness of nothing). RFC 0148 §A\n' +
404
+ ' resolves an unclassified requirement to `blocked`, never to a pass; the bundle is still written so the\n' +
405
+ ' evidence is inspectable, but it does not certify and this process exits 3.\n' +
406
+ rejectedProfiles.map((v) => ` - ${v.profile}: ${v.unclassified.join(', ')}\n`).join(''),
407
+ );
408
+ }
409
+
385
410
  if (args.bundleVersion === '2') {
386
411
  const scenarioIds = [...passed, ...failed, ...skipped].sort();
387
412
  const manifestSha = createHash('sha256').update(scenarioIds.join('\n'), 'utf8').digest('hex');
@@ -392,28 +417,15 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
392
417
  .update(`${args.baseUrl}\n${sha256}\n${process.env['OPENWOP_REQUIRE_BEHAVIOR'] ?? ''}`, 'utf8')
393
418
  .digest('hex');
394
419
 
395
- const requirements = [
396
- ...passed.map((f) => ({
397
- requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
398
- scenarioId: f,
399
- disposition: 'executed-pass' as const,
400
- })),
401
- ...failed.map((f) => ({
402
- requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
403
- scenarioId: f,
404
- disposition: 'executed-fail' as const,
405
- detail: 'the scenario executed and failed',
406
- })),
407
- ...skipped.map((f) => ({
408
- requirementId: `openwop.scenario.${f.replace(/\.test\.ts$/, '')}`,
409
- scenarioId: f,
410
- disposition: 'blocked' as const,
411
- detail:
412
- 'runner cannot classify a skipped file: the RFC 0148 §A requirement ledger is not ' +
413
- 'populated during a run, so skipped/inapplicable/blocked are indistinguishable here. ' +
414
- '§A resolves an unclassifiable requirement to `blocked`, never to a pass.',
415
- })),
416
- ];
420
+ // Rows come from the ledger (S6). A file that recorded nothing is
421
+ // `blocked` — unclassified — and, if it sits on a claimed floor, rejected above.
422
+ const requirements = derived.requirements.map((r) => ({
423
+ requirementId: r.requirementId,
424
+ scenarioId: r.scenarioId,
425
+ disposition: r.disposition,
426
+ ...(r.detail === undefined ? {} : { detail: r.detail }),
427
+ ...(r.assertionCount === undefined ? {} : { assertionCount: r.assertionCount }),
428
+ }));
417
429
 
418
430
  const v2 = {
419
431
  bundleVersion: '2' as const,
@@ -422,48 +434,81 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
422
434
  suite: { package: '@openwop/openwop-conformance' as const, version },
423
435
  host,
424
436
  discovery: { url: discoveryUrl, sha256, document },
425
- claimedProfiles,
437
+ // RFC 0155 §E: canonical ids only in `claimedProfiles`; a deprecated
438
+ // alias that also derives (`openwop-core`, always alongside
439
+ // `openwop-discovery-core`) is reported in `aliases`, never as a claim.
440
+ // A runtime-derived profile (`PROFILE_FLOOR_SCENARIOS[p].runtimeDerived`,
441
+ // today `openwop-node-packs`) is claimed only when the host HOLDS it — every
442
+ // floor row a witnessed pass. Its discovery predicate is `openwop-core`, so
443
+ // deriving the claim from discovery alone made every core host "claim" a
444
+ // registry it never advertised (RFC 0025: the read surface has no advert).
445
+ claimedProfiles: claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES) && !notHeld.has(p)),
446
+ ...(claimedProfiles.some((p) => p in DEPRECATED_PROFILE_ALIASES)
447
+ ? { aliases: claimedProfiles.filter((p) => p in DEPRECATED_PROFILE_ALIASES) }
448
+ : {}),
426
449
  results: {
427
- totals: {
428
- executedPass: passed.length,
429
- executedFail: failed.length,
430
- skipped: 0,
431
- inapplicable: 0,
432
- blocked: skipped.length,
433
- },
450
+ totals: derived.totals,
434
451
  requirements,
435
452
  },
436
453
  scenarioManifestSha256: manifestSha,
437
454
  targetConfigurationSha256: configSha,
438
455
  };
439
456
 
457
+ // RFC 0148 §C: secret canaries never enter evidence. Scrub the finished
458
+ // document with the credential this run was handed, every OPENWOP_*
459
+ // key/token/secret in the environment, and the conformance canary — a
460
+ // scenario's `detail` string, the captured discovery document, or a host
461
+ // field could carry any of them, and the emitter is the last place that
462
+ // can guarantee they do not ship.
463
+ const secrets = evidenceSecretsFromEnv(process.env, [apiKey]);
464
+ const scrubbed = scrubEvidence(v2, secrets);
465
+ const v2Out = scrubbed.value;
466
+ if (scrubbed.redactedAt.length > 0) {
467
+ process.stderr.write(
468
+ `openwop-conformance --certify: REDACTED ${scrubbed.redactedAt.length} evidence field(s) that carried a configured secret or the conformance canary: ${scrubbed.redactedAt.slice(0, 8).join(', ')}${scrubbed.redactedAt.length > 8 ? ', …' : ''}\n`,
469
+ );
470
+ }
471
+ // Self-audit with the consumer verifier: the emitter MUST NOT write a
472
+ // document the verifier would reject on shape (duplicate rows, totals that
473
+ // disagree with rows, unknown dispositions, a canary). Rejections scoped to
474
+ // a claimed profile (unwitnessed / vacuous) are the exit-3 case below and
475
+ // are reported through `derived`; a bundle-wide rejection here is a bug in
476
+ // this emitter and exits 2.
477
+ const selfAudit = verifyBundleV2(v2Out);
478
+ if (selfAudit.rejections.length > 0) {
479
+ process.stderr.write(
480
+ 'openwop-conformance --certify: assembled v2 bundle FAILED self-verification (emitter defect):\n' +
481
+ selfAudit.rejections.map((r) => ` - [${r.kind}] ${r.detail}`).join('\n') +
482
+ '\n',
483
+ );
484
+ process.exit(2);
485
+ }
486
+
440
487
  const v2Schema = JSON.parse(
441
488
  readFileSync(join(SCHEMAS_DIR, 'certification-bundle-v2.schema.json'), 'utf8'),
442
489
  ) as Record<string, unknown>;
443
490
  const v2Ajv = new Ajv2020({ allErrors: true, strict: false });
444
491
  addFormats(v2Ajv);
445
492
  const v2Validate = v2Ajv.compile(v2Schema);
446
- if (!v2Validate(v2)) {
493
+ if (!v2Validate(v2Out)) {
447
494
  process.stderr.write(
448
495
  'openwop-conformance --certify: assembled v2 bundle FAILED schema validation:\n' +
449
496
  `${JSON.stringify(v2Validate.errors, null, 2)}\n`,
450
497
  );
451
498
  process.exit(2);
452
499
  }
453
- writeFileSync(outPath, `${JSON.stringify(v2, null, 2)}\n`);
500
+ writeFileSync(outPath, `${JSON.stringify(v2Out, null, 2)}\n`);
454
501
  process.stdout.write(
455
502
  `openwop-conformance --certify: wrote bundle v2 to ${outPath}\n` +
456
503
  ` host: ${host.name}@${host.version}\n` +
457
- ` executed-pass ${passed.length} / executed-fail ${failed.length} / blocked ${skipped.length}\n` +
458
- (skipped.length > 0
459
- ? ` NOTE: ${skipped.length} requirement(s) are 'blocked', not 'skipped'. The runner cannot\n` +
460
- ` classify a skipped file until scenarios record into the RFC 0148 §A ledger, and §A\n` +
461
- ` resolves an unclassifiable requirement to 'blocked' rather than to a pass. A bundle\n` +
462
- ` with blocked > 0 does NOT certifythat is the true state of the evidence, not a\n` +
463
- ` defect in this emitter.\n`
464
- : ''),
504
+ ` executed-pass ${derived.totals.executedPass} / executed-fail ${derived.totals.executedFail} / skipped ${derived.totals.skipped} / inapplicable ${derived.totals.inapplicable} / blocked ${derived.totals.blocked}\n` +
505
+ (derived.ledgerPresent
506
+ ? ` dispositions come from the RFC 0148 §A ledger (${ledgerEntries.length} entries recorded by the scenarios)\n`
507
+ : ` NOTE: no ledger was recorded every skipped file is 'blocked' (unclassifiable), which is the honest reading\n`) +
508
+ (derived.totals.blocked > 0 ? ` a bundle with blocked > 0 does NOT certify — that is the state of the evidence, not a defect in this emitter\n` : '') +
509
+ derived.verdicts.map((v) => ` ${v.profile}: ${v.runtimeDerived && !v.held ? `not held (runtime-derived dropped from claimedProfiles; floor rows not witnessed passes: ${v.blocking.join(', ')})` : v.certifiable ? 'certifiable' : 'NOT certifiable'}${v.unclassified.length > 0 ? ` (unclassified: ${v.unclassified.length})` : ''}\n`).join(''),
465
510
  );
466
- process.exit(failed.length > 0 ? 1 : 0);
511
+ process.exit(rejectedProfiles.length > 0 ? 3 : failed.length > 0 ? 1 : 0);
467
512
  }
468
513
 
469
514
  // (e) Validate against the bundle schema BEFORE writing.
@@ -473,7 +518,14 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
473
518
  const ajv = new Ajv2020({ allErrors: true, strict: false });
474
519
  addFormats(ajv);
475
520
  const validate = ajv.compile(schema);
476
- if (!validate(bundle)) {
521
+ const bundleScrub = scrubEvidence(bundle, evidenceSecretsFromEnv(process.env, [apiKey]));
522
+ if (bundleScrub.redactedAt.length > 0) {
523
+ process.stderr.write(
524
+ `openwop-conformance --certify: REDACTED ${bundleScrub.redactedAt.length} evidence field(s) that carried a configured secret or the conformance canary\n`,
525
+ );
526
+ }
527
+ const bundleOut = bundleScrub.value;
528
+ if (!validate(bundleOut)) {
477
529
  process.stderr.write(
478
530
  'openwop-conformance --certify: assembled bundle FAILED schema validation:\n' +
479
531
  `${JSON.stringify(validate.errors, null, 2)}\n`,
@@ -481,7 +533,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
481
533
  process.exit(2);
482
534
  }
483
535
 
484
- writeFileSync(outPath, `${JSON.stringify(bundle, null, 2)}\n`);
536
+ writeFileSync(outPath, `${JSON.stringify(bundleOut, null, 2)}\n`);
485
537
  process.stdout.write(
486
538
  `openwop-conformance --certify: wrote certification bundle to ${outPath}\n` +
487
539
  ` host: ${host.name}@${host.version}\n` +
@@ -489,8 +541,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
489
541
  ` results: ${passed.length} passed / ${failed.length} failed / ${skipped.length} skipped\n`,
490
542
  );
491
543
  // Exit code mirrors the suite outcome: a failing run still produces a bundle
492
- // (the failures are recorded), but the process exit reflects pass/fail.
493
- process.exit(failed.length > 0 ? 1 : 0);
544
+ // (the failures are recorded), but the process exit reflects pass/fail — and
545
+ // 3 when a claimed profile has unclassified floor requirements (RFC 0148 §A;
546
+ // v1 bundles cannot express the distinction, so the exit code carries it).
547
+ process.exit(rejectedProfiles.length > 0 ? 3 : failed.length > 0 ? 1 : 0);
494
548
  }
495
549
 
496
550
  async function main(): Promise<never> {