@vxil/feature-configs 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -656,6 +656,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
656
656
  secretApiKeyRef: import("@sinclair/typebox").TString;
657
657
  webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
658
658
  acceptSandbox: import("@sinclair/typebox").TBoolean;
659
+ sandboxUsers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
659
660
  }>>;
660
661
  paypal: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
661
662
  clientIdRef: import("@sinclair/typebox").TString;
@@ -685,6 +686,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
685
686
  creditType: import("@sinclair/typebox").TString;
686
687
  amount: import("@sinclair/typebox").TInteger;
687
688
  period: import("@sinclair/typebox").TString;
689
+ mode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"add">, import("@sinclair/typebox").TLiteral<"reset">]>>;
688
690
  }>>>;
689
691
  }>>;
690
692
  priceMap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
@@ -718,11 +720,17 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
718
720
  retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
719
721
  maxAttempts: import("@sinclair/typebox").TInteger;
720
722
  }>>;
723
+ overlap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"allow">, import("@sinclair/typebox").TLiteral<"skip">]>>;
721
724
  }>>>;
722
725
  scriptRef: import("@sinclair/typebox").TString;
723
726
  scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
724
727
  secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
725
728
  egressAllow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
729
+ runtime: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
730
+ compatibilityDate: import("@sinclair/typebox").TString;
731
+ compatibilityFlags: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
732
+ cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
733
+ }>>;
726
734
  limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
727
735
  cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
728
736
  timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
@@ -790,6 +798,19 @@ export declare const CONFIG_FLAG_CAP = 15;
790
798
  * reconciler maps each to its `auth.<event>` audit-event prefix. */
791
799
  export declare const AUTH_HOOK_EVENTS: readonly ["user.created", "session.created", "session.revoked", "signin.failure"];
792
800
  export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
801
+ /** The events a `cmsHook` function binding may name — the closed union
802
+ * `packages/config` types on the `cmsHook` trigger, and the keys of the
803
+ * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
804
+ * beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
805
+ * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
806
+ * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
807
+ * 'beforeDelete' silently subscribed the function to creates AND updates.
808
+ * tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
809
+ export declare const CMS_HOOK_EVENTS: readonly ["beforeCreate", "beforeUpdate", "beforeWrite"];
810
+ export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
811
+ /** The ONE refusal text for an unknown cmsHook binding event (the config
812
+ * validator and the deploy route's early 422 both use it). */
813
+ export declare function cmsHookEventError(event: unknown): string | null;
793
814
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
794
815
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
795
816
  export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
package/dist/index.js CHANGED
@@ -1116,8 +1116,9 @@ export const AiConfigSchema = Type.Object({
1116
1116
  // stamps on the latest version, and POSTs a new version ONLY when the content
1117
1117
  // differs — so a push is idempotent and versions stay monotonic per name.
1118
1118
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1119
- // name). Stored templates the config does not declare are reported (there is
1120
- // no delete route — they are never removed). Bounded to 50 entries: the
1119
+ // name). Stored templates the config does not declare are reported and left
1120
+ // in place — RETIRED (soft: hidden from list + render, history kept) only
1121
+ // under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
1121
1122
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1122
1123
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1123
1124
  });
@@ -1220,6 +1221,17 @@ export const PaymentsConfigSchema = Type.Object({
1220
1221
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1221
1222
  // environments by signing secret / API base, so only RC carries this knob.
1222
1223
  acceptSandbox: Type.Boolean({ default: false }),
1224
+ // (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
1225
+ // the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
1226
+ // whose subject (and, for a TRANSFER, every source user) is listed here
1227
+ // folds — recorded `environment: 'sandbox'` on the delivery, the
1228
+ // subscription and the charge, so it stays out of revenue — while every
1229
+ // other sandbox event is still `rejected_environment`. The narrow,
1230
+ // production-safe alternative to `acceptSandbox` (which folds EVERY
1231
+ // TestFlight purchase and is refused by the CLI's production promotion
1232
+ // gate). No default on purpose (Value.Default would materialize it into
1233
+ // every RC manifest); absent = an empty list.
1234
+ sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
1223
1235
  })),
