@vxil/feature-configs 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @vxil/feature-configs
2
+
3
+ The typed config schemas for every Vxil feature — the shapes a `vxil.config.ts`
4
+ is built on, published so your editor and your CI can read the same contract the
5
+ platform validates against.
6
+
7
+ ```bash
8
+ npm i -D @vxil/feature-configs
9
+ ```
10
+
11
+ Most people never import this directly: writing
12
+ [`defineConfig`](https://www.npmjs.com/package/@vxil/config) gives you these
13
+ types automatically. Reach for it when you want to validate a manifest yourself,
14
+ generate config, or type a helper.
15
+
16
+ ## What's in it
17
+
18
+ - **One schema per feature**, all 19 of them — `NotificationsConfigSchema`,
19
+ `AuthConfigSchema`, `CmsConfigSchema`, `PaymentsConfigSchema`,
20
+ `FunctionsConfigSchema`, … — plus the matching `Static<>` types
21
+ (`NotificationsConfig`, `AuthConfig`, …).
22
+ - **`FEATURE_KEYS`** — the canonical feature list, and **`FEATURE_SCHEMAS`** —
23
+ the key → schema map.
24
+ - **`validateFeatureConfig(feature, raw)`** — the exact validation the control
25
+ plane runs, returning `{ ok, errors }` with JSON-pointer-style messages. Use it
26
+ in a pre-commit hook or a test to fail on a bad config before you push it.
27
+ - **`hooks`** (`@vxil/feature-configs/hooks`) — the CMS lifecycle-hook expression
28
+ grammar: the same closed, sandboxed parser/validator the server uses, so a
29
+ malformed `validate` / `derive` expression is caught in your editor.
30
+
31
+ ```ts
32
+ import { validateFeatureConfig, FEATURE_KEYS } from '@vxil/feature-configs';
33
+
34
+ const result = validateFeatureConfig('notifications', {
35
+ enabled: true,
36
+ fromEmail: 'noreply@acme.com',
37
+ });
38
+ if (!result.ok) throw new Error(result.errors.join('\n'));
39
+ ```
40
+
41
+ ## Why the schemas are small on purpose
42
+
43
+ Every feature's config is capped at a fixed number of top-level leaves. A knob
44
+ earns its slot by having a reader; one that nothing reads gets deleted rather
45
+ than left to rot as a promise the product does not keep. That is why the schemas
46
+ here are the whole truth about what a feature can be configured to do — and why
47
+ a version bump may *remove* a knob that was never wired. Removals are listed in
48
+ the feature reference at [vxil.com](https://vxil.com).
49
+
50
+ ## Related
51
+
52
+ - [`@vxil/config`](https://www.npmjs.com/package/@vxil/config) — `defineConfig()`.
53
+ - [`@vxil/cli`](https://www.npmjs.com/package/@vxil/cli) — the `vxil` command (`plan` / `push` / `gen`).
54
+ - [`@vxil/sdk`](https://www.npmjs.com/package/@vxil/sdk) — the typed runtime client.
55
+
56
+ Docs: [vxil.com](https://vxil.com) · Dashboard: [vxil.com/dashboard](https://vxil.com/dashboard)
57
+
58
+ MIT © techmaker.io
package/dist/hooks.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare const HOOK_LIMITS: {
10
10
  readonly maxReadHooksPerCollection: 10;
11
11
  };
12
12
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
13
- * end-user principal (docs/end-user-principals-design.md §5.6) — read-only and
13
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
14
14
  * populated ONLY on the READ path (runReadHooks); on the write path and in
15
15
  * server-caller mode it is null, exactly like `before` on a create. It carries
16
16
  * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- // CMS lifecycle hooks — the SAFE expression engine (docs/cms-lifecycle-hooks-rung2-design.md, Lane A).
1
+ // CMS lifecycle hooks — the SAFE expression engine (https://vxil.com/docs/guide/07-validation-and-hooks).
2
2
  //
3
3
  // A "code-based" lifecycle hook is a small EXPRESSION the tenant authors. Three kinds:
4
4
  // - validate: the expression must evaluate truthy or the write is REJECTED (422)
@@ -17,8 +17,8 @@
17
17
  // CROSS-ROW AGGREGATION STAYS OUT — BY DESIGN. Read hooks are a pure function
18
18
  // of ONE row (+ `now`): there are no new root variables, no array/aggregate
19
19
  // functions, no access to sibling rows or other collections. Counts/sums/
20
- // group-bys across rows are the §5 identity-fork anti-item (docs/features/
21
- // cms.md §6.7) and must never enter this engine.
20
+ // group-bys across rows are the identity-fork anti-item (function territory:
21
+ // vxil.com/docs/guide/07-validation-and-hooks) and must never enter this engine.
22
22
  //
23
23
  // SAFETY (the "not malicious" guarantee) is structural, not heuristic:
24
24
  // * No JS is executed. `eval`/`Function`/the host runtime are never touched. The
@@ -61,7 +61,7 @@ export const HOOK_LIMITS = {
61
61
  };
62
62
  // ── allow-lists ─────────────────────────────────────────────────────────────
63
63
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
64
- * end-user principal (docs/end-user-principals-design.md §5.6) — read-only and
64
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
65
65
  * populated ONLY on the READ path (runReadHooks); on the write path and in
66
66
  * server-caller mode it is null, exactly like `before` on a create. It carries
67
67
  * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
package/dist/index.d.ts CHANGED
@@ -6,6 +6,12 @@ export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
6
6
  * of which is restricted to internal platform machinery). */
7
7
  export declare function isReservedCreditType(creditType: string): boolean;
8
8
  export declare const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
9
+ /** Placeholders that are SLOTS in the BODY parts only — the renderer splits
10
+ * `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
11
+ * the plain interpolator, where the same token is just a data key that does not
12
+ * exist and renders EMPTY. Rejected in `subject` for exactly the reason the
13
+ * whole allow-list exists: a silent empty render in a live email. */
14
+ export declare const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
9
15
  /** Per-override caps (D4). Bodies are emails, not documents. */
10
16
  export declare const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
11
17
  export declare const NOTIF_OVERRIDE_BODY_MAX = 20000;
@@ -54,7 +60,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
54
60
  templates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
55
61
  allowOverride: import("@sinclair/typebox").TBoolean;
56
62
  overrides: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
57
- subject: import("@sinclair/typebox").TString;
63
+ subject: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
58
64
  html: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
59
65
  text: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
60
66
  }>>>>;
@@ -203,9 +209,11 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
203
209
  emailVerification: import("@sinclair/typebox").TObject<{
204
210
  required: import("@sinclair/typebox").TBoolean;
205
211
  }>;
206
- magicLink: import("@sinclair/typebox").TObject<{
212
+ magicLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
207
213
  tokenTtlMinutes: import("@sinclair/typebox").TInteger;
208
- }>;
214
+ resendCooldownSec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
215
+ }>>;
216
+ registryAdopt: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
209
217
  otp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
210
218
  enabled: import("@sinclair/typebox").TBoolean;
211
219
  codeTtlMinutes: import("@sinclair/typebox").TInteger;
@@ -275,6 +283,11 @@ export declare const WebhooksConfigSchema: import("@sinclair/typebox").TObject<{
275
283
  enabled: import("@sinclair/typebox").TBoolean;
276
284
  maxSubscriptions: import("@sinclair/typebox").TInteger;
277
285
  maxSources: import("@sinclair/typebox").TInteger;
286
+ alerts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
287
+ enabled: import("@sinclair/typebox").TBoolean;
288
+ minLevel: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"warn">, import("@sinclair/typebox").TLiteral<"error">]>;
289
+ digestMinutes: import("@sinclair/typebox").TInteger;
290
+ }>>;
278
291
  }>;
279
292
  export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
280
293
  export declare const CommentsConfigSchema: import("@sinclair/typebox").TObject<{
@@ -554,6 +567,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
554
567
  enforcePeriodEnd: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
555
568
  slackHours: import("@sinclair/typebox").TInteger;
556
569
  }>>;
570
+ unmappedProduct: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"error">, import("@sinclair/typebox").TLiteral<"ignore">]>>;
557
571
  }>>;
