@vxil/feature-configs 0.5.0 → 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
@@ -149,14 +151,12 @@ export interface ApiStateSummary {
149
151
  export declare function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary;
150
152
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
151
153
  * :feature` and its dashboard twin, dry-run included — control-plane
152
- * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` READS
153
- * back instead of converging a second time (review 2026-09-23, finding 1: the
154
- * server's converge is the one that ran with the key's scopes; a client-side
155
- * repeat re-listed a KV-backed datum ~100 ms later and could double-create).
156
- * `ok:false` + `deferred:true` = the feature worker has not seen the config
157
- * yet (the client may converge after its own wait); `ok:false` otherwise =
158
- * a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
159
- * purpose (a disabled feature). */
154
+ * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
155
+ * `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
156
+ * INDEFINITE reason — the feature worker has not seen the config yet, or a
157
+ * transient failure (a push asks again through POST /v1/apply after its
158
+ * wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
159
+ * `note` = converged nothing on purpose (a disabled feature). */
160
160
  export interface ApiStateResponseField extends ApiStateSummary {
161
161
  ok: boolean;
162
162
  dry_run?: boolean;
@@ -170,8 +170,8 @@ export interface ApiStateResponseField extends ApiStateSummary {
170
170
  export declare const API_STATE_DISABLED_NOTE = "feature disabled \u2014 declaration kept, not converged";
171
171
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
172
172
  * AT the cap is not a complete live set, so planning creates against it would
173
- * re-POST every declared name the cap hid (review finding 5). Both executors
174
- * refuse with this message instead. */
173
+ * re-POST every declared name the cap hid (review finding 5). The server's
174
+ * converge and the CLI's read-only plan refuse with this message instead. */
175
175
  export declare function rlPolicyListCapError(liveCount: number): string | null;
176
176
  export declare const RL_POLICY_LIST_CAP = 100;
177
177
  /** `+2 created · ~1 updated · =3 unchanged · 1 undeclared (left in place)` —
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))
@@ -165,8 +165,8 @@ export function emptyApiStateSummary(datum) {
165
165
  export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, not converged';
166
166
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
167
167
  * AT the cap is not a complete live set, so planning creates against it would
168
- * re-POST every declared name the cap hid (review finding 5). Both executors
169
- * refuse with this message instead. */
168
+ * re-POST every declared name the cap hid (review finding 5). The server's
169
+ * converge and the CLI's read-only plan refuse with this message instead. */
170
170
  export function rlPolicyListCapError(liveCount) {
171
171
  if (liveCount < RL_POLICY_LIST_CAP)
172
172
  return null;
@@ -0,0 +1,7 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export declare function canonicalJson(v: unknown): string | undefined;
@@ -0,0 +1,16 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export function canonicalJson(v) {
8
+ if (Array.isArray(v))
9
+ return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
10
+ if (v !== null && typeof v === 'object') {
11
+ const o = v;
12
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
13
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
14
+ }
15
+ return JSON.stringify(v);
16
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,14 @@ import { type Static, type TSchema } from '@sinclair/typebox';
2
2
  export * from './hooks.js';
3
3
  export * from './readmodels.js';
4
4
  export * from './apiState.js';
5
+ export * from './canonicalJson.js';
5
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>;
6
13
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
7
14
  * of which is restricted to internal platform machinery). */
8
15
  export declare function isReservedCreditType(creditType: string): boolean;
@@ -38,6 +45,10 @@ export declare function validateNotificationOverrides(templates: {
38
45
  text?: string;
39
46
  }>>;
40
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}$";
41
52
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
42
53
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
43
54
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -56,6 +67,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
56
67
  region: import("@sinclair/typebox").TString;
57
68
  accessKeyIdRef: import("@sinclair/typebox").TString;
58
69
  secretAccessKeyRef: import("@sinclair/typebox").TString;
70
+ snsTopicArn: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
59
71
  }>>;
60
72
  defaultLocale: import("@sinclair/typebox").TString;
61
73
  retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
@@ -104,6 +116,49 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
104
116
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
105
117
  /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
106
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
+ };
107
162
  export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
108
163
  enabled: import("@sinclair/typebox").TBoolean;
109
164
  retry: import("@sinclair/typebox").TObject<{
@@ -614,11 +669,14 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
614
669
  trialDays: import("@sinclair/typebox").TInteger;
615
670
  }>;
616
671
  ledger: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
617
- 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<{
618
673
  creditType: import("@sinclair/typebox").TString;
619
674
  amount: import("@sinclair/typebox").TInteger;
620
675
  period: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"once">, import("@sinclair/typebox").TLiteral<"monthly">, import("@sinclair/typebox").TLiteral<"annual">]>;
621
- }>>;
676
+ }>, import("@sinclair/typebox").TObject<{
677
+ tier: import("@sinclair/typebox").TString;
678
+ durationDays: import("@sinclair/typebox").TInteger;
679
+ }>]>>;
622
680
  tierMap: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
623
681
  entitlements: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
624
682
  quotas: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TInteger>;
@@ -657,6 +715,9 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
657
715
  source: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
658
716
  collection: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
659
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
+ }>>;
660
721
  }>>>;
661
722
  scriptRef: import("@sinclair/typebox").TString;
662
723
  scopes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ export * from './readmodels.js';
18
18
  // declared-vs-live reconciliation, shared by the control-plane apply path and
19
19
  // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
20
20
  export * from './apiState.js';
21
+ // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
22
+ // (control-plane shallowDiff, CLI diffManifest).
23
+ export * from './canonicalJson.js';
21
24
  // TypeBox validates `format:` only for registered formats — register the ones
22
25
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
23
26
  if (!FormatRegistry.Has('email')) {
@@ -33,6 +36,12 @@ if (!FormatRegistry.Has('email')) {
33
36
  // config-write gate AND payments-v1 import) so the runtime choke point and the
34
37
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
35
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']);
36
45
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
37
46
  * of which is restricted to internal platform machinery). */
38
47
  export function isReservedCreditType(creditType) {
@@ -145,6 +154,10 @@ export function validateNotificationOverrides(templates) {
145
154
  }
146
155
  return errs;
147
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}$';
148
161
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
149
162
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
150
163
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -187,6 +200,13 @@ export const NotificationsConfigSchema = Type.Object({
187
200
  region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
188
201
  accessKeyIdRef: Type.String({ minLength: 1 }),
189
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 })),
190
210
  })),
191
211
  defaultLocale: Type.String({ default: 'en-US' }),
192
212
  // nested objects carry `default: {}` so Value.Default can materialize them
@@ -269,6 +289,41 @@ export const NotificationsConfigSchema = Type.Object({
269
289
  freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
270
290
  })),
271
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
+ };
272
327
  export const JobsConfigSchema = Type.Object({
273
328
  enabled: Type.Boolean({ default: true }),
274
329
  retry: Type.Object({ defaultMaxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }) }, { default: {} }),
@@ -284,31 +339,32 @@ export const JobsConfigSchema = Type.Object({
284
339
  dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
285
340
  schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
286
341
  concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
287
- // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
288
- // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
289
- // resolveGenerationConfig reads `loaded.generation` and clamps to these same
290
- // 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.
291
347
  generation: Type.Object({
292
348
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
293
- 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 }),
294
350
  /** default expiry/timeout when the descriptor omits one */
295
- 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 }),
296
352
  /** hard ceiling a tenant-supplied timeout is clamped to */
297
- 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 }),
298
354
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
299
- 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 }),
300
356
  /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
301
357
  * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
302
358
  * conservative default so an untrusted deployed function that carries a
303
359
  * reserve block can never hold more than a bounded amount per run without
304
360
  * any tenant action. */
305
- 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 }),
306
362
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
307
363
  * reserve holds across all in-flight generation runs (jobs.md §11.8): a
308
364
  * reserve whose amount would push the tenant's outstanding-holds total over
309
365
  * this is rejected 429, so a runaway function cannot hold every user at
310
366
  * once. Defaulted so no tenant action is required to be safe. */
311
- 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 }),
312
368
  }, { default: {} }),
313
369
  });
314
370
  // One social-provider's BYO credential block. The *Ref fields are POINTERS into
@@ -700,9 +756,9 @@ export const WebhooksConfigSchema = Type.Object({
700
756
  })),
701
757
  // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
702
758
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
703
- // subscription has (there is no name column and no update route, so a changed
704
- // prefix set is delete+recreate, exactly what the function-trigger reconciler
705
- // 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
706
762
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
707
763
  // reported (deleted only under --allow-destructive). Rows on the platform's
708
764
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -1188,12 +1244,27 @@ export const PaymentsConfigSchema = Type.Object({
1188
1244
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1189
1245
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1190
1246
  ledger: Type.Optional(Type.Object({
1191
- productMap: Type.Record(Type.String(), Type.Object({
1192
- creditType: Type.String({ minLength: 1 }),
1193
- amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1194
- period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1195
- Type.Literal('annual')]),
1196
- })),
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
+ ])),
1197
1268
  tierMap: Type.Record(Type.String(), Type.Object({
1198
1269
  entitlements: Type.Array(Type.String()),
1199
1270
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1316,6 +1387,15 @@ export const FunctionsConfigSchema = Type.Object({
1316
1387
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1317
1388
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1318
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
+ })),
1319
1399
  }), { maxItems: 8 })),
1320
1400
  scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1321
1401
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
@@ -1743,10 +1823,17 @@ export function validateFeatureConfig(feature, raw) {
1743
1823
  // user, running vxil-billed functions for free (audit F2). Rejected at write
1744
1824
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1745
1825
  const ledgerErrs = [];
1826
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
1746
1827
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1747
1828
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
1748
1829
  ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1749
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
+ }
1750
1837
  }
1751
1838
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1752
1839
  (rule.grants ?? []).forEach((g, i) => {
@@ -1926,6 +2013,12 @@ export function validateFeatureConfig(feature, raw) {
1926
2013
  if (b.kind === 'cron' && !b.schedule) {
1927
2014
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
1928
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
+ }
1929
2022
  if (b.kind === 'cmsHook' && !b.collection) {
1930
2023
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1931
2024
  }
@@ -1956,8 +2049,14 @@ export function validateFeatureConfig(feature, raw) {
1956
2049
  for (const [id, agent] of Object.entries(v.agents ?? {})) {
1957
2050
  const allow = agent.actions?.allow ?? {};
1958
2051
  const guestAllow = agent.guardrails?.guestToolAllow ?? [];
1959
- if (agent.guardrails?.allowGuest === true)
2052
+ if (agent.guardrails?.allowGuest === true) {
1960
2053
  anyGuestAgent = true;
2054
+ // Guests share ONE tenant-wide bucket that spends the tenant's own AI
2055
+ // key: an unlimited (0) daily cap is never allowed alongside them.
2056
+ if (agent.guardrails.rateLimitPerUserPerDay === 0) {
2057
+ errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
2058
+ }
2059
+ }
1961
2060
  if (knownMcpTools) {
1962
2061
  for (const tool of Object.keys(allow)) {
1963
2062
  if (!knownMcpTools.has(tool)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.5.0",
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,25 +9,29 @@
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
16
16
  // PURE (no I/O, no clock): it takes the declared list and the live rows and
17
- // returns the exact operation set. Two executors drive it against two
18
- // transports — the control-plane (handlers/apiState.ts: the feature's own
19
- // routes with the caller's scopes, or its own table in-process) and the CLI
20
- // (packages/cli/src/apiState.ts: the tenant's API key) — and a parity test pins
21
- // that the same fixtures produce the same plan on both, the cms-schema
22
- // precedent.
17
+ // returns the exact operation set. ONE executor drives it: the control-plane
18
+ // (handlers/apiState.ts convergeApiState — the feature's own routes with the
19
+ // caller's scopes, or its own table in-process), after every config write and
20
+ // as the api-state step of POST /v1/apply. The CLI (packages/cli/src/
21
+ // apiState.ts) never writes these rows: `vxil push` asks the server, and
22
+ // `vxil plan` / `vxil diff` run this planner READ-ONLY when the server's dry
23
+ // run could not plan. packages/cli/src/apiState.e2e.test.ts drives the real
24
+ // CLI binary against the real convergeApiState.
23
25
  //
24
26
  // THE NON-DESTRUCTIVE DEFAULT. A live row the config does NOT declare is
25
27
  // LEFT IN PLACE and reported as `undeclared` — deleted only when the caller
26
28
  // passes allow-destructive — so adopting a block on an existing tenant is a
27
29
  // report, never a wipe. A change that can only be applied by delete+recreate
28
- // (a policy's `key_template`; a subscription's prefix set) is likewise
29
- // destructive-SHAPED: it changes the row's id, which a caller may have
30
- // 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`.
31
35
  //
32
36
  // Two shapes this file deliberately does NOT cover, with the reason:
33
37
  // notifications.templates — templates are CODE with per-locale `overrides`
@@ -159,9 +163,11 @@ export interface LiveWebhookSubscription {
159
163
 
160
164
  export interface WebhookSubscriptionPlan {
161
165
  create: DeclaredWebhookSubscription[];
162
- /** the prefix SET differs — there is no update route, so delete+recreate (a
163
- * NEW sub_id, and the watermark restarts at "now"): destructive-shaped. */
164
- 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[] }>;
165
171
  unchanged: string[];
166
172
  /** live rows the config does not declare, EXCLUDING the function-delivery
167
173
  * lanes (those derive from the functions manifest) — left in place by default */
@@ -176,7 +182,7 @@ function sameStringSet(a: readonly string[], b: readonly string[]): boolean {
176
182
  export function planWebhookSubscriptions(
177
183
  declared: readonly DeclaredWebhookSubscription[], live: readonly LiveWebhookSubscription[],
178
184
  ): WebhookSubscriptionPlan {
179
- const plan: WebhookSubscriptionPlan = { create: [], recreate: [], unchanged: [], undeclared: [] };
185
+ const plan: WebhookSubscriptionPlan = { create: [], update: [], unchanged: [], undeclared: [] };
180
186
  const declaredUrls = new Set(declared.map((d) => d.target_url));
181
187
  const matched = new Set<string>();
182
188
  for (const d of declared) {
@@ -185,12 +191,12 @@ export function planWebhookSubscriptions(
185
191
  if (rows.length === 0) { plan.create.push(d); continue; }
186
192
  // Prefer an EXACT prefix-set match among duplicates (a hand-made row equal
187
193
  // to the declaration is adopted as-is — no churn); else the first row is
188
- // 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.
189
195
  const exact = rows.find((l) => sameStringSet(l.event_prefixes ?? [], want));
190
196
  const chosen = exact ?? rows[0]!;
191
197
  matched.add(chosen.sub_id);
192
198
  if (exact) plan.unchanged.push(d.target_url);
193
- 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] });
194
200
  }
195
201
  for (const l of live) {
196
202
  if (matched.has(l.sub_id)) continue;
@@ -285,14 +291,12 @@ export function emptyApiStateSummary(datum: ApiStateDatum): ApiStateSummary {
285
291
 
286
292
  /** The `api_state` object a config-write response carries (`PUT /v1/config/
287
293
  * :feature` and its dashboard twin, dry-run included — control-plane
288
- * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` READS
289
- * back instead of converging a second time (review 2026-09-23, finding 1: the
290
- * server's converge is the one that ran with the key's scopes; a client-side
291
- * repeat re-listed a KV-backed datum ~100 ms later and could double-create).
292
- * `ok:false` + `deferred:true` = the feature worker has not seen the config
293
- * yet (the client may converge after its own wait); `ok:false` otherwise =
294
- * a DEFINITE, named refusal (nothing to retry); `note` = converged nothing on
295
- * purpose (a disabled feature). */
294
+ * handlers/apiState.ts apiStateResponseField) and the shape `vxil push` /
295
+ * `vxil plan` print. `ok:false` + `deferred:true` = not converged for an
296
+ * INDEFINITE reason — the feature worker has not seen the config yet, or a
297
+ * transient failure (a push asks again through POST /v1/apply after its
298
+ * wait); `ok:false` otherwise = a DEFINITE, named refusal (nothing to retry);
299
+ * `note` = converged nothing on purpose (a disabled feature). */
296
300
  export interface ApiStateResponseField extends ApiStateSummary {
297
301
  ok: boolean;
298
302
  dry_run?: boolean;
@@ -308,8 +312,8 @@ export const API_STATE_DISABLED_NOTE = 'feature disabled — declaration kept, n
308
312
 
309
313
  /** `GET /v1/rate-limits/policies` is a 100-row HARD cap with no cursor. A list
310
314
  * AT the cap is not a complete live set, so planning creates against it would
311
- * re-POST every declared name the cap hid (review finding 5). Both executors
312
- * refuse with this message instead. */
315
+ * re-POST every declared name the cap hid (review finding 5). The server's
316
+ * converge and the CLI's read-only plan refuse with this message instead. */
313
317
  export function rlPolicyListCapError(liveCount: number): string | null {
314
318
  if (liveCount < RL_POLICY_LIST_CAP) return null;
315
319
  return `the policy list is at its ${RL_POLICY_LIST_CAP}-row hard cap (no cursor) — the live set may be incomplete; delete undeclared policies before converging`;
@@ -0,0 +1,15 @@
1
+ /** JSON.stringify with object keys sorted at every depth (arrays keep their
2
+ * order; undefined-valued keys are dropped, as JSON.stringify drops them).
3
+ * Two values with equal output are equal in CONTENT. A stored manifest comes
4
+ * back from storage with its object keys re-ordered, so every declared-vs-
5
+ * stored config compare (the server's shallowDiff, the CLI's diffManifest)
6
+ * must go through this, never through plain JSON.stringify. */
7
+ export function canonicalJson(v: unknown): string | undefined {
8
+ if (Array.isArray(v)) return `[${v.map((x) => canonicalJson(x) ?? 'null').join(',')}]`;
9
+ if (v !== null && typeof v === 'object') {
10
+ const o = v as Record<string, unknown>;
11
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
12
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
13
+ }
14
+ return JSON.stringify(v);
15
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export * from './readmodels.js';
19
19
  // declared-vs-live reconciliation, shared by the control-plane apply path and
20
20
  // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
21
21
  export * from './apiState.js';
22
+ // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
23
+ // (control-plane shallowDiff, CLI diffManifest).
24
+ export * from './canonicalJson.js';
22
25
 
23
26
  // TypeBox validates `format:` only for registered formats — register the ones
24
27
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
@@ -37,6 +40,13 @@ if (!FormatRegistry.Has('email')) {
37
40
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
38
41
  export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
39
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
+
40
50
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
41
51
  * of which is restricted to internal platform machinery). */
42
52
  export function isReservedCreditType(creditType: string): boolean {
@@ -158,6 +168,11 @@ export function validateNotificationOverrides(
158
168
  return errs;
159
169
  }
160
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
+
161
176
  /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
162
177
  * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
163
178
  * one or more lowercase words, a single digit. Pinned as a pattern rather than
@@ -201,6 +216,13 @@ export const NotificationsConfigSchema = Type.Object({
201
216
  region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
202
217
  accessKeyIdRef: Type.String({ minLength: 1 }),
203
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 })),
204
226
  })),
205
227
  defaultLocale: Type.String({ default: 'en-US' }),
206
228
  // nested objects carry `default: {}` so Value.Default can materialize them
@@ -312,6 +334,44 @@ export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
312
334
  /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
313
335
  export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
314
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
+
315
375
  export const JobsConfigSchema = Type.Object({
316
376
  enabled: Type.Boolean({ default: true }),
317
377
  retry: Type.Object(
@@ -339,32 +399,33 @@ export const JobsConfigSchema = Type.Object({
339
399
  { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
340
400
  { default: {} },
341
401
  ),
342
- // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
343
- // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
344
- // resolveGenerationConfig reads `loaded.generation` and clamps to these same
345
- // 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.
346
407
  generation: Type.Object(
347
408
  {
348
409
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
349
- 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 }),
350
411
  /** default expiry/timeout when the descriptor omits one */
351
- 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 }),
352
413
  /** hard ceiling a tenant-supplied timeout is clamped to */
353
- 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 }),
354
415
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
355
- 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 }),
356
417
  /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
357
418
  * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
358
419
  * conservative default so an untrusted deployed function that carries a
359
420
  * reserve block can never hold more than a bounded amount per run without
360
421
  * any tenant action. */
361
- 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 }),
362
423
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
363
424
  * reserve holds across all in-flight generation runs (jobs.md §11.8): a
364
425
  * reserve whose amount would push the tenant's outstanding-holds total over
365
426
  * this is rejected 429, so a runaway function cannot hold every user at
366
427
  * once. Defaulted so no tenant action is required to be safe. */
367
- 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 }),
368
429
  },
369
430
  { default: {} },
370
431
  ),
@@ -834,9 +895,9 @@ export const WebhooksConfigSchema = Type.Object({
834
895
  })),
835
896
  // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
836
897
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
837
- // subscription has (there is no name column and no update route, so a changed
838
- // prefix set is delete+recreate, exactly what the function-trigger reconciler
839
- // 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
840
901
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
841
902
  // reported (deleted only under --allow-destructive). Rows on the platform's
842
903
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -1503,12 +1564,27 @@ export const PaymentsConfigSchema = Type.Object({
1503
1564
  // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
1504
1565
  // MAPS (one typed leaf each), so catalog size never inflates the flag count.
1505
1566
  ledger: Type.Optional(Type.Object({
1506
- productMap: Type.Record(Type.String(), Type.Object({ // product_id → grant rule
1507
- creditType: Type.String({ minLength: 1 }),
1508
- amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1509
- period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1510
- Type.Literal('annual')]),
1511
- })),
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
+ ])),
1512
1588
  tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
1513
1589
  entitlements: Type.Array(Type.String()),
1514
1590
  quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
@@ -1652,6 +1728,15 @@ export const FunctionsConfigSchema = Type.Object({
1652
1728
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1653
1729
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1654
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
+ })),
1655
1740
  }),
1656
1741
  { maxItems: 8 },
1657
1742
  ),
@@ -2102,7 +2187,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2102
2187
  const v = withDefaults as {
2103
2188
  provider?: string;
2104
2189
  resendApiKeyRef?: string;
2105
- ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
2190
+ ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string; snsTopicArn?: string };
2106
2191
  templates?: {
2107
2192
  allowOverride?: boolean;
2108
2193
  overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
@@ -2134,7 +2219,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2134
2219
  const v = withDefaults as {
2135
2220
  provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
2136
2221
  ledger?: {
2137
- productMap?: Record<string, { creditType?: string }>;
2222
+ productMap?: Record<string, { creditType?: string; tier?: string }>;
2138
2223
  tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
2139
2224
  priceMap?: Record<string, string>;
2140
2225
  };
@@ -2154,12 +2239,19 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2154
2239
  // user, running vxil-billed functions for free (audit F2). Rejected at write
2155
2240
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
2156
2241
  const ledgerErrs: string[] = [];
2242
+ const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
2157
2243
  for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
2158
2244
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
2159
2245
  ledgerErrs.push(
2160
2246
  `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
2161
2247
  );
2162
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
+ }
2163
2255
  }
2164
2256
  for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
2165
2257
  (rule.grants ?? []).forEach((g, i) => {
@@ -2336,7 +2428,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2336
2428
  const v = withDefaults as {
2337
2429
  functions?: Record<
2338
2430
  string,
2339
- { 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
+ }
2340
2435
  >;
2341
2436
  };
2342
2437
  const errs: string[] = [];
@@ -2351,6 +2446,12 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2351
2446
  if (b.kind === 'cron' && !b.schedule) {
2352
2447
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2353
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
+ }
2354
2455
  if (b.kind === 'cmsHook' && !b.collection) {
2355
2456
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2356
2457
  }
@@ -2377,7 +2478,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2377
2478
  const v = withDefaults as {
2378
2479
  agents?: Record<string, {
2379
2480
  actions?: { mode?: string; allow?: Record<string, unknown> };
2380
- guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
2481
+ guardrails?: { allowGuest?: boolean; guestToolAllow?: string[]; rateLimitPerUserPerDay?: number };
2381
2482
  }>;
2382
2483
  widget?: { requireAuth?: boolean };
2383
2484
  };
@@ -2386,7 +2487,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2386
2487
  for (const [id, agent] of Object.entries(v.agents ?? {})) {
2387
2488
  const allow = agent.actions?.allow ?? {};
2388
2489
  const guestAllow = agent.guardrails?.guestToolAllow ?? [];
2389
- if (agent.guardrails?.allowGuest === true) anyGuestAgent = true;
2490
+ if (agent.guardrails?.allowGuest === true) {
2491
+ anyGuestAgent = true;
2492
+ // Guests share ONE tenant-wide bucket that spends the tenant's own AI
2493
+ // key: an unlimited (0) daily cap is never allowed alongside them.
2494
+ if (agent.guardrails.rateLimitPerUserPerDay === 0) {
2495
+ errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
2496
+ }
2497
+ }
2390
2498
  if (knownMcpTools) {
2391
2499
  for (const tool of Object.keys(allow)) {
2392
2500
  if (!knownMcpTools.has(tool)) {