@adcp/sdk 6.19.1 → 7.1.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 (89) hide show
  1. package/bin/adcp.js +48 -0
  2. package/dist/lib/core/AgentClient.d.ts.map +1 -1
  3. package/dist/lib/core/ConversationTypes.d.ts +33 -2
  4. package/dist/lib/core/ConversationTypes.d.ts.map +1 -1
  5. package/dist/lib/core/SingleAgentClient.d.ts.map +1 -1
  6. package/dist/lib/core/SingleAgentClient.js +21 -8
  7. package/dist/lib/core/SingleAgentClient.js.map +1 -1
  8. package/dist/lib/index.d.ts +1 -1
  9. package/dist/lib/index.d.ts.map +1 -1
  10. package/dist/lib/index.js.map +1 -1
  11. package/dist/lib/schemas-data/v2.5/_provenance.json +1 -1
  12. package/dist/lib/server/create-adcp-server.d.ts.map +1 -1
  13. package/dist/lib/server/create-adcp-server.js +8 -7
  14. package/dist/lib/server/create-adcp-server.js.map +1 -1
  15. package/dist/lib/server/decisioning/runtime/from-platform.js +54 -52
  16. package/dist/lib/server/decisioning/runtime/from-platform.js.map +1 -1
  17. package/dist/lib/server/envelope-allowlist.d.ts.map +1 -1
  18. package/dist/lib/server/envelope-allowlist.js +17 -0
  19. package/dist/lib/server/envelope-allowlist.js.map +1 -1
  20. package/dist/lib/server/idempotency/store.d.ts +8 -0
  21. package/dist/lib/server/idempotency/store.d.ts.map +1 -1
  22. package/dist/lib/server/idempotency/store.js +28 -4
  23. package/dist/lib/server/idempotency/store.js.map +1 -1
  24. package/dist/lib/server/wire-spec-fields.generated.js +1 -1
  25. package/dist/lib/testing/client.d.ts +16 -2
  26. package/dist/lib/testing/client.d.ts.map +1 -1
  27. package/dist/lib/testing/client.js +1 -0
  28. package/dist/lib/testing/client.js.map +1 -1
  29. package/dist/lib/testing/compliance/comply.d.ts.map +1 -1
  30. package/dist/lib/testing/compliance/comply.js +79 -15
  31. package/dist/lib/testing/compliance/comply.js.map +1 -1
  32. package/dist/lib/testing/compliance/spec-conformance.d.ts.map +1 -1
  33. package/dist/lib/testing/compliance/spec-conformance.js +1 -0
  34. package/dist/lib/testing/compliance/spec-conformance.js.map +1 -1
  35. package/dist/lib/testing/compliance/types.d.ts +35 -0
  36. package/dist/lib/testing/compliance/types.d.ts.map +1 -1
  37. package/dist/lib/testing/index.d.ts +1 -1
  38. package/dist/lib/testing/index.d.ts.map +1 -1
  39. package/dist/lib/testing/index.js.map +1 -1
  40. package/dist/lib/testing/storyboard/default-invariants.js +21 -2
  41. package/dist/lib/testing/storyboard/default-invariants.js.map +1 -1
  42. package/dist/lib/testing/storyboard/index.d.ts +1 -1
  43. package/dist/lib/testing/storyboard/index.d.ts.map +1 -1
  44. package/dist/lib/testing/storyboard/index.js.map +1 -1
  45. package/dist/lib/testing/storyboard/loader.d.ts.map +1 -1
  46. package/dist/lib/testing/storyboard/loader.js +17 -0
  47. package/dist/lib/testing/storyboard/loader.js.map +1 -1
  48. package/dist/lib/testing/storyboard/parallel-dispatch.d.ts +110 -0
  49. package/dist/lib/testing/storyboard/parallel-dispatch.d.ts.map +1 -0
  50. package/dist/lib/testing/storyboard/parallel-dispatch.js +221 -0
  51. package/dist/lib/testing/storyboard/parallel-dispatch.js.map +1 -0
  52. package/dist/lib/testing/storyboard/request-builder.js +1 -1
  53. package/dist/lib/testing/storyboard/request-builder.js.map +1 -1
  54. package/dist/lib/testing/storyboard/runner.d.ts +3 -1
  55. package/dist/lib/testing/storyboard/runner.d.ts.map +1 -1
  56. package/dist/lib/testing/storyboard/runner.js +591 -49
  57. package/dist/lib/testing/storyboard/runner.js.map +1 -1
  58. package/dist/lib/testing/storyboard/task-map.d.ts +1 -0
  59. package/dist/lib/testing/storyboard/task-map.d.ts.map +1 -1
  60. package/dist/lib/testing/storyboard/task-map.js +7 -1
  61. package/dist/lib/testing/storyboard/task-map.js.map +1 -1
  62. package/dist/lib/testing/storyboard/types.d.ts +209 -2
  63. package/dist/lib/testing/storyboard/types.d.ts.map +1 -1
  64. package/dist/lib/testing/storyboard/types.js +2 -0
  65. package/dist/lib/testing/storyboard/types.js.map +1 -1
  66. package/dist/lib/testing/storyboard/validations.d.ts +38 -0
  67. package/dist/lib/testing/storyboard/validations.d.ts.map +1 -1
  68. package/dist/lib/testing/storyboard/validations.js +143 -0
  69. package/dist/lib/testing/storyboard/validations.js.map +1 -1
  70. package/dist/lib/testing/types.d.ts +2 -0
  71. package/dist/lib/testing/types.d.ts.map +1 -1
  72. package/dist/lib/utils/error-extraction.d.ts +2 -1
  73. package/dist/lib/utils/error-extraction.d.ts.map +1 -1
  74. package/dist/lib/utils/error-extraction.js +28 -0
  75. package/dist/lib/utils/error-extraction.js.map +1 -1
  76. package/dist/lib/utils/request-normalizer.d.ts +1 -0
  77. package/dist/lib/utils/request-normalizer.d.ts.map +1 -1
  78. package/dist/lib/utils/request-normalizer.js +34 -11
  79. package/dist/lib/utils/request-normalizer.js.map +1 -1
  80. package/dist/lib/utils/response-unwrapper.d.ts +33 -0
  81. package/dist/lib/utils/response-unwrapper.d.ts.map +1 -1
  82. package/dist/lib/utils/response-unwrapper.js +41 -3
  83. package/dist/lib/utils/response-unwrapper.js.map +1 -1
  84. package/dist/lib/version.d.ts +10 -6
  85. package/dist/lib/version.d.ts.map +1 -1
  86. package/dist/lib/version.js +11 -4
  87. package/dist/lib/version.js.map +1 -1
  88. package/package.json +1 -1
  89. package/skills/build-decisioning-platform/advanced/REFERENCE.md +2 -0
@@ -29,8 +29,10 @@ const rejection_hints_1 = require("./rejection-hints");
29
29
  const shape_drift_hints_1 = require("./shape-drift-hints");
30
30
  const strict_validation_hints_1 = require("./strict-validation-hints");
31
31
  const validations_1 = require("./validations");
32
+ const parallel_dispatch_1 = require("./parallel-dispatch");
32
33
  const path_1 = require("./path");