1224
1236
  paypal: Type.Optional(Type.Object({
1225
1237
  clientIdRef: Type.String(),
@@ -1250,9 +1262,12 @@ export const PaymentsConfigSchema = Type.Object({
1250
1262
  // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1251
1263
  // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1252
1264
  // below) for `durationDays`, as a charge-linked manual-style row that
1253
- // STACKS on the user's live purchases of the same tier and is ENDED by
1254
- // that charge's full refund / chargeback. No defaults in either shape,
1255
- // so an existing manifest is byte-identical after Value.Default.
1265
+ // STACKS behind the user's live same-tier manual rows that have an end
1266
+ // (earlier passes AND comp grants since 2026-09-25 F38 — never a row
1267
+ // linked to the same charge, an open-ended grant or a provider
1268
+ // subscription) and is ENDED by that charge's full refund / chargeback.
1269
+ // No defaults in either shape, so an existing manifest is
1270
+ // byte-identical after Value.Default.
1256
1271
  productMap: Type.Record(Type.String(), Type.Union([
1257
1272
  Type.Object({
1258
1273
  creditType: Type.String({ minLength: 1 }),
@@ -1273,6 +1288,14 @@ export const PaymentsConfigSchema = Type.Object({
1273
1288
  creditType: Type.String({ minLength: 1 }),
1274
1289
  amount: Type.Integer({ minimum: 1 }), // #128
1275
1290
  period: Type.String(),
1291
+ // (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
1292
+ // behaviour) ADDS `amount` each period; 'reset' makes the period's
1293
+ // grant REPLACE what is left: the unspent available balance of
1294
+ // `creditType` is written off as one `expire` ledger row and `amount`
1295
+ // granted, so the user starts every period with exactly `amount`
1296
+ // (credits held by an in-flight job stay held). No schema default (it
1297
+ // would materialize into every manifest carrying grants).
1298
+ mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
1276
1299
  }))),
1277
1300
  })),
1278
1301
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -1396,11 +1419,30 @@ export const FunctionsConfigSchema = Type.Object({
1396
1419
  retry: Type.Optional(Type.Object({
1397
1420
  maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
1398
1421
  })),
1422
+ // W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
1423
+ // while the previous tick's run is still open (queued / running /
1424
+ // retrying / waiting / delayed). Absent = 'allow' (every tick runs).
1425
+ overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
1399
1426
  }), { maxItems: 8 })),
1400
1427
  scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1401
1428
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1402
1429
  secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1403
1430
  egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1431
+ // R1 (2026-10-01): the runtime settings this function's script was
1432
+ // UPLOADED with — server-SET by the deploy pipeline (never authored),
1433
+ // read back by the rollback re-upload so a restored script runs under
1434
+ // the settings it was deployed and tested with, not today's. Absent =
1435
+ // the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
1436
+ // strings on purpose (the deploy writes them from the one constant).
1437
+ // cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
1438
+ // uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
1439
+ // cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
1440
+ // rewrites it after a tier change. Absent = the platform default.
1441
+ runtime: Type.Optional(Type.Object({
1442
+ compatibilityDate: Type.String(),
1443
+ compatibilityFlags: Type.Optional(Type.Array(Type.String())),
1444
+ cpuMs: Type.Optional(Type.Number()),
1445
+ })),
1404
1446
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1405
1447
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
1406
1448
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -1565,6 +1607,24 @@ export const CONFIG_FLAG_CAP = 15;
1565
1607
  * closed union `packages/config` types as AuthHookEvent; the control-plane
1566
1608
  * reconciler maps each to its `auth.<event>` audit-event prefix. */
1567
1609
  export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'];
1610
+ /** The events a `cmsHook` function binding may name — the closed union
1611
+ * `packages/config` types on the `cmsHook` trigger, and the keys of the
1612
+ * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
1613
+ * beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
1614
+ * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
1615
+ * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
1616
+ * 'beforeDelete' silently subscribed the function to creates AND updates.
1617
+ * tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
1618
+ export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'];
1619
+ /** The ONE refusal text for an unknown cmsHook binding event (the config
1620
+ * validator and the deploy route's early 422 both use it). */
1621
+ export function cmsHookEventError(event) {
1622
+ if (event === undefined)
1623
+ return null;
1624
+ if (typeof event === 'string' && CMS_HOOK_EVENTS.includes(event))
1625
+ return null;
1626
+ return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
1627
+ }
1568
1628
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1569
1629
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1570
1630
  export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
@@ -1840,8 +1900,39 @@ export function validateFeatureConfig(feature, raw) {
1840
1900
  if (g.creditType && isReservedCreditType(g.creditType)) {
1841
1901
  ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1842
1902
  }
1903
+ // (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
1904
+ // no period to reset, and a second grant of the same credit type in the
1905
+ // same tier would be wiped (or wipe it) depending on array order.
1906
+ if (g.mode === 'reset') {
1907
+ if (g.period === 'once') {
1908
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
1909
+ }
1910
+ const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
1911
+ if (twin >= 0) {
1912
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`);
1913
+ }
1914
+ }
1915
+ });
1916
+ }
1917
+ // (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
1918
+ // of its credit type each period — purchased credits of that type included.
1919
+ // A credit pack of a reset type would lose paid value at the next renewal,
1920
+ // so it is refused here: keep packs on their own type and spend in order
1921
+ // (`credit_types: ['plan', 'topup']`).
1922
+ const resetBy = new Map();
1923
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1924
+ (rule.grants ?? []).forEach((g, i) => {
1925
+ if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
1926
+ resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
1927
+ }
1843
1928
  });
1844
1929
  }
1930
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1931
+ const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
1932
+ if (by) {
1933
+ ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`);
1934
+ }
1935
+ }
1845
1936
  // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
1846
1937
  // customer (priceMap miss → tier NULL → refold excluded the row). The
1847
1938
  // reducer now stamps such an event outcome 'error' (reprocessable), and
@@ -2013,6 +2104,9 @@ export function validateFeatureConfig(feature, raw) {
2013
2104
  if (b.kind === 'cron' && !b.schedule) {
2014
2105
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2015
2106
  }
2107
+ if (b.overlap !== undefined && b.kind !== 'cron') {
2108
+ errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2109
+ }
2016
2110
  // F33: retry is an opt-in for the platform-delivered event lanes only —
2017
2111
  // an http invoke returns its real status to its caller, and a cron
2018
2112
  // tick's retry would overlap the next tick.
@@ -2022,6 +2116,11 @@ export function validateFeatureConfig(feature, raw) {
2022
2116
  if (b.kind === 'cmsHook' && !b.collection) {
2023
2117
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2024
2118
  }
2119
+ // E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
2120
+ // at write time, never a silent created+updated subscription.
2121
+ const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
2122
+ if (cmsEventErr)
2123
+ errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
2025
2124
  // authHook: a CLOSED event union (F4-30) — reject typos at write time
2026
2125
  // so a binding never silently subscribes to nothing.
2027
2126
  if (b.kind === 'authHook' && b.event !== undefined && !AUTH_HOOK_EVENTS.includes(b.event)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/index.ts CHANGED
@@ -1405,8 +1405,9 @@ export const AiConfigSchema = Type.Object({
1405
1405
  // stamps on the latest version, and POSTs a new version ONLY when the content
1406
1406
  // differs — so a push is idempotent and versions stay monotonic per name.
1407
1407
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1408
- // name). Stored templates the config does not declare are reported (there is
1409
- // no delete route — they are never removed). Bounded to 50 entries: the
1408
+ // name). Stored templates the config does not declare are reported and left
1409
+ // in place — RETIRED (soft: hidden from list + render, history kept) only
1410
+ // under --allow-destructive (cvskit F67, 2026-10-01). Bounded to 50 entries: the
1410
1411
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1411
1412
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1412
1413
  });
@@ -1537,6 +1538,17 @@ export const PaymentsConfigSchema = Type.Object({
1537
1538
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1538
1539
  // environments by signing secret / API base, so only RC carries this knob.
1539
1540
  acceptSandbox: Type.Boolean({ default: false }),
1541
+ // (2026-10-01 §4.13 A18) Store-review purchases on a PRODUCTION tenant:
1542
+ // the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
1543
+ // whose subject (and, for a TRANSFER, every source user) is listed here
1544
+ // folds — recorded `environment: 'sandbox'` on the delivery, the
1545
+ // subscription and the charge, so it stays out of revenue — while every
1546
+ // other sandbox event is still `rejected_environment`. The narrow,
1547
+ // production-safe alternative to `acceptSandbox` (which folds EVERY
1548
+ // TestFlight purchase and is refused by the CLI's production promotion
1549
+ // gate). No default on purpose (Value.Default would materialize it into
1550
+ // every RC manifest); absent = an empty list.
1551
+ sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
1540
1552
  })),
1541
1553
  paypal: Type.Optional(Type.Object({
1542
1554
  clientIdRef: Type.String(),
@@ -1570,9 +1582,12 @@ export const PaymentsConfigSchema = Type.Object({
1570
1582
  // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1571
1583
  // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1572
1584
  // below) for `durationDays`, as a charge-linked manual-style row that
1573
- // STACKS on the user's live purchases of the same tier and is ENDED by
1574
- // that charge's full refund / chargeback. No defaults in either shape,
1575
- // so an existing manifest is byte-identical after Value.Default.
1585
+ // STACKS behind the user's live same-tier manual rows that have an end
1586
+ // (earlier passes AND comp grants since 2026-09-25 F38 — never a row
1587
+ // linked to the same charge, an open-ended grant or a provider
1588
+ // subscription) and is ENDED by that charge's full refund / chargeback.
1589
+ // No defaults in either shape, so an existing manifest is
1590
+ // byte-identical after Value.Default.
1576
1591
  productMap: Type.Record(Type.String(), Type.Union([
1577
1592
  Type.Object({
1578
1593
  creditType: Type.String({ minLength: 1 }),
@@ -1593,6 +1608,14 @@ export const PaymentsConfigSchema = Type.Object({
1593
1608
  creditType: Type.String({ minLength: 1 }),
1594
1609
  amount: Type.Integer({ minimum: 1 }), // #128
1595
1610
  period: Type.String(),
1611
+ // (2026-10-01 §4.13 W9) 'add' (the reader's default, today's
1612
+ // behaviour) ADDS `amount` each period; 'reset' makes the period's
1613
+ // grant REPLACE what is left: the unspent available balance of
1614
+ // `creditType` is written off as one `expire` ledger row and `amount`
1615
+ // granted, so the user starts every period with exactly `amount`
1616
+ // (credits held by an in-flight job stay held). No schema default (it
1617
+ // would materialize into every manifest carrying grants).
1618
+ mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
1596
1619
  }))),
1597
1620
  })),
1598
1621
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -1737,6 +1760,10 @@ export const FunctionsConfigSchema = Type.Object({
1737
1760
  retry: Type.Optional(Type.Object({
1738
1761
  maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
1739
1762
  })),
1763
+ // W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
1764
+ // while the previous tick's run is still open (queued / running /
1765
+ // retrying / waiting / delayed). Absent = 'allow' (every tick runs).
1766
+ overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
1740
1767
  }),
1741
1768
  { maxItems: 8 },
1742
1769
  ),
@@ -1745,6 +1772,23 @@ export const FunctionsConfigSchema = Type.Object({
1745
1772
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1746
1773
  secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1747
1774
  egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1775
+ // R1 (2026-10-01): the runtime settings this function's script was
1776
+ // UPLOADED with — server-SET by the deploy pipeline (never authored),
1777
+ // read back by the rollback re-upload so a restored script runs under
1778
+ // the settings it was deployed and tested with, not today's. Absent =
1779
+ // the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
1780
+ // strings on purpose (the deploy writes them from the one constant).
1781
+ // cpuMs (F2, 2026-10-01): the per-invoke CPU limit the script was
1782
+ // uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
1783
+ // cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
1784
+ // rewrites it after a tier change. Absent = the platform default.
1785
+ runtime: Type.Optional(
1786
+ Type.Object({
1787
+ compatibilityDate: Type.String(),
1788
+ compatibilityFlags: Type.Optional(Type.Array(Type.String())),
1789
+ cpuMs: Type.Optional(Type.Number()),
1790
+ }),
1791
+ ),
1748
1792
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1749
1793
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
1750
1794
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -1952,6 +1996,25 @@ export const CONFIG_FLAG_CAP = 15;
1952
1996
  export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
1953
1997
  export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
1954
1998
 
1999
+ /** The events a `cmsHook` function binding may name — the closed union
2000
+ * `packages/config` types on the `cmsHook` trigger, and the keys of the
2001
+ * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
2002
+ * beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
2003
+ * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
2004
+ * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
2005
+ * 'beforeDelete' silently subscribed the function to creates AND updates.
2006
+ * tests/ci/src/fn-binding-contracts.test.ts pins every copy to this list. */
2007
+ export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'] as const;
2008
+ export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
2009
+
2010
+ /** The ONE refusal text for an unknown cmsHook binding event (the config
2011
+ * validator and the deploy route's early 422 both use it). */
2012
+ export function cmsHookEventError(event: unknown): string | null {
2013
+ if (event === undefined) return null;
2014
+ if (typeof event === 'string' && (CMS_HOOK_EVENTS as readonly string[]).includes(event)) return null;
2015
+ return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
2016
+ }
2017
+
1955
2018
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1956
2019
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1957
2020
  export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
@@ -2220,7 +2283,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2220
2283
  provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
2221
2284
  ledger?: {
2222
2285
  productMap?: Record<string, { creditType?: string; tier?: string }>;
2223
- tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
2286
+ tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string; period?: string; mode?: string }> }>;
2224
2287
  priceMap?: Record<string, string>;
2225
2288
  };
2226
2289
  };
@@ -2260,8 +2323,43 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2260
2323
  `/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
2261
2324
  );
2262
2325
  }
2326
+ // (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
2327
+ // no period to reset, and a second grant of the same credit type in the
2328
+ // same tier would be wiped (or wipe it) depending on array order.
2329
+ if (g.mode === 'reset') {
2330
+ if (g.period === 'once') {
2331
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
2332
+ }
2333
+ const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
2334
+ if (twin >= 0) {
2335
+ ledgerErrs.push(
2336
+ `/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`,
2337
+ );
2338
+ }
2339
+ }
2263
2340
  });
2264
2341
  }
2342
+ // (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
2343
+ // of its credit type each period — purchased credits of that type included.
2344
+ // A credit pack of a reset type would lose paid value at the next renewal,
2345
+ // so it is refused here: keep packs on their own type and spend in order
2346
+ // (`credit_types: ['plan', 'topup']`).
2347
+ const resetBy = new Map<string, string>();
2348
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
2349
+ (rule.grants ?? []).forEach((g, i) => {
2350
+ if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
2351
+ resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
2352
+ }
2353
+ });
2354
+ }
2355
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
2356
+ const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
2357
+ if (by) {
2358
+ ledgerErrs.push(
2359
+ `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`,
2360
+ );
2361
+ }
2362
+ }
2265
2363
  // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
2266
2364
  // customer (priceMap miss → tier NULL → refold excluded the row). The
2267
2365
  // reducer now stamps such an event outcome 'error' (reprocessable), and
@@ -2430,7 +2528,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2430
2528
  string,
2431
2529
  {
2432
2530
  scriptRef?: string; scopes?: string[]; signature?: unknown;
2433
- bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
2531
+ bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number }; overlap?: string }>;
2434
2532
  }
2435
2533
  >;
2436
2534
  };
@@ -2446,6 +2544,9 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2446
2544
  if (b.kind === 'cron' && !b.schedule) {
2447
2545
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2448
2546
  }
2547
+ if (b.overlap !== undefined && b.kind !== 'cron') {
2548
+ errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2549
+ }
2449
2550
  // F33: retry is an opt-in for the platform-delivered event lanes only —
2450
2551
  // an http invoke returns its real status to its caller, and a cron
2451
2552
  // tick's retry would overlap the next tick.
@@ -2455,6 +2556,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2455
2556
  if (b.kind === 'cmsHook' && !b.collection) {
2456
2557
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2457
2558
  }
2559
+ // E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
2560
+ // at write time, never a silent created+updated subscription.
2561
+ const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
2562
+ if (cmsEventErr) errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
2458
2563
  // authHook: a CLOSED event union (F4-30) — reject typos at write time
2459
2564
  // so a binding never silently subscribes to nothing.
2460
2565
  if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {