@vxil/feature-configs 0.5.1 → 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.
@@ -80,12 +80,14 @@ export interface LiveWebhookSubscription {
80
80
  }
81
81
  export interface WebhookSubscriptionPlan {
82
82
  create: DeclaredWebhookSubscription[];
83
- /** the prefix SET differs — there is no update route, so delete+recreate (a
84
- * NEW sub_id, and the watermark restarts at "now"): destructive-shaped. */
85
- recreate: Array<{
83
+ /** the prefix SET differs — `PATCH /v1/webhooks/subscriptions/:subId
84
+ * { event_prefixes }` (2026-09-25) changes it in place: the sub_id and the
85
+ * cursor are kept, so this is an ordinary update, not destructive-shaped
86
+ * (before 2026-09-25 it was delete+recreate under --allow-destructive). */
87
+ update: Array<{
86
88
  sub_id: string;
87
89
  target_url: string;
88
- declared: DeclaredWebhookSubscription;
90
+ event_prefixes: string[];
89
91
  }>;
90
92
  unchanged: string[];
91
93
  /** live rows the config does not declare, EXCLUDING the function-delivery
package/dist/apiState.js CHANGED
@@ -93,7 +93,7 @@ function sameStringSet(a, b) {
93
93
  return A.size === B.size && [...A].every((x) => B.has(x));
94
94
  }
95
95
  export function planWebhookSubscriptions(declared, live) {
96
- const plan = { create: [], recreate: [], unchanged: [], undeclared: [] };
96
+ const plan = { create: [], update: [], unchanged: [], undeclared: [] };
97
97
  const declaredUrls = new Set(declared.map((d) => d.target_url));
98
98
  const matched = new Set();
99
99
  for (const d of declared) {
@@ -105,14 +105,14 @@ export function planWebhookSubscriptions(declared, live) {
105
105
  }
106
106
  // Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
107
107
  // to the declaration is adopted as-is — no churn); else the first row is
108
- // the one recreated and the rest fall through as undeclared duplicates.
108
+ // the one updated in place and the rest fall through as undeclared duplicates.
109
109
  const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
110
110
  const chosen = exact ?? rows[0];
111
111
  matched.add(chosen.sub_id);
112
112
  if (exact)
113
113
  plan.unchanged.push(d.target_url);
114
114
  else
115
- plan.recreate.push({ sub_id: chosen.sub_id, target_url: d.target_url, declared: d });
115
+ plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
116
116
  }
117
117
  for (const l of live) {
118
118
  if (matched.has(l.sub_id))
package/dist/index.d.ts CHANGED
@@ -4,6 +4,12 @@ export * from './readmodels.js';
4
4
  export * from './apiState.js';
5
5
  export * from './canonicalJson.js';
6
6
  export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
7
+ /** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
8
+ * binding kinds that may carry `retry` — the platform-delivered event lanes
9
+ * (an http invoke returns its own status; a cron tick's retry would overlap
10
+ * the next tick). Read by the schema, the deploy clamp and the CLI. */
11
+ export declare const FN_RETRY_MAX_ATTEMPTS = 5;
12
+ export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
7
13
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
8
14
  * of which is restricted to internal platform machinery). */
9
15
  export declare function isReservedCreditType(creditType: string): boolean;
@@ -39,6 +45,10 @@ export declare function validateNotificationOverrides(templates: {
39
45
  text?: string;
40
46
  }>>;
41
47
  } | undefined): string[];
48
+ /** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
49
+ * `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
50
+ * in the notifications worker's snsIntake.ts. */
51
+ export declare const SNS_TOPIC_ARN_PATTERN = "^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$";
42
52
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
43
53
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
44
54
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -57,6 +67,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
57
67
  region: import("@sinclair/typebox").TString;
58
68
  accessKeyIdRef: import("@sinclair/typebox").TString;
59
69
  secretAccessKeyRef: import("@sinclair/typebox").TString;
70
+ snsTopicArn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
60
71
  }>>;
61
72
  defaultLocale: import("@sinclair/typebox").TString;
62
73
  retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
@@ -105,6 +116,49 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
105
116
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
106
117
  /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
107
118
  export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
119
+ /** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
120
+ export declare const GENERATION_DEFAULTS: {
121
+ /** per-tenant in-flight generation cap (separate budget from queue jobs) */
122
+ readonly maxConcurrent: 20;
123
+ /** default expiry/timeout when the descriptor omits one — 5 min */
124
+ readonly defaultTimeoutMs: number;
125
+ /** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
126
+ readonly maxTimeoutMs: number;
127
+ /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
128
+ readonly pollMaxAttempts: 60;
129
+ /** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
130
+ readonly maxReserveCredits: 1000;
131
+ /** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
132
+ readonly maxOutstandingReserveCredits: 100000;
133
+ };
134
+ /** The inclusive [min, max] each `generation` leaf is clamped to — by the
135
+ * schema at config-write time and by jobs-v1 at read time. */
136
+ export declare const GENERATION_BOUNDS: {
137
+ readonly maxConcurrent: {
138
+ readonly min: 1;
139
+ readonly max: 200;
140
+ };
141
+ readonly defaultTimeoutMs: {
142
+ readonly min: 1000;
143
+ readonly max: 3600000;
144
+ };
145
+ readonly maxTimeoutMs: {
146
+ readonly min: 1000;
147
+ readonly max: 3600000;
148
+ };
149
+ readonly pollMaxAttempts: {
150
+ readonly min: 1;
151
+ readonly max: 1000;
152
+ };
153
+ readonly maxReserveCredits: {
154
+ readonly min: 1;
155
+ readonly max: 1000000;
156
+ };
157
+ readonly maxOutstandingReserveCredits: {
158
+ readonly min: 1;
159
+ readonly max: 100000000;
160
+ };
161
+ };
108
162
  export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