33
34
  const redact_secrets_1 = require("../../utils/redact-secrets");
35
+ const response_unwrapper_1 = require("../../utils/response-unwrapper");
34
36
  const test_controller_1 = require("../test-controller");
35
37
  const request_builder_1 = require("./request-builder");
36
38
  const client_2 = require("../client");
@@ -242,6 +244,7 @@ function applyBranchSetGrading(phases, phaseResults, branchSetByPhaseId, contrib
242
244
  step.skip_reason = 'peer_branch_taken';
243
245
  step.skip = { reason: 'peer_branch_taken', detail };
244
246
  delete step.error;
247
+ delete step.adcp_error;
245
248
  skippedDelta++;
246
249
  regraded = true;
247
250
  }
@@ -679,6 +682,7 @@ function buildCapabilityUnsupportedResult(agentUrls, storyboard, detail) {
679
682
  strict_only_failures: 0,
680
683
  lenient_also_failed: 0,
681
684
  },
685
+ notices: [],
682
686
  };
683
687
  }
684
688
  /**
@@ -698,6 +702,8 @@ const REQUIREMENT_TO_SKIP_REASON = {
698
702
  controller: 'missing_test_controller',
699
703
  seeded_state: 'requirement_unmet',
700
704
  real_wire: 'requirement_unmet',
705
+ webhook_receiver: 'requirement_unmet',
706
+ request_signer: 'not_applicable',
701
707
  };
702
708
  /**
703
709
  * Build a minimal StoryboardResult for a storyboard skipped because a
@@ -753,6 +759,7 @@ function buildRequirementUnmetResult(agentUrls, storyboard, requirement, detail)
753
759
  strict_only_failures: 0,
754
760
  lenient_also_failed: 0,
755
761
  },
762
+ notices: [],
756
763
  };
757
764
  }
758
765
  /**
@@ -770,8 +777,25 @@ function buildRequirementUnmetResult(agentUrls, storyboard, requirement, detail)
770
777
  * - `real_wire` — always available; the runner is hitting a real wire
771
778
  * by definition. The tag is a no-op gate today, reserved for a
772
779
  * future `--mock-only` mode.
780
+ * - `webhook_receiver` — operator supplied `options.webhook_receiver`.
781
+ * Autodetected from step `sample_request` token presence (see
782
+ * `detectImplicitRequires`); authors don't write this tag manually.
783
+ * Spec: adcp-client#1678.
784
+ * - `request_signer` — agent advertises `request_signing.supported: true`
785
+ * in `get_adcp_capabilities`. Autodetected for any storyboard whose
786
+ * id is `'signed_requests'` or that contains a `request_signing_probe`
787
+ * step (see `detectImplicitRequires`); authors don't write this tag
788
+ * manually. Absence-skip semantics are INVERTED from the general
789
+ * `requires_capability` rule: the signed-requests universal
790
+ * storyboard's own gating spec (signed-requests.yaml prerequisites)
791
+ * declares "agents that do not advertise support are not tested
792
+ * against this storyboard — absence of advertisement is not a
793
+ * failure". When the runner can't read capabilities (no profile
794
+ * threaded through), the gate is a no-op — the caller accepted
795
+ * responsibility for capability compatibility by reusing an external
796
+ * client. Spec: adcp-client#1702.
773
797
  */
