primitive-admin 1.1.0-alpha.82 → 1.1.0-alpha.84

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 (106) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +105 -675
  2. package/dist/src/commands/collections.js +61 -0
  3. package/dist/src/commands/collections.js.map +1 -1
  4. package/dist/src/commands/databases.js +10 -2
  5. package/dist/src/commands/databases.js.map +1 -1
  6. package/dist/src/commands/documents.d.ts +38 -0
  7. package/dist/src/commands/documents.js +535 -62
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/functions.js +303 -121
  10. package/dist/src/commands/functions.js.map +1 -1
  11. package/dist/src/commands/locks.js +15 -1
  12. package/dist/src/commands/locks.js.map +1 -1
  13. package/dist/src/commands/sync.d.ts +50 -0
  14. package/dist/src/commands/sync.js +560 -312
  15. package/dist/src/commands/sync.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +103 -2
  17. package/dist/src/lib/api-client.js +162 -18
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +5 -5
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/db-codegen/dbTypeIR.js +8 -0
  22. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  23. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  24. package/dist/src/lib/document-ingest-artifact.js +505 -0
  25. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  26. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  27. package/dist/src/lib/document-ingest-input.js +132 -0
  28. package/dist/src/lib/document-ingest-input.js.map +1 -0
  29. package/dist/src/lib/document-ingest-rows.d.ts +137 -0
  30. package/dist/src/lib/document-ingest-rows.js +181 -0
  31. package/dist/src/lib/document-ingest-rows.js.map +1 -0
  32. package/dist/src/lib/document-ingest.d.ts +101 -0
  33. package/dist/src/lib/document-ingest.js +384 -0
  34. package/dist/src/lib/document-ingest.js.map +1 -0
  35. package/dist/src/lib/function-bundle.d.ts +6 -0
  36. package/dist/src/lib/function-bundle.js +8 -0
  37. package/dist/src/lib/function-bundle.js.map +1 -1
  38. package/dist/src/lib/function-collect.d.ts +1 -1
  39. package/dist/src/lib/function-collect.js +16 -0
  40. package/dist/src/lib/function-collect.js.map +1 -1
  41. package/dist/src/lib/function-db-types.d.ts +5 -1
  42. package/dist/src/lib/function-db-types.js +32 -3
  43. package/dist/src/lib/function-db-types.js.map +1 -1
  44. package/dist/src/lib/function-document-types.d.ts +83 -0
  45. package/dist/src/lib/function-document-types.js +155 -1
  46. package/dist/src/lib/function-document-types.js.map +1 -1
  47. package/dist/src/lib/function-grants-preflight.js +5 -0
  48. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  49. package/dist/src/lib/function-log-lines.d.ts +76 -0
  50. package/dist/src/lib/function-log-lines.js +160 -0
  51. package/dist/src/lib/function-log-lines.js.map +1 -0
  52. package/dist/src/lib/function-log-row.d.ts +29 -0
  53. package/dist/src/lib/function-log-row.js +73 -0
  54. package/dist/src/lib/function-log-row.js.map +1 -0
  55. package/dist/src/lib/function-run.d.ts +53 -35
  56. package/dist/src/lib/function-run.js +55 -44
  57. package/dist/src/lib/function-run.js.map +1 -1
  58. package/dist/src/lib/function-schema-codegen.d.ts +6 -9
  59. package/dist/src/lib/function-schema-codegen.js +22 -35
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -35
  62. package/dist/src/lib/function-sync.js +731 -99
  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 +390 -146
  68. package/dist/src/lib/generated-config-surfaces.js +1207 -401
  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/resolve-owner.d.ts +19 -0
  80. package/dist/src/lib/resolve-owner.js +20 -0
  81. package/dist/src/lib/resolve-owner.js.map +1 -0
  82. package/dist/src/lib/skill-installer.d.ts +49 -3
  83. package/dist/src/lib/skill-installer.js +275 -100
  84. package/dist/src/lib/skill-installer.js.map +1 -1
  85. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  86. package/dist/src/lib/snapshot-audit-source.js +58 -0
  87. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  89. package/dist/src/lib/snapshot-audit-store.js +196 -0
  90. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  91. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  92. package/dist/src/lib/snapshot-audit.js +431 -0
  93. package/dist/src/lib/snapshot-audit.js.map +1 -0
  94. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  95. package/dist/src/lib/snapshot-build-rows.js +87 -0
  96. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  97. package/dist/src/lib/snapshot-build.d.ts +50 -0
  98. package/dist/src/lib/snapshot-build.js +111 -0
  99. package/dist/src/lib/snapshot-build.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +23 -16
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +34 -33
  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/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,144 +3084,170 @@ export function parseFunctionManifest(value) {
2467
3084
  errors: [],
2468
3085
  };
2469
3086
  }
2470
- export const FUNCTION_MODES = ["request", "task", "any"];
2471
- /** The runners, for the doors where a caller states one (#3454, D3454-004). */
2472
- export const FUNCTION_RUNNERS = ["request", "task"];
3087
+ // ── src/config-surface/function-retired-keys.ts ──────────────────────────
2473
3088
  /**
2474
- * The rule ids this module owns, declared on the `function` surface (#3374).
2475
- * The union below closes `refuseMode`'s id parameter: a fourth refusal cannot
2476
- * compile without a fourth id, and a fourth id fails `cli-unit-tests` until the
2477
- * surface declares it.
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.
2478
3118
  */
2479
- export const FUNCTION_MODE_RULE_IDS = [
2480
- "mode-value",
2481
- "durable-value",
2482
- "mode-durable-agreement",
2483
- ];
2484
- /** The one refusal constructor (#3373's anchor); `error` keeps its name. */
2485
- function refuseMode(ruleId, error) {
2486
- return { ok: false, ruleId, error };
2487
- }
2488
- /**
2489
- * Resolve `mode` and its alias `durable` to one mode.
2490
- *
2491
- * Absent both → `any` (#3454): a file that says nothing about its runner takes
2492
- * either, which is what "a function is a function" means. `mode` must be one
2493
- * of the three words; `durable` must be a boolean. Both present and
2494
- * disagreeing is refused naming both keys, so an author who half-migrated a
2495
- * file hears which line to fix — and `durable` beside `mode = "any"` is one of
2496
- * those disagreements, because a boolean cannot say "either".
2497
- */
2498
- export function resolveDeclaredMode(input) {
2499
- const rawMode = input?.mode;
2500
- const rawDurable = input?.durable;
2501
- const hasMode = rawMode !== undefined && rawMode !== null;
2502
- const hasDurable = rawDurable !== undefined && rawDurable !== null;
2503
- let fromMode = null;
2504
- if (hasMode) {
2505
- if (typeof rawMode !== "string" || !FUNCTION_MODES.includes(rawMode)) {
2506
- return refuseMode("mode-value", `\`mode\` must be "request", "task" or "any" (got ${describe(rawMode)}): ` +
2507
- '"any" — the default — lets the caller pick, running the function ' +
2508
- "inside the call with `invoke` or as a background run with `start`; " +
2509
- '"request" and "task" lock it to one of the two');
2510
- }
2511
- fromMode = rawMode;
2512
- }
2513
- let fromDurable = null;
2514
- if (hasDurable) {
2515
- if (typeof rawDurable !== "boolean") {
2516
- return refuseMode("durable-value", `\`durable\` must be true or false (got ${describe(rawDurable)}); it is ` +
2517
- 'the accepted alias for `mode = "task"` / `mode = "request"`');
2518
- }
2519
- fromDurable = rawDurable ? "task" : "request";
2520
- }
2521
- if (fromMode && fromDurable && fromMode !== fromDurable) {
2522
- // `mode = "any"` beside either boolean lands here too, and deliberately:
2523
- // the boolean names one runner and `any` names neither, so there is no
2524
- // value of `durable` the author could have meant. The remedy the message
2525
- // gives — drop one key — is the same in both cases.
2526
- return refuseMode("mode-durable-agreement", `\`mode = "${fromMode}"\` and \`durable = ${String(rawDurable)}\` disagree: ` +
2527
- '`durable` is the accepted alias for `mode = "task"` (true) and ' +
2528
- '`mode = "request"` (false), and it cannot say `mode = "any"` at all, ' +
2529
- "so keep one key, or make the two agree");
2530
- }
2531
- const mode = fromMode ?? fromDurable ?? "any";
2532
- return { ok: true, mode, declared: hasMode || hasDurable };
2533
- }
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.";
2534
3124
  /**
2535
- * The mode a stored config-version row runs in.
3125
+ * Every retired runtime key a `[function]` table still carries.
2536
3126
  *
2537
- * `mode` is the stored fact and is read first. A row written before #3454
2538
- * carries none, and is read the way it always read: the `durable` boolean, and
2539
- * the request runner otherwise. That reading is CONSERVATIVE on purpose — it
2540
- * is byte for byte what the row did before the change — and widening a
2541
- * pre-change row to `any` needs its own authored declaration, which
2542
- * `src/server-functions/legacy-mode.ts` recovers from the envelope's TOML and
2543
- * records on the row once (D3454-002).
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).
3132
+ *
3133
+ * Order is the order an author reads their file in: the top-level keys first,
3134
+ * then the cron entries in declaration order.
2544
3135
  */