109
163
  enabled: import("@sinclair/typebox").TBoolean;
110
164
  retry: import("@sinclair/typebox").TObject<{
@@ -602,6 +656,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
602
656
  secretApiKeyRef: import("@sinclair/typebox").TString;
603
657
  webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
604
658
  acceptSandbox: import("@sinclair/typebox").TBoolean;
659
+ sandboxUsers: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
605
660
  }>>;
606
661
  paypal: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
607
662
  clientIdRef: import("@sinclair/typebox").TString;
@@ -615,11 +670,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
615
670
  trialDays: import("@sinclair/typebox").TInteger;
616
671
  }>;
617
672
  ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
618
- productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
673
+ productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
619
674
  creditType: import("@sinclair/typebox").TString;
620
675
  amount: import("@sinclair/typebox").TInteger;
621
676
  period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
622
- }>>;
677
+ }>, import("@sinclair/typebox").TObject<{
678
+ tier: import("@sinclair/typebox").TString;
679
+ durationDays: import("@sinclair/typebox").TInteger;
680
+ }>]>>;
623
681
  tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
624
682
  entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
625
683
  quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
@@ -628,6 +686,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
628
686
  creditType: import("@sinclair/typebox").TString;
629
687
  amount: import("@sinclair/typebox").TInteger;
630
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">]>>;
631
690
  }>>>;
632
691
  }>>;
633
692
  priceMap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TString>>;
@@ -658,11 +717,20 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
658
717
  source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
659
718
  collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
660
719
  event: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
720
+ retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
721
+ maxAttempts: import("@sinclair/typebox").TInteger;
722
+ }>>;
723
+ overlap: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"allow">, import("@sinclair/typebox").TLiteral<"skip">]>>;
661
724
  }>>>;
662
725
  scriptRef: import("@sinclair/typebox").TString;
663
726
  scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
664
727
  secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
665
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
+ }>>;
666
734
  limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
667
735
  cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
668
736
  timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
@@ -730,6 +798,19 @@ export declare const CONFIG_FLAG_CAP = 15;
730
798
  * reconciler maps each to its `auth.<event>` audit-event prefix. */
731
799
  export declare const AUTH_HOOK_EVENTS: readonly ["user.created", "session.created", "session.revoked", "signin.failure"];
732
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;
733
814
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
734
815
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
735
816
  export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
package/dist/index.js CHANGED
@@ -36,6 +36,12 @@ if (!FormatRegistry.Has('email')) {
36
36
  // config-write gate AND payments-v1 import) so the runtime choke point and the
37
37
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
38
38
  export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
39
+ /** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
40
+ * binding kinds that may carry `retry` — the platform-delivered event lanes
41
+ * (an http invoke returns its own status; a cron tick's retry would overlap
42
+ * the next tick). Read by the schema, the deploy clamp and the CLI. */
43
+ export const FN_RETRY_MAX_ATTEMPTS = 5;
44
+ export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
39
45
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
40
46
  * of which is restricted to internal platform machinery). */
41
47
  export function isReservedCreditType(creditType) {
@@ -148,6 +154,10 @@ export function validateNotificationOverrides(templates) {
148
154
  }
149
155
  return errs;
150
156
  }
157
+ /** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
158
+ * `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
159
+ * in the notifications worker's snsIntake.ts. */
160
+ export const SNS_TOPIC_ARN_PATTERN = '^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$';
151
161
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
152
162
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
153
163
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -190,6 +200,13 @@ export const NotificationsConfigSchema = Type.Object({
190
200
  region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
191
201
  accessKeyIdRef: Type.String({ minLength: 1 }),
192
202
  secretAccessKeyRef: Type.String({ minLength: 1 }),
203
+ // Bounce/complaint intake (2026-09-25): the SNS topic SES publishes its
204
+ // bounce/complaint events to; its HTTPS subscription posts to the worker's
205
+ // `/v1/notifications/webhooks/ses/<tenant>/<tag>` route, which accepts a
206
+ // message ONLY when its TopicArn equals this value (fail closed: unset ⇒
207
+ // the lane answers 404). Plain config, not a secret — the AWS signature is
208
+ // the proof. Inside the Optional bag ⇒ zero leaf cost.
209
+ snsTopicArn: Type.Optional(Type.String({ pattern: SNS_TOPIC_ARN_PATTERN, maxLength: 320 })),
193
210
  })),
194
211
  defaultLocale: Type.String({ default: 'en-US' }),
195
212
  // nested objects carry `default: {}` so Value.Default can materialize them
@@ -272,6 +289,41 @@ export const NotificationsConfigSchema = Type.Object({
272
289
  freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
273
290
  })),
274
291
  });
292
+ // ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
293
+ // (roadmap §4.10, 2026-09-25 — "dropping the jobs-v1 GENERATION_DEFAULTS
294
+ // copy"). The JobsConfigSchema `generation` leaf reads these for its
295
+ // `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
296
+ // imports them (jobs-v1 already depends on @vxil/feature-configs; this package
297
+ // has no @vxil/types dependency, so the shared value lives here — the
298
+ // RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
299
+ // feature-configs unit test pins schema ↔ constant, and the CI gate
300
+ // tests/ci/src/generation-defaults-single-source.test.ts pins that no second
301
+ // object-literal declaration of the constant reappears anywhere.
302
+ /** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
303
+ export const GENERATION_DEFAULTS = {
304
+ /** per-tenant in-flight generation cap (separate budget from queue jobs) */
305
+ maxConcurrent: 20,
306
+ /** default expiry/timeout when the descriptor omits one — 5 min */
307
+ defaultTimeoutMs: 5 * 60_000,
308
+ /** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
309
+ maxTimeoutMs: 60 * 60_000,
310
+ /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
311
+ pollMaxAttempts: 60,
312
+ /** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
313
+ maxReserveCredits: 1_000,
314
+ /** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
315
+ maxOutstandingReserveCredits: 100_000,
316
+ };
317
+ /** The inclusive [min, max] each `generation` leaf is clamped to — by the
318
+ * schema at config-write time and by jobs-v1 at read time. */
319
+ export const GENERATION_BOUNDS = {
320
+ maxConcurrent: { min: 1, max: 200 },
321
+ defaultTimeoutMs: { min: 1_000, max: 3_600_000 },
322
+ maxTimeoutMs: { min: 1_000, max: 3_600_000 },
323
+ pollMaxAttempts: { min: 1, max: 1_000 },
324
+ maxReserveCredits: { min: 1, max: 1_000_000 },
325
+ maxOutstandingReserveCredits: { min: 1, max: 100_000_000 },
326
+ };
275
327
  export const JobsConfigSchema = Type.Object({
276
328
  enabled: Type.Boolean({ default: true }),
277
329
  retry: Type.Object({ defaultMaxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }) }, { default: {} }),
@@ -287,31 +339,32 @@ export const JobsConfigSchema = Type.Object({
287
339
  dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
288
340
  schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
289
341
  concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
290
- // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
291
- // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
292
- // resolveGenerationConfig reads `loaded.generation` and clamps to these same
293
- // bounds when a field is absent).
342
+ // 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
343
+ // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
344
+ // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
345
+ // pre-fold manifest to the same numbers — a hand-mirrored copy used to live
346
+ // in workers/jobs-v1/src/generation.ts.
294
347
  generation: Type.Object({
295
348
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
296
- maxConcurrent: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
349
+ maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
297
350
  /** default expiry/timeout when the descriptor omits one */
298
- defaultTimeoutMs: Type.Integer({ default: 300_000, minimum: 1_000, maximum: 3_600_000 }),
351
+ defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
299
352
  /** hard ceiling a tenant-supplied timeout is clamped to */
300
- maxTimeoutMs: Type.Integer({ default: 3_600_000, minimum: 1_000, maximum: 3_600_000 }),
353
+ maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
301
354
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
302
- pollMaxAttempts: Type.Integer({ default: 60, minimum: 1, maximum: 1_000 }),
355
+ pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
303
356
  /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
304
357
  * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
305
358
  * conservative default so an untrusted deployed function that carries a
306
359
  * reserve block can never hold more than a bounded amount per run without
307
360
  * any tenant action. */
308
- maxReserveCredits: Type.Integer({ default: 1_000, minimum: 1, maximum: 1_000_000 }),
361
+ maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
309
362
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
310
363
  * reserve holds across all in-flight generation runs (jobs.md §11.8): a
311
364
  * reserve whose amount would push the tenant's outstanding-holds total over
312
365
  * this is rejected 429, so a runaway function cannot hold every user at
313
366
  * once. Defaulted so no tenant action is required to be safe. */
314
- maxOutstandingReserveCredits: Type.Integer({ default: 100_000, minimum: 1, maximum: 100_000_000 }),
367
+ maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
315
368
  }, { default: {} }),
316
369
  });
317
370
  // One social-provider's BYO credential block. The *Ref fields are POINTERS into
@@ -703,9 +756,9 @@ export const WebhooksConfigSchema = Type.Object({
703
756
  })),
704
757
  // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