558
572
  }>;
559
573
  export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
@@ -579,9 +593,8 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
579
593
  secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
580
594
  egressAllow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
581
595
  limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
582
- cpuMs: import("@sinclair/typebox").TInteger;
583
- timeoutMs: import("@sinclair/typebox").TInteger;
584
- memoryMb: import("@sinclair/typebox").TInteger;
596
+ cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
597
+ timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
585
598
  }>>;
586
599
  enabled: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
587
600
  signature: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
package/dist/index.js CHANGED
@@ -44,7 +44,19 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
44
44
  'magic-link': ['url', 'expires_minutes'],
45
45
  'otp-code': ['code', 'expires_minutes'],
46
46
  welcome: ['app_name', 'first_name', 'first_name_greeting'],
47
- transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
47
+ // `cta` (G-78) is a SLOT, not a data key: an override that writes `{{cta}}`
48
+ // chooses where the escaped CTA anchor built from cta_label + cta_url lands.
49
+ // Absent from the built-in bodies (which append it), so the worker parity
50
+ // suite pins it explicitly — see templates.test.ts.
51
+ transactional: ['subject', 'paragraph', 'cta_label', 'cta_url', 'cta'],
52
+ };
53
+ /** Placeholders that are SLOTS in the BODY parts only — the renderer splits
54
+ * `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
55
+ * the plain interpolator, where the same token is just a data key that does not
56
+ * exist and renders EMPTY. Rejected in `subject` for exactly the reason the
57
+ * whole allow-list exists: a silent empty render in a live email. */
58
+ export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS = {
59
+ transactional: ['cta'],
48
60
  };
49
61
  /** Per-override caps (D4). Bodies are emails, not documents. */
50
62
  export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
@@ -96,14 +108,31 @@ export function validateNotificationOverrides(templates) {
96
108
  continue;
97
109
  }
98
110
  const variant = byLocale[locale];
