@vxil/feature-configs 0.5.1 → 0.6.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<{
@@ -615,11 +669,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
615
669
  trialDays: import("@sinclair/typebox").TInteger;
616
670
  }>;
617
671
  ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
618
- productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
672
+ productMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
619
673
  creditType: import("@sinclair/typebox").TString;
620
674
  amount: import("@sinclair/typebox").TInteger;
621
675
  period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
622
- }>>;
676
+ }>, import("@sinclair/typebox").TObject<{
677
+ tier: import("@sinclair/typebox").TString;
678
+ durationDays: import("@sinclair/typebox").TInteger;
679
+ }>]>>;
623
680
  tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
624
681
  entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
625
682
  quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
@@ -658,6 +715,9 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
658
715
  source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
659
716
  collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
660
717
  event: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
718
+ retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
719
+ maxAttempts: import("@sinclair/typebox").TInteger;
720
+ }>>;
661
721
  }>>>;
662
722
  scriptRef: import("@sinclair/typebox").TString;
663
723
  scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
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
@@ -1191,12 +1244,27 @@ export const PaymentsConfigSchema = Type.Object({
1191
1244
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1192
1245
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1193
1246
  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
- })),
1247
+ // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
1248
+ // `boosts` Record-of-Union precedent) with two rule shapes:
1249
+ // { creditType, amount, period } a credit grant (the original rule)
1250
+ // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1251
+ // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1252
+ // 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.
1256
+ productMap: Type.Record(Type.String(), Type.Union([
1257
+ Type.Object({
1258
+ creditType: Type.String({ minLength: 1 }),
1259
+ amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1260
+ period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1261
+ Type.Literal('annual')]),
1262
+ }),
1263
+ Type.Object({
1264
+ tier: Type.String({ minLength: 1 }),
1265
+ durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
1266
+ }),
1267
+ ])),
1200
1268
  tierMap: Type.Record(Type.String(), Type.Object({
1201
1269
  entitlements: Type.Array(Type.String()),
1202
1270
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1319,6 +1387,15 @@ export const FunctionsConfigSchema = Type.Object({
1319
1387
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1320
1388
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1321
1389
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1390
+ // F33 (2026-09-25): the per-binding opt-in to re-delivery on
1391
+ // queue / webhook / cmsHook / authHook (the cross-field rule
1392
+ // rejects it on http / cron). Absent = the ACK-200 default. The
1393
+ // receiver answers a failed attempt as an enveloped 503 (ladder)
1394
+ // and the last one as a terminal 409 (dead + job.dead_lettered);
1395
+ // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
1396
+ retry: Type.Optional(Type.Object({
1397
+ maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
1398
+ })),
1322
1399
  }), { maxItems: 8 })),
1323
1400
  scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1324
1401
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
@@ -1746,10 +1823,17 @@ export function validateFeatureConfig(feature, raw) {
1746
1823
  // user, running vxil-billed functions for free (audit F2). Rejected at write
1747
1824
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1748
1825
  const ledgerErrs = [];
1826
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
1749
1827
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1750
1828
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
1751
1829
  ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1752
1830
  }
1831
+ // (2026-09-25 F35+) an entitlement rule must name a declared tier — the
1832
+ // write-time mirror of the runtime's unknown-tier refusal (a purchase for
1833
+ // a tier nobody declared would land the delivery `error`).
1834
+ if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
1835
+ ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
1836
+ }
1753
1837
  }
