@convertcom/mcp-server 0.0.1-beta.16 → 0.0.1-beta.18

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 (38) hide show
  1. package/README.md +1 -1
  2. package/build/executor.js +271 -17
  3. package/build/executor.js.map +1 -1
  4. package/build/failure-attribution.d.ts +55 -0
  5. package/build/failure-attribution.js +326 -0
  6. package/build/failure-attribution.js.map +1 -0
  7. package/build/image-dimensions.d.ts +5 -0
  8. package/build/image-dimensions.js +203 -0
  9. package/build/image-dimensions.js.map +1 -0
  10. package/build/kb-search.js +16 -1
  11. package/build/kb-search.js.map +1 -1
  12. package/build/namespaced.d.ts +5 -0
  13. package/build/namespaced.js +227 -12
  14. package/build/namespaced.js.map +1 -1
  15. package/build/operator.d.ts +14 -0
  16. package/build/operator.js +187 -22
  17. package/build/operator.js.map +1 -1
  18. package/build/prompts.js +10 -3
  19. package/build/prompts.js.map +1 -1
  20. package/build/runtime.d.ts +19 -1
  21. package/build/runtime.js +194 -18
  22. package/build/runtime.js.map +1 -1
  23. package/build/whats-new.d.ts +24 -0
  24. package/build/whats-new.js +150 -0
  25. package/build/whats-new.js.map +1 -0
  26. package/data/convert-kb-legacy-map.json +24956 -9527
  27. package/data/convert-kb-manifest.json +26 -27
  28. package/data/convert-kb.json +44625 -64753
  29. package/data/kb-vectors/0/embedding.index.3.proxima +0 -0
  30. package/data/kb-vectors/0/scalar.0.ipc +0 -0
  31. package/data/kb-vectors/corpus.json +2 -2
  32. package/data/kb-vectors/del.0 +0 -0
  33. package/data/kb-vectors/idmap.0/000008.sst +0 -0
  34. package/data/kb-vectors/idmap.0/IDENTITY +1 -1
  35. package/data/kb-vectors/idmap.0/MANIFEST-000005 +0 -0
  36. package/data/kb-vectors/manifest.3 +0 -0
  37. package/package.json +1 -1
  38. package/server.json +2 -2
package/build/operator.js CHANGED
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { executeNamespacedTool, namespaceToolNameForAction } from './namespaced.js';
3
3
  import { JSON_SCHEMA_DRAFT_2020_12 } from './shared.js';
4
4
  import { scrubDiagnosticText } from './redaction.js';
5
+ import { failureAttribution, failureFromStructured, inputFailure, safeFailureAttribution, } from './failure-attribution.js';
5
6
  import { FEEDBACK_BODY_SCHEMA, FEEDBACK_CONFIRMATION_SCHEMA, FeedbackContractError, feedbackConsentProof, feedbackSubmissionContextOutline, feedbackSubmissionDigest, sanitizeFeedbackBody, validateAndSanitizeFeedback, } from './feedback.js';