2545
- export function configMode(row) {
2546
- const stored = row?.mode;
2547
- if (typeof stored === "string" && FUNCTION_MODES.includes(stored)) {
2548
- return stored;
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
+ });
2549
3148
  }
2550
- return row?.durable === true ? "task" : "request";
2551
- }
2552
- /** Is this mode the durable one? The adjective, for the paths that branch on it. */
2553
- export function isDurableMode(mode) {
2554
- return mode === "task";
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;
2555
3172
  }
2556
3173
  /**
2557
- * The runner a call gets on a version of this mode, given what it asked for.
2558
- *
2559
- * A LOCK answers itself whatever was requested — the refusal for the other
2560
- * door is a separate decision, made before this one, so that a caller hears a
2561
- * mismatch rather than silently getting the runner it did not ask for. An
2562
- * `any` version answers what the caller named, and the REQUEST runner when it
2563
- * named nothing: that is byte for byte what every `invoke` caller gets today.
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).
2564
3193
  */
2565
- export function runnerFor(mode, requested) {
2566
- if (mode === "request" || mode === "task")
2567
- return mode;
2568
- return requested ?? "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
+ });
2569
3205
  }
2570
- function describe(value) {
2571
- if (typeof value === "string")
2572
- return JSON.stringify(value);
2573
- if (value === null)
2574
- return "null";
2575
- if (typeof value === "object")
2576
- return Array.isArray(value) ? "an array" : "a table";
2577
- return String(value);
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;
2578
3221
  }
2579
3222
  /**
2580
- * The mode a derivation is checked against.
2581
- *
2582
- * An OMITTED option is the DEFAULT mode, which is `any` since #3454 — the same
2583
- * answer `resolveDeclaredMode` gives a file that says nothing. Every
2584
- * authoritative door resolves and passes explicitly; this keeps the two
2585
- * readings one reading rather than letting an omission mean something a
2586
- * declaration cannot.
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.
2587
3226
  */
2588
- function modeOf(options) {
2589
- return options.mode ?? "any";
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;
2590
3237
  }
2591
3238
  /**
2592
- * 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.
2593
3241
  *
2594
- * The first clause is verbatim what it has always been, so sibling pins on the
2595
- * request-mode prefix (#3281's) keep holding; everything after the dash is the
2596
- * half that was missing — an author who meets this at the push door could not
2597
- * 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.
2598
3244
  */
2599
- export const WEBHOOK_ON_TASK_REFUSAL = "webhook triggers require request mode: set `mode = \"request\"` (or " +
2600
- "remove `mode = \"task\"`) — a webhook delivery needs its answer inside the " +
2601
- "request, and a task run has none to give there: verify and acknowledge in " +
2602
- "the request function, then hand the work to a task function with " +
2603
- "ctx.functions.start — or remove [function.triggers.webhook]";
3245
+ export function ignoredFunctionKeyWarnings(label, functionTable) {
3246
+ return ignoredFunctionKeys(functionTable).map((ignored) => `${label}: ${ignored.message}`);
3247
+ }
2604
3248
  export const EMPTY_TRIGGER_DECLARATION = {
2605
3249
  webhook: null,
2606
3250
  cron: [],
2607
- databases: [],
2608
3251
  };
2609
3252
  /**
2610
3253
  * The most cron entries one function may declare.
@@ -2622,22 +3265,6 @@ export const MAX_CRON_TRIGGERS_PER_FUNCTION = 10;
2622
3265
  * collide with (the intent, "Key namespace?").
2623
3266
  */
2624
3267
  const CRON_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
