@apifuse/provider-sdk 2.2.0-beta.60 → 2.2.0-beta.61

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 (73) hide show
  1. package/AUTHORING.md +17 -16
  2. package/CHANGELOG.md +4 -0
  3. package/bin/apifuse-dev.ts +5 -7
  4. package/bin/apifuse-pack-types.ts +22 -0
  5. package/bin/apifuse-perf.ts +4 -6
  6. package/bin/apifuse-record.ts +4 -7
  7. package/bin/apifuse-submit-check.ts +2 -2
  8. package/dist/define.d.ts +3 -2
  9. package/dist/define.js +1 -1
  10. package/dist/dev.js +3 -1
  11. package/dist/engine.d.ts +7 -6
  12. package/dist/engine.js +3 -4
  13. package/dist/error-resolution.js +21 -4
  14. package/dist/errors.d.ts +3 -0
  15. package/dist/errors.js +9 -0
  16. package/dist/handle-meta.d.ts +39 -0
  17. package/dist/handle-meta.js +29 -0
  18. package/dist/handle.d.ts +187 -0
  19. package/dist/handle.js +254 -0
  20. package/dist/i18n/catalog.js +21 -1
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +3 -2
  23. package/dist/lint.d.ts +3 -0
  24. package/dist/lint.js +352 -47
  25. package/dist/provider.d.ts +3 -3
  26. package/dist/provider.js +2 -2
  27. package/dist/runtime/handle-wordlist.d.ts +43 -0
  28. package/dist/runtime/{choice-wordlist.js → handle-wordlist.js} +127 -9
  29. package/dist/runtime/handle.d.ts +71 -0
  30. package/dist/runtime/handle.js +895 -0
  31. package/dist/runtime/native-network.d.ts +10 -1
  32. package/dist/runtime/native-network.js +230 -29
  33. package/dist/runtime/native-telemetry.d.ts +142 -0
  34. package/dist/runtime/native-telemetry.js +231 -0
  35. package/dist/runtime/state.d.ts +4 -1
  36. package/dist/runtime/state.js +32 -17
  37. package/dist/server/index.d.ts +1 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/sensitive-values.d.ts +1 -1
  40. package/dist/server/sensitive-values.js +0 -3
  41. package/dist/server/serve-implementation.d.ts +4 -11
  42. package/dist/server/serve-implementation.js +86 -18
  43. package/dist/testing/run.js +10 -7
  44. package/dist/types.d.ts +4 -107
  45. package/package.json +1 -1
  46. package/src/define.ts +4 -3
  47. package/src/dev.ts +3 -1
  48. package/src/engine.ts +5 -6
  49. package/src/error-resolution.ts +21 -4
  50. package/src/errors.ts +13 -0
  51. package/src/handle-meta.ts +58 -0
  52. package/src/handle.ts +529 -0
  53. package/src/i18n/catalog.ts +23 -1
  54. package/src/index.ts +26 -13
  55. package/src/lint.ts +414 -55
  56. package/src/provider.ts +9 -20
  57. package/src/runtime/{choice-wordlist.ts → handle-wordlist.ts} +130 -9
  58. package/src/runtime/handle.ts +1162 -0
  59. package/src/runtime/native-network.ts +281 -33
  60. package/src/runtime/native-telemetry.ts +366 -0
  61. package/src/runtime/state.ts +44 -16
  62. package/src/server/index.ts +17 -0
  63. package/src/server/sensitive-values.ts +0 -3
  64. package/src/server/serve-implementation.ts +87 -32
  65. package/src/testing/run.ts +11 -7
  66. package/src/types.ts +4 -137
  67. package/dist/choice-token.d.ts +0 -24
  68. package/dist/choice-token.js +0 -74
  69. package/dist/runtime/choice-wordlist.d.ts +0 -9
  70. package/dist/runtime/choice.d.ts +0 -27
  71. package/dist/runtime/choice.js +0 -801
  72. package/src/choice-token.ts +0 -165
  73. package/src/runtime/choice.ts +0 -1197