111
+ if (typeof variant !== 'object' || variant === null) {
112
+ errs.push(`/templates/overrides/${templateId}/${locale}: must be an object with at least one of subject, html, text`);
113
+ continue;
114
+ }
115
+ // G-78 — every part is OPTIONAL (a body-only translation inherits the
116
+ // built-in subject), but an ALL-EMPTY variant is the new "inert config"
117
+ // and fails LOUD rather than silently rendering the built-in.
118
+ const nonEmpty = ['subject', 'html', 'text']
119
+ .filter((p) => typeof variant[p] === 'string' && variant[p] !== '');
120
+ if (nonEmpty.length === 0) {
121
+ errs.push(`/templates/overrides/${templateId}/${locale}: set at least one of subject, html, text`);
122
+ continue;
123
+ }
124
+ // body-only SLOTS are not subject placeholders (see the map's doc comment)
125
+ const slots = NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS[templateId] ?? [];
126
+ const subjectAllowed = allowed.filter((k) => !slots.includes(k));
99
127
  for (const part of ['subject', 'html', 'text']) {
100
128
  const body = variant[part];
101
129
  if (typeof body !== 'string')
102
130
  continue;
131
+ const ok = part === 'subject' ? subjectAllowed : allowed;
103
132
  for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
104
133
  const key = m[1];
105
- if (!allowed.includes(key)) {
106
- errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
134
+ if (!ok.includes(key)) {
135
+ errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${ok.join(', ')})`);
107
136
  }
108
137
  }
109
138
  }
@@ -161,15 +190,23 @@ export const NotificationsConfigSchema = Type.Object({
161
190
  allowOverride: Type.Boolean({ default: false }),
162
191
  // D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
163
192
  // template ids, as config DATA (a Record = 1 leaf, catalog size never
164
- // moves the count). Shape: { [templateId]: { [locale]: { subject,
193
+ // moves the count). Shape: { [templateId]: { [locale]: { subject?,
165
194
  // html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
166
195
  // built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
167
196
  // sanitisation are identical and there is no raw-output syntax.
168
197
  // validateFeatureConfig rejects: an unknown template id, an unknown
169
198
  // placeholder, a malformed locale tag, an over-size body, and (fail
170
199
  // LOUD, never silently inert) overrides present with allowOverride:false.
171
- overrides: Type.Optional(Type.Record(Type.String(), Type.Record(Type.String(), Type.Object({
172
- subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
200
+ overrides: Type.Optional(Type.Record(Type.String(),
201
+ // G-78: `subject` is OPTIONAL like html/text — a PARTIAL override
202
+ // inherits each omitted part from the built-in, so a body-only
203
+ // translation no longer forces you to restate the subject (and a
204
+ // subject-only 'ar' override no longer pairs an Arabic subject with the
205
+ // English built-in body). A strict widening: every config valid before
206
+ // this change is still valid. validateNotificationOverrides rejects the
207
+ // one shape this opens up — a variant with NO non-empty part.
208
+ Type.Record(Type.String(), Type.Object({
209
+ subject: Type.Optional(Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX })),
173
210
  html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
174
211
  text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
175
212
  })))),
@@ -368,7 +405,35 @@ export const AuthConfigSchema = Type.Object({
368
405
  // verify-email token flow it would bound was never built). Same precedent as
369
406
  // the removed `redirects` block below. Its leaf funds the OTP wave.
370
407
  emailVerification: Type.Object({ required: Type.Boolean({ default: false }) }, { default: {} }),
371
- magicLink: Type.Object({ tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }) }, { default: {} }),
408
+ // P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
409
+ // many knobs it holds — the methods/session/password precedent) so the
410
+ // request cooldown could land without spending a second leaf. It KEEPS
411
+ // `default: {}`, so Value.Default still materializes
412
+ // `magicLink.tokenTtlMinutes` exactly as before — every persisted manifest
413
+ // folds byte-identically.
414
+ magicLink: Type.Optional(Type.Object({
415
+ tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
416
+ // P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
417
+ // `otp.resendCooldownSec` twin, same bounds so the two knobs read the
418
+ // same. A second request for the same address inside the window is a 429
419
+ // `magic_link_rate_limited` with a Retry-After header. Type.Optional with
420
+ // NO default (the `otp.testRecipients` precedent below) so existing
421
+ // manifests fold byte-identically; the 60s fallback is applied by the
422
+ // auth feature at read time. 0 disables the cooldown.
423
+ resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
424
+ }, { default: {} })),
425
+ // P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
426
+ // true, a sign-in by a method that PROVES control of the address — magic
427
+ // link, email OTP, or OAuth with a provider-verified address — reuses the id
428
+ // of the one matching pre-registered end-user (POST /v1/users) that has no
429
+ // account yet, instead of minting a fresh `user_<ulid>` and leaving the
430
+ // tenant with two records for one person. Password SIGN-UP never adopts: it
431
+ // proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
432
+ // OFF by default, and Type.Optional with NO default: adoption means whoever
433
+ // proves control of a pre-registered address becomes that record — a change
434
+ // of security semantics for a tenant that bulk-imports contacts, whose
435
+ // addresses vxil never verified.
436
+ registryAdopt: Type.Optional(Type.Boolean()),
372
437
  // Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
373
438
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
374
439
  // otp?.enabled === true).
@@ -452,7 +517,7 @@ export const FilesConfigSchema = Type.Object({
452
517
  // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
453
518
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
454
519
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
455
- // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
520
+ // tenant tier threaded to files-v1 (plan tiers).
456
521
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
457
522
  maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
458
523
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
@@ -495,6 +560,27 @@ export const WebhooksConfigSchema = Type.Object({
495
560
  enabled: Type.Boolean({ default: true }),
496
561
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
497
562
  maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
563
+ // FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
564
+ // channel; this is the "nobody is consuming them yet" fallback — a periodic
565
+ // e-mail summary of the tenant's FAILURE-class audit events (the level:
566
+ // 'failure' rows of the generated event catalog). Read by
567
+ // workers/control-plane/src/alertDigest.ts on the minute cron.
568
+ //
569
+ // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
570
+ // journaled here the way RateLimitsConfigSchema journals its own: an
571
+ // arbitrary `to` would turn vxil's own sending identity into a relay for
572
+ // tenant-authored content and open a PII egress path out of the audit trail.
573
+ // The digest goes to the OWNER-role dashboard accounts of the project (cap
574
+ // 3), who already receive platform mail (invites, magic links, resets), so no
575
+ // new consent surface is created. An arbitrary recipient is a separate
576
+ // decision with its own abuse review.
577
+ //
578
+ // An Optional object bag counts as ONE leaf (the countLeaves rule).
579
+ alerts: Type.Optional(Type.Object({
580
+ enabled: Type.Boolean({ default: false }),
581
+ minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
582
+ digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
583
+ })),
498
584
  });
499
585
  // comments feature (wishlist): threaded discussion on tenant-defined topics.
500
586
  export const CommentsConfigSchema = Type.Object({
@@ -536,7 +622,7 @@ export const CmsConfigSchema = Type.Object({
536
622
  maxExpandFields: Type.Integer({ default: 5, minimum: 1, maximum: 25 }),
537
623
  maxComputedFieldsPerCollection: Type.Integer({ default: 10, minimum: 1, maximum: 50 }),
538
624
  }, { default: {} }),
539
- // Code-based lifecycle hooks (docs/cms-lifecycle-hooks-rung2-design.md, Lane A):
625
+ // Code-based lifecycle hooks (https://vxil.com/docs/guide/07-validation-and-hooks):
540
626
  // a tenant-authored SAFE expression. Write events (beforeCreate/beforeUpdate/
541
627
  // beforeWrite) run in-transaction — `validate` rejects the write, `derive`
542
628
  // computes a persisted field. Read events shape the RESPONSE only: `validate`
@@ -813,7 +899,7 @@ export const AiConfigSchema = Type.Object({
813
899
  cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
814
900
  limits: Type.Object({
815
901
  tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
816
- consumeCredits: Type.Boolean({ default: false }), // reserve→settle (STUB until payments)
902
+ consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
817
903
  }, { default: {} }),
818
904
  streaming: Type.Object({
819
905
  enabled: Type.Boolean({ default: true }),
@@ -902,10 +988,16 @@ export const PaymentsConfigSchema = Type.Object({
902
988
  projectId: Type.String(),
903
989
  publicSdkKey: Type.String(),
904
990
  secretApiKeyRef: Type.String(),
905
- // Optional per-tenant webhook secret ref (public.tenant_secrets). When set,
906
- // inbound RevenueCat webhooks are verified with THIS tenant's secret instead
907
- // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
908
- // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
991
+ // Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
992
+ // webhooks are verified against THIS ref and nothing else: there is no
993
+ // platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
994
+ // (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
995
+ // frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
996
+ // permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
997
+ // Optional at the SCHEMA level only — leaving it unset does not disable
998
+ // verification, it fails CLOSED: every delivery is 401 bad_signature with a
999
+ // `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
1000
+ // webhookSecretRef.
909
1001
  webhookSecretRef: Type.Optional(Type.String()),
910
1002
  // Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
911
1003
  // and PRODUCTION events to the SAME webhook with the same auth header, so a
@@ -980,6 +1072,29 @@ export const PaymentsConfigSchema = Type.Object({
980
1072
  enforcePeriodEnd: Type.Optional(Type.Object({
981
1073
  slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
982
1074
  })),
1075
+ // (2026-09 P2) What to do with a one-off purchase whose `product_id` is NOT
1076
+ // in productMap. 'error' (the DEFAULT and today's behaviour) stamps the
1077
+ // delivery outcome `error`, emits payments.webhook_event.failed and lands a
1078
+ // `stranded_webhook` reconcile finding. 'ignore' stamps it `ignored`, with
1079
+ // no failure event and no stranded finding.
1080
+ //
1081
+ // WHY IT EXISTS: the trigger is `event.productId && !grantRule`, and Paddle
1082
+ // carries items[0].price.product_id on EVERY transaction.completed —
1083
+ // INCLUDING subscription-renewal transactions, whose money folds through the
1084
+ // subscription.* events instead. A Paddle tenant that drives everything
1085
+ // through tierMap/priceMap therefore got one `error` row + one failure event
1086
+ // + one stranded finding PER RENEWAL, forever. 'ignore' is the one-line
1087
+ // answer. The charge row is still recorded either way — only the ledger
1088
+ // grant is declined — and an `ignored` row stays REPROCESSABLE, so mapping
1089
+ // the product later and re-running the delivery folds it.
1090
+ //
1091
+ // NO SCHEMA DEFAULT ON PURPOSE: the config-write pipeline runs
1092
+ // Value.Default, which MATERIALIZES an Optional-with-default into every
1093
+ // persisted manifest — that would rewrite every payments manifest carrying a
1094
+ // `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
1095
+ // every payments tenant. The READER owns the default
1096
+ // (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
1097
+ unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
983
1098
  })),
984
1099
  // NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
985
1100
  // was DELETED — it had zero readers (never forwarded anything). Outbound
@@ -1044,14 +1159,49 @@ export const FunctionsConfigSchema = Type.Object({
1044
1159
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1045
1160
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1046
1161
  }), { maxItems: 8 })),
1047
- scriptRef: Type.String(), // content-hashed WfP script name: fn-<tenant>-<name>-<sha>
1162
+ scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1048
1163
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1049
- secrets: Type.Optional(Type.Array(Type.String())), // tenant_secrets refs (KEK_FUNCTIONS)
1164
+ secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1050
1165
  egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1166
+ // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1167
+ // used to REQUIRE all three, a latent 422 the moment anything sent it —
1168
+ // nothing ever did, because the CLI never carried it) and BOTH are
1169
+ // UNBOUNDED here on purpose: `deployFunction` validates the WHOLE merged
1170
+ // manifest, `Value.Clean` strips unknown keys but never clamps
1171
+ // out-of-range ones, so a min/max here would let ONE legacy entry 422
1172
+ // every future deploy for that tenant. The bounds (cpuMs 5..300000,
1173
+ // timeoutMs 1000..120000) are enforced at the ONE write path,
1174
+ // `normalizeLimits` in the deploy handler, which also sanitizes pre-existing
1175
+ // entries on merge.
1176
+ // cpuMs — TIGHTENS the per-dispatch isolate CPU cap (never widens
1177
+ // FN_MAX_CPU_MS) and RAISES (never lowers) the metering
1178
+ // reserve, whose floor stays defaultLimits.cpuMs. THE
1179
+ // CONSEQUENCE, stated because the knob's name hides it:
1180
+ // the reserve is the payments HOLD, and the pro-rated
1181
+ // settle clamps the measured wall-ms into [floor, reserve]
1182
+ // — it can never commit more than it holds — so raising
1183
+ // the reserve raises the settle CEILING. An invoke whose
1184
+ // wall time exceeds defaultLimits.cpuMs (50) then settles
1185
+ // at its measured wall-ms instead of being capped at 50:
1186
+ // on a METERED tenant, declaring cpuMs raises the bill for
1187
+ // long-running functions (cpuMs: 1000 turns an 800 ms
1188
+ // invoke from 50 into 800). It does NOT move anyone's
1189
+ // availability — the ACCOUNT-global daily capacity budget
1190
+ // counts the platform's own estimate, never this value
1191
+ // (functions-v1 capacity.ts `capacityReserveCpuMs`).
1192
+ // timeoutMs — the wall budget for ONE outbound fetch, delivered to the
1193
+ // egress guard as an outbound parameter. NOT an invocation
1194
+ // budget: the INVOCATION is bounded by the platform's own
1195
+ // FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
1196
+ // bigger per-fetch budget widens with you (audit FN-3).
1197
+ // (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
1198
+ // is deleted — memory is fixed by the managed runtime and is not a
1199
+ // per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
1200
+ // Value.Clean strips it from an old config, so such a config still loads
1201
+ // and `vxil plan --explain` marks the key DROPPED.)
1051
1202
  limits: Type.Optional(Type.Object({
1052
- cpuMs: Type.Integer(),
1053
- timeoutMs: Type.Integer(),
1054
- memoryMb: Type.Integer(),
1203
+ cpuMs: Type.Optional(Type.Integer()),
1204
+ timeoutMs: Type.Optional(Type.Integer()),
1055
1205
  })),
1056
1206
  enabled: Type.Optional(Type.Boolean()),
1057
1207
  // Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
@@ -1064,7 +1214,7 @@ export const FunctionsConfigSchema = Type.Object({
1064
1214
  })),
1065
1215
  }))),
1066
1216
  });
1067
- // copilot feature (docs/copilot-agents-sku-design.md §4). Re-declared to match
1217
+ // copilot feature (https://vxil.com/docs/guide/11-copilot-agents). Re-declared to match
1068
1218
  // workers/copilot-v1/src/config.ts's exported CopilotConfigSchema (one shape,
1069
1219
  // two consumers — keep the two definitions byte-identical). A thin COMPOSITION
1070
1220
  // layer over ai + vector-search + rag + mcp: it owns the AGENT layer (persona,
@@ -1511,7 +1661,7 @@ export function validateFeatureConfig(feature, raw) {
1511
1661
  if (errs.length)
1512
1662
  return { ok: false, errors: errs.slice(0, 10) };
1513
1663
  }
1514
- // Cross-field rules: copilot (docs/copilot-agents-sku-design.md §4). Pure
1664
+ // Cross-field rules: copilot (https://vxil.com/docs/guide/11-copilot-agents). Pure
1515
1665
  // rules only — cross-feature resolutions (collection existence, directiveRef
1516
1666
  // → ai.templates) are deferred to runtime, which fails soft with the
1517
1667
  // capability-surface envelope. Tool-NAME existence runs only when the mcp
@@ -1557,7 +1707,7 @@ export function validateFeatureConfig(feature, raw) {
1557
1707
  return { ok: true, errors: [], value: withDefaults };
1558
1708
  }
1559
1709
  // ─────────────────────────────────────────────────────────────────────────────
1560
- // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
1710
+ // PLANNER GROUNDING (https://vxil.com/docs/guide/10-agents-and-mcp).
1561
1711
  // FEATURE_KEYS is the literal feature list the planner catalog is projected
1562
1712
  // from; the planner-catalog CI gate asserts set-equality with
1563
1713
  // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
@@ -1,4 +1,4 @@
1
- // cms-rel B5/E config-write gates (docs/cms-relational-depth-options.md §3/§4)
1
+ // cms-rel B5/E config-write gates (read models + transactions — https://vxil.com/docs/guide/04-data-with-cms)
2
2
  // — the hooks.ts sibling: PURE structural validation of the `readModels` and
3
3
  // `cdc` bags at config-write time (the anti-malice-gate pattern). The grammar
4
4
  // (fn/rank allow-lists, arity caps, window bounds, dotted-term shape) is
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.3.0",
4
- "description": "TypeBox config schemas for every vxil feature — the typed manifests vxil.config.ts is built on. INTERNAL workspace package: bundled into the published `vxil` package (via @vxil/config), not published separately.",
3
+ "version": "0.4.0",
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",
7
7
  "repository": {
package/src/hooks.ts CHANGED
@@ -1,4 +1,4 @@
1
- // CMS lifecycle hooks — the SAFE expression engine (docs/cms-lifecycle-hooks-rung2-design.md, Lane A).
1
+ // CMS lifecycle hooks — the SAFE expression engine (https://vxil.com/docs/guide/07-validation-and-hooks).
2
2
  //
3
3
  // A "code-based" lifecycle hook is a small EXPRESSION the tenant authors. Three kinds:
4
4
  // - validate: the expression must evaluate truthy or the write is REJECTED (422)
@@ -17,8 +17,8 @@
17
17
  // CROSS-ROW AGGREGATION STAYS OUT — BY DESIGN. Read hooks are a pure function
18
18
  // of ONE row (+ `now`): there are no new root variables, no array/aggregate
19
19
  // functions, no access to sibling rows or other collections. Counts/sums/
20
- // group-bys across rows are the §5 identity-fork anti-item (docs/features/
21
- // cms.md §6.7) and must never enter this engine.
20
+ // group-bys across rows are the identity-fork anti-item (function territory:
21
+ // vxil.com/docs/guide/07-validation-and-hooks) and must never enter this engine.
22
22
  //
23
23
  // SAFETY (the "not malicious" guarantee) is structural, not heuristic:
24
24
  // * No JS is executed. `eval`/`Function`/the host runtime are never touched. The
@@ -63,7 +63,7 @@ export const HOOK_LIMITS = {
63
63
 
64
64
  // ── allow-lists ─────────────────────────────────────────────────────────────
65
65
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
66
- * end-user principal (docs/end-user-principals-design.md §5.6) — read-only and
66
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
67
67
  * populated ONLY on the READ path (runReadHooks); on the write path and in
68
68
  * server-caller mode it is null, exactly like `before` on a create. It carries
69
69
  * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
package/src/index.test.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  setKnownMcpTools,
12
12
  validateFeatureConfig,
13
13
  NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
14
+ NOTIFICATION_TEMPLATE_PLACEHOLDERS,
14
15
  validateNotificationOverrides,
15
16
  } from './index.js';
16
17
  // The LIVE mcp catalog (zero-import pure data) — the same relative-import
@@ -42,14 +43,16 @@ describe('15-flag cap analyzer (architecture §6)', () => {
42
43
  }
43
44
  });
44
45
 
