@vxil/feature-configs 0.8.0 → 0.9.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,9 +6,12 @@ export * from './canonicalJson.js';
6
6
  export * from './publicAssets.js';
7
7
  export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
8
8
  /** A function binding's `retry.maxAttempts` ceiling, and the
9
- * binding kinds that may carry `retry` — the platform-delivered event lanes
10
- * (an http invoke returns its own status; a cron tick's retry would overlap
11
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
9
+ * binding kinds that may carry `retry` — every lane the platform delivers
10
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
11
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
12
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
13
+ * synchronous invoke always hands its caller the function's own status).
14
+ * Read by the schema, the deploy clamp and the CLI. */
12
15
  export declare const FN_RETRY_MAX_ATTEMPTS = 5;
13
16
  export declare const FN_RETRY_BINDING_KINDS: ReadonlySet<string>;
14
17
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
@@ -359,7 +362,7 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
359
362
  downloadUrlTtl: import("@sinclair/typebox").TInteger;
360
363
  quotas: import("@sinclair/typebox").TObject<{
361
364
  maxObjectBytes: import("@sinclair/typebox").TInteger;
362
- maxTotalBytes: import("@sinclair/typebox").TInteger;
365
+ maxTotalBytes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
363
366
  maxObjectCount: import("@sinclair/typebox").TInteger;
364
367
  }>;
365
368
  allowedContentTypes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
package/dist/index.js CHANGED
@@ -42,11 +42,14 @@ if (!FormatRegistry.Has('email')) {
42
42
  // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
43
43
  export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
44
44
  /** A function binding's `retry.maxAttempts` ceiling, and the
45
- * binding kinds that may carry `retry` — the platform-delivered event lanes
46
- * (an http invoke returns its own status; a cron tick's retry would overlap
47
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
45
+ * binding kinds that may carry `retry` — every lane the platform delivers
46
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
47
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
48
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
49
+ * synchronous invoke always hands its caller the function's own status).
50
+ * Read by the schema, the deploy clamp and the CLI. */
48
51
  export const FN_RETRY_MAX_ATTEMPTS = 5;
49
- export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
52
+ export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'authHook', 'cron', 'http']);
50
53
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
51
54
  * of which is restricted to internal platform machinery). */
52
55
  export function isReservedCreditType(creditType) {
@@ -341,7 +344,24 @@ export const JobsConfigSchema = Type.Object({
341
344
  * pathological target (e.g. an always-throwing function) can generate. */
342
345
  dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
343
346
  schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
344
- concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
347
+ concurrency: Type.Object(
348
+ /** Per-tenant cap on PLAIN runs (queue / cron / event deliveries,
349
+ * incl. platform-delivered function triggers) in flight at once; generation
350
+ * runs have their own `generation.maxConcurrent`. EFFECTIVE value =
351
+ * min(this, the tenant's fair share of the shared delivery consumer): the
352
+ * platform runs at most 75 queue-lane deliveries in flight for ALL tenants
353
+ * together, and one tenant may hold at most 25 of them (jobs-v1 core.ts
354
+ * TENANT_FAIR_SHARE). Values 26..100 still validate (existing configs keep
355
+ * working) but buy nothing past the share. Over-cap runs wait (deferred,
356
+ * never rejected). The schema `description` below is the tenant-facing
357
+ * copy of this (planner catalog, generated config schema) — keep both in
358
+ * step. */
359
+ {
360
+ maxConcurrent: Type.Integer({
361
+ default: 10, minimum: 1, maximum: 100,
362
+ description: 'Plain runs (queued jobs, cron fires, event deliveries, platform-delivered function triggers) delivered at once; generation runs have their own generation.maxConcurrent. Effective at most 25 (the per-project share of the shared delivery capacity): 26..100 validate but buy nothing more. Over-cap runs wait, never rejected.',
363
+ }),
364
+ }, { default: {} }),
345
365
  // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
346
366
  // declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
347
367
  // (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
@@ -680,11 +700,16 @@ export const FilesConfigSchema = Type.Object({
680
700
  quotas: Type.Object({
681
701
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
682
702
  // object storage is cheap but the database-resident metadata + abuse aren't
683
- // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
684
- // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
685
- // tenant tier threaded to files-v1 (plan tiers).
703
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
686
704
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
687
- maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
705
+ // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
706
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
707
+ // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
708
+ // files-v1 where the quota is enforced, so a plan change applies at once.
709
+ // A value is the project's own LOWER cap (above the plan → 422 at config
710
+ // write; a downgrade clamps it). It used to default to 10 GiB on every
711
+ // plan, so a Team project that never set it stayed at 10 GiB.
712
+ maxTotalBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 })),
688
713
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
689
714
  }, { default: {} }),
690
715
  allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
@@ -706,7 +731,7 @@ export const FilesConfigSchema = Type.Object({
706
731
  enabled: Type.Boolean({ default: false }),
707
732
  // per-bucket default; omitted = never expire by default
708
733
  defaultExpiresInSeconds: Type.Optional(Type.Integer({ minimum: 60 })),
709
- sweepCron: Type.String({ default: '0 * * * *' }), // jobs-v1 TTL sweep schedule
734
+ sweepCron: Type.String({ default: '*/15 * * * *' }), // jobs-v1 TTL sweep schedule (every 15 min since 2026-10-04; was hourly)
710
735
  })),
711
736
  extractText: Type.Optional(Type.Object({
712
737
  enabled: Type.Boolean({ default: false }),
@@ -1413,8 +1438,8 @@ export const FunctionsConfigSchema = Type.Object({
1413
1438
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1414
1439
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1415
1440
  // (2026-09-25) the per-binding opt-in to re-delivery on
1416
- // queue / webhook / cmsHook / authHook (the cross-field rule
1417
- // rejects it on http / cron). Absent = the ACK-200 default. The
1441
+ // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
1442
+ // cron and http (the async lane only). Absent = the ACK-200 default. The
1418
1443
  // receiver answers a failed attempt as an enveloped 503 (ladder)
1419
1444
  // and the last one as a terminal 409 (dead + job.dead_lettered);
1420
1445
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
@@ -2127,9 +2152,8 @@ export function validateFeatureConfig(feature, raw) {
2127
2152
  if (b.overlap !== undefined && b.kind !== 'cron') {
2128
2153
  errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2129
2154
  }
2130
- // retry is an opt-in for the platform-delivered event lanes only —
2131
- // an http invoke returns its real status to its caller, and a cron
2132
- // tick's retry would overlap the next tick.
2155
+ // retry is an opt-in for the platform-delivered lanes
2156
+ // (FN_RETRY_BINDING_KINDS — every binding kind since JT-9)
2133
2157
  if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2134
2158
  errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2135
2159
  }
@@ -2223,15 +2247,15 @@ export const FEATURE_KEYS = [
2223
2247
  ];
2224
2248
  export const FEATURE_HINTS = {
2225
2249
  notifications: {
2226
- whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
2250
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests. A send can be scheduled for a known time (send_at), so a one-off reminder needs no job or function.',
2227
2251
  signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
2228
2252
  notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
2229
2253
  dependsOn: [],
2230
2254
  },
2231
2255
  jobs: {
2232
- whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
2233
- signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
2234
- notFor: 'Simple request-response logic that completes inline.',
2256
+ whenToUse: 'Background work: scheduled/cron tasks, delayed runs, retries, queues, runs that wait for an event, per-user serialisation (concurrency keys), debounced bursts, start deadlines, fan-in (a batch completion event), per-user schedules in their own time zone. Also the bookkeeping for LONG work that runs on the tenant\'s own runtime (renders, transcodes, model calls): a generation run holds reserved credits, a deadline, a signed completion callback and live progress mirrored onto a cms row.',
2257
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch', 'render', 'transcode', 'progress', 'long-running'],
2258
+ notFor: 'Simple request-response logic that completes inline; it is a queue with controls, not a workflow engine, and it never runs the heavy compute itself.',
2235
2259
  dependsOn: [],
2236
2260
  },
2237
2261
  auth: {
@@ -2247,8 +2271,8 @@ export const FEATURE_HINTS = {
2247
2271
  dependsOn: [],
2248
2272
  },
2249
2273
  files: {
2250
- whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
2251
- signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
2274
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries. Media shown publicly (galleries, product photos, video, audio, fonts) is published to a public CDN URL, with named image variant presets such as thumbnails (publicAssets).',
2275
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media', 'gallery', 'thumbnail', 'video', 'CDN'],
2252
2276
  notFor: 'Structured records (cms) or text content authored in-app.',
2253
2277
  dependsOn: [],
2254
2278
  },
@@ -2265,7 +2289,7 @@ export const FEATURE_HINTS = {
2265
2289
  dependsOn: [],
2266
2290
  },
2267
2291
  cms: {
2268
- 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.',
2292
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, indexed filters, and optional keyless public reads. Most apps are, underneath, cms collections.',
2269
2293
  signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
2270
2294
  notFor: 'End-user identity (auth) or file bytes (files).',
2271
2295
  dependsOn: [],
@@ -2327,7 +2351,7 @@ export const FEATURE_HINTS = {
2327
2351
  functions: {
2328
2352
  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.',
2329
2353
  signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
2330
- notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
2354
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers, and heavy or long compute (renders, ffmpeg, ML) — that runs on the tenant\'s own runtime, tracked by a jobs generation run.',
2331
2355
  dependsOn: [],
2332
2356
  },
2333
2357
  copilot: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
package/src/index.ts CHANGED
@@ -46,11 +46,14 @@ if (!FormatRegistry.Has('email')) {
46
46
  export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
47
47
 
48
48
  /** A function binding's `retry.maxAttempts` ceiling, and the
49
- * binding kinds that may carry `retry` — the platform-delivered event lanes
50
- * (an http invoke returns its own status; a cron tick's retry would overlap
51
- * the next tick). Read by the schema, the deploy clamp and the CLI. */
49
+ * binding kinds that may carry `retry` — every lane the platform delivers
50
+ * through jobs. Since 2026-10-03 (JT-9) that includes `cron` (a failed tick is
51
+ * re-delivered on the ladder; pair it with `overlap: 'skip'` so a retrying
52
+ * tick never runs beside the next one) and `http` (the ASYNC lane only — a
53
+ * synchronous invoke always hands its caller the function's own status).
54
+ * Read by the schema, the deploy clamp and the CLI. */
52
55
  export const FN_RETRY_MAX_ATTEMPTS = 5;
53
- export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook']);
56
+ export const FN_RETRY_BINDING_KINDS: ReadonlySet<string> = new Set(['queue', 'webhook', 'cmsHook', 'authHook', 'cron', 'http']);
54
57
 
55
58
  /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
56
59
  * of which is restricted to internal platform machinery). */
@@ -399,7 +402,23 @@ export const JobsConfigSchema = Type.Object({
399
402
  { default: {} },
400
403
  ),
401
404
  concurrency: Type.Object(
402
- { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
405
+ /** Per-tenant cap on PLAIN runs (queue / cron / event deliveries,
406
+ * incl. platform-delivered function triggers) in flight at once; generation
407
+ * runs have their own `generation.maxConcurrent`. EFFECTIVE value =
408
+ * min(this, the tenant's fair share of the shared delivery consumer): the
409
+ * platform runs at most 75 queue-lane deliveries in flight for ALL tenants
410
+ * together, and one tenant may hold at most 25 of them (jobs-v1 core.ts
411
+ * TENANT_FAIR_SHARE). Values 26..100 still validate (existing configs keep
412
+ * working) but buy nothing past the share. Over-cap runs wait (deferred,
413
+ * never rejected). The schema `description` below is the tenant-facing
414
+ * copy of this (planner catalog, generated config schema) — keep both in
415
+ * step. */
416
+ {
417
+ maxConcurrent: Type.Integer({
418
+ default: 10, minimum: 1, maximum: 100,
419
+ description: 'Plain runs (queued jobs, cron fires, event deliveries, platform-delivered function triggers) delivered at once; generation runs have their own generation.maxConcurrent. Effective at most 25 (the per-project share of the shared delivery capacity): 26..100 validate but buy nothing more. Over-cap runs wait, never rejected.',
420
+ }),
421
+ },
403
422
  { default: {} },
404
423
  ),
405
424
  // Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
@@ -804,11 +823,16 @@ export const FilesConfigSchema = Type.Object({
804
823
  {
805
824
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
806
825
  // object storage is cheap but the database-resident metadata + abuse aren't
807
- // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
808
- // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
809
- // tenant tier threaded to files-v1 (plan tiers).
826
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
810
827
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
811
- maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
828
+ // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
829
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
830
+ // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
831
+ // files-v1 where the quota is enforced, so a plan change applies at once.
832
+ // A value is the project's own LOWER cap (above the plan → 422 at config
833
+ // write; a downgrade clamps it). It used to default to 10 GiB on every
834
+ // plan, so a Team project that never set it stayed at 10 GiB.
835
+ maxTotalBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 })),
812
836
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
813
837
  },
814
838
  { default: {} },
@@ -835,7 +859,7 @@ export const FilesConfigSchema = Type.Object({
835
859
  enabled: Type.Boolean({ default: false }),
836
860
  // per-bucket default; omitted = never expire by default
837
861
  defaultExpiresInSeconds: Type.Optional(Type.Integer({ minimum: 60 })),
838
- sweepCron: Type.String({ default: '0 * * * *' }), // jobs-v1 TTL sweep schedule
862
+ sweepCron: Type.String({ default: '*/15 * * * *' }), // jobs-v1 TTL sweep schedule (every 15 min since 2026-10-04; was hourly)
839
863
  })),
840
864
  extractText: Type.Optional(Type.Object({
841
865
  enabled: Type.Boolean({ default: false }),
@@ -1754,8 +1778,8 @@ export const FunctionsConfigSchema = Type.Object({
1754
1778
  collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1755
1779
  event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1756
1780
  // (2026-09-25) the per-binding opt-in to re-delivery on
1757
- // queue / webhook / cmsHook / authHook (the cross-field rule
1758
- // rejects it on http / cron). Absent = the ACK-200 default. The
1781
+ // queue / webhook / cmsHook / authHook, and since 2026-10-03 on
1782
+ // cron and http (the async lane only). Absent = the ACK-200 default. The
1759
1783
  // receiver answers a failed attempt as an enveloped 503 (ladder)
1760
1784
  // and the last one as a terminal 409 (dead + job.dead_lettered);
1761
1785
  // effective attempts = min(maxAttempts, jobs.retry.defaultMaxAttempts).
@@ -2567,9 +2591,8 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2567
2591
  if (b.overlap !== undefined && b.kind !== 'cron') {
2568
2592
  errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
2569
2593
  }
2570
- // retry is an opt-in for the platform-delivered event lanes only —
2571
- // an http invoke returns its real status to its caller, and a cron
2572
- // tick's retry would overlap the next tick.
2594
+ // retry is an opt-in for the platform-delivered lanes
2595
+ // (FN_RETRY_BINDING_KINDS — every binding kind since JT-9)
2573
2596
  if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
2574
2597
  errs.push(`/functions/${name}/bindings/${i}: 'retry' applies to ${[...FN_RETRY_BINDING_KINDS].join(' | ')} bindings only (not '${b.kind}')`);
2575
2598
  }
@@ -2683,15 +2706,15 @@ export interface PlannerHint {
2683
2706
 
2684
2707
  export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2685
2708
  notifications: {
2686
- whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
2709
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests. A send can be scheduled for a known time (send_at), so a one-off reminder needs no job or function.',
2687
2710
  signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
2688
2711
  notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
2689
2712
  dependsOn: [],
2690
2713
  },
2691
2714
  jobs: {
2692
- whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
2693
- signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
2694
- notFor: 'Simple request-response logic that completes inline.',
2715
+ whenToUse: 'Background work: scheduled/cron tasks, delayed runs, retries, queues, runs that wait for an event, per-user serialisation (concurrency keys), debounced bursts, start deadlines, fan-in (a batch completion event), per-user schedules in their own time zone. Also the bookkeeping for LONG work that runs on the tenant\'s own runtime (renders, transcodes, model calls): a generation run holds reserved credits, a deadline, a signed completion callback and live progress mirrored onto a cms row.',
2716
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch', 'render', 'transcode', 'progress', 'long-running'],
2717
+ notFor: 'Simple request-response logic that completes inline; it is a queue with controls, not a workflow engine, and it never runs the heavy compute itself.',
2695
2718
  dependsOn: [],
2696
2719
  },
2697
2720
  auth: {
@@ -2707,8 +2730,8 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2707
2730
  dependsOn: [],
2708
2731
  },
2709
2732
  files: {
2710
- whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
2711
- signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
2733
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries. Media shown publicly (galleries, product photos, video, audio, fonts) is published to a public CDN URL, with named image variant presets such as thumbnails (publicAssets).',
2734
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media', 'gallery', 'thumbnail', 'video', 'CDN'],
2712
2735
  notFor: 'Structured records (cms) or text content authored in-app.',
2713
2736
  dependsOn: [],
2714
2737
  },
@@ -2725,7 +2748,7 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2725
2748
  dependsOn: [],
2726
2749
  },
2727
2750
  cms: {
2728
- 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.',
2751
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, indexed filters, and optional keyless public reads. Most apps are, underneath, cms collections.',
2729
2752
  signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
2730
2753
  notFor: 'End-user identity (auth) or file bytes (files).',
2731
2754
  dependsOn: [],
@@ -2787,7 +2810,7 @@ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2787
2810
  functions: {
2788
2811
  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.',
2789
2812
  signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
2790
- notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
2813
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers, and heavy or long compute (renders, ffmpeg, ML) — that runs on the tenant\'s own runtime, tracked by a jobs generation run.',
2791
2814
  dependsOn: [],
2792
2815
  },
2793
2816
  copilot: {