package/src/lint.ts CHANGED
@@ -6,13 +6,16 @@ import {
6
6
  SDK_RUNTIME_OWNED_ERROR_CODES,
7
7
  SDK_STATUS_MAPPED_PROVIDER_ERROR_CODES,
8
8
  } from "./error-resolution.js";
9
+ import {
10
+ APIFUSE_HANDLE_META_KEY,
11
+ type HandleFieldMeta,
12
+ type HandleKindDeclaration,
13
+ handleFieldDescription,
14
+ isHandleFieldMeta,
15
+ } from "./handle-meta.js";
9
16
  import { lintPublicSchemaFieldNames } from "./public-schema-field-lint.js";
10
17
  import { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_SENSITIVE_META_KEY } from "./schema.js";
11
- import type {
12
- AuthMode,
13
- OperationApprovalPolicy,
14
- OperationRiskClass,
15
- } from "./types.js";
18
+ import type { AuthMode, OperationApprovalPolicy, OperationRiskClass } from "./types.js";
16
19
 
17
20
  const requireModule = createRequire(import.meta.url);
18
21
  // `typeof import(...)` keeps the type without emitting a static import: the
@@ -92,12 +95,7 @@ const AUTH_LIFECYCLE_SEGMENT_WORDS = new Set([
92
95
  ]);
93
96
 
94
97
  // Ambiguous as a prefix, unambiguous when they are the whole operation id.
95
- const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set([
96
- "authorize",
97
- "revoke",
98
- "unlink",
99
- "disconnect",
100
- ]);
98
+ const AUTH_LIFECYCLE_WHOLE_ID_WORDS = new Set(["authorize", "revoke", "unlink", "disconnect"]);
101
99
 
102
100
  // A verb stem followed by a direction word across two segments: `sign-out`,
103
101
  // `user_sign_up_flow`, and the spelled-out `log-in` / `shop-log-out` forms
@@ -465,19 +463,30 @@ function getObjectShape(schema: SchemaLike): Record<string, SchemaLike> {
465
463
  return {};
466
464
  }
467
465
 