6
7
  const APPROVAL_RULE_CODES = new Set([
7
8
  'experience.activate',
@@ -129,7 +130,7 @@ const READ_ACTIONS = [
129
130
  {
130
131
  action: 'get_defaults',
131
132
  mode: 'read',
132
- description: 'Infer and cache project defaults such as preferred goal, audience, location, URL, and traffic split.',
133
+ description: 'Infer and cache suggested project defaults such as preferred goal, audience, location, URL, and traffic split. Suggestions are not consent to attach or create a resource.',
133
134
  path_parameters: ['account_id', 'project_id'],
134
135
  accepts_body: true,
135
136
  },
@@ -145,14 +146,14 @@ const WRITE_ACTIONS = [
145
146
  {
146
147
  action: 'create_custom_code_ab_test',
147
148
  mode: 'write',
148
- description: 'Create a draft custom-code A/B experience and verify the persisted payload. Required body: url plus non-empty js or css. Optional: name, control_name, variation_name, description, objective, primary_goal, goals, audiences, locations, settings. Canonical custom variations may be supplied as variations[].changes[] with at most 100 changes per variation for bounded verification. Payload verification is independent from attached-goal lifecycle health; goal_configuration reports archived or unresolved goals without encouraging a duplicate create. Returns one authoritative experience plus a compact create receipt. Unknown fields are rejected.',
149
+ description: 'Create a draft custom-code A/B experience and verify the persisted payload. Required body: url plus non-empty js or css. Optional: name, control_name, variation_name, description, objective, primary_goal, goals, audiences, locations, settings. Omitted goals and goals: [] can both trigger Convert goal fallback; resolve user-intended existing goals and primary, send explicit IDs, and inspect the expanded returned experience. A bare URL does not select the UI Experience URL location; attach a user-intended location explicitly. Canonical custom variations may be supplied as variations[].changes[] with at most 100 changes per variation for bounded verification. Payload verification is independent from attached-goal lifecycle health; goal_configuration reports archived or unresolved goals without encouraging a duplicate create. Returns one authoritative experience plus a compact create receipt. Unknown fields are rejected.',
149
150
  path_parameters: ['account_id', 'project_id'],
150
151
  accepts_body: true,
151
152
  },
152
153
  {
153
154
  action: 'create_split_url_test',
154
155
  mode: 'write',
155
- description: 'Create a draft split URL experience and verify the persisted redirect. Required body: original_pattern (or url) and variation_pattern (variation_url and redirect_url are accepted aliases). Optional: name, variation_name, case_sensitive, description, objective, primary_goal, goals, audiences, locations, settings, split_regex_support, or canonical variations with at most 100 changes each for bounded verification. Payload verification is independent from attached-goal lifecycle health; goal_configuration reports archived or unresolved goals without encouraging a duplicate create. Returns one authoritative experience plus a compact create receipt. Unknown fields are rejected.',
156
+ description: 'Create a draft split URL experience with an Original no-redirect control and a redirect treatment, allocated 50/50, and verify both arms, baseline, allocation and redirects. Required body: original_pattern (or url) and variation_pattern (variation_url and redirect_url are accepted aliases). Optional: name, variation_name, case_sensitive, description, objective, primary_goal, goals, audiences, locations, settings, split_regex_support. Omitted goals and goals: [] can both trigger Convert goal fallback; resolve user-intended existing goals and primary, send explicit IDs, and inspect the expanded returned experience. A bare URL does not select the UI Experience URL location; attach a user-intended location explicitly. Custom variations require unique names, exactly one explicit is_baseline: true control with no changes, one defaultRedirect per treatment, and positive numeric traffic_distribution per arm totaling 100; at most 100 changes per variation. goal_configuration separately reports archived or unresolved goals. Returns one authoritative experience plus a create receipt. If verified is false, inspect that existing draft; never repeat create or activate it automatically. Unknown fields are rejected.',
156
157
  path_parameters: ['account_id', 'project_id'],
157
158
  accepts_body: true,
158
159
  },
@@ -172,6 +173,10 @@ const WRITE_ACTIONS = [
172
173
  },
173
174
  ];
174
175
  const ALL_ACTIONS = [...READ_ACTIONS, ...WRITE_ACTIONS];
176
+ const ALL_ACTION_NAMES = new Set(ALL_ACTIONS.map((action) => action.action));
177
+ export function isRegisteredOperatorAction(action) {
178
+ return typeof action === 'string' && ALL_ACTION_NAMES.has(action);
179
+ }
175
180
  const DEFAULT_RESULTS_PER_PAGE = 50;
176
181
  const MAX_OPERATOR_COLLECTION_PAGES = 20;
177
182
  const MAX_OPERATOR_COLLECTION_BYTES = 8 * 1024 * 1024;
@@ -254,6 +259,9 @@ export const OPERATOR_OUTPUT_SCHEMA = {
254
259
  error: {},
255
260
  issues: { type: 'array', items: { type: 'object' } },
256
261
  verified: { type: 'boolean' },
262
+ failure: { type: 'object', description: 'Closed, identifier-free failure category and provenance for a failed operator call.' },
263
+ partial_failure_count: { type: 'integer', minimum: 1, description: 'Number of failed namespace calls when an operator workflow still returned partial evidence.' },
264
+ partial_failure: { type: 'object', description: 'Closed, identifier-free dominant failure category and provenance for a partially successful workflow.' },
257
265
  },
258
266
  additionalProperties: true,
259
267
  };
@@ -519,16 +527,24 @@ function validateOperatorWriteInput(args) {
519
527
  if (args.action === 'create_split_url_test') {
520
528
  const original = args.body.original_pattern ?? args.body.url;
521
529
  const variation = args.body.variation_pattern ?? args.body.variation_url ?? args.body.redirect_url;
522
- const variationsIssue = validateCanonicalOperatorVariations(args.body.variations);
530
+ const variationsIssue = validateCanonicalOperatorVariations(args.body.variations, true);
523
531
  if (variationsIssue)
524
532
  return variationsIssue;
525
533
  if (typeof original !== 'string' || !original.trim()) {
526
534
  return 'operator.create_split_url_test requires body.original_pattern or body.url.';
527
535
  }
536
+ if (Array.isArray(args.body.variations)) {
537
+ const splitIssue = validateSplitUrlArms(args.body.variations);
538
+ if (splitIssue)
539
+ return splitIssue;
540
+ }
528
541
  if (!Array.isArray(args.body.variations)) {
529
542
  if (typeof variation !== 'string' || !variation.trim()) {
530
543
  return 'operator.create_split_url_test requires body.variation_pattern; body.variation_url and body.redirect_url are accepted aliases.';
531
544
  }
545
+ const splitIssue = validateSplitUrlArms(buildSplitUrlExperienceBody(args.body).variations);
546
+ if (splitIssue)
547
+ return splitIssue;
532
548
  }
533
549
  }
534
550
  if (args.action === 'enable_split_url_regex'
@@ -609,7 +625,7 @@ async function prepareFeedback(args, toolSet, readOnlyImpersonation) {
609
625
  approved_payload_digest: digest,
610
626
  },
611
627
  next_step: submitAvailable
612
- ? 'Show the sanitized summary, context outline, context byte count, payload digest, and review_contract to the user. Explain that context values are intentionally not re-echoed and that the digest binds approval to the complete sanitized payload. Only after explicit approval, call submit_feedback with the unchanged body and set all three confirmation_template booleans to true.'
628
+ ? 'Show the sanitized summary, context outline, context byte count, payload digest, and review_contract to the user. Explain that context values are intentionally not re-echoed and that the digest binds approval to the complete sanitized payload. Only after explicit approval, call submit_feedback with the unchanged body and set all three confirmation_template booleans to true. If the host reports no approval while the confirmation matches this template exactly, the host failed to capture approval: re-present the host approval card instead of editing the confirmation.'
613
629
  : readOnlyImpersonation
614
630
  ? 'Show the sanitized summary, context outline, context byte count, payload digest, and review_contract to the user. Staff impersonation is browse-only, so this preparation cannot be submitted or attributed as the customer. Remove as_user and prepare the feedback again as the connected staff member if they explicitly want to submit it.'
615
631
  : 'Show the sanitized summary, context outline, context byte count, payload digest, and review_contract to the user. This read-only MCP profile cannot store feedback; do not claim submission. A write-enabled Convert MCP profile is required after approval.',
@@ -639,13 +655,15 @@ function feedbackActionConstraints(actions) {
639
655
  });
640
656
  return constraints;
641
657
  }
642
- function validateCanonicalOperatorVariations(value) {
658
+ function validateCanonicalOperatorVariations(value, allowBaseline = false) {
643
659
  if (typeof value === 'undefined')
644
660
  return null;
645
661
  if (!Array.isArray(value) || value.length === 0) {
646
662
  return 'body.variations must be a non-empty array when provided.';
647
663
  }
648
664
  const allowedVariationKeys = new Set(['name', 'status', 'traffic_distribution', 'traffic_percentage', 'changes']);
665
+ if (allowBaseline)
666
+ allowedVariationKeys.add('is_baseline');
649
667
  for (const [index, variation] of value.entries()) {
650
668
  if (!isObject(variation))
651
669
  return `body.variations[${index}] must be an object.`;
@@ -665,6 +683,28 @@ function validateCanonicalOperatorVariations(value) {
665
683
  }
666
684
  return null;
667
685
  }
686
+ function validateSplitUrlArms(variations) {
687
+ const hint = 'Split URL tests require at least two uniquely named arms, exactly one explicit is_baseline: true control with no changes, and a defaultRedirect change in each treatment. Supply numeric traffic_distribution for every arm (positive, totaling 100).';
688
+ if (variations.some((v) => !isObject(v) || typeof v.name !== 'string' || !v.name.trim()
689
+ || !Array.isArray(v.changes) || v.changes.some((change) => !isObject(change))))
690
+ return hint;
691
+ if (variations.length < 2 || new Set(variations.map((v) => v.name)).size !== variations.length)
692
+ return hint;
693
+ const controls = variations.filter((v) => v.is_baseline === true);
694
+ if (controls.length !== 1 || controls[0].changes.length !== 0)
695
+ return hint;
696
+ for (const variation of variations) {
697
+ if (typeof variation.traffic_distribution !== 'number' || !Number.isFinite(variation.traffic_distribution)
698
+ || variation.traffic_distribution <= 0 || variation.traffic_distribution > 100)
699
+ return hint;
700
+ if (variation.is_baseline !== true
701
+ && variation.changes.filter((change) => change.type === 'defaultRedirect').length !== 1)
702
+ return hint;
703
+ }
704
+ if (Math.abs(variations.reduce((sum, v) => sum + v.traffic_distribution, 0) - 100) > 0.00005)
705
+ return hint;
706
+ return null;
707
+ }
668
708
  function operatorVariationsContainCode(value) {
669
709
  if (!Array.isArray(value))
670
710
  return false;
@@ -909,15 +949,23 @@ async function experimentPortfolio(args, context) {
909
949
  }
910
950
  async function experimentVelocity(args, context) {
911
951
  await report(context, 'Fetching experiences for velocity report', 1, 3);
912
- const experienceCall = await listExperiences(basePath(args), portfolioFilters(args));
952
+ const [experienceCall, goalCall] = await Promise.all([
953
+ listExperiences(basePath(args), portfolioFilters(args)),
954
+ listResource('goals', basePath(args)),
955
+ ]);
913
956
  const experiences = collectionFrom(experienceCall.data);
914
- const summaries = summarizeExperiences(experiences);
957
+ const summaries = summarizeExperiences(experiences, canonicalGoalDefinitions(collectionFrom(goalCall.data)));
958
+ const goalCoverage = investigationCollectionCoverage(goalCall);
915
959
  const launched = summaries.filter((item) => typeof item.start_time === 'number' && item.start_time > 0);
916
960
  const completed = summaries.filter((item) => typeof item.end_time === 'number' && item.end_time > 0);
917
961
  const coverage = investigationCollectionCoverage(experienceCall);
918
962
  const completeCoverage = coverage.status === 'complete';
919
963
  return {
920
- source_status: { experiences: experienceCall.ok ? 'available' : 'unavailable' },
964
+ source_status: {
965
+ experiences: experienceCall.ok ? 'available' : 'unavailable',
966
+ goals: goalCall.ok ? 'available' : 'unavailable',
967
+ },
968
+ goal_authority_coverage: goalCoverage,
921
969
  coverage,
922
970
  total: experienceCall.ok && completeCoverage ? summaries.length : null,
923
971
  created_or_returned: experienceCall.ok ? summaries.length : null,
@@ -942,7 +990,8 @@ async function experimentVelocity(args, context) {
942
990
  ...(experienceCall.ok && !completeCoverage
943
991
  ? [`Experience velocity coverage is ${coverage.status}; exact totals and averages are null, while returned_* fields describe only fetched rows.`]
944
992
  : []),
945
- ...warningsFromCalls([experienceCall]),
993
+ ...goalAuthorityWarnings(goalCoverage),
994
+ ...warningsFromCalls([experienceCall, goalCall]),
946
995
  ],
947
996
  };
948
997
  }
@@ -1442,9 +1491,13 @@ async function investigate(args, context) {
1442
1491
  }
1443
1492
  async function failedTestAnalysis(args, context) {
1444
1493
  await report(context, 'Fetching completed and paused experiences', 1, 3);
1445
- const experienceCall = await listExperiences(basePath(args), { status: ['completed', 'paused'] });
1494
+ const [experienceCall, goalCall] = await Promise.all([
1495
+ listExperiences(basePath(args), { status: ['completed', 'paused'] }),
1496
+ listResource('goals', basePath(args)),
1497
+ ]);
1446
1498
  const experiences = collectionFrom(experienceCall.data);
1447
- const summaries = summarizeExperiences(experiences);
1499
+ const summaries = summarizeExperiences(experiences, canonicalGoalDefinitions(collectionFrom(goalCall.data)));
1500
+ const goalCoverage = investigationCollectionCoverage(goalCall);
1448
1501
  const applicable = summaries.filter((item) => item.outcome !== 'not_applicable');
1449
1502
  const losses = applicable.filter((item) => [
1450
1503
  'negative',
@@ -1456,7 +1509,11 @@ async function failedTestAnalysis(args, context) {
1456
1509
  const coverage = investigationCollectionCoverage(experienceCall);
1457
1510
  const completeCoverage = coverage.status === 'complete';
1458
1511
  return {
1459
- source_status: { experiences: experienceCall.ok ? 'available' : 'unavailable' },
1512
+ source_status: {
1513
+ experiences: experienceCall.ok ? 'available' : 'unavailable',
1514
+ goals: goalCall.ok ? 'available' : 'unavailable',
1515
+ },
1516
+ goal_authority_coverage: goalCoverage,
1460
1517
  coverage,
1461
1518
  returned_reviewed: experienceCall.ok ? summaries.length : null,
1462
1519
  total_reviewed: experienceCall.ok && completeCoverage ? summaries.length : null,
@@ -1477,7 +1534,8 @@ async function failedTestAnalysis(args, context) {
1477
1534
  ...(experienceCall.ok && !completeCoverage
1478
1535
  ? [`Failed-test analysis coverage is ${coverage.status}; exact totals are null and returned_* fields describe only fetched rows.`]
1479
1536
  : []),
1480
- ...warningsFromCalls([experienceCall]),
1537
+ ...goalAuthorityWarnings(goalCoverage),
1538
+ ...warningsFromCalls([experienceCall, goalCall]),
1481
1539
  ],
1482
1540
  };
1483
1541
  }
@@ -1567,20 +1625,27 @@ async function cleanupCandidates(args, context) {
1567
1625
  await report(context, 'Fetching cleanup candidates', 1, 4);
1568
1626
  const path = basePath(args);
1569
1627
  const staleDraftDays = numberFrom(args.body.stale_draft_days ?? args.query.stale_draft_days, 30);
1570
- const [drafts, allExperiences, goals, audiences, locations, features] = await Promise.all([
1628
+ const [drafts, allExperiences, goals, audiences, locations, features, goalAuthority] = await Promise.all([
1571
1629
  listExperiences(path, { status: ['draft'] }),
1572
1630
  listExperiences(path),
1573
1631
  listResource('goals', path, { usage: 'not_used' }),
1574
1632
  listResource('audiences', path, { usage: 'not_used' }),
1575
1633
  listResource('locations', path, { usage: 'not_used' }),
1576
1634
  listResource('features', path),
1635
+ listResource('goals', path),
1577
1636
  ]);
1578
1637
  const experienceItems = collectionFrom(allExperiences.data);
1579
1638
  const draftItems = collectionFrom(drafts.data);
1639
+ // Shared goals can embed stale or reordered definitions per experience; resolve
1640
+ // every stale-draft summary against the goals authority (draft_qa parity) so a
1641
+ // shared goal reports one consistent type/name/status everywhere. Missing ids
1642
+ // fall back to their embeds, and an unavailable authority is disclosed below.
1643
+ const canonicalGoals = canonicalGoalDefinitions(collectionFrom(goalAuthority.data));
1644
+ const goalAuthorityCoverage = investigationCollectionCoverage(goalAuthority);
1580
1645
  const staleCutoff = Date.now() / 1000 - staleDraftDays * 24 * 60 * 60;
1581
1646
  const staleDrafts = draftItems
1582
1647
  .filter((item) => typeof item.created_at !== 'number' || item.created_at < staleCutoff)
1583
- .map((experience) => summarizeExperience(experience));
1648
+ .map((experience) => summarizeExperience(experience, canonicalGoals));
1584
1649
  const experienceCoverage = investigationCollectionCoverage(allExperiences);
1585
1650
  const draftCoverage = investigationCollectionCoverage(drafts);
1586
1651
  const completeExperienceCoverage = experienceCoverage.status === 'complete';
@@ -1616,12 +1681,13 @@ async function cleanupCandidates(args, context) {
1616
1681
  sdk_search_patterns: feature.key ? [`${feature.key}`, `isFeatureEnabled(\"${feature.key}\")`, `getFeature(\"${feature.key}\")`] : [],
1617
1682
  };
1618
1683
  });
1619
- const cleanupWarnings = warningsFromCalls([drafts, allExperiences, goals, audiences, locations, features]);
1684
+ const cleanupWarnings = warningsFromCalls([drafts, allExperiences, goals, audiences, locations, features, goalAuthority]);
1620
1685
  for (const [resource, status] of Object.entries(candidateSourceStatus)) {
1621
1686
  if (status !== 'complete') {
1622
1687
  cleanupWarnings.push(`${resource} cleanup candidates are unavailable because verification status is ${status}; other resource categories remain independently evaluated.`);
1623
1688
  }
1624
1689
  }
1690
+ cleanupWarnings.push(...goalAuthorityWarnings(goalAuthorityCoverage));
1625
1691
  return {
1626
1692
  stale_draft_days: staleDraftDays,
1627
1693
  source_status: {
@@ -1636,6 +1702,7 @@ async function cleanupCandidates(args, context) {
1636
1702
  drafts: draftCoverage,
1637
1703
  experiences: experienceCoverage,
1638
1704
  ...resourceCoverage,
1705
+ goal_authority: goalAuthorityCoverage,
1639
1706
  },
1640
1707
  returned_stale_drafts: drafts.ok ? staleDrafts.length : null,
1641
1708
  total_stale_drafts: drafts.ok && draftCoverage.status === 'complete' ? staleDrafts.length : null,
@@ -1804,6 +1871,10 @@ async function createSplitUrlTest(args, context) {
1804
1871
  readback_status: readback?.status ?? null,
1805
1872
  },
1806
1873
  verified,
1874
+ ...(verified ? {} : {
1875
+ remediation_required: true,
1876
+ next_steps: ['Do not repeat create or activate this experience. Inspect the existing resource using create_receipt.experience_id; if the ID is unavailable, locate it before any further write. Correct the draft only after reviewing the persisted arms and allocation.'],
1877
+ }),
1807
1878
  goal_configuration: goalConfigurationSummary(goalStatus),
1808
1879
  ...(verified && verification.normalizedCodeWhitespace ? {
1809
1880
  verification_normalizations: [
@@ -1816,7 +1887,9 @@ async function createSplitUrlTest(args, context) {
1816
1887
  ? 'Readback confirmed the draft split URL experience and exact redirect patterns after Convert canonicalized only surrounding code whitespace.'
1817
1888
  : 'Readback confirmed the draft split URL experience and exact redirect patterns.'
1818
1889
  : 'Readback succeeded, but one or more requested split URL fields did not persist exactly.'
1819
- : 'Create call succeeded but no readback ID was available.',
1890
+ : experienceId
1891
+ ? 'Create succeeded, but readback failed. The existing draft is unverified; do not repeat create.'
1892
+ : 'Create succeeded but returned no usable experience ID. Locate the existing draft before any further write; do not repeat create.',
1820
1893
  };
1821
1894
  }
1822
1895
  async function enableSplitUrlRegex(args, context) {
@@ -2095,8 +2168,11 @@ export function buildSplitUrlExperienceBody(input) {
2095
2168
  }
2096
2169
  : input.settings,
2097
2170
  variations: Array.isArray(input.variations) ? input.variations : [
2171
+ { name: 'Original', is_baseline: true, traffic_distribution: 50, changes: [] },
2098
2172
  {
2099
2173
  name: stringFrom(input.variation_name, 'Variation URL'),
2174
+ is_baseline: false,
2175
+ traffic_distribution: 50,
2100
2176
  changes: [
2101
2177
  {
2102
2178
  type: 'defaultRedirect',
@@ -2371,6 +2447,7 @@ async function namespaceCall(namespace, action, path, body, query, confirmation)
2371
2447
  error: structured.error ?? text ?? 'Unknown error',
2372
2448
  details: structured,
2373
2449
  text,
2450
+ failure: failureFromStructured(structured, 'namespace'),
2374
2451
  });
2375
2452
  }
2376
2453
  return recordNamespaceCall({
@@ -2396,6 +2473,13 @@ async function safeNamespaceCall(namespace, action, path, body, query) {
2396
2473
  namespace,
2397
2474
  action,
2398
2475
  error: error instanceof Error ? error.name : 'ThrownError',
2476
+ failure: failureAttribution({
2477
+ category: 'unknown',
2478
+ source: 'operator',
2479
+ stage: 'operator_workflow',
2480
+ dispatchState: 'unknown',
2481
+ code: error instanceof Error ? error.name : 'ThrownError',
2482
+ }),
2399
2483
  });
2400
2484
  }
2401
2485
  }
@@ -2419,6 +2503,11 @@ function basePath(args, extra = [], override = {}) {
2419
2503
  }
2420
2504
  return path;
2421
2505
  }
2506
+ function goalAuthorityWarnings(coverage) {
2507
+ return coverage.status === 'complete' ? [] : [
2508
+ `Goal type/name/status for unresolved IDs fell back to experience embeds because the goals authority is ${coverage.status}; verify shared goal definitions via goals.get before acting on them.`,
2509
+ ];
2510
+ }
2422
2511
  function summarizeExperiences(experiences, canonicalGoals = new Map()) {
2423
2512
  return experiences.map((experience) => summarizeExperience(experience, canonicalGoals));
2424
2513
  }
@@ -3172,11 +3261,25 @@ function stableLifecycleStatus(value) {
3172
3261
  : 'unknown';
3173
3262
  }
3174
3263
  function stableCallErrorClass(call) {
3264
+ if (call.failure?.category === 'input')
3265
+ return 'invalid_input';
3266
+ if (call.failure?.category === 'permission')
3267
+ return call.status === 401 ? 'authentication_failed' : 'permission_denied';
3268
+ if (call.failure?.category === 'not_found')
3269
+ return 'not_found';
3270
+ if (call.failure?.category === 'timeout')
3271
+ return 'upstream_timeout';
3272
+ if (call.failure?.category === 'network')
3273
+ return 'upstream_network_error';
3274
+ if (call.failure?.category === 'cancellation')
3275
+ return 'cancelled';
3276
+ if (call.failure?.category === 'internal')
3277
+ return 'internal_error';
3175
3278
  const status = call.status;
3176
3279
  if (status === 401)
3177
3280
  return 'authentication_failed';
3178
3281
  if (status === 403)
3179
- return 'permission_denied';
3282
+ return 'upstream_request_failed';
3180
3283
  if (status === 404)
3181
3284
  return 'not_found';
3182
3285
  if (status === 422)
@@ -3719,6 +3822,21 @@ function splitUrlExperienceVerification(data, expected) {
3719
3822
  if (!experienceIdentityMatches(actual, expected)) {
3720
3823
  return { matches: false, normalizedCodeWhitespace: false };
3721
3824
  }
3825
+ const arms = collectionFrom(actual.variations);
3826
+ const expectedArms = collectionFrom(expected.variations);
3827
+ if (arms.length !== expectedArms.length || validateSplitUrlArms(arms) !== null
3828
+ || new Set(arms.map((arm) => Number(arm.id))).size !== arms.length
3829
+ || arms.some((arm) => !Number.isSafeInteger(Number(arm.id)) || Number(arm.id) <= 0)
3830
+ || arms.some((arm) => (arm.is_baseline === true)
3831
+ !== (expectedArms.find((item) => item.name === arm.name)?.is_baseline === true))) {
3832
+ return { matches: false, normalizedCodeWhitespace: false };
3833
+ }
3834
+ for (const arm of arms) {
3835
+ const expectedArm = expectedArms.find((item) => item.name === arm.name);
3836
+ if (!expectedArm || arm.changes.length !== collectionFrom(expectedArm.changes).length) {
3837
+ return { matches: false, normalizedCodeWhitespace: false };
3838
+ }
3839
+ }
3722
3840
  return expectedVariationsVerification(actual.variations, expected.variations, false);
3723
3841
  }
3724
3842
  function experienceIdentityMatches(actual, expected) {
@@ -3962,11 +4080,40 @@ async function report(context, message, progress, total) {
3962
4080
  function finalizeOperatorResult(action, data, calls) {
3963
4081
  const allCallsFailed = calls.length > 0 && calls.every((call) => !call.ok);
3964
4082
  if (!allCallsFailed) {
3965
- return okResult(action, data);
4083
+ return okResult(action, data, calls);
3966
4084
  }
3967
4085
  const status = dominantFailureStatus(calls);
3968
4086
  const errors = calls.map((call) => `${call.namespace}.${call.action}: ${stableCallErrorClass(call)}`);
3969
- return errorResult(action, 'AllUpstreamCallsFailed', `All upstream namespace calls failed for operator.${action}.`, { status, data, errors });
4087
+ const partial = operatorFailureSummary(calls);
4088
+ return errorResult(action, 'AllUpstreamCallsFailed', `All upstream namespace calls failed for operator.${action}.`, {
4089
+ status,
4090
+ data,
4091
+ errors,
4092
+ ...(partial ? { failure: partial.failure } : {}),
4093
+ });
4094
+ }
4095
+ function operatorFailureSummary(calls) {
4096
+ const failures = calls
4097
+ .filter((call) => !call.ok)
4098
+ .map((call) => call.failure ?? failureFromStructured({
4099
+ ...(typeof call.status === 'number' ? { status: call.status } : {}),
4100
+ error: typeof call.error === 'string' ? call.error : 'UnknownFailure',
4101
+ }, 'namespace'));
4102
+ if (failures.length === 0)
4103
+ return null;
4104
+ const priority = new Map([
4105
+ ['permission', 0],
4106
+ ['cancellation', 1],
4107
+ ['timeout', 2],
4108
+ ['network', 3],
4109
+ ['not_found', 4],
4110
+ ['upstream', 5],
4111
+ ['input', 6],
4112
+ ['internal', 7],
4113
+ ['unknown', 8],
4114
+ ]);
4115
+ const failure = failures.slice().sort((left, right) => (priority.get(left.category) ?? 99) - (priority.get(right.category) ?? 99))[0];
4116
+ return { count: failures.length, failure };
3970
4117
  }
3971
4118
  /** The most common HTTP status among failed calls, or 502 when no call reported a numeric status. */
3972
4119
  function dominantFailureStatus(calls) {
@@ -3980,11 +4127,12 @@ function dominantFailureStatus(calls) {
3980
4127
  return 502;
3981
4128
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
3982
4129
  }
3983
- function okResult(action, data) {
4130
+ function okResult(action, data, calls = []) {
3984
4131
  const verified = typeof data.verified === 'boolean' ? data.verified : undefined;
3985
4132
  const responseData = verified === undefined ? data : { ...data };
3986
4133
  if (verified !== undefined)
3987
4134
  delete responseData.verified;
4135
+ const partial = operatorFailureSummary(calls);
3988
4136
  const payload = {
3989
4137
  ok: true,
3990
4138
  namespace: 'operator',
@@ -3993,6 +4141,10 @@ function okResult(action, data) {
3993
4141
  status: 200,
3994
4142
  data: responseData,
3995
4143
  ...(verified !== undefined ? { verified } : {}),
4144
+ ...(partial ? {
4145
+ partial_failure_count: partial.count,
4146
+ partial_failure: partial.failure,
4147
+ } : {}),
3996
4148
  };
3997
4149
  return {
3998
4150
  content: [{ type: 'text', text: JSON.stringify(payload) }],
@@ -4003,6 +4155,10 @@ function errorResult(action, code, message, extra = {}) {
4003
4155
  const scrubbedExtra = scrubOperatorErrorValue(extra);
4004
4156
  const safeExtra = isObject(scrubbedExtra) ? scrubbedExtra : {};
4005
4157
  const status = typeof safeExtra.status === 'number' ? safeExtra.status : 400;
4158
+ const existingFailure = safeFailureAttribution(safeExtra.failure);
4159
+ const inferredFailure = code === 'MissingPathParameters'
4160
+ ? inputFailure(code, 'operator', 'required', 'path')
4161
+ : failureFromStructured({ ...safeExtra, error: code }, 'operator');
4006
4162
  const payload = {
4007
4163
  ...safeExtra,
4008
4164
  ok: false,
@@ -4012,6 +4168,7 @@ function errorResult(action, code, message, extra = {}) {
4012
4168
  status,
4013
4169
  error: code,
4014
4170
  message: scrubDiagnosticText(message),
4171
+ failure: existingFailure ?? inferredFailure,
4015
4172
  };
4016
4173
  return {
4017
4174
  isError: true,
@@ -4065,6 +4222,14 @@ function approvalRequiredOperatorErrorResult(action, details) {
4065
4222
  error: 'ApprovalRequired',
4066
4223
  code: 'ApprovalRequired',
4067
4224
  message,
4225
+ failure: failureAttribution({
4226
+ category: 'permission',
4227
+ source: 'operator',
4228
+ stage: 'upstream_response',
4229
+ dispatchState: 'dispatched',
4230
+ code: 'ApprovalRequired',
4231
+ upstreamStatus: 422,
4232
+ }),
4068
4233
  approval_state: approvalState,
4069
4234
  approval_required: {
4070
4235
  rule_code: ruleCode,