primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83

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 (109) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
  2. package/dist/src/commands/documents.js +455 -22
  3. package/dist/src/commands/documents.js.map +1 -1
  4. package/dist/src/commands/functions.js +889 -76
  5. package/dist/src/commands/functions.js.map +1 -1
  6. package/dist/src/commands/locks.js +15 -1
  7. package/dist/src/commands/locks.js.map +1 -1
  8. package/dist/src/commands/prompts.js +19 -2
  9. package/dist/src/commands/prompts.js.map +1 -1
  10. package/dist/src/commands/sync.d.ts +50 -0
  11. package/dist/src/commands/sync.js +587 -316
  12. package/dist/src/commands/sync.js.map +1 -1
  13. package/dist/src/commands/workflows.d.ts +1 -29
  14. package/dist/src/commands/workflows.js +8 -109
  15. package/dist/src/commands/workflows.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +191 -2
  17. package/dist/src/lib/api-client.js +283 -7
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +12 -4
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  22. package/dist/src/lib/document-ingest-artifact.js +505 -0
  23. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  24. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  25. package/dist/src/lib/document-ingest-input.js +132 -0
  26. package/dist/src/lib/document-ingest-input.js.map +1 -0
  27. package/dist/src/lib/document-ingest.d.ts +78 -0
  28. package/dist/src/lib/document-ingest.js +231 -0
  29. package/dist/src/lib/document-ingest.js.map +1 -0
  30. package/dist/src/lib/env-resolver-core.d.ts +2 -2
  31. package/dist/src/lib/env-resolver-core.js +2 -2
  32. package/dist/src/lib/function-bundle.d.ts +6 -0
  33. package/dist/src/lib/function-bundle.js +8 -0
  34. package/dist/src/lib/function-bundle.js.map +1 -1
  35. package/dist/src/lib/function-collect.d.ts +1 -1
  36. package/dist/src/lib/function-collect.js +16 -0
  37. package/dist/src/lib/function-collect.js.map +1 -1
  38. package/dist/src/lib/function-db-types.d.ts +4 -0
  39. package/dist/src/lib/function-db-types.js +46 -2
  40. package/dist/src/lib/function-db-types.js.map +1 -1
  41. package/dist/src/lib/function-document-types.d.ts +83 -0
  42. package/dist/src/lib/function-document-types.js +137 -0
  43. package/dist/src/lib/function-document-types.js.map +1 -1
  44. package/dist/src/lib/function-grants-preflight.js +5 -0
  45. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  46. package/dist/src/lib/function-log-lines.d.ts +76 -0
  47. package/dist/src/lib/function-log-lines.js +160 -0
  48. package/dist/src/lib/function-log-lines.js.map +1 -0
  49. package/dist/src/lib/function-log-row.d.ts +29 -0
  50. package/dist/src/lib/function-log-row.js +73 -0
  51. package/dist/src/lib/function-log-row.js.map +1 -0
  52. package/dist/src/lib/function-log-tail.d.ts +51 -2
  53. package/dist/src/lib/function-log-tail.js +160 -13
  54. package/dist/src/lib/function-log-tail.js.map +1 -1
  55. package/dist/src/lib/function-run.d.ts +289 -0
  56. package/dist/src/lib/function-run.js +389 -0
  57. package/dist/src/lib/function-run.js.map +1 -0
  58. package/dist/src/lib/function-schema-codegen.d.ts +17 -9
  59. package/dist/src/lib/function-schema-codegen.js +30 -29
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -30
  62. package/dist/src/lib/function-sync.js +731 -77
  63. package/dist/src/lib/function-sync.js.map +1 -1
  64. package/dist/src/lib/function-versions.d.ts +122 -0
  65. package/dist/src/lib/function-versions.js +182 -0
  66. package/dist/src/lib/function-versions.js.map +1 -0
  67. package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
  68. package/dist/src/lib/generated-config-surfaces.js +1858 -287
  69. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  70. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  71. package/dist/src/lib/generated-sdk-types.js +1 -1
  72. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  73. package/dist/src/lib/log-inspection.d.ts +2 -0
  74. package/dist/src/lib/log-inspection.js +4 -0
  75. package/dist/src/lib/log-inspection.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +15 -0
  77. package/dist/src/lib/output.js +28 -0
  78. package/dist/src/lib/output.js.map +1 -1
  79. package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
  80. package/dist/src/lib/prompt-schema-codegen.js +212 -0
  81. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  82. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  83. package/dist/src/lib/snapshot-audit-source.js +58 -0
  84. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  85. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  86. package/dist/src/lib/snapshot-audit-store.js +196 -0
  87. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  89. package/dist/src/lib/snapshot-audit.js +431 -0
  90. package/dist/src/lib/snapshot-audit.js.map +1 -0
  91. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  92. package/dist/src/lib/snapshot-build-rows.js +87 -0
  93. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  94. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  95. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  96. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  97. package/dist/src/lib/step-run-table.d.ts +43 -0
  98. package/dist/src/lib/step-run-table.js +129 -0
  99. package/dist/src/lib/step-run-table.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
  102. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  103. package/dist/src/lib/sync-selectors.d.ts +45 -2
  104. package/dist/src/lib/sync-selectors.js +66 -5
  105. package/dist/src/lib/sync-selectors.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
  107. package/dist/src/lib/workflow-toml-validator.js +18 -10
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/package.json +2 -2
@@ -38,9 +38,16 @@ export const UPDATE_ONLY = ["update"];
38
38
  * the definition never dispatches through it (#1976 decision of record); the
39
39
  * guard asserts the export exists so a renamed handler fails the CLI unit suite
40
40
  * instead of leaving a dangling reference (#2644 behavior 2b).
41
+ *
42
+ * `stage` is REQUIRED (#3375): a field handler that does not say when it runs
43
+ * makes the same empty promise the cross-field slot made before #3373 — it
44
+ * names an owner, and criterion 5 has nothing to check. A `preflight` handler
45
+ * is reached from `cli/src/commands/sync.ts#runConfigPushPreflight` before the
46
+ * first mutating call, which `findUnreachedPreflightRules` proves statically;
47
+ * an `apply` handler is everything else.
41
48
  */
42
- export function handledBy(modulePath, exportName) {
43
- return { handler: `${modulePath}#${exportName}` };
49
+ export function handledBy(modulePath, exportName, stage) {
50
+ return { handler: `${modulePath}#${exportName}`, stage };
44
51
  }
45
52
  /**
46
53
  * Name the ONE export that enforces a cross-field rule, as
@@ -1395,6 +1402,616 @@ export function crossFieldRulesForStage(surface, stage) {
1395
1402
  }
1396
1403
  return (surface.crossFieldRules ?? []).filter((rule) => rule.stage === stage);
1397
1404
  }
1405
+ // ── src/config-surface/preflight-reachability.ts ─────────────────────────
1406
+ /**
1407
+ * Is a rule declared `preflight` actually reached before `config push` mutates
1408
+ * anything? (issue #3375, project phase 3, intent §Success criteria 5.)
1409
+ *
1410
+ * #3373 gave a rule a `stage`; #3374 declared three surfaces' rules. Both left
1411
+ * the string a PROMISE. "This runs before the first mutating call" was
1412
+ * satisfied by typing `preflight` beside a rule that is not on the preflight
1413
+ * path at all, because the push preflight was an inline block inside the
1414
+ * command's `.action()` callback with no exported symbol — so "reached from the
1415
+ * preflight" had nothing to resolve against.
1416
+ *
1417
+ * #3375 extracts that block into `cli/src/commands/sync.ts#runConfigPushPreflight`
1418
+ * and this module answers the question statically, from the declared
1419
+ * `<module>#<export>`:
1420
+ *
1421
+ * - `reachableExports` walks the call graph from one entry symbol over module
1422
+ * SOURCE TEXT and returns what it reached — plus what it could NOT resolve,
1423
+ * so silence is never a pass.
1424
+ * - `findUnreachedPreflightRules` reports every `preflight` declaration,
1425
+ * cross-field or single-field, whose handler is missing from that set.
1426
+ * - `findMalformedFieldValidationStages` is the field-level twin of #3373's
1427
+ * `findMalformedRuleDeclarations`: `ConfigField["validation"]` gained a
1428
+ * `stage` here, and it must be well-formed to mean anything.
1429
+ *
1430
+ * Everything is a PURE function over values and over source text the caller
1431
+ * passes in: `cli/tests/unit/config-surface-preflight-staging-3375.test.ts`
1432
+ * does the filesystem walk, so this module stays vendorable (`types.ts`
1433
+ * §Purity), exactly the split `rules.ts` already uses.
1434
+ *
1435
+ * ── Two things this check is NOT ──────────────────────────────────────────
1436
+ *
1437
+ * It is NECESSARY, not sufficient, and there is deliberately no converse. A
1438
+ * handler reached on one surface's leg satisfies a `preflight` declaration on
1439
+ * another (the residual the intent accepts and #3379 records), and asking "is
1440
+ * this `apply` rule reached from the preflight?" would be wrong by
1441
+ * construction: `validateSigningSecretDeclaration` IS reached on the function
1442
+ * leg and IS correctly `apply` on the webhook surface, where no CLI call
1443
+ * exists.
1444
+ *
1445
+ * The walk is identifier-based over masked source, so it over-approximates in
1446
+ * the PASSING direction: a local variable shadowing an imported handler's name
1447
+ * reads as a call. That is the safe direction for a guard whose failure mode
1448
+ * must be "a promise you did not keep", not "a promise you did keep, reported
1449
+ * as broken".
1450
+ */
1451
+ /** A field's `validation` object may carry these keys and no others (#3375). */
1452
+ export const FIELD_VALIDATION_KEYS = ["handler", "stage"];
1453
+ const PREFLIGHT_IDENTIFIER = /[A-Za-z_$][\w$]*/g;
1454
+ const PREFLIGHT_DECLARATION = /^(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(function|class|const|let|var|type|interface|enum)\s*\*?\s+([A-Za-z_$][\w$]*)/;
1455
+ /** The `<module>` and `<export>` halves, or null when the reference is malformed. */
1456
+ function preflightSplitSymbol(symbol) {
1457
+ if (typeof symbol !== "string")
1458
+ return null;
1459
+ const [module, name, ...rest] = symbol.split("#");
1460
+ if (!module || !name || rest.length)
1461
+ return null;
1462
+ return { module, name };
1463
+ }
1464
+ /** Index of the `}`/`)`/`]` closing the bracket at `open`, or -1. */
1465
+ function preflightMatchingBracket(masked, open) {
1466
+ const pairs = { "[": "]", "{": "}", "(": ")" };
1467
+ const stack = [];
1468
+ for (let i = open; i < masked.length; i++) {
1469
+ const ch = masked[i];
1470
+ if (pairs[ch]) {
1471
+ stack.push(pairs[ch]);
1472
+ continue;
1473
+ }
1474
+ if (ch === "]" || ch === "}" || ch === ")") {
1475
+ if (!stack.length || stack.pop() !== ch)
1476
+ return -1;
1477
+ if (!stack.length)
1478
+ return i;
1479
+ }
1480
+ }
1481
+ return -1;
1482
+ }
1483
+ /**
1484
+ * A keyword that can only OPEN a top-level declaration, never continue an
1485
+ * expression: seeing one at column zero ends the statement before it.
1486
+ *
1487
+ * `async` and `abstract` are spelled with what must follow them, because both
1488
+ * can legitimately continue an expression across a line break; `function` and
1489
+ * `class` alone cannot start an indented continuation in a formatted file, and
1490
+ * the column-zero test below is what rules out the expression forms.
1491
+ */
1492
+ const PREFLIGHT_DECLARATION_OPENS = /^(?:export|import|declare|function|class|const|let|var|type|interface|enum|async\s+function|abstract\s+class)\b/;
1493
+ /**
1494
+ * End of a `const`/`let`/`var`/`type` statement that begins at `from`: the
1495
+ * first `;` outside any bracket, or the line break before the next top-level
1496
+ * declaration — whichever comes first.
1497
+ *
1498
+ * The second boundary is not optional. TypeScript's automatic semicolon
1499
+ * insertion makes `export const entry = () => 1` a complete statement, and a
1500
+ * scan that only ever stops at a `;` runs on through every following
1501
+ * declaration to the next one that has one — attributing an unrelated
1502
+ * apply-only function's body to this declaration, which is an
1503
+ * over-approximation in the PASSING direction: the staging guard would report
1504
+ * a handler as reached that the preflight never mentions. A depth-zero line
1505
+ * break followed by a declaration keyword at column zero is where a formatted
1506
+ * file puts the boundary, and an indented continuation line (`? x`, `: y`,
1507
+ * `| A`, `.then(…)`) never matches it.
1508
+ *
1509
+ * Falling back to the end of the module would let one missing semicolon
1510
+ * attribute the whole rest of the file to a single declaration — an
1511
+ * over-approximation so large the guard would pass on anything. The fallback is
1512
+ * the end of the line instead, which is what an unterminated statement looks
1513
+ * like in a formatted file.
1514
+ */
1515
+ function preflightStatementEnd(masked, from) {
1516
+ let depth = 0;
1517
+ for (let i = from; i < masked.length; i++) {
1518
+ const ch = masked[i];
1519
+ if (ch === "{" || ch === "(" || ch === "[")
1520
+ depth++;
1521
+ else if (ch === "}" || ch === ")" || ch === "]")
1522
+ depth--;
1523
+ else if (ch === ";" && depth <= 0)
1524
+ return i;
1525
+ else if (ch === "\n" && depth <= 0) {
1526
+ const next = preflightNextSignificant(masked, i + 1);
1527
+ if (next === -1)
1528
+ break;
1529
+ // Column zero only: a continuation line is indented, and a comment ahead
1530
+ // of the next declaration is already blanked to whitespace.
1531
+ if (masked[next - 1] !== "\n")
1532
+ continue;
1533
+ if (PREFLIGHT_DECLARATION_OPENS.test(masked.slice(next, next + 40))) {
1534
+ return i;
1535
+ }
1536
+ }
1537
+ }
1538
+ const newline = masked.indexOf("\n", from);
1539
+ return newline === -1 ? masked.length : newline;
1540
+ }
1541
+ /** Index of the next non-whitespace character at or after `from`, or -1. */
1542
+ function preflightNextSignificant(masked, from) {
1543
+ for (let i = from; i < masked.length; i++) {
1544
+ if (!/\s/.test(masked[i]))
1545
+ return i;
1546
+ }
1547
+ return -1;
1548
+ }
1549
+ /** Index of the previous non-whitespace character before `before`, or -1. */
1550
+ function preflightPreviousSignificant(masked, before) {
1551
+ for (let i = before - 1; i >= 0; i--) {
1552
+ if (!/\s/.test(masked[i]))
1553
+ return i;
1554
+ }
1555
+ return -1;
1556
+ }
1557
+ /**
1558
+ * The character ending the token before `before` — `"` when that token is a
1559
+ * string literal.
1560
+ *
1561
+ * The masker blanks a literal's QUOTES along with its contents, so a
1562
+ * literal type is invisible in the masked view and the search above walks
1563
+ * straight past it to whatever preceded it in the annotation. That matters for
1564
+ * exactly one decision: `function typeOfValue(v): Kind | "null" | "unknown" {`
1565
+ * masks to `… Kind | | {`, whose previous significant
1566
+ * character is a `|` — a type continuation — so the body would be read as an
1567
+ * object type, skipped whole, and the NEXT function's body taken as this one's.
1568
+ * The literal table restores the token the mask erased.
1569
+ */
1570
+ function preflightPreviousTokenEnd(masked, literals, before) {
1571
+ const previous = preflightPreviousSignificant(masked, before);
1572
+ const gapStart = previous + 1;
1573
+ for (const literal of literals) {
1574
+ if (literal.start >= gapStart && literal.end <= before)
1575
+ return '"';
1576
+ }
1577
+ return previous === -1 ? "" : masked[previous];
1578
+ }
1579
+ /**
1580
+ * A character that can only CONTINUE a type annotation, so a `{` right after it
1581
+ * opens an object type rather than a function body.
1582
+ */
1583
+ const PREFLIGHT_TYPE_CONTINUES = new Set([
1584
+ ":",
1585
+ "|",
1586
+ "&",
1587
+ ",",
1588
+ "(",
1589
+ "[",
1590
+ "<",
1591
+ "=",
1592
+ ]);
1593
+ /**
1594
+ * Index of the `{` that opens a function's BODY, given the offset just past its
1595
+ * name — or -1 when it has none (an overload signature, a `declare`).
1596
+ *
1597
+ * Not simply the next `{`. A parameter destructured into an inline object type
1598
+ * (`input: { configDir: string; … }`) and a return type naming one
1599
+ * (`Promise<{ parsedVars: … }>`) each open a brace first, and taking either
1600
+ * would end the declaration's span at the signature — after which every symbol
1601
+ * the body really calls is invisible, and a staging check whose walk reaches
1602
+ * nothing passes everything.
1603
+ *
1604
+ * So: match the parameter list, then read the return-type annotation off the
1605
+ * text. A `{` inside a generic argument, or one following a character that can
1606
+ * only continue a type, is skipped whole; the first other `{` is the body. A
1607
+ * `;` reached first means there is no body at all. A literal return type
1608
+ * (`: "ok" {`) is read through the lexer's literal table, because the mask
1609
+ * erased its quotes along with its contents.
1610
+ */
1611
+ function preflightFunctionBody(masked, literals, afterName) {
1612
+ const paren = masked.indexOf("(", afterName);
1613
+ if (paren === -1)
1614
+ return -1;
1615
+ const closeParen = preflightMatchingBracket(masked, paren);
1616
+ if (closeParen === -1)
1617
+ return -1;
1618
+ let angle = 0;
1619
+ for (let i = closeParen + 1; i < masked.length; i++) {
1620
+ const ch = masked[i];
1621
+ if (ch === ";")
1622
+ return -1; // an overload signature: no body follows
1623
+ if (ch === "<") {
1624
+ angle++;
1625
+ continue;
1626
+ }
1627
+ if (ch === ">" && masked[i - 1] !== "=") {
1628
+ angle = Math.max(0, angle - 1);
1629
+ continue;
1630
+ }
1631
+ if (ch !== "{")
1632
+ continue;
1633
+ const character = preflightPreviousTokenEnd(masked, literals, i);
1634
+ if (angle === 0 && !PREFLIGHT_TYPE_CONTINUES.has(character))
1635
+ return i;
1636
+ const close = preflightMatchingBracket(masked, i);
1637
+ if (close === -1)
1638
+ return -1;
1639
+ i = close;
1640
+ }
1641
+ return -1;
1642
+ }
1643
+ /** Every top-level declaration of a masked module, with its span. */
1644
+ function preflightTopLevelDeclarations(masked, literals) {
1645
+ const out = new Map();
1646
+ /** Names whose recorded span is a signature with no body, so far. */
1647
+ const signatureOnly = new Set();
1648
+ let i = 0;
1649
+ let depth = 0;
1650
+ while (i < masked.length) {
1651
+ const ch = masked[i];
1652
+ const boundary = i === 0 || !/[\w$]/.test(masked[i - 1]);
1653
+ if (depth === 0 && /[A-Za-z_$]/.test(ch) && boundary) {
1654
+ const match = PREFLIGHT_DECLARATION.exec(masked.slice(i, i + 200));
1655
+ if (match) {
1656
+ const kind = match[1];
1657
+ const name = match[2];
1658
+ const afterName = i + match[0].length;
1659
+ let end;
1660
+ let hasBody = true;
1661
+ if (kind === "function" ||
1662
+ kind === "class" ||
1663
+ kind === "interface" ||
1664
+ kind === "enum") {
1665
+ const brace = kind === "function"
1666
+ ? preflightFunctionBody(masked, literals, afterName)
1667
+ : masked.indexOf("{", afterName);
1668
+ const close = brace === -1 ? -1 : preflightMatchingBracket(masked, brace);
1669
+ hasBody = close !== -1;
1670
+ end = close === -1 ? preflightStatementEnd(masked, afterName) : close;
1671
+ }
1672
+ else {
1673
+ end = preflightStatementEnd(masked, afterName);
1674
+ }
1675
+ // The declaration with a BODY wins, whichever came first: a `declare`
1676
+ // or an overload signature ahead of the real one carries only types,
1677
+ // and letting it hold the name would hide every call the
1678
+ // implementation makes — the walk would report a genuinely reached
1679
+ // handler as unreached. Between two bodies the first still wins.
1680
+ if (!out.has(name) || (signatureOnly.has(name) && hasBody)) {
1681
+ out.set(name, { start: i, end });
1682
+ if (hasBody)
1683
+ signatureOnly.delete(name);
1684
+ else
1685
+ signatureOnly.add(name);
1686
+ }
1687
+ i = end + 1;
1688
+ continue;
1689
+ }
1690
+ }
1691
+ if (ch === "{" || ch === "(" || ch === "[")
1692
+ depth++;
1693
+ else if (ch === "}" || ch === ")" || ch === "]")
1694
+ depth = Math.max(0, depth - 1);
1695
+ i++;
1696
+ }
1697
+ return out;
1698
+ }
1699
+ /**
1700
+ * Read one module's imports, re-exports and declarations.
1701
+ *
1702
+ * The string CONTENTS are blanked in the masked view — that is what stops a
1703
+ * name inside a comment or a message from reading as a call — so a module
1704
+ * specifier is recovered from the lexer's literal table by position rather than
1705
+ * from the masked text.
1706
+ */
1707
+ function preflightModuleFacts(source) {
1708
+ const { masked, strings } = lexRuleModuleSource(source);
1709
+ const literalAfter = (index) => {
1710
+ for (const literal of strings) {
1711
+ if (literal.start >= index)
1712
+ return literal.value;
1713
+ }
1714
+ return null;
1715
+ };
1716
+ const imports = new Map();
1717
+ const namespaces = new Map();
1718
+ const namedReExports = new Map();
1719
+ const starReExports = [];
1720
+ /**
1721
+ * `{ a, b as c, type D }` → the value bindings it introduces, as
1722
+ * (name on THIS side, name on the OTHER module's side) pairs. The same
1723
+ * shape reads an import clause and a re-export clause, which are mirrors.
1724
+ */
1725
+ const readClause = (clause, onBinding) => {
1726
+ for (const piece of clause.split(",")) {
1727
+ const parts = piece.trim().split(/\s+/).filter(Boolean);
1728
+ if (!parts.length || parts[0] === "type")
1729
+ continue; // `{ type X }`
1730
+ const there = parts[0];
1731
+ const here = parts.length >= 3 && parts[1] === "as" ? parts[2] : there;
1732
+ onBinding(here, there);
1733
+ }
1734
+ };
1735
+ // Anchored at the start of a line, and stopping at a `;`: a static import is
1736
+ // always top-level, and an unanchored `\bimport\b` would let a dynamic
1737
+ // `await import(…)` deep inside a function run on to some unrelated `from`
1738
+ // and invent a binding out of whichever string literal came next.
1739
+ for (const match of masked.matchAll(/^import\b([^;]*?)\bfrom\b/gm)) {
1740
+ const clause = match[1];
1741
+ if (/^\s*type\b/.test(clause))
1742
+ continue; // `import type { … } from`
1743
+ const specifier = literalAfter((match.index ?? 0) + match[0].length);
1744
+ if (!specifier)
1745
+ continue;
1746
+ const namespace = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
1747
+ if (namespace)
1748
+ namespaces.set(namespace[1], specifier);
1749
+ const named = /\{([^}]*)\}/.exec(clause);
1750
+ if (named) {
1751
+ readClause(named[1], (here, there) => {
1752
+ imports.set(here, { specifier, imported: there });
1753
+ });
1754
+ }
1755
+ }
1756
+ for (const match of masked.matchAll(/^export\s+(type\s+)?\{([^}]*)\}\s*from\b/gm)) {
1757
+ if (match[1])
1758
+ continue; // `export type { … } from` re-exports no value
1759
+ const specifier = literalAfter((match.index ?? 0) + match[0].length);
1760
+ if (!specifier)
1761
+ continue;
1762
+ readClause(match[2], (here, there) => {
1763
+ namedReExports.set(here, { specifier, local: there });
1764
+ });
1765
+ }
1766
+ for (const match of masked.matchAll(/^export\s*\*\s*from\b/gm)) {
1767
+ const specifier = literalAfter((match.index ?? 0) + match[0].length);
1768
+ if (specifier)
1769
+ starReExports.push(specifier);
1770
+ }
1771
+ return {
1772
+ masked,
1773
+ declarations: preflightTopLevelDeclarations(masked, strings),
1774
+ imports,
1775
+ namespaces,
1776
+ namedReExports,
1777
+ starReExports,
1778
+ };
1779
+ }
1780
+ /**
1781
+ * Walk the call graph from `entry` and return every symbol it reaches.
1782
+ *
1783
+ * An identifier inside a reached declaration's span is followed when it names
1784
+ * another top-level declaration of the same module, or a value import — which
1785
+ * resolves through the graph to whichever candidate module DECLARES the name,
1786
+ * following `export { x } from` and `export * from` on the way. A symbol is
1787
+ * recorded under the module that declares it, never under a barrel that merely
1788
+ * re-exports it: a declaration that names the barrel is reported unreached,
1789
+ * because the point of the string is to name the one implementation.
1790
+ */
1791
+ export function reachableExports(entry, graph) {
1792
+ const reached = new Set();
1793
+ const unresolved = new Set();
1794
+ const facts = new Map();
1795
+ const queued = new Set();
1796
+ const queue = [];
1797
+ const factsFor = (module) => {
1798
+ const source = graph.modules.get(module);
1799
+ if (source === undefined)
1800
+ return null;
1801
+ let cached = facts.get(module);
1802
+ if (!cached) {
1803
+ cached = preflightModuleFacts(source);
1804
+ facts.set(module, cached);
1805
+ }
1806
+ return cached;
1807
+ };
1808
+ /** Candidate modules for a specifier, reporting a dangling relative one. */
1809
+ const candidates = (from, specifier) => {
1810
+ const resolved = graph.resolve(from, specifier);
1811
+ if (resolved === null)
1812
+ return []; // a package: not followed, not reported
1813
+ if (resolved.length === 0) {
1814
+ unresolved.add(`${from}: "${specifier}" resolves to no module in the graph`);
1815
+ }
1816
+ return resolved;
1817
+ };
1818
+ /**
1819
+ * Where `name` is really DECLARED, starting from `module` — following named
1820
+ * and star re-exports. `[]` when nothing in reach declares it.
1821
+ */
1822
+ const declaringSymbols = (module, name, seen) => {
1823
+ const key = `${module}#${name}`;
1824
+ if (seen.has(key))
1825
+ return [];
1826
+ seen.add(key);
1827
+ const moduleFacts = factsFor(module);
1828
+ if (!moduleFacts)
1829
+ return [];
1830
+ if (moduleFacts.declarations.has(name))
1831
+ return [key];
1832
+ const out = [];
1833
+ const named = moduleFacts.namedReExports.get(name);
1834
+ if (named) {
1835
+ for (const candidate of candidates(module, named.specifier)) {
1836
+ out.push(...declaringSymbols(candidate, named.local, seen));
1837
+ }
1838
+ }
1839
+ for (const specifier of moduleFacts.starReExports) {
1840
+ for (const candidate of candidates(module, specifier)) {
1841
+ out.push(...declaringSymbols(candidate, name, seen));
1842
+ }
1843
+ }
1844
+ return out;
1845
+ };
1846
+ const enqueue = (symbol) => {
1847
+ if (queued.has(symbol))
1848
+ return;
1849
+ queued.add(symbol);
1850
+ queue.push(symbol);
1851
+ };
1852
+ /** Follow an imported binding to the symbol(s) it really names. */
1853
+ const followImport = (module, specifier, imported) => {
1854
+ for (const candidate of candidates(module, specifier)) {
1855
+ for (const symbol of declaringSymbols(candidate, imported, new Set())) {
1856
+ enqueue(symbol);
1857
+ }
1858
+ }
1859
+ };
1860
+ const parsedEntry = preflightSplitSymbol(entry);
1861
+ if (!parsedEntry) {
1862
+ return {
1863
+ reached,
1864
+ unresolved: [
1865
+ `"${String(entry)}" is not a "<module>#<export>" reference`,
1866
+ ],
1867
+ };
1868
+ }
1869
+ const entrySymbols = declaringSymbols(parsedEntry.module, parsedEntry.name, new Set());
1870
+ if (entrySymbols.length === 0) {
1871
+ unresolved.add(`${entry}: no module in the graph declares or re-exports it — the ` +
1872
+ "entry point was renamed, un-exported, or never added to the graph");
1873
+ }
1874
+ for (const symbol of entrySymbols)
1875
+ enqueue(symbol);
1876
+ while (queue.length) {
1877
+ const symbol = queue.shift();
1878
+ const parsed = preflightSplitSymbol(symbol);
1879
+ const moduleFacts = factsFor(parsed.module);
1880
+ if (!moduleFacts)
1881
+ continue;
1882
+ const span = moduleFacts.declarations.get(parsed.name);
1883
+ if (!span)
1884
+ continue;
1885
+ reached.add(symbol);
1886
+ const body = moduleFacts.masked.slice(span.start, span.end);
1887
+ // `ns.member` — the namespace binding's own name says nothing, so the
1888
+ // member is read off the text rather than off the identifier stream.
1889
+ for (const [local, specifier] of moduleFacts.namespaces) {
1890
+ const member = new RegExp(`\\b${local}\\s*\\.\\s*([A-Za-z_$][\\w$]*)`, "g");
1891
+ for (const hit of body.matchAll(member)) {
1892
+ followImport(parsed.module, specifier, hit[1]);
1893
+ }
1894
+ }
1895
+ for (const hit of body.matchAll(PREFLIGHT_IDENTIFIER)) {
1896
+ const name = hit[0];
1897
+ if (name === parsed.name)
1898
+ continue;
1899
+ // A property access is not a reference to a top-level name; `ns.member`
1900
+ // above is the one case where it is, and it is handled there. Walked
1901
+ // backwards rather than by slicing, so a long declaration costs the
1902
+ // walk linear time and not quadratic.
1903
+ let back = (hit.index ?? 0) - 1;
1904
+ while (back >= 0 && /\s/.test(body[back]))
1905
+ back--;
1906
+ if (back >= 0 && body[back] === ".")
1907
+ continue;
1908
+ if (moduleFacts.declarations.has(name)) {
1909
+ enqueue(`${parsed.module}#${name}`);
1910
+ continue;
1911
+ }
1912
+ const imported = moduleFacts.imports.get(name);
1913
+ if (imported) {
1914
+ followImport(parsed.module, imported.specifier, imported.imported);
1915
+ }
1916
+ }
1917
+ }
1918
+ return { reached, unresolved: [...unresolved].sort() };
1919
+ }
1920
+ /** The handler's `<module>` half, or "" when the reference is malformed. */
1921
+ function preflightHandlerModule(handler) {
1922
+ const parsed = preflightSplitSymbol(handler);
1923
+ return parsed ? parsed.module : "";
1924
+ }
1925
+ /**
1926
+ * Every fault in one surface's FIELD validation declarations, each naming the
1927
+ * surface and the field. `[]` means every field is well-formed.
1928
+ *
1929
+ * Runs over every surface in the registry, migrated or not: field declarations
1930
+ * exist independently of the cross-field slot, so the
1931
+ * `CROSS_FIELD_RULES_UNMIGRATED` ratchet does not — and must not — stand
1932
+ * between a field-level `preflight` declaration and this check (DSO-3375-001).
1933
+ */
1934
+ export function findMalformedFieldValidationStages(surface) {
1935
+ const out = [];
1936
+ for (const table of surface.tables ?? []) {
1937
+ for (const field of table.fields ?? []) {
1938
+ const validation = field.validation;
1939
+ if (validation === "passthrough")
1940
+ continue;
1941
+ const where = `${surface.label}.${field.field}`;
1942
+ if (!validation || typeof validation !== "object") {
1943
+ out.push(`${where}: \`validation\` must be "passthrough" or ` +
1944
+ "`{ handler, stage }`");
1945
+ continue;
1946
+ }
1947
+ const keys = Object.keys(validation).sort();
1948
+ const expected = [...FIELD_VALIDATION_KEYS].sort();
1949
+ if (keys.join(",") !== expected.join(",")) {
1950
+ out.push(`${where}: \`validation\` declares ${keys.join(", ") || "no keys"} — ` +
1951
+ `exactly ${expected.join(" and ")}, and nothing else: the slot ` +
1952
+ "classifies, it does not carry behavior knobs");
1953
+ }
1954
+ const handler = validation.handler;
1955
+ const modulePath = preflightHandlerModule(handler);
1956
+ if (!modulePath) {
1957
+ out.push(`${where}: handler ${JSON.stringify(handler)} is not a ` +
1958
+ '"<module>#<export>" reference');
1959
+ }
1960
+ const stage = validation.stage;
1961
+ if (!CROSS_FIELD_RULE_STAGES.includes(stage)) {
1962
+ out.push(`${where}: stage ${JSON.stringify(stage)} is not one of ` +
1963
+ CROSS_FIELD_RULE_STAGES.join(", ") +
1964
+ " — a field handler says when it runs");
1965
+ }
1966
+ else if (stage === "preflight" &&
1967
+ modulePath &&
1968
+ !modulePath.startsWith(PREFLIGHT_HANDLER_MODULE_PREFIX)) {
1969
+ out.push(`${where}: a \`preflight\` handler is vendored into the CLI, so its ` +
1970
+ `module lives under ${PREFLIGHT_HANDLER_MODULE_PREFIX} — ` +
1971
+ `"${modulePath}" does not`);
1972
+ }
1973
+ }
1974
+ }
1975
+ return out.sort();
1976
+ }
1977
+ /**
1978
+ * Every `preflight` declaration on this surface whose handler the walk did not
1979
+ * reach, each naming the surface, the rule (or field), the handler and the
1980
+ * entry point.
1981
+ *
1982
+ * The FIELD half runs on every surface. The CROSS-FIELD half runs only where a
1983
+ * surface has declared, because `crossFieldRulesForStage` throws for one that
1984
+ * has not — "not declared yet" is never "rule-free" (#3373), and asking it
1985
+ * here would turn an unmigrated surface into a crash rather than a skip.
1986
+ */
1987
+ export function findUnreachedPreflightRules(surface, reached, entry) {
1988
+ const out = [];
1989
+ const unreached = (subject, handler) => `${surface.label}: ${subject} is declared \`preflight\`, but its handler ` +
1990
+ `${handler} is not reached from ${entry} — a preflight rule runs before ` +
1991
+ "the first mutating call, or it is not preflight";
1992
+ for (const table of surface.tables ?? []) {
1993
+ for (const field of table.fields ?? []) {
1994
+ const validation = field.validation;
1995
+ if (!validation || typeof validation !== "object")
1996
+ continue;
1997
+ const { handler, stage } = validation;
1998
+ if (stage !== "preflight")
1999
+ continue;
2000
+ const reference = String(handler);
2001
+ if (reached.has(reference))
2002
+ continue;
2003
+ out.push(unreached(`field \`${field.field}\``, reference));
2004
+ }
2005
+ }
2006
+ if (hasDeclaredCrossFieldRules(surface)) {
2007
+ for (const rule of crossFieldRulesForStage(surface, "preflight")) {
2008
+ if (reached.has(rule.handler))
2009
+ continue;
2010
+ out.push(unreached(`rule "${rule.id}"`, rule.handler));
2011
+ }
2012
+ }
2013
+ return out.sort();
2014
+ }
1398
2015
  /**
1399
2016
  * Protocol keys every config UPDATE accepts, whichever object it is.
1400
2017
  *
@@ -2467,92 +3084,170 @@ export function parseFunctionManifest(value) {
2467
3084
  errors: [],
2468
3085
  };
2469
3086
  }
2470
- export const FUNCTION_MODES = ["request", "task"];
3087
+ // ── src/config-surface/function-retired-keys.ts ──────────────────────────
3088
+ /**
3089
+ * The runtime keys a `functions/<key>.toml` no longer decides anything with —
3090
+ * #3482, project `server-functions` phase 4.
3091
+ *
3092
+ * The sponsor settled it on 2026-09-15 (the intent, "Mode vocabulary?",
3093
+ * amended again): the config says NOTHING about how a function runs. A
3094
+ * function is a function; the caller picks the runtime at each call, cron
3095
+ * fires always start a task run, webhook deliveries always run as a request,
3096
+ * and a function that must not run under one runtime tests for it in code with
3097
+ * `assertRuntime`.
3098
+ *
3099
+ * ── Why these keys are accepted, not refused ──────────────────────────────
3100
+ *
3101
+ * Every tree pushed since #3454 that declares a cron trigger carries
3102
+ * `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it;
3103
+ * plenty carry a top-level `mode` or the older `durable`. Removing the keys
3104
+ * from the accepted set outright would refuse those trees with an unknown-key
3105
+ * error one day after the platform insisted on them, which is the opposite of
3106
+ * principle 5 inside a non-breaking phase.
3107
+ *
3108
+ * So through phase 4 they are ACCEPTED and IGNORED, with one warning per key
3109
+ * naming it and the line to delete; from phase 5 (#3188) they are refused with
3110
+ * the rest of the retirement matrix. The value is not validated — there is
3111
+ * nothing left to validate it against, and refusing a bad value for a key
3112
+ * nobody reads would be a refusal about spelling alone.
3113
+ *
3114
+ * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). Both
3115
+ * the CLI preflight (vendored by `cli/scripts/gen-config-surfaces.mjs`) and the
3116
+ * admin config-version route read this one scanner, so the warning an author
3117
+ * sees from `config push` is the sentence the API answers with.
3118
+ */
3119
+ /** The two `[function]` keys that no longer say anything (#3482). */
3120
+ export const RETIRED_FUNCTION_RUNTIME_KEYS = ["mode", "durable"];
3121
+ const RUNTIME_IS_THE_CALLERS = "a function is a function, and the caller picks the runtime at each call — " +
3122
+ "`invoke` runs it inside the request, `start` runs it as a task";
3123
+ const FROM_PHASE_5 = "Remove the line; from phase 5 it is refused.";
2471
3124
  /**
2472
- * Resolve `mode` and its alias `durable` to one mode.
3125
+ * Every retired runtime key a `[function]` table still carries.
3126
+ *
3127
+ * Reads the table the way the author wrote it: the two top-level keys, and a
3128
+ * `mode` on each `[[function.triggers.cron]]` entry. A cron entry's sentence
3129
+ * says the extra thing its author needs to hear — that the schedule no longer
3130
+ * chooses, because every fire starts a task run (#3334's runner branch is
3131
+ * gone).
2473
3132
  *
2474
- * Absent both → `request`. `mode` must be exactly `"request"` or `"task"`;
2475
- * `durable` must be a boolean. Both present and disagreeing is refused naming
2476
- * both keys, so an author who half-migrated a file hears which line to fix.
3133
+ * Order is the order an author reads their file in: the top-level keys first,
3134
+ * then the cron entries in declaration order.
2477
3135
  */
2478
- export function resolveDeclaredMode(input) {
2479
- const rawMode = input?.mode;
2480
- const rawDurable = input?.durable;
2481
- const hasMode = rawMode !== undefined && rawMode !== null;
2482
- const hasDurable = rawDurable !== undefined && rawDurable !== null;
2483
- let fromMode = null;
2484
- if (hasMode) {
2485
- if (typeof rawMode !== "string" || !FUNCTION_MODES.includes(rawMode)) {
2486
- return {
2487
- ok: false,
2488
- error: `\`mode\` must be "request" or "task" (got ${describe(rawMode)}): a ` +
2489
- "request function runs inside the call and returns its result; a " +
2490
- "task function returns a run id and survives restarts",
2491
- };
2492
- }
2493
- fromMode = rawMode;
2494
- }
2495
- let fromDurable = null;
2496
- if (hasDurable) {
2497
- if (typeof rawDurable !== "boolean") {
2498
- return {
2499
- ok: false,
2500
- error: `\`durable\` must be true or false (got ${describe(rawDurable)}); it is ` +
2501
- 'the accepted alias for `mode = "task"` / `mode = "request"`',
2502
- };
2503
- }
2504
- fromDurable = rawDurable ? "task" : "request";
2505
- }
2506
- if (fromMode && fromDurable && fromMode !== fromDurable) {
2507
- return {
2508
- ok: false,
2509
- error: `\`mode = "${fromMode}"\` and \`durable = ${String(rawDurable)}\` disagree: ` +
2510
- '`durable` is the accepted alias for `mode = "task"` (true) and ' +
2511
- '`mode = "request"` (false), so keep one key, or make the two agree',
2512
- };
3136
+ export function ignoredFunctionKeys(functionTable) {
3137
+ const table = functionTable;
3138
+ if (!table || typeof table !== "object" || Array.isArray(table))
3139
+ return [];
3140
+ const found = [];
3141
+ for (const key of RETIRED_FUNCTION_RUNTIME_KEYS) {
3142
+ if (table[key] === undefined)
3143
+ continue;
3144
+ found.push({
3145
+ path: key,
3146
+ message: `\`${key}\` is ignored (#3482): ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3147
+ });
2513
3148
  }
2514
- const mode = fromMode ?? fromDurable ?? "request";
2515
- return { ok: true, mode, declared: hasMode || hasDurable };
3149
+ const triggers = table.triggers;
3150
+ const rawCron = triggers && typeof triggers === "object" && !Array.isArray(triggers)
3151
+ ? triggers.cron
3152
+ : undefined;
3153
+ const entries = Array.isArray(rawCron)
3154
+ ? rawCron
3155
+ : rawCron && typeof rawCron === "object"
3156
+ ? [rawCron]
3157
+ : [];
3158
+ entries.forEach((entry, index) => {
3159
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3160
+ return;
3161
+ if (entry.mode === undefined)
3162
+ return;
3163
+ const rawName = entry.name;
3164
+ const name = typeof rawName === "string" && rawName ? rawName : `#${index + 1}`;
3165
+ found.push({
3166
+ path: `triggers.cron.${name}.mode`,
3167
+ message: `\`mode\` on cron trigger '${name}' is ignored (#3482): every cron ` +
3168
+ `fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3169
+ });
3170
+ });
3171
+ return found;
2516
3172
  }
2517
3173
  /**
2518
- * The mode a stored config-version row runs in.
2519
- *
2520
- * The column is `durable`; a row written before #3281 carries only that, and a
2521
- * row written after it carries the same thing, so there is exactly one reading.
3174
+ * The database types a `[function]` table's retired trigger block names —
3175
+ * #3483.
3176
+ *
3177
+ * Beside `ignoredFunctionKeys` because it answers the same question about the
3178
+ * same table, for the same reader: `config pull` writes a version's AUTHORED
3179
+ * bytes, and a version pushed before the removal carries the block. Without
3180
+ * this the pulled tree would be refused on its next push — a tree an author
3181
+ * cannot get back into a working state, which is the failure D3482-007 already
3182
+ * had to close for the runtime keys.
3183
+ *
3184
+ * This is NOT a refusal and shares nothing with one. The block is refused on
3185
+ * input by `normalizeTriggerDeclaration`; what a strip needs is the list of
3186
+ * entries it is taking out so it can say so, which is why an entry whose
3187
+ * `type` is unreadable still counts as one entry (the empty string). It is the
3188
+ * block's PRESENCE that goes, not its contents.
3189
+ *
3190
+ * TOML spells the block three ways — an array of tables, a single table, and
3191
+ * an inline array — and a scan that missed one would leave a key behind
3192
+ * (CR3482-002's lesson, applied to a whole table).
2522
3193
  */
2523
- export function configMode(row) {
2524
- return row?.durable === true ? "task" : "request";
3194
+ export function retiredDatabaseTriggerTypes(functionTable) {
3195
+ const raw = retiredDatabaseTriggerValue(functionTable);
3196
+ if (raw === undefined || raw === null)
3197
+ return [];
3198
+ const entries = Array.isArray(raw) ? raw : [raw];
3199
+ return entries.map((entry) => {
3200
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
3201
+ return "";
3202
+ const type = entry.type;
3203
+ return typeof type === "string" ? type : String(type ?? "");
3204
+ });
2525
3205
  }
2526
- /** Is this mode the durable one? The adjective, for the paths that branch on it. */
2527
- export function isDurableMode(mode) {
2528
- return mode === "task";
3206
+ /**
3207
+ * Whether the block is THERE at all — the question a strip has to ask, which
3208
+ * the type list above cannot answer (CR3483-001).
3209
+ *
3210
+ * `database = []` names no type, so the list is empty and a strip that decided
3211
+ * by its length left the key in the pulled file. That file then fails its own
3212
+ * next push: `normalizeTriggerDeclaration` refuses the key by PRESENCE, of any
3213
+ * shape, an empty array included — so the two halves disagreed about what the
3214
+ * block is, and the tree an author pulled was a tree they could not push.
3215
+ * Presence is the one rule both sides read now, spelled the way
3216
+ * `normalizeTriggerDeclaration` spells it — `!== undefined` — so a value one
3217
+ * of them would call a block and the other would not cannot exist.
3218
+ */
3219
+ export function hasRetiredDatabaseTriggerBlock(functionTable) {
3220
+ return retiredDatabaseTriggerValue(functionTable) !== undefined;
2529
3221
  }
2530
- function describe(value) {
2531
- if (typeof value === "string")
2532
- return JSON.stringify(value);
2533
- if (value === null)
2534
- return "null";
2535
- if (typeof value === "object")
2536
- return Array.isArray(value) ? "an array" : "a table";
2537
- return String(value);
3222
+ /**
3223
+ * The raw `[function.triggers].database` value, or `undefined` when the block
3224
+ * is absent. One walk down to the key, so presence and contents can never be
3225
+ * read off two different traversals.
3226
+ */
3227
+ function retiredDatabaseTriggerValue(functionTable) {
3228
+ const table = functionTable;
3229
+ if (!table || typeof table !== "object" || Array.isArray(table)) {
3230
+ return undefined;
3231
+ }
3232
+ const triggers = table.triggers;
3233
+ if (!triggers || typeof triggers !== "object" || Array.isArray(triggers)) {
3234
+ return undefined;
3235
+ }
3236
+ return triggers.database;
2538
3237
  }
2539
3238
  /**
2540
- * Why a webhook cannot drive a task function, and what to do instead (#3334).
3239
+ * The same scan, rendered for one file — the shape `config push` and
3240
+ * `config diff` print and the admin route answers with.
2541
3241
  *
2542
- * The first clause is verbatim what it has always been, so sibling pins on the
2543
- * request-mode prefix (#3281's) keep holding; everything after the dash is the
2544
- * half that was missing — an author who meets this at the push door could not
2545
- * previously tell whether it was a limitation or a design, nor what to write.
3242
+ * `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
3243
+ * and the function's key from the API, which has no file to name.
2546
3244
  */
2547
- export const WEBHOOK_ON_TASK_REFUSAL = "webhook triggers require request mode: set `mode = \"request\"` (or " +
2548
- "remove `mode = \"task\"`) — a webhook delivery needs its answer inside the " +
2549
- "request, and a task run has none to give there: verify and acknowledge in " +
2550
- "the request function, then hand the work to a task function with " +
2551
- "ctx.functions.start — or remove [function.triggers.webhook]";
3245
+ export function ignoredFunctionKeyWarnings(label, functionTable) {
3246
+ return ignoredFunctionKeys(functionTable).map((ignored) => `${label}: ${ignored.message}`);
3247
+ }
2552
3248
  export const EMPTY_TRIGGER_DECLARATION = {
2553
3249
  webhook: null,
2554
3250
  cron: [],
2555
- databases: [],
2556
3251
  };
2557
3252
  /**
2558
3253
  * The most cron entries one function may declare.
@@ -2570,22 +3265,6 @@ export const MAX_CRON_TRIGGERS_PER_FUNCTION = 10;
2570
3265
  * collide with (the intent, "Key namespace?").
2571
3266
  */
2572
3267
  const CRON_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
2573
- /**
2574
- * The most database types one function may watch (#3184, D-07).
2575
- *
2576
- * Per-OBJECT, like the cron cap above: a function watching more than a handful
2577
- * of types is describing a fan-in the platform cannot bound per write, and
2578
- * every extra type is another discovery read on somebody's write path.
2579
- */
2580
- export const MAX_DATABASE_TRIGGERS_PER_FUNCTION = 5;
2581
- /**
2582
- * A database type key, matching the charset the grant grammar already uses for
2583
- * the same value (`src/config-surface/function-grants.ts`). One rule for one
2584
- * concept: `database:<type>/<model>:read` and `[[function.triggers.database]]`
2585
- * name the same thing.
2586
- */
2587
- const DATABASE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
2588
- const DATABASE_KEYS = new Set(["type"]);
2589
3268
  const WEBHOOK_KEYS = new Set([
2590
3269
  "verificationScheme",
2591
3270
  "signingSecret",
@@ -2599,18 +3278,67 @@ const WEBHOOK_KEYS = new Set([
2599
3278
  const CRON_KEYS = new Set([
2600
3279
  "name",
2601
3280
  "cron",
3281
+ // #3482 — RETIRED and still accepted. `cron-trigger-mode-required` demanded
3282
+ // this key on every default-mode function since #3454, so every tree pushed
3283
+ // with a cron entry since then carries it; dropping it from the accepted set
3284
+ // would refuse those trees with an unknown-key error one day after the
3285
+ // platform insisted on it (D3482-006). Ignored, warned about at the push
3286
+ // door, and refused from phase 5.
3287
+ "mode",
2602
3288
  "timezone",
2603
3289
  "overlapPolicy",
2604
3290
  "rootInput",
2605
3291
  ]);
2606
3292
  const VALID_OVERLAP_POLICIES = ["skip", "allow"];
3293
+ /**
3294
+ * The rule ids this module owns — one per refusal, declared on the `function`
3295
+ * surface (#3374). The union below closes `refuseTrigger`'s id parameter, so a
3296
+ * twenty-seventh refusal cannot compile without a twenty-seventh id, and that
3297
+ * id fails `cli-unit-tests` until the surface declares it (#3373's anchor).
3298
+ */
3299
+ export const TRIGGER_RULE_IDS = [
3300
+ "triggers-table",
3301
+ "trigger-kind",
3302
+ // #3483 — one refusal replaced the eight that shaped the block. There is no
3303
+ // shape left to get right; the block itself is what is refused.
3304
+ "database-trigger-removed",
3305
+ "webhook-trigger-single",
3306
+ "webhook-trigger-table",
3307
+ "webhook-trigger-keys",
3308
+ "webhook-trigger-scheme-required",
3309
+ "webhook-trigger-scheme-none",
3310
+ "webhook-trigger-number",
3311
+ "webhook-trigger-dedup-boolean",
3312
+ "webhook-trigger-verification-table",
3313
+ "cron-trigger-cap",
3314
+ "cron-trigger-entry",
3315
+ "cron-trigger-keys",
3316
+ "cron-trigger-name-required",
3317
+ "cron-trigger-name-charset",
3318
+ "cron-trigger-duplicate",
3319
+ "cron-trigger-expression-required",
3320
+ "cron-trigger-overlap-policy",
3321
+ ];
3322
+ /**
3323
+ * A broken trigger rule. `ruleId` names which one, for a reader that wants to
3324
+ * branch without matching prose; every existing site reads `message` only.
3325
+ * Optional so `new TriggerDeclarationError(message)` — the CLI's own parse
3326
+ * refusal in `function-sync.ts` — keeps constructing.
3327
+ */
2607
3328
  export class TriggerDeclarationError extends Error {
3329
+ ruleId;
3330
+ constructor(message, ruleId) {
3331
+ super(message);
3332
+ this.ruleId = ruleId;
3333
+ }
3334
+ }
3335
+ /** The one `throw` in this module: every refusal names its rule here. */
3336
+ function refuseTrigger(ruleId, message) {
3337
+ throw new TriggerDeclarationError(message, ruleId);
2608
3338
  }
2609
3339
  /** Is anything at all declared? Used to skip work on the common case. */
2610
3340
  export function hasDeclaredTriggers(declaration) {
2611
- return (declaration.webhook !== null ||
2612
- declaration.cron.length > 0 ||
2613
- declaration.databases.length > 0);
3341
+ return declaration.webhook !== null || declaration.cron.length > 0;
2614
3342
  }
2615
3343
  /**
2616
3344
  * Normalize the `triggers` table of an already-parsed `[function]` table.
@@ -2621,114 +3349,72 @@ export function hasDeclaredTriggers(declaration) {
2621
3349
  * push boundary can normalize a declaration it read from a stored row without
2622
3350
  * re-parsing bytes.
2623
3351
  */
2624
- export function normalizeTriggerDeclaration(functionTable, options = {}) {
3352
+ export function normalizeTriggerDeclaration(functionTable) {
2625
3353
  const triggers = functionTable?.triggers;
2626
3354
  if (triggers === undefined || triggers === null) {
2627
3355
  return EMPTY_TRIGGER_DECLARATION;
2628
3356
  }
2629
3357
  if (typeof triggers !== "object" || Array.isArray(triggers)) {
2630
- throw new TriggerDeclarationError("[function.triggers] must be a table declaring `webhook` and/or `cron`");
3358
+ refuseTrigger("triggers-table", "[function.triggers] must be a table declaring `webhook` and/or `cron`");
3359
+ }
3360
+ // #3483 — checked BEFORE the unknown-kind rule, so an author who declares
3361
+ // the block hears why it went rather than that `database` is not a word the
3362
+ // platform knows. Any shape of the key is the same refusal: it is the
3363
+ // block's PRESENCE that is refused, and an author who wrote
3364
+ // `triggers.database = []` still has a line to delete.
3365
+ if (triggers.database !== undefined) {
3366
+ refuseTrigger("database-trigger-removed", DATABASE_TRIGGER_REMOVED);
2631
3367
  }
2632
3368
  for (const key of Object.keys(triggers)) {
2633
- if (key !== "webhook" && key !== "cron" && key !== "database") {
2634
- throw new TriggerDeclarationError(`unknown trigger kind '${key}' under [function.triggers]: the kinds are ` +
2635
- "`webhook`, `cron` and `database`");
3369
+ if (key !== "webhook" && key !== "cron") {
3370
+ refuseTrigger("trigger-kind", `unknown trigger kind '${key}' under [function.triggers]: the kinds are ` +
3371
+ "`webhook` and `cron`");
2636
3372
  }
2637
3373
  }
2638
- const webhook = normalizeWebhook(triggers.webhook, options);
2639
- const cron = normalizeCron(triggers.cron, options);
2640
- const databases = normalizeDatabases(triggers.database, options);
2641
- return { webhook, cron, databases };
3374
+ const webhook = normalizeWebhook(triggers.webhook);
3375
+ const cron = normalizeCron(triggers.cron);
3376
+ return { webhook, cron };
2642
3377
  }
2643
3378
  /**
2644
- * `[[function.triggers.database]]` — one accepted key, `type` (#3184, D-07).
2645
- *
2646
- * Sync mode only, permanently: a database-change fire runs INSIDE the write
2647
- * request, best-effort, with no run row (the intent, "Sync functions write a
2648
- * run row?"), and there is no durable shape for that. This is the same
2649
- * permanent refusal `webhook` carries, not the release-scoped one `cron` does.
3379
+ * The sentence an author who declared the block is owed — D3483-001.
3380
+ *
3381
+ * It names the pattern that replaces the trigger rather than only saying the
3382
+ * trigger is gone, because the author's problem is not "how do I spell this"
3383
+ * but "where does this work go now". The answer is: into the handler that
3384
+ * writes the row. Database access moves behind server functions (phase 6
3385
+ * retires the client's direct database paths), so the code that writes a
3386
+ * record is already a function — a trigger fired by that write has no caller
3387
+ * it cannot already see.
2650
3388
  */
2651
- function normalizeDatabases(raw, options) {
2652
- if (raw === undefined || raw === null)
2653
- return [];
2654
- const entries = Array.isArray(raw) ? raw : [raw];
2655
- if (entries.length === 0)
2656
- return [];
2657
- if (options.mode === "task") {
2658
- throw new TriggerDeclarationError("database triggers require request mode: set `mode = \"request\"` (or " +
2659
- "remove `mode = \"task\"`), or remove the [[function.triggers.database]] " +
2660
- "entries");
2661
- }
2662
- if (entries.length > MAX_DATABASE_TRIGGERS_PER_FUNCTION) {
2663
- throw new TriggerDeclarationError(`a function may watch at most ${MAX_DATABASE_TRIGGERS_PER_FUNCTION} database ` +
2664
- `types, and this one declares ${entries.length}`);
2665
- }
2666
- const seen = new Set();
2667
- const normalized = [];
2668
- for (const entry of entries) {
2669
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
2670
- throw new TriggerDeclarationError("each [[function.triggers.database]] entry must be a table");
2671
- }
2672
- const table = entry;
2673
- for (const key of Object.keys(table)) {
2674
- if (!DATABASE_KEYS.has(key)) {
2675
- throw new TriggerDeclarationError(`unknown key '${key}' in a [[function.triggers.database]] entry: the ` +
2676
- `only accepted key is ${[...DATABASE_KEYS].join(", ")}`);
2677
- }
2678
- }
2679
- const type = String(table.type ?? "").trim();
2680
- if (!type) {
2681
- throw new TriggerDeclarationError("each [[function.triggers.database]] entry needs a `type`: it names the " +
2682
- "database type whose writes fire the function");
2683
- }
2684
- if (!DATABASE_TYPE_PATTERN.test(type)) {
2685
- throw new TriggerDeclarationError(`database trigger type '${type}' must contain only letters, digits, ` +
2686
- "'-' or '_'");
2687
- }
2688
- if (seen.has(type)) {
2689
- throw new TriggerDeclarationError(`duplicate database trigger type '${type}': a function fires once per ` +
2690
- "write request, so each type is declared once");
2691
- }
2692
- seen.add(type);
2693
- normalized.push({ type });
2694
- }
2695
- // Sorted, like the cron entries: two files that differ only in the order
2696
- // they list their blocks describe the same triggers.
2697
- normalized.sort((a, b) => a.type.localeCompare(b.type));
2698
- return normalized;
2699
- }
2700
- function normalizeWebhook(raw, options) {
3389
+ const DATABASE_TRIGGER_REMOVED = "[[function.triggers.database]] is no longer supported (#3483): database " +
3390
+ "access moves behind functions, so the code that writes the record is a " +
3391
+ "function — do the follow-up in the handler that writes (write the row, " +
3392
+ "then publish, notify or write again), or hand longer work to a task with " +
3393
+ "ctx.functions.start. Remove the block.";
3394
+ function normalizeWebhook(raw) {
2701
3395
  if (raw === undefined || raw === null)
2702
3396
  return null;
2703
3397
  if (Array.isArray(raw)) {
2704
3398
  // `[[function.triggers.webhook]]` — the shape that would declare two.
2705
- throw new TriggerDeclarationError("[function.triggers.webhook] is a single table: one webhook trigger per " +
3399
+ refuseTrigger("webhook-trigger-single", "[function.triggers.webhook] is a single table: one webhook trigger per " +
2706
3400
  "function (the intent, \"Webhook trigger URL and scheme?\")");
2707
3401
  }
2708
3402
  if (typeof raw !== "object") {
2709
- throw new TriggerDeclarationError("[function.triggers.webhook] must be a table");
3403
+ refuseTrigger("webhook-trigger-table", "[function.triggers.webhook] must be a table");
2710
3404
  }
2711
3405
  const table = raw;
2712
3406
  for (const key of Object.keys(table)) {
2713
3407
  if (!WEBHOOK_KEYS.has(key)) {
2714
- throw new TriggerDeclarationError(`unknown key '${key}' under [function.triggers.webhook]: accepted keys ` +
3408
+ refuseTrigger("webhook-trigger-keys", `unknown key '${key}' under [function.triggers.webhook]: accepted keys ` +
2715
3409
  `are ${[...WEBHOOK_KEYS].join(", ")}`);
2716
3410
  }
2717
3411
  }
2718
- // The intent forbids this permanently: a webhook fires the function inside
2719
- // the receiver, and the provider is holding the connection open waiting for
2720
- // its answer. A task run has no answer to give inside that request — it
2721
- // returns a run id and finishes later — so the refusal says so and names
2722
- // the pattern that does the same job (#3334).
2723
- if (options.mode === "task") {
2724
- throw new TriggerDeclarationError(WEBHOOK_ON_TASK_REFUSAL);
2725
- }
2726
3412
  const scheme = String(table.verificationScheme ?? "").trim();
2727
3413
  if (!scheme) {
2728
- throw new TriggerDeclarationError("[function.triggers.webhook] verificationScheme is required");
3414
+ refuseTrigger("webhook-trigger-scheme-required", "[function.triggers.webhook] verificationScheme is required");
2729
3415
  }
2730
3416
  if (scheme === "none") {
2731
- throw new TriggerDeclarationError("verification scheme 'none' is not allowed for function triggers: an " +
3417
+ refuseTrigger("webhook-trigger-scheme-none", "verification scheme 'none' is not allowed for function triggers: an " +
2732
3418
  "unauthenticated caller could run the function");
2733
3419
  }
2734
3420
  const declaration = { verificationScheme: scheme };
@@ -2745,13 +3431,13 @@ function normalizeWebhook(raw, options) {
2745
3431
  continue;
2746
3432
  const value = Number(table[key]);
2747
3433
  if (!Number.isFinite(value)) {
2748
- throw new TriggerDeclarationError(`[function.triggers.webhook] ${key} must be a number`);
3434
+ refuseTrigger("webhook-trigger-number", `[function.triggers.webhook] ${key} must be a number`);
2749
3435
  }
2750
3436
  declaration[key] = value;
2751
3437
  }
2752
3438
  if (table.deduplicationEnabled !== undefined) {
2753
3439
  if (typeof table.deduplicationEnabled !== "boolean") {
2754
- throw new TriggerDeclarationError("[function.triggers.webhook] deduplicationEnabled must be a boolean");
3440
+ refuseTrigger("webhook-trigger-dedup-boolean", "[function.triggers.webhook] deduplicationEnabled must be a boolean");
2755
3441
  }
2756
3442
  declaration.deduplicationEnabled = table.deduplicationEnabled;
2757
3443
  }
@@ -2760,58 +3446,56 @@ function normalizeWebhook(raw, options) {
2760
3446
  if (verification === null ||
2761
3447
  typeof verification !== "object" ||
2762
3448
  Array.isArray(verification)) {
2763
- throw new TriggerDeclarationError("[function.triggers.webhook.verification] must be a table");
3449
+ refuseTrigger("webhook-trigger-verification-table", "[function.triggers.webhook.verification] must be a table");
2764
3450
  }
2765
3451
  declaration.verification = jsonSafe(verification);
2766
3452
  }
2767
3453
  return declaration;
2768
3454
  }
2769
- function normalizeCron(raw, options) {
3455
+ function normalizeCron(raw) {
2770
3456
  if (raw === undefined || raw === null)
2771
3457
  return [];
2772
3458
  const entries = Array.isArray(raw) ? raw : [raw];
2773
3459
  if (entries.length === 0)
2774
3460
  return [];
2775
- // No mode check: a cron entry is legal in EITHER mode (#3334). The intent
2776
- // allows it ("Sync functions write a run row?": *HTTP and cron allow both*),
2777
- // and the cron DO starts a durable run for a task target. #3181's refusal
2778
- // here was release-scoped, written before task starts existed, and its own
2779
- // comment asked for exactly this one-line removal.
2780
3461
  if (entries.length > MAX_CRON_TRIGGERS_PER_FUNCTION) {
2781
- throw new TriggerDeclarationError(`a function may declare at most ${MAX_CRON_TRIGGERS_PER_FUNCTION} cron ` +
3462
+ refuseTrigger("cron-trigger-cap", `a function may declare at most ${MAX_CRON_TRIGGERS_PER_FUNCTION} cron ` +
2782
3463
  `trigger entries, and this one declares ${entries.length}`);
2783
3464
  }
2784
3465
  const seen = new Set();
2785
3466
  const normalized = [];
2786
3467
  for (const entry of entries) {
2787
3468
  if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
2788
- throw new TriggerDeclarationError("each [[function.triggers.cron]] entry must be a table");
3469
+ refuseTrigger("cron-trigger-entry", "each [[function.triggers.cron]] entry must be a table");
2789
3470
  }
2790
3471
  const table = entry;
2791
3472
  for (const key of Object.keys(table)) {
2792
3473
  if (!CRON_KEYS.has(key)) {
2793
- throw new TriggerDeclarationError(`unknown key '${key}' in a [[function.triggers.cron]] entry: accepted ` +
3474
+ refuseTrigger("cron-trigger-keys", `unknown key '${key}' in a [[function.triggers.cron]] entry: accepted ` +
2794
3475
  `keys are ${[...CRON_KEYS].join(", ")}`);
2795
3476
  }
2796
3477
  }
2797
3478
  const name = String(table.name ?? "").trim();
2798
3479
  if (!name) {
2799
- throw new TriggerDeclarationError("each [[function.triggers.cron]] entry needs a `name`: it is required " +
3480
+ refuseTrigger("cron-trigger-name-required", "each [[function.triggers.cron]] entry needs a `name`: it is required " +
2800
3481
  "because it is half of the trigger's key, `<functionKey>:<name>`");
2801
3482
  }
2802
3483
  if (!CRON_NAME_PATTERN.test(name)) {
2803
- throw new TriggerDeclarationError(`cron trigger name '${name}' must start with a letter or digit and ` +
3484
+ refuseTrigger("cron-trigger-name-charset", `cron trigger name '${name}' must start with a letter or digit and ` +
2804
3485
  "contain only letters, digits, '-' or '_'");
2805
3486
  }
2806
3487
  if (seen.has(name)) {
2807
- throw new TriggerDeclarationError(`duplicate cron trigger name '${name}': names are the trigger's key ` +
3488
+ refuseTrigger("cron-trigger-duplicate", `duplicate cron trigger name '${name}': names are the trigger's key ` +
2808
3489
  "within the function, so each must appear once");
2809
3490
  }
2810
3491
  seen.add(name);
2811
3492
  const cron = String(table.cron ?? "").trim();
2812
3493
  if (!cron) {
2813
- throw new TriggerDeclarationError(`cron trigger '${name}' needs a \`cron\` expression`);
3494
+ refuseTrigger("cron-trigger-expression-required", `cron trigger '${name}' needs a \`cron\` expression`);
2814
3495
  }
3496
+ // #3482 — no runner branch here any more. Every cron fire starts a task
3497
+ // run, so an entry's `mode` decides nothing; it is accepted (D3482-006),
3498
+ // reported as ignored by `ignoredFunctionKeys`, and dropped.
2815
3499
  const declaration = { name, cron };
2816
3500
  if (table.timezone !== undefined) {
2817
3501
  declaration.timezone = String(table.timezone);
@@ -2819,7 +3503,7 @@ function normalizeCron(raw, options) {
2819
3503
  if (table.overlapPolicy !== undefined) {
2820
3504
  const policy = String(table.overlapPolicy);
2821
3505
  if (!VALID_OVERLAP_POLICIES.includes(policy)) {
2822
- throw new TriggerDeclarationError(`cron trigger '${name}': overlapPolicy must be one of ` +
3506
+ refuseTrigger("cron-trigger-overlap-policy", `cron trigger '${name}': overlapPolicy must be one of ` +
2823
3507
  VALID_OVERLAP_POLICIES.join(", "));
2824
3508
  }
2825
3509
  declaration.overlapPolicy = policy;
@@ -2880,15 +3564,39 @@ export function parseStoredTriggerDeclaration(stored) {
2880
3564
  return {
2881
3565
  webhook: parsed?.webhook ?? null,
2882
3566
  cron: Array.isArray(parsed?.cron) ? parsed.cron : [],
2883
- // Absent in every row written before #3184, which is exactly what an
2884
- // empty list means: that version watches no database type.
2885
- databases: Array.isArray(parsed?.databases) ? parsed.databases : [],
3567
+ // #3483 — a stored `databases` key is DROPPED, not read. A version
3568
+ // pushed before the refusal existed still carries one in its immutable
3569
+ // JSON, and the row is never rewritten; answering nothing at all is what
3570
+ // makes that version inert at every reader at once.
3571
+ // `storedDatabaseTriggerTypes` below is how a caller that wants to SAY
3572
+ // something about it still can.
2886
3573
  };
2887
3574
  }
2888
3575
  catch {
2889
3576
  return EMPTY_TRIGGER_DECLARATION;
2890
3577
  }
2891
3578
  }
3579
+ /**
3580
+ * The database types a STORED declaration used to watch, for a reader that
3581
+ * wants to name them — #3483.
3582
+ *
3583
+ * Pure, and separate from the parse on purpose: the parse answers what the
3584
+ * version means NOW (nothing), and this answers what it said, which is only
3585
+ * ever used to write one log line. Nothing branches on it.
3586
+ */
3587
+ export function storedDatabaseTriggerTypes(stored) {
3588
+ if (typeof stored !== "string" || !stored.trim())
3589
+ return [];
3590
+ try {
3591
+ const parsed = JSON.parse(stored);
3592
+ if (!Array.isArray(parsed?.databases))
3593
+ return [];
3594
+ return parsed.databases.map((entry) => String(entry?.type ?? ""));
3595
+ }
3596
+ catch {
3597
+ return [];
3598
+ }
3599
+ }
2892
3600
  /** The `CronTrigger.triggerKey` an entry owns. */
2893
3601
  export function cronTriggerKeyFor(functionKey, name) {
2894
3602
  return `${functionKey}:${name}`;
@@ -3141,6 +3849,27 @@ export function requiresSigningSecret(scheme) {
3141
3849
  /** The coded refusals, so a client can branch without matching prose. */
3142
3850
  export const SIGNING_SECRET_MUST_BE_SECRET_REF = "SIGNING_SECRET_MUST_BE_SECRET_REF";
3143
3851
  export const SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME = "SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME";
3852
+ /**
3853
+ * The rule ids this module owns, declared on the `function` surface as
3854
+ * `preflight` and on the `webhook` surface as `apply` (#3374) — one rule, two
3855
+ * surfaces, one handler. `whole-secret-reference` is generic on purpose:
3856
+ * `validateWholeSecretReference` is reused by the Google client secret and by
3857
+ * both webhook controllers under their own labels and codes.
3858
+ */
3859
+ export const SIGNING_SECRET_RULE_IDS = [
3860
+ "signing-secret-required",
3861
+ "whole-secret-reference",
3862
+ "signing-secret-not-supported",
3863
+ ];
3864
+ /** The one refusal constructor (#3373's anchor). */
3865
+ function refuseSigningSecret(ruleId, message, extra = {}) {
3866
+ const refusal = { ruleId, message };
3867
+ if (extra.code !== undefined)
3868
+ refusal.code = extra.code;
3869
+ if (extra.plain !== undefined)
3870
+ refusal.plain = extra.plain;
3871
+ return refusal;
3872
+ }
3144
3873
  /** The reference-only message, shared by every field that carries one. */
3145
3874
  export function wholeSecretReferenceMessage(label) {
3146
3875
  return (`${label} must be a whole {{secrets.KEY}} reference to an app secret, not ` +
@@ -3159,7 +3888,7 @@ export function wholeSecretReferenceMessage(label) {
3159
3888
  export function validateWholeSecretReference(label, value, code) {
3160
3889
  if (typeof value === "string" && isWholeSecretReference(value))
3161
3890
  return null;
3162
- return { message: wholeSecretReferenceMessage(label), code };
3891
+ return refuseSigningSecret("whole-secret-reference", wholeSecretReferenceMessage(label), { code });
3163
3892
  }
3164
3893
  /**
3165
3894
  * The whole file-decidable `signingSecret` rule, in the order the standalone
@@ -3175,21 +3904,67 @@ export function validateSigningSecretDeclaration(input) {
3175
3904
  const scheme = input.verificationScheme;
3176
3905
  if (requiresSigningSecret(scheme)) {
3177
3906
  if (!input.signingSecret) {
3178
- return {
3179
- message: `signingSecret is required for verification scheme '${scheme}'`,
3180
- plain: true,
3181
- };
3907
+ return refuseSigningSecret("signing-secret-required", `signingSecret is required for verification scheme '${scheme}'`, { plain: true });
3182
3908
  }
3183
3909
  return validateWholeSecretReference("signingSecret", input.signingSecret, SIGNING_SECRET_MUST_BE_SECRET_REF);
3184
3910
  }
3185
3911
  if (input.signingSecret !== undefined &&
3186
3912
  input.signingSecret !== null &&
3187
3913
  input.signingSecret !== "") {
3188
- return {
3189
- message: `signingSecret is not supported for verification scheme '${scheme}' — ` +
3190
- "its key material comes from the scheme's config.",
3191
- code: SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME,
3192
- };
3914
+ return refuseSigningSecret("signing-secret-not-supported", `signingSecret is not supported for verification scheme '${scheme}' — ` +
3915
+ "its key material comes from the scheme's config.", { code: SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME });
3916
+ }
3917
+ return null;
3918
+ }
3919
+ // ── src/config-surface/workflow-rules.ts ─────────────────────────────────
3920
+ /**
3921
+ * The workflow rules two runners share (#3374, project `cli-server-rule-parity`
3922
+ * phase 2; intent §Success criteria 3).
3923
+ *
3924
+ * ── One predicate, two messages ──────────────────────────────────────────
3925
+ *
3926
+ * `runAs = "system"` beside a non-empty `accessRule` is dead config: a system
3927
+ * run can only be started by the system and never evaluates its access rule
3928
+ * (#1258, broadening #1172). The server refused it at save time
3929
+ * (`validateWorkflowIdentityConfig`) and the CLI refused it in the push
3930
+ * preflight (`validateWorkflowIdentity`), and the two carried the conjunction
3931
+ * byte-for-byte as separate copies kept in step by a comment. This module is
3932
+ * the one copy: it decides, and returns a STRUCTURED violation — the rule id
3933
+ * and the server's sentence — that each site renders in its own words. The
3934
+ * server pushes the sentence into its `string[]`; the CLI keeps its own hint,
3935
+ * which names the preflight remedy. Neither message changed.
3936
+ *
3937
+ * ── Why a `*-rules.ts` module ────────────────────────────────────────────
3938
+ *
3939
+ * The registry's unclaimed-export check (`findUnclaimedRuleModuleExports`)
3940
+ * keys on this name under `src/config-surface/`: every exported function here
3941
+ * is a rule, so one no surface declares fails `cli-unit-tests`. Shared
3942
+ * predicates that are not rules live elsewhere (`webhook-signing-secret.ts`).
3943
+ * The module is vendored into the CLI artifact like the rest of the
3944
+ * directory, which is what lets `workflow`'s declaration call the rule
3945
+ * `preflight`.
3946
+ */
3947
+ /** The rule ids this module owns — the union type below anchors them (#3373). */
3948
+ export const WORKFLOW_RULE_IDS = ["system-access-rule"];
3949
+ /** The one refusal constructor: a new branch cannot compile without an id. */
3950
+ function refuseWorkflowRule(ruleId, message) {
3951
+ return { ruleId, message };
3952
+ }
3953
+ /**
3954
+ * `runAs = "system"` with a non-empty string `accessRule`, or null.
3955
+ *
3956
+ * Exactly the conjunction both sites carried: a default, unset, empty or
3957
+ * whitespace-only rule is no rule, and a non-string one (`accessRule = false`)
3958
+ * is malformed input for another check to name, not dead config. The message
3959
+ * is the server's save-time sentence; the CLI renders its own hint from the
3960
+ * same answer.
3961
+ */
3962
+ export function checkWorkflowSystemAccessRule(input) {
3963
+ const { runAs, accessRule } = input;
3964
+ if (runAs === "system" &&
3965
+ typeof accessRule === "string" &&
3966
+ accessRule.trim() !== "") {
3967
+ return refuseWorkflowRule("system-access-rule", 'runAs:"system" workflows do not evaluate accessRule — remove the accessRule');
3193
3968
  }
3194
3969
  return null;
3195
3970
  }
@@ -3210,7 +3985,20 @@ export function validateSigningSecretDeclaration(input) {
3210
3985
  * (`cli/src/lib/workflow-payload.ts`, #1177), both deleted with this change.
3211
3986
  */
3212
3987
  const HANDLERS = "src/workflows/config/workflow-field-handlers.ts";
3213
- const handler = (name) => handledBy(HANDLERS, name);
3988
+ const handler = (name) => handledBy(HANDLERS, name, "apply");
3989
+ /**
3990
+ * The save-time rules the server runs and the CLI does not (#3374). They need
3991
+ * the runner's vocabulary (`isValidRunAs`, `GRANTABLE_SENSITIVE_CAPS`), which
3992
+ * this directory cannot import, so they are `apply`; the module carries the
3993
+ * rule-id anchor and `definition-store.ts` re-exports it.
3994
+ */
3995
+ const WORKFLOW_SAVE_RULES = "src/workflows/config/workflow-save-rules.ts";
3996
+ const workflowApplyRule = (id, exportName, description) => ({
3997
+ id,
3998
+ description,
3999
+ stage: "apply",
4000
+ handler: enforcedBy(WORKFLOW_SAVE_RULES, exportName),
4001
+ });
3214
4002
  /**
3215
4003
  * The five per-app / per-user queue limits (#1177). All share one handler: a
3216
4004
  * finite non-negative number, floored.
@@ -3524,7 +4312,464 @@ export const WORKFLOW_SURFACE = {
3524
4312
  "tables), expanded into `steps` by expandWorkflowTomlData before push.",
3525
4313
  },
3526
4314
  },
4315
+ // ── Cross-field rules (#3374) ─────────────────────────────────────────────
4316
+ //
4317
+ // One rule the CLI's push preflight runs — through the vendored copy of
4318
+ // `workflow-rules.ts` — and seven the server runs at save time. What stays
4319
+ // UNDECLARED, on purpose: the step-body validation (`validateWorkflowDefinition`
4320
+ // and the `steps` structural sub-tree, which is not a field-table rule), and
4321
+ // the enable-time activation lint (`workflow-activation-lint.ts`, a transition
4322
+ // check rather than a write rule). `runAs`'s VALUE is checked twice today —
4323
+ // by its field handler and again inside the identity validator — which is
4324
+ // stated here as a finding, not fixed.
4325
+ crossFieldRules: [
4326
+ {
4327
+ id: "system-access-rule",
4328
+ description: 'A `runAs = "system"` workflow may not carry a non-empty `accessRule`: ' +
4329
+ "a system run never evaluates its rule, so any rule there is dead " +
4330
+ "config. The one predicate both runners share; each renders its own " +
4331
+ "message.",
4332
+ stage: "preflight",
4333
+ handler: enforcedBy("src/config-surface/workflow-rules.ts", "checkWorkflowSystemAccessRule"),
4334
+ },
4335
+ workflowApplyRule("run-as-value", "validateWorkflowIdentityConfig", '`runAs` must be "caller" or "system" when present — the runner has no ' +
4336
+ "third identity."),
4337
+ workflowApplyRule("capabilities-shape", "validateWorkflowIdentityConfig", "`capabilities` must be an array or set of capability strings."),
4338
+ workflowApplyRule("capability-grantable", "validateWorkflowIdentityConfig", "Every declared capability must be one the runner can grant; the " +
4339
+ "always-on data baseline is never declared."),
4340
+ workflowApplyRule("iterate-users-system-only", "validateWorkflowIdentityConfig", 'An `iterate-users` step may appear only in a `runAs = "system"` ' +
4341
+ "workflow — fanning out over every user is not a member-as-themselves " +
4342
+ "operation."),
4343
+ workflowApplyRule("access-rule-type", "validateWorkflowIdentityConfig", "On create, a supplied `accessRule` must be a string — a non-string is " +
4344
+ "malformed input, reported as such rather than as an omission."),
4345
+ workflowApplyRule("access-rule-required", "validateWorkflowIdentityConfig", "On create, a caller-mode workflow must state its `accessRule` (`\"true\"` " +
4346
+ "for any app member), so a locked workflow is a decision rather than a " +
4347
+ "default."),
4348
+ workflowApplyRule("sync-callable-step-kind", "validateSyncCallableSteps", "A `syncCallable = true` workflow may not carry a step kind that only " +
4349
+ "the durable runtime can execute (`delay`, `event.wait`)."),
4350
+ ],
3527
4351
  };
4352
+ const FIELD_RANGES = [
4353
+ { name: "minute", min: 0, max: 59 },
4354
+ { name: "hour", min: 0, max: 23 },
4355
+ { name: "day-of-month", min: 1, max: 31 },
4356
+ { name: "month", min: 1, max: 12 },
4357
+ { name: "day-of-week", min: 0, max: 6 },
4358
+ ];
4359
+ function parseField(field, min, max, fieldName) {
4360
+ const result = new Set();
4361
+ const parts = field.split(",");
4362
+ for (const partRaw of parts) {
4363
+ const part = partRaw.trim();
4364
+ if (!part) {
4365
+ throw new Error(`Invalid ${fieldName} field: empty segment`);
4366
+ }
4367
+ let step = 1;
4368
+ let rangePart = part;
4369
+ const slashIdx = part.indexOf("/");
4370
+ if (slashIdx !== -1) {
4371
+ rangePart = part.slice(0, slashIdx);
4372
+ const stepStr = part.slice(slashIdx + 1);
4373
+ const stepNum = Number(stepStr);
4374
+ if (!Number.isInteger(stepNum) || stepNum <= 0) {
4375
+ throw new Error(`Invalid ${fieldName} step: "${stepStr}"`);
4376
+ }
4377
+ step = stepNum;
4378
+ }
4379
+ let lo;
4380
+ let hi;
4381
+ if (rangePart === "*") {
4382
+ lo = min;
4383
+ hi = max;
4384
+ }
4385
+ else if (rangePart.includes("-")) {
4386
+ const [loStr, hiStr] = rangePart.split("-");
4387
+ lo = Number(loStr);
4388
+ hi = Number(hiStr);
4389
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
4390
+ throw new Error(`Invalid ${fieldName} range: "${rangePart}"`);
4391
+ }
4392
+ }
4393
+ else {
4394
+ const exact = Number(rangePart);
4395
+ if (!Number.isInteger(exact)) {
4396
+ throw new Error(`Invalid ${fieldName} value: "${rangePart}"`);
4397
+ }
4398
+ lo = exact;
4399
+ hi = exact;
4400
+ }
4401
+ // Normalize day-of-week 7 -> 0 (Sunday). We only do this for bare
4402
+ // values, not inside a range — `5-7` would otherwise become `5-0`
4403
+ // which then fails the `lo > hi` check with a confusing error. Users
4404
+ // who want Fri–Sun should write `5-0,0` or `5,6,0`; we reject 7 in
4405
+ // ranges outright with a clearer message.
4406
+ if (fieldName === "day-of-week") {
4407
+ const isRange = rangePart.includes("-");
4408
+ if (isRange && (lo === 7 || hi === 7)) {
4409
+ throw new Error(`Invalid day-of-week range "${rangePart}": 7 (Sunday) is only allowed as a single value, not inside a range. Use 0 for Sunday in ranges.`);
4410
+ }
4411
+ if (!isRange) {
4412
+ if (lo === 7)
4413
+ lo = 0;
4414
+ if (hi === 7)
4415
+ hi = 0;
4416
+ }
4417
+ }
4418
+ if (lo < min || hi > max || lo > hi) {
4419
+ throw new Error(`Invalid ${fieldName} range: ${lo}-${hi} (allowed ${min}-${max})`);
4420
+ }
4421
+ for (let v = lo; v <= hi; v += step) {
4422
+ result.add(v);
4423
+ }
4424
+ }
4425
+ return result;
4426
+ }
4427
+ export function parseCron(expr) {
4428
+ if (!expr || typeof expr !== "string") {
4429
+ throw new Error("Cron expression must be a non-empty string");
4430
+ }
4431
+ const fields = expr.trim().split(/\s+/);
4432
+ if (fields.length !== 5) {
4433
+ throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
4434
+ }
4435
+ const [minField, hourField, domField, monField, dowField] = fields;
4436
+ const minutes = parseField(minField, FIELD_RANGES[0].min, FIELD_RANGES[0].max, "minute");
4437
+ const hours = parseField(hourField, FIELD_RANGES[1].min, FIELD_RANGES[1].max, "hour");
4438
+ const daysOfMonth = parseField(domField, FIELD_RANGES[2].min, FIELD_RANGES[2].max, "day-of-month");
4439
+ const months = parseField(monField, FIELD_RANGES[3].min, FIELD_RANGES[3].max, "month");
4440
+ const daysOfWeek = parseField(dowField, FIELD_RANGES[4].min, FIELD_RANGES[4].max, "day-of-week");
4441
+ return {
4442
+ minutes,
4443
+ hours,
4444
+ daysOfMonth,
4445
+ months,
4446
+ daysOfWeek,
4447
+ dayOfMonthWild: domField === "*",
4448
+ dayOfWeekWild: dowField === "*",
4449
+ };
4450
+ }
4451
+ // ─── The two predicates both runners call ───────────────────────────────
4452
+ /**
4453
+ * The message `parseCron` would throw for `expr`, or null when it parses.
4454
+ *
4455
+ * The same grammar, asked as a question instead of as an exception, because
4456
+ * both callers want the sentence rather than the control flow: the CLI's push
4457
+ * preflight collects one message per broken entry, and the standalone cron
4458
+ * routes render theirs into a 400 body. `expr` is typed `unknown` because it
4459
+ * arrives as an authored TOML value, where it can be anything at all.
4460
+ */
4461
+ export function cronExpressionError(expr) {
4462
+ try {
4463
+ parseCron(expr);
4464
+ return null;
4465
+ }
4466
+ catch (err) {
4467
+ return err?.message || String(err);
4468
+ }
4469
+ }
4470
+ /**
4471
+ * Whether `Intl.DateTimeFormat` accepts `name` as a timezone.
4472
+ *
4473
+ * This is THE timezone check — before #3376 the identical probe was written
4474
+ * out three times (the function push boundary and the standalone create and
4475
+ * update). Each of those sites keeps its own sentence and its own defaulting;
4476
+ * what they now share is this answer. A non-string is false rather than a
4477
+ * throw, for the same reason as above.
4478
+ */
4479
+ export function isValidTimezone(name) {
4480
+ try {
4481
+ new Intl.DateTimeFormat("en-US", { timeZone: name });
4482
+ return true;
4483
+ }
4484
+ catch {
4485
+ return false;
4486
+ }
4487
+ }
4488
+ // ── src/config-surface/cron-rules.ts ─────────────────────────────────────
4489
+ /**
4490
+ * The cron schedule rules two runners share (#3376, project
4491
+ * `cli-server-rule-parity` phase 4; intent §Success criteria 6).
4492
+ *
4493
+ * ── One implementation, two runners ──────────────────────────────────────
4494
+ *
4495
+ * A cron expression the grammar refuses, and a timezone `Intl` does not know,
4496
+ * are both decidable from the authored file. Before this issue only the SERVER
4497
+ * decided them, inside the apply loop: a `functions/<key>.toml` carrying
4498
+ * either passed `config push`'s preflight, sibling entities were written, and
4499
+ * the function alone was refused — while the command promises that a refusal
4500
+ * means nothing was applied. Now `cli/src/lib/function-sync.ts` and
4501
+ * `src/services/function-trigger-validation.ts` both call the rule below, and
4502
+ * the sentence an author reads is the same on either side of the wire because
4503
+ * it is built in one place.
4504
+ *
4505
+ * ── Why this is a `*-rules.ts` module and the parser is not ──────────────
4506
+ *
4507
+ * The registry's unclaimed-export check (`findUnclaimedRuleModuleExports`)
4508
+ * keys on this name under `src/config-surface/`: every exported function here
4509
+ * is a rule, so one no surface declares fails `cli-unit-tests`. The anchor
4510
+ * also forbids a `throw` outside the one refusal constructor — and `parseCron`
4511
+ * throws eight different sentences, which `tests/unit/cron-parser.test.ts`
4512
+ * pins. So the grammar and the `Intl` probe live next door in
4513
+ * `cron-expression.ts`, which is a shared-predicate module rather than a rules
4514
+ * module, and this one only decides and renders.
4515
+ *
4516
+ * ── What is NOT shared ───────────────────────────────────────────────────
4517
+ *
4518
+ * The standalone cron routes ask the same two predicates and render their own
4519
+ * `Invalid cron expression: …` / `Invalid timezone: …`, with their own
4520
+ * defaulting (create reads an absent timezone as UTC, update does not). That
4521
+ * is intent assumption 2 in the form its own fallback describes, and the shape
4522
+ * `workflow-rules.ts` set in phase 2: one predicate, each site's message and
4523
+ * defaulting preserved. No refusal message changed anywhere.
4524
+ */
4525
+ /** The rule ids this module owns — the union type below anchors them (#3373). */
4526
+ export const CRON_TRIGGER_RULE_IDS = [
4527
+ "cron-trigger-expression",
4528
+ "cron-trigger-timezone",
4529
+ ];
4530
+ /** The one refusal constructor: a new branch cannot compile without an id. */
4531
+ function refuseCronTriggerRule(ruleId, message) {
4532
+ return { ruleId, message };
4533
+ }
4534
+ /**
4535
+ * One `[[function.triggers.cron]]` entry's schedule, or null.
4536
+ *
4537
+ * Expression first, then timezone, which is the order the push boundary ran
4538
+ * them in — so an entry broken both ways still answers with the expression's
4539
+ * refusal, exactly as before. An unset or empty `timezone` reads as UTC, again
4540
+ * exactly as before: the boundary's `entry.timezone || "UTC"`.
4541
+ */
4542
+ export function checkCronTriggerEntry(entry) {
4543
+ const cronError = cronExpressionError(entry.cron);
4544
+ if (cronError !== null) {
4545
+ return refuseCronTriggerRule("cron-trigger-expression", `cron trigger '${entry.name}': invalid cron expression — ${cronError}`);
4546
+ }
4547
+ const timezone = entry.timezone || "UTC";
4548
+ if (!isValidTimezone(timezone)) {
4549
+ return refuseCronTriggerRule("cron-trigger-timezone", `cron trigger '${entry.name}': invalid timezone '${timezone}'`);
4550
+ }
4551
+ return null;
4552
+ }
4553
+ // ── src/config-surface/prompt-reasoning.ts ───────────────────────────────
4554
+ /**
4555
+ * The prompt config's reasoning controls, resolved onto each provider's own
4556
+ * spelling — issue #3358.
4557
+ *
4558
+ * A `[[configs]]` entry states EITHER `reasoningEffort` (a provider-neutral
4559
+ * level, `none` through `high`) or `reasoningBudget` (a token count), never
4560
+ * both. This module is the whole translation: every authored pair either maps
4561
+ * onto the provider's native key or is REFUSED with a message naming the
4562
+ * remedy. Nothing is dropped — that was the trap `providerConfig` already laid
4563
+ * in the tree (stored, serialized, round-tripped, and never read at execution),
4564
+ * and the issue's third success criterion is precisely that a config asking for
4565
+ * something a provider cannot express fails rather than silently doing nothing.
4566
+ *
4567
+ * Pure and dependency-free, like every module under `src/config-surface/`: the
4568
+ * server's create/update handlers, the executor, and the vendored CLI artifact
4569
+ * all read this one copy, so a refusal at push and a refusal at execution
4570
+ * cannot disagree.
4571
+ *
4572
+ * Provider facts this encodes:
4573
+ *
4574
+ * - OpenRouter takes a unified `reasoning` block: `{ effort }`,
4575
+ * `{ max_tokens }`, or `{ enabled: false }` to decline it. `max_tokens` is an
4576
+ * exact bound on budget-native models and is translated to the nearest
4577
+ * effort level on effort-only ones, so it is documented as a bound, not a
4578
+ * guarantee — `metrics.reasoningTokens` is the measure of what was spent.
4579
+ * Default routing lets an endpoint IGNORE a parameter it does not support,
4580
+ * so a request carrying `reasoning` also carries
4581
+ * `provider: { require_parameters: true }`: an endpoint that cannot honor it
4582
+ * is refused by OpenRouter and surfaces as a failed execution rather than a
4583
+ * silent drop.
4584
+ * - Gemini 3.x expresses thinking as a LEVEL
4585
+ * (`thinkingConfig.thinkingLevel`) and cannot turn it off; `thinkingBudget`
4586
+ * is accepted there only for backward compatibility and is translated to a
4587
+ * level rather than honored as a bound, so a numeric budget is refused with
4588
+ * a pointer at `reasoningEffort`.
4589
+ * - Gemini 2.5 expresses it as a numeric BUDGET
4590
+ * (`thinkingConfig.thinkingBudget`); `0` turns thinking off on Flash and
4591
+ * Flash-Lite, while Pro has a floor of 128 and answers 400 for less.
4592
+ * - Gemini 2.0 and earlier have no thinking control at all.
4593
+ */
4594
+ /** The provider-neutral effort levels a config may state. */
4595
+ export const REASONING_EFFORTS = [
4596
+ "none",
4597
+ "minimal",
4598
+ "low",
4599
+ "medium",
4600
+ "high",
4601
+ ];
4602
+ /** The smallest budget Gemini 2.5 Pro accepts; it cannot stop thinking. */
4603
+ export const GEMINI_PRO_MIN_THINKING_BUDGET = 128;
4604
+ export function geminiReasoningFamily(model) {
4605
+ const match = /gemini-(\d+)(?:[.-](\d+))?/i.exec(model || "");
4606
+ if (!match)
4607
+ return "none";
4608
+ const major = Number(match[1]);
4609
+ const minor = match[2] === undefined ? 0 : Number(match[2]);
4610
+ if (!Number.isFinite(major))
4611
+ return "none";
4612
+ if (major >= 3)
4613
+ return "level";
4614
+ if (major === 2 && minor >= 5)
4615
+ return "budget";
4616
+ return "none";
4617
+ }
4618
+ /** Gemini Pro: the tier that cannot stop thinking in either family. */
4619
+ export function isGeminiProModel(model) {
4620
+ return /(^|[-_/.])pro($|[-_/.])/i.test(model || "");
4621
+ }
4622
+ /** Is EITHER reasoning key stated on this config? */
4623
+ export function hasPromptReasoning(input) {
4624
+ return (normalizeEffort(input.reasoningEffort) !== null ||
4625
+ normalizeBudget(input.reasoningBudget) !== null);
4626
+ }
4627
+ function normalizeEffort(value) {
4628
+ if (value === undefined || value === null)
4629
+ return null;
4630
+ const text = String(value).trim();
4631
+ return text === "" ? null : text.toLowerCase();
4632
+ }
4633
+ function normalizeBudget(value) {
4634
+ if (value === undefined || value === null)
4635
+ return null;
4636
+ if (typeof value === "string" && value.trim() === "")
4637
+ return null;
4638
+ const parsed = typeof value === "number" ? value : Number(value);
4639
+ return Number.isNaN(parsed) ? Number.NaN : parsed;
4640
+ }
4641
+ /**
4642
+ * Resolve an authored pair onto the provider's spelling.
4643
+ *
4644
+ * Returns `{ ok: true, directive: null }` when neither key is set — the case
4645
+ * that must leave the upstream payload byte-identical to what it was before
4646
+ * this issue — a directive when the pair maps, and `{ ok: false, error }` when
4647
+ * it does not. The error text is what `config push` reports as the file's
4648
+ * error and what the admin routes answer 400 with.
4649
+ */
4650
+ export function resolvePromptReasoning(input) {
4651
+ const effort = normalizeEffort(input.reasoningEffort);
4652
+ const budget = normalizeBudget(input.reasoningBudget);
4653
+ if (effort === null && budget === null) {
4654
+ return { ok: true, directive: null };
4655
+ }
4656
+ if (effort !== null && budget !== null && !Number.isNaN(budget)) {
4657
+ return {
4658
+ ok: false,
4659
+ error: "reasoningEffort and reasoningBudget cannot both be set on one prompt " +
4660
+ "config: state an effort level or a numeric token budget, not both. " +
4661
+ "Remove one of the two keys.",
4662
+ };
4663
+ }
4664
+ if (effort !== null && !REASONING_EFFORTS.includes(effort)) {
4665
+ return {
4666
+ ok: false,
4667
+ error: `reasoningEffort must be one of ${REASONING_EFFORTS.join(", ")}; ` +
4668
+ `got '${input.reasoningEffort}'`,
4669
+ };
4670
+ }
4671
+ if (budget !== null && (!Number.isInteger(budget) || budget < 0)) {
4672
+ return {
4673
+ ok: false,
4674
+ error: "reasoningBudget must be a non-negative whole number of reasoning " +
4675
+ `tokens; got '${input.reasoningBudget}'`,
4676
+ };
4677
+ }
4678
+ const provider = String(input.provider || "").trim().toLowerCase();
4679
+ const model = String(input.model || "").trim();
4680
+ if (provider === "openrouter") {
4681
+ return { ok: true, directive: openRouterDirective(effort, budget) };
4682
+ }
4683
+ if (provider === "gemini") {
4684
+ return geminiResolution(model, effort, budget);
4685
+ }
4686
+ return {
4687
+ ok: false,
4688
+ error: `provider '${input.provider ?? ""}' has no reasoning control; ` +
4689
+ "reasoningEffort and reasoningBudget are supported on 'openrouter' and " +
4690
+ "'gemini'",
4691
+ };
4692
+ }
4693
+ function openRouterDirective(effort, budget) {
4694
+ const reasoning = effort === "none"
4695
+ ? // `enabled: false` rather than `effort: "none"`: the older switch every
4696
+ // model path handles, where the newer spelling is not universal.
4697
+ { enabled: false }
4698
+ : effort !== null
4699
+ ? { effort }
4700
+ : { max_tokens: budget };
4701
+ return { provider: "openrouter", reasoning, requireParameters: true };
4702
+ }
4703
+ function geminiResolution(model, effort, budget) {
4704
+ const family = geminiReasoningFamily(model);
4705
+ const isPro = isGeminiProModel(model);
4706
+ if (family === "none") {
4707
+ return {
4708
+ ok: false,
4709
+ error: `Gemini model '${model}' has no reasoning control: reasoningEffort and ` +
4710
+ "reasoningBudget need Gemini 2.5 (a token budget) or Gemini 3 (an " +
4711
+ "effort level). Remove the key or move the config to a model that has one.",
4712
+ };
4713
+ }
4714
+ if (family === "level") {
4715
+ if (budget !== null) {
4716
+ return {
4717
+ ok: false,
4718
+ error: `Gemini model '${model}' expresses reasoning as a LEVEL, not a token ` +
4719
+ "budget — it translates thinkingBudget to a level rather than honoring " +
4720
+ "it as a bound. Use reasoningEffort " +
4721
+ `(${REASONING_EFFORTS.filter((e) => e !== "none").join(", ")}) instead ` +
4722
+ "of reasoningBudget.",
4723
+ };
4724
+ }
4725
+ if (effort === "none") {
4726
+ return {
4727
+ ok: false,
4728
+ error: `Gemini model '${model}' cannot turn thinking off. The lowest level it ` +
4729
+ `accepts is '${isPro ? "low" : "minimal"}'.`,
4730
+ };
4731
+ }
4732
+ if (isPro && (effort === "minimal" || effort === "medium")) {
4733
+ return {
4734
+ ok: false,
4735
+ error: `Gemini Pro model '${model}' accepts only 'low' or 'high' as a ` +
4736
+ `thinking level; '${effort}' is a Flash-only level.`,
4737
+ };
4738
+ }
4739
+ return {
4740
+ ok: true,
4741
+ directive: {
4742
+ provider: "gemini",
4743
+ thinkingConfig: { thinkingLevel: String(effort).toUpperCase() },
4744
+ },
4745
+ };
4746
+ }
4747
+ // family === "budget" — Gemini 2.5.
4748
+ if (effort !== null && effort !== "none") {
4749
+ return {
4750
+ ok: false,
4751
+ error: `Gemini model '${model}' expresses reasoning as a token BUDGET, not an ` +
4752
+ `effort level. Use reasoningBudget instead of reasoningEffort = '${effort}' ` +
4753
+ "(reasoningEffort = \"none\" is accepted and means a budget of 0).",
4754
+ };
4755
+ }
4756
+ const resolved = effort === "none" ? 0 : budget;
4757
+ if (isPro && resolved < GEMINI_PRO_MIN_THINKING_BUDGET) {
4758
+ return {
4759
+ ok: false,
4760
+ error: `Gemini Pro model '${model}' cannot turn thinking off: its smallest ` +
4761
+ `thinking budget is ${GEMINI_PRO_MIN_THINKING_BUDGET} tokens, and ` +
4762
+ `${resolved} is refused by the provider.`,
4763
+ };
4764
+ }
4765
+ return {
4766
+ ok: true,
4767
+ directive: {
4768
+ provider: "gemini",
4769
+ thinkingConfig: { thinkingBudget: resolved },
4770
+ },
4771
+ };
4772
+ }
3528
4773
  // ── src/config-surface/prompt.ts ─────────────────────────────────────────
3529
4774
  /**
3530
4775
  * The `prompt` configuration object's definition (issue #2644, phase 2).
@@ -3572,7 +4817,7 @@ export const PROMPT_SURFACE = {
3572
4817
  type: "json",
3573
4818
  emit: "whenSet",
3574
4819
  writableOn: BOTH,
3575
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4820
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3576
4821
  },
3577
4822
  // #2644 — accepted by both handlers and returned by the detail, but
3578
4823
  // never written to TOML before this: a prompt's declared output shape
@@ -3583,7 +4828,7 @@ export const PROMPT_SURFACE = {
3583
4828
  type: "json",
3584
4829
  emit: "whenSet",
3585
4830
  writableOn: BOTH,
3586
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4831
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3587
4832
  },
3588
4833
  // #2652 — the CEL rule gating who may execute the prompt. Required at
3589
4834
  // create; an explicit null/"" on update clears it (fail-closed). This
@@ -3596,7 +4841,7 @@ export const PROMPT_SURFACE = {
3596
4841
  type: "string",
3597
4842
  emit: "whenSet",
3598
4843
  writableOn: BOTH,
3599
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4844
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3600
4845
  },
3601
4846
  ],
3602
4847
  notExposed: {
@@ -3669,8 +4914,13 @@ export const PROMPT_SURFACE = {
3669
4914
  tomlOnlyKeys: {
3670
4915
  key: {
3671
4916
  kind: "structural",
3672
- note: "The prompt key. `config pull` writes it for readability; push takes " +
3673
- "the identity from the file name and attaches `promptKey` itself.",
4917
+ note: "The prompt key. `config pull` writes it, and push resolves the " +
4918
+ "identity as the declared `[prompt] key` when there is one and the " +
4919
+ "file name otherwise (`tomlData.prompt?.key || basename(file)`), " +
4920
+ "then attaches `promptKey` itself. In a pulled tree the two always " +
4921
+ "agree; a hand-written file whose key differs from its name is " +
4922
+ "deployed — and typed by `functions codegen` — under the DECLARED " +
4923
+ "key (#3330).",
3674
4924
  },
3675
4925
  },
3676
4926
  responseOnlyKeys: {
@@ -3693,7 +4943,7 @@ export const PROMPT_SURFACE = {
3693
4943
  emit: "always",
3694
4944
  writableOn: BOTH,
3695
4945
  // Renamable on update, uniqueness-checked within the prompt.
3696
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4946
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3697
4947
  },
3698
4948
  // #2645 — a config's status is configuration: `prompts configs update
3699
4949
  // --status archived` used to be the only way to retire one, and pull
@@ -3716,7 +4966,7 @@ export const PROMPT_SURFACE = {
3716
4966
  type: "string",
3717
4967
  emit: "whenSet",
3718
4968
  writableOn: UPDATE_ONLY,
3719
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4969
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3720
4970
  serverDefault: "active",
3721
4971
  },
3722
4972
  {
@@ -3734,7 +4984,7 @@ export const PROMPT_SURFACE = {
3734
4984
  emit: "always",
3735
4985
  writableOn: BOTH,
3736
4986
  // openrouter | gemini, lower-cased and cross-checked against the model.
3737
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4987
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3738
4988
  },
3739
4989
  {
3740
4990
  field: "model",
@@ -3742,7 +4992,7 @@ export const PROMPT_SURFACE = {
3742
4992
  type: "string",
3743
4993
  emit: "always",
3744
4994
  writableOn: BOTH,
3745
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4995
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3746
4996
  },
3747
4997
  {
3748
4998
  field: "systemPrompt",
@@ -3752,7 +5002,7 @@ export const PROMPT_SURFACE = {
3752
5002
  writableOn: BOTH,
3753
5003
  // Offloaded to R2 above the inline size limit and read back
3754
5004
  // transparently, so the TOML value is the whole prompt either way.
3755
- validation: handledBy("src/services/large-field-storage.ts", "storeField"),
5005
+ validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
3756
5006
  },
3757
5007
  {
3758
5008
  field: "userPromptTemplate",
@@ -3760,7 +5010,7 @@ export const PROMPT_SURFACE = {
3760
5010
  type: "string",
3761
5011
  emit: "whenSet",
3762
5012
  writableOn: BOTH,
3763
- validation: handledBy("src/services/large-field-storage.ts", "storeField"),
5013
+ validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
3764
5014
  },
3765
5015
  {
3766
5016
  field: "temperature",
@@ -3769,7 +5019,7 @@ export const PROMPT_SURFACE = {
3769
5019
  emit: "whenSet",
3770
5020
  writableOn: BOTH,
3771
5021
  // Stored as a string on the model, returned parsed as a number.
3772
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5022
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3773
5023
  dualEncoded: {
3774
5024
  note: "#2869 — a `StringField` on the model: the server stores what it " +
3775
5025
  "is given and returns `parseFloat` of it, so `temperature = " +
@@ -3785,7 +5035,7 @@ export const PROMPT_SURFACE = {
3785
5035
  type: "number",
3786
5036
  emit: "whenSet",
3787
5037
  writableOn: BOTH,
3788
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5038
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3789
5039
  dualEncoded: {
3790
5040
  note: "#2869 — a `StringField` returned parsed, exactly as `temperature`.",
3791
5041
  },
@@ -3814,7 +5064,7 @@ export const PROMPT_SURFACE = {
3814
5064
  type: "json",
3815
5065
  emit: "whenSet",
3816
5066
  writableOn: BOTH,
3817
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5067
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
3818
5068
  },
3819
5069
  {
3820
5070
  field: "providerConfig",
@@ -3822,7 +5072,35 @@ export const PROMPT_SURFACE = {
3822
5072
  type: "json",
3823
5073
  emit: "whenSet",
3824
5074
  writableOn: BOTH,
3825
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5075
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
5076
+ },
5077
+ // #3358 — the provider's reasoning budget, in a provider-neutral
5078
+ // spelling. Before this the only lever was the model itself: an app
5079
+ // running a classification prompt on a reasoning-by-default model paid
5080
+ // 49–82% of its decode for deliberation it did not want, in latency as
5081
+ // well as money, and no key in this table could decline it.
5082
+ //
5083
+ // The two are MUTUALLY EXCLUSIVE — a config states an effort level or a
5084
+ // numeric token budget, never both — and every authored value either
5085
+ // maps onto the provider's own spelling or is refused at push. That
5086
+ // mapping is `resolvePromptReasoning`, named here so the drift guard
5087
+ // checks the export and the parity project (#3374/#3375) can declare it
5088
+ // as a preflight rule without moving it.
5089
+ {
5090
+ field: "reasoningEffort",
5091
+ tomlKey: "reasoningEffort",
5092
+ type: "string",
5093
+ emit: "whenSet",
5094
+ writableOn: BOTH,
5095
+ validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
5096
+ },
5097
+ {
5098
+ field: "reasoningBudget",
5099
+ tomlKey: "reasoningBudget",
5100
+ type: "number",
5101
+ emit: "whenSet",
5102
+ writableOn: BOTH,
5103
+ validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
3826
5104
  },
3827
5105
  ],
3828
5106
  notExposed: {
@@ -3945,7 +5223,7 @@ export const INTEGRATION_SURFACE = {
3945
5223
  type: "number",
3946
5224
  emit: "always",
3947
5225
  writableOn: BOTH,
3948
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5226
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
3949
5227
  // `src/admin-api.ts` createAppIntegration: a create with no
3950
5228
  // `timeoutMs` stores 300000. Recorded so a hand-authored file that
3951
5229
  // omits the key compares (and pushes) as the value the server holds
@@ -3961,7 +5239,7 @@ export const INTEGRATION_SURFACE = {
3961
5239
  type: "number",
3962
5240
  emit: "whenSet",
3963
5241
  writableOn: BOTH,
3964
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5242
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
3965
5243
  // Same create default, same reason (#2880 DSO-002): 1 MiB.
3966
5244
  serverDefault: 1048576,
3967
5245
  },
@@ -3973,7 +5251,7 @@ export const INTEGRATION_SURFACE = {
3973
5251
  type: "string",
3974
5252
  emit: "whenSet",
3975
5253
  writableOn: BOTH,
3976
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5254
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
3977
5255
  },
3978
5256
  ],
3979
5257
  notExposed: {
@@ -4087,6 +5365,18 @@ const WEBHOOK_CONFIG_VALIDATION = "src/app-api/services/webhook-verification/con
4087
5365
  // alone, so it moved beside the definitions where `config push`'s preflight can
4088
5366
  // run the server's own copy of it.
4089
5367
  const WEBHOOK_SIGNING_SECRET_RULE = "src/config-surface/webhook-signing-secret.ts";
5368
+ /**
5369
+ * Every rule of this object is `apply` (#3374): the standalone webhook's CLI
5370
+ * path runs field-shape checks only, and everything else needs the app's state
5371
+ * or the STORED row. A module-local builder, named so it cannot collide with a
5372
+ * sibling's once the directory is concatenated into the artifact.
5373
+ */
5374
+ const webhookApplyRules = (modulePath, exportName, entries) => entries.map(([id, description]) => ({
5375
+ id,
5376
+ description,
5377
+ stage: "apply",
5378
+ handler: enforcedBy(modulePath, exportName),
5379
+ }));
4090
5380
  export const WEBHOOK_SURFACE = {
4091
5381
  label: "webhook",
4092
5382
  tables: [
@@ -4126,7 +5416,7 @@ export const WEBHOOK_SURFACE = {
4126
5416
  type: "string",
4127
5417
  emit: "always",
4128
5418
  writableOn: BOTH,
4129
- validation: handledBy(WEBHOOK_HANDLER, "AdminAPI"),
5419
+ validation: handledBy(WEBHOOK_HANDLER, "AdminAPI", "apply"),
4130
5420
  },
4131
5421
  {
4132
5422
  field: "toleranceSeconds",
@@ -4134,7 +5424,7 @@ export const WEBHOOK_SURFACE = {
4134
5424
  type: "number",
4135
5425
  emit: "always",
4136
5426
  writableOn: BOTH,
4137
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5427
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4138
5428
  },
4139
5429
  {
4140
5430
  field: "deduplicationEnabled",
@@ -4142,7 +5432,7 @@ export const WEBHOOK_SURFACE = {
4142
5432
  type: "boolean",
4143
5433
  emit: "always",
4144
5434
  writableOn: BOTH,
4145
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5435
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4146
5436
  },
4147
5437
  {
4148
5438
  field: "deduplicationWindowMs",
@@ -4150,7 +5440,7 @@ export const WEBHOOK_SURFACE = {
4150
5440
  type: "number",
4151
5441
  emit: "always",
4152
5442
  writableOn: BOTH,
4153
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5443
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4154
5444
  },
4155
5445
  // Per-webhook inbound body cap. Unset means the platform default
4156
5446
  // (5 MiB), so it is omitted from TOML rather than written as a zero.
@@ -4160,7 +5450,7 @@ export const WEBHOOK_SURFACE = {
4160
5450
  type: "number",
4161
5451
  emit: "whenSet",
4162
5452
  writableOn: BOTH,
4163
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes"),
5453
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes", "apply"),
4164
5454
  },
4165
5455
  {
4166
5456
  field: "secretGracePeriodMs",
@@ -4168,7 +5458,7 @@ export const WEBHOOK_SURFACE = {
4168
5458
  type: "number",
4169
5459
  emit: "always",
4170
5460
  writableOn: BOTH,
4171
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs"),
5461
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs", "apply"),
4172
5462
  },
4173
5463
  // #2254 — reference-only: a whole `{{secrets.KEY}}` reference naming an
4174
5464
  // existing app secret, and nothing else. The server never returns
@@ -4180,7 +5470,7 @@ export const WEBHOOK_SURFACE = {
4180
5470
  type: "string",
4181
5471
  emit: "whenSet",
4182
5472
  writableOn: BOTH,
4183
- validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference"),
5473
+ validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference", "apply"),
4184
5474
  },
4185
5475
  ],
4186
5476
  notExposed: {
@@ -4345,6 +5635,91 @@ export const WEBHOOK_SURFACE = {
4345
5635
  "configs, which spells `[metadata]` the same way.",
4346
5636
  },
4347
5637
  },
5638
+ // ── Cross-field rules (#3374) ─────────────────────────────────────────────
5639
+ //
5640
+ // Nothing here is `preflight`. Stage is a property of the (surface, rule)
5641
+ // pair, and on THIS surface even the file-decidable signing-secret rule is
5642
+ // enforced mid-apply: the standalone webhook CLI path runs no signing-secret
5643
+ // preflight (`function-sync.ts` is that rule's only CLI caller), and an
5644
+ // update that omits `signingSecret` keeps the stored one, so a local check
5645
+ // would need update-retains-stored-secret semantics the intent lists as a
5646
+ // non-goal. Declaring `preflight` would promise a refusal that lands after
5647
+ // sibling entities are written.
5648
+ //
5649
+ // The residue, named: `webhook-verification/config-validation.ts` is not a
5650
+ // rules module (it builds forty-odd refusals, most single-field). Its
5651
+ // cross-field refusals — scheme × config, replay protection, config secret
5652
+ // references — reach a write only through `validateWebhookSettings`, which
5653
+ // re-builds each under its own pipeline id, so they are declared here under
5654
+ // those ids; the module itself is unmigrated.
5655
+ crossFieldRules: [
5656
+ ...webhookApplyRules(WEBHOOK_SIGNING_SECRET_RULE, "validateSigningSecretDeclaration", [
5657
+ [
5658
+ "signing-secret-required",
5659
+ "A scheme that verifies with an HMAC secret must be given a " +
5660
+ "`signingSecret`. The same handler the function surface runs in " +
5661
+ "preflight; here it runs at apply because the standalone CLI path " +
5662
+ "never calls it and an update preserves an omitted stored secret.",
5663
+ ],
5664
+ [
5665
+ "whole-secret-reference",
5666
+ "A supplied `signingSecret` is a whole `{{secrets.KEY}}` reference " +
5667
+ "and nothing else — a literal is never stored (#2254).",
5668
+ ],
5669
+ [
5670
+ "signing-secret-not-supported",
5671
+ "A scheme whose key material lives in its config (`discord`, `jwt`, " +
5672
+ "`plaid`) or that verifies nothing (`none`) refuses a `signingSecret`.",
5673
+ ],
5674
+ ]),
5675
+ ...webhookApplyRules("src/app-api/services/webhook-limits.ts", "validateWebhookCountUnderCap", [
5676
+ [
5677
+ "webhook-cap",
5678
+ "An app holds at most fifty webhook rows, archived included; a " +
5679
+ "create that would add one past the cap is refused with " +
5680
+ "`WEBHOOK_LIMIT_REACHED` (#2232). Read early by `config push` as an " +
5681
+ "advisory check; the server decides.",
5682
+ ],
5683
+ ]),
5684
+ ...webhookApplyRules("src/app-api/services/webhook-settings-validation.ts", "validateWebhookSettings", [
5685
+ ["verification-scheme-required", "A webhook names its `verificationScheme`."],
5686
+ ["verification-scheme-value", "The scheme is one the receiver knows."],
5687
+ [
5688
+ "verification-config",
5689
+ "The scheme's `config` is valid for that scheme under this " +
5690
+ "environment's JWKS policy, and carries no literal credential.",
5691
+ ],
5692
+ [
5693
+ "replay-protection",
5694
+ "A signed scheme keeps deduplication on, with a window and tolerance " +
5695
+ "in range and long enough to cover the signature's freshness window.",
5696
+ ],
5697
+ [
5698
+ "signing-secret-exists",
5699
+ "The app secret a `signingSecret` reference names must exist, so a " +
5700
+ "missing key fails the write rather than the first delivery.",
5701
+ ],
5702
+ [
5703
+ "verification-config-secrets-exist",
5704
+ "Every secret reference the active scheme's `config` resolves at " +
5705
+ "delivery must name an existing app secret.",
5706
+ ],
5707
+ ]),
5708
+ ...webhookApplyRules("src/app-api/services/webhook-verification/signing-secret-migration.ts", "validateSigningSecretMigration", [
5709
+ [
5710
+ "signing-secret-migration",
5711
+ "A row whose STORED `signingSecret` is still a legacy literal cannot " +
5712
+ "be updated until the write migrates it to a reference (#2332); " +
5713
+ "gated on the scheme and status the row will end up with.",
5714
+ ],
5715
+ [
5716
+ "signing-secret-migration-malformed",
5717
+ "The same gate for a stored value carrying reference syntax that does " +
5718
+ "not resolve — every signed delivery is already failing, and the " +
5719
+ "message says so.",
5720
+ ],
5721
+ ]),
5722
+ ],
4348
5723
  };
4349
5724
  // ── src/config-surface/cron-trigger.ts ───────────────────────────────────
4350
5725
  /**
@@ -4394,7 +5769,23 @@ export const CRON_TRIGGER_SURFACE = {
4394
5769
  type: "string",
4395
5770
  emit: "always",
4396
5771
  writableOn: BOTH,
4397
- validation: handledBy("src/cron-parser.ts", "parseCron"),
5772
+ // #3376 — re-pointed from `src/cron-parser.ts`, which now only
5773
+ // re-exports the parser: the drift guard resolves `export function
5774
+ // <name>` in the named module's OWN source, so the declaring module
5775
+ // is the only one it can name.
5776
+ //
5777
+ // `apply`, not `preflight`, even though the predicate is vendored
5778
+ // and the rule is decidable from the file: no CLI check reads a
5779
+ // standalone `cron-triggers/<key>.toml`'s VALUES.
5780
+ // `collectConfigFileValidationErrors` is definition-driven and
5781
+ // cannot dispatch through a `handledBy` string (#1976), so this
5782
+ // file still fails at apply with the controller's message.
5783
+ // `preflight` would make the registry promise a refusal that in
5784
+ // fact lands mid-apply — and the reachability walker could not
5785
+ // catch the lie, because the FUNCTION leg reaches this same
5786
+ // predicate. Building that standalone leg, with its `config diff`
5787
+ // twin, is a finding for #3379.
5788
+ validation: handledBy("src/config-surface/cron-expression.ts", "cronExpressionError", "apply"),
4398
5789
  },
4399
5790
  {
4400
5791
  field: "timezone",
@@ -4402,8 +5793,12 @@ export const CRON_TRIGGER_SURFACE = {
4402
5793
  type: "string",
4403
5794
  emit: "always",
4404
5795
  writableOn: BOTH,
4405
- // Validated against Intl; unset reads as "UTC".
4406
- validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
5796
+ // Validated against Intl; unset reads as "UTC". #3376 named the
5797
+ // predicate itself rather than the controller class: the probe the
5798
+ // three sites used to spell out is one function now, and this is
5799
+ // one of the two surfaces that own it. `apply` for the same reason
5800
+ // as `cron` above — no standalone CLI preflight runs it (#3379).
5801
+ validation: handledBy("src/config-surface/cron-expression.ts", "isValidTimezone", "apply"),
4407
5802
  // `cron-triggers-controller.ts` stores `timezone || "UTC"`, so a file
4408
5803
  // that omits the key describes a trigger running in UTC — compared
4409
5804
  // and pushed as that, rather than as a difference no push can clear
@@ -4417,7 +5812,7 @@ export const CRON_TRIGGER_SURFACE = {
4417
5812
  emit: "always",
4418
5813
  writableOn: BOTH,
4419
5814
  // #1002 — a reserved `__internal.` key is rejected at save time.
4420
- validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
5815
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
4421
5816
  },
4422
5817
  {
4423
5818
  field: "overlapPolicy",
@@ -4426,7 +5821,7 @@ export const CRON_TRIGGER_SURFACE = {
4426
5821
  emit: "always",
4427
5822
  writableOn: BOTH,
4428
5823
  // skip | allow.
4429
- validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
5824
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
4430
5825
  // Same store-time default as `timezone` (#2880 DSO-002).
4431
5826
  serverDefault: "skip",
4432
5827
  },
@@ -4630,7 +6025,7 @@ export const BLOB_BUCKET_SURFACE = {
4630
6025
  emit: "always",
4631
6026
  writableOn: CREATE_ONLY,
4632
6027
  // Immutable after create: `update` never reads it.
4633
- validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
6028
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
4634
6029
  },
4635
6030
  {
4636
6031
  field: "preset",
@@ -4640,7 +6035,7 @@ export const BLOB_BUCKET_SURFACE = {
4640
6035
  writableOn: BOTH,
4641
6036
  // #1020 — the honest access field. A rule-set-backed bucket reports
4642
6037
  // "custom", which is not written back as a preset.
4643
- validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset"),
6038
+ validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset", "apply"),
4644
6039
  },
4645
6040
  {
4646
6041
  field: "ruleSetId",
@@ -4650,7 +6045,7 @@ export const BLOB_BUCKET_SURFACE = {
4650
6045
  writableOn: BOTH,
4651
6046
  // Existence-checked against AccessRuleSet; mutually exclusive with a
4652
6047
  // preset change in the same request.
4653
- validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
6048
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
4654
6049
  },
4655
6050
  ],
4656
6051
  notExposed: {
@@ -4778,7 +6173,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
4778
6173
  emit: "always",
4779
6174
  writableOn: BOTH,
4780
6175
  // Size-capped and checked for unescaped `{{{var}}}` interpolation.
4781
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6176
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
4782
6177
  },
4783
6178
  {
4784
6179
  field: "htmlBody",
@@ -4786,7 +6181,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
4786
6181
  type: "string",
4787
6182
  emit: "whenSet",
4788
6183
  writableOn: BOTH,
4789
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6184
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
4790
6185
  },
4791
6186
  {
4792
6187
  field: "textBody",
@@ -4794,7 +6189,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
4794
6189
  type: "string",
4795
6190
  emit: "whenSet",
4796
6191
  writableOn: BOTH,
4797
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6192
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
4798
6193
  },
4799
6194
  ],
4800
6195
  notExposed: {
@@ -4924,7 +6319,7 @@ export const DATABASE_TYPE_SURFACE = {
4924
6319
  writableOn: CREATE_ONLY,
4925
6320
  // The type's identity: required and `#`-free on create, taken from
4926
6321
  // the URL on update.
4927
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6322
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4928
6323
  },
4929
6324
  {
4930
6325
  field: "ruleSetId",
@@ -4934,7 +6329,7 @@ export const DATABASE_TYPE_SURFACE = {
4934
6329
  writableOn: BOTH,
4935
6330
  // Authored as a portable name and sent as an id; the server checks
4936
6331
  // the set exists and has `resourceType: "database"`.
4937
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6332
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4938
6333
  },
4939
6334
  {
4940
6335
  field: "metadataAccess",
@@ -4944,7 +6339,7 @@ export const DATABASE_TYPE_SURFACE = {
4944
6339
  writableOn: BOTH,
4945
6340
  // CEL, parsed before storing (#1336). The legacy `metadataAccess`
4946
6341
  // spelling stays readable on push — see tomlOnlyKeys.
4947
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6342
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4948
6343
  },
4949
6344
  {
4950
6345
  field: "defaultAccess",
@@ -4953,7 +6348,7 @@ export const DATABASE_TYPE_SURFACE = {
4953
6348
  emit: "whenSet",
4954
6349
  writableOn: BOTH,
4955
6350
  // CEL, plus the shared `md`-shape allowlist (#1523).
4956
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6351
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4957
6352
  },
4958
6353
  {
4959
6354
  field: "autoPopulatedFields",
@@ -4963,7 +6358,7 @@ export const DATABASE_TYPE_SURFACE = {
4963
6358
  writableOn: BOTH,
4964
6359
  // #750 — shorthand and verbose forms both accepted, normalized by
4965
6360
  // `validateAndNormalizeAutoPopulatedFields`.
4966
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6361
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4967
6362
  },
4968
6363
  {
4969
6364
  field: "timestamps",
@@ -4973,7 +6368,7 @@ export const DATABASE_TYPE_SURFACE = {
4973
6368
  writableOn: BOTH,
4974
6369
  // #748 — `{ create?, update?, models? }`, normalized by
4975
6370
  // `validateAndNormalizeTimestamps`.
4976
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6371
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
4977
6372
  },
4978
6373
  ],
4979
6374
  notExposed: {
@@ -5115,7 +6510,7 @@ export const RULE_SET_SURFACE = {
5115
6510
  emit: "always",
5116
6511
  writableOn: BOTH,
5117
6512
  // Required, trimmed, and non-empty on both modes.
5118
- validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
6513
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
5119
6514
  },
5120
6515
  {
5121
6516
  field: "resourceType",
@@ -5125,7 +6520,7 @@ export const RULE_SET_SURFACE = {
5125
6520
  writableOn: CREATE_ONLY,
5126
6521
  // Checked against `getValidResourceTypes()`, and singleton types
5127
6522
  // (#1148) reject a second set. `update` never reads it.
5128
- validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
6523
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
5129
6524
  },
5130
6525
  {
5131
6526
  field: "description",
@@ -5217,7 +6612,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5217
6612
  writableOn: CREATE_ONLY,
5218
6613
  // The config's identity: required and `#`-free on create, taken from
5219
6614
  // the URL on update.
5220
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6615
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5221
6616
  },
5222
6617
  {
5223
6618
  field: "ruleSetId",
@@ -5227,7 +6622,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5227
6622
  writableOn: BOTH,
5228
6623
  // Authored as a name, sent as an id; existence and resourceType are
5229
6624
  // checked server-side, and an empty value clears the reference.
5230
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6625
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5231
6626
  },
5232
6627
  {
5233
6628
  field: "autoAddCreator",
@@ -5237,7 +6632,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5237
6632
  writableOn: BOTH,
5238
6633
  // Defaults to `true` when absent on create, and is coerced with `!!`
5239
6634
  // on update — not a stored-as-is passthrough.
5240
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6635
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5241
6636
  },
5242
6637
  ],
5243
6638
  notExposed: {
@@ -5310,7 +6705,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5310
6705
  writableOn: CREATE_ONLY,
5311
6706
  // The config's identity: required on create, taken from the URL on
5312
6707
  // update.
5313
- validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
6708
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
5314
6709
  },
5315
6710
  {
5316
6711
  field: "ruleSetId",
@@ -5320,7 +6715,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5320
6715
  writableOn: BOTH,
5321
6716
  // Authored as a name, sent as an id; existence and resourceType are
5322
6717
  // checked server-side, and an empty value clears the reference.
5323
- validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
6718
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
5324
6719
  },
5325
6720
  ],
5326
6721
  notExposed: {
@@ -5385,6 +6780,18 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5385
6780
  * enum and `unique` rules — but the field itself round-trips as one value.
5386
6781
  */
5387
6782
  const METADATA_HANDLER = "src/app-api/controllers/metadata-controller.ts";
6783
+ /**
6784
+ * ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
6785
+ *
6786
+ * A `preflight` handler must be VENDORED — its module lives under
6787
+ * `src/config-surface/`, so the CLI runs the server's own copy through the
6788
+ * generated artifact rather than a second implementation. A handler that
6789
+ * lives in `cli/src/` is a CLI-only copy of a server rule, which is precisely
6790
+ * the drift this project exists to remove; until it is vendored it cannot make
6791
+ * the registry's preflight promise, whatever the push path happens to do
6792
+ * today. Declared `apply`, and recorded as a finding for the validation child
6793
+ * (#3379) rather than papered over with a third stage.
6794
+ */
5388
6795
  const CLI_METADATA_TOML = "cli/src/lib/toml-metadata-config.ts";
5389
6796
  export const METADATA_CATEGORY_CONFIG_SURFACE = {
5390
6797
  label: "metadata-category-config",
@@ -5403,7 +6810,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5403
6810
  emit: "always",
5404
6811
  writableOn: BOTH,
5405
6812
  // Half of the category's identity; required on every upsert.
5406
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6813
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
5407
6814
  },
5408
6815
  {
5409
6816
  field: "category",
@@ -5412,7 +6819,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5412
6819
  emit: "always",
5413
6820
  writableOn: BOTH,
5414
6821
  // The other half of the identity; required on every upsert.
5415
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6822
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
5416
6823
  },
5417
6824
  {
5418
6825
  field: "readRule",
@@ -5421,7 +6828,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5421
6828
  emit: "whenSet",
5422
6829
  writableOn: BOTH,
5423
6830
  // CEL, parsed and stored or nulled.
5424
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6831
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
5425
6832
  },
5426
6833
  {
5427
6834
  field: "writeRule",
@@ -5429,7 +6836,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5429
6836
  type: "string",
5430
6837
  emit: "whenSet",
5431
6838
  writableOn: BOTH,
5432
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6839
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
5433
6840
  },
5434
6841
  {
5435
6842
  field: "description",
@@ -5449,7 +6856,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5449
6856
  // `{ fields: {} }` so the file stays pushable. Field types, enums and
5450
6857
  // the single-`unique` rule are checked locally before push and again
5451
6858
  // server-side.
5452
- validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema"),
6859
+ validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema", "apply"),
5453
6860
  },
5454
6861
  ],
5455
6862
  notExposed: {
@@ -5591,6 +6998,128 @@ export const TRANSFORM_SURFACE = {
5591
6998
  * table's prefix.
5592
6999
  */
5593
7000
  const FUNCTION_HANDLER = "src/admin-api/controllers/server-functions-controller.ts";
7001
+ /**
7002
+ * The rule declarations (#3374). Three vendored modules decide everything the
7003
+ * file alone can decide, and the CLI's push preflight runs each of them
7004
+ * (`cli/src/lib/function-sync.ts`) before the first request — so those are
7005
+ * `preflight`. One server module decides everything that needs the app's
7006
+ * state, and it is `apply`. A rule id here is one id in its module's
7007
+ * `*_RULE_IDS` list; the guard holds the two sides equal.
7008
+ */
7009
+ const functionRules = (stage, modulePath, exportName, entries) => entries.map(([id, description]) => ({
7010
+ id,
7011
+ description,
7012
+ stage,
7013
+ handler: enforcedBy(modulePath, exportName),
7014
+ }));
7015
+ /** The trigger blocks' shape rules — one id per refusal (#3181, #3184, #3334). */
7016
+ const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface/trigger-declaration.ts", "normalizeTriggerDeclaration", [
7017
+ ["triggers-table", "`[function.triggers]` must be a table."],
7018
+ ["trigger-kind", "Only `webhook` and `cron` are trigger kinds."],
7019
+ [
7020
+ "database-trigger-removed",
7021
+ "`[[function.triggers.database]]` is refused: the trigger was removed " +
7022
+ "(#3483), and the refusal names the pattern that replaces it — do the " +
7023
+ "follow-up in the handler that writes, or start a task.",
7024
+ ],
7025
+ [
7026
+ "webhook-trigger-single",
7027
+ "`[function.triggers.webhook]` is one table, never an array: one webhook " +
7028
+ "trigger per function.",
7029
+ ],
7030
+ ["webhook-trigger-table", "`[function.triggers.webhook]` must be a table."],
7031
+ ["webhook-trigger-keys", "A webhook block accepts only its declared keys."],
7032
+ ["webhook-trigger-scheme-required", "A webhook block names its `verificationScheme`."],
7033
+ [
7034
+ "webhook-trigger-scheme-none",
7035
+ "Scheme `none` is refused on a function trigger: an unauthenticated " +
7036
+ "caller could run the function.",
7037
+ ],
7038
+ ["webhook-trigger-number", "The numeric webhook settings must be numbers."],
7039
+ ["webhook-trigger-dedup-boolean", "`deduplicationEnabled` must be a boolean."],
7040
+ [
7041
+ "webhook-trigger-verification-table",
7042
+ "`[function.triggers.webhook.verification]` must be a table.",
7043
+ ],
7044
+ ["cron-trigger-cap", "A function declares at most ten cron entries."],
7045
+ ["cron-trigger-entry", "Each `[[function.triggers.cron]]` entry is a table."],
7046
+ ["cron-trigger-keys", "A cron entry accepts only its declared keys."],
7047
+ [
7048
+ "cron-trigger-name-required",
7049
+ "A cron entry names itself: the name is half of its trigger key.",
7050
+ ],
7051
+ ["cron-trigger-name-charset", "A cron name uses the standalone trigger-key charset."],
7052
+ ["cron-trigger-duplicate", "Each cron name appears once within the function."],
7053
+ ["cron-trigger-expression-required", "A cron entry carries a `cron` expression."],
7054
+ ["cron-trigger-overlap-policy", "`overlapPolicy` is `skip` or `allow`."],
7055
+ ]);
7056
+ /**
7057
+ * The file-decidable half of the webhook `signingSecret` rule (#3320). The
7058
+ * SAME handler under the SAME ids is declared `apply` on the standalone
7059
+ * `webhook` surface, whose CLI path does not run it: stage is a property of
7060
+ * the (surface, rule) pair.
7061
+ */
7062
+ const SIGNING_SECRET_RULES = functionRules("preflight", "src/config-surface/webhook-signing-secret.ts", "validateSigningSecretDeclaration", [
7063
+ [
7064
+ "signing-secret-required",
7065
+ "A scheme that verifies with an HMAC secret must be given a `signingSecret`.",
7066
+ ],
7067
+ [
7068
+ "whole-secret-reference",
7069
+ "A supplied `signingSecret` is a whole `{{secrets.KEY}}` reference and " +
7070
+ "nothing else — a literal is never stored.",
7071
+ ],
7072
+ [
7073
+ "signing-secret-not-supported",
7074
+ "A scheme whose key material lives in its config (`discord`, `jwt`, " +
7075
+ "`plaid`) or that verifies nothing (`none`) refuses a `signingSecret`.",
7076
+ ],
7077
+ ]);
7078
+ /**
7079
+ * The cron entries' schedule, decidable from the file (#3376).
7080
+ *
7081
+ * Both were `apply` until this issue — declared where they ran, with a note
7082
+ * saying so — because the only implementation lived on the server. Splitting
7083
+ * `src/cron-parser.ts` put the grammar and the `Intl` probe under
7084
+ * `src/config-surface/`, so the CLI's push preflight runs the same rule
7085
+ * before the first request and a malformed schedule no longer reaches apply.
7086
+ */
7087
+ const CRON_SCHEDULE_RULES = functionRules("preflight", "src/config-surface/cron-rules.ts", "checkCronTriggerEntry", [
7088
+ [
7089
+ "cron-trigger-expression",
7090
+ "The `cron` expression must parse under the server's five-field grammar " +
7091
+ "(`parseCron`).",
7092
+ ],
7093
+ [
7094
+ "cron-trigger-timezone",
7095
+ "The `timezone`, unset reading as UTC, must be one " +
7096
+ "`Intl.DateTimeFormat` accepts.",
7097
+ ],
7098
+ ]);
7099
+ /** What only the app's state can decide, at the push boundary (#3181). */
7100
+ const FUNCTION_TRIGGER_STATE_RULES = functionRules("apply", "src/services/function-trigger-validation.ts", "validateFunctionTriggerDeclaration", [
7101
+ [
7102
+ "webhook-trigger-settings",
7103
+ "The webhook block's verification settings pass the same pipeline the " +
7104
+ "standalone create runs (scheme config, replay protection, secret " +
7105
+ "existence), prefixed `[function.triggers.webhook]: `.",
7106
+ ],
7107
+ [
7108
+ "webhook-trigger-key-held",
7109
+ "A standalone webhook that already holds the function's key blocks the " +
7110
+ "trigger — nobody else's webhook is rewritten.",
7111
+ ],
7112
+ [
7113
+ "function-webhook-cap",
7114
+ "The per-app webhook cap, measured prospectively over what this push " +
7115
+ "would add (the standalone cap's sentence, this surface's count).",
7116
+ ],
7117
+ [
7118
+ "function-cron-cap",
7119
+ "The per-app cron-trigger cap, measured prospectively over what this " +
7120
+ "push would add.",
7121
+ ],
7122
+ ]);
5594
7123
  export const SERVER_FUNCTION_SURFACE = {
5595
7124
  label: "function",
5596
7125
  tables: [
@@ -5617,7 +7146,7 @@ export const SERVER_FUNCTION_SURFACE = {
5617
7146
  // function with no gate fails closed at invoke, so accepting a bundle
5618
7147
  // for one would report a successful push of something that can never
5619
7148
  // run.
5620
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7149
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
5621
7150
  },
5622
7151
  {
5623
7152
  field: "inputSchema",
@@ -5625,7 +7154,7 @@ export const SERVER_FUNCTION_SURFACE = {
5625
7154
  type: "json",
5626
7155
  emit: "whenSet",
5627
7156
  writableOn: BOTH,
5628
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7157
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
5629
7158
  },
5630
7159
  {
5631
7160
  field: "outputSchema",
@@ -5633,7 +7162,7 @@ export const SERVER_FUNCTION_SURFACE = {
5633
7162
  type: "json",
5634
7163
  emit: "whenSet",
5635
7164
  writableOn: BOTH,
5636
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7165
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
5637
7166
  },
5638
7167
  ],
5639
7168
  notExposed: {
@@ -5669,6 +7198,18 @@ export const SERVER_FUNCTION_SURFACE = {
5669
7198
  "Repointed by a config-version push; versions are nameless, so " +
5670
7199
  "there is no `activeConfigName` for an author to write.",
5671
7200
  },
7201
+ activeConfigActivatedAt: {
7202
+ kind: "server-owned",
7203
+ note: "#3462 — WHEN the active version was put live. Stamped by both " +
7204
+ "doors that move the pointer (`config push` and the activate " +
7205
+ "route); an audit fact, so nothing authors it and `config pull` " +
7206
+ "does not write it back.",
7207
+ },
7208
+ activeConfigActivatedBy: {
7209
+ kind: "server-owned",
7210
+ note: "#3462 — WHICH admin put the active version live. Same two doors, " +
7211
+ "same reason: the actor is observed, never declared.",
7212
+ },
5672
7213
  runAs: {
5673
7214
  kind: "server-owned",
5674
7215
  note: "RETIRED from the write surface by #3279: function code acts as " +
@@ -5713,21 +7254,24 @@ export const SERVER_FUNCTION_SURFACE = {
5713
7254
  },
5714
7255
  mode: {
5715
7256
  kind: "structural",
5716
- note: '`mode = "request" | "task"` (#3281): a request function runs ' +
5717
- "inside the call and returns its result; a task function returns " +
5718
- "a run id and survives restarts (#3186). Stored on the config " +
5719
- "version (as its `durable` column) and pushed with it, so it is " +
5720
- "versioned with the code it describes rather than mutable on the " +
5721
- "header. Absent means request.",
7257
+ note: "RETIRED (#3482), and still ACCEPTED through phase 4. A function " +
7258
+ "is a function: the CALLER picks the runtime at each call, with " +
7259
+ "`invoke` running it inside the request and `start` running it as " +
7260
+ "a task, cron fires always starting a task run and webhook " +
7261
+ "deliveries always running as a request. A file that still " +
7262
+ "carries the key pushes, with a warning naming it as ignored and " +
7263
+ "every other key applied, and `config pull` removes it; from " +
7264
+ "phase 5 (#3188) it is refused as an unknown key. Nothing reads " +
7265
+ "it, and its value is not validated. A function that must not run " +
7266
+ "under one runtime says so in code with `assertRuntime`.",
5722
7267
  },
5723
7268
  durable: {
5724
7269
  kind: "structural",
5725
- note: 'The accepted ALIAS of `mode` through the transition (#3281): ' +
5726
- '`durable = true` is `mode = "task"` and `durable = false` is ' +
5727
- '`mode = "request"`. When both keys are present they must agree, ' +
5728
- "or the file is refused naming both. Kept as the adjective for the " +
5729
- "property; `config pull` writes the authored bytes back unchanged, " +
5730
- "so a file that says it keeps saying it until its author edits it.",
7270
+ note: "RETIRED (#3482), and still ACCEPTED through phase 4 — the same " +
7271
+ "as `mode`, whose alias it was. It named one runner for the whole " +
7272
+ "function, and there is no such thing any more. A file that " +
7273
+ "carries it pushes with a warning, `config pull` removes it, and " +
7274
+ "phase 5 refuses it.",
5731
7275
  },
5732
7276
  limits: {
5733
7277
  kind: "structural",
@@ -5763,6 +7307,20 @@ export const SERVER_FUNCTION_SURFACE = {
5763
7307
  // The file is the `[function]` table and nothing else. The function's code is
5764
7308
  // in the source files `entry` reaches, not in a TOML table.
5765
7309
  tomlDocumentKeys: {},
7310
+ // ── Cross-field rules (#3374) ─────────────────────────────────────────────
7311
+ //
7312
+ // Single-field refusals inside these modules (`mode`'s value, a webhook
7313
+ // block's `verificationScheme`) are declared here too: this is the only
7314
+ // id-bearing slot until #3375 gives `ConfigField.validation` a stage, and a
7315
+ // module's list must equal its declarations in both directions.
7316
+ crossFieldRules: [
7317
+ ...TRIGGER_DECLARATION_RULES,
7318
+ ...SIGNING_SECRET_RULES,
7319
+ // #3376 — after the signing-secret group, mirroring the order
7320
+ // `functionTomlPreflightErrors` runs them in.
7321
+ ...CRON_SCHEDULE_RULES,
7322
+ ...FUNCTION_TRIGGER_STATE_RULES,
7323
+ ],
5766
7324
  };
5767
7325
  // ── src/config-surface/test-case.ts ──────────────────────────────────────
5768
7326
  /**
@@ -5789,6 +7347,18 @@ export const SERVER_FUNCTION_SURFACE = {
5789
7347
  * re-stringifies on store.
5790
7348
  */
5791
7349
  const TEST_CASE_HANDLER = "src/admin-api.ts";
7350
+ /**
7351
+ * ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
7352
+ *
7353
+ * A `preflight` handler must be VENDORED — its module lives under
7354
+ * `src/config-surface/`, so the CLI runs the server's own copy through the
7355
+ * generated artifact rather than a second implementation. A handler that
7356
+ * lives in `cli/src/` is a CLI-only copy of a server rule, which is precisely
7357
+ * the drift this project exists to remove; until it is vendored it cannot make
7358
+ * the registry's preflight promise, whatever the push path happens to do
7359
+ * today. Declared `apply`, and recorded as a finding for the validation child
7360
+ * (#3379) rather than papered over with a third stage.
7361
+ */
5792
7362
  const CLI_SYNC = "cli/src/commands/sync.ts";
5793
7363
  export const TEST_CASE_SURFACE = {
5794
7364
  label: "test-case",
@@ -5806,7 +7376,7 @@ export const TEST_CASE_SURFACE = {
5806
7376
  emit: "always",
5807
7377
  writableOn: BOTH,
5808
7378
  // Required and trimmed on create; an empty name is a 400.
5809
- validation: handledBy(TEST_CASE_HANDLER, "AdminAPI"),
7379
+ validation: handledBy(TEST_CASE_HANDLER, "AdminAPI", "apply"),
5810
7380
  },
5811
7381
  {
5812
7382
  field: "description",
@@ -5824,7 +7394,7 @@ export const TEST_CASE_SURFACE = {
5824
7394
  writableOn: BOTH,
5825
7395
  // Required. TOML carries the JSON text; the wire carries the parsed
5826
7396
  // object, which the server re-stringifies for storage.
5827
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7397
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5828
7398
  },
5829
7399
  {
5830
7400
  field: "configId",
@@ -5833,7 +7403,7 @@ export const TEST_CASE_SURFACE = {
5833
7403
  emit: "always",
5834
7404
  writableOn: BOTH,
5835
7405
  // Authored as the config's NAME and resolved to an id at push time.
5836
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7406
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5837
7407
  },
5838
7408
  {
5839
7409
  field: "evaluatorPromptId",
@@ -5842,7 +7412,7 @@ export const TEST_CASE_SURFACE = {
5842
7412
  emit: "always",
5843
7413
  writableOn: BOTH,
5844
7414
  // Authored as the judge prompt's KEY and resolved to an id at push.
5845
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7415
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5846
7416
  },
5847
7417
  {
5848
7418
  field: "evaluatorConfigId",
@@ -5850,7 +7420,7 @@ export const TEST_CASE_SURFACE = {
5850
7420
  type: "string",
5851
7421
  emit: "always",
5852
7422
  writableOn: BOTH,
5853
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7423
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5854
7424
  },
5855
7425
  {
5856
7426
  field: "expectedOutputPattern",
@@ -5867,7 +7437,7 @@ export const TEST_CASE_SURFACE = {
5867
7437
  emit: "always",
5868
7438
  writableOn: BOTH,
5869
7439
  // JSON array of strings; TOML carries the JSON text.
5870
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7440
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5871
7441
  },
5872
7442
  {
5873
7443
  field: "expectedJsonSubset",
@@ -5876,7 +7446,7 @@ export const TEST_CASE_SURFACE = {
5876
7446
  emit: "always",
5877
7447
  writableOn: BOTH,
5878
7448
  // JSON object, deep-partial-matched against the parsed output.
5879
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7449
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
5880
7450
  },
5881
7451
  ],
5882
7452
  notExposed: {
@@ -6070,7 +7640,7 @@ export const APP_SETTINGS_SURFACE = {
6070
7640
  // with `=== true`, so `"false"` must be a 400 rather than a stored
6071
7641
  // `true`. The generated request schema stays keys-only (#1976), so
6072
7642
  // the check is `parseDirectLlmEnabledWrite` in the handler.
6073
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7643
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6074
7644
  },
6075
7645
  {
6076
7646
  field: "allowedDomains",
@@ -6088,7 +7658,7 @@ export const APP_SETTINGS_SURFACE = {
6088
7658
  writableOn: UPDATE_ONLY,
6089
7659
  // `+primitivetest` bypass whitelist (#490/#675), shape-checked by
6090
7660
  // `validateTestAccountBaseEmails`.
6091
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7661
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6092
7662
  },
6093
7663
  {
6094
7664
  field: "largeDocumentWindowDays",
@@ -6102,7 +7672,7 @@ export const APP_SETTINGS_SURFACE = {
6102
7672
  // Refused rather than clamped: answering 200 to a 30-day window the
6103
7673
  // server does not keep would have the caller plan a month of
6104
7674
  // offline work around it (`parseLargeDocumentWindowSetting`).
6105
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7675
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6106
7676
  },
6107
7677
  ],
6108
7678
  notExposed: {
@@ -6220,7 +7790,7 @@ export const APP_SETTINGS_SURFACE = {
6220
7790
  // per-type secret table, cross-entry URI uniqueness and the
6221
7791
  // aggregate caps are all `validateGoogleClientsWrite`
6222
7792
  // (`src/services/google-clients.ts`), applied by the handler.
6223
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7793
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6224
7794
  },
6225
7795
  {
6226
7796
  field: "passkeyEnabled",
@@ -6241,7 +7811,7 @@ export const APP_SETTINGS_SURFACE = {
6241
7811
  writableOn: UPDATE_ONLY,
6242
7812
  // Mirrored into the two retired model fields by the handler so an
6243
7813
  // un-migrated reader cannot disagree.
6244
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7814
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6245
7815
  },
6246
7816
  {
6247
7817
  field: "appleSignInEnabled",
@@ -6271,7 +7841,7 @@ export const APP_SETTINGS_SURFACE = {
6271
7841
  // `validateRedirectUris`; matching is fail-closed, and a request
6272
7842
  // whose target this list does not cover gets a code-only email
6273
7843
  // rather than a link (#2884).
6274
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7844
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6275
7845
  },
6276
7846
  {
6277
7847
  field: "passkeyRpConfig",
@@ -6281,7 +7851,7 @@ export const APP_SETTINGS_SURFACE = {
6281
7851
  writableOn: UPDATE_ONLY,
6282
7852
  // `[auth.passkeys]` — rpId → { name }, shape-checked by
6283
7853
  // `SettingsController.validatePasskeyRpConfig`.
6284
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7854
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6285
7855
  },
6286
7856
  {
6287
7857
  field: "passkeyUserVerification",
@@ -6299,7 +7869,7 @@ export const APP_SETTINGS_SURFACE = {
6299
7869
  // both halves of every passkey ceremony state, so an unrecognized
6300
7870
  // value is a 400 rather than a silent fall back to the default —
6301
7871
  // the `directLlmEnabled` rationale, applied to a security policy.
6302
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7872
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6303
7873
  },
6304
7874
  ],
6305
7875
  notExposed: {},
@@ -6463,10 +8033,8 @@ export const CONFIG_SURFACES = [
6463
8033
  * is as review-visible as weakening any other assertion.
6464
8034
  */
6465
8035
  export const CROSS_FIELD_RULES_UNMIGRATED = [
6466
- "workflow",
6467
8036
  "prompt",
6468
8037
  "integration",
6469
- "webhook",
6470
8038
  "cron-trigger",
6471
8039
  "blob-bucket",
6472
8040
  "email-template",
@@ -6475,7 +8043,6 @@ export const CROSS_FIELD_RULES_UNMIGRATED = [
6475
8043
  "group-type-config",
6476
8044
  "collection-type-config",
6477
8045
  "metadata-category-config",
6478
- "function",
6479
8046
  "test-case",
6480
8047
  "app-settings",
6481
8048
  ];
@@ -6603,6 +8170,8 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
6603
8170
  "outputFormat",
6604
8171
  "outputSchema",
6605
8172
  "providerConfig",
8173
+ "reasoningEffort",
8174
+ "reasoningBudget",
6606
8175
  "status",
6607
8176
  "createdBy",
6608
8177
  "createdAt",
@@ -6770,6 +8339,8 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
6770
8339
  "description",
6771
8340
  "status",
6772
8341
  "activeConfigId",
8342
+ "activeConfigActivatedAt",
8343
+ "activeConfigActivatedBy",
6773
8344
  "runAs",
6774
8345
  "access",
6775
8346
  "inputSchema",