45
- it('auth schema is 11 leaves after D3 (methods collapsed to ONE optional bag; was 15 at the cap)', () => {
46
+ it('auth schema is 12 leaves (D3 collapse = 11, + the P1-5 registryAdopt opt-in)', () => {
46
47
  // enabled(1) + methods(1, optional bag — was 6) + providers(1) + session(1)
47
- // + password(1) + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
48
- // + orgClaims(1) + security(1, optional bag — P0-3) = 11. The auth wave
48
+ // + password(1) + emailVerification(1) + magicLink(1, optional bag since
49
+ // P1-16) + otp(1) + anonymous(1) + orgClaims(1) + security(1, optional bag —
50
+ // P0-3) + registryAdopt(1, P1-5) = 12, 3 under the cap. The auth wave
49
51
  // (2026-09-10, D3) funded itself by the methods collapse — the notifications
50
- // retry/broadcast precedent; session.maxConcurrent / otp.testRecipients ride
51
- // inside existing bags and never move the count.
52
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
52
+ // retry/broadcast precedent; session.maxConcurrent / otp.testRecipients /
53
+ // magicLink.resendCooldownSec ride inside existing bags and never move the
54
+ // count.
55
+ expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(12);
53
56
  expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
54
57
  });
55
58
 
@@ -412,8 +415,8 @@ describe('validateFeatureConfig', () => {
412
415
  const min = validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' } } });
413
416
  expect(min.ok).toBe(true);
414
417
  expect((min.value as { providers: { oidc: Record<string, unknown> } }).providers.oidc).toEqual({ issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' });
415
- // still 11 leaves — the block lives INSIDE the providers bag
416
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
418
+ // still 12 leaves — the block lives INSIDE the providers bag
419
+ expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(12);
417
420
  // issuer must be https with no query/fragment; domains lowercase dotted
418
421
  for (const issuer of ['http://login.acme.example', 'https://login.acme.example/?x=1', 'https://a#b', 'login.acme.example']) {
419
422
  expect(validateFeatureConfig('auth', { providers: { oidc: { issuer, clientId: 'a', clientSecretRef: 'secret:s' } } }).ok, issuer).toBe(false);
@@ -423,6 +426,40 @@ describe('validateFeatureConfig', () => {
423
426
  expect(validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a' } } }).ok).toBe(false);
424
427
  });
425
428
 
429
+ it('P1-16: the magicLink Optional bag folds byte-identically and bounds resendCooldownSec', () => {
430
+ // The bag became Type.Optional (so the new knob costs NO leaf) but KEPT
431
+ // `default: {}`, and resendCooldownSec is Optional with NO default — so a
432
+ // manifest that never mentions the cooldown folds to exactly the bytes it
433
+ // folded to before the change. This is the assertion that keeps every
434
+ // PUBLISHED manifest unchanged (and the reason the worker, not the schema,
435
+ // supplies the 60s fallback).
436
+ expect(JSON.stringify((validateFeatureConfig('auth', {}).value as { magicLink: unknown }).magicLink))
437
+ .toBe('{"tokenTtlMinutes":15}');
438
+ expect(JSON.stringify((validateFeatureConfig('auth', { magicLink: { tokenTtlMinutes: 30 } }).value as { magicLink: unknown }).magicLink))
439
+ .toBe('{"tokenTtlMinutes":30}');
440
+ // set explicitly ⇒ persisted verbatim
441
+ const set = validateFeatureConfig('auth', { magicLink: { tokenTtlMinutes: 15, resendCooldownSec: 0 } });
442
+ expect(set.ok, set.errors.join()).toBe(true);
443
+ expect((set.value as { magicLink: Record<string, number> }).magicLink)
444
+ .toEqual({ tokenTtlMinutes: 15, resendCooldownSec: 0 });
445
+ // bounds mirror otp.resendCooldownSec exactly (0..600)
446
+ expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: 600 } }).ok).toBe(true);
447
+ expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: 601 } }).ok).toBe(false);
448
+ expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: -1 } }).ok).toBe(false);
449
+ });
450
+
451
+ it('P1-5: registryAdopt is an OPT-IN boolean with no default (absent stays absent)', () => {
452
+ const bare = validateFeatureConfig('auth', {});
453
+ expect(bare.ok).toBe(true);
454
+ expect(bare.value).not.toHaveProperty('registryAdopt');
455
+ for (const v of [true, false]) {
456
+ const r = validateFeatureConfig('auth', { registryAdopt: v });
457
+ expect(r.ok, r.errors.join()).toBe(true);
458
+ expect((r.value as { registryAdopt: boolean }).registryAdopt).toBe(v);
459
+ }
460
+ expect(validateFeatureConfig('auth', { registryAdopt: 'yes' }).ok).toBe(false);
461
+ });
462
+
426
463
  it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
427
464
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
428
465
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
@@ -932,6 +969,42 @@ describe('payments: reserved credit_type rejected in ledger grant maps (F2)', ()
932
969
  });
933
970
  });
934
971
 
972
+ // ── payments: ledger.unmappedProduct (2026-09 P2) ────────────────────────────
973
+ // The policy for a one-off purchase whose product_id is not in productMap.
974
+ // It lives INSIDE the one optional `ledger` bag, so the leaf budget is unmoved,
975
+ // and it carries NO schema default so no persisted manifest is rewritten.
976
+ describe('payments: ledger.unmappedProduct policy', () => {
977
+ it("accepts 'error' and 'ignore', rejects anything else", () => {
978
+ for (const v of ['error', 'ignore']) {
979
+ const r = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: v } });
980
+ expect(r.ok, r.ok ? '' : r.errors.join()).toBe(true);
981
+ }
982
+ const bad = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: 'skip' } });
983
+ expect(bad.ok).toBe(false);
984
+ });
985
+
986
+ it('LOAD-BEARING: it has NO schema default, so Value.Default never materializes it into a manifest', () => {
987
+ // A default would be written into every payments manifest carrying a
988
+ // `ledger` bag on the next PUT, making `vxil push --dry-run` / `vxil diff`
989
+ // report drift for every payments tenant. The READER owns the default.
990
+ const before = { ledger: { productMap: {}, tierMap: {} } };
991
+ const r = validateFeatureConfig('payments', before);
992
+ expect(r.ok, r.ok ? '' : r.errors.join()).toBe(true);
993
+ expect((r.value as { ledger?: Record<string, unknown> }).ledger).not.toHaveProperty('unmappedProduct');
994
+ });
995
+
996
+ it("an explicit 'ignore' round-trips through the write pipeline verbatim", () => {
997
+ const r = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: 'ignore' } });
998
+ expect(r.ok).toBe(true);
999
+ expect((r.value as { ledger?: { unmappedProduct?: string } }).ledger?.unmappedProduct).toBe('ignore');
1000
+ });
1001
+
1002
+ it('the policy sits inside the ONE optional ledger leaf — the leaf budget is unchanged', () => {
1003
+ expect(countLeaves(PaymentsConfigSchema)).toBe(10);
1004
+ expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
1005
+ });
1006
+ });
1007
+
935
1008
  // ── payments: money-path wave (F1-3 config lint, F1-4 acceptSandbox, F1-7 grace,
936
1009
  // F3-19 forwardToTenantUrl deleted) ─────────────────────────────────────────
937
1010
  describe('payments: priceMap ⊆ tierMap + unique ranks (F1-3 / D3 config lint)', () => {
@@ -1139,6 +1212,70 @@ describe('notifications: templates.overrides (D4 per-locale overrides)', () => {
1139
1212
  expect(huge.errors.join()).toContain(`${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
1140
1213
  });
1141
1214
 
1215
+ // ── G-78: every part is Optional; an ALL-EMPTY variant is the only new refusal
1216
+ it('accepts a BODY-ONLY override (subject inherited from the built-in)', () => {
1217
+ const v = validateFeatureConfig('notifications', {
1218
+ ...base,
1219
+ templates: {
1220
+ allowOverride: true,
1221
+ overrides: { transactional: { ar: { html: '<div dir="rtl">{{paragraph}}</div>' } } },
1222
+ },
1223
+ });
1224
+ expect(v.ok, v.errors.join()).toBe(true);
1225
+ });
1226
+
1227
+ it('accepts a TEXT-ONLY override', () => {
1228
+ const v = validateFeatureConfig('notifications', {
1229
+ ...base,
1230
+ templates: {
1231
+ allowOverride: true,
1232
+ overrides: { transactional: { fr: { text: 'FR {{paragraph}}' } } },
1233
+ },
1234
+ });
1235
+ expect(v.ok, v.errors.join()).toBe(true);
1236
+ });
1237
+
1238
+ it('REJECTS a variant with no non-empty part (the new inert shape)', () => {
1239
+ // `{}` and all-empty bodies reach the cross-field rule (html/text carry no
1240
+ // minLength); an empty `subject` is still refused earlier by the schema's own
1241
+ // minLength. Either way the write fails LOUD — never a silently inert override.
1242
+ for (const variant of [{}, { html: '' }, { html: '', text: '' }]) {
1243
+ const v = validateFeatureConfig('notifications', {
1244
+ ...base,
1245
+ templates: { allowOverride: true, overrides: { transactional: { ar: variant } } },
1246
+ });
1247
+ expect(v.ok, JSON.stringify(variant)).toBe(false);
1248
+ if (!v.ok) {
1249
+ expect(v.errors.join('\n'), JSON.stringify(variant))
1250
+ .toContain('set at least one of subject, html, text');
1251
+ }
1252
+ }
1253
+ const emptySubject = validateFeatureConfig('notifications', {
1254
+ ...base,
1255
+ templates: { allowOverride: true, overrides: { transactional: { ar: { subject: '' } } } },
1256
+ });
1257
+ expect(emptySubject.ok).toBe(false);
1258
+ });
1259
+
1260
+ it('the pure validator refuses a non-object variant without throwing (total)', () => {
1261
+ expect(validateNotificationOverrides({
1262
+ allowOverride: true,
1263
+ overrides: { welcome: { 'fr-FR': null } },
1264
+ } as never).join('\n')).toContain('at least one of subject, html, text');
1265
+ });
1266
+
1267
+ it('`cta` is an allowed transactional placeholder (the {{cta}} slot)', () => {
1268
+ expect(NOTIFICATION_TEMPLATE_PLACEHOLDERS['transactional']).toContain('cta');
1269
+ const v = validateFeatureConfig('notifications', {
1270
+ ...base,
1271
+ templates: {
1272
+ allowOverride: true,
1273
+ overrides: { transactional: { ar: { html: '<p>{{paragraph}}</p>{{cta}}' } } },
1274
+ },
1275
+ });
1276
+ expect(v.ok, v.errors.join()).toBe(true);
1277
+ });
1278
+
1142
1279
  it('the pure validator is [] for an absent/empty bag', () => {
1143
1280
  expect(validateNotificationOverrides(undefined)).toEqual([]);
1144
1281
  expect(validateNotificationOverrides({ allowOverride: true })).toEqual([]);
@@ -1247,6 +1384,37 @@ describe('F8-54: the deleted inert leaves stay deleted, and old manifests still
1247
1384
  expect(dl['memoryMb']).toBeUndefined();
1248
1385
  });
1249
1386
 
1387
+ // D.18 — the PER-FUNCTION bag (inside the Type.Record value), not defaultLimits.
1388
+ // It used to REQUIRE all three members whenever present, a latent 422 the moment
1389
+ // anything sent one; nothing ever did, because the CLI never carried it.
1390
+ it('functions: the per-function limits bag accepts each member ALONE', () => {
1391
+ const base = { scriptRef: 'fn-t-hello-abc123def456' };
1392
+ for (const limits of [{}, { cpuMs: 200 }, { timeoutMs: 60_000 }, { cpuMs: 200, timeoutMs: 60_000 }]) {
1393
+ const r = validateFeatureConfig('functions', {
1394
+ enabled: true, functions: { hello: { ...base, limits } },
1395
+ });
1396
+ expect(r.ok, JSON.stringify(limits)).toBe(true);
1397
+ }
1398
+ });
1399
+
1400
+ it('functions: a per-function memoryMb is STRIPPED, not rejected (the published F8-54 contract)', () => {
1401
+ // docs/guide/changelog.md publishes strip-and-ignore for removed config keys:
1402
+ // an already-saved manifest still loads and `vxil plan --explain` marks the
1403
+ // key DROPPED. The DEPLOY BODY is the surface that 422s it (normalizeLimits).
1404
+ const r = validateFeatureConfig('functions', {
1405
+ enabled: true,
1406
+ functions: { hello: { scriptRef: 'fn-t-hello-abc123def456', limits: { cpuMs: 200, memoryMb: 512 } } },
1407
+ });
1408
+ expect(r.ok).toBe(true);
1409
+ const fns = (r.value as { functions: Record<string, { limits: Record<string, unknown> }> }).functions;
1410
+ expect(fns.hello!.limits).toEqual({ cpuMs: 200 });
1411
+ expect(fns.hello!.limits['memoryMb']).toBeUndefined();
1412
+ });
1413
+
1414
+ it('functions: the per-function bag is DATA — re-typing it never moves the leaf count', () => {
1415
+ expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
1416
+ });
1417
+
1250
1418
  it('every surviving schema still fits the cap (the deletions only freed budget)', () => {
1251
1419
  for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
1252
1420
  expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
package/src/index.ts CHANGED
@@ -49,7 +49,20 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonl
49
49
  'magic-link': ['url', 'expires_minutes'],
50
50
  'otp-code': ['code', 'expires_minutes'],
51
51
  welcome: ['app_name', 'first_name', 'first_name_greeting'],
52
- transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
52
+ // `cta` (G-78) is a SLOT, not a data key: an override that writes `{{cta}}`
53
+ // chooses where the escaped CTA anchor built from cta_label + cta_url lands.
54
+ // Absent from the built-in bodies (which append it), so the worker parity
55
+ // suite pins it explicitly — see templates.test.ts.
56
+ transactional: ['subject', 'paragraph', 'cta_label', 'cta_url', 'cta'],
57
+ };
58
+
59
+ /** Placeholders that are SLOTS in the BODY parts only — the renderer splits
60
+ * `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
61
+ * the plain interpolator, where the same token is just a data key that does not
62
+ * exist and renders EMPTY. Rejected in `subject` for exactly the reason the
63
+ * whole allow-list exists: a silent empty render in a live email. */
64
+ export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, readonly string[]>> = {
65
+ transactional: ['cta'],
53
66
  };
54
67
 
55
68
  /** Per-override caps (D4). Bodies are emails, not documents. */
@@ -108,13 +121,30 @@ export function validateNotificationOverrides(
108
121
  continue;
109
122
  }
110
123
  const variant = byLocale![locale]!;
124
+ if (typeof variant !== 'object' || variant === null) {
125
+ errs.push(`/templates/overrides/${templateId}/${locale}: must be an object with at least one of subject, html, text`);
126
+ continue;
127
+ }
128
+ // G-78 — every part is OPTIONAL (a body-only translation inherits the
129
+ // built-in subject), but an ALL-EMPTY variant is the new "inert config"
130
+ // and fails LOUD rather than silently rendering the built-in.
131
+ const nonEmpty = (['subject', 'html', 'text'] as const)
132
+ .filter((p) => typeof variant[p] === 'string' && variant[p] !== '');
133
+ if (nonEmpty.length === 0) {
134
+ errs.push(`/templates/overrides/${templateId}/${locale}: set at least one of subject, html, text`);
135
+ continue;
136
+ }
137
+ // body-only SLOTS are not subject placeholders (see the map's doc comment)
138
+ const slots = NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS[templateId] ?? [];
139
+ const subjectAllowed = allowed.filter((k) => !slots.includes(k));
111
140
  for (const part of ['subject', 'html', 'text'] as const) {
112
141
  const body = variant[part];
113
142
  if (typeof body !== 'string') continue;
143
+ const ok = part === 'subject' ? subjectAllowed : allowed;
114
144
  for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
115
145
  const key = m[1]!;
116
- if (!allowed.includes(key)) {
117
- errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
146
+ if (!ok.includes(key)) {
147
+ errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${ok.join(', ')})`);
118
148
  }
119
149
  }
120
150
  }
@@ -183,7 +213,7 @@ export const NotificationsConfigSchema = Type.Object({
183
213
  allowOverride: Type.Boolean({ default: false }),
184
214
  // D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
185
215
  // template ids, as config DATA (a Record = 1 leaf, catalog size never
186
- // moves the count). Shape: { [templateId]: { [locale]: { subject,
216
+ // moves the count). Shape: { [templateId]: { [locale]: { subject?,
187
217
  // html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
188
218
  // built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
189
219
  // sanitisation are identical and there is no raw-output syntax.
@@ -192,8 +222,15 @@ export const NotificationsConfigSchema = Type.Object({
192
222
  // LOUD, never silently inert) overrides present with allowOverride:false.
193
223
  overrides: Type.Optional(Type.Record(
194
224
  Type.String(),
225
+ // G-78: `subject` is OPTIONAL like html/text — a PARTIAL override
226
+ // inherits each omitted part from the built-in, so a body-only
227
+ // translation no longer forces you to restate the subject (and a
228
+ // subject-only 'ar' override no longer pairs an Arabic subject with the
229
+ // English built-in body). A strict widening: every config valid before
230
+ // this change is still valid. validateNotificationOverrides rejects the
231
+ // one shape this opens up — a variant with NO non-empty part.
195
232
  Type.Record(Type.String(), Type.Object({
196
- subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
233
+ subject: Type.Optional(Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX })),
197
234
  html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
198
235
  text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
199
236
  })),
@@ -445,10 +482,38 @@ export const AuthConfigSchema = Type.Object({
445
482
  { required: Type.Boolean({ default: false }) },
446
483
  { default: {} },
447
484
  ),
448
- magicLink: Type.Object(
449
- { tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }) },
485
+ // P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
486
+ // many knobs it holds — the methods/session/password precedent) so the
487
+ // request cooldown could land without spending a second leaf. It KEEPS
488
+ // `default: {}`, so Value.Default still materializes
489
+ // `magicLink.tokenTtlMinutes` exactly as before — every persisted manifest
490
+ // folds byte-identically.
491
+ magicLink: Type.Optional(Type.Object(
492
+ {
493
+ tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
494
+ // P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
495
+ // `otp.resendCooldownSec` twin, same bounds so the two knobs read the
496
+ // same. A second request for the same address inside the window is a 429
497
+ // `magic_link_rate_limited` with a Retry-After header. Type.Optional with
498
+ // NO default (the `otp.testRecipients` precedent below) so existing
499
+ // manifests fold byte-identically; the 60s fallback is applied by the
500
+ // auth feature at read time. 0 disables the cooldown.
501
+ resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
502
+ },
450
503
  { default: {} },
451
- ),
504
+ )),
505
+ // P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
506
+ // true, a sign-in by a method that PROVES control of the address — magic
507
+ // link, email OTP, or OAuth with a provider-verified address — reuses the id
508
+ // of the one matching pre-registered end-user (POST /v1/users) that has no
509
+ // account yet, instead of minting a fresh `user_<ulid>` and leaving the
510
+ // tenant with two records for one person. Password SIGN-UP never adopts: it
511
+ // proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
512
+ // OFF by default, and Type.Optional with NO default: adoption means whoever
513
+ // proves control of a pre-registered address becomes that record — a change
514
+ // of security semantics for a tenant that bulk-imports contacts, whose
515
+ // addresses vxil never verified.
516
+ registryAdopt: Type.Optional(Type.Boolean()),
452
517
  // Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
453
518
  // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
454
519
  // otp?.enabled === true).
@@ -524,11 +589,12 @@ export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
524
589
  // Leaves (auth wave 2026-09-10, D3): enabled(1) + methods(1, optional bag —
525
590
  // was 6) + providers(1, optional bag holding the four google/github/apple/
526
591
  // facebook credential blocks + the RB-2 `oidc` issuer block) + session(1, optional bag) + password(1,
527
- // optional bag) + emailVerification(1) + magicLink(1) + otp(1, optional bag)
592
+ // optional bag) + emailVerification(1) + magicLink(1, optional bag) + otp(1, optional bag)
528
593
  // + anonymous(1, optional bag) + orgClaims(1, optional bag) + security(1,
529
- // optional bag) = 11. Cap = 15 — 4 leaves of headroom. Knobs added inside an
530
- // existing bag (session.maxConcurrent, otp.testRecipients, the security
531
- // sub-bags) never move the count.
594
+ // optional bag) + registryAdopt(1, P1-5) = 12. Cap = 15 — 3 leaves of
595
+ // headroom. Knobs added inside an existing bag (session.maxConcurrent,
596
+ // otp.testRecipients, magicLink.resendCooldownSec, the security sub-bags)
597
+ // never move the count.
532
598
 
533
599
  export type AuthConfig = Static<typeof AuthConfigSchema>;
534
600
 
@@ -564,7 +630,7 @@ export const FilesConfigSchema = Type.Object({
564
630
  // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
565
631
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
566
632
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
567
- // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
633
+ // tenant tier threaded to files-v1 (plan tiers).
568
634
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
569
635
  maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
570
636
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
@@ -620,8 +686,29 @@ export const WebhooksConfigSchema = Type.Object({
620
686
  enabled: Type.Boolean({ default: true }),
621
687
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
622
688
  maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
689
+ // FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
690
+ // channel; this is the "nobody is consuming them yet" fallback — a periodic
691
+ // e-mail summary of the tenant's FAILURE-class audit events (the level:
692
+ // 'failure' rows of the generated event catalog). Read by
693
+ // workers/control-plane/src/alertDigest.ts on the minute cron.
694
+ //
695
+ // RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
696
+ // journaled here the way RateLimitsConfigSchema journals its own: an
697
+ // arbitrary `to` would turn vxil's own sending identity into a relay for
698
+ // tenant-authored content and open a PII egress path out of the audit trail.
699
+ // The digest goes to the OWNER-role dashboard accounts of the project (cap
700
+ // 3), who already receive platform mail (invites, magic links, resets), so no
701
+ // new consent surface is created. An arbitrary recipient is a separate
702
+ // decision with its own abuse review.
703
+ //
704
+ // An Optional object bag counts as ONE leaf (the countLeaves rule).
705
+ alerts: Type.Optional(Type.Object({
706
+ enabled: Type.Boolean({ default: false }),
707
+ minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
708
+ digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
709
+ })),
623
710
  });
624
- // Leaves: 3. Cap = 15.
711
+ // Leaves: 3 + alerts(1, optional bag) = 4. Cap = 15.
625
712
 
626
713
  export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
627
714
 
@@ -678,7 +765,7 @@ export const CmsConfigSchema = Type.Object({
678
765
  },
679
766
  { default: {} },
680
767
  ),
681
- // Code-based lifecycle hooks (docs/cms-lifecycle-hooks-rung2-design.md, Lane A):
768
+ // Code-based lifecycle hooks (https://vxil.com/docs/guide/07-validation-and-hooks):
682
769
  // a tenant-authored SAFE expression. Write events (beforeCreate/beforeUpdate/
683
770
  // beforeWrite) run in-transaction — `validate` rejects the write, `derive`
684
771
  // computes a persisted field. Read events shape the RESPONSE only: `validate`
@@ -1086,7 +1173,7 @@ export const AiConfigSchema = Type.Object({
1086
1173
  cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
1087
1174
  limits: Type.Object({
1088
1175
  tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
1089
- consumeCredits: Type.Boolean({ default: false }), // reserve→settle (STUB until payments)
1176
+ consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
1090
1177
  }, { default: {} }),
1091
1178
  streaming: Type.Object({
1092
1179
  enabled: Type.Boolean({ default: true }),
@@ -1204,10 +1291,16 @@ export const PaymentsConfigSchema = Type.Object({
1204
1291
  projectId: Type.String(),
1205
1292
  publicSdkKey: Type.String(),
1206
1293
  secretApiKeyRef: Type.String(),
1207
- // Optional per-tenant webhook secret ref (public.tenant_secrets). When set,
1208
- // inbound RevenueCat webhooks are verified with THIS tenant's secret instead
1209
- // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
1210
- // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
1294
+ // Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
1295
+ // webhooks are verified against THIS ref and nothing else: there is no
1296
+ // platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
1297
+ // (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
1298
+ // frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
1299
+ // permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
1300
+ // Optional at the SCHEMA level only — leaving it unset does not disable
1301
+ // verification, it fails CLOSED: every delivery is 401 bad_signature with a
1302
+ // `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
1303
+ // webhookSecretRef.
1211
1304
  webhookSecretRef: Type.Optional(Type.String()),
1212
1305
  // Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
1213
1306
  // and PRODUCTION events to the SAME webhook with the same auth header, so a
@@ -1285,6 +1378,29 @@ export const PaymentsConfigSchema = Type.Object({
1285
1378
  enforcePeriodEnd: Type.Optional(Type.Object({
1286
1379
  slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
1287
1380
  })),
1381
+ // (2026-09 P2) What to do with a one-off purchase whose `product_id` is NOT
1382
+ // in productMap. 'error' (the DEFAULT and today's behaviour) stamps the
1383
+ // delivery outcome `error`, emits payments.webhook_event.failed and lands a
1384
+ // `stranded_webhook` reconcile finding. 'ignore' stamps it `ignored`, with
1385
+ // no failure event and no stranded finding.
1386
+ //
1387
+ // WHY IT EXISTS: the trigger is `event.productId && !grantRule`, and Paddle
1388
+ // carries items[0].price.product_id on EVERY transaction.completed —
1389
+ // INCLUDING subscription-renewal transactions, whose money folds through the
1390
+ // subscription.* events instead. A Paddle tenant that drives everything
1391
+ // through tierMap/priceMap therefore got one `error` row + one failure event
1392
+ // + one stranded finding PER RENEWAL, forever. 'ignore' is the one-line
1393
+ // answer. The charge row is still recorded either way — only the ledger
1394
+ // grant is declined — and an `ignored` row stays REPROCESSABLE, so mapping
1395
+ // the product later and re-running the delivery folds it.
1396
+ //
1397
+ // NO SCHEMA DEFAULT ON PURPOSE: the config-write pipeline runs
1398
+ // Value.Default, which MATERIALIZES an Optional-with-default into every
1399
+ // persisted manifest — that would rewrite every payments manifest carrying a
1400
+ // `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
1401
+ // every payments tenant. The READER owns the default
1402
+ // (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
1403
+ unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
1288
1404
  })),
1289
1405
  // NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
1290
1406
  // was DELETED — it had zero readers (never forwarded anything). Outbound
@@ -1373,15 +1489,50 @@ export const FunctionsConfigSchema = Type.Object({
1373
1489
  { maxItems: 8 },
1374
1490
  ),
1375
1491
  ),
1376
- scriptRef: Type.String(), // content-hashed WfP script name: fn-<tenant>-<name>-<sha>
1492
+ scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
1377
1493
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1378
- secrets: Type.Optional(Type.Array(Type.String())), // tenant_secrets refs (KEK_FUNCTIONS)
1494
+ secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
1379
1495
  egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1496
+ // Per-function resource declarations. BOTH members are OPTIONAL (the bag
1497
+ // used to REQUIRE all three, a latent 422 the moment anything sent it —
1498
+ // nothing ever did, because the CLI never carried it) and BOTH are
1499
+ // UNBOUNDED here on purpose: `deployFunction` validates the WHOLE merged
1500
+ // manifest, `Value.Clean` strips unknown keys but never clamps
1501
+ // out-of-range ones, so a min/max here would let ONE legacy entry 422
1502
+ // every future deploy for that tenant. The bounds (cpuMs 5..300000,
1503
+ // timeoutMs 1000..120000) are enforced at the ONE write path,
1504
+ // `normalizeLimits` in the deploy handler, which also sanitizes pre-existing
1505
+ // entries on merge.
1506
+ // cpuMs — TIGHTENS the per-dispatch isolate CPU cap (never widens
1507
+ // FN_MAX_CPU_MS) and RAISES (never lowers) the metering
1508
+ // reserve, whose floor stays defaultLimits.cpuMs. THE
1509
+ // CONSEQUENCE, stated because the knob's name hides it:
1510
+ // the reserve is the payments HOLD, and the pro-rated
1511
+ // settle clamps the measured wall-ms into [floor, reserve]
1512
+ // — it can never commit more than it holds — so raising
1513
+ // the reserve raises the settle CEILING. An invoke whose
1514
+ // wall time exceeds defaultLimits.cpuMs (50) then settles
1515
+ // at its measured wall-ms instead of being capped at 50:
1516
+ // on a METERED tenant, declaring cpuMs raises the bill for
1517
+ // long-running functions (cpuMs: 1000 turns an 800 ms
1518
+ // invoke from 50 into 800). It does NOT move anyone's
1519
+ // availability — the ACCOUNT-global daily capacity budget
1520
+ // counts the platform's own estimate, never this value
1521
+ // (functions-v1 capacity.ts `capacityReserveCpuMs`).
1522
+ // timeoutMs — the wall budget for ONE outbound fetch, delivered to the
1523
+ // egress guard as an outbound parameter. NOT an invocation
1524
+ // budget: the INVOCATION is bounded by the platform's own
1525
+ // FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
1526
+ // bigger per-fetch budget widens with you (audit FN-3).
1527
+ // (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
1528
+ // is deleted — memory is fixed by the managed runtime and is not a
1529
+ // per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
1530
+ // Value.Clean strips it from an old config, so such a config still loads
1531
+ // and `vxil plan --explain` marks the key DROPPED.)
1380
1532
  limits: Type.Optional(
1381
1533
  Type.Object({
1382
- cpuMs: Type.Integer(),
1383
- timeoutMs: Type.Integer(),
1384
- memoryMb: Type.Integer(),
1534
+ cpuMs: Type.Optional(Type.Integer()),
1535
+ timeoutMs: Type.Optional(Type.Integer()),
1385
1536
  }),
1386
1537
  ),
1387
1538
  enabled: Type.Optional(Type.Boolean()),
@@ -1405,7 +1556,7 @@ export const FunctionsConfigSchema = Type.Object({
1405
1556
  // defaultLimits.memoryMb leaves.)
1406
1557
  export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
1407
1558
 
1408
- // copilot feature (docs/copilot-agents-sku-design.md §4). Re-declared to match
1559
+ // copilot feature (https://vxil.com/docs/guide/11-copilot-agents). Re-declared to match
1409
1560
  // workers/copilot-v1/src/config.ts's exported CopilotConfigSchema (one shape,
1410
1561
  // two consumers — keep the two definitions byte-identical). A thin COMPOSITION
1411
1562
  // layer over ai + vector-search + rag + mcp: it owns the AGENT layer (persona,
@@ -1922,7 +2073,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1922
2073
  }
1923
2074
  if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
1924
2075
  }
1925
- // Cross-field rules: copilot (docs/copilot-agents-sku-design.md §4). Pure
2076
+ // Cross-field rules: copilot (https://vxil.com/docs/guide/11-copilot-agents). Pure
1926
2077
  // rules only — cross-feature resolutions (collection existence, directiveRef
1927
2078
  // → ai.templates) are deferred to runtime, which fails soft with the
1928
2079
  // capability-surface envelope. Tool-NAME existence runs only when the mcp
@@ -1973,7 +2124,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1973
2124
  }
1974
2125
 
1975
2126
  // ─────────────────────────────────────────────────────────────────────────────
1976
- // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
2127
+ // PLANNER GROUNDING (https://vxil.com/docs/guide/10-agents-and-mcp).
1977
2128
  // FEATURE_KEYS is the literal feature list the planner catalog is projected
1978
2129
  // from; the planner-catalog CI gate asserts set-equality with
1979
2130
  // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
package/src/readmodels.ts CHANGED
@@ -1,4 +1,4 @@
1
- // cms-rel B5/E config-write gates (docs/cms-relational-depth-options.md §3/§4)
1
+ // cms-rel B5/E config-write gates (read models + transactions — https://vxil.com/docs/guide/04-data-with-cms)
2
2
  // — the hooks.ts sibling: PURE structural validation of the `readModels` and
3
3
  // `cdc` bags at config-write time (the anti-malice-gate pattern). The grammar
4
4
  // (fn/rank allow-lists, arity caps, window bounds, dotted-term shape) is