468
- function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: SchemaLike }> {
466
+ /**
467
+ * Child edges of a schema node. `origin: "shape"` marks object properties whose
468
+ * key is provider-chosen (and may collide with a structural edge name such as
469
+ * `item`, `type`, or `schema`); `origin: "structural"` marks zod internals
470
+ * (wrappers, array elements, union options, pipe stages).
471
+ */
472
+ type SchemaChildEdge = { key: string; schema: SchemaLike; origin: "shape" | "structural" };
473
+
474
+ function getChildSchemas(schema: SchemaLike): SchemaChildEdge[] {
469
475
  const seen = new Map<string, SchemaLike>();
476
+ const origins = new Map<string, "shape" | "structural">();
470
477
  const def = getSchemaDef(schema);
471
478
 
472
- const add = (key: string, value: unknown) => {
479
+ const add = (key: string, value: unknown, origin: "shape" | "structural" = "structural") => {
473
480
  if (!isSchema(value)) {
474
481
  return;
475
482
  }
476
- seen.set(`${key}:${seen.size}`, value);
483
+ const id = `${key}:${seen.size}`;
484
+ seen.set(id, value);
485
+ origins.set(id, origin);
477
486
  };
478
487
 
479
488
  for (const [key, value] of Object.entries(getObjectShape(schema))) {
480
- add(key, value);
489
+ add(key, value, "shape");
481
490
  }
482
491
 
483
492
  add("element", schema.element);
@@ -520,6 +529,9 @@ function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: Schem
520
529
  "schema",
521
530
  "innerType",
522
531
  "type",
532
+ // zod v4 intersections keep their branches on the definition.
533
+ "left",
534
+ "right",
523
535
  "valueType",
524
536
  "keyType",
525
537
  "item",
@@ -543,6 +555,7 @@ function getChildSchemas(schema: SchemaLike): Array<{ key: string; schema: Schem
543
555
  return Array.from(seen.entries()).map(([entryKey, child]) => ({
544
556
  key: entryKey.split(":")[0] ?? entryKey,
545
557
  schema: child,
558
+ origin: origins.get(entryKey) ?? "structural",
546
559
  }));
547
560
  }
548
561
 
@@ -622,6 +635,77 @@ function isDeclaredSensitiveField(key: string, schema: unknown): boolean {
622
635
  );
623
636
  }
624
637
 
638
+ // Child keys produced by getChildSchemas() that wrap a schema without adding a
639
+ // property path segment (optional/nullable/default/pipe/effects/etc.).
640
+ const SCHEMA_WRAPPER_NODE_KEYS: readonly string[] = [
641
+ "unwrap",
642
+ "innerType",
643
+ "sourceType",
644
+ "schema",
645
+ "type",
646
+ "in",
647
+ "out",
648
+ "option",
649
+ "pipe",
650
+ "payload",
651
+ "item",
652
+ "rest",
653
+ "catchall",
654
+ "keyType",
655
+ "valueType",
656
+ // zod v4 intersection branches: transparent, the enclosing property key applies.
657
+ "left",
658
+ "right",
659
+ ];
660
+
661
+ function isSchemaWrapperNodeKey(key: string): boolean {
662
+ return SCHEMA_WRAPPER_NODE_KEYS.includes(key) || key.startsWith("pipe.");
663
+ }
664
+
665
+ function getOwnHandleFieldMeta(schema: SchemaLike): HandleFieldMeta | undefined {
666
+ const value = Reflect.get(getSchemaMetadata(schema), APIFUSE_HANDLE_META_KEY);
667
+ return isHandleFieldMeta(value) ? value : undefined;
668
+ }
669
+
670
+ /**
671
+ * Handle meta placed by `kind.field()` lives on the string schema itself; zod
672
+ * wrappers (`.optional()`, `.nullable()`, `.default()`, pipes) do not inherit
673
+ * registry metadata, so look through wrapper nodes until a non-wrapper is hit.
674
+ */
675
+ function getHandleFieldMeta(
676
+ schema: unknown,
677
+ seen = new Set<SchemaLike>(),
678
+ ): HandleFieldMeta | undefined {
679
+ if (!isSchema(schema) || seen.has(schema)) return undefined;
680
+ seen.add(schema);
681
+ const own = getOwnHandleFieldMeta(schema);
682
+ if (own) return own;
683
+ for (const child of getChildSchemas(schema)) {
684
+ if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key)) continue;
685
+ const found = getHandleFieldMeta(child.schema, seen);
686
+ if (found) return found;
687
+ }
688
+ return undefined;
689
+ }
690
+
691
+ /**
692
+ * True when `schema` is a handle field (directly or through zod wrappers) whose
693
+ * description is still the SDK-generated wording and no wrapper adds its own
694
+ * description.
695
+ */
696
+ function isSdkOwnedHandleField(schema: unknown, seen = new Set<SchemaLike>()): boolean {
697
+ if (!isSchema(schema) || seen.has(schema)) return false;
698
+ seen.add(schema);
699
+ const own = getOwnHandleFieldMeta(schema);
700
+ if (own) return schema.description === handleFieldDescription(own);
701
+ if (schema.description) return false;
702
+ for (const child of getChildSchemas(schema)) {
703
+ if (child.origin === "shape" || !isSchemaWrapperNodeKey(child.key)) continue;
704
+ if (isSdkOwnedHandleField(child.schema, seen)) return true;
705
+ }
706
+ return false;
707
+ }
708
+
625
709
  function collectUnmarkedSensitiveFields(
626
710
  schema: unknown,
627
711
  basePath: string,
@@ -641,23 +725,7 @@ function collectUnmarkedSensitiveFields(
641
725
  }
642
726
  for (const child of getChildSchemas(schema)) {
643
727
  if (Object.hasOwn(getObjectShape(schema), child.key)) continue;
644
- const isWrapperNode = [
645
- "unwrap",
646
- "innerType",
647
- "sourceType",
648
- "schema",
649
- "type",
650
- "in",
651
- "out",
652
- "option",
653
- "pipe",
654
- "payload",
655
- "item",
656
- "rest",
657
- "catchall",
658
- "keyType",
659
- "valueType",
660
- ].includes(child.key);
728
+ const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
661
729
  const childPath =
662
730
  child.key === "element" || child.key.startsWith("element.")
663
731
  ? `${basePath}[]`
@@ -686,6 +754,15 @@ function collectSchemaDescriptionKeyDiagnostics(
686
754
  const currentPath = basePath || "schema";
687
755
  const hasDescriptionKey = getSchemaDescriptionKey(schema) !== undefined;
688
756
 
757
+ // Handle fields (`kind.field()`, ADR-0012) carry SDK-owned wording via
758
+ // .describe() plus x-apifuse-handle meta; they count as described and are
759
+ // leaves, so neither this node nor its wrapper chain needs a describeKey.
760
+ // A provider override of that wording (`.describe("…")` on the field or on
761
+ // a wrapper) is provider prose again and stays subject to the rules below.
762
+ if (isSdkOwnedHandleField(schema)) {
763
+ return diagnostics;
764
+ }
765
+
689
766
  if (schema.description && !hasDescriptionKey) {
690
767
  diagnostics.push({
691
768
  rule: "schema-description-raw-prose",
@@ -707,23 +784,7 @@ function collectSchemaDescriptionKeyDiagnostics(
707
784
  }
708
785
 
709
786
  for (const child of getChildSchemas(schema)) {
710
- const isWrapperNode = [
711
- "unwrap",
712
- "innerType",
713
- "sourceType",
714
- "schema",
715
- "type",
716
- "in",
717
- "out",
718
- "option",
719
- "pipe",
720
- "payload",
721
- "item",
722
- "rest",
723
- "catchall",
724
- "keyType",
725
- "valueType",
726
- ].includes(child.key);
787
+ const isWrapperNode = SCHEMA_WRAPPER_NODE_KEYS.includes(child.key);
727
788
  const isStructuralNode =
728
789
  isWrapperNode ||
729
790
  child.key.startsWith("pipe.") ||
@@ -1275,7 +1336,10 @@ function collectLiteralErrorCodeValues(args: string): string[] {
1275
1336
  after++;
1276
1337
  }
1277
1338
  const nextChar = after < args.length ? (args[after] ?? "") : "";
1278
- if (!value.includes("\\") && (nextChar === "," || nextChar === "}" || nextChar === "")) {
1339
+ if (
1340
+ !value.includes("\\") &&
1341
+ (nextChar === "," || nextChar === "}" || nextChar === "")
1342
+ ) {
1279
1343
  codes.push(value);
1280
1344
  }
1281
1345
  index = end;
@@ -1388,6 +1452,300 @@ function lintUndeclaredThrownErrorCodes(provider: {
1388
1452
  return diagnostics;
1389
1453
  }
1390
1454
 
1455
+ type HandleFieldOccurrence = {
1456
+ /** Diagnostic path, e.g. `output.offers[].waiting_token`. */
1457
+ path: string;
1458
+ /** Property key the field is declared under (inherited through arrays/wrappers). */
1459
+ key: string;
1460
+ meta: HandleFieldMeta;
1461
+ };
1462
+
1463
+ function collectHandleFields(
1464
+ schema: unknown,
1465
+ basePath: string,
1466
+ key: string | undefined = undefined,
1467
+ seen = new Set<SchemaLike>(),
1468
+ ): HandleFieldOccurrence[] {
1469
+ if (!isSchema(schema)) return [];
1470
+
1471
+ // Record before the cycle guard so one `kind.field()` instance reused under
1472
+ // two property keys is still reported at both positions.
1473
+ const own = getOwnHandleFieldMeta(schema);
1474
+ if (own && key !== undefined) {
1475
+ return [{ path: basePath, key, meta: own }];
1476
+ }
1477
+ // Path-local cycle guard: the same wrapped schema instance may legitimately
1478
+ // appear under several property keys (`Kind.field().optional()` reused), and
1479
+ // each position must be checked. Only true ancestor cycles are cut.
1480
+ if (seen.has(schema)) return [];
1481
+ seen.add(schema);
1482
+ try {
1483
+ return collectHandleFieldChildren(schema, basePath, key, seen);
1484
+ } finally {
1485
+ seen.delete(schema);
1486
+ }
1487
+ }
1488
+
1489
+ function collectHandleFieldChildren(
1490
+ schema: SchemaLike,
1491
+ basePath: string,
1492
+ key: string | undefined,
1493
+ seen: Set<SchemaLike>,
1494
+ ): HandleFieldOccurrence[] {
1495
+ const out: HandleFieldOccurrence[] = [];
1496
+ // zod exposes the same inner schema under several structural edges
1497
+ // (`unwrap`/`innerType`, `element`/`def.element`); traverse each structural
1498
+ // child object once. Shape children are always traversed: the same instance
1499
+ // under two property keys is two fields.
1500
+ const structuralSeen = new Set<SchemaLike>();
1501
+ for (const child of getChildSchemas(schema)) {
1502
+ if (child.origin !== "shape") {
1503
+ if (structuralSeen.has(child.schema)) continue;
1504
+ structuralSeen.add(child.schema);
1505
+ }
1506
+ if (child.origin === "shape") {
1507
+ // Provider-chosen property key: always a real field, even when it is
1508
+ // spelled like a structural edge (`item`, `type`, `schema`, ...).
1509
+ out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
1510
+ } else if (isSchemaWrapperNodeKey(child.key)) {
1511
+ out.push(...collectHandleFields(child.schema, basePath, key, seen));
1512
+ } else if (child.key === "element" || child.key.startsWith("element.")) {
1513
+ out.push(...collectHandleFields(child.schema, `${basePath}[]`, key, seen));
1514
+ } else if (/^\d+$/.test(child.key)) {
1515
+ out.push(...collectHandleFields(child.schema, `${basePath}[${child.key}]`, key, seen));
1516
+ } else {
1517
+ out.push(...collectHandleFields(child.schema, `${basePath}.${child.key}`, child.key, seen));
1518
+ }
1519
+ }
1520
+ return out;
1521
+ }
1522
+
1523
+ /**
1524
+ * Reads `provider.handle` structurally. The declaration type is owned by
1525
+ * define.ts/types.ts; lint only needs the HandleKindDeclaration view, so
1526
+ * malformed entries are skipped rather than reported.
1527
+ */
1528
+ function readHandleKindDeclarations(value: unknown): HandleKindDeclaration[] {
1529
+ if (!Array.isArray(value)) return [];
1530
+ return value.filter((entry): entry is HandleKindDeclaration => {
1531
+ if (typeof entry !== "object" || entry === null) return false;
1532
+ const record = entry as Record<string, unknown>;
1533
+ return (
1534
+ typeof record.name === "string" &&
1535
+ (record.type === "cursor" || record.type === "draft") &&
1536
+ typeof record.fieldName === "string" &&
1537
+ (record.issuedBy === undefined || typeof record.issuedBy === "string")
1538
+ );
1539
+ });
1540
+ }
1541
+
1542
+ /**
1543
+ * ADR-0012 handle rules: every `x-apifuse-handle` schema field must belong to
1544
+ * a declared kind under its declared property key; every declared kind must be
1545
+ * both issued (some output) and accepted (some input); `issuedBy` must name an
1546
+ * operation whose output carries the field; and `ctx.handle` needs `state`.
1547
+ */
1548
+ function lintHandleDeclarations(provider: {
1549
+ id?: string;
1550
+ handle?: unknown;
1551
+ state?: unknown;
1552
+ operations?: Record<string, { input: unknown; output: unknown }>;
1553
+ }): LintDiagnostic[] {
1554
+ const diagnostics: LintDiagnostic[] = [];
1555
+ const providerLabel = provider.id ? `Provider "${provider.id}"` : "Provider";
1556
+ const kinds = readHandleKindDeclarations(provider.handle);
1557
+ const kindByName = new Map(kinds.map((kind) => [kind.name, kind]));
1558
+ const operations = provider.operations ?? {};
1559
+
1560
+ if (kinds.length > 0 && !provider.state) {
1561
+ diagnostics.push({
1562
+ rule: "handle-requires-state",
1563
+ level: "warn",
1564
+ field: "handle",
1565
+ message: `${providerLabel} declares handle kinds but not the state capability; ctx.handle stores records through ctx.state, so declare state: true.`,
1566
+ });
1567
+ }
1568
+
1569
+ const kindsInInputs = new Set<string>();
1570
+ const kindsInOutputs = new Set<string>();
1571
+ const outputKindsByOperation = new Map<string, Set<string>>();
1572
+
1573
+ for (const [operationKey, operation] of Object.entries(operations)) {
1574
+ const positions = [
1575
+ { side: "input", fields: collectHandleFields(operation.input, "input") },
1576
+ { side: "output", fields: collectHandleFields(operation.output, "output") },
1577
+ ] as const;
1578
+ for (const { side, fields } of positions) {
1579
+ for (const occurrence of fields) {
1580
+ const field = `operations.${operationKey}.${occurrence.path}`;
1581
+ const { kind, fieldName } = occurrence.meta;
1582
+
1583
+ if (!kindByName.has(kind)) {
1584
+ diagnostics.push({
1585
+ rule: "handle-kind-undeclared",
1586
+ level: "error",
1587
+ field,
1588
+ message: `${providerLabel} operation "${operationKey}" ${side} field "${occurrence.path}" is a handle of kind "${kind}", which is not declared in provider.handle. Add the kind to handle: [...] or remove the field.`,
1589
+ });
1590
+ }
1591
+
1592
+ if (occurrence.key !== fieldName) {
1593
+ diagnostics.push({
1594
+ rule: "handle-field-name",
1595
+ level: "error",
1596
+ field,
1597
+ message: `${providerLabel} operation "${operationKey}" ${side} declares handle kind "${kind}" under property "${occurrence.key}" but the kind's fieldName is "${fieldName}". Use the same key in every schema so the LLM sees one name.`,
1598
+ });
1599
+ }
1600
+
1601
+ if (side === "input") {
1602
+ kindsInInputs.add(kind);
1603
+ } else {
1604
+ kindsInOutputs.add(kind);
1605
+ let outputKinds = outputKindsByOperation.get(operationKey);
1606
+ if (!outputKinds) {
1607
+ outputKinds = new Set<string>();
1608
+ outputKindsByOperation.set(operationKey, outputKinds);
1609
+ }
1610
+ outputKinds.add(kind);
1611
+ }
1612
+ }
1613
+ }
1614
+ }
1615
+
1616
+ for (const kind of kinds) {
1617
+ const field = `handle.${kind.name}`;
1618
+ const issued = kindsInOutputs.has(kind.name);
1619
+ const accepted = kindsInInputs.has(kind.name);
1620
+
1621
+ if (!issued) {
1622
+ diagnostics.push({
1623
+ rule: "handle-field-parity",
1624
+ level: "error",
1625
+ field,
1626
+ message: `${providerLabel} handle kind "${kind.name}" is never issued: no operation output contains its field "${kind.fieldName}". Add ${kind.name}.field() to the issuing operation's output.`,
1627
+ });
1628
+ }
1629
+ if (!accepted) {
1630
+ diagnostics.push({
1631
+ rule: "handle-field-parity",
1632
+ level: "error",
1633
+ field,
1634
+ message: `${providerLabel} handle kind "${kind.name}" is never accepted: no operation input contains its field "${kind.fieldName}". Add ${kind.name}.field() to the consuming operation's input.`,
1635
+ });
1636
+ }
1637
+
1638
+ if (kind.issuedBy === undefined) continue;
1639
+ if (!Object.hasOwn(operations, kind.issuedBy)) {
1640
+ diagnostics.push({
1641
+ rule: "handle-issued-by",
1642
+ level: "error",
1643
+ field,
1644
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${kind.issuedBy}", which is not an operation key.`,
1645
+ });
1646
+ } else if (!outputKindsByOperation.get(kind.issuedBy)?.has(kind.name)) {
1647
+ diagnostics.push({
1648
+ rule: "handle-issued-by",
1649
+ level: "error",
1650
+ field,
1651
+ message: `${providerLabel} handle kind "${kind.name}" declares issuedBy "${kind.issuedBy}", but that operation's output has no "${kind.fieldName}" handle field.`,
1652
+ });
1653
+ }
1654
+ }
1655
+
1656
+ return diagnostics;
1657
+ }
1658
+
1659
+ const LEGACY_CHOICE_USAGE_PATTERN =
1660
+ /\bctx\.choice\b|\b(?:createProviderChoiceToken|parseProviderChoiceToken|ProviderChoiceTokenError|createTestProviderChoiceContext)\b/;
1661
+
1662
+ const LEGACY_CHOICE_USAGE_MESSAGE =
1663
+ "Choice tokens were replaced by handles (ADR-0012). See docs/migrations/handle.md.";
1664
+
1665
+ /**
1666
+ * The inline choice-token API was removed outright (no aliases). Any surviving
1667
+ * reference in provider source would fail at runtime, so report it at check time.
1668
+ */
1669
+ const LEGACY_CHOICE_IDENTIFIERS = new Set([
1670
+ "createProviderChoiceToken",
1671
+ "parseProviderChoiceToken",
1672
+ "ProviderChoiceTokenError",
1673
+ "createTestProviderChoiceContext",
1674
+ ]);
1675
+
1676
+ /**
1677
+ * Finds the first legacy choice API reference in executable code. Uses the
1678
+ * TypeScript AST when available so comments, string and template literal text
1679
+ * never count (a migration note such as `// moved from ctx.choice` must not fail
1680
+ * the provider) while code inside `${}` interpolations still does. Falls back
1681
+ * to a raw-source regex when TypeScript is not installed.
1682
+ */
1683
+ function findLegacyChoiceUsage(source: string): string | undefined {
1684
+ let ts: typeof import("typescript");
1685
+ try {
1686
+ ts = getTypeScript();
1687
+ } catch {
1688
+ return LEGACY_CHOICE_USAGE_PATTERN.exec(source)?.[0];
1689
+ }
1690
+ const file = ts.createSourceFile(
1691
+ "provider.ts",
1692
+ source,
1693
+ ts.ScriptTarget.Latest,
1694
+ false,
1695
+ ts.ScriptKind.TSX,
1696
+ );
1697
+ let found: string | undefined;
1698
+ const visit = (node: import("typescript").Node): void => {
1699
+ if (found) return;
1700
+ if (ts.isIdentifier(node) && LEGACY_CHOICE_IDENTIFIERS.has(node.text)) {
1701
+ found = node.text;
1702
+ return;
1703
+ }
1704
+ if (
1705
+ ts.isPropertyAccessExpression(node) &&
1706
+ ts.isIdentifier(node.expression) &&
1707
+ node.expression.text === "ctx" &&
1708
+ node.name.text === "choice"
1709
+ ) {
1710
+ found = "ctx.choice";
1711
+ return;
1712
+ }
1713
+ ts.forEachChild(node, visit);
1714
+ };
1715
+ visit(file);
1716
+ return found;
1717
+ }
1718
+
1719
+ function lintLegacyChoiceUsage(provider: ProviderSourceLike): LintDiagnostic[] {
1720
+ const sources: Array<{ field: string; source: string }> = [];
1721
+
1722
+ if (provider.authFlowSource) {
1723
+ sources.push({ field: "auth.flow", source: provider.authFlowSource });
1724
+ }
1725
+ for (const [filePath, source] of Object.entries(provider.providerSourceFiles ?? {})) {
1726
+ sources.push({ field: `sourceFiles.${filePath}`, source });
1727
+ }
1728
+ for (const [operationKey, operation] of Object.entries(provider.operations ?? {})) {
1729
+ const source = getOperationSource(operation);
1730
+ if (source) {
1731
+ sources.push({ field: `operations.${operationKey}.handler`, source });
1732
+ }
1733
+ }
1734
+
1735
+ return sources.flatMap(({ field, source }) => {
1736
+ const match = findLegacyChoiceUsage(source);
1737
+ if (!match) return [];
1738
+ return [
1739
+ {
1740
+ rule: "legacy-choice-usage",
1741
+ level: "error" as const,
1742
+ field,
1743
+ message: `Legacy choice API "${match}" is no longer supported. ${LEGACY_CHOICE_USAGE_MESSAGE}`,
1744
+ },
1745
+ ];
1746
+ });
1747
+ }
1748
+
1391
1749
  export function lintOperation(op: {
1392
1750
  descriptionKey?: string;
1393
1751
  whenToUseKeys?: readonly string[];
@@ -1541,6 +1899,9 @@ export function lintProvider(
1541
1899
  contract?: ProviderContractMetaLike;
1542
1900
  };
1543
1901
  reviewed?: string;
1902
+ /** Declared handle kinds (ADR-0012); read structurally, see readHandleKindDeclarations. */
1903
+ handle?: unknown;
1904
+ state?: unknown;
1544
1905
  },
1545
1906
  options: ProviderLintOptions = {},
1546
1907
  ): LintDiagnostic[] {
@@ -1562,6 +1923,8 @@ export function lintProviderWithInformation(
1562
1923
  ...lintSelfHostedBrowserPatterns(provider, options),
1563
1924
  ...lintBrowserVersionLiterals(provider),
1564
1925
  ...lintUndeclaredThrownErrorCodes(provider),
1926
+ ...lintLegacyChoiceUsage(provider),
1927
+ ...lintHandleDeclarations(provider),
1565
1928
  ];
1566
1929
 
1567
1930
  if (provider.operations) {
@@ -1591,11 +1954,7 @@ export function lintProviderWithInformation(
1591
1954
  // Every authenticated mode owns an auth.flow; `oauth2_proxied` was
1592
1955
  // previously exempt, which let auth-lifecycle operations ship on
1593
1956
  // proxied providers unchecked.
1594
- if (
1595
- authMode === "credentials" ||
1596
- authMode === "oauth2" ||
1597
- authMode === "oauth2_proxied"
1598
- ) {
1957
+ if (authMode === "credentials" || authMode === "oauth2" || authMode === "oauth2_proxied") {
1599
1958
  for (const operationKey of Object.keys(provider.operations)) {
1600
1959
  if (isAuthLifecycleOperationId(operationKey, authMode)) {
1601
1960
  diagnostics.push({
package/src/provider.ts CHANGED
@@ -18,14 +18,6 @@ export type {
18
18
  DefinedCredentialsAuth,
19
19
  } from "./auth.js";
20
20
  export { createFormCeremony } from "./ceremonies/index.js";
21
- export {
22
- assertFreshProviderChoiceIssuedAt,
23
- createProviderChoiceToken,
24
- ProviderChoiceTokenError,
25
- type ProviderChoiceTokenErrorReason,
26
- type ProviderChoiceTokenPayload,
27
- parseProviderChoiceToken,
28
- } from "./choice-token.js";
29
21
  export {
30
22
  centered,
31
23
  delayed,
@@ -161,12 +153,16 @@ export {
161
153
  providerLocaleKey,
162
154
  qualifyProviderLocaleKey,
163
155
  } from "./i18n/index.js";
156
+ export * from "./handle.js";
164
157
  export {
165
- type CreateProviderChoiceContextOptions,
166
- createProviderChoiceContext,
167
- createTestProviderChoiceContext,
168
- PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV,
169
- } from "./runtime/choice.js";
158
+ type CreateHandleContextOptions,
159
+ type CreateTestHandleContextOptions,
160
+ createHandleContext,
161
+ createTestHandleContext,
162
+ type HandleNormalizationClass,
163
+ type NormalizedHandle,
164
+ normalizeHandle,
165
+ } from "./runtime/handle.js";
170
166
  export {
171
167
  APIFUSE_CONTENT_PROVENANCE_META_KEY,
172
168
  APIFUSE_CONTENT_TRUST_META_KEY,
@@ -245,13 +241,6 @@ export type {
245
241
  OperationSensitivePath,
246
242
  OperationTransport,
247
243
  ProviderAccessVisibility,
248
- ProviderChoiceBindingOptions,
249
- ProviderChoiceConsumeMode,
250
- ProviderChoiceConsumeResult,
251
- ProviderChoiceContext,
252
- ProviderChoiceExplicitParseResult,
253
- ProviderChoiceIssueOptions,
254
- ProviderChoiceParseOptions,
255
244
  ProviderContext,
256
245
  ProviderContextFor,
257
246
  ProviderDefinition,