2625
- /**
2626
- * The most database types one function may watch (#3184, D-07).
2627
- *
2628
- * Per-OBJECT, like the cron cap above: a function watching more than a handful
2629
- * of types is describing a fan-in the platform cannot bound per write, and
2630
- * every extra type is another discovery read on somebody's write path.
2631
- */
2632
- export const MAX_DATABASE_TRIGGERS_PER_FUNCTION = 5;
2633
- /**
2634
- * A database type key, matching the charset the grant grammar already uses for
2635
- * the same value (`src/config-surface/function-grants.ts`). One rule for one
2636
- * concept: `database:<type>/<model>:read` and `[[function.triggers.database]]`
2637
- * name the same thing.
2638
- */
2639
- const DATABASE_TYPE_PATTERN = /^[A-Za-z0-9_-]+$/;
2640
- const DATABASE_KEYS = new Set(["type"]);
2641
3268
  const WEBHOOK_KEYS = new Set([
2642
3269
  "verificationScheme",
2643
3270
  "signingSecret",
@@ -2651,6 +3278,12 @@ const WEBHOOK_KEYS = new Set([
2651
3278
  const CRON_KEYS = new Set([
2652
3279
  "name",
2653
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.
2654
3287
  "mode",
2655
3288
  "timezone",
2656
3289
  "overlapPolicy",
@@ -2666,17 +3299,12 @@ const VALID_OVERLAP_POLICIES = ["skip", "allow"];
2666
3299
  export const TRIGGER_RULE_IDS = [
2667
3300
  "triggers-table",
2668
3301
  "trigger-kind",
2669
- "database-trigger-mode",
2670
- "database-trigger-cap",
2671
- "database-trigger-entry",
2672
- "database-trigger-keys",
2673
- "database-trigger-type-required",
2674
- "database-trigger-type-charset",
2675
- "database-trigger-duplicate",
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",
2676
3305
  "webhook-trigger-single",
2677
3306
  "webhook-trigger-table",
2678
3307
  "webhook-trigger-keys",
2679
- "webhook-trigger-mode",
2680
3308
  "webhook-trigger-scheme-required",
2681
3309
  "webhook-trigger-scheme-none",
2682
3310
  "webhook-trigger-number",
@@ -2690,9 +3318,6 @@ export const TRIGGER_RULE_IDS = [
2690
3318
  "cron-trigger-duplicate",
2691
3319
  "cron-trigger-expression-required",
2692
3320
  "cron-trigger-overlap-policy",
2693
- "cron-trigger-mode-value",
2694
- "cron-trigger-mode-required",
2695
- "cron-trigger-mode-locked",
2696
3321
  ];
2697
3322
  /**
2698
3323
  * A broken trigger rule. `ruleId` names which one, for a reader that wants to
@@ -2713,9 +3338,7 @@ function refuseTrigger(ruleId, message) {
2713
3338
  }
2714
3339
  /** Is anything at all declared? Used to skip work on the common case. */
2715
3340
  export function hasDeclaredTriggers(declaration) {
2716
- return (declaration.webhook !== null ||
2717
- declaration.cron.length > 0 ||
2718
- declaration.databases.length > 0);
3341
+ return declaration.webhook !== null || declaration.cron.length > 0;
2719
3342
  }
2720
3343
  /**
2721
3344
  * Normalize the `triggers` table of an already-parsed `[function]` table.
@@ -2726,7 +3349,7 @@ export function hasDeclaredTriggers(declaration) {
2726
3349
  * push boundary can normalize a declaration it read from a stored row without
2727
3350
  * re-parsing bytes.
2728
3351
  */
2729
- export function normalizeTriggerDeclaration(functionTable, options = {}) {
3352
+ export function normalizeTriggerDeclaration(functionTable) {
2730
3353
  const triggers = functionTable?.triggers;
2731
3354
  if (triggers === undefined || triggers === null) {
2732
3355
  return EMPTY_TRIGGER_DECLARATION;
@@ -2734,75 +3357,41 @@ export function normalizeTriggerDeclaration(functionTable, options = {}) {
2734
3357
  if (typeof triggers !== "object" || Array.isArray(triggers)) {
2735
3358
  refuseTrigger("triggers-table", "[function.triggers] must be a table declaring `webhook` and/or `cron`");
2736
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);
3367
+ }
2737
3368
  for (const key of Object.keys(triggers)) {
2738
- if (key !== "webhook" && key !== "cron" && key !== "database") {
3369
+ if (key !== "webhook" && key !== "cron") {
2739
3370
  refuseTrigger("trigger-kind", `unknown trigger kind '${key}' under [function.triggers]: the kinds are ` +
2740
- "`webhook`, `cron` and `database`");
3371
+ "`webhook` and `cron`");
2741
3372
  }
2742
3373
  }
2743
- const webhook = normalizeWebhook(triggers.webhook, options);
2744
- const cron = normalizeCron(triggers.cron, options);
2745
- const databases = normalizeDatabases(triggers.database, options);
2746
- return { webhook, cron, databases };
3374
+ const webhook = normalizeWebhook(triggers.webhook);
3375
+ const cron = normalizeCron(triggers.cron);
3376
+ return { webhook, cron };
2747
3377
  }
2748
3378
  /**
2749
- * `[[function.triggers.database]]` — one accepted key, `type` (#3184, D-07).
2750
- *
2751
- * Sync mode only, permanently: a database-change fire runs INSIDE the write
2752
- * request, best-effort, with no run row (the intent, "Sync functions write a
2753
- * run row?"), and there is no durable shape for that. This is the same
2754
- * 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.
2755
3388
  */
2756
- function normalizeDatabases(raw, options) {
2757
- if (raw === undefined || raw === null)
2758
- return [];
2759
- const entries = Array.isArray(raw) ? raw : [raw];
2760
- if (entries.length === 0)
2761
- return [];
2762
- if (modeOf(options) === "task") {
2763
- refuseTrigger("database-trigger-mode", "database triggers require request mode: set `mode = \"request\"` (or " +
2764
- "remove `mode = \"task\"`), or remove the [[function.triggers.database]] " +
2765
- "entries");
2766
- }
2767
- if (entries.length > MAX_DATABASE_TRIGGERS_PER_FUNCTION) {
2768
- refuseTrigger("database-trigger-cap", `a function may watch at most ${MAX_DATABASE_TRIGGERS_PER_FUNCTION} database ` +
2769
- `types, and this one declares ${entries.length}`);
2770
- }
2771
- const seen = new Set();
2772
- const normalized = [];
2773
- for (const entry of entries) {
2774
- if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
2775
- refuseTrigger("database-trigger-entry", "each [[function.triggers.database]] entry must be a table");
2776
- }
2777
- const table = entry;
2778
- for (const key of Object.keys(table)) {
2779
- if (!DATABASE_KEYS.has(key)) {
2780
- refuseTrigger("database-trigger-keys", `unknown key '${key}' in a [[function.triggers.database]] entry: the ` +
2781
- `only accepted key is ${[...DATABASE_KEYS].join(", ")}`);
2782
- }
2783
- }
2784
- const type = String(table.type ?? "").trim();
2785
- if (!type) {
2786
- refuseTrigger("database-trigger-type-required", "each [[function.triggers.database]] entry needs a `type`: it names the " +
2787
- "database type whose writes fire the function");
2788
- }
2789
- if (!DATABASE_TYPE_PATTERN.test(type)) {
2790
- refuseTrigger("database-trigger-type-charset", `database trigger type '${type}' must contain only letters, digits, ` +
2791
- "'-' or '_'");
2792
- }
2793
- if (seen.has(type)) {
2794
- refuseTrigger("database-trigger-duplicate", `duplicate database trigger type '${type}': a function fires once per ` +
2795
- "write request, so each type is declared once");
2796
- }
2797
- seen.add(type);
2798
- normalized.push({ type });
2799
- }
2800
- // Sorted, like the cron entries: two files that differ only in the order
2801
- // they list their blocks describe the same triggers.
2802
- normalized.sort((a, b) => a.type.localeCompare(b.type));
2803
- return normalized;
2804
- }
2805
- 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) {
2806
3395
  if (raw === undefined || raw === null)
2807
3396
  return null;
2808
3397
  if (Array.isArray(raw)) {
@@ -2820,14 +3409,6 @@ function normalizeWebhook(raw, options) {
2820
3409
  `are ${[...WEBHOOK_KEYS].join(", ")}`);
2821
3410
  }
2822
3411
  }
2823
- // The intent forbids this permanently: a webhook fires the function inside
2824
- // the receiver, and the provider is holding the connection open waiting for
2825
- // its answer. A task run has no answer to give inside that request — it
2826
- // returns a run id and finishes later — so the refusal says so and names
2827
- // the pattern that does the same job (#3334).
2828
- if (modeOf(options) === "task") {
2829
- refuseTrigger("webhook-trigger-mode", WEBHOOK_ON_TASK_REFUSAL);
2830
- }
2831
3412
  const scheme = String(table.verificationScheme ?? "").trim();
2832
3413
  if (!scheme) {
2833
3414
  refuseTrigger("webhook-trigger-scheme-required", "[function.triggers.webhook] verificationScheme is required");
@@ -2871,18 +3452,12 @@ function normalizeWebhook(raw, options) {
2871
3452
  }
2872
3453
  return declaration;
2873
3454
  }
2874
- function normalizeCron(raw, options) {
3455
+ function normalizeCron(raw) {
2875
3456
  if (raw === undefined || raw === null)
2876
3457
  return [];
2877
3458
  const entries = Array.isArray(raw) ? raw : [raw];
2878
3459
  if (entries.length === 0)
2879
3460
  return [];
2880
- // No refusal for the function's own mode: a cron entry is legal in EVERY
2881
- // mode (#3334). The intent allows it ("Sync functions write a run row?":
2882
- // *HTTP and cron allow both*), and the cron DO starts a durable run for a
2883
- // task target. What each ENTRY is checked for is which runner ITS fires use
2884
- // (#3454) — see the three `cron-trigger-mode-*` refusals below.
2885
- const functionMode = modeOf(options);
2886
3461
  if (entries.length > MAX_CRON_TRIGGERS_PER_FUNCTION) {
2887
3462
  refuseTrigger("cron-trigger-cap", `a function may declare at most ${MAX_CRON_TRIGGERS_PER_FUNCTION} cron ` +
2888
3463
  `trigger entries, and this one declares ${entries.length}`);
@@ -2918,27 +3493,10 @@ function normalizeCron(raw, options) {
2918
3493
  if (!cron) {
2919
3494
  refuseTrigger("cron-trigger-expression-required", `cron trigger '${name}' needs a \`cron\` expression`);
2920
3495
  }
2921
- // ── which runner this schedule's fires use (#3454, D3454-006) ──
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.
2922
3499
  const declaration = { name, cron };
2923
- if (table.mode !== undefined && table.mode !== null) {
2924
- const entryMode = String(table.mode);
2925
- if (!FUNCTION_RUNNERS.includes(entryMode)) {
2926
- refuseTrigger("cron-trigger-mode-value", `cron trigger '${name}': \`mode\` must be "request" or "task" (got ` +
2927
- `${JSON.stringify(table.mode)}): a fire runs on one runner, so the ` +
2928
- 'entry names one — "any" is a mode a FUNCTION may have, not a runner');
2929
- }
2930
- if (functionMode !== "any" && functionMode !== entryMode) {
2931
- refuseTrigger("cron-trigger-mode-locked", `cron trigger '${name}' says \`mode = "${entryMode}"\` but the ` +
2932
- `function is locked to ${functionMode}: drop the entry's mode or ` +
2933
- "change the function's");
2934
- }
2935
- declaration.mode = entryMode;
2936
- }
2937
- else if (functionMode === "any") {
2938
- refuseTrigger("cron-trigger-mode-required", `cron trigger '${name}': the function's mode is any, so the entry says ` +
2939
- 'which runner each fire uses: add `mode = "request"` or ' +
2940
- '`mode = "task"`');
2941
- }
2942
3500
  if (table.timezone !== undefined) {
2943
3501
  declaration.timezone = String(table.timezone);
2944
3502
  }
@@ -3006,15 +3564,39 @@ export function parseStoredTriggerDeclaration(stored) {
3006
3564
  return {
3007
3565
  webhook: parsed?.webhook ?? null,
3008
3566
  cron: Array.isArray(parsed?.cron) ? parsed.cron : [],
3009
- // Absent in every row written before #3184, which is exactly what an
3010
- // empty list means: that version watches no database type.
3011
- 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.
3012
3573
  };
3013
3574
  }
3014
3575
  catch {
3015
3576
  return EMPTY_TRIGGER_DECLARATION;
3016
3577
  }
3017
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
+ }
3018
3600
  /** The `CronTrigger.triggerKey` an entry owns. */
3019
3601
  export function cronTriggerKeyFor(functionKey, name) {
3020
3602
  return `${functionKey}:${name}`;
@@ -3403,7 +3985,7 @@ export function checkWorkflowSystemAccessRule(input) {
3403
3985
  * (`cli/src/lib/workflow-payload.ts`, #1177), both deleted with this change.
3404
3986
  */
3405
3987
  const HANDLERS = "src/workflows/config/workflow-field-handlers.ts";
3406
- const handler = (name) => handledBy(HANDLERS, name);
3988
+ const handler = (name) => handledBy(HANDLERS, name, "apply");
3407
3989
  /**
3408
3990
  * The save-time rules the server runs and the CLI does not (#3374). They need
3409
3991
  * the runner's vocabulary (`isValidRunAs`, `GRANTABLE_SENSITIVE_CAPS`), which
@@ -3767,6 +4349,207 @@ export const WORKFLOW_SURFACE = {
3767
4349
  "the durable runtime can execute (`delay`, `event.wait`)."),
3768
4350
  ],
3769
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
+ }
3770
4553
  // ── src/config-surface/prompt-reasoning.ts ───────────────────────────────
3771
4554
  /**
3772
4555
  * The prompt config's reasoning controls, resolved onto each provider's own
@@ -4034,7 +4817,7 @@ export const PROMPT_SURFACE = {
4034
4817
  type: "json",
4035
4818
  emit: "whenSet",
4036
4819
  writableOn: BOTH,
4037
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4820
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4038
4821
  },
4039
4822
  // #2644 — accepted by both handlers and returned by the detail, but
4040
4823
  // never written to TOML before this: a prompt's declared output shape
@@ -4045,7 +4828,7 @@ export const PROMPT_SURFACE = {
4045
4828
  type: "json",
4046
4829
  emit: "whenSet",
4047
4830
  writableOn: BOTH,
4048
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4831
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4049
4832
  },
4050
4833
  // #2652 — the CEL rule gating who may execute the prompt. Required at
4051
4834
  // create; an explicit null/"" on update clears it (fail-closed). This
@@ -4058,7 +4841,7 @@ export const PROMPT_SURFACE = {
4058
4841
  type: "string",
4059
4842
  emit: "whenSet",
4060
4843
  writableOn: BOTH,
4061
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4844
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4062
4845
  },
4063
4846
  ],
4064
4847
  notExposed: {
@@ -4160,7 +4943,7 @@ export const PROMPT_SURFACE = {
4160
4943
  emit: "always",
4161
4944
  writableOn: BOTH,
4162
4945
  // Renamable on update, uniqueness-checked within the prompt.
4163
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4946
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4164
4947
  },
4165
4948
  // #2645 — a config's status is configuration: `prompts configs update
4166
4949
  // --status archived` used to be the only way to retire one, and pull
@@ -4183,7 +4966,7 @@ export const PROMPT_SURFACE = {
4183
4966
  type: "string",
4184
4967
  emit: "whenSet",
4185
4968
  writableOn: UPDATE_ONLY,
4186
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4969
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4187
4970
  serverDefault: "active",
4188
4971
  },
4189
4972
  {
@@ -4201,7 +4984,7 @@ export const PROMPT_SURFACE = {
4201
4984
  emit: "always",
4202
4985
  writableOn: BOTH,
4203
4986
  // openrouter | gemini, lower-cased and cross-checked against the model.
4204
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4987
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4205
4988
  },
4206
4989
  {
4207
4990
  field: "model",
@@ -4209,7 +4992,7 @@ export const PROMPT_SURFACE = {
4209
4992
  type: "string",
4210
4993
  emit: "always",
4211
4994
  writableOn: BOTH,
4212
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
4995
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4213
4996
  },
4214
4997
  {
4215
4998
  field: "systemPrompt",
@@ -4219,7 +5002,7 @@ export const PROMPT_SURFACE = {
4219
5002
  writableOn: BOTH,
4220
5003
  // Offloaded to R2 above the inline size limit and read back
4221
5004
  // transparently, so the TOML value is the whole prompt either way.
4222
- validation: handledBy("src/services/large-field-storage.ts", "storeField"),
5005
+ validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
4223
5006
  },
4224
5007
  {
4225
5008
  field: "userPromptTemplate",
@@ -4227,7 +5010,7 @@ export const PROMPT_SURFACE = {
4227
5010
  type: "string",
4228
5011
  emit: "whenSet",
4229
5012
  writableOn: BOTH,
4230
- validation: handledBy("src/services/large-field-storage.ts", "storeField"),
5013
+ validation: handledBy("src/services/large-field-storage.ts", "storeField", "apply"),
4231
5014
  },
4232
5015
  {
4233
5016
  field: "temperature",
@@ -4236,7 +5019,7 @@ export const PROMPT_SURFACE = {
4236
5019
  emit: "whenSet",
4237
5020
  writableOn: BOTH,
4238
5021
  // Stored as a string on the model, returned parsed as a number.
4239
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5022
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4240
5023
  dualEncoded: {
4241
5024
  note: "#2869 — a `StringField` on the model: the server stores what it " +
4242
5025
  "is given and returns `parseFloat` of it, so `temperature = " +
@@ -4252,7 +5035,7 @@ export const PROMPT_SURFACE = {
4252
5035
  type: "number",
4253
5036
  emit: "whenSet",
4254
5037
  writableOn: BOTH,
4255
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5038
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4256
5039
  dualEncoded: {
4257
5040
  note: "#2869 — a `StringField` returned parsed, exactly as `temperature`.",
4258
5041
  },
@@ -4281,7 +5064,7 @@ export const PROMPT_SURFACE = {
4281
5064
  type: "json",
4282
5065
  emit: "whenSet",
4283
5066
  writableOn: BOTH,
4284
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5067
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4285
5068
  },
4286
5069
  {
4287
5070
  field: "providerConfig",
@@ -4289,7 +5072,7 @@ export const PROMPT_SURFACE = {
4289
5072
  type: "json",
4290
5073
  emit: "whenSet",
4291
5074
  writableOn: BOTH,
4292
- validation: handledBy(PROMPT_HANDLER, "AdminAPI"),
5075
+ validation: handledBy(PROMPT_HANDLER, "AdminAPI", "apply"),
4293
5076
  },
4294
5077
  // #3358 — the provider's reasoning budget, in a provider-neutral
4295
5078
  // spelling. Before this the only lever was the model itself: an app
@@ -4309,7 +5092,7 @@ export const PROMPT_SURFACE = {
4309
5092
  type: "string",
4310
5093
  emit: "whenSet",
4311
5094
  writableOn: BOTH,
4312
- validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning"),
5095
+ validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
4313
5096
  },
4314
5097
  {
4315
5098
  field: "reasoningBudget",
@@ -4317,7 +5100,7 @@ export const PROMPT_SURFACE = {
4317
5100
  type: "number",
4318
5101
  emit: "whenSet",
4319
5102
  writableOn: BOTH,
4320
- validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning"),
5103
+ validation: handledBy("src/config-surface/prompt-reasoning.ts", "resolvePromptReasoning", "apply"),
4321
5104
  },
4322
5105
  ],
4323
5106
  notExposed: {
@@ -4440,7 +5223,7 @@ export const INTEGRATION_SURFACE = {
4440
5223
  type: "number",
4441
5224
  emit: "always",
4442
5225
  writableOn: BOTH,
4443
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5226
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
4444
5227
  // `src/admin-api.ts` createAppIntegration: a create with no
4445
5228
  // `timeoutMs` stores 300000. Recorded so a hand-authored file that
4446
5229
  // omits the key compares (and pushes) as the value the server holds
@@ -4456,7 +5239,7 @@ export const INTEGRATION_SURFACE = {
4456
5239
  type: "number",
4457
5240
  emit: "whenSet",
4458
5241
  writableOn: BOTH,
4459
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5242
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
4460
5243
  // Same create default, same reason (#2880 DSO-002): 1 MiB.
4461
5244
  serverDefault: 1048576,
4462
5245
  },
@@ -4468,7 +5251,7 @@ export const INTEGRATION_SURFACE = {
4468
5251
  type: "string",
4469
5252
  emit: "whenSet",
4470
5253
  writableOn: BOTH,
4471
- validation: handledBy(INTEGRATION_HANDLER, "AdminAPI"),
5254
+ validation: handledBy(INTEGRATION_HANDLER, "AdminAPI", "apply"),
4472
5255
  },
4473
5256
  ],
4474
5257
  notExposed: {
@@ -4633,7 +5416,7 @@ export const WEBHOOK_SURFACE = {
4633
5416
  type: "string",
4634
5417
  emit: "always",
4635
5418
  writableOn: BOTH,
4636
- validation: handledBy(WEBHOOK_HANDLER, "AdminAPI"),
5419
+ validation: handledBy(WEBHOOK_HANDLER, "AdminAPI", "apply"),
4637
5420
  },
4638
5421
  {
4639
5422
  field: "toleranceSeconds",
@@ -4641,7 +5424,7 @@ export const WEBHOOK_SURFACE = {
4641
5424
  type: "number",
4642
5425
  emit: "always",
4643
5426
  writableOn: BOTH,
4644
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5427
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4645
5428
  },
4646
5429
  {
4647
5430
  field: "deduplicationEnabled",
@@ -4649,7 +5432,7 @@ export const WEBHOOK_SURFACE = {
4649
5432
  type: "boolean",
4650
5433
  emit: "always",
4651
5434
  writableOn: BOTH,
4652
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5435
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4653
5436
  },
4654
5437
  {
4655
5438
  field: "deduplicationWindowMs",
@@ -4657,7 +5440,7 @@ export const WEBHOOK_SURFACE = {
4657
5440
  type: "number",
4658
5441
  emit: "always",
4659
5442
  writableOn: BOTH,
4660
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection"),
5443
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateReplayProtection", "apply"),
4661
5444
  },
4662
5445
  // Per-webhook inbound body cap. Unset means the platform default
4663
5446
  // (5 MiB), so it is omitted from TOML rather than written as a zero.
@@ -4667,7 +5450,7 @@ export const WEBHOOK_SURFACE = {
4667
5450
  type: "number",
4668
5451
  emit: "whenSet",
4669
5452
  writableOn: BOTH,
4670
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes"),
5453
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "validateMaxBodyBytes", "apply"),
4671
5454
  },
4672
5455
  {
4673
5456
  field: "secretGracePeriodMs",
@@ -4675,7 +5458,7 @@ export const WEBHOOK_SURFACE = {
4675
5458
  type: "number",
4676
5459
  emit: "always",
4677
5460
  writableOn: BOTH,
4678
- validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs"),
5461
+ validation: handledBy(WEBHOOK_CONFIG_VALIDATION, "normalizeSecretGracePeriodMs", "apply"),
4679
5462
  },
4680
5463
  // #2254 — reference-only: a whole `{{secrets.KEY}}` reference naming an
4681
5464
  // existing app secret, and nothing else. The server never returns
@@ -4687,7 +5470,7 @@ export const WEBHOOK_SURFACE = {
4687
5470
  type: "string",
4688
5471
  emit: "whenSet",
4689
5472
  writableOn: BOTH,
4690
- validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference"),
5473
+ validation: handledBy(WEBHOOK_SIGNING_SECRET_RULE, "validateWholeSecretReference", "apply"),
4691
5474
  },
4692
5475
  ],
4693
5476
  notExposed: {
@@ -4986,7 +5769,23 @@ export const CRON_TRIGGER_SURFACE = {
4986
5769
  type: "string",
4987
5770
  emit: "always",
4988
5771
  writableOn: BOTH,
4989
- 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"),
4990
5789
  },
4991
5790
  {
4992
5791
  field: "timezone",
@@ -4994,8 +5793,12 @@ export const CRON_TRIGGER_SURFACE = {
4994
5793
  type: "string",
4995
5794
  emit: "always",
4996
5795
  writableOn: BOTH,
4997
- // Validated against Intl; unset reads as "UTC".
4998
- 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"),
4999
5802
  // `cron-triggers-controller.ts` stores `timezone || "UTC"`, so a file
5000
5803
  // that omits the key describes a trigger running in UTC — compared
5001
5804
  // and pushed as that, rather than as a difference no push can clear
@@ -5009,7 +5812,7 @@ export const CRON_TRIGGER_SURFACE = {
5009
5812
  emit: "always",
5010
5813
  writableOn: BOTH,
5011
5814
  // #1002 — a reserved `__internal.` key is rejected at save time.
5012
- validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
5815
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
5013
5816
  },
5014
5817
  {
5015
5818
  field: "overlapPolicy",
@@ -5018,7 +5821,7 @@ export const CRON_TRIGGER_SURFACE = {
5018
5821
  emit: "always",
5019
5822
  writableOn: BOTH,
5020
5823
  // skip | allow.
5021
- validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController"),
5824
+ validation: handledBy(CRON_TRIGGER_HANDLER, "CronTriggersController", "apply"),
5022
5825
  // Same store-time default as `timezone` (#2880 DSO-002).
5023
5826
  serverDefault: "skip",
5024
5827
  },
@@ -5222,7 +6025,7 @@ export const BLOB_BUCKET_SURFACE = {
5222
6025
  emit: "always",
5223
6026
  writableOn: CREATE_ONLY,
5224
6027
  // Immutable after create: `update` never reads it.
5225
- validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
6028
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
5226
6029
  },
5227
6030
  {
5228
6031
  field: "preset",
@@ -5232,7 +6035,7 @@ export const BLOB_BUCKET_SURFACE = {
5232
6035
  writableOn: BOTH,
5233
6036
  // #1020 — the honest access field. A rule-set-backed bucket reports
5234
6037
  // "custom", which is not written back as a preset.
5235
- validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset"),
6038
+ validation: handledBy("src/app-api/resource-types/blob-bucket.ts", "resolveBucketPreset", "apply"),
5236
6039
  },
5237
6040
  {
5238
6041
  field: "ruleSetId",
@@ -5242,7 +6045,7 @@ export const BLOB_BUCKET_SURFACE = {
5242
6045
  writableOn: BOTH,
5243
6046
  // Existence-checked against AccessRuleSet; mutually exclusive with a
5244
6047
  // preset change in the same request.
5245
- validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController"),
6048
+ validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
5246
6049
  },
5247
6050
  ],
5248
6051
  notExposed: {
@@ -5370,7 +6173,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
5370
6173
  emit: "always",
5371
6174
  writableOn: BOTH,
5372
6175
  // Size-capped and checked for unescaped `{{{var}}}` interpolation.
5373
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6176
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
5374
6177
  },
5375
6178
  {
5376
6179
  field: "htmlBody",
@@ -5378,7 +6181,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
5378
6181
  type: "string",
5379
6182
  emit: "whenSet",
5380
6183
  writableOn: BOTH,
5381
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6184
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
5382
6185
  },
5383
6186
  {
5384
6187
  field: "textBody",
@@ -5386,7 +6189,7 @@ export const EMAIL_TEMPLATE_SURFACE = {
5386
6189
  type: "string",
5387
6190
  emit: "whenSet",
5388
6191
  writableOn: BOTH,
5389
- validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI"),
6192
+ validation: handledBy(EMAIL_TEMPLATE_HANDLER, "AdminAPI", "apply"),
5390
6193
  },
5391
6194
  ],
5392
6195
  notExposed: {
@@ -5516,7 +6319,7 @@ export const DATABASE_TYPE_SURFACE = {
5516
6319
  writableOn: CREATE_ONLY,
5517
6320
  // The type's identity: required and `#`-free on create, taken from
5518
6321
  // the URL on update.
5519
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6322
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5520
6323
  },
5521
6324
  {
5522
6325
  field: "ruleSetId",
@@ -5526,7 +6329,7 @@ export const DATABASE_TYPE_SURFACE = {
5526
6329
  writableOn: BOTH,
5527
6330
  // Authored as a portable name and sent as an id; the server checks
5528
6331
  // the set exists and has `resourceType: "database"`.
5529
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6332
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5530
6333
  },
5531
6334
  {
5532
6335
  field: "metadataAccess",
@@ -5536,7 +6339,7 @@ export const DATABASE_TYPE_SURFACE = {
5536
6339
  writableOn: BOTH,
5537
6340
  // CEL, parsed before storing (#1336). The legacy `metadataAccess`
5538
6341
  // spelling stays readable on push — see tomlOnlyKeys.
5539
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6342
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5540
6343
  },
5541
6344
  {
5542
6345
  field: "defaultAccess",
@@ -5545,7 +6348,7 @@ export const DATABASE_TYPE_SURFACE = {
5545
6348
  emit: "whenSet",
5546
6349
  writableOn: BOTH,
5547
6350
  // CEL, plus the shared `md`-shape allowlist (#1523).
5548
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6351
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5549
6352
  },
5550
6353
  {
5551
6354
  field: "autoPopulatedFields",
@@ -5555,7 +6358,7 @@ export const DATABASE_TYPE_SURFACE = {
5555
6358
  writableOn: BOTH,
5556
6359
  // #750 — shorthand and verbose forms both accepted, normalized by
5557
6360
  // `validateAndNormalizeAutoPopulatedFields`.
5558
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6361
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5559
6362
  },
5560
6363
  {
5561
6364
  field: "timestamps",
@@ -5565,7 +6368,7 @@ export const DATABASE_TYPE_SURFACE = {
5565
6368
  writableOn: BOTH,
5566
6369
  // #748 — `{ create?, update?, models? }`, normalized by
5567
6370
  // `validateAndNormalizeTimestamps`.
5568
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController"),
6371
+ validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
5569
6372
  },
5570
6373
  ],
5571
6374
  notExposed: {
@@ -5707,7 +6510,7 @@ export const RULE_SET_SURFACE = {
5707
6510
  emit: "always",
5708
6511
  writableOn: BOTH,
5709
6512
  // Required, trimmed, and non-empty on both modes.
5710
- validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
6513
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
5711
6514
  },
5712
6515
  {
5713
6516
  field: "resourceType",
@@ -5717,7 +6520,7 @@ export const RULE_SET_SURFACE = {
5717
6520
  writableOn: CREATE_ONLY,
5718
6521
  // Checked against `getValidResourceTypes()`, and singleton types
5719
6522
  // (#1148) reject a second set. `update` never reads it.
5720
- validation: handledBy(RULE_SET_HANDLER, "RuleSetsController"),
6523
+ validation: handledBy(RULE_SET_HANDLER, "RuleSetsController", "apply"),
5721
6524
  },
5722
6525
  {
5723
6526
  field: "description",
@@ -5809,7 +6612,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5809
6612
  writableOn: CREATE_ONLY,
5810
6613
  // The config's identity: required and `#`-free on create, taken from
5811
6614
  // the URL on update.
5812
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6615
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5813
6616
  },
5814
6617
  {
5815
6618
  field: "ruleSetId",
@@ -5819,7 +6622,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5819
6622
  writableOn: BOTH,
5820
6623
  // Authored as a name, sent as an id; existence and resourceType are
5821
6624
  // checked server-side, and an empty value clears the reference.
5822
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6625
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5823
6626
  },
5824
6627
  {
5825
6628
  field: "autoAddCreator",
@@ -5829,7 +6632,7 @@ export const GROUP_TYPE_CONFIG_SURFACE = {
5829
6632
  writableOn: BOTH,
5830
6633
  // Defaults to `true` when absent on create, and is coerced with `!!`
5831
6634
  // on update — not a stored-as-is passthrough.
5832
- validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController"),
6635
+ validation: handledBy(GROUP_TYPE_HANDLER, "GroupTypeConfigController", "apply"),
5833
6636
  },
5834
6637
  ],
5835
6638
  notExposed: {
@@ -5902,7 +6705,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5902
6705
  writableOn: CREATE_ONLY,
5903
6706
  // The config's identity: required on create, taken from the URL on
5904
6707
  // update.
5905
- validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
6708
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
5906
6709
  },
5907
6710
  {
5908
6711
  field: "ruleSetId",
@@ -5912,7 +6715,7 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5912
6715
  writableOn: BOTH,
5913
6716
  // Authored as a name, sent as an id; existence and resourceType are
5914
6717
  // checked server-side, and an empty value clears the reference.
5915
- validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController"),
6718
+ validation: handledBy(COLLECTION_TYPE_HANDLER, "CollectionTypeConfigController", "apply"),
5916
6719
  },
5917
6720
  ],
5918
6721
  notExposed: {
@@ -5977,6 +6780,18 @@ export const COLLECTION_TYPE_CONFIG_SURFACE = {
5977
6780
  * enum and `unique` rules — but the field itself round-trips as one value.
5978
6781
  */
5979
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
+ */
5980
6795
  const CLI_METADATA_TOML = "cli/src/lib/toml-metadata-config.ts";
5981
6796
  export const METADATA_CATEGORY_CONFIG_SURFACE = {
5982
6797
  label: "metadata-category-config",
@@ -5995,7 +6810,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
5995
6810
  emit: "always",
5996
6811
  writableOn: BOTH,
5997
6812
  // Half of the category's identity; required on every upsert.
5998
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6813
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
5999
6814
  },
6000
6815
  {
6001
6816
  field: "category",
@@ -6004,7 +6819,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
6004
6819
  emit: "always",
6005
6820
  writableOn: BOTH,
6006
6821
  // The other half of the identity; required on every upsert.
6007
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6822
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
6008
6823
  },
6009
6824
  {
6010
6825
  field: "readRule",
@@ -6013,7 +6828,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
6013
6828
  emit: "whenSet",
6014
6829
  writableOn: BOTH,
6015
6830
  // CEL, parsed and stored or nulled.
6016
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6831
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
6017
6832
  },
6018
6833
  {
6019
6834
  field: "writeRule",
@@ -6021,7 +6836,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
6021
6836
  type: "string",
6022
6837
  emit: "whenSet",
6023
6838
  writableOn: BOTH,
6024
- validation: handledBy(METADATA_HANDLER, "MetadataController"),
6839
+ validation: handledBy(METADATA_HANDLER, "MetadataController", "apply"),
6025
6840
  },
6026
6841
  {
6027
6842
  field: "description",
@@ -6041,7 +6856,7 @@ export const METADATA_CATEGORY_CONFIG_SURFACE = {
6041
6856
  // `{ fields: {} }` so the file stays pushable. Field types, enums and
6042
6857
  // the single-`unique` rule are checked locally before push and again
6043
6858
  // server-side.
6044
- validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema"),
6859
+ validation: handledBy(CLI_METADATA_TOML, "validateCategorySchema", "apply"),
6045
6860
  },
6046
6861
  ],
6047
6862
  notExposed: {
@@ -6197,34 +7012,16 @@ const functionRules = (stage, modulePath, exportName, entries) => entries.map(([
6197
7012
  stage,
6198
7013
  handler: enforcedBy(modulePath, exportName),
6199
7014
  }));
6200
- /** `mode` / `durable`: one fact, two spellings, resolved once (#3281). */
6201
- const FUNCTION_MODE_RULES = functionRules("preflight", "src/config-surface/function-mode.ts", "resolveDeclaredMode", [
6202
- ["mode-value", '`mode` must be exactly "request" or "task" when present.'],
6203
- ["durable-value", "`durable`, the accepted alias of `mode`, must be a boolean."],
6204
- [
6205
- "mode-durable-agreement",
6206
- "A file carrying both `mode` and `durable` must have them agree; a " +
6207
- "silent winner would be worse than two spellings.",
6208
- ],
6209
- ]);
6210
7015
  /** The trigger blocks' shape rules — one id per refusal (#3181, #3184, #3334). */
6211
7016
  const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface/trigger-declaration.ts", "normalizeTriggerDeclaration", [
6212
7017
  ["triggers-table", "`[function.triggers]` must be a table."],
6213
- ["trigger-kind", "Only `webhook`, `cron` and `database` are trigger kinds."],
6214
- [
6215
- "database-trigger-mode",
6216
- "A database trigger needs request mode: the fire runs inside the write " +
6217
- "request, and a task run has no answer to give there.",
6218
- ],
6219
- ["database-trigger-cap", "A function watches at most five database types."],
6220
- ["database-trigger-entry", "Each `[[function.triggers.database]]` entry is a table."],
6221
- ["database-trigger-keys", "A database entry accepts only `type`."],
6222
- ["database-trigger-type-required", "A database entry names its `type`."],
7018
+ ["trigger-kind", "Only `webhook` and `cron` are trigger kinds."],
6223
7019
  [
6224
- "database-trigger-type-charset",
6225
- "A database trigger type uses the grant grammar's charset.",
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.",
6226
7024
  ],
6227
- ["database-trigger-duplicate", "Each database type is declared once."],
6228
7025
  [
6229
7026
  "webhook-trigger-single",
6230
7027
  "`[function.triggers.webhook]` is one table, never an array: one webhook " +
@@ -6232,11 +7029,6 @@ const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface
6232
7029
  ],
6233
7030
  ["webhook-trigger-table", "`[function.triggers.webhook]` must be a table."],
6234
7031
  ["webhook-trigger-keys", "A webhook block accepts only its declared keys."],
6235
- [
6236
- "webhook-trigger-mode",
6237
- "A webhook trigger needs request mode: the provider waits inside the " +
6238
- "request for an answer a task run cannot give there.",
6239
- ],
6240
7032
  ["webhook-trigger-scheme-required", "A webhook block names its `verificationScheme`."],
6241
7033
  [
6242
7034
  "webhook-trigger-scheme-none",
@@ -6260,20 +7052,6 @@ const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface
6260
7052
  ["cron-trigger-duplicate", "Each cron name appears once within the function."],
6261
7053
  ["cron-trigger-expression-required", "A cron entry carries a `cron` expression."],
6262
7054
  ["cron-trigger-overlap-policy", "`overlapPolicy` is `skip` or `allow`."],
6263
- [
6264
- "cron-trigger-mode-value",
6265
- "A cron entry's `mode` names one of the two runners: a fire runs on one " +
6266
- "of them, so `any` is not a value it can take.",
6267
- ],
6268
- [
6269
- "cron-trigger-mode-required",
6270
- "A cron entry on an `any` function says which runner each fire uses: " +
6271
- "nothing else in the system answers it.",
6272
- ],
6273
- [
6274
- "cron-trigger-mode-locked",
6275
- "A cron entry may not name the runner the function's own lock forbids.",
6276
- ],
6277
7055
  ]);
6278
7056
  /**
6279
7057
  * The file-decidable half of the webhook `signingSecret` rule (#3320). The
@@ -6297,33 +7075,35 @@ const SIGNING_SECRET_RULES = functionRules("preflight", "src/config-surface/webh
6297
7075
  "`plaid`) or that verifies nothing (`none`) refuses a `signingSecret`.",
6298
7076
  ],
6299
7077
  ]);
6300
- /** What only the app's state can decide, at the push boundary (#3181). */
6301
- const FUNCTION_TRIGGER_STATE_RULES = functionRules("apply", "src/services/function-trigger-validation.ts", "validateFunctionTriggerDeclaration", [
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", [
6302
7088
  [
6303
- "database-trigger-type-exists",
6304
- "A watched database type must exist in the app, or the function would " +
6305
- "silently never fire.",
7089
+ "cron-trigger-expression",
7090
+ "The `cron` expression must parse under the server's five-field grammar " +
7091
+ "(`parseCron`).",
6306
7092
  ],
6307
7093
  [
6308
- "database-trigger-type-watchers",
6309
- "A database type has a per-type cap on the functions watching it.",
7094
+ "cron-trigger-timezone",
7095
+ "The `timezone`, unset reading as UTC, must be one " +
7096
+ "`Intl.DateTimeFormat` accepts.",
6310
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", [
6311
7101
  [
6312
7102
  "webhook-trigger-settings",
6313
7103
  "The webhook block's verification settings pass the same pipeline the " +
6314
7104
  "standalone create runs (scheme config, replay protection, secret " +
6315
7105
  "existence), prefixed `[function.triggers.webhook]: `.",
6316
7106
  ],
6317
- [
6318
- "cron-trigger-expression",
6319
- "The `cron` expression must parse (`parseCron`). File-decidable; #3376 " +
6320
- "moves it to preflight.",
6321
- ],
6322
- [
6323
- "cron-trigger-timezone",
6324
- "The `timezone` must be one `Intl.DateTimeFormat` accepts. " +
6325
- "File-decidable; #3376 moves it to preflight.",
6326
- ],
6327
7107
  [
6328
7108
  "webhook-trigger-key-held",
6329
7109
  "A standalone webhook that already holds the function's key blocks the " +
@@ -6366,7 +7146,7 @@ export const SERVER_FUNCTION_SURFACE = {
6366
7146
  // function with no gate fails closed at invoke, so accepting a bundle
6367
7147
  // for one would report a successful push of something that can never
6368
7148
  // run.
6369
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7149
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
6370
7150
  },
6371
7151
  {
6372
7152
  field: "inputSchema",
@@ -6374,7 +7154,7 @@ export const SERVER_FUNCTION_SURFACE = {
6374
7154
  type: "json",
6375
7155
  emit: "whenSet",
6376
7156
  writableOn: BOTH,
6377
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7157
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
6378
7158
  },
6379
7159
  {
6380
7160
  field: "outputSchema",
@@ -6382,7 +7162,7 @@ export const SERVER_FUNCTION_SURFACE = {
6382
7162
  type: "json",
6383
7163
  emit: "whenSet",
6384
7164
  writableOn: BOTH,
6385
- validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController"),
7165
+ validation: handledBy(FUNCTION_HANDLER, "ServerFunctionsController", "apply"),
6386
7166
  },
6387
7167
  ],
6388
7168
  notExposed: {
@@ -6418,6 +7198,18 @@ export const SERVER_FUNCTION_SURFACE = {
6418
7198
  "Repointed by a config-version push; versions are nameless, so " +
6419
7199
  "there is no `activeConfigName` for an author to write.",
6420
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
+ },
6421
7213
  runAs: {
6422
7214
  kind: "server-owned",
6423
7215
  note: "RETIRED from the write surface by #3279: function code acts as " +
@@ -6462,26 +7254,24 @@ export const SERVER_FUNCTION_SURFACE = {
6462
7254
  },
6463
7255
  mode: {
6464
7256
  kind: "structural",
6465
- note: '`mode = "request" | "task" | "any"` (#3281, widened by #3454): ' +
6466
- '"any" — the default when the key is absent — takes EITHER runner ' +
6467
- "and the caller picks at call time, with `invoke` running it " +
6468
- "inside the call and `start` returning a run id (#3186); " +
6469
- '"request" and "task" LOCK it to one, and the other door is ' +
6470
- "refused. Stored on the config version (its `mode` column, with " +
6471
- "`durable` beside it as the adjective) and pushed with it, so it " +
6472
- "is versioned with the code it describes rather than mutable on " +
6473
- "the header.",
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`.",
6474
7267
  },
6475
7268
  durable: {
6476
7269
  kind: "structural",
6477
- note: 'The accepted ALIAS of the two LOCKS (#3281, #3454): ' +
6478
- '`durable = true` is `mode = "task"` and `durable = false` is ' +
6479
- '`mode = "request"`. A boolean names one runner, so it cannot say ' +
6480
- '`mode = "any"`: beside it the file is refused naming both keys, ' +
6481
- "as two locks that disagree always were. Kept as the adjective for " +
6482
- "the property; `config pull` writes the authored bytes back " +
6483
- "unchanged, so a file that says it keeps saying it until its " +
6484
- "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.",
6485
7275
  },
6486
7276
  limits: {
6487
7277
  kind: "structural",
@@ -6524,9 +7314,11 @@ export const SERVER_FUNCTION_SURFACE = {
6524
7314
  // id-bearing slot until #3375 gives `ConfigField.validation` a stage, and a
6525
7315
  // module's list must equal its declarations in both directions.
6526
7316
  crossFieldRules: [
6527
- ...FUNCTION_MODE_RULES,
6528
7317
  ...TRIGGER_DECLARATION_RULES,
6529
7318
  ...SIGNING_SECRET_RULES,
7319
+ // #3376 — after the signing-secret group, mirroring the order
7320
+ // `functionTomlPreflightErrors` runs them in.
7321
+ ...CRON_SCHEDULE_RULES,
6530
7322
  ...FUNCTION_TRIGGER_STATE_RULES,
6531
7323
  ],
6532
7324
  };
@@ -6555,6 +7347,18 @@ export const SERVER_FUNCTION_SURFACE = {
6555
7347
  * re-stringifies on store.
6556
7348
  */
6557
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
+ */
6558
7362
  const CLI_SYNC = "cli/src/commands/sync.ts";
6559
7363
  export const TEST_CASE_SURFACE = {
6560
7364
  label: "test-case",
@@ -6572,7 +7376,7 @@ export const TEST_CASE_SURFACE = {
6572
7376
  emit: "always",
6573
7377
  writableOn: BOTH,
6574
7378
  // Required and trimmed on create; an empty name is a 400.
6575
- validation: handledBy(TEST_CASE_HANDLER, "AdminAPI"),
7379
+ validation: handledBy(TEST_CASE_HANDLER, "AdminAPI", "apply"),
6576
7380
  },
6577
7381
  {
6578
7382
  field: "description",
@@ -6590,7 +7394,7 @@ export const TEST_CASE_SURFACE = {
6590
7394
  writableOn: BOTH,
6591
7395
  // Required. TOML carries the JSON text; the wire carries the parsed
6592
7396
  // object, which the server re-stringifies for storage.
6593
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7397
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6594
7398
  },
6595
7399
  {
6596
7400
  field: "configId",
@@ -6599,7 +7403,7 @@ export const TEST_CASE_SURFACE = {
6599
7403
  emit: "always",
6600
7404
  writableOn: BOTH,
6601
7405
  // Authored as the config's NAME and resolved to an id at push time.
6602
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7406
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6603
7407
  },
6604
7408
  {
6605
7409
  field: "evaluatorPromptId",
@@ -6608,7 +7412,7 @@ export const TEST_CASE_SURFACE = {
6608
7412
  emit: "always",
6609
7413
  writableOn: BOTH,
6610
7414
  // Authored as the judge prompt's KEY and resolved to an id at push.
6611
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7415
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6612
7416
  },
6613
7417
  {
6614
7418
  field: "evaluatorConfigId",
@@ -6616,7 +7420,7 @@ export const TEST_CASE_SURFACE = {
6616
7420
  type: "string",
6617
7421
  emit: "always",
6618
7422
  writableOn: BOTH,
6619
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7423
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6620
7424
  },
6621
7425
  {
6622
7426
  field: "expectedOutputPattern",
@@ -6633,7 +7437,7 @@ export const TEST_CASE_SURFACE = {
6633
7437
  emit: "always",
6634
7438
  writableOn: BOTH,
6635
7439
  // JSON array of strings; TOML carries the JSON text.
6636
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7440
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6637
7441
  },
6638
7442
  {
6639
7443
  field: "expectedJsonSubset",
@@ -6642,7 +7446,7 @@ export const TEST_CASE_SURFACE = {
6642
7446
  emit: "always",
6643
7447
  writableOn: BOTH,
6644
7448
  // JSON object, deep-partial-matched against the parsed output.
6645
- validation: handledBy(CLI_SYNC, "parseTestCaseToml"),
7449
+ validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
6646
7450
  },
6647
7451
  ],
6648
7452
  notExposed: {
@@ -6836,7 +7640,7 @@ export const APP_SETTINGS_SURFACE = {
6836
7640
  // with `=== true`, so `"false"` must be a 400 rather than a stored
6837
7641
  // `true`. The generated request schema stays keys-only (#1976), so
6838
7642
  // the check is `parseDirectLlmEnabledWrite` in the handler.
6839
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7643
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6840
7644
  },
6841
7645
  {
6842
7646
  field: "allowedDomains",
@@ -6854,7 +7658,7 @@ export const APP_SETTINGS_SURFACE = {
6854
7658
  writableOn: UPDATE_ONLY,
6855
7659
  // `+primitivetest` bypass whitelist (#490/#675), shape-checked by
6856
7660
  // `validateTestAccountBaseEmails`.
6857
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7661
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6858
7662
  },
6859
7663
  {
6860
7664
  field: "largeDocumentWindowDays",
@@ -6868,7 +7672,7 @@ export const APP_SETTINGS_SURFACE = {
6868
7672
  // Refused rather than clamped: answering 200 to a 30-day window the
6869
7673
  // server does not keep would have the caller plan a month of
6870
7674
  // offline work around it (`parseLargeDocumentWindowSetting`).
6871
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7675
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6872
7676
  },
6873
7677
  ],
6874
7678
  notExposed: {
@@ -6986,7 +7790,7 @@ export const APP_SETTINGS_SURFACE = {
6986
7790
  // per-type secret table, cross-entry URI uniqueness and the
6987
7791
  // aggregate caps are all `validateGoogleClientsWrite`
6988
7792
  // (`src/services/google-clients.ts`), applied by the handler.
6989
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7793
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
6990
7794
  },
6991
7795
  {
6992
7796
  field: "passkeyEnabled",
@@ -7007,7 +7811,7 @@ export const APP_SETTINGS_SURFACE = {
7007
7811
  writableOn: UPDATE_ONLY,
7008
7812
  // Mirrored into the two retired model fields by the handler so an
7009
7813
  // un-migrated reader cannot disagree.
7010
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7814
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
7011
7815
  },
7012
7816
  {
7013
7817
  field: "appleSignInEnabled",
@@ -7037,7 +7841,7 @@ export const APP_SETTINGS_SURFACE = {
7037
7841
  // `validateRedirectUris`; matching is fail-closed, and a request
7038
7842
  // whose target this list does not cover gets a code-only email
7039
7843
  // rather than a link (#2884).
7040
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7844
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
7041
7845
  },
7042
7846
  {
7043
7847
  field: "passkeyRpConfig",
@@ -7047,7 +7851,7 @@ export const APP_SETTINGS_SURFACE = {
7047
7851
  writableOn: UPDATE_ONLY,
7048
7852
  // `[auth.passkeys]` — rpId → { name }, shape-checked by
7049
7853
  // `SettingsController.validatePasskeyRpConfig`.
7050
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7854
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
7051
7855
  },
7052
7856
  {
7053
7857
  field: "passkeyUserVerification",
@@ -7065,7 +7869,7 @@ export const APP_SETTINGS_SURFACE = {
7065
7869
  // both halves of every passkey ceremony state, so an unrecognized
7066
7870
  // value is a 400 rather than a silent fall back to the default —
7067
7871
  // the `directLlmEnabled` rationale, applied to a security policy.
7068
- validation: handledBy(SETTINGS_HANDLER, "SettingsController"),
7872
+ validation: handledBy(SETTINGS_HANDLER, "SettingsController", "apply"),
7069
7873
  },
7070
7874
  ],
7071
7875
  notExposed: {},
@@ -7535,6 +8339,8 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
7535
8339
  "description",
7536
8340
  "status",
7537
8341
  "activeConfigId",
8342
+ "activeConfigActivatedAt",
8343
+ "activeConfigActivatedBy",
7538
8344
  "runAs",
7539
8345
  "access",
7540
8346
  "inputSchema",