@vxil/feature-configs 0.1.1 → 0.2.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/dist/index.d.ts CHANGED
@@ -1,18 +1,23 @@
1
1
  import { type Static, type TSchema } from '@sinclair/typebox';
2
2
  export * from './hooks.js';
3
3
  export * from './readmodels.js';
4
+ export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
5
+ /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
6
+ * of which is restricted to internal platform machinery). */
7
+ export declare function isReservedCreditType(creditType: string): boolean;
4
8
  export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObject<{
5
9
  enabled: import("@sinclair/typebox").TBoolean;
6
10
  fromEmail: import("@sinclair/typebox").TString;
7
11
  fromName: import("@sinclair/typebox").TString;
8
12
  replyTo: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
9
13
  resendApiKeyRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
14
+ webhookSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
10
15
  provider: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"resend">, import("@sinclair/typebox").TLiteral<"mock">]>;
11
16
  defaultLocale: import("@sinclair/typebox").TString;
12
- retry: import("@sinclair/typebox").TObject<{
17
+ retry: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
13
18
  maxAttempts: import("@sinclair/typebox").TInteger;
14
19
  backoff: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"exponential">, import("@sinclair/typebox").TLiteral<"linear">]>;
15
- }>;
20
+ }>>;
16
21
  suppression: import("@sinclair/typebox").TObject<{
17
22
  softBounceThreshold: import("@sinclair/typebox").TInteger;
18
23
  }>;
@@ -25,8 +30,31 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
25
30
  }>;
26
31
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
27
32
  inboxEnabled: import("@sinclair/typebox").TBoolean;
33
+ /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
34
+ * disabled; per-campaign quiet_hours / freq_cap overrides live on the
35
+ * notifications.campaigns ROW (tenant data), not here. Folded into the
36
+ * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
37
+ * worker-local extension, so campaigns 403'd via the real config path). */
38
+ broadcast: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
39
+ enabled: import("@sinclair/typebox").TBoolean;
40
+ /** tenant-default per-recipient quiet window (defer-not-drop); a
41
+ * per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
42
+ * (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
43
+ * pattern-pinned to what the worker's parseHhMm actually parses — a
44
+ * looser string ('10pm') would validate but FAIL OPEN at runtime
45
+ * (quiet window silently ignored). */
46
+ defaultQuietHours: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
47
+ tz: import("@sinclair/typebox").TString;
48
+ start: import("@sinclair/typebox").TString;
49
+ end: import("@sinclair/typebox").TString;
50
+ }>>;
51
+ /** default rolling per-user-per-day campaign-send cap across campaigns */
52
+ freqCapPerUserPerDay: import("@sinclair/typebox").TInteger;
53
+ }>>;
28
54
  }>;
29
55
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
56
+ /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
57
+ export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
30
58
  export declare const JobsConfigSchema: import("@sinclair/typebox").TObject<{
31
59
  enabled: import("@sinclair/typebox").TBoolean;
32
60
  retry: import("@sinclair/typebox").TObject<{
@@ -585,3 +613,19 @@ export declare const MCP_MAX_PROMPTS = 32;
585
613
  * validation below and the control-plane deploy clamp. */
586
614
  export declare const DENY_FUNCTION_SCOPES: Set<string>;
587
615
  export declare function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation;
616
+ export declare const FEATURE_KEYS: readonly ["notifications", "jobs", "auth", "rate-limits", "files", "webhooks", "comments", "cms", "mcp", "realtime", "presence", "orgs", "activity-feed", "vector-search", "ai", "rag", "payments", "functions", "copilot"];
617
+ export type FeatureKey = (typeof FEATURE_KEYS)[number];
618
+ /** The hand-authored "when to use" intent a machine schema cannot convey.
619
+ * REQUIRED for every feature (Record key-closure): a feature added to
620
+ * FEATURE_KEYS without a hint is a compile error naming the missing key. */
621
+ export interface PlannerHint {
622
+ /** One or two sentences: when an app needs this feature. */
623
+ whenToUse: string;
624
+ /** Soft priors — words/needs that suggest the feature (NOT a matcher). */
625
+ signals: string[];
626
+ /** When NOT to pick it (disambiguation vs neighbors). */
627
+ notFor: string;
628
+ /** Features that must be enabled alongside (runtime substrate deps). */
629
+ dependsOn: FeatureKey[];
630
+ }
631
+ export declare const FEATURE_HINTS: Record<FeatureKey, PlannerHint>;
package/dist/index.js CHANGED
@@ -18,6 +18,21 @@ export * from './readmodels.js';
18
18
  if (!FormatRegistry.Has('email')) {
19
19
  FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
20
20
  }
21
+ // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
22
+ // `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
23
+ // functions "recover-by-price" meter). It must NEVER be grantable or consumable
24
+ // by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
25
+ // tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
26
+ // through the webhook/subscription grant reducers) runs vxil-billed function CPU
27
+ // for free. Defined HERE (the typebox-only shared package both the control-plane
28
+ // config-write gate AND payments-v1 import) so the runtime choke point and the
29
+ // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
30
+ export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
31
+ /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
32
+ * of which is restricted to internal platform machinery). */
33
+ export function isReservedCreditType(creditType) {
34
+ return RESERVED_CREDIT_TYPES.has(creditType);
35
+ }
21
36
  export const NotificationsConfigSchema = Type.Object({
22
37
  enabled: Type.Boolean({ default: true }),
23
38
  fromEmail: Type.String({ format: 'email' }),
@@ -27,19 +42,32 @@ export const NotificationsConfigSchema = Type.Object({
27
42
  // need no email account, so the mock path is zero-config. A cross-field
28
43
  // check in validateFeatureConfig requires it only when provider === 'resend'.
29
44
  resendApiKeyRef: Type.Optional(Type.String()),
45
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
46
+ // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
47
+ // When set, inbound Resend webhooks are verified with THIS tenant's secret
48
+ // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
49
+ // to the tenant so a signed event for tenant A can never validate at tenant
50
+ // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
51
+ webhookSecretRef: Type.Optional(Type.String()),
30
52
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
31
53
  provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
32
54
  default: 'resend',
33
55
  }),
34
56
  defaultLocale: Type.String({ default: 'en-US' }),
35
57
  // nested objects carry `default: {}` so Value.Default can materialize them
36
- // and then recurse into the leaf defaults
37
- retry: Type.Object({
58
+ // and then recurse into the leaf defaults.
59
+ // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
60
+ // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
61
+ // It KEEPS `default: {}`, which Value.Default still materializes — so every
62
+ // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
63
+ // (zero behavioral delta); only the TS type is now optional (workers read
64
+ // via retryOf()'s fallback).
65
+ retry: Type.Optional(Type.Object({
38
66
  maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
39
67
  backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
40
68
  default: 'exponential',
41
69
  }),
42
- }, { default: {} }),
70
+ }, { default: {} })),
43
71
  suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
44
72
  rateLimit: Type.Object({
45
73
  perDay: Type.Integer({ default: 100000 }),
@@ -48,6 +76,27 @@ export const NotificationsConfigSchema = Type.Object({
48
76
  templates: Type.Object({ allowOverride: Type.Boolean({ default: false }) }, { default: {} }),
49
77
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
50
78
  inboxEnabled: Type.Boolean({ default: false }),
79
+ /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
80
+ * disabled; per-campaign quiet_hours / freq_cap overrides live on the
81
+ * notifications.campaigns ROW (tenant data), not here. Folded into the
82
+ * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
83
+ * worker-local extension, so campaigns 403'd via the real config path). */
84
+ broadcast: Type.Optional(Type.Object({
85
+ enabled: Type.Boolean({ default: false }),
86
+ /** tenant-default per-recipient quiet window (defer-not-drop); a
87
+ * per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
88
+ * (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
89
+ * pattern-pinned to what the worker's parseHhMm actually parses — a
90
+ * looser string ('10pm') would validate but FAIL OPEN at runtime
91
+ * (quiet window silently ignored). */
92
+ defaultQuietHours: Type.Optional(Type.Object({
93
+ tz: Type.String({ minLength: 1, maxLength: 64 }),
94
+ start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
95
+ end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
96
+ })),
97
+ /** default rolling per-user-per-day campaign-send cap across campaigns */
98
+ freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
99
+ })),
51
100
  });
52
101
  export const JobsConfigSchema = Type.Object({
53
102
  enabled: Type.Boolean({ default: true }),
@@ -208,7 +257,7 @@ export const FilesConfigSchema = Type.Object({
208
257
  downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
209
258
  quotas: Type.Object({
210
259
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
211
- // R2 is cheap but Neon-resident metadata + abuse aren't (pricing re-audit
260
+ // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
212
261
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
213
262
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
214
263
  // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
@@ -338,7 +387,7 @@ export const CmsConfigSchema = Type.Object({
338
387
  enabled: Type.Optional(Type.Boolean()),
339
388
  }))),
340
389
  // Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
341
- // a realtime channel — config-only rewiring of postgres_changes-style subs.
390
+ // a realtime channel — config-only rewiring of database-change-event-style subs.
342
391
  // Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
343
392
  // webhooks-out / functions cms-hook). ONE Type.Record leaf.
344
393
  cdc: Type.Optional(Type.Record(Type.String({ maxLength: 64 }), Type.Object({
@@ -473,10 +522,10 @@ export const ActivityFeedConfigSchema = Type.Object({
473
522
  // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
474
523
  export const VectorSearchConfigSchema = Type.Object({
475
524
  enabled: Type.Boolean({ default: true }),
476
- // 'auto' resolves to pgvector until Lakebase is GA'd for the tier (#147).
525
+ // 'auto' resolves to the default managed vector backend for the tier (#147).
477
526
  backend: Type.Union([Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')], { default: 'auto' }),
478
527
  // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
479
- // BYO key from public.tenant_secrets via apiKeyRef (envelope-encrypted).
528
+ // BYO key from tenant secrets via apiKeyRef (encrypted at rest).
480
529
  embed: Type.Object({
481
530
  provider: Type.Union([
482
531
  Type.Literal('byov'),
@@ -709,7 +758,7 @@ export const PaymentsConfigSchema = Type.Object({
709
758
  webhooks: Type.Object({ forwardToTenantUrl: Type.Optional(Type.String({ format: 'uri' })) }, { default: {} }),
710
759
  });
711
760
  // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
712
- // functions on Workers-for-Platforms. The FUNCTION owns its identity (bundle via
761
+ // functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
713
762
  // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
714
763
  // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
715
764
  // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
@@ -1020,6 +1069,25 @@ export function validateFeatureConfig(feature, raw) {
1020
1069
  errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
1021
1070
  };
1022
1071
  }
1072
+ // A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
1073
+ // the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
1074
+ // user, running vxil-billed functions for free (audit F2). Rejected at write
1075
+ // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1076
+ const ledgerErrs = [];
1077
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1078
+ if (rule.creditType && isReservedCreditType(rule.creditType)) {
1079
+ ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1080
+ }
1081
+ }
1082
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1083
+ (rule.grants ?? []).forEach((g, i) => {
1084
+ if (g.creditType && isReservedCreditType(g.creditType)) {
1085
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1086
+ }
1087
+ });
1088
+ }
1089
+ if (ledgerErrs.length)
1090
+ return { ok: false, errors: ledgerErrs.slice(0, 10) };
1023
1091
  }
1024
1092
  // Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
1025
1093
  // AST-validated against the closed sandbox allow-list HERE, at config-write
@@ -1189,3 +1257,133 @@ export function validateFeatureConfig(feature, raw) {
1189
1257
  }
1190
1258
  return { ok: true, errors: [], value: withDefaults };
1191
1259
  }
1260
+ // ─────────────────────────────────────────────────────────────────────────────
1261
+ // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
1262
+ // FEATURE_KEYS is the literal feature list the planner catalog is projected
1263
+ // from; the planner-catalog CI gate asserts set-equality with
1264
+ // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
1265
+ // list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
1266
+ // turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
1267
+ // human-authored but gated-for-presence.
1268
+ // ─────────────────────────────────────────────────────────────────────────────
1269
+ export const FEATURE_KEYS = [
1270
+ 'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
1271
+ 'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
1272
+ 'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
1273
+ ];
1274
+ export const FEATURE_HINTS = {
1275
+ notifications: {
1276
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
1277
+ signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
1278
+ notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
1279
+ dependsOn: [],
1280
+ },
1281
+ jobs: {
1282
+ whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
1283
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
1284
+ notFor: 'Simple request-response logic that completes inline.',
1285
+ dependsOn: [],
1286
+ },
1287
+ auth: {
1288
+ whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
1289
+ signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
1290
+ notFor: 'Pure-public read-only content with no user state.',
1291
+ dependsOn: [],
1292
+ },
1293
+ 'rate-limits': {
1294
+ whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
1295
+ signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
1296
+ notFor: 'General correctness — the platform already meters requests globally.',
1297
+ dependsOn: [],
1298
+ },
1299
+ files: {
1300
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
1301
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
1302
+ notFor: 'Structured records (cms) or text content authored in-app.',
1303
+ dependsOn: [],
1304
+ },
1305
+ webhooks: {
1306
+ whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
1307
+ signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
1308
+ notFor: 'In-app eventing between vxil features (the audit bus covers that).',
1309
+ dependsOn: ['jobs'],
1310
+ },
1311
+ comments: {
1312
+ whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
1313
+ signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
1314
+ notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
1315
+ dependsOn: [],
1316
+ },
1317
+ cms: {
1318
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
1319
+ signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
1320
+ notFor: 'End-user identity (auth) or file bytes (files).',
1321
+ dependsOn: [],
1322
+ },
1323
+ mcp: {
1324
+ whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
1325
+ signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
1326
+ notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
1327
+ dependsOn: [],
1328
+ },
1329
+ realtime: {
1330
+ whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
1331
+ signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
1332
+ notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
1333
+ dependsOn: [],
1334
+ },
1335
+ presence: {
1336
+ whenToUse: 'Showing who is online/typing/active on a channel right now.',
1337
+ signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
1338
+ notFor: 'Message delivery itself (realtime carries the messages).',
1339
+ dependsOn: ['realtime'],
1340
+ },
1341
+ orgs: {
1342
+ whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
1343
+ signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
1344
+ notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
1345
+ dependsOn: ['auth'],
1346
+ },
1347
+ 'activity-feed': {
1348
+ whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
1349
+ signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
1350
+ notFor: 'Live transport (realtime) or email (notifications).',
1351
+ dependsOn: [],
1352
+ },
1353
+ 'vector-search': {
1354
+ whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
1355
+ signals: ['search', 'semantic', 'similar', 'find by meaning'],
1356
+ notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
1357
+ dependsOn: [],
1358
+ },
1359
+ ai: {
1360
+ whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
1361
+ signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
1362
+ notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
1363
+ dependsOn: [],
1364
+ },
1365
+ rag: {
1366
+ whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
1367
+ signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
1368
+ notFor: 'Free-form generation with no grounding (ai).',
1369
+ dependsOn: ['vector-search', 'ai', 'files'],
1370
+ },
1371
+ payments: {
1372
+ whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
1373
+ signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
1374
+ notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
1375
+ dependsOn: [],
1376
+ },
1377
+ functions: {
1378
+ whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
1379
+ signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
1380
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
1381
+ dependsOn: [],
1382
+ },
1383
+ copilot: {
1384
+ whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
1385
+ signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
1386
+ notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
1387
+ dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
1388
+ },
1389
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
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.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/index.test.ts CHANGED
@@ -4,7 +4,10 @@ import {
4
4
  CopilotConfigSchema,
5
5
  FEATURE_SCHEMAS,
6
6
  NotificationsConfigSchema,
7
+ PaymentsConfigSchema,
8
+ RESERVED_CREDIT_TYPES,
7
9
  countLeaves,
10
+ isReservedCreditType,
8
11
  setKnownMcpTools,
9
12
  validateFeatureConfig,
10
13
  } from './index.js';
@@ -14,8 +17,11 @@ import {
14
17
  import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
15
18
 
16
19
  describe('15-flag cap analyzer (architecture §6)', () => {
17
- it('notifications schema has 14 leaves — under the cap', () => {
18
- expect(countLeaves(NotificationsConfigSchema)).toBe(14);
20
+ it('notifications schema has 15 leaves — at the cap (retry + broadcast are Optional bags, M21)', () => {
21
+ // enabled, fromEmail, fromName, replyTo, resendApiKeyRef, webhookSecretRef,
22
+ // provider, defaultLocale, retry(bag=1), suppression.softBounceThreshold,
23
+ // rateLimit(2), templates.allowOverride, inboxEnabled, broadcast(bag=1) = 15.
24
+ expect(countLeaves(NotificationsConfigSchema)).toBe(15);
19
25
  expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
20
26
  });
21
27
 
@@ -129,6 +135,74 @@ describe('validateFeatureConfig', () => {
129
135
  expect(r.ok).toBe(false);
130
136
  });
131
137
 
138
+ it('broadcast SURVIVES Value.Clean and persists via the real config path (M21/#4 regression)', () => {
139
+ // The HIGH this guards: `broadcast` used to live only in a worker-local
140
+ // schema, so this exact pipeline (Default → Clean → Check) silently
141
+ // DROPPED it and createCampaign 403'd on every real-config tenant.
142
+ const r = validateFeatureConfig('notifications', {
143
+ ...minimal,
144
+ broadcast: { enabled: true },
145
+ });
146
+ expect(r.ok).toBe(true);
147
+ const v = r.value as {
148
+ broadcast?: { enabled: boolean; freqCapPerUserPerDay: number };
149
+ };
150
+ expect(v.broadcast).toBeDefined();
151
+ expect(v.broadcast!.enabled).toBe(true);
152
+ // leaf default materialized inside the present bag
153
+ expect(v.broadcast!.freqCapPerUserPerDay).toBe(5);
154
+ });
155
+
156
+ it('broadcast.defaultQuietHours round-trips; absent broadcast stays absent (defaults-off deny)', () => {
157
+ const withQh = validateFeatureConfig('notifications', {
158
+ ...minimal,
159
+ broadcast: {
160
+ enabled: true,
161
+ defaultQuietHours: { tz: 'UTC', start: '22:00', end: '08:00' },
162
+ },
163
+ });
164
+ expect(withQh.ok).toBe(true);
165
+ const v = withQh.value as {
166
+ broadcast?: { defaultQuietHours?: { tz: string; start: string; end: string } };
167
+ };
168
+ expect(v.broadcast!.defaultQuietHours).toEqual({ tz: 'UTC', start: '22:00', end: '08:00' });
169
+
170
+ const absent = validateFeatureConfig('notifications', minimal);
171
+ expect(absent.ok).toBe(true);
172
+ expect((absent.value as { broadcast?: unknown }).broadcast).toBeUndefined();
173
+ // while retry (Optional bag WITH default:{}) still materializes — the
174
+ // shape-preserving half of the M21 leaf-budget fund
175
+ expect(((absent.value as { retry?: { maxAttempts: number } }).retry ?? {}).maxAttempts).toBe(5);
176
+ });
177
+
178
+ it('rejects a quiet-hours window parseHhMm cannot parse (would fail OPEN at run time)', () => {
179
+ const bad = validateFeatureConfig('notifications', {
180
+ ...minimal,
181
+ broadcast: {
182
+ enabled: true,
183
+ defaultQuietHours: { tz: 'UTC', start: '10pm', end: '08:00' },
184
+ },
185
+ });
186
+ expect(bad.ok).toBe(false);
187
+ expect(bad.errors.join(' ')).toContain('start');
188
+ });
189
+
190
+ it('rejects a malformed broadcast bag (unknown sub-keys stripped; bad types fail)', () => {
191
+ const badType = validateFeatureConfig('notifications', {
192
+ ...minimal,
193
+ broadcast: { enabled: 'yes' },
194
+ });
195
+ expect(badType.ok).toBe(false);
196
+
197
+ const sneaky = validateFeatureConfig('notifications', {
198
+ ...minimal,
199
+ broadcast: { enabled: true, maxAudiencePerRun: 100000 },
200
+ });
201
+ expect(sneaky.ok).toBe(true); // unknown key CLEANED, not persisted
202
+ expect(Object.keys((sneaky.value as { broadcast: object }).broadcast))
203
+ .not.toContain('maxAudiencePerRun');
204
+ });
205
+
132
206
  it('rejects an unknown feature', () => {
133
207
  const r = validateFeatureConfig('telepathy', { enabled: true });
134
208
  expect(r.ok).toBe(false);
@@ -737,3 +811,60 @@ describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () =
737
811
  expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
738
812
  });
739
813
  });
814
+
815
+ // ── payments: reserved (vxil-COGS) credit type cannot be mapped in ledger (F2) ─
816
+ // A tenant that maps a product/tier grant to `fn_cpu_ms` and then triggers a
817
+ // webhook/subscription would credit the vxil-COGS meter type through the grant
818
+ // reducers → free vxil-billed functions. Rejected at config-write time so the
819
+ // tenant gets a clear `vxil push` error instead of a silent runtime skip.
820
+ describe('payments: reserved credit_type rejected in ledger grant maps (F2)', () => {
821
+ it('RESERVED_CREDIT_TYPES / isReservedCreditType are the shared SSOT', () => {
822
+ expect(RESERVED_CREDIT_TYPES.has('fn_cpu_ms')).toBe(true);
823
+ expect(isReservedCreditType('fn_cpu_ms')).toBe(true);
824
+ expect(isReservedCreditType('tokens')).toBe(false);
825
+ });
826
+
827
+ it('rejects fn_cpu_ms in productMap.creditType', () => {
828
+ const v = validateFeatureConfig('payments', {
829
+ ledger: {
830
+ productMap: { prod_free_cpu: { creditType: 'fn_cpu_ms', amount: 1000, period: 'once' } },
831
+ tierMap: {},
832
+ },
833
+ });
834
+ expect(v.ok).toBe(false);
835
+ expect(v.errors.some((e) => e.includes('/ledger/productMap/prod_free_cpu/creditType') && e.includes('reserved'))).toBe(true);
836
+ });
837
+
838
+ it('rejects fn_cpu_ms in a tierMap grant', () => {
839
+ const v = validateFeatureConfig('payments', {
840
+ ledger: {
841
+ productMap: {},
842
+ tierMap: {
843
+ pro: {
844
+ entitlements: [], quotas: {},
845
+ grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' },
846
+ { creditType: 'fn_cpu_ms', amount: 999_999, period: 'monthly' }],
847
+ },
848
+ },
849
+ },
850
+ });
851
+ expect(v.ok).toBe(false);
852
+ expect(v.errors.some((e) => e.includes('/ledger/tierMap/pro/grants/1/creditType') && e.includes('reserved'))).toBe(true);
853
+ });
854
+
855
+ it('accepts an ORDINARY credit_type through the same maps (no false-positive)', () => {
856
+ const v = validateFeatureConfig('payments', {
857
+ ledger: {
858
+ productMap: { pack_100: { creditType: 'tokens', amount: 100, period: 'once' } },
859
+ tierMap: { pro: { entitlements: ['pro'], quotas: {}, grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' }] } },
860
+ },
861
+ });
862
+ expect(v.ok, v.errors.join()).toBe(true);
863
+ });
864
+
865
+ it('the reserved-type refinement is a VALIDATION rule, not a leaf — cap unaffected', () => {
866
+ // The refinement adds cross-field checks, never a schema property, so the
867
+ // payments leaf count is unchanged and comfortably under the cap.
868
+ expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
869
+ });
870
+ });
package/src/index.ts CHANGED
@@ -21,6 +21,23 @@ if (!FormatRegistry.Has('email')) {
21
21
  FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
22
22
  }
23
23
 
24
+ // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
25
+ // `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
26
+ // functions "recover-by-price" meter). It must NEVER be grantable or consumable
27
+ // by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
28
+ // tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
29
+ // through the webhook/subscription grant reducers) runs vxil-billed function CPU
30
+ // for free. Defined HERE (the typebox-only shared package both the control-plane
31
+ // config-write gate AND payments-v1 import) so the runtime choke point and the
32
+ // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
33
+ export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
34
+
35
+ /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
36
+ * of which is restricted to internal platform machinery). */
37
+ export function isReservedCreditType(creditType: string): boolean {
38
+ return RESERVED_CREDIT_TYPES.has(creditType);
39
+ }
40
+
24
41
  export const NotificationsConfigSchema = Type.Object({
25
42
  enabled: Type.Boolean({ default: true }),
26
43
  fromEmail: Type.String({ format: 'email' }),
@@ -30,14 +47,27 @@ export const NotificationsConfigSchema = Type.Object({
30
47
  // need no email account, so the mock path is zero-config. A cross-field
31
48
  // check in validateFeatureConfig requires it only when provider === 'resend'.
32
49
  resendApiKeyRef: Type.Optional(Type.String()),
50
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
51
+ // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
52
+ // When set, inbound Resend webhooks are verified with THIS tenant's secret
53
+ // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
54
+ // to the tenant so a signed event for tenant A can never validate at tenant
55
+ // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
56
+ webhookSecretRef: Type.Optional(Type.String()),
33
57
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
34
58
  provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
35
59
  default: 'resend',
36
60
  }),
37
61
  defaultLocale: Type.String({ default: 'en-US' }),
38
62
  // nested objects carry `default: {}` so Value.Default can materialize them
39
- // and then recurse into the leaf defaults
40
- retry: Type.Object(
63
+ // and then recurse into the leaf defaults.
64
+ // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
65
+ // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
66
+ // It KEEPS `default: {}`, which Value.Default still materializes — so every
67
+ // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
68
+ // (zero behavioral delta); only the TS type is now optional (workers read
69
+ // via retryOf()'s fallback).
70
+ retry: Type.Optional(Type.Object(
41
71
  {
42
72
  maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
43
73
  backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
@@ -45,7 +75,7 @@ export const NotificationsConfigSchema = Type.Object({
45
75
  }),
46
76
  },
47
77
  { default: {} },
48
- ),
78
+ )),
49
79
  suppression: Type.Object(
50
80
  { softBounceThreshold: Type.Integer({ default: 3 }) },
51
81
  { default: {} },
@@ -63,13 +93,37 @@ export const NotificationsConfigSchema = Type.Object({
63
93
  ),
64
94
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
65
95
  inboxEnabled: Type.Boolean({ default: false }),
96
+ /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
97
+ * disabled; per-campaign quiet_hours / freq_cap overrides live on the
98
+ * notifications.campaigns ROW (tenant data), not here. Folded into the
99
+ * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
100
+ * worker-local extension, so campaigns 403'd via the real config path). */
101
+ broadcast: Type.Optional(Type.Object({
102
+ enabled: Type.Boolean({ default: false }),
103
+ /** tenant-default per-recipient quiet window (defer-not-drop); a
104
+ * per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
105
+ * (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
106
+ * pattern-pinned to what the worker's parseHhMm actually parses — a
107
+ * looser string ('10pm') would validate but FAIL OPEN at runtime
108
+ * (quiet window silently ignored). */
109
+ defaultQuietHours: Type.Optional(Type.Object({
110
+ tz: Type.String({ minLength: 1, maxLength: 64 }),
111
+ start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
112
+ end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
113
+ })),
114
+ /** default rolling per-user-per-day campaign-send cap across campaigns */
115
+ freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
116
+ })),
66
117
  });
67
- // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef, provider,
68
- // defaultLocale, retry.maxAttempts, retry.backoff,
118
+ // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
119
+ // webhookSecretRef, provider, defaultLocale, retry (Optional bag = 1),
69
120
  // suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
70
- // templates.allowOverride, inboxEnabled → 14. Cap = 15.
121
+ // templates.allowOverride, inboxEnabled, broadcast (Optional bag = 1) → 15.
122
+ // Cap = 15 — AT the cap; the next flag must collapse something.
71
123
 
72
124
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
125
+ /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
126
+ export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
73
127
 
74
128
  export const JobsConfigSchema = Type.Object({
75
129
  enabled: Type.Boolean({ default: true }),
@@ -288,7 +342,7 @@ export const FilesConfigSchema = Type.Object({
288
342
  quotas: Type.Object(
289
343
  {
290
344
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
291
- // R2 is cheap but Neon-resident metadata + abuse aren't (pricing re-audit
345
+ // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
292
346
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
293
347
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
294
348
  // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
@@ -458,7 +512,7 @@ export const CmsConfigSchema = Type.Object({
458
512
  }),
459
513
  )),
460
514
  // Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
461
- // a realtime channel — config-only rewiring of postgres_changes-style subs.
515
+ // a realtime channel — config-only rewiring of database-change-event-style subs.
462
516
  // Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
463
517
  // webhooks-out / functions cms-hook). ONE Type.Record leaf.
464
518
  cdc: Type.Optional(Type.Record(
@@ -681,13 +735,13 @@ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
681
735
  // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
682
736
  export const VectorSearchConfigSchema = Type.Object({
683
737
  enabled: Type.Boolean({ default: true }),
684
- // 'auto' resolves to pgvector until Lakebase is GA'd for the tier (#147).
738
+ // 'auto' resolves to the default managed vector backend for the tier (#147).
685
739
  backend: Type.Union(
686
740
  [Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
687
741
  { default: 'auto' },
688
742
  ),
689
743
  // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
690
- // BYO key from public.tenant_secrets via apiKeyRef (envelope-encrypted).
744
+ // BYO key from tenant secrets via apiKeyRef (encrypted at rest).
691
745
  embed: Type.Object(
692
746
  {
693
747
  provider: Type.Union(
@@ -1005,7 +1059,7 @@ export const PaymentsConfigSchema = Type.Object({
1005
1059
  export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
1006
1060
 
1007
1061
  // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
1008
- // functions on Workers-for-Platforms. The FUNCTION owns its identity (bundle via
1062
+ // functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
1009
1063
  // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
1010
1064
  // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
1011
1065
  // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
@@ -1381,8 +1435,14 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1381
1435
  // 'mock' provider (the deterministic default) stays zero-config so the whole
1382
1436
  // ledger path is testable without real keys (features/payments.md §0/§6).
1383
1437
  if (feature === 'payments') {
1384
- const v = withDefaults as { provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown };
1385
- const needsBlock: Record<string, keyof typeof v> = {
1438
+ const v = withDefaults as {
1439
+ provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
1440
+ ledger?: {
1441
+ productMap?: Record<string, { creditType?: string }>;
1442
+ tierMap?: Record<string, { grants?: Array<{ creditType?: string }> }>;
1443
+ };
1444
+ };
1445
+ const needsBlock: Record<string, 'stripe' | 'paddle' | 'revenuecat'> = {
1386
1446
  stripe: 'stripe', paddle: 'paddle', revenuecat: 'revenuecat',
1387
1447
  };
1388
1448
  const key = v.provider ? needsBlock[v.provider] : undefined;
@@ -1392,6 +1452,28 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1392
1452
  errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
1393
1453
  };
1394
1454
  }
1455
+ // A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
1456
+ // the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
1457
+ // user, running vxil-billed functions for free (audit F2). Rejected at write
1458
+ // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1459
+ const ledgerErrs: string[] = [];
1460
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1461
+ if (rule.creditType && isReservedCreditType(rule.creditType)) {
1462
+ ledgerErrs.push(
1463
+ `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
1464
+ );
1465
+ }
1466
+ }
1467
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1468
+ (rule.grants ?? []).forEach((g, i) => {
1469
+ if (g.creditType && isReservedCreditType(g.creditType)) {
1470
+ ledgerErrs.push(
1471
+ `/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
1472
+ );
1473
+ }
1474
+ });
1475
+ }
1476
+ if (ledgerErrs.length) return { ok: false, errors: ledgerErrs.slice(0, 10) };
1395
1477
  }
1396
1478
  // Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
1397
1479
  // AST-validated against the closed sandbox allow-list HERE, at config-write
@@ -1570,3 +1652,150 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1570
1652
  }
1571
1653
  return { ok: true, errors: [], value: withDefaults };
1572
1654
  }
1655
+
1656
+ // ─────────────────────────────────────────────────────────────────────────────
1657
+ // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
1658
+ // FEATURE_KEYS is the literal feature list the planner catalog is projected
1659
+ // from; the planner-catalog CI gate asserts set-equality with
1660
+ // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
1661
+ // list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
1662
+ // turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
1663
+ // human-authored but gated-for-presence.
1664
+ // ─────────────────────────────────────────────────────────────────────────────
1665
+ export const FEATURE_KEYS = [
1666
+ 'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
1667
+ 'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
1668
+ 'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
1669
+ ] as const;
1670
+ export type FeatureKey = (typeof FEATURE_KEYS)[number];
1671
+
1672
+ /** The hand-authored "when to use" intent a machine schema cannot convey.
1673
+ * REQUIRED for every feature (Record key-closure): a feature added to
1674
+ * FEATURE_KEYS without a hint is a compile error naming the missing key. */
1675
+ export interface PlannerHint {
1676
+ /** One or two sentences: when an app needs this feature. */
1677
+ whenToUse: string;
1678
+ /** Soft priors — words/needs that suggest the feature (NOT a matcher). */
1679
+ signals: string[];
1680
+ /** When NOT to pick it (disambiguation vs neighbors). */
1681
+ notFor: string;
1682
+ /** Features that must be enabled alongside (runtime substrate deps). */
1683
+ dependsOn: FeatureKey[];
1684
+ }
1685
+
1686
+ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
1687
+ notifications: {
1688
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
1689
+ signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
1690
+ notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
1691
+ dependsOn: [],
1692
+ },
1693
+ jobs: {
1694
+ whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
1695
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
1696
+ notFor: 'Simple request-response logic that completes inline.',
1697
+ dependsOn: [],
1698
+ },
1699
+ auth: {
1700
+ whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
1701
+ signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
1702
+ notFor: 'Pure-public read-only content with no user state.',
1703
+ dependsOn: [],
1704
+ },
1705
+ 'rate-limits': {
1706
+ whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
1707
+ signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
1708
+ notFor: 'General correctness — the platform already meters requests globally.',
1709
+ dependsOn: [],
1710
+ },
1711
+ files: {
1712
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
1713
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
1714
+ notFor: 'Structured records (cms) or text content authored in-app.',
1715
+ dependsOn: [],
1716
+ },
1717
+ webhooks: {
1718
+ whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
1719
+ signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
1720
+ notFor: 'In-app eventing between vxil features (the audit bus covers that).',
1721
+ dependsOn: ['jobs'],
1722
+ },
1723
+ comments: {
1724
+ whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
1725
+ signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
1726
+ notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
1727
+ dependsOn: [],
1728
+ },
1729
+ cms: {
1730
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
1731
+ signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
1732
+ notFor: 'End-user identity (auth) or file bytes (files).',
1733
+ dependsOn: [],
1734
+ },
1735
+ mcp: {
1736
+ whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
1737
+ signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
1738
+ notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
1739
+ dependsOn: [],
1740
+ },
1741
+ realtime: {
1742
+ whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
1743
+ signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
1744
+ notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
1745
+ dependsOn: [],
1746
+ },
1747
+ presence: {
1748
+ whenToUse: 'Showing who is online/typing/active on a channel right now.',
1749
+ signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
1750
+ notFor: 'Message delivery itself (realtime carries the messages).',
1751
+ dependsOn: ['realtime'],
1752
+ },
1753
+ orgs: {
1754
+ whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
1755
+ signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
1756
+ notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
1757
+ dependsOn: ['auth'],
1758
+ },
1759
+ 'activity-feed': {
1760
+ whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
1761
+ signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
1762
+ notFor: 'Live transport (realtime) or email (notifications).',
1763
+ dependsOn: [],
1764
+ },
1765
+ 'vector-search': {
1766
+ whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
1767
+ signals: ['search', 'semantic', 'similar', 'find by meaning'],
1768
+ notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
1769
+ dependsOn: [],
1770
+ },
1771
+ ai: {
1772
+ whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
1773
+ signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
1774
+ notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
1775
+ dependsOn: [],
1776
+ },
1777
+ rag: {
1778
+ whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
1779
+ signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
1780
+ notFor: 'Free-form generation with no grounding (ai).',
1781
+ dependsOn: ['vector-search', 'ai', 'files'],
1782
+ },
1783
+ payments: {
1784
+ whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
1785
+ signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
1786
+ notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
1787
+ dependsOn: [],
1788
+ },
1789
+ functions: {
1790
+ whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
1791
+ signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
1792
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
1793
+ dependsOn: [],
1794
+ },
1795
+ copilot: {
1796
+ whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
1797
+ signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
1798
+ notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
1799
+ dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
1800
+ },
1801
+ };