@vxil/feature-configs 0.3.1 → 0.4.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.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).
@@ -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({
@@ -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
@@ -971,7 +1063,7 @@ export const PaymentsConfigSchema = Type.Object({
971
1063
  // Opt-in period-end enforcement (money-path operations wave, decision D2
972
1064
  // option a). ABSENT (the default) = today's behaviour: a subscription whose
973
1065
  // current_period_end passed with no provider event stays entitled forever
974
- // (the provider is the only clock). PRESENT = the hourly reconcile sweep
1066
+ // (the provider is the only clock). PRESENT = the nightly reconcile sweep
975
1067
  // flips an `active`/`trialing` row whose current_period_end + slackHours
976
1068
  // has passed with no renewal to status 'lapsed' (+ refold, +
977
1069
  // payments.subscription.lapsed); a later provider renewal event revives it
@@ -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
@@ -1048,10 +1163,45 @@ export const FunctionsConfigSchema = Type.Object({
1048
1163
  scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1049
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "files": [
26
26
  "dist",
27
- "src"
27
+ "src",
28
+ "!src/**/*.test.ts"
28
29
  ],
29
30
  "dependencies": {
30
31
  "@sinclair/typebox": "^0.34.0"