705
758
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
706
- // subscription has (there is no name column and no update route, so a changed
707
- // prefix set is delete+recreate, exactly what the function-trigger reconciler
708
- // does). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
759
+ // subscription has (there is no name column). A changed prefix set is an
760
+ // in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
761
+ // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
709
762
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
710
763
  // reported (deleted only under --allow-destructive). Rows on the platform's
711
764
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -1063,8 +1116,9 @@ export const AiConfigSchema = Type.Object({
1063
1116
  // stamps on the latest version, and POSTs a new version ONLY when the content
1064
1117
  // differs — so a push is idempotent and versions stay monotonic per name.
1065
1118
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1066
- // name). Stored templates the config does not declare are reported (there is
1067
- // 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
1068
1122
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1069
1123
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1070
1124
  });
@@ -1167,6 +1221,17 @@ export const PaymentsConfigSchema = Type.Object({
1167
1221
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1168
1222
  // environments by signing secret / API base, so only RC carries this knob.
1169
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 })),
1170
1235
  })),
1171
1236
  paypal: Type.Optional(Type.Object({
1172
1237
  clientIdRef: Type.String(),
@@ -1191,12 +1256,30 @@ export const PaymentsConfigSchema = Type.Object({
1191
1256
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1192
1257
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1193
1258
  ledger: Type.Optional(Type.Object({
1194
- productMap: Type.Record(Type.String(), Type.Object({
1195
- creditType: Type.String({ minLength: 1 }),
1196
- amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1197
- period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1198
- Type.Literal('annual')]),
1199
- })),
1259
+ // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
1260
+ // `boosts` Record-of-Union precedent) with two rule shapes:
1261
+ // { creditType, amount, period } a credit grant (the original rule)
1262
+ // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1263
+ // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1264
+ // below) for `durationDays`, as a charge-linked manual-style row that
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.
1271
+ productMap: Type.Record(Type.String(), Type.Union([
1272
+ Type.Object({
1273
+ creditType: Type.String({ minLength: 1 }),
1274
+ amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1275
+ period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1276
+ Type.Literal('annual')]),
1277
+ }),
1278
+ Type.Object({
1279
+ tier: Type.String({ minLength: 1 }),
1280
+ durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
1281
+ }),
1282
+ ])),
1200
1283
  tierMap: Type.Record(Type.String(), Type.Object({
1201
1284
  entitlements: Type.Array(Type.String()),
1202
1285
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1205,6 +1288,14 @@ export const PaymentsConfigSchema = Type.Object({
1205
1288
  creditType: Type.String({ minLength: 1 }),
1206
1289
  amount: Type.Integer({ minimum: 1 }), // #128
1207
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')])),
1208
1299
  }))),
1209
1300
  })),
1210
1301
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -1319,11 +1410,39 @@ export const FunctionsConfigSchema = Type.Object({
1319
1410
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1320
1411
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1321
1412
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1413
+ // F33 (2026-09-25): the per-binding opt-in to re-delivery on
1414
+ // queue / webhook / cmsHook / authHook (the cross-field rule
1415
+ // rejects it on http / cron). Absent = the ACK-200 default. The
1416
+ // receiver answers a failed attempt as an enveloped 503 (ladder)
1417
+ // and the last one as a terminal 409 (dead + job.dead_lettered);
1418
+ // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
1419
+ retry: Type.Optional(Type.Object({
1420
+ maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
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')])),
1322
1426
  }), { maxItems: 8 })),
1323
1427
  scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1324
1428
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1325
1429
  secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1326
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
+ })),
1327
1446
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1328
1447
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
1329
1448
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -1488,6 +1607,24 @@ export const CONFIG_FLAG_CAP = 15;
1488
1607
  * closed union `packages/config` types as AuthHookEvent; the control-plane
1489
1608
  * reconciler maps each to its `auth.<event>` audit-event prefix. */
1490
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
+ }
1491
1628
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1492
1629
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1493
1630
  export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
@@ -1746,18 +1883,56 @@ export function validateFeatureConfig(feature, raw) {
1746
1883
  // user, running vxil-billed functions for free (audit F2). Rejected at write
1747
1884
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1748
1885
  const ledgerErrs = [];
1886
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
1749
1887
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1750
1888
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
1751
1889
  ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1752
1890
  }
1891
+ // (2026-09-25 F35+) an entitlement rule must name a declared tier — the
1892
+ // write-time mirror of the runtime's unknown-tier refusal (a purchase for
1893
+ // a tier nobody declared would land the delivery `error`).
1894
+ if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
1895
+ ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
1896
+ }
1753
1897
  }
1754
1898
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1755
1899
  (rule.grants ?? []).forEach((g, i) => {
1756
1900
  if (g.creditType && isReservedCreditType(g.creditType)) {
1757
1901
  ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1758
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
+ }
1759
1928
  });
1760
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
+ }
1761
1936
  // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
1762
1937
  // customer (priceMap miss → tier NULL → refold excluded the row). The
1763
1938
  // reducer now stamps such an event outcome 'error' (reprocessable), and
@@ -1929,9 +2104,23 @@ export function validateFeatureConfig(feature, raw) {
1929
2104
  if (b.kind === 'cron' && !b.schedule) {
1930
2105
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
1931
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
+ }
2110
+ // F33: retry is an opt-in for the platform-delivered event lanes only —
2111
+ // an http invoke returns its real status to its caller, and a cron
2112
+ // tick's retry would overlap the next tick.
2113
+ if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2114
+ errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2115
+ }
1932
2116
  if (b.kind === 'cmsHook' && !b.collection) {
1933
2117
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1934
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}`);
1935
2124
  // authHook: a CLOSED event union (F4-30) — reject typos at write time
1936
2125
  // so a binding never silently subscribes to nothing.
1937
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.5.1",
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/apiState.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // by `vxil push` and `POST /v1/apply`:
10
10
  //
11
11
  // rate-limits.policies[] — keyed by `name` (POST/PUT/DELETE /v1/rate-limits/policies)
12
- // webhooks.subscriptions[] — keyed by `target_url` (POST/DELETE /v1/webhooks/subscriptions)
12
+ // webhooks.subscriptions[] — keyed by `target_url` (POST/PATCH/DELETE /v1/webhooks/subscriptions)
13
13
  // ai.templates[] — keyed by `template` (POST /v1/ai/templates, by content hash)
14
14
  //
15
15
  // ONE WRITER PER DATUM, AND THE WRITER IS THE REPOSITORY. The planner below is
@@ -27,9 +27,11 @@
27
27
  // LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
28
28
  // passes allow-destructive — so adopting a block on an existing tenant is a
29
29
  // report, never a wipe. A change that can only be applied by delete+recreate
30
- // (a policy's `key_template`; a subscription's prefix set) is likewise
31
- // destructive-SHAPED: it changes the row's id, which a caller may have
32
- // cached, so it is reported and applied only under the same ack.
30
+ // (a policy's `key_template`) is likewise destructive-SHAPED: it changes the
31
+ // row's id, which a caller may have cached, so it is reported and applied
32
+ // only under the same ack. A subscription's prefix set used to be one of
33
+ // those; since 2026-09-25 the PATCH route changes it in place (same sub_id,
34
+ // same cursor), so it is an ordinary `update`.
33
35
  //
34
36
  // Two shapes this file deliberately does NOT cover, with the reason:
35
37
  // notifications.templates — templates are CODE with per-locale `overrides`
@@ -161,9 +163,11 @@ export interface LiveWebhookSubscription {
161
163
 
162
164
  export interface WebhookSubscriptionPlan {
163
165
  create: DeclaredWebhookSubscription[];
164
- /** the prefix SET differs — there is no update route, so delete+recreate (a
165
- * NEW sub_id, and the watermark restarts at "now"): destructive-shaped. */
166
- recreate: Array<{ sub_id: string; target_url: string; declared: DeclaredWebhookSubscription }>;
166
+ /** the prefix SET differs — `PATCH /v1/webhooks/subscriptions/:subId
167
+ * { event_prefixes }` (2026-09-25) changes it in place: the sub_id and the
168
+ * cursor are kept, so this is an ordinary update, not destructive-shaped
169
+ * (before 2026-09-25 it was delete+recreate under --allow-destructive). */
170
+ update: Array<{ sub_id: string; target_url: string; event_prefixes: string[] }>;
167
171
  unchanged: string[];
168
172
  /** live rows the config does not declare, EXCLUDING the function-delivery
169
173
  * lanes (those derive from the functions manifest) — left in place by default */
@@ -178,7 +182,7 @@ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
178
182
  export function planWebhookSubscriptions(
179
183
  declared: readonly DeclaredWebhookSubscription[], live: readonly LiveWebhookSubscription[],
180
184
  ): WebhookSubscriptionPlan {
181
- const plan: WebhookSubscriptionPlan = { create: [], recreate: [], unchanged: [], undeclared: [] };
185
+ const plan: WebhookSubscriptionPlan = { create: [], update: [], unchanged: [], undeclared: [] };
182
186
  const declaredUrls = new Set(declared.map((d) => d.target_url));
183
187
  const matched = new Set<string>();
184
188
  for (const d of declared) {
@@ -187,12 +191,12 @@ export function planWebhookSubscriptions(
187
191
  if (rows.length === 0) { plan.create.push(d); continue; }
188
192
  // Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
189
193
  // to the declaration is adopted as-is — no churn); else the first row is
190
- // the one recreated and the rest fall through as undeclared duplicates.
194
+ // the one updated in place and the rest fall through as undeclared duplicates.
191
195
  const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
192
196
  const chosen = exact ?? rows[0]!;
193
197
  matched.add(chosen.sub_id);
194
198
  if (exact) plan.unchanged.push(d.target_url);
195
- else plan.recreate.push({ sub_id: chosen.sub_id, target_url: d.target_url, declared: d });
199
+ else plan.update.push({ sub_id: chosen.sub_id, target_url: d.target_url, event_prefixes: [...want] });
196
200
  }
197
201
  for (const l of live) {
198
202
  if (matched.has(l.sub_id)) continue;
package/src/index.ts CHANGED
@@ -40,6 +40,13 @@ if (!FormatRegistry.Has('email')) {
40
40
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
41
41
  export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
42
42
 
43
+ /** F33 (2026-09-25): a function binding's `retry.maxAttempts` ceiling, and the
44
+ * binding kinds that may carry `retry` — the platform-delivered event lanes
45
+ * (an http invoke returns its own status; a cron tick's retry would overlap
46
+ * the next tick). Read by the schema, the deploy clamp and the CLI. */
47
+ export const FN_RETRY_MAX_ATTEMPTS = 5;
48
+ export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
49
+
43
50
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
44
51
  * of which is restricted to internal platform machinery). */
45
52
  export function isReservedCreditType(creditType: string): boolean {
@@ -161,6 +168,11 @@ export function validateNotificationOverrides(
161
168
  return errs;
162
169
  }
163
170
 
171
+ /** `notifications.ses.snsTopicArn` grammar: a plain SNS topic ARN (the
172
+ * `aws-cn` / `aws-us-gov` partitions included). Mirrored by SNS_TOPIC_ARN_RE
173
+ * in the notifications worker's snsIntake.ts. */
174
+ export const SNS_TOPIC_ARN_PATTERN = '^arn:aws(-[a-z]+)?:sns:[a-z0-9-]+:\\d{12}:[A-Za-z0-9_-]{1,256}$';
175
+
164
176
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
165
177
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
166
178
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -204,6 +216,13 @@ export const NotificationsConfigSchema = Type.Object({
204
216
  region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
205
217
  accessKeyIdRef: Type.String({ minLength: 1 }),
206
218
  secretAccessKeyRef: Type.String({ minLength: 1 }),
219
+ // Bounce/complaint intake (2026-09-25): the SNS topic SES publishes its
220
+ // bounce/complaint events to; its HTTPS subscription posts to the worker's
221
+ // `/v1/notifications/webhooks/ses/<tenant>/<tag>` route, which accepts a
222
+ // message ONLY when its TopicArn equals this value (fail closed: unset ⇒
223
+ // the lane answers 404). Plain config, not a secret — the AWS signature is
224
+ // the proof. Inside the Optional bag ⇒ zero leaf cost.
225
+ snsTopicArn: Type.Optional(Type.String({ pattern: SNS_TOPIC_ARN_PATTERN, maxLength: 320 })),
207
226
  })),
208
227
  defaultLocale: Type.String({ default: 'en-US' }),
209
228
  // nested objects carry `default: {}` so Value.Default can materialize them
@@ -315,6 +334,44 @@ export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
315
334
  /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
316
335
  export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
317
336
 
337
+ // ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
338
+ // (roadmap §4.10, 2026-09-25 — "dropping the jobs-v1 GENERATION_DEFAULTS
339
+ // copy"). The JobsConfigSchema `generation` leaf reads these for its
340
+ // `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
341
+ // imports them (jobs-v1 already depends on @vxil/feature-configs; this package
342
+ // has no @vxil/types dependency, so the shared value lives here — the
343
+ // RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
344
+ // feature-configs unit test pins schema ↔ constant, and the CI gate
345
+ // tests/ci/src/generation-defaults-single-source.test.ts pins that no second
346
+ // object-literal declaration of the constant reappears anywhere.
347
+
348
+ /** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
349
+ export const GENERATION_DEFAULTS = {
350
+ /** per-tenant in-flight generation cap (separate budget from queue jobs) */
351
+ maxConcurrent: 20,
352
+ /** default expiry/timeout when the descriptor omits one — 5 min */
353
+ defaultTimeoutMs: 5 * 60_000,
354
+ /** hard ceiling a tenant-supplied timeout is clamped to — 1 h */
355
+ maxTimeoutMs: 60 * 60_000,
356
+ /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
357
+ pollMaxAttempts: 60,
358
+ /** MANDATORY per-hold cap on a `reserve_credits.amount` (clamped, never rejected) */
359
+ maxReserveCredits: 1_000,
360
+ /** MANDATORY per-tenant ceiling on the sum of un-settled provisional holds */
361
+ maxOutstandingReserveCredits: 100_000,
362
+ } as const;
363
+
364
+ /** The inclusive [min, max] each `generation` leaf is clamped to — by the
365
+ * schema at config-write time and by jobs-v1 at read time. */
366
+ export const GENERATION_BOUNDS = {
367
+ maxConcurrent: { min: 1, max: 200 },
368
+ defaultTimeoutMs: { min: 1_000, max: 3_600_000 },
369
+ maxTimeoutMs: { min: 1_000, max: 3_600_000 },
370
+ pollMaxAttempts: { min: 1, max: 1_000 },
371
+ maxReserveCredits: { min: 1, max: 1_000_000 },
372
+ maxOutstandingReserveCredits: { min: 1, max: 100_000_000 },
373
+ } as const satisfies Record<keyof typeof GENERATION_DEFAULTS, { min: number; max: number }>;
374
+
318
375
  export const JobsConfigSchema = Type.Object({
319
376
  enabled: Type.Boolean({ default: true }),
320
377
  retry: Type.Object(
@@ -342,32 +399,33 @@ export const JobsConfigSchema = Type.Object({
342
399
  { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
343
400
  { default: {} },
344
401
  ),
345
- // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
346
- // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
347
- // resolveGenerationConfig reads `loaded.generation` and clamps to these same
348
- // bounds when a field is absent).
402
+ // 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
403
+ // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
404
+ // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
405
+ // pre-fold manifest to the same numbers — a hand-mirrored copy used to live
406
+ // in workers/jobs-v1/src/generation.ts.
349
407
  generation: Type.Object(
350
408
  {
351
409
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
352
- maxConcurrent: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
410
+ maxConcurrent: Type.Integer({ default: GENERATION_DEFAULTS.maxConcurrent, minimum: GENERATION_BOUNDS.maxConcurrent.min, maximum: GENERATION_BOUNDS.maxConcurrent.max }),
353
411
  /** default expiry/timeout when the descriptor omits one */
354
- defaultTimeoutMs: Type.Integer({ default: 300_000, minimum: 1_000, maximum: 3_600_000 }),
412
+ defaultTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.defaultTimeoutMs, minimum: GENERATION_BOUNDS.defaultTimeoutMs.min, maximum: GENERATION_BOUNDS.defaultTimeoutMs.max }),
355
413
  /** hard ceiling a tenant-supplied timeout is clamped to */
356
- maxTimeoutMs: Type.Integer({ default: 3_600_000, minimum: 1_000, maximum: 3_600_000 }),
414
+ maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
357
415
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
358
- pollMaxAttempts: Type.Integer({ default: 60, minimum: 1, maximum: 1_000 }),
416
+ pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
359
417
  /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
360
418
  * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
361
419
  * conservative default so an untrusted deployed function that carries a
362
420
  * reserve block can never hold more than a bounded amount per run without
363
421
  * any tenant action. */
364
- maxReserveCredits: Type.Integer({ default: 1_000, minimum: 1, maximum: 1_000_000 }),
422
+ maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
365
423
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
366
424
  * reserve holds across all in-flight generation runs (jobs.md §11.8): a
367
425
  * reserve whose amount would push the tenant's outstanding-holds total over
368
426
  * this is rejected 429, so a runaway function cannot hold every user at
369
427
  * once. Defaulted so no tenant action is required to be safe. */
370
- maxOutstandingReserveCredits: Type.Integer({ default: 100_000, minimum: 1, maximum: 100_000_000 }),
428
+ maxOutstandingReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxOutstandingReserveCredits, minimum: GENERATION_BOUNDS.maxOutstandingReserveCredits.min, maximum: GENERATION_BOUNDS.maxOutstandingReserveCredits.max }),
371
429
  },
372
430
  { default: {} },
373
431
  ),
@@ -837,9 +895,9 @@ export const WebhooksConfigSchema = Type.Object({
837
895
  })),
838
896
  // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
839
897
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
840
- // subscription has (there is no name column and no update route, so a changed
841
- // prefix set is delete+recreate, exactly what the function-trigger reconciler
842
- // does). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
898
+ // subscription has (there is no name column). A changed prefix set is an
899
+ // in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
900
+ // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
843
901
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
844
902
  // reported (deleted only under --allow-destructive). Rows on the platform's
845
903
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -1347,8 +1405,9 @@ export const AiConfigSchema = Type.Object({
1347
1405
  // stamps on the latest version, and POSTs a new version ONLY when the content
1348
1406
  // differs — so a push is idempotent and versions stay monotonic per name.
1349
1407
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1350
- // name). Stored templates the config does not declare are reported (there is
1351
- // 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
1352
1411
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1353
1412
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1354
1413
  });
@@ -1479,6 +1538,17 @@ export const PaymentsConfigSchema = Type.Object({
1479
1538
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1480
1539
  // environments by signing secret / API base, so only RC carries this knob.
1481
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 })),
1482
1552
  })),
1483
1553
  paypal: Type.Optional(Type.Object({
1484
1554
  clientIdRef: Type.String(),
@@ -1506,12 +1576,30 @@ export const PaymentsConfigSchema = Type.Object({
1506
1576
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1507
1577
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1508
1578
  ledger: Type.Optional(Type.Object({
1509
- productMap: Type.Record(Type.String(), Type.Object({ // product_id → grant rule
1510
- creditType: Type.String({ minLength: 1 }),
1511
- amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1512
- period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1513
- Type.Literal('annual')]),
1514
- })),
1579
+ // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
1580
+ // `boosts` Record-of-Union precedent) with two rule shapes:
1581
+ // { creditType, amount, period } a credit grant (the original rule)
1582
+ // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1583
+ // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1584
+ // below) for `durationDays`, as a charge-linked manual-style row that
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.
1591
+ productMap: Type.Record(Type.String(), Type.Union([
1592
+ Type.Object({
1593
+ creditType: Type.String({ minLength: 1 }),
1594
+ amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1595
+ period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1596
+ Type.Literal('annual')]),
1597
+ }),
1598
+ Type.Object({
1599
+ tier: Type.String({ minLength: 1 }),
1600
+ durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
1601
+ }),
1602
+ ])),
1515
1603
  tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
1516
1604
  entitlements: Type.Array(Type.String()),
1517
1605
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1520,6 +1608,14 @@ export const PaymentsConfigSchema = Type.Object({
1520
1608
  creditType: Type.String({ minLength: 1 }),
1521
1609
  amount: Type.Integer({ minimum: 1 }), // #128
1522
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')])),
1523
1619
  }))),
1524
1620
  })),
1525
1621
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -1655,6 +1751,19 @@ export const FunctionsConfigSchema = Type.Object({
1655
1751
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1656
1752
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1657
1753
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1754
+ // F33 (2026-09-25): the per-binding opt-in to re-delivery on
1755
+ // queue / webhook / cmsHook / authHook (the cross-field rule
1756
+ // rejects it on http / cron). Absent = the ACK-200 default. The
1757
+ // receiver answers a failed attempt as an enveloped 503 (ladder)
1758
+ // and the last one as a terminal 409 (dead + job.dead_lettered);
1759
+ // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
1760
+ retry: Type.Optional(Type.Object({
1761
+ maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
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')])),
1658
1767
  }),
1659
1768
  { maxItems: 8 },
1660
1769
  ),
@@ -1663,6 +1772,23 @@ export const FunctionsConfigSchema = Type.Object({
1663
1772
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1664
1773
  secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1665
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
+ ),
1666
1792
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1667
1793
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
1668
1794
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -1870,6 +1996,25 @@ export const CONFIG_FLAG_CAP = 15;
1870
1996
  export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
1871
1997
  export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
1872
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
+
1873
2018
  /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1874
2019
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1875
2020
  export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
@@ -2105,7 +2250,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2105
2250
  const v = withDefaults as {
2106
2251
  provider?: string;
2107
2252
  resendApiKeyRef?: string;
2108
- ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
2253
+ ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
2109
2254
  templates?: {
2110
2255
  allowOverride?: boolean;
2111
2256
  overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
@@ -2137,8 +2282,8 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2137
2282
  const v = withDefaults as {
2138
2283
  provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
2139
2284
  ledger?: {
2140
- productMap?: Record<string, { creditType?: string }>;
2141
- tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
2285
+ productMap?: Record<string, { creditType?: string; tier?: string }>;
2286
+ tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string; period?: string; mode?: string }> }>;
2142
2287
  priceMap?: Record<string, string>;
2143
2288
  };
2144
2289
  };
@@ -2157,12 +2302,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2157
2302
  // user, running vxil-billed functions for free (audit F2). Rejected at write
2158
2303
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
2159
2304
  const ledgerErrs: string[] = [];
2305
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
2160
2306
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
2161
2307
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
2162
2308
  ledgerErrs.push(
2163
2309
  `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
2164
2310
  );
2165
2311
  }
2312
+ // (2026-09-25 F35+) an entitlement rule must name a declared tier — the
2313
+ // write-time mirror of the runtime's unknown-tier refusal (a purchase for
2314
+ // a tier nobody declared would land the delivery `error`).
2315
+ if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
2316
+ ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
2317
+ }
2166
2318
  }
2167
2319
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
2168
2320
  (rule.grants ?? []).forEach((g, i) => {
@@ -2171,8 +2323,43 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2171
2323
  `/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
2172
2324
  );
2173
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
+ }
2340
+ });
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
+ }
2174
2353
  });
2175
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
+ }
2176
2363
  // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
2177
2364
  // customer (priceMap miss → tier NULL → refold excluded the row). The
2178
2365
  // reducer now stamps such an event outcome 'error' (reprocessable), and
@@ -2339,7 +2526,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2339
2526
  const v = withDefaults as {
2340
2527
  functions?: Record<
2341
2528
  string,
2342
- { scriptRef?: string; scopes?: string[]; bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string }>; signature?: unknown }
2529
+ {
2530
+ scriptRef?: string; scopes?: string[]; signature?: unknown;
2531
+ bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number }; overlap?: string }>;
2532
+ }
2343
2533
  >;
2344
2534
  };
2345
2535
  const errs: string[] = [];
@@ -2354,9 +2544,22 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2354
2544
  if (b.kind === 'cron' && !b.schedule) {
2355
2545
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2356
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
+ }
2550
+ // F33: retry is an opt-in for the platform-delivered event lanes only —
2551
+ // an http invoke returns its real status to its caller, and a cron
2552
+ // tick's retry would overlap the next tick.
2553
+ if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2554
+ errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2555
+ }
2357
2556
  if (b.kind === 'cmsHook' && !b.collection) {
2358
2557
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2359
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}`);
2360
2563
  // authHook: a CLOSED event union (F4-30) — reject typos at write time
2361
2564
  // so a binding never silently subscribes to nothing.
2362
2565
  if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {