@vxil/feature-configs 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Per-feature TypeBox config schemas + the 15-leaf cap analyzer.
2
- // Location note (journaled deviation): the docs place each schema inside its
3
- // feature Worker; the control plane must validate writes against the same
4
- // schema (architecture §7.1 stage 2), so schemas live in this shared package
2
+ // Location note: each schema belongs to its feature, but the config-write
3
+ // path must validate writes against the same schema, so schemas live in this
4
+ // shared package
5
5
  // and feature Workers import from here — one definition, two consumers.
6
6
  import { FormatRegistry, OptionalKind, Type } from '@sinclair/typebox';
7
7
  import { Value } from '@sinclair/typebox/value';
@@ -14,7 +14,7 @@ export * from './hooks.js';
14
14
  // Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
15
15
  // grammar validated here at config-write; field existence at runtime).
16
16
  export * from './readmodels.js';
17
- // Re-export the DECLARED API STATE planner (roadmap §4.11 P0-3): pure
17
+ // Re-export the DECLARED API STATE planner: pure
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';
@@ -26,7 +26,7 @@ export * from './canonicalJson.js';
26
26
  if (!FormatRegistry.Has('email')) {
27
27
  FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
28
28
  }
29
- // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
29
+ // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth ─────────────────
30
30
  // `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
31
31
  // functions "recover-by-price" meter). It must NEVER be grantable or consumable
32
32
  // by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
@@ -36,7 +36,7 @@ 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
39
+ /** A function binding's `retry.maxAttempts` ceiling, and the
40
40
  * binding kinds that may carry `retry` — the platform-delivered event lanes
41
41
  * (an http invoke returns its own status; a cron tick's retry would overlap
42
42
  * the next tick). Read by the schema, the deploy clamp and the CLI. */
@@ -47,7 +47,7 @@ export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'a
47
47
  export function isReservedCreditType(creditType) {
48
48
  return RESERVED_CREDIT_TYPES.has(creditType);
49
49
  }
50
- // ── notifications template catalog (D4 per-locale overrides) ─────────────────
50
+ // ── notifications template catalog (per-locale overrides) ────────────────────
51
51
  // The SHIPPED template ids and the placeholders each body may interpolate. The
52
52
  // renderer itself lives in workers/notifications-v1/src/templates.ts (templates
53
53
  // are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
@@ -72,7 +72,7 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
72
72
  export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS = {
73
73
  transactional: ['cta'],
74
74
  };
75
- /** Per-override caps (D4). Bodies are emails, not documents. */
75
+ /** Per-override caps. Bodies are emails, not documents. */
76
76
  export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
77
77
  export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
78
78
  /** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
@@ -84,7 +84,7 @@ const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
84
84
  /** Same permissive BCP-47-ish shape the worker's auto-locale resolver accepts. */
85
85
  const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
86
86
  /**
87
- * D4 — validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
87
+ * Validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
88
88
  * bag is absent or clean. Every rule fails LOUD rather than shipping something
89
89
  * that silently renders wrong in a customer's inbox:
90
90
  * • overrides present with `allowOverride:false` → rejected (never inert);
@@ -173,12 +173,12 @@ export const NotificationsConfigSchema = Type.Object({
173
173
  // need no email account, so the mock path is zero-config. A cross-field
174
174
  // check in validateFeatureConfig requires it only when provider === 'resend'.
175
175
  resendApiKeyRef: Type.Optional(Type.String()),
176
- // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
177
- // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
176
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (a pointer into the
177
+ // tenant's encrypted secret store — same store as resendApiKeyRef).
178
178
  // When set, inbound Resend webhooks are verified with THIS tenant's secret
179
179
  // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
180
180
  // to the tenant so a signed event for tenant A can never validate at tenant
181
- // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
181
+ // B's webhook URL (mirrors payments revenuecat.webhookSecretRef).
182
182
  webhookSecretRef: Type.Optional(Type.String()),
183
183
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
184
184
  // 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
@@ -190,7 +190,7 @@ export const NotificationsConfigSchema = Type.Object({
190
190
  // cap rule — funded by folding `rateLimit` into an Optional bag below);
191
191
  // NO `default: {}` so an absent bag stays absent (the auth `security`
192
192
  // precedent). Both refs are `secret:<name>` POINTERS into
193
- // public.tenant_secrets (feature='notifications', KEK_NOTIFICATIONS) —
193
+ // the tenant's encrypted secret store (feature='notifications') —
194
194
  // exactly how resendApiKeyRef resolves; the region is plain config (not a
195
195
  // secret) and is pattern-pinned to the AWS region grammar so a typo fails
196
196
  // at `vxil push` instead of as a DNS error on the first send. Required
@@ -212,7 +212,7 @@ export const NotificationsConfigSchema = Type.Object({
212
212
  // nested objects carry `default: {}` so Value.Default can materialize them
213
213
  // and then recurse into the leaf defaults.
214
214
  // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
215
- // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
215
+ // Optional object as ONE) to fund `broadcast` below (2026-07-18).
216
216
  // It KEEPS `default: {}`, which Value.Default still materializes — so every
217
217
  // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
218
218
  // (zero behavioral delta); only the TS type is now optional (workers read
@@ -224,7 +224,7 @@ export const NotificationsConfigSchema = Type.Object({
224
224
  }),
225
225
  }, { default: {} })),
226
226
  suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
227
- // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the M21 `retry` trick)
227
+ // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
228
228
  // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
229
229
  // `default: {}`, so Value.Default still materializes
230
230
  // rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
@@ -234,15 +234,15 @@ export const NotificationsConfigSchema = Type.Object({
234
234
  perDay: Type.Integer({ default: 100000 }),
235
235
  perTenantSec: Type.Integer({ default: 50 }),
236
236
  }, { default: {} })),
237
- // `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
238
- // uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
237
+ // `templates` became an OPTIONAL bag (1 leaf, the same trick `retry`
238
+ // uses) to fund the per-locale `overrides` map WITHOUT moving the count:
239
239
  // countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
240
240
  // Value.Default still materializes `templates.allowOverride` into every
241
241
  // persisted manifest exactly as before — zero behavioral delta; only the TS
242
242
  // type is optional (workers read via core.ts `templatesOf()`).
243
243
  templates: Type.Optional(Type.Object({
244
244
  allowOverride: Type.Boolean({ default: false }),
245
- // D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
245
+ // (2026-09-10) TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
246
246
  // template ids, as config DATA (a Record = 1 leaf, catalog size never
247
247
  // moves the count). Shape: { [templateId]: { [locale]: { subject?,
248
248
  // html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
@@ -267,10 +267,10 @@ export const NotificationsConfigSchema = Type.Object({
267
267
  }, { default: {} })),
268
268
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
269
269
  inboxEnabled: Type.Boolean({ default: false }),
270
- /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
270
+ /** Broadcast campaigns channel. Optional bag (= 1 leaf): absent means
271
271
  * disabled; per-campaign quiet_hours / freq_cap overrides live on the
272
272
  * notifications.campaigns ROW (tenant data), not here. Folded into the
273
- * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
273
+ * canonical schema 2026-07-18 (Value.Clean previously STRIPPED the
274
274
  * worker-local extension, so campaigns 403'd via the real config path). */
275
275
  broadcast: Type.Optional(Type.Object({
276
276
  enabled: Type.Boolean({ default: false }),
@@ -290,16 +290,14 @@ export const NotificationsConfigSchema = Type.Object({
290
290
  })),
291
291
  });
292
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
293
+ // (2026-09-25 — one copy of the generation defaults, not two). The JobsConfigSchema `generation` leaf reads these for its
295
294
  // `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
296
295
  // imports them (jobs-v1 already depends on @vxil/feature-configs; this package
297
296
  // has no @vxil/types dependency, so the shared value lives here — the
298
297
  // 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). */
298
+ // feature-configs unit test pins schema ↔ constant, and a CI gate pins that no
299
+ // second object-literal declaration of the constant reappears anywhere.
300
+ /** Generation-lifecycle config defaults (guide ch. 6, jobs). */
303
301
  export const GENERATION_DEFAULTS = {
304
302
  /** per-tenant in-flight generation cap (separate budget from queue jobs) */
305
303
  maxConcurrent: 20,
@@ -339,7 +337,7 @@ export const JobsConfigSchema = Type.Object({
339
337
  dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
340
338
  schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
341
339
  concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
342
- // 2.F6 generation lifecycle knobs (jobs.md §11). The defaults and bounds are
340
+ // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
343
341
  // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
344
342
  // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
345
343
  // pre-fold manifest to the same numbers — a hand-mirrored copy used to live
@@ -353,14 +351,14 @@ export const JobsConfigSchema = Type.Object({
353
351
  maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
354
352
  /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
355
353
  pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
356
- /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
357
- * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
354
+ /** MANDATORY per-hold cap on a generation `reserve_credits.amount`
355
+ * (guide ch. 6, jobs). Every requested amount is CLAMPED to this (never rejected) — a
358
356
  * conservative default so an untrusted deployed function that carries a
359
357
  * reserve block can never hold more than a bounded amount per run without
360
358
  * any tenant action. */
361
359
  maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
362
360
  /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
363
- * reserve holds across all in-flight generation runs (jobs.md §11.8): a
361
+ * reserve holds across all in-flight generation runs (guide ch. 6, jobs): a
364
362
  * reserve whose amount would push the tenant's outstanding-holds total over
365
363
  * this is rejected 429, so a runaway function cannot hold every user at
366
364
  * once. Defaulted so no tenant action is required to be safe. */
@@ -368,43 +366,42 @@ export const JobsConfigSchema = Type.Object({
368
366
  }, { default: {} }),
369
367
  });
370
368
  // One social-provider's BYO credential block. The *Ref fields are POINTERS into
371
- // public.tenant_secrets (envelope-encrypted) — never raw secrets (features/auth.md
372
- // §6.5). google/github/facebook share the clientId/secret shape; apple is distinct
369
+ // the tenant's encrypted secret store — never raw secrets (guide ch. 6, auth).
370
+ // google/github/facebook share the clientId/secret shape; apple is distinct
373
371
  // (Sign in with Apple has no static secret — it mints an ES256 client_secret from
374
- // the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef, §6.4).
372
+ // the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef).
375
373
  const OAuthRefsSchema = Type.Object({
376
374
  // clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
377
375
  // partial block, and the worker enforces presence at use (→ 501 if missing),
378
376
  // matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
379
- // into tenant_secrets — never raw secrets (features/auth.md §6.5).
377
+ // into the tenant's secret store — never raw secrets (guide ch. 6, auth).
380
378
  clientIdRef: Type.Optional(Type.String()),
381
379
  clientSecretRef: Type.Optional(Type.String()),
382
380
  // extra native-aud allow-list entries (iOS/web client ids that differ from the
383
- // primary clientIdRef) — also tenant_secrets refs (features/auth.md §6.4).
381
+ // primary clientIdRef) — also secret-store refs (guide ch. 6, auth).
384
382
  audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
385
383
  });
386
384
  const AppleRefsSchema = Type.Object({
387
385
  servicesId: Type.String(), // the OAuth client_id / native aud (NOT a secret)
388
386
  teamId: Type.String(),
389
387
  keyId: Type.String(),
390
- p8KeyRef: Type.String(), // tenant_secrets ref → envelope-encrypted .p8 PEM
388
+ p8KeyRef: Type.String(), // secret-store ref → the encrypted .p8 PEM
391
389
  // extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
392
- // carry the app BUNDLE ID as aud, not the Services ID (auth.md §6.4). Plain
390
+ // carry the app BUNDLE ID as aud, not the Services ID (guide ch. 6, auth). Plain
393
391
  // config values — bundle ids are not secrets. The worker already honors them
394
392
  // (oauthCore.ts nativeAudAllowList); declaring them here is what stops
395
393
  // Value.Clean stripping the field out of PUT /v1/config/auth.
396
394
  bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
397
395
  });
398
- // RB-2 — the generic OIDC / SSO bridge (roadmap §4.6.B, §1B.5; features/auth.md
399
- // §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
396
+ // The generic OIDC / SSO bridge (guide ch. 6, auth). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
400
397
  // bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
401
398
  // Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
402
399
  // methods.* toggle — a `default:false` flag would re-materialize every published
403
- // manifest, breaking the D3 byte-identical fold). The worker discovers the
400
+ // manifest, breaking the byte-identical fold). The worker discovers the
404
401
  // endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
405
402
  // iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
406
403
  // `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
407
- // is a tenant_secrets POINTER (feature 'auth'), never the value. A SAML IdP
404
+ // is a secret-store POINTER (feature 'auth'), never the value. A SAML IdP
408
405
  // plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
409
406
  // there is deliberately NO native SAML.
410
407
  const OidcProviderSchema = Type.Object({
@@ -428,7 +425,7 @@ const OidcProviderSchema = Type.Object({
428
425
  // marks the email verified (the tenant declared the issuer authoritative).
429
426
  allowedDomains: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }), { maxItems: 32 })),
430
427
  // default true: an identity whose VERIFIED email matches an existing user is
431
- // linked to it (the shipped §6.4 rule). false: link only by the stable
428
+ // linked to it (the shipped social sign-in rule). false: link only by the stable
432
429
  // (issuer, sub) anchor; a matching email that is not yet linked → 409.
433
430
  autoLink: Type.Optional(Type.Boolean()),
434
431
  });
@@ -437,7 +434,7 @@ const OidcProviderSchema = Type.Object({
437
434
  // on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
438
435
  // `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
439
436
  // delivered to, or a tenant could allow-list an origin the worker then refuses
440
- // (or the reverse). The rule (cvskit gap 7, 2026-09-19): `https://` on any
437
+ // (or the reverse). The rule (2026-09-19): `https://` on any
441
438
  // host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
442
439
  // `127.0.0.1`, any port — so a local dev server can complete a magic link
443
440
  // without a staging origin. Any other `http://` (a LAN host, `*.local`, a
@@ -448,7 +445,7 @@ const OidcProviderSchema = Type.Object({
448
445
  // with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
449
446
  // in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
450
447
  // production promotion gate still refuses any `http://` origin on a
451
- // production push (cli-sdk.md), so a loopback entry is a DEV-tenant affair.
448
+ // production push, so a loopback entry is a DEV-tenant affair.
452
449
  export const REDIRECT_ORIGIN_PATTERN = '^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
453
450
  /** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
454
451
  * only. Takes the parsed URL (or any {protocol, hostname} pair) so the config
@@ -473,13 +470,13 @@ export function isAllowedRedirectOriginEntry(entry) {
473
470
  }
474
471
  export const AuthConfigSchema = Type.Object({
475
472
  enabled: Type.Boolean({ default: true }),
476
- // D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
473
+ // (2026-09-10) the six method toggles collapsed into ONE
477
474
  // Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
478
475
  // the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
479
476
  // which Value.Default still materializes, so every persisted manifest carries
480
477
  // methods.{emailPassword,magicLink,google,github,apple,facebook} with the
481
478
  // SAME keys and defaults as before — byte-identical folds for existing
482
- // tenants (index.test.ts "D3 fold bytes"). Only the TS type is now optional;
479
+ // tenants (index.test.ts pins the fold bytes). Only the TS type is now optional;
483
480
  // the worker's gate() normalizes an absent bag to the defaults so every
484
481
  // reader (config.methods.<flag>) is unchanged.
485
482
  methods: Type.Optional(Type.Object({
@@ -494,12 +491,12 @@ export const AuthConfigSchema = Type.Object({
494
491
  // rule) keyed by provider, so the four provider blocks (and any future one)
495
492
  // never inflate the flag count — the prior shape spent a leaf per top-level
496
493
  // google/github block. This bag REPLACES those two top-level blocks (−2, +1
497
- // for the bag) and EXTENDS the accepted providers to apple + facebook (§6):
494
+ // for the bag) and EXTENDS the accepted providers to apple + facebook:
498
495
  // • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
499
496
  // • apple → { servicesId, teamId, keyId, p8KeyRef }
500
- // All *Ref fields are tenant_secrets POINTERS, never raw secrets (§6.5). The
497
+ // All *Ref fields are secret-store POINTERS, never raw secrets. The
501
498
  // worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
502
- // The §6.4 runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
499
+ // The runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
503
500
  // (id_token/access_token verification + ES256 Apple client_secret minting);
504
501
  // methods.{apple,facebook} above are the enable toggles it gates on.
505
502
  providers: Type.Optional(Type.Object({
@@ -507,18 +504,18 @@ export const AuthConfigSchema = Type.Object({
507
504
  github: Type.Optional(OAuthRefsSchema),
508
505
  apple: Type.Optional(AppleRefsSchema),
509
506
  facebook: Type.Optional(OAuthRefsSchema),
510
- // RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
507
+ // the generic OIDC / SSO issuer (presence = enabled; see above)
511
508
  oidc: Type.Optional(OidcProviderSchema),
512
509
  })),
513
510
  // session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
514
- // the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
511
+ // the OTP/anonymous/orgClaims release — the `{ default: {} }` keeps the inner
515
512
  // defaults materializing on publish, so the worker still reads fully-populated
516
513
  // manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
517
514
  // hand-built manifests only.
518
515
  session: Type.Optional(Type.Object({
519
516
  ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
520
517
  refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
521
- // A1 (auth wave 2026-09-10): concurrent-session cap per user with
518
+ // (2026-09-10) concurrent-session cap per user with
522
519
  // TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
523
520
  // (revoked_reason 'device_cap', edge cache written) and reports them as
524
521
  // `took_over: [session_id…]`. Optional WITHOUT a default so existing
@@ -529,12 +526,12 @@ export const AuthConfigSchema = Type.Object({
529
526
  minLength: Type.Integer({ default: 8, minimum: 6, maximum: 128 }),
530
527
  requireMixed: Type.Boolean({ default: false }),
531
528
  }, { default: {} })),
532
- // Deviation (journaled): emailVerification.tokenTtlHours was DROPPED —
529
+ // Note: emailVerification.tokenTtlHours was DROPPED —
533
530
  // consumer-less (grep-verified: only this schema + dist mentioned it; the
534
531
  // verify-email token flow it would bound was never built). Same precedent as
535
- // the removed `redirects` block below. Its leaf funds the OTP wave.
532
+ // the removed `redirects` block below. Its leaf funds the OTP sign-in bag.
536
533
  emailVerification: Type.Object({ required: Type.Boolean({ default: false }) }, { default: {} }),
537
- // P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
534
+ // (2026-09-18) magicLink is now an OPTIONAL bag (= ONE leaf however
538
535
  // many knobs it holds — the methods/session/password precedent) so the
539
536
  // request cooldown could land without spending a second leaf. It KEEPS
540
537
  // `default: {}`, so Value.Default still materializes
@@ -542,7 +539,7 @@ export const AuthConfigSchema = Type.Object({
542
539
  // folds byte-identically.
543
540
  magicLink: Type.Optional(Type.Object({
544
541
  tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
545
- // P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
542
+ // the per-(tenant, identifier) magic-link REQUEST cooldown — the
546
543
  // `otp.resendCooldownSec` twin, same bounds so the two knobs read the
547
544
  // same. A second request for the same address inside the window is a 429
548
545
  // `magic_link_rate_limited` with a Retry-After header. Type.Optional with
@@ -551,19 +548,19 @@ export const AuthConfigSchema = Type.Object({
551
548
  // auth feature at read time. 0 disables the cooldown.
552
549
  resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
553
550
  }, { default: {} })),
554
- // P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
551
+ // Identity continuity (2026-09-18) — OPT-IN registry adoption. When
555
552
  // true, a sign-in by a method that PROVES control of the address — magic
556
553
  // link, email OTP, or OAuth with a provider-verified address — reuses the id
557
554
  // of the one matching pre-registered end-user (POST /v1/users) that has no
558
555
  // account yet, instead of minting a fresh `user_<ulid>` and leaving the
559
556
  // tenant with two records for one person. Password SIGN-UP never adopts: it
560
- // proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
557
+ // proves nothing about the address (guide ch. 6, auth).
561
558
  // OFF by default, and Type.Optional with NO default: adoption means whoever
562
559
  // proves control of a pre-registered address becomes that record — a change
563
560
  // of security semantics for a tenant that bulk-imports contacts, whose
564
561
  // addresses vxil never verified.
565
562
  registryAdopt: Type.Optional(Type.Boolean()),
566
- // Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
563
+ // Email OTP sign-in + the knobs step-up re-auth shares.
567
564
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
568
565
  // otp?.enabled === true).
569
566
  otp: Type.Optional(Type.Object({
@@ -571,7 +568,7 @@ export const AuthConfigSchema = Type.Object({
571
568
  codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
572
569
  maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
573
570
  resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
574
- // F4-29 (auth wave 2026-09-10): test recipients — an OTP / step-up / email-
571
+ // (2026-09-10) test recipients — an OTP / step-up / email-
575
572
  // claim request whose address matches an entry sends NO mail and returns
576
573
  // the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
577
574
  // email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
@@ -580,11 +577,11 @@ export const AuthConfigSchema = Type.Object({
580
577
  // only — never a real user's address.
581
578
  testRecipients: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 })),
582
579
  })),
583
- // Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
580
+ // Anonymous (guest) sign-in. OPTIONAL bag = 1 leaf.
584
581
  anonymous: Type.Optional(Type.Object({
585
582
  enabled: Type.Boolean({ default: false }),
586
583
  })),
587
- // Org claims embedded in session JWTs at mint/refresh (roadmap Tier-1C):
584
+ // Org claims embedded in session JWTs at mint/refresh:
588
585
  // when enabled, auth-v1 fetches the user's active-org membership from orgs
589
586
  // over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
590
587
  // Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
@@ -593,23 +590,22 @@ export const AuthConfigSchema = Type.Object({
593
590
  orgClaims: Type.Optional(Type.Object({
594
591
  enabled: Type.Boolean({ default: false }),
595
592
  })),
596
- // Deviation (journaled): the doc's §4 `redirects` block was DROPPED entirely —
593
+ // Note: the earlier `redirects` block was DROPPED entirely —
597
594
  // it had zero consumers (grep-verified: no worker reads config.redirects) and
598
595
  // its leaf was spent on the methods.{apple,facebook} toggles the shipped
599
- // §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
596
+ // social sign-in flow actually gates on. Re-adding it requires headroom or a
600
597
  // collapse elsewhere.
601
598
  //
602
- // Account-security controls (auth wave 2026-09-10, P0-3; features/auth.md
603
- // §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
599
+ // Account-security controls (2026-09-10; guide ch. 6, auth). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
604
600
  // absent (existing manifests fold byte-identically) and every control is
605
601
  // opt-in:
606
602
  // • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
607
603
  // sign-in + OTP verify (429 account_locked + Retry-After); a bounded
608
- // counter row in auth.lockouts (migration 0088) survives restarts.
604
+ // stored counter survives restarts.
609
605
  // • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
610
606
  // chars leave the worker, never the password) at sign-up / reset-confirm;
611
607
  // fail-OPEN on network error → 422 password_breached.
612
- // • captchaSecretRef — a tenant_secrets ref (feature 'auth') holding the
608
+ // • captchaSecretRef — a secret-store ref (feature 'auth') holding the
613
609
  // Turnstile secret; when set, sign-up / OTP request / magic-link request
614
610
  // require `captcha_token` (403 captcha_failed otherwise).
615
611
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
@@ -629,10 +625,10 @@ export const AuthConfigSchema = Type.Object({
629
625
  allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }), { maxItems: 32 })),
630
626
  })),
631
627
  });
632
- // ── DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23) ──────────────────────
628
+ // ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
633
629
  // A rate-limit policy is a ROW in the feature's own KV store, written by
634
630
  // POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
635
- // vxil.config (a journaled deviation: "one writer per datum, and the writer is
631
+ // vxil.config (by the old rule "one writer per datum, and the writer is
636
632
  // the route"), which made a second environment non-reproducible — every tenant
637
633
  // ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
638
634
  // rule is unchanged — one writer per datum — but the writer is now THE
@@ -670,7 +666,7 @@ export const RateLimitsConfigSchema = Type.Object({
670
666
  });
671
667
  export const FilesConfigSchema = Type.Object({
672
668
  enabled: Type.Boolean({ default: true }),
673
- // (F8-54) `bucketRef` was DELETED: the object-storage bucket is a platform
669
+ // `bucketRef` was DELETED (2026-09-11): the object-storage bucket is a platform
674
670
  // binding on files-v1, never tenant-selectable, and no code ever read the
675
671
  // leaf — declaring it invited "point files at my own bucket", which vxil does
676
672
  // not offer. A persisted manifest that still carries it folds (Value.Clean).
@@ -678,8 +674,8 @@ export const FilesConfigSchema = Type.Object({
678
674
  downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
679
675
  quotas: Type.Object({
680
676
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
681
- // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
682
- // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
677
+ // object storage is cheap but the database-resident metadata + abuse aren't
678
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
683
679
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
684
680
  // tenant tier threaded to files-v1 (plan tiers).
685
681
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
@@ -687,7 +683,7 @@ export const FilesConfigSchema = Type.Object({
687
683
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
688
684
  }, { default: {} }),
689
685
  allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
690
- // (F8-54) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
686
+ // (2026-09-11) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
691
687
  // there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
692
688
  // neither leaf was ever read), so the knob promised quarantine that never
693
689
  // happened. Re-declare it in the same change as a real scanner, not before.
@@ -695,7 +691,7 @@ export const FilesConfigSchema = Type.Object({
695
691
  enabled: Type.Boolean({ default: true }),
696
692
  maxTtl: Type.Integer({ default: 7 * 24 * 3600 }),
697
693
  }, { default: {} }),
698
- // Wave-2 extensions (features/files.md §1.1 OCR + §1.2 TTL) — the merge of
694
+ // Wave-2 extensions (guide ch. 6, files: OCR + TTL) — the merge of
699
695
  // workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
700
696
  // 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
701
697
  // rule); files-v1 already reads both defensively (FilesConfigWithExt), so
@@ -710,16 +706,15 @@ export const FilesConfigSchema = Type.Object({
710
706
  extractText: Type.Optional(Type.Object({
711
707
  enabled: Type.Boolean({ default: false }),
712
708
  provider: Type.Union([Type.Literal('gcv'), Type.Literal('textract'), Type.Literal('azure-di'), Type.Literal('mock')], { default: 'mock' }),
713
- // provider key is BYO + envelope-encrypted in public.tenant_secrets — NOT a
714
- // config flag. keyRef names the tenant_secrets row (like ai's keyRefs).
709
+ // provider key is BYO + encrypted in the tenant's secret store — NOT a
710
+ // config flag. keyRef names the stored secret (like ai's keyRefs).
715
711
  keyRef: Type.Optional(Type.String()),
716
712
  asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
717
713
  boundingBoxes: Type.Boolean({ default: false }),
718
714
  })),
719
715
  });
720
716
  // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
721
- // engine. Subscriptions live in their own table (§1 contract — one writer
722
- // per datum); config is just the capability gate + a cap.
717
+ // engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
723
718
  export const DeclaredWebhookSubscriptionSchema = Type.Object({
724
719
  target_url: Type.String({ minLength: 9, maxLength: 2000 }),
725
720
  event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
@@ -728,14 +723,14 @@ export const WebhooksConfigSchema = Type.Object({
728
723
  enabled: Type.Boolean({ default: true }),
729
724
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
730
725
  maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
731
- // FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
726
+ // FAILURE-ALERT DIGEST. Outbound subscriptions are the real-time
732
727
  // channel; this is the "nobody is consuming them yet" fallback — a periodic
733
728
  // e-mail summary of the tenant's FAILURE-class audit events (the level:
734
729
  // 'failure' rows of the generated event catalog). Read by
735
730
  // workers/control-plane/src/alertDigest.ts on the minute cron.
736
731
  //
737
- // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
738
- // journaled here the way RateLimitsConfigSchema journals its own: an
732
+ // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this decision is
733
+ // recorded here the way RateLimitsConfigSchema records its own: an
739
734
  // arbitrary `to` would turn vxil's own sending identity into a relay for
740
735
  // tenant-authored content and open a PII egress path out of the audit trail.
741
736
  // The digest goes to the OWNER-role dashboard accounts of the project (cap
@@ -745,7 +740,7 @@ export const WebhooksConfigSchema = Type.Object({
745
740
  //
746
741
  // An Optional object bag counts as ONE leaf (the countLeaves rule).
747
742
  //
748
- // `digestMinutes: 0` is IMMEDIATE (roadmap §4.11 P0-4c, 2026-09-23): the
743
+ // `digestMinutes: 0` is IMMEDIATE (2026-09-23): the
749
744
  // pass runs every minute for the tenant and mails the `error`-level failure
750
745
  // rows that landed since its last mail — at most one mail per minute, still
751
746
  // to the owner accounts. 1–4 are clamped up to 5 by the reader.
@@ -754,11 +749,11 @@ export const WebhooksConfigSchema = Type.Object({
754
749
  minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
755
750
  digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
756
751
  })),
757
- // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
752
+ // DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
758
753
  // subscriptions as config. Keyed by `target_url` — the only stable identity a
759
754
  // subscription has (there is no name column). A changed prefix set is an
760
755
  // 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
756
+ // cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge the live subscriptions
762
757
  // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
763
758
  // reported (deleted only under --allow-destructive). Rows on the platform's
764
759
  // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
@@ -775,16 +770,16 @@ export const CommentsConfigSchema = Type.Object({
775
770
  /** Author edits allowed this long after posting; 0 disables editing. */
776
771
  editWindowMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 10_080 }),
777
772
  });
778
- // content feature (cms-feature-analysis.md §4.2): flags govern LIMITS, never the
773
+ // content feature (guide ch. 4): flags govern LIMITS, never the
779
774
  // content model — the model itself is data (cms.collections / cms.fields via
780
- // the REST surface). versioning/localization/publicRead land later waves.
775
+ // the REST surface). versioning/localization/publicRead land later.
781
776
  export const CmsConfigSchema = Type.Object({
782
777
  enabled: Type.Boolean({ default: true }),
783
778
  draftPublish: Type.Boolean({ default: true }),
784
- // cms end-user default-deny fail-safe (path-to-100 §3.2, Feature B). When ON,
779
+ // cms end-user default-deny fail-safe (guide ch. 4). When ON,
785
780
  // a VERIFIED end-user key (owner-scope mode) is DENIED access to any
786
781
  // collection that declares no owner_field — `403 server_only` on read AND
787
- // write (finding 29, 2026-09-20; reads used to be a 404) — instead of the
782
+ // write (2026-09-20; reads used to be a 404) — instead of the
788
783
  // default tenant-wide-shared behavior. Server-caller mode is a
789
784
  // byte-for-byte no-op. Default OFF preserves today's shared semantics
790
785
  // (owner.int.test.ts's shared-collection invariant). A collection that DOES
@@ -800,7 +795,7 @@ export const CmsConfigSchema = Type.Object({
800
795
  maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
801
796
  defaultPageSize: Type.Integer({ default: 25, minimum: 1, maximum: 500 }),
802
797
  }, { default: {} }),
803
- // cms ENRICHMENT (cms.md §6.4): the read-time relation budget. The worker
798
+ // cms ENRICHMENT (guide ch. 4): the read-time relation budget. The worker
804
799
  // clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
805
800
  // hard ceilings, so an out-of-range value can never widen the bound.
806
801
  relations: Type.Object({
@@ -832,7 +827,7 @@ export const CmsConfigSchema = Type.Object({
832
827
  message: Type.Optional(Type.String({ maxLength: 200 })),
833
828
  enabled: Type.Optional(Type.Boolean()),
834
829
  }))),
835
- // Declarative relational read-models (cms-relational-depth §3 B2/B3/B5).
830
+ // Declarative relational read-models (guide ch. 4).
836
831
  // Each is a NAMED, closed-grammar aggregate/rank spec, optionally
837
832
  // materialized to a rollup collection on the EXISTING jobs cron (the
838
833
  // fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
@@ -841,7 +836,7 @@ export const CmsConfigSchema = Type.Object({
841
836
  readModels: Type.Optional(Type.Record(Type.String({ maxLength: 64 }), Type.Object({
842
837
  collection: Type.String({ maxLength: 64 }),
843
838
  kind: Type.Union([Type.Literal('aggregate'), Type.Literal('rank')]),
844
- // the §3.1/§4.1 body minus limit — Type.Unknown so Value.Clean keeps it
839
+ // the aggregate/rank query body minus limit — Type.Unknown so Value.Clean keeps it
845
840
  // (the functions `signature` idiom); shape checked by the cross-field rule.
846
841
  spec: Type.Unknown(),
847
842
  materialize: Type.Optional(Type.Object({
@@ -865,13 +860,13 @@ export const CmsConfigSchema = Type.Object({
865
860
  enabled: Type.Optional(Type.Boolean()),
866
861
  }))),
867
862
  });
868
- // mcp feature (mcp.md §5): the aggregation surface's own knobs. Default-enabled —
863
+ // mcp feature (guide ch. 10): the aggregation surface's own knobs. Default-enabled —
869
864
  // a tenant with no mcp config row still gets the aggregated tool list.
870
865
  export const McpConfigSchema = Type.Object({
871
866
  enabled: Type.Boolean({ default: true }),
872
867
  exposureLevel: Type.Union([Type.Literal('all'), Type.Literal('read-only'), Type.Literal('custom')], { default: 'all' }),
873
868
  allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
874
- // (F8-54) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
869
+ // (2026-09-11) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
875
870
  // a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
876
871
  // control-plane concern independent of MCP exposure — per-agent keys already
877
872
  // work for every tenant, gated by nothing here.
@@ -880,7 +875,7 @@ export const McpConfigSchema = Type.Object({
880
875
  serverName: Type.String({ default: 'Vxil' }),
881
876
  serverInstructions: Type.Optional(Type.String({ maxLength: 4000 })),
882
877
  }, { default: {} }),
883
- // Tenant-authored CUSTOM tools (mcp.md §6.5): name → tenant-owned https
878
+ // Tenant-authored CUSTOM tools (guide ch. 10): name → tenant-owned https
884
879
  // endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
885
880
  // arguments to `url`, HMAC-signed with the per-tenant key from
886
881
  // GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
@@ -896,7 +891,7 @@ export const McpConfigSchema = Type.Object({
896
891
  inputSchema: Type.Optional(Type.Unknown()),
897
892
  timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 20_000 })),
898
893
  }))),
899
- // Config-declared MCP PROMPTS (mcp.md §11 closure): name → template with
894
+ // Config-declared MCP PROMPTS (guide ch. 10): name → template with
900
895
  // {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
901
896
  // prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
902
897
  // a cross-field rule below.
@@ -926,7 +921,7 @@ export const OrgsConfigSchema = Type.Object({
926
921
  maxMembersPerOrg: Type.Integer({ default: 1000, minimum: 1, maximum: 100_000 }),
927
922
  invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
928
923
  });
929
- // activity-feed feature (features/activity-feed.md §3): a GetStream-class activity-
924
+ // activity-feed feature (guide ch. 6, activity-feed): a GetStream-class activity-
930
925
  // streams engine + a Knock/Novu-class in-app notification FEED. Flags govern
931
926
  // the fan-out throttle, the follow/aggregation caps, and the cross-channel /
932
927
  // realtime gates — NEVER the verb vocabulary or the personalized ranker (the
@@ -943,8 +938,8 @@ export const ActivityFeedConfigSchema = Type.Object({
943
938
  Type.Literal('aggregated'),
944
939
  Type.Literal('notification'),
945
940
  ]),
946
- aggregation: Type.Optional(Type.String()), // group-format rule (§7); required for aggregated/notification
947
- ranking: Type.Optional(Type.String()), // 'chronological' | 'decay' (§8); flat-only; default chronological
941
+ aggregation: Type.Optional(Type.String()), // group-format rule; required for aggregated/notification
942
+ ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'; flat-only; default chronological
948
943
  }), {
949
944
  default: {
950
945
  user: { type: 'flat' },
@@ -958,14 +953,14 @@ export const ActivityFeedConfigSchema = Type.Object({
958
953
  fanout: Type.Object({
959
954
  celebrityThreshold: Type.Integer({ default: 10_000, minimum: 0 }), // ≥ → pull (read-side); < → push (write-side)
960
955
  maxFanoutPerJob: Type.Integer({ default: 1000, minimum: 1, maximum: 10_000 }), // follower batch size per jobs task
961
- maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (LOCAL throttle §5)
956
+ maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (local throttle)
962
957
  pendingCeiling: Type.Integer({ default: 50_000, minimum: 1 }), // pending-fan-out-depth back-pressure ceiling
963
958
  }, { default: {} }),
964
959
  follow: Type.Object({
965
960
  copyLimit: Type.Integer({ default: 100, minimum: 0, maximum: 1000 }), // backfill budget on follow
966
961
  maxFollowing: Type.Integer({ default: 10_000, minimum: 0 }), // per-feed following cap
967
962
  }, { default: {} }),
968
- // (F8-54) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
963
+ // (2026-09-11) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
969
964
  // or returned a per-group activity LIST — an aggregated read returns the group
970
965
  // rollup (activity_count/actor_count/last_actor), so there was never an N to
971
966
  // bound and no code read the leaf. Re-declare it with a group-detail route.
@@ -973,16 +968,16 @@ export const ActivityFeedConfigSchema = Type.Object({
973
968
  enabled: Type.Boolean({ default: true }), // live new-activity + count push over the realtime ChannelDO
974
969
  }, { default: {} }),
975
970
  crossChannel: Type.Object({
976
- enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger (§10)
971
+ enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
977
972
  digestCadence: Type.Union([Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')], { default: 'off' }), // digest roll-up window (feed owns the roll-up, rides notifications' live single-send)
978
973
  }, { default: {} }),
979
- // (F8-54) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
974
+ // (2026-09-11) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
980
975
  // rate-limiter binding and never read it, so the declared per-tenant write
981
976
  // burst was enforced by nothing (the edge front-door limiter and the
982
977
  // per-tenant request meter are the real bounds). Re-declare it together with
983
978
  // the binding that enforces it.
984
979
  });
985
- // vector-search feature (features/vector-search.md §4). Re-declared here to match the
980
+ // vector-search feature (guide ch. 6, vector-search). Re-declared here to match the
986
981
  // schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
987
982
  // control plane validates writes against this shared copy (the same
988
983
  // one-definition / two-consumers note as the other features above; this package owns
@@ -991,7 +986,7 @@ export const ActivityFeedConfigSchema = Type.Object({
991
986
  // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
992
987
  export const VectorSearchConfigSchema = Type.Object({
993
988
  enabled: Type.Boolean({ default: true }),
994
- // 'auto' resolves to the default managed vector backend for the tier (#147).
989
+ // 'auto' resolves to the default managed vector backend for the tier.
995
990
  backend: Type.Union([Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')], { default: 'auto' }),
996
991
  // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
997
992
  // BYO key from tenant secrets via apiKeyRef (encrypted at rest).
@@ -1025,7 +1020,7 @@ export const VectorSearchConfigSchema = Type.Object({
1025
1020
  provider: Type.Union([Type.Literal('mock'), Type.Literal('cohere'), Type.Literal('voyage')]),
1026
1021
  model: Type.Optional(Type.String({ maxLength: 128 })), // cohere 'rerank-v3.5' / voyage 'rerank-2'
1027
1022
  topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
1028
- apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' under KEK_VECTOR_SEARCH
1023
+ apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' in the tenant's secret store
1029
1024
  })),
1030
1025
  // Config-driven auto-embedding sync from cms collections: the control plane
1031
1026
  // reconciles one `vs-sync:` jobs schedule per entry on every config commit;
@@ -1042,12 +1037,12 @@ export const VectorSearchConfigSchema = Type.Object({
1042
1037
  cron: Type.Optional(Type.String({ pattern: '^\\S+ \\S+ \\S+ \\S+ \\S+$' })),
1043
1038
  }), { maxItems: 8 })),
1044
1039
  });
1045
- // ai feature (features/ai.md §4). Re-declared to match workers/ai-v1/src/core.ts's
1040
+ // ai feature (guide ch. 6, ai). Re-declared to match workers/ai-v1/src/core.ts's
1046
1041
  // exported AiConfigSchema. vxil owns the call SCAFFOLDING (routing, streaming,
1047
1042
  // token accounting, caching, the reserve→settle budget); the tenant owns the
1048
1043
  // intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
1049
1044
  // authored). 'mock' is the deterministic default until a BYO key is provisioned;
1050
- // the real providers route via tenant_secrets keyRefs.
1045
+ // the real providers route via secret-store keyRefs.
1051
1046
  export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
1052
1047
  export const AI_MAX_DECLARED_TEMPLATES = 50;
1053
1048
  export const DeclaredAiTemplateSchema = Type.Object({
@@ -1063,7 +1058,7 @@ export const AiConfigSchema = Type.Object({
1063
1058
  // meta-provider (BYO key under providers.compat.openrouterKeyRef).
1064
1059
  defaultProvider: Type.Union([Type.Literal('mock'), Type.Literal('openai'), Type.Literal('anthropic'),
1065
1060
  Type.Literal('gemini'), Type.Literal('azure'), Type.Literal('openrouter')], { default: 'mock' }),
1066
- // BYO keyRefs → public.tenant_secrets (envelope-encrypted). The block is NOT
1061
+ // BYO keyRefs → the tenant's encrypted secret store. The block is NOT
1067
1062
  // optional (the worker declares it plain), so its three optional refs each count
1068
1063
  // as a leaf. The nested blocks carry `default: {}` (this package's convention)
1069
1064
  // so Value.Default materializes them + recurses into the leaf defaults when a
@@ -1075,7 +1070,7 @@ export const AiConfigSchema = Type.Object({
1075
1070
  geminiKeyRef: Type.Optional(Type.String()),
1076
1071
  // The openai-compatible extension surface — ONE optional object = ONE config
1077
1072
  // leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
1078
- // compat.openrouterKeyRef: BYO OpenRouter key (tenant_secrets ref, KEK_AI).
1073
+ // compat.openrouterKeyRef: BYO OpenRouter key (a secret-store ref).
1079
1074
  // compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
1080
1075
  // host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
1081
1076
  // at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
@@ -1095,7 +1090,7 @@ export const AiConfigSchema = Type.Object({
1095
1090
  tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
1096
1091
  consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
1097
1092
  }, { default: {} }),
1098
- // `streaming` became an OPTIONAL bag (3 leaves → 1, the M21 `retry` trick)
1093
+ // `streaming` became an OPTIONAL bag (3 leaves → 1, the `retry` trick)
1099
1094
  // on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
1100
1095
  // `default: {}`, so Value.Default still materializes
1101
1096
  // streaming.{enabled,replayBufferFrames,flushMs} into every persisted
@@ -1104,24 +1099,24 @@ export const AiConfigSchema = Type.Object({
1104
1099
  // here is optional.
1105
1100
  streaming: Type.Optional(Type.Object({
1106
1101
  enabled: Type.Boolean({ default: true }),
1107
- replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // §2a ring-buffer depth
1108
- flushMs: Type.Integer({ default: 50, minimum: 0 }), // §2a/#148 token→frame coalesce window
1102
+ replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // replay ring-buffer depth
1103
+ flushMs: Type.Integer({ default: 50, minimum: 0 }), // token→frame coalesce window
1109
1104
  }, { default: {} })),
1110
- // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's stored
1111
- // prompt templates as config. vxil STORES + versions, never authors (ai.md
1112
- // §0) — declaring them here changes WHO writes the row (the repository, via
1105
+ // DECLARED API STATE (2026-09-23): the tenant's stored
1106
+ // prompt templates as config. vxil STORES + versions, never authors — declaring them here changes WHO writes the row (the repository, via
1113
1107
  // `vxil push`), not what vxil does with it. Converged by CONTENT: the
1114
1108
  // control-plane hashes each declared entry (@vxil/runtime
1115
1109
  // aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
1116
1110
  // stamps on the latest version, and POSTs a new version ONLY when the content
1117
1111
  // differs — so a push is idempotent and versions stay monotonic per name.
1118
1112
  // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1119
- // name). Stored templates the config does not declare are reported (there is
1120
- // no delete route — they are never removed). Bounded to 50 entries: the
1113
+ // name). Stored templates the config does not declare are reported and left
1114
+ // in place — RETIRED (soft: hidden from list + render, history kept) only
1115
+ // under --allow-destructive (2026-10-01). Bounded to 50 entries: the
1121
1116
  // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1122
1117
  templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1123
1118
  });
1124
- // rag feature (features/rag.md §2). Re-declared to match workers/rag-v1/src/config.ts's
1119
+ // rag feature (guide ch. 6, rag). Re-declared to match workers/rag-v1/src/config.ts's
1125
1120
  // exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
1126
1121
  // context budget + strategy + tokenizer, the citation/stream gates — NEVER the
1127
1122
  // prompt, the synthesis, or relevance tuning (the tenant's `ai` template owns those).
@@ -1139,7 +1134,7 @@ export const RagConfigSchema = Type.Object({
1139
1134
  rerank: Type.Boolean({ default: false }),
1140
1135
  }, { default: {} }),
1141
1136
  // declarative per-metadata-field relevance boosts applied in rag AFTER
1142
- // retrieval, BEFORE minScore/budget/grounding (rag.md §2f). ONE Type.Record
1137
+ // retrieval, BEFORE minScore/budget/grounding (guide ch. 6, rag). ONE Type.Record
1143
1138
  // leaf (the activity-feed feedGroups precedent).
1144
1139
  boosts: Type.Record(Type.String(), Type.Union([
1145
1140
  Type.Object({
@@ -1153,13 +1148,13 @@ export const RagConfigSchema = Type.Object({
1153
1148
  }),
1154
1149
  ]), { default: {} }),
1155
1150
  context: Type.Object({
1156
- // bounded context budget — enforced via the §2c tokenizer, BEFORE the ai call.
1151
+ // bounded context budget — enforced via the tokenizer, BEFORE the ai call.
1157
1152
  maxTokens: Type.Integer({ default: 4000, minimum: 1, maximum: 1_000_000 }),
1158
1153
  strategy: Type.Union([Type.Literal('topk'), Type.Literal('mmr')], {
1159
1154
  default: 'topk',
1160
1155
  }),
1161
1156
  // 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
1162
- // ~chars/4 with a safety margin (§2c, #149).
1157
+ // ~chars/4 with a safety margin.
1163
1158
  tokenizer: Type.Union([Type.Literal('provider'), Type.Literal('heuristic')], {
1164
1159
  default: 'provider',
1165
1160
  }),
@@ -1169,7 +1164,7 @@ export const RagConfigSchema = Type.Object({
1169
1164
  citations: Type.Boolean({ default: true }),
1170
1165
  streaming: Type.Boolean({ default: true }),
1171
1166
  });
1172
- // payments feature (features/payments.md §5). Re-declared to match the schema the
1167
+ // payments feature (guide ch. 6, payments). Re-declared to match the schema the
1173
1168
  // worker EXPORTS from workers/payments-v1/src/core.ts — the control plane
1174
1169
  // validates writes against this shared copy (one-definition / two-consumers,
1175
1170
  // like the other features). Two co-equal pillars: (A) provider payments (the
@@ -1185,9 +1180,9 @@ export const PaymentsConfigSchema = Type.Object({
1185
1180
  // events) so the ENTIRE ledger path is testable WITHOUT real provider keys.
1186
1181
  provider: Type.Union([Type.Literal('mock'), Type.Literal('stripe'), Type.Literal('paddle'),
1187
1182
  Type.Literal('revenuecat'), Type.Literal('paypal')], { default: 'mock' }),
1188
- // BYO-key credential blocks → public.tenant_secrets (envelope-encrypted).
1183
+ // BYO-key credential blocks → the tenant's encrypted secret store.
1189
1184
  // Each OPTIONAL object counts as ONE leaf (the tenant's decision is
1190
- // "configure it or not", not each inner ref — features/auth.md §4).
1185
+ // "configure it or not", not each inner ref — guide ch. 6, auth).
1191
1186
  stripe: Type.Optional(Type.Object({
1192
1187
  secretKeyRef: Type.String(),
1193
1188
  webhookSecretRef: Type.String(),
@@ -1202,24 +1197,34 @@ export const PaymentsConfigSchema = Type.Object({
1202
1197
  projectId: Type.String(),
1203
1198
  publicSdkKey: Type.String(),
1204
1199
  secretApiKeyRef: Type.String(),
1205
- // Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
1200
+ // Per-tenant webhook secret ref (secret store). Inbound RevenueCat
1206
1201
  // webhooks are verified against THIS ref and nothing else: there is no
1207
1202
  // platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
1208
- // (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
1209
- // frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
1203
+ // (that fallback WAS a cross-tenant webhook-forgery vector; a CI gate now
1210
1204
  // permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
1211
1205
  // Optional at the SCHEMA level only — leaving it unset does not disable
1212
1206
  // verification, it fails CLOSED: every delivery is 401 bad_signature with a
1213
1207
  // `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
1214
1208
  // webhookSecretRef.
1215
1209
  webhookSecretRef: Type.Optional(Type.String()),
1216
- // Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
1210
+ // Environment integrity. RevenueCat posts SANDBOX
1217
1211
  // and PRODUCTION events to the SAME webhook with the same auth header, so a
1218
1212
  // sandbox purchase would otherwise fold into production entitlements. A
1219
1213
  // sandbox event is persisted as outcome 'rejected_environment' (200, never
1220
1214
  // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1221
1215
  // environments by signing secret / API base, so only RC carries this knob.
1222
1216
  acceptSandbox: Type.Boolean({ default: false }),
1217
+ // (2026-10-01) Store-review purchases on a PRODUCTION tenant:
1218
+ // the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
1219
+ // whose subject (and, for a TRANSFER, every source user) is listed here
1220
+ // folds — recorded `environment: 'sandbox'` on the delivery, the
1221
+ // subscription and the charge, so it stays out of revenue — while every
1222
+ // other sandbox event is still `rejected_environment`. The narrow,
1223
+ // production-safe alternative to `acceptSandbox` (which folds EVERY
1224
+ // TestFlight purchase and is refused by the CLI's production promotion
1225
+ // gate). No default on purpose (Value.Default would materialize it into
1226
+ // every RC manifest); absent = an empty list.
1227
+ sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
1223
1228
  })),
1224
1229
  paypal: Type.Optional(Type.Object({
1225
1230
  clientIdRef: Type.String(),
@@ -1230,9 +1235,9 @@ export const PaymentsConfigSchema = Type.Object({
1230
1235
  // Where a provider-hosted flow sends the payer back (Stripe billing-portal
1231
1236
  // return, PayPal approval return/cancel) — the TENANT's own app URL,
1232
1237
  // https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
1233
- // hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
1238
+ // hardcoded host (2026-07-10: the old fallback pointed at a dead apex).
1234
1239
  returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
1235
- // NB (F8-54, 2026-09-11): the former `prices.catalogRef` leaf was DELETED —
1240
+ // NB (2026-09-11): the former `prices.catalogRef` leaf was DELETED —
1236
1241
  // it named nothing (prices resolve from ledger.priceMap; no code path ever
1237
1242
  // read it). A persisted manifest that still carries `prices` folds:
1238
1243
  // validateFeatureConfig's Value.Clean strips the stray key.
@@ -1247,16 +1252,19 @@ export const PaymentsConfigSchema = Type.Object({
1247
1252
  // product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
1248
1253
  // `boosts` Record-of-Union precedent) with two rule shapes:
1249
1254
  // { creditType, amount, period } a credit grant (the original rule)
1250
- // { tier, durationDays } (2026-09-25 F35+) a TIME-BOXED
1255
+ // { tier, durationDays } (2026-09-25) a TIME-BOXED
1251
1256
  // ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
1252
1257
  // 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.
1258
+ // STACKS behind the user's live same-tier manual rows that have an end
1259
+ // (earlier passes AND comp grants since 2026-09-25 — never a row
1260
+ // linked to the same charge, an open-ended grant or a provider
1261
+ // subscription) and is ENDED by that charge's full refund / chargeback.
1262
+ // No defaults in either shape, so an existing manifest is
1263
+ // byte-identical after Value.Default.
1256
1264
  productMap: Type.Record(Type.String(), Type.Union([
1257
1265
  Type.Object({
1258
1266
  creditType: Type.String({ minLength: 1 }),
1259
- amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
1267
+ amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
1260
1268
  period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
1261
1269
  Type.Literal('annual')]),
1262
1270
  }),
@@ -1267,12 +1275,20 @@ export const PaymentsConfigSchema = Type.Object({
1267
1275
  ])),
1268
1276
  tierMap: Type.Record(Type.String(), Type.Object({
1269
1277
  entitlements: Type.Array(Type.String()),
1270
- quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
1271
- rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold (#125)
1278
+ quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // no negative quota
1279
+ rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
1272
1280
  grants: Type.Optional(Type.Array(Type.Object({
1273
1281
  creditType: Type.String({ minLength: 1 }),
1274
- amount: Type.Integer({ minimum: 1 }), // #128
1282
+ amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
1275
1283
  period: Type.String(),
1284
+ // (2026-10-01) 'add' (the reader's default, today's
1285
+ // behaviour) ADDS `amount` each period; 'reset' makes the period's
1286
+ // grant REPLACE what is left: the unspent available balance of
1287
+ // `creditType` is written off as one `expire` ledger row and `amount`
1288
+ // granted, so the user starts every period with exactly `amount`
1289
+ // (credits held by an in-flight job stay held). No schema default (it
1290
+ // would materialize into every manifest carrying grants).
1291
+ mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
1276
1292
  }))),
1277
1293
  })),
1278
1294
  // provider price/plan id → tier. A real subscription webhook carries the
@@ -1281,7 +1297,7 @@ export const PaymentsConfigSchema = Type.Object({
1281
1297
  // fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
1282
1298
  priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
1283
1299
  autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
1284
- // Grace window (money-path wave F1-7): a `past_due` subscription stays
1300
+ // Grace window: a `past_due` subscription stays
1285
1301
  // entitled for this many days AFTER its current_period_end (the dunning
1286
1302
  // window the provider is retrying inside). 0 = today's behaviour (a past_due
1287
1303
  // row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
@@ -1289,8 +1305,7 @@ export const PaymentsConfigSchema = Type.Object({
1289
1305
  grace: Type.Optional(Type.Object({
1290
1306
  pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
1291
1307
  })),
1292
- // Opt-in period-end enforcement (money-path operations wave, decision D2
1293
- // option a). ABSENT (the default) = today's behaviour: a subscription whose
1308
+ // Opt-in period-end enforcement. ABSENT (the default) = today's behaviour: a subscription whose
1294
1309
  // current_period_end passed with no provider event stays entitled forever
1295
1310
  // (the provider is the only clock). PRESENT = the nightly reconcile sweep
1296
1311
  // flips an `active`/`trialing` row whose current_period_end + slackHours
@@ -1325,28 +1340,28 @@ export const PaymentsConfigSchema = Type.Object({
1325
1340
  // (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
1326
1341
  unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
1327
1342
  })),
1328
- // NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
1343
+ // NB: the former `webhooks.forwardToTenantUrl` leaf
1329
1344
  // was DELETED — it had zero readers (never forwarded anything). Outbound
1330
- // delivery of payments state changes rides the audit_event → webhooks-out
1331
- // spine: subscribe to the `payments.` event prefix (payments.md §7b).
1345
+ // delivery of payments state changes rides the audit stream → outbound
1346
+ // webhooks: subscribe to the `payments.` event prefix (guide ch. 6, payments).
1332
1347
  });
1333
- // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
1348
+ // functions feature (guide ch. 8). Tenant-deployed backend edge
1334
1349
  // functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
1335
1350
  // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
1336
1351
  // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
1337
1352
  // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
1338
1353
  // (cms.hooks / payments.ledger precedent), so any number of deployed functions
1339
- // never grows the flag cap. This is the §7.3 crossing — paid, tier-walled,
1354
+ // never grows the flag cap. Tenant code on the platform — paid, tier-walled,
1340
1355
  // opt-in (enabled defaults to false), egress-guarded.
1341
1356
  export const FunctionsConfigSchema = Type.Object({
1342
1357
  enabled: Type.Boolean({ default: false }),
1343
- // (F8-54) `runtime` ('isolate' | 'container') was DELETED: the container lane
1344
- // is design-only, nothing read the leaf, and accepting 'container' silently
1358
+ // (2026-09-11) `runtime` ('isolate' | 'container') was DELETED: the container lane
1359
+ // is not built, nothing read the leaf, and accepting 'container' silently
1345
1360
  // ran the isolate anyway. It comes back with the lane, not before.
1346
1361
  defaultLimits: Type.Object({
1347
1362
  // cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
1348
1363
  // only one anything reads (functions-v1 meter.ts + the dispatch cap).
1349
- // (F8-54) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
1364
+ // (2026-09-11) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
1350
1365
  // runtime and not tenant-selectable, and no wall-clock abort was ever
1351
1366
  // applied — a declared 10s default that nothing enforced.
1352
1367
  cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
@@ -1387,7 +1402,7 @@ export const FunctionsConfigSchema = Type.Object({
1387
1402
  source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1388
1403
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1389
1404
  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
1405
+ // (2026-09-25) the per-binding opt-in to re-delivery on
1391
1406
  // queue / webhook / cmsHook / authHook (the cross-field rule
1392
1407
  // rejects it on http / cron). Absent = the ACK-200 default. The
1393
1408
  // receiver answers a failed attempt as an enveloped 503 (ladder)
@@ -1396,11 +1411,30 @@ export const FunctionsConfigSchema = Type.Object({
1396
1411
  retry: Type.Optional(Type.Object({
1397
1412
  maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
1398
1413
  })),
1414
+ // W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
1415
+ // while the previous tick's run is still open (queued / running /
1416
+ // retrying / waiting / delayed). Absent = 'allow' (every tick runs).
1417
+ overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
1399
1418
  }), { maxItems: 8 })),
1400
1419
  scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1401
1420
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1402
1421
  secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1403
1422
  egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1423
+ // R1 (2026-10-01): the runtime settings this function's script was
1424
+ // UPLOADED with — server-SET by the deploy pipeline (never authored),
1425
+ // read back by the rollback re-upload so a restored script runs under
1426
+ // the settings it was deployed and tested with, not today's. Absent =
1427
+ // the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
1428
+ // strings on purpose (the deploy writes them from the one constant).
1429
+ // cpuMs (2026-10-01): the per-invoke CPU limit the script was
1430
+ // uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
1431
+ // cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
1432
+ // rewrites it after a tier change. Absent = the platform default.
1433
+ runtime: Type.Optional(Type.Object({
1434
+ compatibilityDate: Type.String(),
1435
+ compatibilityFlags: Type.Optional(Type.Array(Type.String())),
1436
+ cpuMs: Type.Optional(Type.Number()),
1437
+ })),
1404
1438
  // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1405
1439
  // used to REQUIRE all three, a latent 422 the moment anything sent it —
1406
1440
  // nothing ever did, because the CLI never carried it) and BOTH are
@@ -1431,10 +1465,9 @@ export const FunctionsConfigSchema = Type.Object({
1431
1465
  // egress guard as an outbound parameter. NOT an invocation
1432
1466
  // budget: the INVOCATION is bounded by the platform's own
1433
1467
  // FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
1434
- // bigger per-fetch budget widens with you (audit FN-3).
1435
- // (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
1436
- // is deleted — memory is fixed by the managed runtime and is not a
1437
- // per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
1468
+ // bigger per-fetch budget widens with you.
1469
+ // (`memoryMb` is deleted — memory is fixed by the managed runtime and is
1470
+ // not a per-dispatch option; a dispatch takes { cpuMs, subRequests }.
1438
1471
  // Value.Clean strips it from an old config, so such a config still loads
1439
1472
  // and `vxil plan --explain` marks the key DROPPED.)
1440
1473
  limits: Type.Optional(Type.Object({
@@ -1442,7 +1475,7 @@ export const FunctionsConfigSchema = Type.Object({
1442
1475
  timeoutMs: Type.Optional(Type.Integer()),
1443
1476
  })),
1444
1477
  enabled: Type.Optional(Type.Boolean()),
1445
- // Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
1478
+ // Level-1 typed I/O (guide ch. 8): the declared input/output
1446
1479
  // contract, persisted by the deploy body so ONLINE `vxil gen` emits the
1447
1480
  // same typed fn client as --offline. Opaque JSON-schema-ish payloads —
1448
1481
  // the CLI's lowerSig lowers them; the platform never interprets them.
@@ -1516,14 +1549,14 @@ export const CopilotConfigSchema = Type.Object({
1516
1549
  guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] })),
1517
1550
  })),
1518
1551
  }), { default: {} }),
1519
- // (F8-54) the `escalation` bag ({enabled, handler, notifyTemplate}) was
1552
+ // (2026-09-11) the `escalation` bag ({enabled, handler, notifyTemplate}) was
1520
1553
  // DELETED: the human hand-off it declared was never built — copilot-v1 read
1521
1554
  // none of the three leaves, so a tenant who turned it on got silence. The
1522
1555
  // shipped hand-off path is a tenant function on the conversation events.
1523
1556
  // ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
1524
1557
  limits: Type.Object({
1525
1558
  consumeCredits: Type.Boolean({ default: false }),
1526
- // (F8-54) `tokensPerUserPerDay` was DELETED here: token accounting is
1559
+ // (2026-09-11) `tokensPerUserPerDay` was DELETED here: token accounting is
1527
1560
  // delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
1528
1561
  // limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
1529
1562
  }, { default: {} }),
@@ -1557,15 +1590,33 @@ export const FEATURE_SCHEMAS = {
1557
1590
  copilot: CopilotConfigSchema,
1558
1591
  };
1559
1592
  export const CONFIG_FLAG_CAP = 15;
1560
- /** Counts leaf flags in a TypeBox object schema (architecture §6).
1561
- * Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
1593
+ /** Counts leaf flags in a TypeBox object schema.
1594
+ * Per guide ch. 6, auth: an OPTIONAL object (e.g. a provider credential
1562
1595
  * block) counts as ONE flag — the tenant's decision is "configure it or
1563
1596
  * not", not each inner ref. */
1564
- /** The auth lifecycle events an `authHook` binding may name (F4-30) — the
1597
+ /** The auth lifecycle events an `authHook` binding may name — the
1565
1598
  * closed union `packages/config` types as AuthHookEvent; the control-plane
1566
1599
  * reconciler maps each to its `auth.<event>` audit-event prefix. */
1567
1600
  export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'];
1568
- /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1601
+ /** The events a `cmsHook` function binding may name — the closed union
1602
+ * `packages/config` types on the `cmsHook` trigger, and the keys of the
1603
+ * functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
1604
+ * beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
1605
+ * else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
1606
+ * to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
1607
+ * 'beforeDelete' silently subscribed the function to creates AND updates.
1608
+ * A CI gate pins every copy to this list. */
1609
+ export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'];
1610
+ /** The ONE refusal text for an unknown cmsHook binding event (the config
1611
+ * validator and the deploy route's early 422 both use it). */
1612
+ export function cmsHookEventError(event) {
1613
+ if (event === undefined)
1614
+ return null;
1615
+ if (typeof event === 'string' && CMS_HOOK_EVENTS.includes(event))
1616
+ return null;
1617
+ return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
1618
+ }
1619
+ /** The OTP test-recipient entry grammar (shared by the validator and auth-v1's
1569
1620
  * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1570
1621
  export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
1571
1622
  export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
@@ -1618,7 +1669,7 @@ export function setKnownMcpTools(names) {
1618
1669
  knownMcpTools = new Set(names);
1619
1670
  }
1620
1671
  // ── mcp custom-tool / prompt caps + the pure public-https check ──────────────
1621
- // (mcp.md §6.5) Caps are deliberately tighter than the 200-tool listing cap:
1672
+ // (guide ch. 10) Caps are deliberately tighter than the 200-tool listing cap:
1622
1673
  // each custom tool is a platform-signed egress target, so the bag stays small.
1623
1674
  export const MCP_MAX_CUSTOM_TOOLS = 32;
1624
1675
  export const MCP_MAX_PROMPTS = 32;
@@ -1674,7 +1725,7 @@ function publicHttpsUrlError(url) {
1674
1725
  * stays ALLOWED (fn→fn composition). Source of truth for both the config-write
1675
1726
  * validation below and the control-plane deploy clamp. */
1676
1727
  export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
1677
- // ── declared API state — config-write validators (roadmap §4.11 P0-3) ────────
1728
+ // ── declared API state — config-write validators ────────────────────────────
1678
1729
  /** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
1679
1730
  * templateVars parity). */
1680
1731
  export function rlTemplateVars(template) {
@@ -1768,12 +1819,12 @@ export function validateFeatureConfig(feature, raw) {
1768
1819
  return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
1769
1820
  }
1770
1821
  // Apply defaults to a clone, then STRIP any property the schema does not
1771
- // declare, then check. Value.Clean makes the validator TOTAL (audit #112):
1822
+ // declare, then check. Value.Clean makes the validator TOTAL:
1772
1823
  // the schemas are open Type.Object()s, so without it Value.Check passes on —
1773
1824
  // and putConfig would persist — arbitrary unknown keys. Clean runs AFTER
1774
1825
  // Default so materialized nested defaults survive but stray top-level/nested
1775
1826
  // keys are dropped. This also removes the CLI dry-run idempotency drift
1776
- // (audit #118): plan/push diff the same cleaned manifest the server stores.
1827
+ // too: plan/push diff the same cleaned manifest the server stores.
1777
1828
  const withDefaults = Value.Clean(schema, Value.Default(schema, Value.Clone(raw)));
1778
1829
  if (!Value.Check(schema, withDefaults)) {
1779
1830
  const errors = [...Value.Errors(schema, withDefaults)].map((e) => `${e.path || '/'}: ${e.message}`);
@@ -1805,7 +1856,7 @@ export function validateFeatureConfig(feature, raw) {
1805
1856
  }
1806
1857
  // Cross-field rule: a REAL payments provider needs its credential block; the
1807
1858
  // 'mock' provider (the deterministic default) stays zero-config so the whole
1808
- // ledger path is testable without real keys (features/payments.md §0/§6).
1859
+ // ledger path is testable without real keys (guide ch. 6, payments).
1809
1860
  if (feature === 'payments') {
1810
1861
  const v = withDefaults;
1811
1862
  const needsBlock = {
@@ -1820,7 +1871,7 @@ export function validateFeatureConfig(feature, raw) {
1820
1871
  }
1821
1872
  // A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
1822
1873
  // the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
1823
- // user, running vxil-billed functions for free (audit F2). Rejected at write
1874
+ // user, running vxil-billed functions for free. Rejected at write
1824
1875
  // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1825
1876
  const ledgerErrs = [];
1826
1877
  const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
@@ -1828,7 +1879,7 @@ export function validateFeatureConfig(feature, raw) {
1828
1879
  if (rule.creditType && isReservedCreditType(rule.creditType)) {
1829
1880
  ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1830
1881
  }
1831
- // (2026-09-25 F35+) an entitlement rule must name a declared tier — the
1882
+ // (2026-09-25) an entitlement rule must name a declared tier — the
1832
1883
  // write-time mirror of the runtime's unknown-tier refusal (a purchase for
1833
1884
  // a tier nobody declared would land the delivery `error`).
1834
1885
  if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
@@ -1840,9 +1891,40 @@ export function validateFeatureConfig(feature, raw) {
1840
1891
  if (g.creditType && isReservedCreditType(g.creditType)) {
1841
1892
  ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1842
1893
  }
1894
+ // (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
1895
+ // no period to reset, and a second grant of the same credit type in the
1896
+ // same tier would be wiped (or wipe it) depending on array order.
1897
+ if (g.mode === 'reset') {
1898
+ if (g.period === 'once') {
1899
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
1900
+ }
1901
+ const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
1902
+ if (twin >= 0) {
1903
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`);
1904
+ }
1905
+ }
1906
+ });
1907
+ }
1908
+ // (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
1909
+ // of its credit type each period — purchased credits of that type included.
1910
+ // A credit pack of a reset type would lose paid value at the next renewal,
1911
+ // so it is refused here: keep packs on their own type and spend in order
1912
+ // (`credit_types: ['plan', 'topup']`).
1913
+ const resetBy = new Map();
1914
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1915
+ (rule.grants ?? []).forEach((g, i) => {
1916
+ if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
1917
+ resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
1918
+ }
1843
1919
  });
1844
1920
  }
1845
- // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
1921
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1922
+ const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
1923
+ if (by) {
1924
+ ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`);
1925
+ }
1926
+ }
1927
+ // An UNMAPPED price silently revoked a paying
1846
1928
  // customer (priceMap miss → tier NULL → refold excluded the row). The
1847
1929
  // reducer now stamps such an event outcome 'error' (reprocessable), and
1848
1930
  // this lint catches the config half at `vxil push` time: every priceMap
@@ -1890,7 +1972,7 @@ export function validateFeatureConfig(feature, raw) {
1890
1972
  if (rmErrors.length)
1891
1973
  return { ok: false, errors: rmErrors.slice(0, 10) };
1892
1974
  }
1893
- // Cross-field rules: mcp custom tools + prompts (mcp.md §6.5/§11). The URL
1975
+ // Cross-field rules: mcp custom tools + prompts (guide ch. 10). The URL
1894
1976
  // check here is a PURE mirror of @vxil/runtime assertPublicHttpsUrl (this
1895
1977
  // package is typebox-only) — the authoritative runtime guard re-runs in
1896
1978
  // mcp-v1 at call time; this gate rejects obviously-internal targets BEFORE
@@ -1955,7 +2037,7 @@ export function validateFeatureConfig(feature, raw) {
1955
2037
  if (tplErrs.length)
1956
2038
  return { ok: false, errors: tplErrs.slice(0, 10) };
1957
2039
  }
1958
- // Cross-field rules: declared API state (roadmap §4.11 P0-3). Each list is
2040
+ // Cross-field rules: declared API state. Each list is
1959
2041
  // converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
1960
2042
  // the per-item rules mirror the feature route's own validation so a declared
1961
2043
  // entry can never be one the converge would 422 on.
@@ -1971,7 +2053,7 @@ export function validateFeatureConfig(feature, raw) {
1971
2053
  if (errs.length)
1972
2054
  return { ok: false, errors: errs.slice(0, 10) };
1973
2055
  }
1974
- // Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
2056
+ // Cross-field rule: auth otp.testRecipients — every entry must be an
1975
2057
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
1976
2058
  // match and silently do nothing.
1977
2059
  if (feature === 'auth') {
@@ -2013,7 +2095,10 @@ export function validateFeatureConfig(feature, raw) {
2013
2095
  if (b.kind === 'cron' && !b.schedule) {
2014
2096
  errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
2015
2097
  }
2016
- // F33: retry is an opt-in for the platform-delivered event lanes only —
2098
+ if (b.overlap !== undefined && b.kind !== 'cron') {
2099
+ errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2100
+ }
2101
+ // retry is an opt-in for the platform-delivered event lanes only —
2017
2102
  // an http invoke returns its real status to its caller, and a cron
2018
2103
  // tick's retry would overlap the next tick.
2019
2104
  if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
@@ -2022,7 +2107,12 @@ export function validateFeatureConfig(feature, raw) {
2022
2107
  if (b.kind === 'cmsHook' && !b.collection) {
2023
2108
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
2024
2109
  }
2025
- // authHook: a CLOSED event union (F4-30) — reject typos at write time
2110
+ // E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
2111
+ // at write time, never a silent created+updated subscription.
2112
+ const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
2113
+ if (cmsEventErr)
2114
+ errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
2115
+ // authHook: a CLOSED event union — reject typos at write time
2026
2116
  // so a binding never silently subscribes to nothing.
2027
2117
  if (b.kind === 'authHook' && b.event !== undefined && !AUTH_HOOK_EVENTS.includes(b.event)) {
2028
2118
  errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);