1754
1838
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1755
1839
  (rule.grants ?? []).forEach((g, i) => {
@@ -1929,6 +2013,12 @@ export function validateFeatureConfig(feature, raw) {
1929
2013
  if (b.kind === 'cron' && !b.schedule) {
1930
2014
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
1931
2015
  }
2016
+ // F33: retry is an opt-in for the platform-delivered event lanes only —
2017
+ // an http invoke returns its real status to its caller, and a cron
2018
+ // tick's retry would overlap the next tick.
2019
+ if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2020
+ errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2021
+ }
1932
2022
  if (b.kind === 'cmsHook' && !b.collection) {
1933
2023
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1934
2024
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.5.1",
3
+ "version": "0.6.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
@@ -1506,12 +1564,27 @@ export const PaymentsConfigSchema = Type.Object({
1506
1564
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1507
1565
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1508
1566
  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
- })),
1567
+ // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
1568
+ // `boosts` Record-of-Union precedent) with two rule shapes:
1569
+ // { creditType, amount, period } a credit grant (the original rule)
1570
+ // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1571
+ // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1572
+ // 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.
1576
+ productMap: Type.Record(Type.String(), Type.Union([
1577
+ Type.Object({
1578
+ creditType: Type.String({ minLength: 1 }),
1579
+ amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1580
+ period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1581
+ Type.Literal('annual')]),
1582
+ }),
1583
+ Type.Object({
1584
+ tier: Type.String({ minLength: 1 }),
1585
+ durationDays: Type.Integer({ minimum: 1, maximum: 3650 }),
1586
+ }),
1587
+ ])),
1515
1588
  tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
1516
1589
  entitlements: Type.Array(Type.String()),
1517
1590
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1655,6 +1728,15 @@ export const FunctionsConfigSchema = Type.Object({
1655
1728
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1656
1729
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1657
1730
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1731
+ // F33 (2026-09-25): the per-binding opt-in to re-delivery on
1732
+ // queue / webhook / cmsHook / authHook (the cross-field rule
1733
+ // rejects it on http / cron). Absent = the ACK-200 default. The
1734
+ // receiver answers a failed attempt as an enveloped 503 (ladder)
1735
+ // and the last one as a terminal 409 (dead + job.dead_lettered);
1736
+ // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
1737
+ retry: Type.Optional(Type.Object({
1738
+ maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
1739
+ })),
1658
1740
  }),
1659
1741
  { maxItems: 8 },
1660
1742
  ),
@@ -2105,7 +2187,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2105
2187
  const v = withDefaults as {
2106
2188
  provider?: string;
2107
2189
  resendApiKeyRef?: string;
2108
- ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
2190
+ ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
2109
2191
  templates?: {
2110
2192
  allowOverride?: boolean;
2111
2193
  overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
@@ -2137,7 +2219,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2137
2219
  const v = withDefaults as {
2138
2220
  provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
2139
2221
  ledger?: {
2140
- productMap?: Record<string, { creditType?: string }>;
2222
+ productMap?: Record<string, { creditType?: string; tier?: string }>;
2141
2223
  tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
2142
2224
  priceMap?: Record<string, string>;
2143
2225
  };
@@ -2157,12 +2239,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2157
2239
  // user, running vxil-billed functions for free (audit F2). Rejected at write
2158
2240
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
2159
2241
  const ledgerErrs: string[] = [];
2242
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
2160
2243
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
2161
2244
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
2162
2245
  ledgerErrs.push(
2163
2246
  `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
2164
2247
  );
2165
2248
  }
2249
+ // (2026-09-25 F35+) an entitlement rule must name a declared tier — the
2250
+ // write-time mirror of the runtime's unknown-tier refusal (a purchase for
2251
+ // a tier nobody declared would land the delivery `error`).
2252
+ if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
2253
+ ledgerErrs.push(`/ledger/productMap/${productId}/tier: '${rule.tier}' is not a ledger.tierMap key`);
2254
+ }
2166
2255
  }
2167
2256
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
2168
2257
  (rule.grants ?? []).forEach((g, i) => {
@@ -2339,7 +2428,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2339
2428
  const v = withDefaults as {
2340
2429
  functions?: Record<
2341
2430
  string,
2342
- { scriptRef?: string; scopes?: string[]; bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string }>; signature?: unknown }
2431
+ {
2432
+ scriptRef?: string; scopes?: string[]; signature?: unknown;
2433
+ bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
2434
+ }
2343
2435
  >;
2344
2436
  };
2345
2437
  const errs: string[] = [];
@@ -2354,6 +2446,12 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2354
2446
  if (b.kind === 'cron' && !b.schedule) {
2355
2447
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2356
2448
  }
2449
+ // F33: retry is an opt-in for the platform-delivered event lanes only —
2450
+ // an http invoke returns its real status to its caller, and a cron
2451
+ // tick's retry would overlap the next tick.
2452
+ if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2453
+ errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2454
+ }
2357
2455
  if (b.kind === 'cmsHook' && !b.collection) {
2358
2456
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2359
2457
  }