774
- function checkRequires(requires, options) {
798
+ function checkRequires(requires, options, profile) {
775
799
  for (const requirement of requires) {
776
800
  switch (requirement) {
777
801
  case 'controller': {
@@ -799,10 +823,119 @@ function checkRequires(requires, options) {
799
823
  case 'real_wire':
800
824
  // Always available — reserved for a future --mock-only mode.
801
825
  break;
826
+ case 'webhook_receiver': {
827
+ if (options.webhook_receiver === undefined) {
828
+ return {
829
+ requirement,
830
+ detail: 'Storyboard references `{{runner.webhook_url:…}}` or `{{runner.webhook_base}}` but no ' +
831
+ 'webhook receiver is configured. Pass `webhook_receiver` in StoryboardRunOptions ' +
832
+ '(or `--webhook-receiver` on the CLI) to host a receiver, or omit this storyboard. ' +
833
+ 'Required by the webhook-emission universal: ' +
834
+ 'compliance/{version}/universal/webhook-emission.yaml grades not_applicable when ' +
835
+ 'no receiver is configured (prerequisites section).',
836
+ };
837
+ }
838
+ break;
839
+ }
840
+ case 'request_signer': {
841
+ // Gate is a no-op when no profile is threaded through (external
842
+ // `_client` mode). The caller has accepted responsibility for
843
+ // capability compatibility; failing the gate here would surprise
844
+ // them with a skip they can't explain from CLI options alone.
845
+ if (!profile?.raw_capabilities)
846
+ break;
847
+ const supported = resolveCapabilityPath(profile.raw_capabilities, 'request_signing.supported');
848
+ if (supported === true)
849
+ break;
850
+ return {
851
+ requirement,
852
+ detail: 'Storyboard requires `request_signing.supported: true` in `get_adcp_capabilities`; ' +
853
+ `agent declared ${supported === undefined ? 'no request_signing block' : JSON.stringify(supported)}. ` +
854
+ 'Per compliance/{version}/universal/signed-requests.yaml: "Agents that do not advertise ' +
855
+ 'support are not tested against this storyboard — absence of advertisement is not a ' +
856
+ 'failure, it is a declaration that the agent does not offer verified signed requests." ' +
857
+ 'To opt in, advertise `request_signing.supported: true` and pre-register the runner ' +
858
+ 'compliance test keypair per test-kits/signed-requests-runner.yaml. ' +
859
+ 'Forward-readiness: optional in AdCP 3.0; the schema (request_signing block description) ' +
860
+ 'declares request signing required for spend-committing operations in AdCP 4.0. Sellers ' +
861
+ 'that intend to support spend-committing tools SHOULD advertise the capability and ' +
862
+ 'register the test keypair before the 4.0 cut to avoid a hard compliance failure then.',
863
+ };
864
+ }
802
865
  }
803
866
  }
804
867
  return null;
805
868
  }
869
+ /**
870
+ * Webhook-token regex used to autodetect the implicit `webhook_receiver`
871
+ * requirement. Matches `{{runner.webhook_url:<step_id>}}` and the bare
872
+ * `{{runner.webhook_base}}`. Same shape as the `MUSTACHE_TOKEN_RE` in
873
+ * context.ts but scoped to the two webhook-bearing tokens — we only care
874
+ * whether the storyboard needs a receiver, not what substitution it
875
+ * would produce. Single `[^{}]+` body avoids the polynomial-redos
876
+ * backtrack pattern flagged by CodeQL alert #49.
877
+ */
878
+ const WEBHOOK_TOKEN_RE = /\{\{runner\.(?:webhook_url:[A-Za-z0-9_]+|webhook_base)\}\}/;
879
+ /**
880
+ * Recursively scan a JSON-like value for any webhook receiver token. The
881
+ * scan only touches strings; objects and arrays are walked structurally
882
+ * so deeply-nested `push_notification_config.url` entries (or any other
883
+ * authoring pattern) are discovered without a hard-coded field list.
884
+ */
885
+ function valueContainsWebhookToken(value) {
886
+ if (typeof value === 'string') {
887
+ return WEBHOOK_TOKEN_RE.test(value);
888
+ }
889
+ if (Array.isArray(value)) {
890
+ return value.some(valueContainsWebhookToken);
891
+ }
892
+ if (value !== null && typeof value === 'object') {
893
+ return Object.values(value).some(valueContainsWebhookToken);
894
+ }
895
+ return false;
896
+ }
897
+ /**
898
+ * Return the implicit requirements a storyboard needs based on its
899
+ * structure (not its declared `requires:` list). Today:
900
+ * - `'webhook_receiver'`, autodetected from `{{runner.webhook_url:…}}`
901
+ * / `{{runner.webhook_base}}` token presence inside any step's
902
+ * `sample_request`. Token presence is the authoring contract — a
903
+ * storyboard that names the runner's webhook receiver cannot run
904
+ * without one — so authors don't need to remember to add
905
+ * `requires: [webhook_receiver]` separately. Spec: adcp-client#1678.
906
+ * - `'request_signer'`, autodetected from `storyboard.id ===
907
+ * 'signed_requests'` or any step using the synthesized
908
+ * `request_signing_probe` task. The signed-requests universal
909
+ * storyboard's own prerequisites section declares the
910
+ * `request_signing.supported: true` capability gate; the runner
911
+ * enforces it here so adopters don't see false-negative vector
912
+ * failures on bearer-only agents that never claimed signing.
913
+ * Spec: adcp-client#1702.
914
+ */
915
+ function detectImplicitRequires(storyboard) {
916
+ const requires = [];
917
+ let needsWebhook = false;
918
+ let needsSigner = storyboard.id === 'signed_requests';
919
+ for (const phase of storyboard.phases) {
920
+ for (const step of phase.steps) {
921
+ if (!needsWebhook && step.sample_request && valueContainsWebhookToken(step.sample_request)) {
922
+ needsWebhook = true;
923
+ }
924
+ if (!needsSigner && step.task === 'request_signing_probe') {
925
+ needsSigner = true;
926
+ }
927
+ if (needsWebhook && needsSigner)
928
+ break;
929
+ }
930
+ if (needsWebhook && needsSigner)
931
+ break;
932
+ }
933
+ if (needsWebhook)
934
+ requires.push('webhook_receiver');
935
+ if (needsSigner)
936
+ requires.push('request_signer');
937
+ return requires;
938
+ }
806
939
  /**
807
940
  * Build a hard-failure StoryboardResult for when agent capability
808
941
  * discovery (`get_agent_info` / MCP `tools/list`) failed. Surfacing
@@ -858,6 +991,7 @@ function buildDiscoveryFailedResult(agentUrls, storyboard, discoveryStep) {
858
991
  strict_only_failures: 0,
859
992
  lenient_also_failed: 0,
860
993
  },
994
+ notices: [],
861
995
  };
862
996
  }
863
997
  /**
@@ -915,8 +1049,85 @@ function buildRequiredToolsMissingResult(agentUrls, storyboard, detail) {
915
1049
  strict_only_failures: 0,
916
1050
  lenient_also_failed: 0,
917
1051
  },
1052
+ notices: [],
918
1053
  };
919
1054
  }
1055
+ /**
1056
+ * Collect protocol-compliance notices for a storyboard run based on the
1057
+ * agent's declared capabilities. Returns an empty array when rawCaps is
1058
+ * absent (standalone runner without pre-fetched profile).
1059
+ *
1060
+ * Currently emits two spec-grounded notices (adcp-client#1704):
1061
+ *
1062
+ * - `request_signing.required`: `request_signing.supported` is absent or
1063
+ * false on the signed_requests storyboard. Signing becomes required for
1064
+ * spend-committing operations in `effective_version: '4.0'`.
1065
+ *
1066
+ * - `webhook_signing.legacy_hmac_fallback.removed`: agent claims
1067
+ * `webhook_signing.legacy_hmac_fallback: true`, which is removed in
1068
+ * `effective_version: '4.0'`.
1069
+ *
1070
+ * Note: a third notice (`signed_requests_specialism_deprecated`) is deferred
1071
+ * pending upstream deprecation of the `'signed-requests'` specialism value in
1072
+ * adcontextprotocol/adcp#4418 — the value is still active in the spec.
1073
+ */
1074
+ function collectCapabilityNotices(storyboard, rawCaps) {
1075
+ const notices = [];
1076
+ if (!rawCaps || typeof rawCaps !== 'object')
1077
+ return notices;
1078
+ const caps = rawCaps;
1079
+ // Notice: request_signing required in AdCP 4.0.
1080
+ // Fired when this is the signed_requests storyboard and the agent hasn't
1081
+ // declared request_signing support. The storyboard is identified by id OR
1082
+ // by the presence of a request_signing_probe step (mirrors the implicit-
1083
+ // require detection in detectImplicitRequires for PR #1703's gate).
1084
+ const isSignedRequestsStoryboard = storyboard.id === 'signed_requests' ||
1085
+ storyboard.phases.some(p => p.steps.some(s => s.task === 'request_signing_probe'));
1086
+ if (isSignedRequestsStoryboard) {
1087
+ const requestSigning = caps['request_signing'];
1088
+ if (requestSigning?.['supported'] !== true) {
1089
+ notices.push({
1090
+ severity: 'future_required',
1091
+ code: 'request_signing.required',
1092
+ message: 'RFC 9421 request signing (`request_signing.supported: true`) is not advertised. ' +
1093
+ 'Required for spend-committing operations in AdCP 4.0 — declare the capability and ' +
1094
+ 'pre-register the runner compliance test keypair before the 4.0 cut.',
1095
+ effective_version: '4.0',
1096
+ capability_path: 'request_signing.supported',
1097
+ docs_url: 'https://adcontextprotocol.org/docs/building/implementation/security#signed-requests-transport-layer',
1098
+ storyboard_ids: [storyboard.id],
1099
+ });
1100
+ }
1101
+ }
1102
+ // Notice: legacy_hmac_fallback removed in AdCP 4.0.
1103
+ // Scoped via the WEBHOOK_STEP_TASKS step-task set rather than an id-regex.
1104
+ // Step-task presence is the authoring contract — a storyboard that asserts
1105
+ // webhook delivery uses one of these tasks. A storyboard id alone (e.g. a
1106
+ // hypothetical `webhook_authoring_guide`) shouldn't trigger the notice
1107
+ // unless it actually exercises the delivery path.
1108
+ const WEBHOOK_STEP_TASKS = new Set([
1109
+ 'expect_webhook',
1110
+ 'expect_webhook_retry_keys_stable',
1111
+ 'expect_webhook_signature_valid',
1112
+ ]);
1113
+ const isWebhookRelatedStoryboard = storyboard.phases.some(p => p.steps.some(s => WEBHOOK_STEP_TASKS.has(s.task)));
1114
+ if (isWebhookRelatedStoryboard) {
1115
+ const webhookSigning = caps['webhook_signing'];
1116
+ if (webhookSigning?.['legacy_hmac_fallback'] === true) {
1117
+ notices.push({
1118
+ severity: 'deprecation',
1119
+ code: 'webhook_signing.legacy_hmac_fallback.removed',
1120
+ message: '`webhook_signing.legacy_hmac_fallback: true` is deprecated and removed in AdCP 4.0. ' +
1121
+ 'Migrate webhook signature verification to RFC 9421 before the 4.0 cut.',
1122
+ effective_version: '4.0',
1123
+ capability_path: 'webhook_signing.legacy_hmac_fallback',
1124
+ docs_url: 'https://adcontextprotocol.org/docs/building/implementation/webhooks',
1125
+ storyboard_ids: [storyboard.id],
1126
+ });
1127
+ }
1128
+ }
1129
+ return notices;
1130
+ }
920
1131
  /**
921
1132
  * Execute a single pass of the storyboard against the supplied replica URLs
922
1133
  * using round-robin dispatch starting at `dispatchOffset`. Called directly
@@ -1030,12 +1241,33 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1030
1241
  // Spec: adcp-client#1626. The default (no `requires` field) is
1031
1242
  // `[real_wire]`, which is always available — untagged storyboards
1032
1243
  // run unchanged.
1033
- if (storyboard.requires?.length) {
1034
- const unmet = checkRequires(storyboard.requires, options);
1244
+ //
1245
+ // Implicit requirements (adcp-client#1678) are unioned with the
1246
+ // declared list: today this means `'webhook_receiver'` is added when
1247
+ // any step's `sample_request` references `{{runner.webhook_url:…}}`
1248
+ // or `{{runner.webhook_base}}`. Authors do not need to retag those
1249
+ // storyboards — the token presence is the declaration. Without the
1250
+ // implicit gate, storyboards that name the receiver but run without
1251
+ // one would silently ship literal mustache tokens on the wire and
1252
+ // get rejected by 3.0-strict sellers as `INVALID_REQUEST: relative URL`.
1253
+ // Pre-flight notice collection. Uses options._profile (set by the comply()
1254
+ // pipeline before calling runStoryboard) so the notices are available on
1255
+ // early-return results (requirement-unmet, capability-unsupported). In the
1256
+ // standalone runner path options._profile may be undefined; notices will be
1257
+ // collected again from the fully-fetched profile at result-build time.
1258
+ const preflightNotices = collectCapabilityNotices(storyboard, options._profile?.raw_capabilities);
1259
+ const declared = storyboard.requires ?? [];
1260
+ const implicit = detectImplicitRequires(storyboard);
1261
+ const allRequires = [...declared, ...implicit.filter(r => !declared.includes(r))];
1262
+ if (allRequires.length) {
1263
+ const unmet = checkRequires(allRequires, options, profile);
1035
1264
  if (unmet) {
1036
1265
  if (!options._client)
1037
1266
  await (0, protocols_1.closeConnections)(options.protocol);
1038
- return buildRequirementUnmetResult(agentUrls, storyboard, unmet.requirement, unmet.detail);
1267
+ return {
1268
+ ...buildRequirementUnmetResult(agentUrls, storyboard, unmet.requirement, unmet.detail),
1269
+ notices: preflightNotices,
1270
+ };
1039
1271
  }
1040
1272
  }
1041
1273
  // Evaluate requires_capability predicate before any phase setup.
@@ -1061,7 +1293,7 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1061
1293
  `agent declared ${JSON.stringify(actual)}.`;
1062
1294
  if (!options._client)
1063
1295
  await (0, protocols_1.closeConnections)(options.protocol);
1064
- return buildCapabilityUnsupportedResult(agentUrls, storyboard, detail);
1296
+ return { ...buildCapabilityUnsupportedResult(agentUrls, storyboard, detail), notices: preflightNotices };
1065
1297
  }
1066
1298
  }
1067
1299
  }
@@ -1081,7 +1313,10 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1081
1313
  if (!hasAnyRequired) {
1082
1314
  if (!options._client)
1083
1315
  await (0, protocols_1.closeConnections)(options.protocol);
1084
- return buildRequiredToolsMissingResult(agentUrls, storyboard, `agent does not advertise any of [${storyboard.required_tools.join(', ')}]`);
1316
+ return {
1317
+ ...buildRequiredToolsMissingResult(agentUrls, storyboard, `agent does not advertise any of [${storyboard.required_tools.join(', ')}]`),
1318
+ notices: preflightNotices,
1319
+ };
1085
1320
  }
1086
1321
  }
1087
1322
  let context = { ...options.context };
@@ -1366,6 +1601,18 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1366
1601
  });
1367
1602
  continue;
1368
1603
  }
1604
+ // Pre-empt OAuth-metadata probes when the agent's capabilities never
1605
+ // advertised OAuth at all (adcp-client#1702). Without this, an
1606
+ // API-key-only agent (e.g. Wonderstruck) has its PRM endpoint
1607
+ // probed; if the well-known path returns anything other than 404
1608
+ // (the existing reactive cascade trigger in `executeProbeStep`),
1609
+ // validations fail and `oauth_discovery` produces 5 false-negative
1610
+ // step failures even though the phase is `optional: true`. The skip
1611
+ // is phase-level — not storyboard-level — so the universal
1612
+ // `unauth_rejection` and `mechanism_required` phases still run.
1613
+ if (!phaseAbsent && phaseContainsOauthMetadataProbe(phase) && !agentAdvertisesOauth(profile)) {
1614
+ phaseAbsent = true;
1615
+ }
1369
1616
  // Reset alias cache at this phase boundary (#1657). $generate:uuid_v4#alias
1370
1617
  // is designed to be stable within a scenario — the initial call and its
1371
1618
  // idempotency replay share the same UUID — but aliases must NOT bleed across
@@ -1569,6 +1816,20 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1569
1816
  }
1570
1817
  stepResults.push(result);
1571
1818
  priorStepResults.set(step.id, result);
1819
+ // Schema-validation short-circuit (adcp-client#1709). When the
1820
+ // response unwrapper rejected the agent's response against the SDK's
1821
+ // Zod schema, the step-execution path synthesized a failing
1822
+ // `response_schema` ValidationResult on `result.validations`.
1823
+ // Running step-scope invariants against a response the SDK has
1824
+ // already declared malformed produces noise — the invariants
1825
+ // can't meaningfully grade a payload that didn't parse. Worse,
1826
+ // before #1709 the invariants' failure entries crowded out the
1827
+ // schema-validation entry in `extractFailures`, masking the root
1828
+ // cause across BidMachine's 10+ deploys (see adcp#4419). Skip the
1829
+ // invariant pass entirely on this step and emit a single skipped
1830
+ // `assertion` entry per invariant so consumers can still see WHICH
1831
+ // invariants were skipped and why.
1832
+ const schemaInvalidResponse = result.validations.some(v => v.check === 'response_schema' && !v.passed);
1572
1833
  // Fire per-step assertions. Each result is appended to the step's
1573
1834
  // `validations[]` under `check: "assertion"` so existing UI renders
1574
1835
  // them alongside inline checks, and mirrored into `assertionResults`
@@ -1583,6 +1844,18 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1583
1844
  // behavior the invariant would flag (validated at runner start).
1584
1845
  if ((0, assertions_1.stepDisablesAssertion)(step.invariants, spec.id))
1585
1846
  continue;
1847
+ if (schemaInvalidResponse) {
1848
+ // Emit a skipped marker so the report shows the invariant was
1849
+ // intentionally bypassed (not silently dropped). `passed: true`
1850
+ // keeps it from flipping `result.passed` — the schema-validation
1851
+ // failure already did that.
1852
+ result.validations.push({
1853
+ check: 'assertion',
1854
+ passed: true,
1855
+ description: `${spec.id}: skipped — response failed schema validation (adcp-client#1709)`,
1856
+ });
1857
+ continue;
1858
+ }
1586
1859
  const raw = await spec.onStep(assertionContexts.get(spec.id), result);
1587
1860
  for (const r of raw) {
1588
1861
  const full = { ...r, assertion_id: spec.id, scope: 'step', step_id: step.id };
@@ -1944,6 +2217,10 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1944
2217
  const schemasUsed = collectSchemasUsed(phaseResults);
1945
2218
  const strictSummary = summarizeStrictValidation(phaseResults);
1946
2219
  const validationsNotApplicable = countValidationsNotApplicable(phaseResults);
2220
+ // Use the fully-fetched profile for notice detection; fall back to pre-flight
2221
+ // notices (which used options._profile) when profile was not re-fetched in
2222
+ // this pass (standalone runner with options._profile pre-set skips the fetch).
2223
+ const notices = collectCapabilityNotices(storyboard, profile?.raw_capabilities ?? options._profile?.raw_capabilities);
1947
2224
  const result = {
1948
2225
  storyboard_id: storyboard.id,
1949
2226
  storyboard_title: storyboard.title,
@@ -1966,6 +2243,7 @@ async function executeStoryboardPass(agentUrls, storyboard, options, dispatchOff
1966
2243
  ...(schemasUsed.length > 0 ? { schemas_used: schemasUsed } : {}),
1967
2244
  ...(assertionResults.length > 0 ? { assertions: assertionResults } : {}),
1968
2245
  strict_validation_summary: strictSummary,
2246
+ notices,
1969
2247
  };
1970
2248
  // Close protocol connections when the runner created its own client. The
1971
2249
  // connection pool is keyed by URL+auth, so a single closeConnections() call
@@ -2042,6 +2320,8 @@ async function runMultiPass(agentUrls, storyboard, options) {
2042
2320
  // readers see a per-pass timeline; de-duplicating would hide a real
2043
2321
  // "passed on pass 1, failed on pass 2" divergence.
2044
2322
  const assertionsAgg = passResults.flatMap(r => r.assertions ?? []);
2323
+ // Notices are identical across passes (same agent, same capabilities); deduplicate by code.
2324
+ const noticesDedup = [...new Map(passResults.flatMap(r => r.notices).map(n => [n.code, n])).values()];
2045
2325
  return {
2046
2326
  storyboard_id: storyboard.id,
2047
2327
  storyboard_title: storyboard.title,
@@ -2060,6 +2340,7 @@ async function runMultiPass(agentUrls, storyboard, options) {
2060
2340
  tested_at: new Date().toISOString(),
2061
2341
  ...(schemasDedup.length > 0 ? { schemas_used: schemasDedup } : {}),
2062
2342
  ...(assertionsAgg.length > 0 ? { assertions: assertionsAgg } : {}),
2343
+ notices: noticesDedup,
2063
2344
  };
2064
2345
  }
2065
2346
  /**
@@ -2426,7 +2707,9 @@ client, step, phaseId, context, allSteps, options, state) {
2426
2707
  // match the options. Enforcing this here (after builder + sample_request)
2427
2708
  // prevents session-key divergence across create/get/update/delete steps
2428
2709
  // when individual builders or sample_request YAML omit brand.
2429
- request = applyBrandInvariant(request, options, effectiveStep.task);
2710
+ // `step.omit_account` suppresses account synthesis for schema_validation
2711
+ // steps that deliberately test the seller's missing-account rejection path.
2712
+ request = applyBrandInvariant(request, options, effectiveStep.task, { omit_account: step.omit_account });
2430
2713
  // Per-run sandbox-bypass hint (#841). When the operator passes
2431
2714
  // `--no-sandbox` (or sets `disable_sandbox: true` programmatically), the
2432
2715
  // runner stamps `ext.adcp.disable_sandbox: true` on every outgoing
@@ -2507,24 +2790,115 @@ client, step, phaseId, context, allSteps, options, state) {
2507
2790
  // and the server never sees a missing-key request. Paired flags so the two
2508
2791
  // layers agree; see `applyIdempotencyInvariant` for the runner-level skip.
2509
2792
  const testsMissingIdempotencyKey = step.omit_idempotency_key === true && (0, idempotency_1.isMutatingTask)(effectiveStep.task);
2510
- // Defense-in-depth for the missing-key vector: when a mutating step sets
2511
- // `omit_idempotency_key: true` and `step.auth` is unset, route via
2512
- // `rawMcpProbe` anyway so no SDK-layer normalization can slip a key onto the
2513
- // wire. The SDK's `skipIdempotencyAutoInject` plumbing already honors this
2514
- // flag, but the raw-HTTP path removes the escape hatch entirely. A2A and
2515
- // oauth stay on the SDK path their dispatch can't be replicated here
2516
- // (A2A uses a different envelope; oauth needs refresh semantics).
2793
+ // Analogous to `testsMissingIdempotencyKey`: when a step sets
2794
+ // `omit_account: true` the runner has already suppressed account synthesis
2795
+ // in `applyBrandInvariant` (above ordering is load-bearing: this must
2796
+ // come after `applyBrandInvariant` so the comment "above" stays accurate
2797
+ // if either block is reordered). Track the flag here so the raw-probe
2798
+ // defense-in-depth path is also triggered and no SDK-layer normalization
2799
+ // can silently re-inject an account before the wire call.
2800
+ const testsMissingAccount = step.omit_account === true && effectiveStep.task === 'create_media_buy';
2801
+ // Defense-in-depth for the missing-field vectors: when a step sets
2802
+ // `omit_idempotency_key: true` or `omit_account: true` and `step.auth` is
2803
+ // unset, route via `rawMcpProbe` anyway so no SDK-layer normalization can
2804
+ // slip the missing field onto the wire. The SDK's `skipIdempotencyAutoInject`
2805
+ // / `skipAccountValidation` plumbing already honors these flags, but the
2806
+ // raw-HTTP path removes the escape hatch entirely. A2A and oauth stay on
2807
+ // the SDK path — their dispatch can't be replicated here (A2A uses a
2808
+ // different envelope; oauth needs refresh semantics).
2517
2809
  const rawProbeHeaders = step.auth !== undefined
2518
2810
  ? authHeadersForStep(step.auth, options)
2519
- : testsMissingIdempotencyKey && options.protocol !== 'a2a'
2811
+ : (testsMissingIdempotencyKey || testsMissingAccount) && options.protocol !== 'a2a'
2520
2812
  ? defaultAuthHeadersForRawProbe(options)
2521
2813
  : undefined;
2522
2814
  const useRawProbe = rawProbeHeaders !== undefined;
2523
2815
  let taskResult;
2524
2816
  let stepResult;
2817
+ // Raw caught error from the dispatch fn — preserved as `unknown` so the
2818
+ // schema-validation attribution path below can `instanceof` it against
2819
+ // `ResponseSchemaValidationError`. Undefined when the step succeeded or
2820
+ // failed for a non-typed reason. Spec: adcp-client#1709.
2821
+ let caughtError;
2525
2822
  let httpResult;
2526
2823
  let responseRecord;
2527
2824
  let a2aEnvelope;
2825
+ let crossResponses;
2826
+ // Parallel-dispatch fan-out: when the storyboard step declares
2827
+ // `parallel_dispatch`, the runner fires N concurrent dispatches against
2828
+ // the same agent with the same idempotency_key (default) and grades the
2829
+ // cross-response set instead of a single response. Gated on the
2830
+ // `parallel_dispatch_runner` test-kit contract — runners (or runs) without
2831
+ // it grade the step `not_applicable` so older runners don't fail on
2832
+ // newer storyboard contracts.
2833
+ if (step.parallel_dispatch && !useRawProbe) {
2834
+ const contractsInScope = new Set(options.contracts ?? []);
2835
+ if (!contractsInScope.has(parallel_dispatch_1.PARALLEL_DISPATCH_CONTRACT)) {
2836
+ const next = getNextStepPreview(step.id, allSteps, context, runState.runnerVars);
2837
+ const detail = `Test-kit contract "${parallel_dispatch_1.PARALLEL_DISPATCH_CONTRACT}" is not configured on this runner; concurrent-retry grading requires it.`;
2838
+ return {
2839
+ step_id: step.id,
2840
+ phase_id: phaseId,
2841
+ title: step.title,
2842
+ task: step.task,
2843
+ passed: true,
2844
+ skipped: true,
2845
+ skip_reason: 'not_applicable',
2846
+ skip: buildSkip('not_applicable', detail),
2847
+ duration_ms: 0,
2848
+ validations: [],
2849
+ context,
2850
+ next,
2851
+ extraction: { path: 'none' },
2852
+ };
2853
+ }
2854
+ const specError = (0, parallel_dispatch_1.validateParallelDispatchSpec)(step.parallel_dispatch);
2855
+ if (specError) {
2856
+ const next = getNextStepPreview(step.id, allSteps, context, runState.runnerVars);
2857
+ return {
2858
+ step_id: step.id,
2859
+ phase_id: phaseId,
2860
+ title: step.title,
2861
+ task: step.task,
2862
+ passed: false,
2863
+ duration_ms: 0,
2864
+ validations: [
2865
+ {
2866
+ check: 'parallel_dispatch_misconfigured',
2867
+ passed: false,
2868
+ description: specError,
2869
+ json_pointer: null,
2870
+ expected: 'a well-formed parallel_dispatch spec',
2871
+ actual: step.parallel_dispatch,
2872
+ schema_id: null,
2873
+ schema_url: null,
2874
+ },
2875
+ ],
2876
+ context,
2877
+ error: specError,
2878
+ next,
2879
+ extraction: { path: 'none' },
2880
+ };
2881
+ }
2882
+ if (step.parallel_dispatch.mode === 'distributed') {
2883
+ const next = getNextStepPreview(step.id, allSteps, context, runState.runnerVars);
2884
+ const detail = 'parallel_dispatch.mode: distributed is not implemented in @adcp/sdk; use process_local for in-process grading.';
2885
+ return {
2886
+ step_id: step.id,
2887
+ phase_id: phaseId,
2888
+ title: step.title,
2889
+ task: step.task,
2890
+ passed: true,
2891
+ skipped: true,
2892
+ skip_reason: 'not_applicable',
2893
+ skip: buildSkip('not_applicable', detail),
2894
+ duration_ms: 0,
2895
+ validations: [],
2896
+ context,
2897
+ next,
2898
+ extraction: { path: 'none' },
2899
+ };
2900
+ }
2901
+ }
2528
2902
  // Capture the ISO timestamp immediately before the step's AdCP request
2529
2903
  // dispatch. `upstream_traffic` validations use this as the default
2530
2904
  // `since_timestamp` window bound when querying the controller. Recorded
@@ -2583,40 +2957,92 @@ client, step, phaseId, context, allSteps, options, state) {
2583
2957
  // lands, key the capture off the negotiated transport instead.
2584
2958
  const captureA2a = options.protocol === 'a2a';
2585
2959
  let a2aCaptures;
2586
- const dispatch = () => (0, task_map_1.executeStoryboardTask)(client, effectiveStep.task, request, {
2587
- skipIdempotencyAutoInject: testsMissingIdempotencyKey,
2588
- });
2589
- const run = await (0, client_1.runStep)(step.title, effectiveStep.task, async () => {
2590
- if (!captureA2a)
2591
- return dispatch();
2592
- try {
2593
- const { result: dispatchResult, captures } = await (0, rawResponseCapture_1.withRawResponseCapture)(dispatch);
2594
- a2aCaptures = captures;
2595
- return dispatchResult;
2596
- }
2597
- catch (err) {
2598
- // `withRawResponseCapture` attaches partial captures to the
2599
- // thrown error so we still get the wire-shape envelope when
2600
- // the SDK threw mid-parse (e.g. agent emitted malformed JSON).
2601
- // Bare-throw cases (network errors, no captures attached)
2602
- // leave `a2aCaptures` undefined and the validator self-skips.
2603
- const partial = (0, rawResponseCapture_1.getCapturesFromError)(err);
2604
- if (partial)
2605
- a2aCaptures = partial;
2606
- throw err;
2960
+ if (step.parallel_dispatch) {
2961
+ // Fan out N concurrent dispatches via the SDK client. All dispatches
2962
+ // share the runner-minted idempotency_key (default) so the seller
2963
+ // sees one logical request and resolves the race deterministically.
2964
+ //
2965
+ // Known limitation: `dispatchWithBarrier` uses `Promise.race` against a
2966
+ // timer; when the barrier wins, the underlying SDK request is NOT
2967
+ // aborted it continues to completion against the seller. The runner
2968
+ // reports the dispatch as `timed_out`, but a late-arriving success
2969
+ // can still land on the seller's idempotency cache. Storyboard
2970
+ // authors writing follow-up steps that observe seller state after a
2971
+ // barrier timeout should account for this race.
2972
+ const started = Date.now();
2973
+ crossResponses = await (0, parallel_dispatch_1.runParallelDispatches)(client, effectiveStep.task, request, {
2974
+ spec: step.parallel_dispatch,
2975
+ keyMinter: idempotency_1.generateIdempotencyKey,
2976
+ correlationPrefix: step.id,
2977
+ });
2978
+ const durationMs = Date.now() - started;
2979
+ // Representative TaskResult is ALWAYS `dispatches[0]` — pinning the
2980
+ // representative to a fixed index keeps the aggregation loop's `i=1`
2981
+ // start aligned (every dispatch graded exactly once). Picking the
2982
+ // "best" resolved arm would double-count whichever dispatch won and
2983
+ // skip dispatches[0] from per-response grading entirely.
2984
+ const firstDispatch = crossResponses.dispatches[0];
2985
+ const allTimedOut = crossResponses.dispatches.every(d => d.timed_out);
2986
+ const barrierTimeoutError = 'parallel_dispatch_barrier_timeout: no dispatch resolved within barrier_timeout_ms';
2987
+ taskResult = firstDispatch?.taskResult ?? {
2988
+ success: false,
2989
+ ...(allTimedOut && { error: barrierTimeoutError }),
2990
+ };
2991
+ // Pass/fail is derived from the cross-response set rather than any
2992
+ // single arm: the step passes when at least one dispatch resolved
2993
+ // (cross-response validations then grade the race outcome). All-
2994
+ // timed-out is a hard failure regardless of validations.
2995
+ stepResult = {
2996
+ duration_ms: durationMs,
2997
+ passed: !allTimedOut && crossResponses.resolved.length > 0,
2998
+ ...(allTimedOut && { error: barrierTimeoutError }),
2999
+ };
3000
+ if (taskResult) {
3001
+ responseRecord = {
3002
+ transport: options.protocol === 'a2a' ? 'a2a' : 'mcp',
3003
+ payload: (0, redact_secrets_1.redactSecrets)(taskResult.data ?? taskResult.error ?? null),
3004
+ duration_ms: durationMs,
3005
+ };
2607
3006
  }
2608
- });
2609
- taskResult = run.result;
2610
- stepResult = run.step;
2611
- if (captureA2a && a2aCaptures) {
2612
- a2aEnvelope = parseLastA2aMessageSendCapture(a2aCaptures);
2613
3007
  }
2614
- if (taskResult) {
2615
- responseRecord = {
2616
- transport: options.protocol === 'a2a' ? 'a2a' : 'mcp',
2617
- payload: (0, redact_secrets_1.redactSecrets)(taskResult.data ?? taskResult.error ?? null),
2618
- duration_ms: stepResult.duration_ms,
2619
- };
3008
+ else {
3009
+ const dispatch = () => (0, task_map_1.executeStoryboardTask)(client, effectiveStep.task, request, {
3010
+ skipIdempotencyAutoInject: testsMissingIdempotencyKey,
3011
+ skipAccountValidation: testsMissingAccount,
3012
+ });
3013
+ const run = await (0, client_1.runStep)(step.title, effectiveStep.task, async () => {
3014
+ if (!captureA2a)
3015
+ return dispatch();
3016
+ try {
3017
+ const { result: dispatchResult, captures } = await (0, rawResponseCapture_1.withRawResponseCapture)(dispatch);
3018
+ a2aCaptures = captures;
3019
+ return dispatchResult;
3020
+ }
3021
+ catch (err) {
3022
+ // `withRawResponseCapture` attaches partial captures to the
3023
+ // thrown error so we still get the wire-shape envelope when
3024
+ // the SDK threw mid-parse (e.g. agent emitted malformed JSON).
3025
+ // Bare-throw cases (network errors, no captures attached)
3026
+ // leave `a2aCaptures` undefined and the validator self-skips.
3027
+ const partial = (0, rawResponseCapture_1.getCapturesFromError)(err);
3028
+ if (partial)
3029
+ a2aCaptures = partial;
3030
+ throw err;
3031
+ }
3032
+ });
3033
+ taskResult = run.result;
3034
+ stepResult = run.step;
3035
+ caughtError = run.caughtError;
3036
+ if (captureA2a && a2aCaptures) {
3037
+ a2aEnvelope = parseLastA2aMessageSendCapture(a2aCaptures);
3038
+ }
3039
+ if (taskResult) {
3040
+ responseRecord = {
3041
+ transport: options.protocol === 'a2a' ? 'a2a' : 'mcp',
3042
+ payload: (0, redact_secrets_1.redactSecrets)(taskResult.data ?? taskResult.error ?? null),
3043
+ duration_ms: stepResult.duration_ms,
3044
+ };
3045
+ }
2620
3046
  }
2621
3047
  }
2622
3048
  const requestRecord = {
@@ -2662,13 +3088,21 @@ client, step, phaseId, context, allSteps, options, state) {
2662
3088
  // Step passes when the task fails (returns an error)
2663
3089
  passed = !taskResult?.success || !!stepResult.error;
2664
3090
  }
3091
+ else if (crossResponses) {
3092
+ // Parallel-dispatch step: pass/fail is driven by the cross-response
3093
+ // set, not the representative arm. The representative is pinned to
3094
+ // `dispatches[0]` (which may itself have failed under the race) so
3095
+ // gating on its `.success` would false-fail steps where later arms
3096
+ // resolved correctly. Validations grade the actual race outcome.
3097
+ passed = stepResult.passed;
3098
+ }
2665
3099
  else {
2666
3100
  passed = stepResult.passed && (taskResult?.success ?? false);
2667
3101
  }
3102
+ let validations = [];
2668
3103
  // Run validations. Resolve `$context.<key>` placeholders in `value` and
2669
3104
  // `allowed_values` fields so expected values can reference prior steps
2670
3105
  // (e.g., replay tests assert `media_buy_id === $context.initial_media_buy_id`).
2671
- let validations = [];
2672
3106
  if (step.validations?.length && (taskResult || httpResult)) {
2673
3107
  const resolvedValidations = step.validations.map(v => {
2674
3108
  const resolved = { ...v };
@@ -2701,6 +3135,7 @@ client, step, phaseId, context, allSteps, options, state) {
2701
3135
  ...(a2aEnvelope && { a2aEnvelope }),
2702
3136
  ...(upstreamTraffic && { upstreamTraffic }),
2703
3137
  ...(step.sample_request && { storyboardStep: { sample_request: step.sample_request } }),
3138
+ ...(crossResponses && { crossResponses }),
2704
3139
  ...(() => {
2705
3140
  // Walk back through the run's captured A2A envelopes and use
2706
3141
  // the most recent prior step's envelope as the comparison
@@ -2722,6 +3157,78 @@ client, step, phaseId, context, allSteps, options, state) {
2722
3157
  })(),
2723
3158
  };
2724
3159
  validations = (0, validations_1.runValidations)(resolvedValidations, vctx);
3160
+ // Parallel-dispatch aggregation: per-response checks (`response_schema`,
3161
+ // `field_present`, `error_code`, etc.) declared by the storyboard MUST
3162
+ // grade against every dispatch's resolved response, not just the
3163
+ // representative one. Re-run each non-cross-response validation against
3164
+ // each dispatch's TaskResult and append the additional results so the
3165
+ // step's overall pass/fail reflects the full fan-out. The first run
3166
+ // (above) already covers dispatch[0]; this loop covers dispatches[1..N].
3167
+ if (crossResponses && crossResponses.dispatches.length > 1) {
3168
+ const perResponseValidations = resolvedValidations.filter(v => v.check !== 'cross_response_field_equal' && v.check !== 'cross_response_count_distinct');
3169
+ for (let i = 1; i < crossResponses.dispatches.length; i++) {
3170
+ const d = crossResponses.dispatches[i];
3171
+ if (!d || !d.taskResult)
3172
+ continue;
3173
+ const dispatchCtx = {
3174
+ ...vctx,
3175
+ taskResult: d.taskResult,
3176
+ // Per-dispatch responseRecord stays minimal — the redacted payload
3177
+ // captures enough for failure attribution without bloating success.
3178
+ ...(responseRecord && {
3179
+ response: {
3180
+ ...responseRecord,
3181
+ payload: (0, redact_secrets_1.redactSecrets)(d.taskResult.data ?? d.taskResult.error ?? null),
3182
+ duration_ms: d.duration_ms,
3183
+ },
3184
+ }),
3185
+ };
3186
+ // Drop crossResponses on the per-dispatch context so cross-response
3187
+ // checks don't re-fire here (they already ran once above on the
3188
+ // representative dispatch and produced their single result).
3189
+ delete dispatchCtx.crossResponses;
3190
+ const dispatchResults = (0, validations_1.runValidations)(perResponseValidations, dispatchCtx).map(r => ({
3191
+ ...r,
3192
+ description: `[dispatch ${d.correlation_id}] ${r.description}`,
3193
+ }));
3194
+ validations.push(...dispatchResults);
3195
+ }
3196
+ }
3197
+ }
3198
+ // Schema-validation attribution (adcp-client#1709). When the response
3199
+ // unwrapper rejected the agent's response against the SDK's Zod schema
3200
+ // for the tool, it threw a typed `ResponseSchemaValidationError` that
3201
+ // surfaced on `caughtError`. Without this attribution, the rejection
3202
+ // would silently propagate into whichever step-scope invariant (e.g.
3203
+ // `context.no_secret_echo`) happened to fire next — the BidMachine
3204
+ // misdiagnosis trace from adcp-client#1709 / adcp#4419 ate 10+ deploys
3205
+ // chasing the wrong cause.
3206
+ //
3207
+ // Synthesize a canonical `response_schema` ValidationResult and prepend
3208
+ // it so `extractFailures.find(v => !v.passed)` resolves it before any
3209
+ // invariant entry. Step-scope invariants downstream of this point will
3210
+ // short-circuit on the schema-invalid response (see the invariant
3211
+ // dispatch loop in `executeStoryboardPass`).
3212
+ if (caughtError instanceof response_unwrapper_1.ResponseSchemaValidationError) {
3213
+ const issues = caughtError.issues
3214
+ .slice(0, 5)
3215
+ .map(i => `${i.path.join('.') || '(root)'}: ${i.message}`)
3216
+ .join('; ');
3217
+ const firstIssue = caughtError.issues[0];
3218
+ const jsonPointer = firstIssue ? '/' + firstIssue.path.map(s => String(s)).join('/') : null;
3219
+ const synthSchemaResult = {
3220
+ check: 'response_schema',
3221
+ passed: false,
3222
+ description: `Response schema validation for ${caughtError.toolName}`,
3223
+ error: issues,
3224
+ json_pointer: jsonPointer,
3225
+ expected: `response schema for ${caughtError.toolName}`,
3226
+ actual: caughtError.issues,
3227
+ };
3228
+ // Prepend so extractFailures picks it up before any inline validation
3229
+ // entry that may also be failing (e.g. `field_present` checks that
3230
+ // legitimately can't observe their target against an unparsed payload).
3231
+ validations = [synthSchemaResult, ...validations];
2725
3232
  }
2726
3233
  // Persist the captured A2A envelope keyed by step id so cross-step
2727
3234
  // validators (`a2a_context_continuity`) on subsequent steps can
@@ -2916,6 +3423,7 @@ client, step, phaseId, context, allSteps, options, state) {
2916
3423
  context_provenance: Object.fromEntries(runState.contextProvenance),
2917
3424
  }),
2918
3425
  error: step.expect_error ? undefined : truncateError(stepResult.error || taskResult?.error),
3426
+ ...(!step.expect_error && taskResult?.adcp_error && { adcp_error: taskResult.adcp_error }),
2919
3427
  next,
2920
3428
  request: requestRecord,
2921
3429
  ...(responseRecord && { response_record: responseRecord }),
@@ -3151,6 +3659,33 @@ function isTaskShape(result) {
3151
3659
  // ────────────────────────────────────────────────────────────
3152
3660
  // Phase / step skip predicates
3153
3661
  // ────────────────────────────────────────────────────────────
3662
+ /**
3663
+ * True when the phase contains an OAuth-metadata probe step
3664
+ * (`protected_resource_metadata` or `oauth_auth_server_metadata`). Used
3665
+ * to pre-emptively trigger the existing `phaseAbsent` cascade when the
3666
+ * agent's capabilities never advertised OAuth in the first place, so we
3667
+ * don't burn step failures probing a well-known path the agent doesn't
3668
+ * claim. Spec: adcp-client#1702.
3669
+ */
3670
+ function phaseContainsOauthMetadataProbe(phase) {
3671
+ return phase.steps.some(s => s.task === 'protected_resource_metadata' || s.task === 'oauth_auth_server_metadata');
3672
+ }
3673
+ /**
3674
+ * True when the agent's `get_adcp_capabilities` response declares an
3675
+ * OAuth issuer (today: `account.authorization_endpoint`). When the
3676
+ * profile or its capabilities aren't available — e.g. external
3677
+ * `_client` mode — we conservatively return `true` so the runner falls
3678
+ * through to the existing reactive cascade (`phaseAbsent` flips on a
3679
+ * 404 from the PRM probe). That keeps backward compatibility for
3680
+ * callers that haven't threaded a profile through. Spec: adcp-client#1702.
3681
+ */
3682
+ function agentAdvertisesOauth(profile) {
3683
+ const rawCaps = profile?.raw_capabilities;
3684
+ if (rawCaps === undefined)
3685
+ return true;
3686
+ const endpoint = resolveCapabilityPath(rawCaps, 'account.authorization_endpoint');
3687
+ return typeof endpoint === 'string' && endpoint.length > 0;
3688
+ }
3154
3689
  /**
3155
3690
  * Evaluate a phase's `skip_if` expression against the runtime options. Only
3156
3691
  * a tiny grammar is supported today; unknown expressions fail closed (phase runs).
@@ -3333,7 +3868,7 @@ function evalContributesIf(expr, priorStepResults) {
3333
3868
  * the function fails open and injects as before. Schema checks use raw JSON
3334
3869
  * reads, not AJV internals.
3335
3870
  */
3336
- function applyBrandInvariant(request, options, taskName) {
3871
+ function applyBrandInvariant(request, options, taskName, stepFlags) {
3337
3872
  // Only force the invariant when the caller has actually supplied a brand.
3338
3873
  // Storyboards that don't exercise brand-scoped tools (e.g. security
3339
3874
  // probes) legitimately run without one and should pass through unchanged.
@@ -3349,6 +3884,13 @@ function applyBrandInvariant(request, options, taskName) {
3349
3884
  const result = { ...request };
3350
3885
  if (topBrandOk)
3351
3886
  result.brand = brand;
3887
+ // When a storyboard step sets `omit_account: true` it is deliberately
3888
+ // testing the seller's missing-account rejection path. Skip all account
3889
+ // synthesis — both the natural-key-merge branch (existing account on the
3890
+ // request) and the synthetic-construction branch (no account on the
3891
+ // request) — so the request reaches the wire exactly as authored.
3892
+ if (stepFlags?.omit_account)
3893
+ return result;
3352
3894
  if ('account' in request) {
3353
3895
  // Caller sent an account — merge brand in only when it's a plain object
3354
3896
  // using AccountReference's natural-key variant (`{brand, operator, sandbox?}`).